Compare commits

...
188 Commits
Author SHA1 Message Date
Eugene C.andHugo P.Brito 0b9791ffdc feat(ecr): add ecr_repository_image_no_secrets check (#12123)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-18 11:10:07 +01:00
Prowler Botandprowler-bot f6defefb58 chore(changelog): v5.39.1 forward-sync to master (#12483)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-18 11:32:20 +02:00
ye11oc4tandHugo P.Brito f3224d0988 fix(ses): evaluate all identity authorization policies (#12464)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-17 14:19:45 +01:00
Pepe Fagoaga 450e6ba553 chore(api): drop temporary SDK pin overrides after cryptography cap bump (#12473) 2026-08-17 13:42:09 +02:00
2cd93fe119 fix(sdk): skip undescribed ECS task definitions (#12217)
Co-authored-by: Nguyễn Công Thuận Huy <nguyencongthuanhuy@gmail.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-17 12:42:06 +01:00
Pablo Fernandez Guerra (PFE) f807b22ea6 ci: lint .github markdown and fix the violations it exposed (#12290) 2026-08-17 13:00:32 +02:00
Pepe Fagoaga b6e9967da6 fix(deps): make published wheels installable and add package checks (#12467) 2026-08-17 12:34:11 +02:00
Hugo Pereira Brito 16e62f7514 ci(labeler): cover existing provider labels (#12476) 2026-08-17 11:33:22 +01:00
Adrián Peña 13ce9436b3 chore: update Trivy to 0.74.0 (#12466) 2026-08-17 10:25:28 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> d3ced63397 fix(docs): typos and grammar (#12468)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-08-17 09:21:56 +02:00
Adrián Peña 758b696ca5 feat(ui): highlight imported providers (#12447) 2026-08-17 08:53:14 +02:00
Pepe Fagoaga 0d3ce45374 fix(pypi): bump to pypa/gh-action-pypi-publish v1.14.2 (#12456) 2026-08-14 14:57:07 +02:00
Alejandro Bailo f35666ff0a fix(ui): settle scan auto-refresh safely (#12455) 2026-08-14 11:48:08 +02:00
Pedro Martín 0758c3585d feat(rolesanywhere): flag profiles with unscoped sessions (#12416) 2026-08-13 17:06:24 +02:00
dd882c70e7 chore(release): Bump versions to v5.40.0 (#12443)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-08-13 13:49:26 +02:00
Hugo Pereira BritoandPablo F.G d05c9fbb31 feat(ui): add trial usage sidebar banner (#12420)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
2026-08-13 11:36:59 +01:00
Josema Camacho 7bde42ffb9 docs: update attack paths documentation for grouped graphs (#12440) 2026-08-13 12:25:14 +02:00
Pepe Fagoaga 0d3df0fd0b chore(changelog): v5.39.0 release highlights (#12432) 2026-08-13 12:08:15 +02:00
Pedro Martín ab996417e6 fix(ci): bump Trivy to v0.73.0 to fix CVE-2026-46600 (#12444) 2026-08-13 12:04:51 +02:00
Prowler Botandprowler-bot 5f109bc00e chore(changelog): v5.39.0 (#12433)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-13 09:01:51 +02:00
Pepe Fagoaga 472e04f4cc docs(triage): manual PASS verification for MANUAL findings (#12431) 2026-08-12 15:46:54 +02:00
Rubén De la Torre Vico b848aace33 docs(mcp): document the Cloud organization tools and Jira dispatch options (#12427) 2026-08-12 15:05:24 +02:00
Pedro Martín 94c20eb9fe feat(ui): add CMMC compliance framework (#12414) 2026-08-12 14:52:09 +02:00
lydiavilchezpedroootcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>pedrooot
02df22ca19 fix(html): escape provider identity fields in report header (#12424)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pedrooot <56402503+pedrooot@users.noreply.github.com>
2026-08-12 13:58:31 +02:00
Hugo Pereira BritoandJosema Camacho de64df11b9 fix(api): normalize social account names (#12413)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-08-12 12:41:44 +01:00
Pedro Martín 37ebd9b6fd fix(cmmc): remove stale config_requirements (#12425) 2026-08-12 12:29:43 +02:00
Pedro Martín a28487cbff fix(ci): suppress .NET runtime CVE temporarily (#12426) 2026-08-12 12:16:14 +02:00
Rubén De la Torre Vico 68471d2a0e feat(ui): add Manage Lighthouse AI role permission (#12412) 2026-08-12 10:19:20 +02:00
Pablo Fernandez Guerra (PFE) d41b2eaa0f docs: add the Azure Management Groups onboarding tutorial (#12389) 2026-08-12 09:15:32 +02:00
Pablo Fernandez Guerra (PFE) b480907484 feat(ui): onboard Azure subscriptions from a Management Group (#12386) 2026-08-12 09:07:43 +02:00
Pablo Fernandez Guerra (PFE) 6d7bc8a86e test(ui): consolidate the providers page integration suites (#12383) 2026-08-11 18:50:10 +02:00
Hugo Pereira Britoandalejandrobailo 8bfca81e4b feat(ui): add manual pass triage workflow (#12253)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-11 15:09:08 +01:00
Pablo Fernandez Guerra (PFE) 931612443a test(ui): drop organization unit tests restated by integration (#12382) 2026-08-11 15:41:34 +02:00
Daniel Barranqueroandalejandrobailo 3074f02a63 feat(ui): grouped Attack Paths graph with expandable resource classes and outcome node (#12381)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-11 14:05:12 +02:00
Pablo Fernandez Guerra (PFE) 5cfc22040a fix(ui): open scan findings using the scan's UTC day (#12411) 2026-08-11 12:54:47 +02:00
Hugo Pereira Brito 48ba1692e1 docs: update provider check counts (#12418) 2026-08-11 10:37:48 +01:00
Alejandro Bailo a8b12813f9 feat(ui): add Lighthouse AI Skills on findings (#12355) 2026-08-11 11:05:04 +02:00
Pedro Martín 85c36bb812 feat(compliance): add CMMC 2.0 compliance framework (#12401) 2026-08-10 12:48:26 -07:00
ce037318cd feat(m365): add CIS M365 v7.0.0 entra authentication method, PIM and access review checks (#12155)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-10 15:37:48 +01:00
Pedro MartínandHugo P.Brito 356036fe1f feat(m365): add CIS M365 v7.0.0 entra conditional access and session checks (#12154)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-10 11:17:19 +01:00
Andoni AlonsoandLydia Vilchez 286685a4f3 feat(github): scale organization_repository_creation_limited severity by repository visibility (#12164)
Co-authored-by: Lydia Vilchez <lydiavilchezlopez@gmail.com>
2026-08-10 11:51:38 +02:00
Hugo Pereira Brito 9daca2e4df fix(ci): suppress Trivy go-git vulnerability temporarily (#12405) 2026-08-10 10:41:19 +01:00
Hugo Pereira Brito 3ca3a977a9 test(m365): avoid Lob secret pattern in test name (#12395) 2026-08-10 08:35:28 +01:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 561a1390be docs: fix typos and grammar (#12404)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-08-10 09:22:41 +02:00
Pedro MartínandHugo P.Brito f2a00f19aa feat(m365): add CIS M365 v7.0.0 entra password protection and default user permission checks (#12153)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-07 13:42:52 +01:00
Adrián Peña 34b4e6f016 fix(api): enforce POST on SAML ACS endpoint (#12393) 2026-08-07 14:24:45 +02:00
praneetrajvandDaniel Barranquero 94594d6766 feat(batch): add batch_job_definition_no_secrets check (#12117)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-07 11:57:58 +02:00
SaiGaneshcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Daniel BarranqueroClaude Opus 5
6e71dee85d feat(awslambda): add awslambda_layer_no_secrets_in_content check (#12233)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:33:54 +02:00
Alejandro Bailo 90712c9ad7 test(ui): preload lazy survey chunk to fix flaky feedback tests (#12387) 2026-08-07 11:04:38 +02:00
Josema Camacho 3672b17a00 feat(api): identify active membership in current user response (#12388) 2026-08-07 10:40:21 +02:00
Adrián Peña fd555e2989 docs: v5.38.0 changelog highlights (#12363) 2026-08-06 18:38:57 +02:00
Adrián Peña cf558c5f0a fix(api): make tenant deletion cleanup atomic (#12379) 2026-08-06 17:36:52 +02:00
lydiavilchez e2cae35d38 feat(ui): pre-fill Cloudflare and GitHub token creation URLs (#12349) 2026-08-06 16:36:10 +02:00
Prowler Botandprowler-bot d8c8027215 chore(release): Bump versions to v5.39.0 (#12376)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-06 15:59:35 +02:00
Prowler Botandprowler-bot 226504982b chore(changelog): v5.38.0 (#12373)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-06 13:25:12 +02:00
César Arroba a700865340 ci: always report from actionlint, skipping the work when nothing changed (#12371) 2026-08-06 12:53:43 +02:00
César Arroba 07faddd64e ci: fix the remaining shellcheck findings and enable the check (#12367) 2026-08-06 12:35:55 +02:00
César Arroba 1b9a44b164 fix(ci): keep the cloud sync dispatch payload within the 10-property limit (#12370) 2026-08-06 12:31:37 +02:00
Alan Buscagliaandalejandrobailo ff0ee666e3 fix(ui): prevent feedback widget overlaps (#12282)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-06 12:28:32 +02:00
César Arroba b684ad06f3 ci: forward stacked PR metadata in the cloud sync dispatch (#12368) 2026-08-06 12:24:48 +02:00
Alan Buscagliaandalejandrobailo cd4693168a fix(ui): align overview metric cards (#12323)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-06 12:21:14 +02:00
Alejandro Bailo f727f1bb50 fix(ui): handle pending compliance overview responses (#12358) 2026-08-06 12:00:30 +02:00
César Arroba 76d7a2882c ci: quote the unquoted shell expansions in workflows (#12365) 2026-08-06 11:59:40 +02:00
Josema Camacho c7adc14729 fix(ui): correct GCP organization credential labels (#12362) 2026-08-06 11:28:22 +02:00
César Arroba 31d8faccfa ci: check GitHub Actions schemas with actionlint (#12361) 2026-08-06 11:01:53 +02:00
Hugo Pereira Brito d0da56f352 fix(alibabacloud): retry STS connection failures (#12353) 2026-08-06 09:44:00 +01:00
Adrián Peña 5e41b2054d docs: document multiple SAML domains (#12351) 2026-08-06 10:07:41 +02:00
Josema Camacho 058db7bcc9 fix(ui): launch organization scans through the bulk endpoint (#12350) 2026-08-06 09:48:53 +02:00
César Arroba d1a37039fd fix(deps): upgrade cryptography to 50.0.0 (#12356) 2026-08-05 19:16:11 +02:00
Rubén De la Torre VicoandClaude Opus 5 97c342ad80 test(mcp): cover the integrations tools and models (#12343)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 16:47:11 +02:00
César Arroba f3c602a5ac feat(container): ship an SBOM and provenance with the published images (#12352) 2026-08-05 16:19:42 +02:00
Josema Camacho d7816f1179 feat(ui): show sign-in methods in the Cloud users table (#12268) 2026-08-05 14:41:59 +02:00
Daniel Barranquero dd61c417b7 feat(attack-paths): add outcome to query metadata (#12344) 2026-08-05 14:13:24 +02:00
Pablo Fernandez Guerra (PFE) 2fdd46336c fix(ui): keep page-size selector when table fits one page (#12299) 2026-08-05 14:13:11 +02:00
Pablo Fernandez Guerra (PFE) ae8c86ecb5 fix(ui): always show Scans page so imported scans are visible (#12025) 2026-08-05 14:12:17 +02:00
Pedro MartínandDaniel Barranquero aaa29d3528 feat(m365): add CIS M365 v7.0.0 entra device registration checks (#12152)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-05 13:51:29 +02:00
Pepe Fagoaga 06799dcaa8 docs(compliance): watchlist review (#12348) 2026-08-05 13:44:03 +02:00
Pepe Fagoaga b53c5a4e70 docs(compliance): watchlist (#12347) 2026-08-05 13:29:45 +02:00
César Arroba af757a4d69 ci(container): pin Trivy to v0.72.0 across the estate (#12346) 2026-08-05 13:12:24 +02:00
Pedro MartínandDaniel Barranquero f87522e423 feat(m365): add CIS M365 v7.0.0 teams external access check (#12151)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-05 12:24:44 +02:00
Adrián Peña d3524d50fb feat(ui): support multiple SAML domains in Cloud (#12332) 2026-08-05 11:49:59 +02:00
Pedro Martín 5b1bd146be docs(m365): highlight key terms in check Risk descriptions (#12156) 2026-08-05 10:49:53 +01:00
Pablo Fernandez Guerra (PFE) 34431b5b88 test(ui): stabilize browser-mode test harnesses (#12301) 2026-08-05 11:37:58 +02:00
Pedro Martínandalejandrobailo 5285d25cfd feat(ui): add compliance watchlist (#12300)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-05 11:18:45 +02:00
Pedro MartínandAlan Buscaglia a6d5dbacd9 fix(api): log comp report output dir failures with exc_info (#12142)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
2026-08-05 11:12:35 +02:00
César Arroba 3b577907e4 ci(container): gate Grype only on fixable findings, and comment results on the PR (#12341) 2026-08-05 10:23:29 +02:00
Pedro Martín 8bf788ea95 fix(aws): delegated administrator lookup and reporting (#12319) 2026-08-05 09:48:00 +02:00
César Arroba 87bc1eceae ci(container): scan images with Grype alongside Trivy, blocking on critical and high (#12340) 2026-08-04 18:49:53 +02:00
César Arroba 635e451d9b fix(container): verify the checksum of downloaded third-party binaries (#12334) 2026-08-04 18:07:46 +02:00
Pedro MartínandAlan Buscaglia bd6aec8c20 fix(sdk): only report JDBC strings with embedded credentials (#12288)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
2026-08-04 17:22:31 +02:00
Pedro Martín 3c14df7e5b fix(oci): handle null fields in identity service (#12327) 2026-08-04 17:11:26 +02:00
César Arroba 531f61df2f chore(docs): add the Prowler logo to the Docker Hub overview (#12336) 2026-08-04 17:10:31 +02:00
César Arroba 765a1596f9 chore(docs): sync Docker Hub repository overviews from a single source (#12333) 2026-08-04 16:53:59 +02:00
Rubén De la Torre Vico 138d643119 test(mcp): add test foundation for the MCP server (#12291) 2026-08-04 16:19:10 +02:00
Alejandro Bailo c74eac1369 refactor(ui): type overview action results as ApiResult (#12287) 2026-08-04 13:41:44 +02:00
Alejandro Bailo f9dbb0eee9 fix(ui): harden Lighthouse context compiler against invalid items and budget pressure (#12286) 2026-08-04 13:41:20 +02:00
Alejandro Bailo ab13d111c2 feat(ui): capture Lighthouse context on tenant admin pages (#12285) 2026-08-04 13:40:34 +02:00
Alejandro Bailo f0d2972969 fix(ui): guard Overview Lighthouse context against API error responses (#12284) 2026-08-04 13:37:47 +02:00
Pedro MartínandDaniel Barranquero 90905dcc9f feat(m365): add CIS M365 v7.0.0 exchange checks (#12149)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-04 13:37:28 +02:00
César Arroba 5cf49805a2 fix(ci): make the YAML Trivy suppressions actually apply (#12326) 2026-08-04 13:05:46 +02:00
c610d9ac31 chore(changelog): v5.37.1 forward-sync to master (#12322)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
Co-authored-by: César Arroba <cesar@prowler.com>
2026-08-04 12:12:15 +02:00
fb9d989be8 fix(ui): deep-link wizard docs link to provider authentication guide (#12218)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-04 11:57:56 +02:00
César Arroba 64c0cf900f fix(compose): bump DozerDB to 5.26.27.0 (PROWLER-2308) (#12320) 2026-08-04 11:41:23 +02:00
Pedro MartínandDaniel Barranquero 0f39665ece feat(m365): add CIS M365 v7.0.0 defender preset policy checks (#12148)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-04 11:31:50 +02:00
Alan Buscaglia 162c6560d9 fix(ui): stabilize overview responsive layout (#12317) 2026-08-04 11:18:10 +02:00
César Arroba 681be7537d chore(security): migrate suppressions to .trivyignore.yaml (PROWLER-2327) (#12314) 2026-08-04 11:13:16 +02:00
César Arroba 94254555a4 fix(ui): drop apk upgrade and move the base digest forward (PROWLER-2323) (#12313) 2026-08-04 11:07:34 +02:00
Bruno FerreiraCursorcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Utwo
2646068e7e fix(app): correct the worker KEDA PostgreSQL scaler (#12055)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Utwo <mihai.legat@gmail.com>
2026-08-04 10:47:02 +02:00
Daniel Barranquero caf27de6ee fix(m365): bump microsoft-kiota packages to 1.9.10 so guest-user CA checks parse guestOrExternalUserTypes (#12280) 2026-08-04 10:37:04 +02:00
César Arroba aab8154139 ci(workflows): align container build-push workflows across api, ui, mcp and sdk (#12310) 2026-08-04 10:25:31 +02:00
StylusFrostcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Daniel Barranquero
1de779c978 fix(sdk): resolve entry-point checks on built-in providers (#12294)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-04 10:24:55 +02:00
César Arroba f4d6cd8609 fix(deps): restore the SDK dependency to master (PROWLER-2328) (#12309) 2026-08-04 09:59:21 +02:00
Pedro MartínandDaniel Barranquero b3d174d0c1 feat(m365): add CIS M365 v7.0.0 admincenter shared bookings check (#12147)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-04 09:46:47 +02:00
César Arroba 8ebb4a1ee7 fix(container): clear the Private Cloud image vulnerabilities (PROWLER-2291) (#12258) 2026-08-04 09:41:47 +02:00
Maringanti Vasist Acharya abf660ce06 fix(sdk): renumber Huawei Cloud off E2E Networks' range, and correct the M365 header comment (#12296) 2026-08-04 09:36:46 +02:00
Daniel Barranquero a19fd70001 feat(aws): add pathfinding.cloud privilege-escalation coverage (#12237) 2026-08-04 08:59:52 +02:00
Pepe Fagoaga 9b6a239abe fix(docs): v5.37.0 changelog date (#12304) 2026-08-03 18:31:30 +02:00
8c0fbf5073 docs: v5.37.0 changelog highlights (#12264)
Co-authored-by: Rubén De la Torre Vico <ruben@prowler.com>
Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-08-03 18:24:44 +02:00
Prowler Botandprowler-bot ce77eb7f41 chore(release): Bump versions to v5.38.0 (#12302)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-03 18:20:09 +02:00
Copilotcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Pepe Fagoaga
e15f6970ef fix(ci): use PROWLER_BOT_ACCESS_TOKEN for release freeze (#12295)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-08-03 14:45:23 +02:00
b9aa863e52 chore(changelog): v5.37.0 (#12293)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-08-03 14:35:29 +02:00
Alan Buscaglia 39cbcbfe2b chore(ui): reclassify deployment mode changelog (#12292) 2026-08-03 14:21:07 +02:00
lydiavilchez f19106281b feat(aws): add Nitro Enclaves security checks for EC2 and KMS (#12283) 2026-08-03 13:00:26 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 137b388df9 docs: fix typos and grammar (#12278)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-08-03 09:35:37 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> e0cb18bdbd docs: brand tone and writing style fixes (#12277)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-08-03 09:16:47 +02:00
Rubén De la Torre Vico b08d2eb472 fix(api): prevent 500 on Jira integrations with sparse fieldsets (#12261) 2026-07-31 15:03:04 +02:00
Pablo Fernandez Guerra (PFE)andPablo F.G 74a760517e docs(organizations): document GCP organization onboarding (#12262)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
2026-07-31 13:31:05 +02:00
Pablo Fernandez Guerra (PFE)andPablo F.G 2db3bebd15 feat(ui): GCP org onboarding — canonical contract + shared lifecycle (#12255)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
2026-07-31 13:30:46 +02:00
Pedro Martín b56df840fc chore(ui): migrate 5243 changes (#12269) 2026-07-31 13:10:17 +02:00
César Arroba bd9fa88d2a fix(ci): harden osv-scan.sh against a missing osv-scanner.toml (#12267) 2026-07-31 13:09:58 +02:00
Rubén De la Torre Vico 9adc248c05 docs(mcp): document the Cloud-only prowler_cloud_* tool namespace (#12266) 2026-07-31 12:16:04 +02:00
Rubén De la Torre Vico ea0c7eb271 fix(mcp): prevent prowler_list_integrations 500 on tenants with a Jira integration (#12259) 2026-07-31 11:02:45 +02:00
88c666a0d2 feat(attack-paths): Add 4 IAM privilege escalation detection queries (#11460)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-07-31 10:29:28 +02:00
César Arroba 7275b46707 chore(ci): repin trivy-action to a resolvable tag (#12257) 2026-07-31 09:51:18 +02:00
Rubén De la Torre Vicoandalejandrobailo 60bc06271a feat(ui): improve Lighthouse per-page suggested prompts (#12219)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-07-31 09:43:32 +02:00
stepsecurity-app[bot]andstepsecurity-app[bot] <188008098+stepsecurity-app[bot]@users.noreply.github.com> 8abd72e857 feat(security): security best practices from StepSecurity (#12254)
Signed-off-by: StepSecurity Bot <bot@stepsecurity.io>
Co-authored-by: stepsecurity-app[bot] <188008098+stepsecurity-app[bot]@users.noreply.github.com>
2026-07-31 09:27:01 +02:00
StylusFrostcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>CodeRabbit
9185b043da fix(sdk): exclude sdk_only providers from scan config schema (#12094)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-07-31 08:49:31 +02:00
Adrián Peña 3dd6b29477 fix(api): make social signup transactional (#12245) 2026-07-30 16:28:45 +02:00
Hugo Pereira Brito 6db407ed3c fix(html): escape provider data in reports (#12221) 2026-07-30 14:29:25 +01:00
0b98a34687 feat(aws): add glue_catalog_connection_no_secrets check (#11963)
Signed-off-by: Alex Chen <l46983284@gmail.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Rishi943 <84287593+Rishi943@users.noreply.github.com>
Co-authored-by: Utkarsh <udaydeepak1928@gmail.com>
2026-07-30 15:12:37 +02:00
Siddhant JadhavandDaniel Barranquero fc0204a40d feat(codecommit): add codecommit service and codecommit_repository_no_secrets check (#11846)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-07-30 15:05:27 +02:00
Alan Buscaglia 7a62926a09 fix(ui): stabilize cloud e2e prerequisites (#12024) 2026-07-30 14:54:26 +02:00
7a6a35afec feat(sagemaker): add sagemaker_endpoint_config_kms_encryption_enabled check (#12118)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Alex Chen <l46983284@gmail.com>
2026-07-30 14:29:45 +02:00
b7281a5221 fix(sdk): align secret scan source line indexing (#12141)
Co-authored-by: jbchief-dev <285331266+jbchief-dev@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-07-30 12:44:20 +02:00
Daniel Barranquero 5c4b0ba1fe fix(api): scope Attack Paths predefined queries with provider label (#12167) 2026-07-30 11:55:24 +02:00
César ArrobaandRubén De la Torre Vico f3b8ac1dbb fix(mcp): run streamable HTTP stateless to stop session memory leak (#12235)
Co-authored-by: Rubén De la Torre Vico <ruben@prowler.com>
2026-07-30 11:40:55 +02:00
Adrián Peña 8dac2a7ccf fix(api): assign fallback role to SAML users (#12223) 2026-07-30 11:17:25 +02:00
Rubén De la Torre Vico 6192b8ac32 feat(mcp): add integrations tools (#12138) 2026-07-30 11:11:09 +02:00
Adrián Peña e49babb9e7 fix(ui): stabilize SAML ACS URL field (#12236) 2026-07-30 11:05:24 +02:00
Josema Camacho f8be9afa7c fix(api): safely decode stored Celery task arguments (#12165) 2026-07-30 10:30:19 +02:00
Adrián Peña ecf7ec8e85 fix(api): respect provider group scope in provider actions (#12216) 2026-07-30 10:06:57 +02:00
stepsecurity-app[bot]andstepsecurity-app[bot] <188008098+stepsecurity-app[bot]@users.noreply.github.com> a57a507cee feat(security): security best practices from StepSecurity (#12232)
Signed-off-by: StepSecurity Bot <bot@stepsecurity.io>
Co-authored-by: stepsecurity-app[bot] <188008098+stepsecurity-app[bot]@users.noreply.github.com>
2026-07-30 09:24:44 +02:00
Pedro Martín 9b565586da docs(compliance): improve cross-provider compliance guide (#12162) 2026-07-30 07:59:55 +02:00
Alejandro Bailo 17f726f816 feat(ui): enrich Lighthouse context on Overview and remaining pages (#12220) 2026-07-29 18:12:40 +02:00
Alan Buscaglia 3fd748994a docs(msp): align guides with current portal behavior (#12105) 2026-07-29 17:21:16 +02:00
Pedro Martín 976220dbf5 fix(api): reject API keys whose owning user was deleted (#12210) 2026-07-29 17:18:09 +02:00
Hugo Pereira Brito a77e56b5a0 fix(ocsf): avoid missing MITRE catalog errors (#12222) 2026-07-29 15:27:37 +01:00
Hugo Pereira Brito 34e4d25576 fix(ocsf): use provider MITRE catalog for attacks (#12157) 2026-07-29 13:05:47 +01:00
Daniel Barranquero d4a33c0d1c feat(ui): link every Attack Paths query to its Prowler Hub page (#12145) 2026-07-29 13:31:42 +02:00
Alejandro Bailo 4c3017e2ed fix(ui): wrap long Lighthouse chat messages (#12215) 2026-07-29 11:55:52 +02:00
Adrián Peña 03f2ab46c9 fix(api): refresh Security Hub connection status (#12212) 2026-07-29 11:17:25 +02:00
Alan BuscagliaandPablo F.G 7f1cdb82ae feat(ui): add PostHog-backed in-app feedback survey (#12116)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
2026-07-29 11:12:38 +02:00
Hugo Pereira Brito 1bb3fc9bda ci: add release freeze gate (#11621) 2026-07-29 10:08:15 +01:00
Hugo Pereira Brito 463e8309f7 fix(sdk): render inline code with valid ADF marks (#12158) 2026-07-29 09:47:14 +01:00
Alejandro Bailo 59a7d30a3e fix(ui): restore finding delta colors and shorten update labels (#12160) 2026-07-29 10:31:22 +02:00
Alejandro Bailo 2ae1062e76 feat(ui): add contextual Lighthouse page UX (#12069) 2026-07-29 10:06:02 +02:00
Daniel Barranquero 1218b0920f docs: restructure Attack Paths docs and add query developer guide (#12144) 2026-07-28 18:28:17 +02:00
Pepe Fagoaga 06ea61ffbf docs(saml): update missing images (#12166) 2026-07-28 18:24:08 +02:00
Rubén De la Torre Vico 8eeb37aea4 feat(mcp): add users and roles tools to the Prowler MCP Server (#12088) 2026-07-28 17:16:56 +02:00
Pepe Fagoaga 7ba96ab2e2 docs: update images (#12159) 2026-07-28 16:47:38 +02:00
Stefano BaldoandHugo P.Brito 4987d8a08e fix(gcp): make gen2 Cloud Functions IAM policy query thread-safe (#12107)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-07-28 15:40:19 +01:00
Pepe Fagoaga 2bd89ceb97 docs: update images (#12146) 2026-07-28 14:56:22 +02:00
Pedro Martínandalejandrobailo 0d4a21b5a4 feat(ui): add cross-account compliance view (#12086)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-07-28 14:03:58 +02:00
Alejandro Bailo 1460f7b188 feat(ui): add Lighthouse contextual transport core (#12068) 2026-07-28 12:12:19 +02:00
Daniel Barranquero e9bbde2f01 fix(api): remove cartesian product in Attack Paths IAM privesc queries (#12136) 2026-07-28 11:12:10 +02:00
Oleksandr_SaninandHugo P.Brito 76239b7076 feat(ocsf): populate analytic and attacks fields in OCSF detection finding output (#11492)
Signed-off-by: Oleksandr Sanin <alexaaander.sanin@gmail.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-07-28 08:19:23 +01:00
César Arroba 0c0f150cd4 chore(ui): drop the unused deployment-mode variable (#12131) 2026-07-27 18:44:00 +02:00
rayair250-droidandHugo P.Brito 43de7709cb fix(gcp): detect SSH/RDP exposure when the port is not first in a multi-port firewall rule (#12115)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-07-27 16:44:35 +01:00
Pepe Fagoaga bb18dcb882 docs(readme): agentic cloud defender and link to Prowler Cloud (#12135) 2026-07-27 17:21:17 +02:00
Bruno FerreiraCursorcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
f5fe9c7b40 ci(helm): publish immutable chart versions on release (#12056)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-27 16:58:59 +02:00
Hugo Pereira Brito 60c81f6549 docs: document Jira dispatch for Finding Groups (#12130) 2026-07-27 15:56:54 +01:00
Alan Buscaglia f18c2841a8 fix(ui): classify attack path findings exactly (#11244) 2026-07-27 14:48:01 +02:00
Bruno FerreiraandCursor 6b21e31a28 fix(app): cap Celery worker concurrency in the Helm chart (#12054)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 12:53:03 +02:00
Pepe Fagoaga 294e665d9e docs: changelog section (#12126) 2026-07-27 12:44:56 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 94e14660da docs: apply brand tone and writing style fixes (#12125)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-27 09:25:28 +02:00
Nithin ReddyandDaniel Barranquero 339930ef13 feat(ec2): add ec2_instance_stopped_older_than_specific_days check (#12076)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-07-27 09:13:18 +02:00
d933a8ecab feat(providers): add Huawei Cloud provider with CIS 1.0 benchmark (#11950)
Co-authored-by: tomitobio <tomitobio@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Lydia Vilchez <lydiavilchezlopez@gmail.com>
2026-07-27 08:52:58 +02:00
Prowler Botandprowler-bot da09ad9813 chore(release): Bump versions to v5.37.0 (#12113)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-07-24 14:56:54 +02:00
1568 changed files with 113972 additions and 8870 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.36.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.40.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+6
View File
@@ -0,0 +1,6 @@
# Generated by gh-aw and marked DO NOT EDIT. It uses concurrency options newer than
# actionlint knows, so the findings are about actionlint's schema, not our workflows.
paths:
.github/workflows/**/*.lock.yml:
ignore:
- '.*'
+179
View File
@@ -0,0 +1,179 @@
name: 'Container Security Scan with Grype'
description: 'Scans container images for vulnerabilities using Grype and reports results'
author: 'Prowler'
inputs:
image-name:
description: 'Container image name to scan'
required: true
image-tag:
description: 'Container image tag to scan'
required: true
default: ${{ github.sha }}
fail-on-severity:
description: 'Fail the build on findings at this severity or above: critical, high, or none'
required: false
default: 'high'
upload-sarif:
description: 'Upload results to GitHub Security tab'
required: false
default: 'true'
create-pr-comment:
description: 'Create a comment on the PR with scan results'
required: false
default: 'true'
artifact-retention-days:
description: 'Days to retain the Grype report artifact'
required: false
default: '2'
outputs:
critical-count:
description: 'Number of critical vulnerabilities found'
value: ${{ steps.security-check.outputs.critical }}
high-count:
description: 'Number of high vulnerabilities found'
value: ${{ steps.security-check.outputs.high }}
total-count:
description: 'Total number of vulnerabilities found'
value: ${{ steps.security-check.outputs.total }}
runs:
using: 'composite'
steps:
- name: Run Grype vulnerability scan (JSON)
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
with:
image: ${{ inputs.image-name }}:${{ inputs.image-tag }}
output-format: 'json'
output-file: 'grype-report.json'
fail-build: 'false'
by-cve: 'true' # Report CVE ids rather than GHSA, so findings line up with Trivy's
only-fixed: 'true' # A finding with no available fix is not actionable, so it must not gate
cache-db: 'true'
grype-version: 'v0.116.1'
- name: Run Grype vulnerability scan (SARIF)
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
with:
image: ${{ inputs.image-name }}:${{ inputs.image-tag }}
output-format: 'sarif'
output-file: 'grype-results.sarif'
fail-build: 'false'
severity-cutoff: 'high'
by-cve: 'true'
only-fixed: 'true' # A finding with no available fix is not actionable, so it must not gate
cache-db: 'true'
grype-version: 'v0.116.1'
- name: Upload Grype results to GitHub Security tab
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
uses: github/codeql-action/upload-sarif@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
with:
sarif_file: 'grype-results.sarif'
category: 'grype-container'
- name: Upload Grype report artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: always()
with:
name: grype-scan-report-${{ inputs.image-name }}-${{ inputs.image-tag }}
path: grype-report.json
retention-days: ${{ inputs.artifact-retention-days }}
- name: Generate security summary
id: security-check
shell: bash
run: |
CRITICAL=$(jq '[.matches[]? | select(.vulnerability.severity=="Critical")] | length' grype-report.json)
HIGH=$(jq '[.matches[]? | select(.vulnerability.severity=="High")] | length' grype-report.json)
TOTAL=$(jq '[.matches[]?] | length' grype-report.json)
echo "critical=$CRITICAL" >> $GITHUB_OUTPUT
echo "high=$HIGH" >> $GITHUB_OUTPUT
echo "total=$TOTAL" >> $GITHUB_OUTPUT
echo "### 🔎 Container Security Scan (Grype)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Image:** \`${INPUTS_IMAGE_NAME}:${INPUTS_IMAGE_TAG}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- 🔴 Critical: $CRITICAL" >> $GITHUB_STEP_SUMMARY
echo "- 🟠 High: $HIGH" >> $GITHUB_STEP_SUMMARY
echo "- **Total**: $TOTAL" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Reported alongside Trivy, not instead of it. Counts differ by design." >> $GITHUB_STEP_SUMMARY
env:
INPUTS_IMAGE_NAME: ${{ inputs.image-name }}
INPUTS_IMAGE_TAG: ${{ inputs.image-tag }}
# Before the gate, so the comment is there to explain a failure rather than absent because of it
- name: Comment scan results on PR
if: >-
inputs.create-pr-comment == 'true'
&& github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
IMAGE_NAME: ${{ inputs.image-name }}
GITHUB_SHA: ${{ inputs.image-tag }}
CUTOFF: ${{ inputs.fail-on-severity }}
with:
script: |
const comment = require('./.github/scripts/grype-pr-comment.js');
// Unique identifier to find our comment
const marker = `<!-- grype-scan-comment:${process.env.IMAGE_NAME} -->`;
const body = marker + '\n' + comment;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existingComment = comments.find(c => c.body?.includes(marker));
if (existingComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body: body
});
console.log('✅ Updated existing Grype scan comment');
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body
});
console.log('✅ Created new Grype scan comment');
}
- name: Check for blocking vulnerabilities
if: inputs.fail-on-severity != 'none'
shell: bash
run: |
if [ "$CUTOFF" = "critical" ]; then
BLOCKING=$CRITICAL
SEVERITIES='["Critical"]'
else
BLOCKING=$((CRITICAL + HIGH))
SEVERITIES='["Critical","High"]'
fi
if [ "$BLOCKING" -gt 0 ]; then
echo "::error::Found $BLOCKING vulnerabilities at severity ${CUTOFF} or above ($CRITICAL critical, $HIGH high)"
echo "::warning::Update the package, or add it to .grype.yaml with a reason if nothing can be done"
jq -r --argjson severities "$SEVERITIES" \
'.matches[] | select(.vulnerability.severity | IN($severities[]))
| " \(.vulnerability.severity)\t\(.vulnerability.id)\t\(.artifact.name) \(.artifact.version)"' \
grype-report.json | sort -u
exit 1
fi
env:
CUTOFF: ${{ inputs.fail-on-severity }}
CRITICAL: ${{ steps.security-check.outputs.critical }}
HIGH: ${{ steps.security-check.outputs.high }}
+36 -13
View File
@@ -14,10 +14,10 @@ inputs:
description: 'Severities to scan for (comma-separated)'
required: false
default: 'CRITICAL,HIGH,MEDIUM,LOW'
fail-on-critical:
description: 'Fail the build if critical vulnerabilities are found'
fail-on-severity:
description: 'Fail the build on findings at this severity or above: critical, high, or none'
required: false
default: 'false'
default: 'high'
upload-sarif:
description: 'Upload results to GitHub Security tab'
required: false
@@ -54,7 +54,7 @@ runs:
trivy-db-${{ runner.os }}-
- name: Run Trivy vulnerability scan (JSON)
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ inputs.image-name }}:${{ inputs.image-tag }}
format: 'json'
@@ -62,12 +62,16 @@ runs:
severity: ${{ inputs.severity }}
exit-code: '0'
scanners: 'vuln'
ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate
timeout: '5m'
version: 'v0.71.2'
version: 'v0.74.0'
# Not trivyignores: that input drops the .yaml extension Trivy parses by.
env:
TRIVY_IGNOREFILE: '.trivyignore.yaml'
- name: Run Trivy vulnerability scan (SARIF)
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ inputs.image-name }}:${{ inputs.image-tag }}
format: 'sarif'
@@ -75,8 +79,12 @@ runs:
severity: 'CRITICAL,HIGH'
exit-code: '0'
scanners: 'vuln'
ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate
timeout: '5m'
version: 'v0.71.2'
version: 'v0.74.0'
# Not trivyignores: that input drops the .yaml extension Trivy parses by.
env:
TRIVY_IGNOREFILE: '.trivyignore.yaml'
- name: Upload Trivy results to GitHub Security tab
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
@@ -163,13 +171,28 @@ runs:
console.log('✅ Created new Trivy scan comment');
}
- name: Check for critical vulnerabilities
if: inputs.fail-on-critical == 'true' && steps.security-check.outputs.critical != '0'
- name: Check for blocking vulnerabilities
if: inputs.fail-on-severity != 'none'
shell: bash
run: |
echo "::error::Found ${STEPS_SECURITY_CHECK_OUTPUTS_CRITICAL} critical vulnerabilities"
echo "::warning::Please update packages or use a different base image"
exit 1
if [ "$CUTOFF" = "critical" ]; then
BLOCKING=$CRITICAL
SEVERITIES='["CRITICAL"]'
else
BLOCKING=$((CRITICAL + HIGH))
SEVERITIES='["CRITICAL","HIGH"]'
fi
if [ "$BLOCKING" -gt 0 ]; then
echo "::error::Found $BLOCKING vulnerabilities at severity ${CUTOFF} or above ($CRITICAL critical, $HIGH high)"
echo "::warning::Update the package, or add it to .trivyignore.yaml with a reason if nothing can be done"
jq -r --argjson severities "$SEVERITIES" \
'.Results[]?.Vulnerabilities[]? | select(.Severity | IN($severities[]))
| " \(.Severity)\t\(.VulnerabilityID)\t\(.PkgName) \(.InstalledVersion)"' \
trivy-report.json | sort -u
exit 1
fi
env:
STEPS_SECURITY_CHECK_OUTPUTS_CRITICAL: ${{ steps.security-check.outputs.critical }}
CUTOFF: ${{ inputs.fail-on-severity }}
CRITICAL: ${{ steps.security-check.outputs.critical }}
HIGH: ${{ steps.security-check.outputs.high }}
+6 -6
View File
@@ -199,7 +199,7 @@ You MUST structure your response using this EXACT format. Do NOT include anythin
### For Check Logic Bug
```
```markdown
### AI Assessment [Experimental]: Check Logic Bug
**Component**: {component from issue template}
@@ -297,7 +297,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns
### For Bug (non-check)
```
```markdown
### AI Assessment [Experimental]: Bug
**Component**: {CLI/SDK | API | UI | Dashboard | MCP Server | Other}
@@ -378,7 +378,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns
### For Already Fixed
```
```markdown
### AI Assessment [Experimental]: Already Fixed
**Component**: {component}
@@ -401,7 +401,7 @@ Upgrade to the latest version. Close the issue as resolved.
### For Feature Request
```
```markdown
### AI Assessment [Experimental]: Feature Request
**Component**: {component}
@@ -419,7 +419,7 @@ Upgrade to the latest version. Close the issue as resolved.
### For Not a Bug
```
```markdown
### AI Assessment [Experimental]: Not a Bug
**Component**: {component}
@@ -440,7 +440,7 @@ Upgrade to the latest version. Close the issue as resolved.
### For Needs More Information
```
```markdown
### AI Assessment [Experimental]: Needs More Information
**Component**: {component or "Unknown"}
+15
View File
@@ -52,6 +52,16 @@ provider/alibabacloud:
- any-glob-to-any-file: "prowler/providers/alibabacloud/**"
- any-glob-to-any-file: "tests/providers/alibabacloud/**"
provider/huaweicloud:
- changed-files:
- any-glob-to-any-file: "prowler/providers/huaweicloud/**"
- any-glob-to-any-file: "tests/providers/huaweicloud/**"
provider/image:
- changed-files:
- any-glob-to-any-file: "prowler/providers/image/**"
- any-glob-to-any-file: "tests/providers/image/**"
provider/cloudflare:
- changed-files:
- any-glob-to-any-file: "prowler/providers/cloudflare/**"
@@ -82,6 +92,11 @@ provider/linode:
- any-glob-to-any-file: "prowler/providers/linode/**"
- any-glob-to-any-file: "tests/providers/linode/**"
provider/stackit:
- changed-files:
- any-glob-to-any-file: "prowler/providers/stackit/**"
- any-glob-to-any-file: "tests/providers/stackit/**"
github_actions:
- changed-files:
- any-glob-to-any-file: ".github/workflows/*"
+100
View File
@@ -0,0 +1,100 @@
const fs = require('fs');
// Configuration from environment variables
const REPORT_FILE = process.env.GRYPE_REPORT_FILE || 'grype-report.json';
const IMAGE_NAME = process.env.IMAGE_NAME || 'container-image';
const GITHUB_SHA = process.env.GITHUB_SHA || 'unknown';
const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY || '';
const GITHUB_RUN_ID = process.env.GITHUB_RUN_ID || '';
const CUTOFF = process.env.CUTOFF || 'high';
// A cutoff of 'critical' blocks only on critical; anything else blocks on high and above
const blocking = CUTOFF === 'critical' ? ['Critical'] : ['Critical', 'High'];
const report = JSON.parse(fs.readFileSync(REPORT_FILE, 'utf-8'));
const matches = Array.isArray(report.matches) ? report.matches : [];
const ignored = Array.isArray(report.ignoredMatches) ? report.ignoredMatches : [];
const counts = { Critical: 0, High: 0, Medium: 0, Low: 0, Negligible: 0, Unknown: 0 };
const blockers = new Map();
for (const match of matches) {
const severity = match.vulnerability.severity;
if (counts[severity] !== undefined) {
counts[severity]++;
}
if (blocking.includes(severity)) {
const artifact = match.artifact;
const fixedIn = (match.vulnerability.fix && match.vulnerability.fix.versions || []).join(', ');
// Same CVE can match several install paths of one package; collapse them
blockers.set(`${match.vulnerability.id}|${artifact.name}`, {
id: match.vulnerability.id,
severity,
name: artifact.name,
version: artifact.version,
fixedIn
});
}
}
const ignoredBlocking = ignored.filter(m => blocking.includes(m.vulnerability.severity)).length;
const shortSha = GITHUB_SHA.substring(0, 7);
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
const severityConfig = {
Critical: { icon: '🔴', label: 'Critical' },
High: { icon: '🟠', label: 'High' },
Medium: { icon: '🟡', label: 'Medium' },
Low: { icon: '🔵', label: 'Low' }
};
let comment = '## 🔎 Container Security Scan (Grype)\n\n';
comment += `**Image:** \`${IMAGE_NAME}:${shortSha}\`\n`;
comment += `**Last scan:** ${timestamp}\n\n`;
if (blockers.size === 0) {
comment += '### ✅ Nothing Blocking\n\n';
comment += `No findings at **${blocking.join(' or ').toLowerCase()}** severity.\n`;
} else {
comment += `### ⚠️ ${blockers.size} Finding(s) Blocking This PR\n\n`;
comment += '| Severity | CVE | Package | Installed | Fixed in |\n';
comment += '|---|---|---|---|---|\n';
const order = { Critical: 0, High: 1 };
const rows = [...blockers.values()].sort((a, b) =>
(order[a.severity] - order[b.severity]) || a.name.localeCompare(b.name));
for (const row of rows) {
const config = severityConfig[row.severity];
comment += `| ${config.icon} ${config.label} | \`${row.id}\` | \`${row.name}\` | ${row.version} | ${row.fixedIn || '—'} |\n`;
}
comment += '\n**What to do:**\n';
comment += '- Upgrade the package to the version in the "Fixed in" column.\n';
comment += '- If it is pinned by another dependency, or the fix is otherwise out of reach, add it to `.grype.yaml` **with the reason**.\n';
comment += '- Findings with no published fix never appear here: the scan runs with `only-fixed`, so it reports only what can actually be acted on.\n';
}
const otherCounts = Object.entries(counts)
.filter(([severity, count]) => !blocking.includes(severity) && count > 0)
.map(([severity, count]) => `${severity.toLowerCase()}: ${count}`);
if (otherCounts.length > 0) {
comment += `\nNot blocking at this cutoff — ${otherCounts.join(', ')}.\n`;
}
if (ignoredBlocking > 0) {
comment += `\n${ignoredBlocking} finding(s) excluded by \`.grype.yaml\`, each with a documented reason.\n`;
}
comment += '\n---\n';
comment += '📋 **Resources:**\n';
if (GITHUB_REPOSITORY && GITHUB_RUN_ID) {
comment += `- [Download full report](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) (see artifacts)\n`;
}
comment += '- [View in Security tab](https://github.com/' + (GITHUB_REPOSITORY || 'repository') + '/security/code-scanning)\n';
comment += '- Scanned with [Grype](https://github.com/anchore/grype), alongside Trivy\n';
module.exports = comment;
+5 -2
View File
@@ -51,7 +51,8 @@ STDERR="$(mktemp)"
trap 'rm -f "${STDERR}"' EXIT
set +e
OUTPUT="$(osv-scanner scan source "${SCAN_ARGS[@]}" --format=json "$@" 2>"${STDERR}")"
# ${a[@]+...} guard: an empty array trips `set -u` on bash before 4.4.
OUTPUT="$(osv-scanner scan source ${SCAN_ARGS[@]+"${SCAN_ARGS[@]}"} --format=json "$@" 2>"${STDERR}")"
RC=$?
set -e
@@ -100,6 +101,8 @@ FINDINGS="$(printf '%s' "${OUTPUT}" | jq --argjson sevs "${SEVERITY_JSON}" '
]
')"
# jq exits 0 with no output on empty stdin, but non-zero on malformed JSON.
# Let the failure abort under set -e rather than reporting zero findings.
COUNT="$(printf '%s' "${FINDINGS}" | jq 'length')"
# Write the findings JSON to OSV_REPORT_FILE so callers (e.g. the composite
@@ -108,7 +111,7 @@ if [ -n "${OSV_REPORT_FILE:-}" ]; then
printf '%s' "${FINDINGS}" > "${OSV_REPORT_FILE}"
fi
if [ "${COUNT}" -gt 0 ]; then
if [ "${COUNT:-0}" -gt 0 ]; then
echo "osv-scanner: ${COUNT} finding(s) at severity ${SEVERITY_LEVELS}"
printf '%s' "${FINDINGS}" | jq -r '
.[] | " [\(.severity)\(if .score then " \(.score)" else "" end)] \(.id) \(.ecosystem)/\(.package)@\(.version) — \(.summary // "(no summary)")"
+5 -5
View File
@@ -8,11 +8,11 @@ These JSON templates are used with the `slackapi/slack-github-action` using the
### Available Templates
**Container Releases**
#### Container Releases
- `container-release-started.json`: Simple one-line notification when container push starts
- `container-release-completed.json`: Simple one-line notification when container release completes
**Deployments**
#### Deployments
- `deployment-started.json`: Deployment start notification with Block Kit formatting
- `deployment-completed.json`: Deployment completion notification (updates the start message)
@@ -416,17 +416,17 @@ For deployments that start with one message and update it with the final status:
### Container Release (Simple One-Line)
**Start message:**
```
```text
API container release 4.5.0 push started... View run
```
**Completion message (success):**
```
```text
[✓] API container release 4.5.0 push completed successfully! View run
```
**Completion message (failure):**
```
```text
[✗] API container release 4.5.0 push failed View run
```
+11
View File
@@ -249,6 +249,7 @@ modules:
- ui/tests/profile/**
- ui/tests/lighthouse/**
- ui/tests/home/**
- ui/tests/navigation/**
- ui/tests/attack-paths/**
- name: api-serializers
@@ -275,6 +276,7 @@ modules:
- ui/tests/profile/**
- ui/tests/lighthouse/**
- ui/tests/home/**
- ui/tests/navigation/**
- ui/tests/attack-paths/**
- name: api-filters
@@ -432,6 +434,14 @@ modules:
e2e:
- ui/tests/lighthouse/**
- name: ui-navigation
match:
- ui/components/layout/**
- ui/tests/navigation/**
tests: []
e2e:
- ui/tests/navigation/**
- name: ui-overview
match:
- ui/components/overview/**
@@ -464,6 +474,7 @@ modules:
- ui/tests/profile/**
- ui/tests/lighthouse/**
- ui/tests/home/**
- ui/tests/navigation/**
- ui/tests/attack-paths/**
- name: ui-attack-paths
+26 -12
View File
@@ -42,6 +42,7 @@ jobs:
timeout-minutes: 5
outputs:
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
created: ${{ steps.set-short-sha.outputs.created }}
permissions:
contents: read
steps:
@@ -52,7 +53,9 @@ jobs:
- name: Calculate short SHA
id: set-short-sha
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
run: |
echo "short-sha=${GITHUB_SHA::7}" >> "${GITHUB_OUTPUT}"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
notify-release-started:
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
@@ -159,9 +162,20 @@ jobs:
with:
context: ${{ env.WORKING_DIRECTORY }}
push: true
sbom: true
# max, not the default min: min records little beyond the build ref.
provenance: mode=max
platforms: ${{ matrix.platform }}
tags: |
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }}
labels: |
org.opencontainers.image.title=Prowler Local Server API
org.opencontainers.image.description=API for Prowler Local Server (Django/DRF)
org.opencontainers.image.vendor=ProwlerPro, Inc.
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
@@ -179,12 +193,12 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
github.com:443
release-assets.githubusercontent.com:443
registry-1.docker.io:443
auth.docker.io:443
github.com:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
registry-1.docker.io:443
release-assets.githubusercontent.com:443
- name: Login to DockerHub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
@@ -196,9 +210,9 @@ jobs:
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -206,10 +220,10 @@ jobs:
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG} \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG}" \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -249,9 +263,9 @@ jobs:
id: outcome
run: |
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
echo "outcome=success" >> $GITHUB_OUTPUT
echo "outcome=success" >> "$GITHUB_OUTPUT"
else
echo "outcome=failure" >> $GITHUB_OUTPUT
echo "outcome=failure" >> "$GITHUB_OUTPUT"
fi
env:
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
+21 -3
View File
@@ -81,6 +81,10 @@ jobs:
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
raw.githubusercontent.com:443
objects.githubusercontent.com:443
grype.anchore.io:443
get.anchore.io:443
debian.map.fastlydns.net:80
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
@@ -105,7 +109,13 @@ jobs:
id: check-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: api/**
files: |
api/**
.trivyignore.yaml
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
.grype.yaml
.github/scripts/grype-pr-comment.js
files_ignore: |
api/docs/**
api/README.md
@@ -143,5 +153,13 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'true'
severity: 'CRITICAL'
fail-on-severity: 'high'
severity: 'CRITICAL,HIGH'
- name: Scan container with Grype
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/grype-scan
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
+1
View File
@@ -107,6 +107,7 @@ jobs:
files: |
api/**
.github/workflows/api-tests.yml
codecov.yml
files_ignore: |
api/docs/**
api/README.md
+63
View File
@@ -0,0 +1,63 @@
name: 'CI: Actionlint'
on:
push:
branches:
- 'master'
pull_request:
branches:
- 'master'
schedule:
- cron: '45 06 * * *'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
actionlint:
if: github.repository == 'prowler-cloud/prowler'
name: GitHub Actions Schema Check
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
auth.docker.io:443
registry-1.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
# zizmor: ignore[artipacked]
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
# Always runs so it always reports; the lint is skipped when nothing changed.
- name: Check for workflow changes
id: check-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: .github/**
# SC2129 is style only: it suggests grouping consecutive redirects.
- name: Run actionlint
if: steps.check-changes.outputs.any_changed == 'true'
env:
SHELLCHECK_OPTS: '-e SC2129'
run: |
docker run --rm -v "$PWD:/repo" --workdir /repo -e SHELLCHECK_OPTS \
rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 \
-color
+2 -2
View File
@@ -32,7 +32,7 @@ jobs:
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
echo "Removing 'status/awaiting-response' label from #$ISSUE_NUMBER"
gh api /repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels/status%2Fawaiting-response \
gh api "/repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels/status%2Fawaiting-response" \
-X DELETE
- name: Add 'status/waiting-for-revision' label
@@ -41,6 +41,6 @@ jobs:
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
echo "Adding 'status/waiting-for-revision' label to #$ISSUE_NUMBER"
gh api /repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels \
gh api "/repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels" \
-X POST \
-f labels[]='status/waiting-for-revision'
@@ -0,0 +1,92 @@
name: 'Tools: Sync Docker Hub Descriptions'
on:
push:
branches:
- 'master'
paths:
- 'docs/dockerhub/README.md'
- '.github/workflows/dockerhub-descriptions.yml'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
env:
OVERVIEW_FILE: docs/dockerhub/README.md
permissions: {}
jobs:
prowlercloud:
if: github.repository == 'prowler-cloud/prowler' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- repository: prowlercloud/prowler
short_description: 'Prowler CLI: the Open Cloud Security tool for AWS, Azure, Google Cloud, Kubernetes, M365 and GitHub'
- repository: prowlercloud/prowler-api
short_description: 'Prowler Local Server - API: the JSON API and Task Runner components of Prowler'
- repository: prowlercloud/prowler-ui
short_description: 'Prowler Local Server - UI: the web interface to run Prowler scans and explore findings'
- repository: prowlercloud/prowler-mcp
short_description: 'Prowler MCP: the interface for agents, including IDE plugins and agent integrations'
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
hub.docker.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Update Docker Hub description for ${{ matrix.repository }}
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: ${{ matrix.repository }}
short-description: ${{ matrix.short_description }}
readme-filepath: ${{ env.OVERVIEW_FILE }}
toniblyx:
if: github.repository == 'prowler-cloud/prowler' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
hub.docker.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Update Docker Hub description for toniblyx/prowler
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
with:
username: ${{ secrets.TONIBLYX_DOCKERHUB_USERNAME }}
password: ${{ secrets.TONIBLYX_DOCKERHUB_PASSWORD }}
repository: toniblyx/prowler
short-description: 'Prowler CLI (legacy repository, mirrors prowlercloud/prowler)'
readme-filepath: ${{ env.OVERVIEW_FILE }}
+8 -5
View File
@@ -38,16 +38,19 @@ jobs:
- name: Set up Helm
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
- name: Set appVersion from release tag
- name: Set chart version and appVersion from release tag
run: |
RELEASE_TAG="${GITHUB_EVENT_RELEASE_TAG_NAME}"
echo "Setting appVersion to ${RELEASE_TAG}"
sed -i "s/^appVersion:.*/appVersion: \"${RELEASE_TAG}\"/" ${{ env.CHART_PATH }}/Chart.yaml
# Strip any leading "v" so the chart version is valid SemVer 2.
RELEASE_TAG="${GITHUB_EVENT_RELEASE_TAG_NAME#v}"
echo "Setting chart version and appVersion to ${RELEASE_TAG}"
# Publish an immutable chart version per release instead of the static
# 0.0.1 in source, so every release is a distinct, addressable artifact.
yq -i ".version = \"${RELEASE_TAG}\" | .appVersion = \"${RELEASE_TAG}\"" ${{ env.CHART_PATH }}/Chart.yaml
env:
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
- name: Login to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${GITHUB_ACTOR} --password-stdin
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin
- name: Update chart dependencies
run: helm dependency update ${{ env.CHART_PATH }}
+2 -2
View File
@@ -85,10 +85,10 @@ jobs:
# Check if author is in the org members list
if printf '%s\n' "${ORG_MEMBERS[@]}" | grep -q "^${AUTHOR}$"; then
echo "is_member=true" >> $GITHUB_OUTPUT
echo "is_member=true" >> "$GITHUB_OUTPUT"
echo "$AUTHOR is an organization member"
else
echo "is_member=false" >> $GITHUB_OUTPUT
echo "is_member=false" >> "$GITHUB_OUTPUT"
echo "$AUTHOR is not an organization member"
fi
+5 -1
View File
@@ -55,6 +55,10 @@ jobs:
# Pin must match .pre-commit-config.yaml so prek and CI behave identically.
# pnpm dlx doesn't accept --ignore-scripts as a flag; the env var
# disables postinstall scripts on transitives the same way.
#
# Files come from `git ls-files` because markdownlint doesn't traverse
# dot-directories, so `.github/**/*.md` went unlinted.
# `.markdownlintignore` still applies to the listed paths.
env:
pnpm_config_ignore_scripts: 'true'
run: pnpm dlx markdownlint-cli@0.45.0 '**/*.md'
run: git ls-files -z '*.md' | xargs -0 -r pnpm dlx markdownlint-cli@0.45.0 --
+25 -19
View File
@@ -41,6 +41,7 @@ jobs:
timeout-minutes: 5
outputs:
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
created: ${{ steps.set-short-sha.outputs.created }}
permissions:
contents: read
steps:
@@ -51,7 +52,9 @@ jobs:
- name: Calculate short SHA
id: set-short-sha
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
run: |
echo "short-sha=${GITHUB_SHA::7}" >> "${GITHUB_OUTPUT}"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
notify-release-started:
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
@@ -110,15 +113,15 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
github.com:443
registry-1.docker.io:443
auth.docker.io:443
files.pythonhosted.org:443
ghcr.io:443
github.com:443
pkg-containers.githubusercontent.com:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
ghcr.io:443
pkg-containers.githubusercontent.com:443
files.pythonhosted.org:443
pypi.org:443
registry-1.docker.io:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -141,17 +144,20 @@ jobs:
with:
context: ${{ env.WORKING_DIRECTORY }}
push: true
sbom: true
# max, not the default min: min records little beyond the build ref.
provenance: mode=max
platforms: ${{ matrix.platform }}
tags: |
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }}
labels: |
org.opencontainers.image.title=Prowler MCP Server
org.opencontainers.image.title=Prowler MCP
org.opencontainers.image.description=Model Context Protocol server for Prowler
org.opencontainers.image.vendor=ProwlerPro, Inc.
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ github.event_name == 'release' && github.event.release.published_at || github.event.head_commit.timestamp }}
${{ github.event_name == 'release' && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
@@ -169,11 +175,11 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
registry-1.docker.io:443
auth.docker.io:443
github.com:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
github.com:443
registry-1.docker.io:443
release-assets.githubusercontent.com:443
- name: Login to DockerHub
@@ -187,9 +193,9 @@ jobs:
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -197,10 +203,10 @@ jobs:
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG} \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG}" \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -240,9 +246,9 @@ jobs:
id: outcome
run: |
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
echo "outcome=success" >> $GITHUB_OUTPUT
echo "outcome=success" >> "$GITHUB_OUTPUT"
else
echo "outcome=failure" >> $GITHUB_OUTPUT
echo "outcome=failure" >> "$GITHUB_OUTPUT"
fi
env:
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
+20 -3
View File
@@ -87,6 +87,9 @@ jobs:
get.trivy.dev:443
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
raw.githubusercontent.com:443
grype.anchore.io:443
get.anchore.io:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -98,7 +101,13 @@ jobs:
id: check-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: mcp_server/**
files: |
mcp_server/**
.trivyignore.yaml
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
.grype.yaml
.github/scripts/grype-pr-comment.js
files_ignore: |
mcp_server/README.md
mcp_server/CHANGELOG.md
@@ -125,5 +134,13 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'true'
severity: 'CRITICAL'
fail-on-severity: 'high'
severity: 'CRITICAL,HIGH'
- name: Scan MCP container with Grype
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/grype-scan
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
+1 -1
View File
@@ -113,7 +113,7 @@ jobs:
- name: Publish prowler-mcp package to PyPI
if: steps.pypi-check.outputs.skip != 'true'
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
packages-dir: ${{ env.WORKING_DIRECTORY }}/dist/
print-hash: true
+99
View File
@@ -0,0 +1,99 @@
name: 'MCP: Tests'
on:
push:
branches:
- 'master'
- 'v5.*'
pull_request:
branches:
- 'master'
- 'v5.*'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
MCP_WORKING_DIR: ./mcp_server
permissions: {}
jobs:
mcp-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
strategy:
matrix:
# requires-python is >=3.12 while the shipped image is 3.13; testing both
# is what keeps that floor honest.
python-version:
- '3.12'
- '3.13'
defaults:
run:
working-directory: ./mcp_server
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
# hub.prowler.com and raw.githubusercontent.com are deliberately absent:
# the suite mocks every outbound call, so a real one must fail the job.
# The sentry.io entry is not the test suite: the Codecov uploader sends
# its own telemetry there, so api-tests.yml and sdk-tests.yml allow it too.
allowed-endpoints: >
github.com:443
pypi.org:443
files.pythonhosted.org:443
cli.codecov.io:443
keybase.io:443
ingest.codecov.io:443
o26192.ingest.us.sentry.io:443
storage.googleapis.com:443
api.github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# zizmor: ignore[artipacked]
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
- name: Check for MCP server changes
id: check-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: |
mcp_server/**
.github/workflows/mcp-tests.yml
codecov.yml
files_ignore: |
mcp_server/README.md
mcp_server/CHANGELOG.md
mcp_server/changelog.d/**
mcp_server/AGENTS.md
mcp_server/Dockerfile
mcp_server/.dockerignore
mcp_server/entrypoint.sh
- name: Setup Python with uv
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ matrix.python-version }}
working-directory: ./mcp_server
- name: Run tests with pytest
if: steps.check-changes.outputs.any_changed == 'true'
run: uv run pytest --cov=./prowler_mcp_server --cov-report=xml tests
- name: Upload coverage reports to Codecov
if: steps.check-changes.outputs.any_changed == 'true'
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
flags: mcp
-1
View File
@@ -129,7 +129,6 @@ jobs:
handwritten_changelogs=""
all_changed=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n')
added=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" | tr ' ' '\n')
added_or_renamed=$(printf '%s\n%s' "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_RENAMED_FILES}" | tr ' ' '\n')
added_modified_or_renamed=$(printf '%s\n%s\n%s' "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_MODIFIED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_RENAMED_FILES}" | tr ' ' '\n')
@@ -111,7 +111,7 @@ jobs:
done
if [ -n "$found_in" ]; then
found_in=$(echo "$found_in" | sed 's/, $//')
found_in="${found_in%, }"
MAPPED="${MAPPED}- \`${check_id}\` (\`${provider}\`): ${found_in}"$'\n'
else
UNMAPPED="${UNMAPPED}- \`${check_id}\` (\`${provider}\`)"$'\n'
+3 -3
View File
@@ -74,15 +74,15 @@ jobs:
done <<< "$STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES"
if [ "$HAS_CONFLICTS" = true ]; then
echo "has_conflicts=true" >> $GITHUB_OUTPUT
echo "has_conflicts=true" >> "$GITHUB_OUTPUT"
{
echo "conflict_files<<EOF"
echo "$CONFLICT_FILES"
echo "EOF"
} >> $GITHUB_OUTPUT
} >> "$GITHUB_OUTPUT"
echo "Conflict markers detected"
else
echo "has_conflicts=false" >> $GITHUB_OUTPUT
echo "has_conflicts=false" >> "$GITHUB_OUTPUT"
echo "No conflict markers found in changed files"
fi
env:
+5 -4
View File
@@ -36,7 +36,7 @@ jobs:
id: vars
run: |
SHORT_SHA="${GITHUB_EVENT_PULL_REQUEST_MERGE_COMMIT_SHA}"
echo "short_sha=${SHORT_SHA::7}" >> $GITHUB_OUTPUT
echo "short_sha=${SHORT_SHA::7}" >> "$GITHUB_OUTPUT"
env:
GITHUB_EVENT_PULL_REQUEST_MERGE_COMMIT_SHA: ${{ github.event.pull_request.merge_commit_sha }}
@@ -46,6 +46,7 @@ jobs:
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
repository: ${{ secrets.CLOUD_DISPATCH }}
event-type: prowler-pull-request-merged
# repository_dispatch caps client_payload at 10 properties; this is exactly at the cap.
client-payload: |
{
"PROWLER_COMMIT_SHA": "${{ github.event.pull_request.merge_commit_sha }}",
@@ -54,8 +55,8 @@ jobs:
"PROWLER_PR_TITLE": ${{ toJson(github.event.pull_request.title) }},
"PROWLER_PR_LABELS": ${{ toJson(github.event.pull_request.labels.*.name) }},
"PROWLER_PR_BODY": ${{ toJson(github.event.pull_request.body) }},
"PROWLER_PR_URL": ${{ toJson(github.event.pull_request.html_url) }},
"PROWLER_PR_MERGED_BY": "${{ github.event.pull_request.merged_by.login }}",
"PROWLER_PR_BASE_BRANCH": ${{ toJson(github.event.pull_request.base.ref) }},
"PROWLER_PR_HEAD_BRANCH": ${{ toJson(github.event.pull_request.head.ref) }}
"PROWLER_PR_STACK_NUMBER": "${{ github.event.pull_request.stack.number }}",
"PROWLER_PR_STACK_POSITION": "${{ github.event.pull_request.stack.position }}",
"PROWLER_PR_STACK_SIZE": "${{ github.event.pull_request.stack.size }}"
}
+20 -11
View File
@@ -25,6 +25,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
actions: write
contents: write
pull-requests: write
steps:
@@ -33,6 +34,12 @@ jobs:
with:
egress-policy: audit
- name: Enable release freeze
env:
GH_TOKEN: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
run: |
gh variable set RELEASE_FREEZE --body true --repo "${GITHUB_REPOSITORY}"
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -70,7 +77,7 @@ jobs:
echo "Prowler version: $PROWLER_VERSION"
echo "Branch name: $BRANCH_NAME"
echo "Is minor release: $([ $PATCH_VERSION -eq 0 ] && echo 'true' || echo 'false')"
echo "Is minor release: $([ "$PATCH_VERSION" -eq 0 ] && echo 'true' || echo 'false')"
else
echo "Invalid version syntax: '$PROWLER_VERSION' (must be N.N.N)" >&2
exit 1
@@ -100,7 +107,8 @@ jobs:
if [ -f "$changelog_file" ]; then
# Extract version that matches this Prowler release
# Format: ## [version] (Prowler X.Y.Z) or ## [vversion] (Prowler vX.Y.Z)
local version=$(grep '^## \[' "$changelog_file" | grep "(Prowler v\?${prowler_version})" | head -1 | sed 's/^## \[\(.*\)\].*/\1/' | sed 's/^v//' | tr -d '[:space:]')
local version
version=$(grep '^## \[' "$changelog_file" | grep "(Prowler v\?${prowler_version})" | head -1 | sed 's/^## \[\(.*\)\].*/\1/' | sed 's/^v//' | tr -d '[:space:]')
echo "$version"
else
echo ""
@@ -171,55 +179,55 @@ jobs:
# Determine if components have changes for this specific release
if [ -n "$SDK_VERSION" ]; then
echo "HAS_SDK_CHANGES=true" >> $GITHUB_ENV
echo "HAS_SDK_CHANGES=true" >> "$GITHUB_ENV"
HAS_SDK_CHANGES="true"
echo "✓ SDK changes detected - version: $SDK_VERSION"
extract_changelog "prowler/CHANGELOG.md" "$SDK_VERSION" "prowler_changelog.md"
else
echo "HAS_SDK_CHANGES=false" >> $GITHUB_ENV
echo "HAS_SDK_CHANGES=false" >> "$GITHUB_ENV"
HAS_SDK_CHANGES="false"
echo " No SDK changes for this release"
touch "prowler_changelog.md"
fi
if [ -n "$API_VERSION" ]; then
echo "HAS_API_CHANGES=true" >> $GITHUB_ENV
echo "HAS_API_CHANGES=true" >> "$GITHUB_ENV"
HAS_API_CHANGES="true"
echo "✓ API changes detected - version: $API_VERSION"
extract_changelog "api/CHANGELOG.md" "$API_VERSION" "api_changelog.md"
else
echo "HAS_API_CHANGES=false" >> $GITHUB_ENV
echo "HAS_API_CHANGES=false" >> "$GITHUB_ENV"
HAS_API_CHANGES="false"
echo " No API changes for this release"
touch "api_changelog.md"
fi
if [ -n "$UI_VERSION" ]; then
echo "HAS_UI_CHANGES=true" >> $GITHUB_ENV
echo "HAS_UI_CHANGES=true" >> "$GITHUB_ENV"
HAS_UI_CHANGES="true"
echo "✓ UI changes detected - version: $UI_VERSION"
extract_changelog "ui/CHANGELOG.md" "$UI_VERSION" "ui_changelog.md"
else
echo "HAS_UI_CHANGES=false" >> $GITHUB_ENV
echo "HAS_UI_CHANGES=false" >> "$GITHUB_ENV"
HAS_UI_CHANGES="false"
echo " No UI changes for this release"
touch "ui_changelog.md"
fi
if [ -n "$MCP_VERSION" ]; then
echo "HAS_MCP_CHANGES=true" >> $GITHUB_ENV
echo "HAS_MCP_CHANGES=true" >> "$GITHUB_ENV"
HAS_MCP_CHANGES="true"
echo "✓ MCP changes detected - version: $MCP_VERSION"
extract_changelog "mcp_server/CHANGELOG.md" "$MCP_VERSION" "mcp_changelog.md"
else
echo "HAS_MCP_CHANGES=false" >> $GITHUB_ENV
echo "HAS_MCP_CHANGES=false" >> "$GITHUB_ENV"
HAS_MCP_CHANGES="false"
echo " No MCP changes for this release"
touch "mcp_changelog.md"
fi
# Combine changelogs in order: UI, API, SDK, MCP
> combined_changelog.md
: > combined_changelog.md
if [ "$HAS_UI_CHANGES" = "true" ] && [ -s "ui_changelog.md" ]; then
echo "## UI" >> combined_changelog.md
@@ -382,3 +390,4 @@ jobs:
if: always()
run: |
rm -f prowler_changelog.md api_changelog.md ui_changelog.md mcp_changelog.md combined_changelog.md
+45
View File
@@ -0,0 +1,45 @@
name: 'Tools: Release Freeze Gate'
on:
pull_request:
branches:
- 'master'
types:
- opened
- synchronize
- reopened
- ready_for_review
merge_group:
branches:
- 'master'
types:
- checks_requested
workflow_dispatch:
permissions: {}
jobs:
release-freeze-gate:
name: release-freeze-gate
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Check release freeze status
env:
RELEASE_FREEZE: ${{ vars.RELEASE_FREEZE }}
run: |
case "${RELEASE_FREEZE}" in
true|TRUE|True)
echo "::error::Release freeze is active. Merges to master are temporarily blocked."
echo "Set the RELEASE_FREEZE repository variable to false when the release is complete."
exit 1
;;
*)
echo "Release freeze is not active."
;;
esac
+44 -31
View File
@@ -54,6 +54,7 @@ jobs:
timeout-minutes: 5
outputs:
prowler_version: ${{ steps.get-prowler-version.outputs.prowler_version }}
created: ${{ steps.get-prowler-version.outputs.created }}
latest_tag: ${{ steps.get-prowler-version.outputs.latest_tag }}
stable_tag: ${{ steps.get-prowler-version.outputs.stable_tag }}
permissions:
@@ -64,9 +65,9 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
files.pythonhosted.org:443
github.com:443
pypi.org:443
files.pythonhosted.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -86,6 +87,7 @@ jobs:
fi
echo "latest_tag=latest" >> "${GITHUB_OUTPUT}"
echo "stable_tag=stable" >> "${GITHUB_OUTPUT}"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
notify-release-started:
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
@@ -146,24 +148,24 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
_http._tcp.deb.debian.org:443
aka.ms:443
api.ecr-public.us-east-1.amazonaws.com:443
public.ecr.aws:443
sts.amazonaws.com:443
sts.us-east-1.amazonaws.com:443
registry-1.docker.io:443
auth.docker.io:443
cdn.powershellgallery.com:443
debian.map.fastlydns.net:80
files.pythonhosted.org:443
github.com:443
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
auth.docker.io:443
debian.map.fastlydns.net:80
github.com:443
release-assets.githubusercontent.com:443
public.ecr.aws:443
pypi.org:443
files.pythonhosted.org:443
registry-1.docker.io:443
release-assets.githubusercontent.com:443
sts.amazonaws.com:443
sts.us-east-1.amazonaws.com:443
www.powershellgallery.com:443
aka.ms:443
cdn.powershellgallery.com:443
_http._tcp.deb.debian.org:443
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -198,9 +200,20 @@ jobs:
context: .
file: ${{ env.DOCKERFILE_PATH }}
push: true
sbom: true
# max, not the default min: min records little beyond the build ref.
provenance: mode=max
platforms: ${{ matrix.platform }}
tags: |
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-${{ matrix.arch }}
labels: |
org.opencontainers.image.title=Prowler CLI
org.opencontainers.image.description=Open Source security tool for cloud security assessments, audits, incident response, continuous monitoring, hardening and forensics readiness
org.opencontainers.image.vendor=ProwlerPro, Inc.
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
org.opencontainers.image.version=${{ needs.setup.outputs.prowler_version }}
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
@@ -219,14 +232,14 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
registry-1.docker.io:443
api.ecr-public.us-east-1.amazonaws.com:443
auth.docker.io:443
public.ecr.aws:443
github.com:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
github.com:443
public.ecr.aws:443
registry-1.docker.io:443
release-assets.githubusercontent.com:443
api.ecr-public.us-east-1.amazonaws.com:443
sts.amazonaws.com:443
sts.us-east-1.amazonaws.com:443
@@ -252,10 +265,10 @@ jobs:
if: github.event_name == 'push'
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG} \
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}" \
-t "${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64"
env:
NEEDS_SETUP_OUTPUTS_LATEST_TAG: ${{ needs.setup.outputs.latest_tag }}
@@ -263,12 +276,12 @@ jobs:
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG} \
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION}" \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG}" \
-t "${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION}" \
-t "${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64"
env:
NEEDS_SETUP_OUTPUTS_PROWLER_VERSION: ${{ needs.setup.outputs.prowler_version }}
NEEDS_SETUP_OUTPUTS_STABLE_TAG: ${{ needs.setup.outputs.stable_tag }}
@@ -293,7 +306,7 @@ jobs:
if: needs.setup.outputs.latest_tag == 'latest' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
run: |
docker buildx imagetools create \
-t ${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
-t "${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION}" \
-t ${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:stable \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:stable
env:
@@ -343,9 +356,9 @@ jobs:
id: outcome
run: |
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
echo "outcome=success" >> $GITHUB_OUTPUT
echo "outcome=success" >> "$GITHUB_OUTPUT"
else
echo "outcome=failure" >> $GITHUB_OUTPUT
echo "outcome=failure" >> "$GITHUB_OUTPUT"
fi
env:
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
+20 -2
View File
@@ -10,6 +10,7 @@ on:
- 'Dockerfile*'
- 'pyproject.toml'
- 'uv.lock'
- '.trivyignore.yaml'
- '.github/workflows/sdk-container-checks.yml'
pull_request:
branches:
@@ -83,6 +84,10 @@ jobs:
api.github.com:443
mirror.gcr.io:443
check.trivy.dev:443
raw.githubusercontent.com:443
objects.githubusercontent.com:443
grype.anchore.io:443
get.anchore.io:443
debian.map.fastlydns.net:80
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
@@ -112,7 +117,12 @@ jobs:
Dockerfile*
pyproject.toml
uv.lock
.trivyignore.yaml
.github/workflows/sdk-container-checks.yml
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
.grype.yaml
.github/scripts/grype-pr-comment.js
files_ignore: |
prowler/CHANGELOG.md
prowler/changelog.d/**
@@ -139,5 +149,13 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'true'
severity: 'CRITICAL'
fail-on-severity: 'high'
severity: 'CRITICAL,HIGH'
- name: Scan SDK container with Grype
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/grype-scan
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
+196
View File
@@ -0,0 +1,196 @@
name: 'SDK: Package Checks'
# Rehearses the PyPI release on every packaging change and once a week, from the
# consumer's side. Two incidents this guards against:
#
# - 5.38.0 shipped an unsatisfiable Requires-Dist (cryptography==50.0.0 while
# alibabacloud-tea-openapi and pyopenssl cap it below 49). A [tool.uv] override hid
# the conflict inside the repo; pip could not install the wheel and silently
# resolved `pip install prowler` to 5.37.1 for a week.
# - 5.39.0 never published: an unpinned build backend started emitting core metadata
# 2.5 and the twine bundled in the publish action rejected it.
#
# Both were only detectable at release time because nothing built and installed the
# artifact earlier. The weekly run also catches releases yanked from PyPI after we
# pinned them (zstd 1.5.7.3, "buggy - not thread safe", sat in uv.lock for months).
on:
push:
branches:
- 'master'
- 'v5.*'
pull_request:
branches:
- 'master'
- 'v5.*'
schedule:
# Monday 06:00 UTC. Yanks and upstream releases happen without a commit here.
- cron: '0 6 * * 1'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
env:
# Must equal the twine bundled in the pypa/gh-action-pypi-publish pin used by
# sdk-pypi-release.yml (requirements/runtime.txt in that repo at the pinned tag).
# A metadata check that passes here must pass there.
TWINE_VERSION: '7.0.0'
jobs:
changes:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
# Scheduled and manual runs always execute; pushes and PRs only when a packaging
# input changed. Jobs skipped this way still report success to branch protection.
run: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || steps.filter.outputs.any_changed == 'true' }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
- name: Checkout repository
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# zizmor: ignore[artipacked]
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
- name: Detect packaging changes
if: github.event_name == 'push' || github.event_name == 'pull_request'
id: filter
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: |
pyproject.toml
uv.lock
README.md
util/replicate_pypi_package.py
util/check_yanked_pins.py
api/pyproject.toml
api/uv.lock
mcp_server/pyproject.toml
mcp_server/uv.lock
.github/workflows/sdk-package-checks.yml
.github/workflows/sdk-pypi-release.yml
.github/actions/setup-python-uv/**
install-from-wheel:
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
strategy:
fail-fast: false
matrix:
python-version:
- '3.10'
- '3.11'
- '3.12'
- '3.13'
package:
- 'prowler'
include:
# prowler-cloud is the same tree renamed by util/replicate_pypi_package.py;
# one Python is enough to prove the rename and its build still work.
- python-version: '3.12'
package: 'prowler-cloud'
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Python with uv
uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ matrix.python-version }}
install-dependencies: 'false'
- name: Rename package to prowler-cloud
if: matrix.package == 'prowler-cloud'
run: |
pip install --no-cache-dir toml
python util/replicate_pypi_package.py
- name: Build sdist and wheel
run: uv build
- name: Check metadata with the release workflow's twine
run: uvx --from "twine==${TWINE_VERSION}" twine check --strict dist/*
- name: Install the wheel with pip into a clean virtualenv
# Plain pip, --isolated, from outside the repo: consumers never see [tool.uv]
# override-dependencies or constraint-dependencies, so neither does this step.
run: |
python -m venv "${RUNNER_TEMP}/consumer"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir "${GITHUB_WORKSPACE}"/dist/*.whl
- name: Smoke test the installed CLI
run: |
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/prowler" --version
# Loads every AWS check module from the installed wheel: catches files missing
# from the package. grep fails the step if the summary line never appears.
"${RUNNER_TEMP}/consumer/bin/prowler" aws --list-checks | grep 'available checks'
pinned-releases-not-yanked:
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: '3.12'
- name: Check every pinned and locked release against PyPI
run: python util/check_yanked_pins.py . api mcp_server
+22 -2
View File
@@ -84,8 +84,18 @@ jobs:
- name: Build Prowler package
run: uv build
- name: Verify the wheel installs with pip
# Same check as "SDK: Package Checks", repeated on the exact artifact about to be
# published. Plain pip, --isolated, from outside the repo: an unsatisfiable
# Requires-Dist fails here instead of on users' machines (5.38.0 shipped one).
run: |
python -m venv "${RUNNER_TEMP}/consumer"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl
- name: Publish Prowler package to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
print-hash: true
@@ -128,7 +138,17 @@ jobs:
- name: Build prowler-cloud package
run: uv build
- name: Verify the wheel installs with pip
# Same check as "SDK: Package Checks", repeated on the exact artifact about to be
# published. Plain pip, --isolated, from outside the repo: an unsatisfiable
# Requires-Dist fails here instead of on users' machines (5.38.0 shipped one).
run: |
python -m venv "${RUNNER_TEMP}/consumer"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl
- name: Publish prowler-cloud package to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
print-hash: true
+2 -1
View File
@@ -216,7 +216,8 @@ jobs:
elif [ -z "${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}" ]; then
echo "No AWS service paths detected; skipping AWS tests."
else
uv run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml ${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}
read -ra service_paths <<< "${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}"
uv run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml "${service_paths[@]}"
fi
env:
STEPS_AWS_SERVICES_OUTPUTS_RUN_ALL: ${{ steps.aws-services.outputs.run_all }}
+20 -19
View File
@@ -84,7 +84,8 @@ jobs:
echo "Changed files:"
echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n'
echo ""
python .github/scripts/test-impact.py ${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}
read -ra changed <<< "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}"
python .github/scripts/test-impact.py "${changed[@]}"
env:
STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
@@ -92,21 +93,21 @@ jobs:
id: set-flags
run: |
if [[ -n "${STEPS_IMPACT_OUTPUTS_SDK_TESTS}" ]]; then
echo "has-sdk-tests=true" >> $GITHUB_OUTPUT
echo "has-sdk-tests=true" >> "$GITHUB_OUTPUT"
else
echo "has-sdk-tests=false" >> $GITHUB_OUTPUT
echo "has-sdk-tests=false" >> "$GITHUB_OUTPUT"
fi
if [[ -n "${STEPS_IMPACT_OUTPUTS_API_TESTS}" ]]; then
echo "has-api-tests=true" >> $GITHUB_OUTPUT
echo "has-api-tests=true" >> "$GITHUB_OUTPUT"
else
echo "has-api-tests=false" >> $GITHUB_OUTPUT
echo "has-api-tests=false" >> "$GITHUB_OUTPUT"
fi
if [[ -n "${STEPS_IMPACT_OUTPUTS_UI_E2E}" ]]; then
echo "has-ui-e2e=true" >> $GITHUB_OUTPUT
echo "has-ui-e2e=true" >> "$GITHUB_OUTPUT"
else
echo "has-ui-e2e=false" >> $GITHUB_OUTPUT
echo "has-ui-e2e=false" >> "$GITHUB_OUTPUT"
fi
env:
STEPS_IMPACT_OUTPUTS_SDK_TESTS: ${{ steps.impact.outputs.sdk-tests }}
@@ -115,22 +116,22 @@ jobs:
- name: Summary
run: |
echo "## Test Impact Analysis" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Test Impact Analysis" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [[ "${STEPS_IMPACT_OUTPUTS_RUN_ALL}" == "true" ]]; then
echo "🚨 **Critical path changed - running ALL tests**" >> $GITHUB_STEP_SUMMARY
echo "🚨 **Critical path changed - running ALL tests**" >> "$GITHUB_STEP_SUMMARY"
else
echo "### Affected Modules" >> $GITHUB_STEP_SUMMARY
echo "\`${STEPS_IMPACT_OUTPUTS_MODULES}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Affected Modules" >> "$GITHUB_STEP_SUMMARY"
echo "\`${STEPS_IMPACT_OUTPUTS_MODULES}\`" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### Tests to Run" >> $GITHUB_STEP_SUMMARY
echo "| Category | Paths |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| SDK Tests | \`${STEPS_IMPACT_OUTPUTS_SDK_TESTS:-none}\` |" >> $GITHUB_STEP_SUMMARY
echo "| API Tests | \`${STEPS_IMPACT_OUTPUTS_API_TESTS:-none}\` |" >> $GITHUB_STEP_SUMMARY
echo "| UI E2E | \`${STEPS_IMPACT_OUTPUTS_UI_E2E:-none}\` |" >> $GITHUB_STEP_SUMMARY
echo "### Tests to Run" >> "$GITHUB_STEP_SUMMARY"
echo "| Category | Paths |" >> "$GITHUB_STEP_SUMMARY"
echo "|----------|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| SDK Tests | \`${STEPS_IMPACT_OUTPUTS_SDK_TESTS:-none}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| API Tests | \`${STEPS_IMPACT_OUTPUTS_API_TESTS:-none}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| UI E2E | \`${STEPS_IMPACT_OUTPUTS_UI_E2E:-none}\` |" >> "$GITHUB_STEP_SUMMARY"
fi
env:
+32 -18
View File
@@ -41,17 +41,20 @@ jobs:
timeout-minutes: 5
outputs:
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
created: ${{ steps.set-short-sha.outputs.created }}
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
egress-policy: block
- name: Calculate short SHA
id: set-short-sha
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
run: |
echo "short-sha=${GITHUB_SHA::7}" >> "${GITHUB_OUTPUT}"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
notify-release-started:
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
@@ -111,15 +114,15 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
registry-1.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
auth.docker.io:443
registry.npmjs.org:443
dl-cdn.alpinelinux.org:443
fonts.googleapis.com:443
fonts.gstatic.com:443
github.com:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
registry-1.docker.io:443
registry.npmjs.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -144,9 +147,20 @@ jobs:
build-args: |
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('v{0}', env.RELEASE_TAG) || needs.setup.outputs.short-sha }}
push: true
sbom: true
# max, not the default min: min records little beyond the build ref.
provenance: mode=max
platforms: ${{ matrix.platform }}
tags: |
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }}
labels: |
org.opencontainers.image.title=Prowler Local Server UI
org.opencontainers.image.description=Web UI for Prowler Local Server (Next.js)
org.opencontainers.image.vendor=ProwlerPro, Inc.
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
@@ -164,12 +178,12 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
github.com:443
release-assets.githubusercontent.com:443
registry-1.docker.io:443
auth.docker.io:443
github.com:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
registry-1.docker.io:443
release-assets.githubusercontent.com:443
- name: Login to DockerHub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
@@ -182,9 +196,9 @@ jobs:
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -192,10 +206,10 @@ jobs:
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG} \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG}" \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -235,9 +249,9 @@ jobs:
id: outcome
run: |
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
echo "outcome=success" >> $GITHUB_OUTPUT
echo "outcome=success" >> "$GITHUB_OUTPUT"
else
echo "outcome=failure" >> $GITHUB_OUTPUT
echo "outcome=failure" >> "$GITHUB_OUTPUT"
fi
env:
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
+20 -3
View File
@@ -88,6 +88,9 @@ jobs:
get.trivy.dev:443
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
raw.githubusercontent.com:443
grype.anchore.io:443
get.anchore.io:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -99,7 +102,13 @@ jobs:
id: check-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: ui/**
files: |
ui/**
.trivyignore.yaml
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
.grype.yaml
.github/scripts/grype-pr-comment.js
files_ignore: |
ui/CHANGELOG.md
ui/changelog.d/**
@@ -130,5 +139,13 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'true'
severity: 'CRITICAL'
fail-on-severity: 'high'
severity: 'CRITICAL,HIGH'
- name: Scan UI container with Grype
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/grype-scan
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
+138 -15
View File
@@ -36,6 +36,7 @@ jobs:
needs: impact-analysis
if: |
github.repository == 'prowler-cloud/prowler' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) &&
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
runs-on: ubuntu-latest
env:
@@ -107,17 +108,115 @@ jobs:
- name: Show test scope
run: |
echo "## E2E Test Scope" >> $GITHUB_STEP_SUMMARY
echo "## E2E Test Scope" >> "$GITHUB_STEP_SUMMARY"
if [[ "${RUN_ALL_TESTS}" == "true" ]]; then
echo "Running **ALL** E2E tests (critical path changed)" >> $GITHUB_STEP_SUMMARY
echo "Running **ALL** E2E tests (critical path changed)" >> "$GITHUB_STEP_SUMMARY"
else
echo "Running tests matching: \`${E2E_TEST_PATHS}\`" >> $GITHUB_STEP_SUMMARY
echo "Running tests matching: \`${E2E_TEST_PATHS}\`" >> "$GITHUB_STEP_SUMMARY"
fi
echo ""
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> $GITHUB_STEP_SUMMARY
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> "$GITHUB_STEP_SUMMARY"
env:
NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES: ${{ needs.impact-analysis.outputs.modules }}
- name: Validate E2E prerequisites
shell: bash
run: |
declare -A required=()
suite_selected() {
[[ "${RUN_ALL_TESTS}" == "true" ]] ||
[[ " ${E2E_TEST_PATHS} " == *"ui/tests/$1/"* ]]
}
require_vars() {
local variable
for variable in "$@"; do
required["${variable}"]=1
done
}
if suite_selected auth || suite_selected providers ||
suite_selected invitations || suite_selected scans ||
suite_selected navigation; then
require_vars E2E_ADMIN_USER E2E_ADMIN_PASSWORD
fi
if suite_selected sign-up; then
require_vars E2E_NEW_USER_PASSWORD
fi
if suite_selected invitations; then
require_vars E2E_NEW_USER_PASSWORD E2E_ORGANIZATION_ID
fi
if suite_selected scans; then
require_vars \
E2E_AWS_PROVIDER_ACCOUNT_ID \
E2E_AWS_PROVIDER_ACCESS_KEY \
E2E_AWS_PROVIDER_SECRET_KEY
fi
if suite_selected providers; then
require_vars \
E2E_AWS_PROVIDER_ACCOUNT_ID \
E2E_AWS_PROVIDER_ACCESS_KEY \
E2E_AWS_PROVIDER_SECRET_KEY \
E2E_AWS_PROVIDER_ROLE_ARN \
E2E_AZURE_SUBSCRIPTION_ID \
E2E_AZURE_CLIENT_ID \
E2E_AZURE_SECRET_ID \
E2E_AZURE_TENANT_ID \
E2E_M365_DOMAIN_ID \
E2E_M365_CLIENT_ID \
E2E_M365_SECRET_ID \
E2E_M365_TENANT_ID \
E2E_M365_CERTIFICATE_CONTENT \
E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY \
E2E_GCP_PROJECT_ID \
E2E_GITHUB_APP_ID \
E2E_GITHUB_BASE64_APP_PRIVATE_KEY \
E2E_GITHUB_USERNAME \
E2E_GITHUB_PERSONAL_ACCESS_TOKEN \
E2E_GITHUB_ORGANIZATION \
E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN \
E2E_OCI_TENANCY_ID \
E2E_OCI_USER_ID \
E2E_OCI_FINGERPRINT \
E2E_OCI_KEY_CONTENT \
E2E_ALIBABACLOUD_ACCOUNT_ID \
E2E_ALIBABACLOUD_ACCESS_KEY_ID \
E2E_ALIBABACLOUD_ACCESS_KEY_SECRET \
E2E_ALIBABACLOUD_ROLE_ARN \
E2E_OKTA_DOMAIN \
E2E_OKTA_CLIENT_ID \
E2E_OKTA_BASE64_PRIVATE_KEY \
E2E_GOOGLEWORKSPACE_CUSTOMER_ID \
E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON \
E2E_GOOGLEWORKSPACE_DELEGATED_USER \
E2E_VERCEL_TEAM_ID \
E2E_VERCEL_API_TOKEN
fi
missing=()
if (( ${#required[@]} > 0 )); then
while IFS= read -r variable; do
[[ -z "${!variable:-}" ]] && missing+=("${variable}")
done < <(printf '%s\n' "${!required[@]}" | sort)
fi
if (( ${#missing[@]} > 0 )); then
echo "Missing required E2E variables:"
printf ' - %s\n' "${missing[@]}"
{
echo "## Missing E2E prerequisites"
printf -- "- \`%s\`\n" "${missing[@]}"
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
echo "E2E prerequisite preflight passed."
- name: Create k8s Kind Cluster
uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1
with:
@@ -134,7 +233,7 @@ jobs:
yq -i '.services.worker.networks = ["kind","default"]' docker-compose.yml
- name: Fix API data directory permissions
run: docker run --rm -v $(pwd)/_data/api:/data alpine chown -R 1000:1000 /data
run: docker run --rm -v "$(pwd)/_data/api:/data" alpine chown -R 1000:1000 /data
- name: Add AWS credentials for testing
run: |
@@ -168,7 +267,7 @@ jobs:
timeout=150
elapsed=0
while [ $elapsed -lt $timeout ]; do
if curl -s ${UI_API_BASE_URL}/docs >/dev/null 2>&1; then
if curl -s "${UI_API_BASE_URL}/docs" >/dev/null 2>&1; then
echo "Prowler API is ready!"
exit 0
fi
@@ -202,7 +301,7 @@ jobs:
run_install: false
- name: Get pnpm store directory
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV"
- name: Setup pnpm and Next.js cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -288,7 +387,8 @@ jobs:
fi
TEST_PATHS=$(echo "$VALID_PATHS" | tr '\n' ' ')
echo "Resolved test paths: $TEST_PATHS"
pnpm exec playwright test $TEST_PATHS
read -ra test_paths <<< "$TEST_PATHS"
pnpm exec playwright test "${test_paths[@]}"
fi
- name: Upload test reports
@@ -304,6 +404,29 @@ jobs:
run: |
docker compose down -v || true
# Fork pull requests cannot access the secrets required by the E2E suites.
fork-e2e-unavailable:
needs: impact-analysis
if: |
github.repository == 'prowler-cloud/prowler' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == true &&
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Report unavailable E2E tests
run: |
echo "## E2E Tests Skipped" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "UI E2E tests require repository secrets and cannot run for fork pull requests." >> "$GITHUB_STEP_SUMMARY"
# Skip job - provides clear feedback when no E2E tests needed
skip-e2e:
needs: impact-analysis
@@ -322,12 +445,12 @@ jobs:
- name: No E2E tests needed
run: |
echo "## E2E Tests Skipped" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "No UI E2E tests needed for this change." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "To run all tests, modify a file in a critical path (e.g., \`ui/lib/**\`)." >> $GITHUB_STEP_SUMMARY
echo "## E2E Tests Skipped" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "No UI E2E tests needed for this change." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "To run all tests, modify a file in a critical path (e.g., \`ui/lib/**\`)." >> "$GITHUB_STEP_SUMMARY"
env:
NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES: ${{ needs.impact-analysis.outputs.modules }}
+5 -4
View File
@@ -113,7 +113,7 @@ jobs:
- name: Get pnpm store directory
if: steps.check-changes.outputs.any_changed == 'true'
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV"
- name: Setup pnpm and Next.js cache
if: steps.check-changes.outputs.any_changed == 'true'
@@ -157,7 +157,8 @@ jobs:
echo "${STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES}"
# Convert space-separated to vitest related format (remove ui/ prefix for relative paths)
CHANGED_FILES=$(echo "${STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | sed 's|^ui/||' | tr '\n' ' ')
pnpm exec vitest related $CHANGED_FILES --run --project unit
read -ra changed <<< "$CHANGED_FILES"
pnpm exec vitest related "${changed[@]}" --run --project unit
env:
STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-source.outputs.all_changed_files }}
@@ -181,9 +182,9 @@ jobs:
if: steps.check-changes.outputs.any_changed == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install chromium
- name: Run browser tests
- name: Run integration tests
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run test:browser
run: pnpm run test:integration
- name: Build application
if: steps.check-changes.outputs.any_changed == 'true'
+2
View File
@@ -173,3 +173,5 @@ GEMINI.md
# Docker
docker-compose.override.yml
docker-compose-dev.override.yml
# Local Pi runtime state
.atl/
+83
View File
@@ -0,0 +1,83 @@
# Findings excluded from the Grype gate, each with a reason.
# Anything not listed here blocks the pull request at critical or high severity.
# Pairs are explicit: a new CVE against an already-listed package still blocks.
#
# Every entry below has a published fix we cannot take. Findings with no fix at all are
# not listed: the scan runs with only-fixed, so they never reach the gate.
ignore:
# Modules compiled into the Trivy binary we ship.
# Only a Trivy rebuild by its vendor can change these; the version is pinned in our Dockerfile.
# CVE-2026-71556 is the same temporary exception documented in .trivyignore.yaml:
# Trivy 0.73.0 still embeds go-git 5.19.1, while the 5.19.2 fix is merged only on
# Trivy main. Remove this entry with the Trivy exception by 2026-09-15.
# https://github.com/aquasecurity/trivy/blob/v0.73.0/go.mod#L46
# https://github.com/aquasecurity/trivy/commit/a2edba9a03987ba0d2ebc8212c1a9a1e6979497b
- vulnerability: CVE-2026-71556
package:
name: github.com/go-git/go-git/v5
- vulnerability: CVE-2026-56852
package:
name: golang.org/x/text
- vulnerability: GHSA-hrxh-6v49-42gf
package:
name: google.golang.org/grpc
- vulnerability: CVE-2026-50151
package:
name: oras.land/oras-go/v2
# Shipped inside the PowerShell tarball, in its bundled MicrosoftTeams module.
# Not a dependency we declare, and not one we can upgrade independently.
- vulnerability: CVE-2026-26127
package:
name: Microsoft.Bcl.Memory
# The .NET runtime bundled inside the PowerShell tarball the Dockerfile pins.
# CVE-2026-62901 is the same temporary exception documented in .trivyignore.yaml:
# fixed in .NET 9.0.19 / 10.0.11 (2026-08-11), but no published PowerShell release
# ships a patched runtime yet (7.5.9 bundles 9.0.18; 7.6.4 bundles 10.0.x < 10.0.11).
# pwsh runs only local M365 module cmdlets; nothing listens for inbound WebSocket
# connections. Remove with the Trivy exception by 2026-09-15.
- vulnerability: CVE-2026-62901
package:
name: Microsoft.NETCore.App.Runtime.linux-x64
- vulnerability: CVE-2026-62901
package:
name: Microsoft.NETCore.App.Runtime.linux-arm64
# The CPython interpreter, compiled into the official base image.
# TEMPORARY, unlike the entries above: moving to Python 3.13 clears seven of these, and
# that is a runtime upgrade pending its own evaluation. The remaining three need 3.15 and
# are unfixable either way -- the MCP image already runs 3.13.14 and still reports them.
- vulnerability: CVE-2026-11940
package:
name: python
- vulnerability: CVE-2026-11972
package:
name: python
- vulnerability: CVE-2026-15308
package:
name: python
- vulnerability: CVE-2026-3298
package:
name: python
- vulnerability: CVE-2026-3644
package:
name: python
- vulnerability: CVE-2026-4224
package:
name: python
- vulnerability: CVE-2026-4786
package:
name: python
- vulnerability: CVE-2026-6100
package:
name: python
- vulnerability: CVE-2026-7210
package:
name: python
- vulnerability: CVE-2026-9669
package:
name: python
+8
View File
@@ -47,6 +47,14 @@ repos:
priority: 20
## GITHUB ACTIONS
- repo: https://github.com/rhysd/actionlint
rev: v1.7.12
hooks:
- id: actionlint
# SC2129 only suggests grouping consecutive redirects; not worth restructuring for.
args: ['-shellcheck=-e SC2129']
priority: 30
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.24.1
hooks:
-123
View File
@@ -1,123 +0,0 @@
# Trivy ignore file for prowlercloud/prowler SDK container image.
# Each entry below documents (a) the affected package and why it ships in the
# image, (b) why the CVE is not exploitable in Prowler's runtime, and (c) the
# upstream fix status. Entries carry an expiry so they auto-force re-review.
# Entries are scoped per-package so suppressions cannot drift onto unrelated
# packages that may be assigned the same CVE in the future.
#
# Scanned by: .github/actions/trivy-scan via .github/workflows/sdk-container-checks.yml
# CVE-2026-42496 — perl-archive-tar path traversal via crafted symlinks.
# CVE-2026-8376 — perl heap buffer overflow when compiling regex.
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be
# removed without breaking dpkg. The Prowler SDK does not invoke perl at runtime;
# neither vulnerable code path (Archive::Tar parsing or regex compilation of
# attacker-controlled input) is reachable from Prowler. No Debian bookworm fix
# is available yet.
CVE-2026-42496 pkg:perl exp:2026-08-15
CVE-2026-42496 pkg:perl-base exp:2026-08-15
CVE-2026-42496 pkg:perl-modules-5.36 exp:2026-08-15
CVE-2026-42496 pkg:libperl5.36 exp:2026-08-15
CVE-2026-8376 pkg:perl exp:2026-08-15
CVE-2026-8376 pkg:perl-base exp:2026-08-15
CVE-2026-8376 pkg:perl-modules-5.36 exp:2026-08-15
CVE-2026-8376 pkg:libperl5.36 exp:2026-08-15
# CVE-2026-13221 - Perl regex trie overflow.
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
# Why ignored: upstream confirms Perl 5.36.0 is not affected; the regression
# was introduced after this version. Debian currently marks bookworm as
# vulnerable, which causes Trivy to report a false positive.
# Ref: https://github.com/Perl/perl5/issues/23388
CVE-2026-13221 pkg:perl exp:2026-08-15
CVE-2026-13221 pkg:perl-base exp:2026-08-15
CVE-2026-13221 pkg:perl-modules-5.36 exp:2026-08-15
CVE-2026-13221 pkg:libperl5.36 exp:2026-08-15
# CVE-2026-57433 — Perl Storable signed integer overflow when deserializing a
# crafted SX_HOOK record (retrieve_hook_common passes a wrapped negative count
# to av_extend).
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be
# removed without breaking dpkg. Prowler does not invoke perl at runtime and
# never calls Storable's thaw/retrieve on attacker-controlled blobs, so the
# vulnerable deserialization path is unreachable. Fixed upstream in
# Storable 3.41; no Debian bookworm fix is available yet.
CVE-2026-57433 pkg:perl exp:2026-08-15
CVE-2026-57433 pkg:perl-base exp:2026-08-15
CVE-2026-57433 pkg:perl-modules-5.36 exp:2026-08-15
CVE-2026-57433 pkg:libperl5.36 exp:2026-08-15
# CVE-2025-7458 — SQLite integer overflow.
# Package: libsqlite3-0.
# Why ignored: transitive dependency of CPython's stdlib sqlite3 module. The
# Prowler SDK does not open user-supplied SQLite databases; SQLite usage is
# internal and bounded. No Debian bookworm fix is available.
CVE-2025-7458 pkg:libsqlite3-0 exp:2026-08-15
# CVE-2026-43185 — Linux kernel ksmbd signedness bug.
# Package: linux-libc-dev.
# Why ignored: linux-libc-dev ships kernel headers for build-time compilation,
# not a running kernel. Containers execute against the host kernel, so these
# headers are inert at runtime. The upstream fix landed in kernel 7.0-rc2 and
# has not been backported to Debian's 6.1 LTS line.
CVE-2026-43185 pkg:linux-libc-dev exp:2026-08-15
# CVE-2023-45853 — zlib MiniZip integer overflow / heap overflow in
# zipOpenNewFileInZip4_64.
# Packages: zlib1g, zlib1g-dev.
# Why ignored: Debian Security Tracker status for bookworm is <ignored>, with
# the published rationale "contrib/minizip not built and src:zlib not producing
# binary packages" — i.e. the vulnerable symbol is not present in the libz.so
# shipped by Debian. Real-not-affected, not unpatched. Upstream fix is in
# zlib 1.3.1, available in Debian trixie (13); migrating the base image would
# clear it fully.
# Ref: https://security-tracker.debian.org/tracker/CVE-2023-45853
CVE-2023-45853 pkg:zlib1g exp:2026-08-15
CVE-2023-45853 pkg:zlib1g-dev exp:2026-08-15
# CVE-2026-55200 — libssh2 out-of-bounds write in ssh2_transport_read() due to
# an unchecked packet_length field in transport.c (heap corruption, possible RCE).
# Package: libssh2-1.
# Why ignored: libssh2-1 is pulled in only as a transitive dependency of libcurl4
# (installed in the SDK Dockerfile for the networking/PowerShell stack). The
# vulnerable path is reached exclusively when libssh2 acts as an SSH/SCP/SFTP
# client parsing transport packets from a server. Prowler never uses libcurl's
# SSH/SCP/SFTP transports; it talks to cloud provider HTTPS endpoints only, so the
# affected code is unreachable at runtime. Fixed upstream in libssh2 commit
# 97acf3df (PR #2052); no Debian bookworm fix is available yet.
# Ref: https://security-tracker.debian.org/tracker/CVE-2026-55200
CVE-2026-55200 pkg:libssh2-1 exp:2026-08-15
# --- API container image (api/Dockerfile) ---
# The entries below are specific to the Prowler API image, which ships
# PowerShell and additional build tooling on top of the same bookworm base.
# CVE-2026-7210 — CPython/Expat hash-flooding denial of service in
# `xml.parsers.expat` and `xml.etree.ElementTree`.
# Packages: the Debian system Python 3.11 (python3.11*, libpython3.11*).
# Why ignored: the API runs under the Python 3.12 interpreter shipped in its
# `.venv`; the system `python3.11` is only present because `python3-dev` is
# pulled in to compile native extensions (xmlsec, lxml) and is never executed
# at runtime. The vulnerable path requires parsing attacker-controlled XML with
# the affected interpreter, which Prowler does not do with the system Python.
# Full mitigation also needs libexpat >= 2.8.0; no Debian bookworm fix yet.
CVE-2026-7210 pkg:python3.11 exp:2026-08-15
CVE-2026-7210 pkg:python3.11-dev exp:2026-08-15
CVE-2026-7210 pkg:python3.11-minimal exp:2026-08-15
CVE-2026-7210 pkg:libpython3.11 exp:2026-08-15
CVE-2026-7210 pkg:libpython3.11-dev exp:2026-08-15
CVE-2026-7210 pkg:libpython3.11-minimal exp:2026-08-15
CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-08-15
# CVE-2026-33278 — Unbound DNSSEC validator use-after-free (DoS, possible RCE).
# CVE-2026-42960 — Unbound DNS cache poisoning via promiscuous additional records.
# Package: libunbound8.
# Why ignored: libunbound8 is a transitive apt dependency of the TLS/networking
# stack (GnuTLS DANE support); only the shared library ships in the image. Both
# vulnerabilities require operating a live Unbound recursive DNSSEC validator
# that processes attacker-influenced DNS responses. Prowler never starts an
# Unbound resolver, so neither code path is reachable. No Debian bookworm fix yet.
CVE-2026-33278 pkg:libunbound8 exp:2026-08-15
CVE-2026-42960 pkg:libunbound8 exp:2026-08-15
+166
View File
@@ -0,0 +1,166 @@
# Trivy suppressions for the prowlercloud/prowler SDK and API container images.
#
# This file replaces the classic .trivyignore, which parsed only the CVE id: the
# `pkg:` selector written on each line was documentation and the entry suppressed
# its CVE across every package in the image. The `purls` field below is honoured,
# so each entry is scoped to the package it names. Verified against Trivy 0.71.2:
# an entry given the wrong purl leaves the finding reported, where the classic
# format suppressed it.
#
# `expired_at` forces re-review. Keep the dates staggered.
#
# The four entries below are currently redundant: the scan runs with ignore-unfixed,
# and none of them has a published fix, so they never reach the gate either way. They
# are kept because the reasoning is what justifies accepting them, and because they
# apply again the moment any of them gains a fix we do not take.
#
# perl-base is Debian "Essential: yes". Trivy spreads src:perl CVEs across every
# binary package built from that source, so perl-base is flagged for modules only
# perl-modules-* ships. Neither image installs those, and nothing in either
# invokes perl.
#
# Why these four are accepted rather than fixed (reviewed 2026-07-31):
#
# 1. No fix exists. All four report no fixed version on perl-base 5.40.1-6.
# Debian marks CVE-2026-42496 "fix_deferred" and the other three "affected".
# A newer base image, apt upgrade, or a newer Debian release changes nothing.
# 2. The package cannot be removed. "Essential: yes" means removal needs
# dpkg --force-remove-essential, which breaks apt for anything built
# downstream from these images.
# 3. Changing base distribution was evaluated and rejected. Alpine drops perl
# entirely, but PowerShell publishes no linux-musl-arm64 build in any
# release, so M365 scanning would break on arm64 -- which is what we run in
# production. Wolfi keeps glibc and drops perl, but pinnable versioned tags
# are a paid tier, so builds would not be reproducibly pinnable.
#
# Not-invoked claim verified by sweeping both images for files with a perl
# shebang, shell/python callers of perl, ELF binaries containing "perl", and
# .pl/.pm files or perl subprocess calls anywhere in site-packages. The only
# consumers found are dpkg/debconf/adduser/pam tooling, none of which runs at
# runtime, plus one build-time script inside the ExchangeOnlineManagement
# PowerShell module that is never invoked.
vulnerabilities:
# Archive::Tar path traversal. Not installed: `perl -MArchive::Tar -e1` cannot locate it.
- id: CVE-2026-42496
purls:
- "pkg:deb/debian/perl-base"
expired_at: 2027-01-31
# Storable integer overflow. Not installed: `perl -MStorable -e1` cannot locate it.
- id: CVE-2026-57433
purls:
- "pkg:deb/debian/perl-base"
expired_at: 2027-01-31
# Regex heap overflow on 32-bit builds only; both published arches are 64-bit.
- id: CVE-2026-8376
purls:
- "pkg:deb/debian/perl-base"
expired_at: 2027-01-31
# Regex trie bug giving silently wrong matches above 65535 alternation branches.
# perl 5.40.1 is in range, so this rests on nothing invoking perl. Short expiry
# to force a re-look. Ref: https://github.com/Perl/perl5/issues/23388
- id: CVE-2026-13221
purls:
- "pkg:deb/debian/perl-base"
expired_at: 2026-11-30
# Declared in the SPDX manifest that ships inside PowerShell's MicrosoftTeams module
# (Modules/MicrosoftTeams/7.9.0/_manifest/spdx_2.2/manifest.spdx.json). Trivy reads that
# SBOM and reports what it declares, which is not the same as what the image contains:
# there is no Node runtime and no node_modules anywhere in the image, and the .NET
# assemblies target net472, a Windows-only framework. Nothing here is reachable, and none
# of it is a dependency we declare -- only Microsoft can change the module's contents.
- id: CVE-2020-0606
purls:
- "pkg:nuget/Microsoft.WindowsDesktop.App.Ref"
expired_at: 2027-01-31
- id: CVE-2019-0820
purls:
- "pkg:nuget/System.Text.RegularExpressions"
expired_at: 2027-01-31
- id: CVE-2026-47302
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
- id: CVE-2026-47304
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
- id: CVE-2026-50525
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
- id: CVE-2026-50527
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
- id: CVE-2026-50648
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
- id: CVE-2026-13676
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-16221
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-18446
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-69192
purls:
- "pkg:npm/ip-address"
expired_at: 2027-01-31
# CVE-2026-62901 is a DoS in System.Net.WebSockets (unchecked input for loop condition,
# CWE-606), fixed in .NET 9.0.19 / 10.0.11 (published 2026-08-11). The vulnerable runtime
# ships inside the PowerShell tarball the Dockerfile pins: 7.5.9 is the latest 7.5.x and
# bundles .NET 9.0.18; 7.6.4 bundles .NET 10.0.x < 10.0.11, so no published PowerShell
# release contains the fix yet. Prowler only invokes pwsh locally to run M365 module
# cmdlets; the image does not accept inbound WebSocket connections, so the DoS path is
# not reachable from the network. Remove this temporary suppression as soon as a
# PowerShell release shipping .NET 9.0.19+ is available.
- id: CVE-2026-62901
purls:
- "pkg:nuget/Microsoft.NETCore.App.Runtime.linux-x64"
- "pkg:nuget/Microsoft.NETCore.App.Runtime.linux-arm64"
expired_at: 2026-09-15
# Modules compiled into the Trivy binary the images ship. The binary is pinned by version
# and verified by checksum in the Dockerfile; only a rebuild by its vendor moves these.
# CVE-2026-71556 affects go-git worktree operations that can follow symlinks outside a
# cloned repository. Trivy 0.73.0, the latest published release and the version the
# images ship, still pins that vulnerable version:
# https://github.com/aquasecurity/trivy/blob/v0.73.0/go.mod#L46
# Trivy main already contains the 5.19.2 fix, but no published release includes it yet:
# https://github.com/aquasecurity/trivy/commit/a2edba9a03987ba0d2ebc8212c1a9a1e6979497b
# Prowler invokes Trivy only with `fs` on an existing local path or with `image`; it does
# not ask Trivy to clone or mutate a Git worktree, so the affected path is not reachable.
# Remove this temporary suppression as soon as a fixed Trivy release is available.
- id: CVE-2026-71556
purls:
- "pkg:golang/github.com/go-git/go-git/v5"
expired_at: 2026-09-15
- id: CVE-2026-56852
purls:
- "pkg:golang/golang.org/x/text"
expired_at: 2026-12-31
- id: GHSA-hrxh-6v49-42gf
purls:
- "pkg:golang/google.golang.org/grpc"
expired_at: 2026-12-31
- id: CVE-2026-50151
purls:
- "pkg:golang/oras.land/oras-go/v2"
expired_at: 2026-12-31
- id: CVE-2026-50163
purls:
- "pkg:golang/oras.land/oras-go/v2"
expired_at: 2026-12-31
+36 -5
View File
@@ -1,23 +1,32 @@
FROM python:3.12.13-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS build
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS build
LABEL maintainer="https://github.com/prowler-cloud/prowler"
LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
ARG POWERSHELL_VERSION=7.5.0
ARG POWERSHELL_VERSION=7.5.9
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
ENV POWERSHELL_TELEMETRY_OPTOUT=1
ARG TRIVY_VERSION=0.71.2
ARG TRIVY_VERSION=0.74.0
ENV TRIVY_VERSION=${TRIVY_VERSION}
ARG ZIZMOR_VERSION=1.24.1
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
ARG TRIVY_SHA256_AMD64=2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a
ARG TRIVY_SHA256_ARM64=b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5
ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0
ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
wget libicu72 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
wget libicu76 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
build-essential pkg-config libzstd-dev zlib1g-dev \
&& apt-get install -y --no-install-recommends --only-upgrade util-linux \
&& rm -rf /var/lib/apt/lists/*
# Install PowerShell
@@ -29,6 +38,9 @@ RUN ARCH=$(uname -m) && \
else \
echo "Unsupported architecture: $ARCH" && exit 1 ; \
fi && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$POWERSHELL_SHA256_AMD64" ; else EXPECT="$POWERSHELL_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/powershell.tar.gz" > /tmp/powershell.sha256 && \
sha256sum -c /tmp/powershell.sha256 && rm /tmp/powershell.sha256 && \
mkdir -p /opt/microsoft/powershell/7 && \
tar zxf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 && \
chmod +x /opt/microsoft/powershell/7/pwsh && \
@@ -45,6 +57,9 @@ RUN ARCH=$(uname -m) && \
echo "Unsupported architecture for Trivy: $ARCH" && exit 1 ; \
fi && \
wget --progress=dot:giga "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_${TRIVY_ARCH}.tar.gz" -O /tmp/trivy.tar.gz && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$TRIVY_SHA256_AMD64" ; else EXPECT="$TRIVY_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/trivy.tar.gz" > /tmp/trivy.sha256 && \
sha256sum -c /tmp/trivy.sha256 && rm /tmp/trivy.sha256 && \
tar zxf /tmp/trivy.tar.gz -C /tmp && \
mv /tmp/trivy /usr/local/bin/trivy && \
chmod +x /usr/local/bin/trivy && \
@@ -63,6 +78,9 @@ RUN ARCH=$(uname -m) && \
echo "Unsupported architecture for zizmor: $ARCH" && exit 1 ; \
fi && \
wget --progress=dot:giga "https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-${ZIZMOR_ARCH}.tar.gz" -O /tmp/zizmor.tar.gz && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$ZIZMOR_SHA256_AMD64" ; else EXPECT="$ZIZMOR_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/zizmor.tar.gz" > /tmp/zizmor.sha256 && \
sha256sum -c /tmp/zizmor.sha256 && rm /tmp/zizmor.sha256 && \
mkdir -p /tmp/zizmor-extract && \
tar zxf /tmp/zizmor.tar.gz -C /tmp/zizmor-extract && \
mv /tmp/zizmor-extract/zizmor /usr/local/bin/zizmor && \
@@ -89,7 +107,7 @@ ENV HOME='/home/prowler'
ENV PATH="${HOME}/.local/bin:${PATH}"
#hadolint ignore=DL3013
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir uv==0.11.14
pip install --no-cache-dir uv==0.12.0
RUN uv sync --locked --compile-bytecode && \
rm -rf ~/.cache/uv
@@ -105,6 +123,9 @@ RUN apt-get purge -y --auto-remove \
pkg-config \
libzstd-dev \
zlib1g-dev \
wget \
gnupg \
apt-transport-https \
&& rm -rf /var/lib/apt/lists/*
USER prowler
@@ -113,5 +134,15 @@ USER prowler
RUN pip uninstall dash-html-components -y && \
pip uninstall dash-core-components -y
USER root
# pip is build-only; the entrypoint runs the venv directly.
RUN rm -rf /usr/local/lib/python3.12/site-packages/pip \
/usr/local/lib/python3.12/site-packages/pip-*.dist-info \
/home/prowler/.local/lib/python3.12/site-packages/pip \
/home/prowler/.local/lib/python3.12/site-packages/pip-*.dist-info \
/usr/local/bin/pip /usr/local/bin/pip3 /usr/local/bin/pip3.12 \
/home/prowler/.local/bin/pip /home/prowler/.local/bin/pip3 /home/prowler/.local/bin/pip3.12
USER prowler
ENTRYPOINT ["/home/prowler/.venv/bin/prowler"]
+3
View File
@@ -34,6 +34,9 @@ test: ## Test with pytest
rm -rf .coverage && \
pytest -n auto -vvv -s --cov=./prowler --cov-report=xml tests
test-mcp: ## Test MCP server with pytest (mirrors CI)
cd mcp_server && uv run pytest --cov=./prowler_mcp_server --cov-report=term-missing tests
coverage: ## Show Test Coverage
coverage run --skip-covered -m pytest -v && \
coverage report -m && \
+9 -5
View File
@@ -6,7 +6,10 @@
<b><i>Prowler</b> is the Open Cloud Security Platform trusted by thousands to automate security and compliance in any cloud environment. With thousands of ready-to-use checks and compliance frameworks, Prowler delivers real-time, customizable monitoring and seamless integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.
</p>
<p align="center">
<b>Secure ANY cloud at AI Speed at <a href="https://prowler.com">prowler.com</i></b>
<b>The Agentic Cloud Defender</i></b>
</p>
<p align="center">
<a href="https://cloud.prowler.com/sign-up">Try Prowler Cloud</a>
</p>
<p align="center">
@@ -56,7 +59,7 @@ Prowler includes hundreds of built-in controls to ensure compliance with standar
## Prowler Cloud & Prowler Local Server
[Prowler Cloud](https://cloud.prowler.com/) and Prowler Local Server, its self-hosted open-source version, are web applications that simplify running Prowler across your cloud provider accounts. They provide a user-friendly interface to visualize the results and streamline your security assessments.
[Prowler Cloud](https://cloud.prowler.com/sign-up) and Prowler Local Server, its self-hosted open-source version, are web applications that simplify running Prowler across your cloud provider accounts. They provide a user-friendly interface to visualize the results and streamline your security assessments.
![Prowler Cloud](docs/images/products/overview.png)
![Risk Pipeline](docs/images/products/risk-pipeline.png)
@@ -123,12 +126,12 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/user-guide/compliance/tutorials/compliance) | [Categories](https://docs.prowler.com/user-guide/cli/tutorials/misc#categories) | Support | Interface |
|---|---|---|---|---|---|---|
| AWS | 621 | 86 | 47 | 19 | Official | UI, API, CLI |
| AWS | 639 | 86 | 47 | 19 | Official | UI, API, CLI |
| Azure | 191 | 22 | 21 | 16 | Official | UI, API, CLI |
| GCP | 109 | 20 | 19 | 12 | Official | UI, API, CLI |
| Kubernetes | 92 | 7 | 8 | 11 | Official | UI, API, CLI |
| GitHub | 24 | 3 | 2 | 5 | Official | UI, API, CLI |
| M365 | 111 | 10 | 6 | 10 | Official | UI, API, CLI |
| M365 | 143 | 10 | 6 | 10 | Official | UI, API, CLI |
| OCI | 52 | 14 | 5 | 10 | Official | UI, API, CLI |
| Alibaba Cloud | 63 | 9 | 6 | 9 | Official | UI, API, CLI |
| Cloudflare | 29 | 3 | 2 | 5 | Official | UI, API, CLI |
@@ -141,9 +144,10 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| Vercel | 26 | 6 | 1 | 8 | Official | UI, API, CLI |
| Okta | 29 | 8 | 2 | 2 | Official | UI, API, CLI |
| Linode [Contact us](https://prowler.com/contact) | 10 | 3 | 1 | 4 | Unofficial | CLI |
| Huawei Cloud [Contact us](https://prowler.com/contact) | 25 | 10 | 1 | 6 | Unofficial | CLI |
| E2E Networks [Contact us](https://prowler.com/contact) | 27 | 6 | 0 | 2 | Unofficial | CLI |
| Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 1 | 1 | Unofficial | CLI |
| StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 1 | 3 | Unofficial | CLI |
| StackIT [Contact us](https://prowler.com/contact) | 8 | 2 | 1 | 3 | Unofficial | CLI |
| NHN | 6 | 2 | 2 | 0 | Unofficial | CLI |
> [!Note]
+97
View File
@@ -4,6 +4,103 @@ All notable changes to the **Prowler API** are documented in this file.
<!-- changelog: release notes start -->
## [1.40.1] (Prowler v5.39.1)
### 🔄 Changed
- Bump alibabacloud-tea-openapi to 0.4.6, oci to 2.184.1 and pyopenssl to 26.4.0 to match the SDK; the cryptography override now names its actual blockers (azure-cli-core pins msal below 1.37, workos 8.3.0 requires cryptography 48) [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
### 🐞 Fixed
- Pin zstd to 1.5.7.2; 1.5.7.3 was yanked from PyPI as not thread safe [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
### 🔐 Security
- Trivy from v0.72.0 to v0.73.0 in the container image, fixing HIGH CVE-2026-46600 in the bundled `golang.org/x/net` [(#12445)](https://github.com/prowler-cloud/prowler/pull/12445)
- Trivy v0.74.0 and Debian util-linux 2.41.5-0+deb13u1 in the API container image, patching Go standard library vulnerabilities and CVE-2026-53615 [(#12470)](https://github.com/prowler-cloud/prowler/pull/12470)
---
## [1.40.0] (Prowler v5.39.0)
### 🔄 Changed
- `GET /api/v1/users/me` membership relationships identify the active tenant with `meta.active` for JWT and API key authentication [(#12388)](https://github.com/prowler-cloud/prowler/pull/12388)
### 🐞 Fixed
- Tenant deletion no longer leaves memberships partially removed when exclusive-user cleanup fails [(#12379)](https://github.com/prowler-cloud/prowler/pull/12379)
- `/api/v1/accounts/saml/{organization_slug}/acs/` rejects non-POST requests before SAML response processing [(#12393)](https://github.com/prowler-cloud/prowler/pull/12393)
- Social login derives a valid user name when identity providers omit the profile name [(#12413)](https://github.com/prowler-cloud/prowler/pull/12413)
---
## [1.39.0] (Prowler v5.38.0)
### 🚀 Added
- Attack Paths adds 20 AWS privilege-escalation detection queries from pathfinding.cloud, covering service PassRole escalations (Batch, Braket, Cognito Identity, ECS, EMR, EMR Serverless, GameLift, Glue, EC2 Image Builder, Kinesis Analytics, HealthOmics, EventBridge Scheduler, SSM, Step Functions), CodeDeploy and Step Functions existing-resource abuse, role permissions-boundary removal with role assumption, and IAM Identity Center permission-set policy injection [(#12237)](https://github.com/prowler-cloud/prowler/pull/12237)
- Attack Paths query metadata now carries an outcome (Code execution, Privilege escalation, Public exposure, or Resource inventory), exposed on the queries endpoint so the graph can show a terminal outcome node [(#12344)](https://github.com/prowler-cloud/prowler/pull/12344)
- Container images now ship an SBOM and build provenance as OCI attestations [(#12352)](https://github.com/prowler-cloud/prowler/pull/12352)
### 🔄 Changed
- Pin the container vulnerability scanner to Trivy v0.72.0, matching prowler-registry and partner-portal [(#12346)](https://github.com/prowler-cloud/prowler/pull/12346)
### 🐞 Fixed
- Compliance report output directory failures are now logged with the exception attached and fingerprinted by `errno` in Sentry, so `ENOSPC`, `ENOENT` and `EACCES` no longer share a single issue [(#12142)](https://github.com/prowler-cloud/prowler/pull/12142)
- Restored the SDK dependency to `@master` now that the dependency bumps have landed there, and regenerated the lock. The API image no longer builds against a temporary integration branch [(#12309)](https://github.com/prowler-cloud/prowler/pull/12309)
### 🔐 Security
- The API container image now verifies the checksum of every third-party binary it downloads (PowerShell, Trivy, zizmor) before installing it [(#12334)](https://github.com/prowler-cloud/prowler/pull/12334)
- Upgrade aiohttp to 3.14.3 to pick up the fix for CVE-2026-69244 [(#12340)](https://github.com/prowler-cloud/prowler/pull/12340)
- Upgrade cryptography to 50.0.0, closing CVE-2026-69247 and CVE-2026-69249 [(#12356)](https://github.com/prowler-cloud/prowler/pull/12356)
---
## [1.38.1] (Prowler v5.37.1)
### 🐞 Fixed
- Entra Conditional Access guest-user checks no longer report false FAILs in M365 scans: microsoft-kiota packages overridden to 1.9.10 so `guestOrExternalUserTypes` (a flags enum Graph serializes as a comma-separated string) deserializes correctly instead of returning an empty list [(#12315)](https://github.com/prowler-cloud/prowler/pull/12315)
### 🔐 Security
- The API container image now builds on Debian 13 (trixie), taking its critical CVE count from 18 to 4 [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
- Bumped PowerShell, Trivy and uv in the API container image, clearing 14 high-severity CVEs [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
- Bumped `workos` and `pyopenssl` so the API can move to `cryptography` 48.0.1 [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
- Removed `gnupg` and `apt-transport-https` from the API container image [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
- The API container image no longer ships `git`; removing it also dropped `perl`, `perl-modules`, `libperl` and `liberror-perl`, clearing 12 critical CVEs. Only `perl-base` remains, which Debian marks Essential and cannot be removed [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
- Removed `pip` from the API container image, clearing two high-severity CVEs in the vendored copies of `setuptools` and `msgpack` [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
- Bumped `pillow` to 12.3.0, `httplib2` to 0.32.0 and `pyasn1` to 0.6.4 to resolve known CVEs [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
---
## [1.38.0] (Prowler v5.37.0)
### 🚀 Added
- Attack Paths: four AWS privilege-escalation detection queries from pathfinding.cloud: cross-account role trust (STS-002), wildcard role trust (STS-003), user permissions-boundary removal (IAM-022), and IAM Identity Center permission-set escalation (SSO-001) [(#11460)](https://github.com/prowler-cloud/prowler/pull/11460)
### 🐞 Fixed
- Attack Paths IAM privilege-escalation queries no longer build an all-nodes × all-resource-items cartesian product, fixing runtime errors and timeouts on accounts with many IAM roles, users, or groups [(#12136)](https://github.com/prowler-cloud/prowler/pull/12136)
- `task_args` serialization no longer returns HTTP 500 errors when Celery truncates stored task keyword arguments [(#12165)](https://github.com/prowler-cloud/prowler/pull/12165)
- Attack Paths predefined queries on migrated graphs are now scoped with the provider label, letting the graph database seed from its label index instead of a global label scan and preventing query timeouts on Neptune [(#12167)](https://github.com/prowler-cloud/prowler/pull/12167)
- Authentication with an API key whose owning user was deleted now returns `401` instead of an unhandled `AttributeError`, and user deletion now revokes the user's API keys across all their tenants [(#12210)](https://github.com/prowler-cloud/prowler/pull/12210)
- AWS Security Hub integrations now persist successful connection checks during finding delivery so their connection status and last checked timestamp stay current [(#12212)](https://github.com/prowler-cloud/prowler/pull/12212)
- SAML users without a `userType` attribute and without an existing role in the SAML tenant now receive a least-privilege `read_only` fallback role; a numeric suffix is used when that name belongs to a role with different permissions [(#12223)](https://github.com/prowler-cloud/prowler/pull/12223)
- Social signups create users and authentication records in one database transaction, preventing incomplete accounts when provisioning fails [(#12245)](https://github.com/prowler-cloud/prowler/pull/12245)
- Requesting integrations with a sparse fieldset that leaves out `configuration` no longer returns HTTP 500 errors when the tenant has a Jira integration [(#12261)](https://github.com/prowler-cloud/prowler/pull/12261)
### 🔐 Security
- Provider deletion and connection checks, scan creation, provider secrets, provider groups, and daily schedules now respect role provider-group visibility [(#12216)](https://github.com/prowler-cloud/prowler/pull/12216)
---
## [1.37.0] (Prowler v5.36.0)
### 🔄 Changed
+38 -6
View File
@@ -1,23 +1,31 @@
FROM python:3.12.13-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS build
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS build
LABEL maintainer="https://github.com/prowler-cloud/api"
ARG POWERSHELL_VERSION=7.5.0
ARG POWERSHELL_VERSION=7.5.9
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
ENV POWERSHELL_TELEMETRY_OPTOUT=1
ARG TRIVY_VERSION=0.71.2
ARG TRIVY_VERSION=0.74.0
ENV TRIVY_VERSION=${TRIVY_VERSION}
ARG ZIZMOR_VERSION=1.24.1
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
ARG TRIVY_SHA256_AMD64=2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a
ARG TRIVY_SHA256_ARM64=b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5
ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0
ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
git \
libicu72 \
libicu76 \
gcc \
g++ \
make \
@@ -28,7 +36,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libtool \
libxslt1-dev \
python3-dev \
git \
&& apt-get install -y --no-install-recommends --only-upgrade util-linux \
&& rm -rf /var/lib/apt/lists/*
# Install PowerShell
@@ -40,6 +48,9 @@ RUN ARCH=$(uname -m) && \
else \
echo "Unsupported architecture: $ARCH" && exit 1 ; \
fi && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$POWERSHELL_SHA256_AMD64" ; else EXPECT="$POWERSHELL_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/powershell.tar.gz" > /tmp/powershell.sha256 && \
sha256sum -c /tmp/powershell.sha256 && rm /tmp/powershell.sha256 && \
mkdir -p /opt/microsoft/powershell/7 && \
tar zxf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 && \
chmod +x /opt/microsoft/powershell/7/pwsh && \
@@ -56,6 +67,9 @@ RUN ARCH=$(uname -m) && \
echo "Unsupported architecture for Trivy: $ARCH" && exit 1 ; \
fi && \
wget --progress=dot:giga "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_${TRIVY_ARCH}.tar.gz" -O /tmp/trivy.tar.gz && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$TRIVY_SHA256_AMD64" ; else EXPECT="$TRIVY_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/trivy.tar.gz" > /tmp/trivy.sha256 && \
sha256sum -c /tmp/trivy.sha256 && rm /tmp/trivy.sha256 && \
tar zxf /tmp/trivy.tar.gz -C /tmp && \
mv /tmp/trivy /usr/local/bin/trivy && \
chmod +x /usr/local/bin/trivy && \
@@ -74,6 +88,9 @@ RUN ARCH=$(uname -m) && \
echo "Unsupported architecture for zizmor: $ARCH" && exit 1 ; \
fi && \
wget --progress=dot:giga "https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-${ZIZMOR_ARCH}.tar.gz" -O /tmp/zizmor.tar.gz && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$ZIZMOR_SHA256_AMD64" ; else EXPECT="$ZIZMOR_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/zizmor.tar.gz" > /tmp/zizmor.sha256 && \
sha256sum -c /tmp/zizmor.sha256 && rm /tmp/zizmor.sha256 && \
mkdir -p /tmp/zizmor-extract && \
tar zxf /tmp/zizmor.tar.gz -C /tmp/zizmor-extract && \
mv /tmp/zizmor-extract/zizmor /usr/local/bin/zizmor && \
@@ -94,7 +111,7 @@ RUN mkdir -p /tmp/prowler_api_output
COPY --chown=prowler:prowler pyproject.toml uv.lock ./
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir uv==0.11.14
pip install --no-cache-dir uv==0.12.0
ENV PATH="/home/prowler/.local/bin:$PATH"
@@ -109,19 +126,34 @@ RUN .venv/bin/python -m prowler.providers.m365.lib.powershell.m365_powershell
USER root
# Remove build-only packages from the final image after Python dependencies are installed.
# git is only needed by uv sync for the `prowler @ git+...` dependency; purging it drops perl too.
# wget stays: the compose healthcheck shells out to it.
RUN apt-get purge -y --auto-remove \
gcc \
g++ \
git \
make \
libxml2-dev \
libxmlsec1-dev \
libxmlsec1-openssl \
libxmlsec1t64 \
libxmlsec1t64-openssl \
pkg-config \
libtool \
libxslt1-dev \
python3-dev \
gnupg \
apt-transport-https \
&& rm -rf /var/lib/apt/lists/*
# pip is build-only; the entrypoint runs uv against the prepared venv. uv stays.
RUN rm -rf /usr/local/lib/python3.12/site-packages/pip \
/usr/local/lib/python3.12/site-packages/pip-*.dist-info \
/home/prowler/.local/lib/python3.12/site-packages/pip \
/home/prowler/.local/lib/python3.12/site-packages/pip-*.dist-info \
/usr/local/bin/pip /usr/local/bin/pip3 /usr/local/bin/pip3.12 \
/home/prowler/.local/bin/pip /home/prowler/.local/bin/pip3 /home/prowler/.local/bin/pip3.12
USER prowler
COPY --chown=prowler:prowler src/backend/ ./backend/
+43 -26
View File
@@ -63,7 +63,7 @@ dependencies = [
"werkzeug (==3.1.7)",
"sqlparse (==0.5.5)",
"fonttools (==4.62.1)",
"uvicorn-worker (==0.4.0)",
"uvicorn-worker (==0.4.0)"
]
description = "Prowler's API (Django/DRF)"
license = "Apache-2.0"
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.37.0"
version = "1.41.0"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
@@ -92,6 +92,7 @@ extend-select = [
[tool.uv]
# Transitive pins matching master to avoid silent drift; bump deliberately.
# workos is api-only; pyopenssl matches master (PROWLER-2310).
constraint-dependencies = [
"about-time==4.2.1",
"adal==1.2.7",
@@ -99,7 +100,7 @@ constraint-dependencies = [
"aiobotocore==2.25.1",
"aiofiles==24.1.0",
"aiohappyeyeballs==2.6.1",
"aiohttp==3.14.0",
"aiohttp==3.14.3",
"aioitertools==0.13.0",
"aiosignal==1.4.0",
"alibabacloud-actiontrail20200706==2.4.1",
@@ -128,7 +129,7 @@ constraint-dependencies = [
"alibabacloud-sls20201230==5.9.0",
"alibabacloud-sts20150401==1.1.6",
"alibabacloud-tea==0.4.3",
"alibabacloud-tea-openapi==0.4.4",
"alibabacloud-tea-openapi==0.4.6",
"alibabacloud-tea-util==0.3.14",
"alibabacloud-tea-xml==0.0.3",
"alibabacloud-vpc20160428==6.13.0",
@@ -210,9 +211,9 @@ constraint-dependencies = [
"coverage==7.5.4",
"cron-descriptor==1.4.5",
"crowdstrike-falconpy==1.6.0",
"cryptography==46.0.7",
"cryptography==50.0.0",
"cycler==0.12.1",
"darabonba-core==1.0.5",
"darabonba-core==1.0.8",
"dash==3.1.1",
"dash-bootstrap-components==2.0.3",
"debugpy==1.8.20",
@@ -277,7 +278,7 @@ constraint-dependencies = [
"h2==4.3.0",
"hpack==4.1.0",
"httpcore==1.0.9",
"httplib2==0.31.2",
"httplib2==0.32.0",
"httpx==0.28.1",
"humanfriendly==10.0",
"hyperframe==6.1.0",
@@ -314,13 +315,13 @@ constraint-dependencies = [
"matplotlib==3.10.8",
"mccabe==0.7.0",
"mdurl==0.1.2",
"microsoft-kiota-abstractions==1.9.9",
"microsoft-kiota-authentication-azure==1.9.9",
"microsoft-kiota-http==1.9.9",
"microsoft-kiota-serialization-form==1.9.9",
"microsoft-kiota-serialization-json==1.9.9",
"microsoft-kiota-serialization-multipart==1.9.9",
"microsoft-kiota-serialization-text==1.9.9",
"microsoft-kiota-abstractions==1.9.10",
"microsoft-kiota-authentication-azure==1.9.10",
"microsoft-kiota-http==1.9.10",
"microsoft-kiota-serialization-form==1.9.10",
"microsoft-kiota-serialization-json==1.9.10",
"microsoft-kiota-serialization-multipart==1.9.10",
"microsoft-kiota-serialization-text==1.9.10",
"microsoft-security-utilities-secret-masker==1.0.0b4",
"msal==1.35.0b1",
"msal-extensions==1.2.0",
@@ -337,7 +338,7 @@ constraint-dependencies = [
"nltk==3.9.4",
"numpy==2.2.6",
"oauthlib==3.3.1",
"oci==2.169.0",
"oci==2.184.1",
"openai==1.109.1",
"openstacksdk==4.2.0",
"opentelemetry-api==1.39.1",
@@ -349,7 +350,7 @@ constraint-dependencies = [
"pagerduty==6.1.0",
"pandas==2.2.3",
"pbr==7.0.3",
"pillow==12.2.0",
"pillow==12.3.0",
"pkginfo==1.12.1.2",
"platformdirs==4.5.1",
"plotly==6.5.2",
@@ -365,8 +366,8 @@ constraint-dependencies = [
"psycopg2-binary==2.9.9",
"py-deviceid==0.1.1",
"py-iam-expand==0.3.0",
"py-ocsf-models==0.8.1",
"pyasn1==0.6.3",
"py-ocsf-models==0.10.0",
"pyasn1==0.6.4",
"pyasn1-modules==0.4.2",
"pycodestyle==2.14.0",
"pycparser==3.0",
@@ -378,7 +379,7 @@ constraint-dependencies = [
"pylint==3.2.5",
"pymsalruntime==0.18.1",
"pynacl==1.6.2",
"pyopenssl==26.0.0",
"pyopenssl==26.4.0",
"pyparsing==3.3.2",
"pyreadline3==3.5.4",
"pysocks==1.7.1",
@@ -447,7 +448,7 @@ constraint-dependencies = [
"wcwidth==0.5.3",
"websocket-client==1.9.0",
"werkzeug==3.1.7",
"workos==6.0.8",
"workos==8.3.0",
"wrapt==1.17.3",
"xlsxwriter==3.2.9",
"xmlsec==1.3.17",
@@ -456,7 +457,7 @@ constraint-dependencies = [
"zipp==3.23.0",
"zope-event==6.1",
"zope-interface==8.2",
"zstd==1.5.7.3"
"zstd==1.5.7.2"
]
# prowler@master needs okta==3.4.2, but cartography 0.138.1 requires okta<1.0.0.
# Attack Paths does not ingest Okta today, so override the Cartography
@@ -466,10 +467,13 @@ constraint-dependencies = [
# 0.138.1 requires azure-mgmt-containerservice>=41.0.0. Attack Paths does not
# ingest Azure today, so override the Cartography dependency to the Prowler pin.
#
# prowler@master hard-pins microsoft-kiota-abstractions==1.9.2 in [project.dependencies].
# The microsoft-kiota-http security bump to 1.9.9 (GHSA-7j59-v9qr-6fq9) requires
# microsoft-kiota-abstractions>=1.9.9, which a constraint cannot satisfy against the
# SDK's hard pin; override it to the patched, kiota-aligned version.
# prowler@master hard-pins the microsoft-kiota packages in [project.dependencies].
# microsoft-kiota-serialization-json 1.9.10 fixes get_collection_of_enum_values
# returning [] for flags enums serialized as CSV strings (microsoft/kiota-python#515),
# which broke the Entra Conditional Access guest-user checks; the kiota packages
# release in lockstep and 1.9.10 requires microsoft-kiota-abstractions>=1.9.10, which
# a constraint cannot satisfy against the SDK's hard pins, so override the whole set
# to 1.9.10 until the SDK bump propagates to the pinned master rev.
#
# prowler@master hard-pins dulwich==0.23.0 and pyjwt==2.12.1 in [project.dependencies].
# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.13.0
@@ -480,8 +484,21 @@ constraint-dependencies = [
# that request pyjwt[crypto] and leave cryptography (needed for RS256) only transitive.
override-dependencies = [
"okta==3.4.2",
# prowler requires cryptography==50.0.0. Two api-only dependencies still cap it below
# 49 and cannot move yet: msal, pinned exactly by azure-cli-core (2.83.0 -> 1.35.0b1,
# 2.89.1 -> 1.36.0, both <49; cartography needs azure-cli-core), and workos 8.3.0
# (~=48.0; workos 10.1.1+ needs ~=50.0 and is a separate SDK upgrade). This api is
# deployed from this lock with `uv sync --locked`, so the override applies to what runs.
# Remove when azure-cli-core pins msal>=1.37.0 and workos is on 10.x.
"cryptography==50.0.0",
"azure-mgmt-containerservice==34.1.0",
"microsoft-kiota-abstractions==1.9.9",
"microsoft-kiota-abstractions==1.9.10",
"microsoft-kiota-authentication-azure==1.9.10",
"microsoft-kiota-http==1.9.10",
"microsoft-kiota-serialization-form==1.9.10",
"microsoft-kiota-serialization-json==1.9.10",
"microsoft-kiota-serialization-multipart==1.9.10",
"microsoft-kiota-serialization-text==1.9.10",
"dulwich==1.2.5",
"pyjwt[crypto]==2.13.0"
]
+32 -6
View File
@@ -1,7 +1,7 @@
from allauth.account.models import EmailAddress
from allauth.core.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from api.db_router import MainRouter
from api.db_router import MainRouter, write_db_alias
from api.db_utils import rls_transaction
from api.models import (
Membership,
@@ -12,11 +12,37 @@ from api.models import (
UserRoleRelationship,
)
from api.utils import accept_invitation_for_user
from django.core.exceptions import ValidationError
from django.db import transaction
from django.http import HttpResponseForbidden
class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
@staticmethod
def _get_social_account_name(extra_data: dict, email: str) -> str:
name_field = User._meta.get_field("name")
for value in (
extra_data.get("name"),
extra_data.get("login"),
extra_data.get("username"),
email,
):
if not isinstance(value, str):
continue
candidate = value.strip()[: name_field.max_length].rstrip()
if not candidate:
continue
try:
name_field.run_validators(candidate)
except ValidationError:
continue
return candidate
raise ValueError("Social account does not provide a valid user identity.")
@staticmethod
def get_user_by_email(email: str):
try:
@@ -107,17 +133,17 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
and is about to be saved to the DB for the first time.
"""
with transaction.atomic(using=MainRouter.admin_db):
user = super().save_user(request, sociallogin, form)
# Allauth saves the user without an explicit alias. Route that save
# through admin so every signup record shares this transaction.
with write_db_alias(MainRouter.admin_db):
user = super().save_user(request, sociallogin, form)
provider = sociallogin.provider.id
extra = sociallogin.account.extra_data
if provider != "saml":
# Handle other providers (e.g., GitHub, Google)
user.name = self._get_social_account_name(extra, user.email)
user.save(using=MainRouter.admin_db)
social_account_name = extra.get("name")
if social_account_name:
user.name = social_account_name
user.save(using=MainRouter.admin_db)
invitation_token = self._get_invitation_token(request)
if invitation_token:
@@ -1,5 +1,6 @@
from api.attack_paths.queries import (
AttackPathsQueryDefinition,
AttackPathsQueryOutcome,
AttackPathsQueryParameterDefinition,
get_queries_for_provider,
get_query_by_id,
@@ -7,6 +8,7 @@ from api.attack_paths.queries import (
__all__ = [
"AttackPathsQueryDefinition",
"AttackPathsQueryOutcome",
"AttackPathsQueryParameterDefinition",
"get_queries_for_provider",
"get_query_by_id",
@@ -4,11 +4,13 @@ from api.attack_paths.queries.registry import (
)
from api.attack_paths.queries.types import (
AttackPathsQueryDefinition,
AttackPathsQueryOutcome,
AttackPathsQueryParameterDefinition,
)
__all__ = [
"AttackPathsQueryDefinition",
"AttackPathsQueryOutcome",
"AttackPathsQueryParameterDefinition",
"get_queries_for_provider",
"get_query_by_id",
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,38 @@
from dataclasses import dataclass, field
from enum import Enum
@dataclass(frozen=True)
class AttackPathsQueryOutcomeMeta:
"""Display metadata for an outcome kind.
`label` and `partial` are properties of the outcome *kind*, not of an
individual query, so they live here once and every query just references a
kind. `partial` marks a latent/posture outcome (e.g. inventory) that the UI
renders as a marker rather than a full realized outcome.
"""
kind: str
label: str
partial: bool = False
class AttackPathsQueryOutcome(Enum):
"""The terminal impact an attack-path query leads to.
Set per query and exposed by the API so the UI can render the graph's
terminal outcome node. The taxonomy is shared with Prowler Hub's attack-path
diagram (whose terminal labels match these values).
"""
CODE_EXECUTION = AttackPathsQueryOutcomeMeta("code_execution", "Code execution")
PRIVILEGE_ESCALATION = AttackPathsQueryOutcomeMeta(
"privilege_escalation", "Privilege escalation"
)
PUBLIC_EXPOSURE = AttackPathsQueryOutcomeMeta("public_exposure", "Public exposure")
RESOURCE_INVENTORY = AttackPathsQueryOutcomeMeta(
"resource_inventory", "Resource inventory", partial=True
)
@dataclass
@@ -36,4 +70,5 @@ class AttackPathsQueryDefinition:
provider: str
cypher: str
attribution: AttackPathsQueryAttribution | None = None
outcome: AttackPathsQueryOutcome | None = None
parameters: list[AttackPathsQueryParameterDefinition] = field(default_factory=list)
@@ -115,7 +115,26 @@ def execute_query(
# TODO: drop after Neptune cutover
# Route reads by the scan row's recorded sink, not by current settings.
backend = sink_module.get_backend_for_scan(scan)
graph = backend.execute_read_query(database_name, definition.cypher, parameters)
cypher = definition.cypher
# Every synced node carries a `_Provider_{uuid}` isolation label (the
# sync labels the whole provider subgraph). Injecting it into the
# predefined query's node patterns gives the planner a selective label
# index to seed from instead of a global label scan (`:AWSRole` across
# every tenant), which on Neptune is the difference between a sub-second
# plan and a query that times out. The custom-query path relies on this
# same injection.
#
# Restrict it to migrated scans: that catalog runs on the Neptune sink
# where the plan blowup happens, while the pre-cutover legacy catalog
# runs on the old sink and is dropped after the cutover, so leave it
# byte-for-byte unchanged. This only affects the query plan, not
# isolation - `_serialize_graph` already label-filters both catalogs.
# TODO: drop the is_migrated guard after Neptune cutover
if scan.is_migrated:
cypher = inject_provider_label(cypher, provider_id)
graph = backend.execute_read_query(database_name, cypher, parameters)
return _serialize_graph(graph, provider_id)
except graph_database.WriteQueryNotAllowedException:
+63 -30
View File
@@ -1,3 +1,4 @@
import logging
from math import isfinite
from uuid import UUID
@@ -5,6 +6,7 @@ from api.db_router import MainRouter
from api.models import TenantAPIKey, TenantAPIKeyManager
from cryptography.fernet import InvalidToken
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.utils import timezone
from drf_simple_apikey.backends import APIKeyAuthentication as BaseAPIKeyAuth
from drf_simple_apikey.crypto import get_crypto
@@ -14,6 +16,16 @@ from rest_framework.exceptions import AuthenticationFailed
from rest_framework.request import Request
from rest_framework_simplejwt.authentication import JWTAuthentication
logger = logging.getLogger(__name__)
class OrphanedAPIKeyError(Exception):
"""Raised when an API key outlived the user that owns it.
Handled by `authenticate`, which commits the revocation written while detecting it
and then rejects the request with `AuthenticationFailed`.
"""
class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
model = TenantAPIKey
@@ -24,10 +36,13 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
def _authenticate_credentials(self, request, key):
"""
Override to use admin connection, bypassing RLS during authentication.
Returns the validated API key row, locked with `select_for_update`, so callers
must run inside `transaction.atomic(using=MainRouter.admin_db)`.
"""
try:
payload = self.key_crypto.decrypt(key)
except ValueError:
except (ValueError, InvalidToken):
raise AuthenticationFailed("Invalid API Key.")
if not isinstance(payload, dict):
@@ -52,13 +67,33 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
raise AuthenticationFailed("API Key has already expired.")
try:
api_key = self.model.objects.using(MainRouter.admin_db).get(id=api_key_pk)
api_key = (
self.model.objects.using(MainRouter.admin_db)
.select_for_update()
.get(id=api_key_pk)
)
except ObjectDoesNotExist:
raise AuthenticationFailed("No entity matching this api key.")
if api_key.revoked:
raise AuthenticationFailed("This API Key has been revoked.")
# `entity` is nullable and `on_delete=SET_NULL` leaves the key behind when its
# owner is deleted, so a key can outlive its user. Reject it here: further down
# the authentication would return `None` as the authenticated user, which blows
# up while building the auth dict and surfaces as a 500 instead of a 401.
# Revoke it as well, so it stops showing up as active and later attempts fail
# the `revoked` check above like any other revoked key.
if api_key.entity_id is None:
api_key.revoked = True
api_key.save(update_fields=["revoked"], using=MainRouter.admin_db)
logger.warning(
"Revoked orphaned API key: prefix=%s tenant=%s",
api_key.prefix,
api_key.tenant_id,
)
raise OrphanedAPIKeyError
client_ip = request.META.get(package_settings.IP_ADDRESS_HEADER)
if api_key.blacklisted_ips and client_ip in api_key.blacklisted_ips:
raise AuthenticationFailed("Access denied from blacklisted IP.")
@@ -66,7 +101,7 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
if api_key.whitelisted_ips and client_ip not in api_key.whitelisted_ips:
raise AuthenticationFailed("Access restricted to specific IP addresses.")
return api_key.entity, key
return api_key
def authenticate(self, request: Request):
prefixed_key = self.get_key(request)
@@ -77,36 +112,34 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
except ValueError:
raise AuthenticationFailed("Invalid API Key.")
try:
entity, _ = self._authenticate_credentials(request, key)
except InvalidToken:
raise AuthenticationFailed("Invalid API Key.")
# Validation, the `last_used_at` update and the auth claims all read the same
# row, locked until the transaction ends. Looking the key up a second time to
# build the claims used to leave a window where a key revoked or orphaned right
# after passing validation still authenticated.
with transaction.atomic(using=MainRouter.admin_db):
try:
api_key = self._authenticate_credentials(request, key)
except OrphanedAPIKeyError:
# Rejected below instead of here: leaving the block normally commits
# the revocation `_authenticate_credentials` wrote, while raising from
# inside would roll it back.
pass
else:
# The prefix used to be checked by the second lookup
if api_key.prefix != prefix:
raise AuthenticationFailed("Invalid API Key.")
# Get the API key instance to update last_used_at and retrieve tenant info
# We need to decrypt again to get the pk (already validated by _authenticate_credentials)
payload = self.key_crypto.decrypt(key)
api_key_pk = payload["_pk"]
api_key.last_used_at = timezone.now()
api_key.save(update_fields=["last_used_at"], using=MainRouter.admin_db)
# Convert string UUID back to UUID object for lookup
if isinstance(api_key_pk, str):
api_key_pk = UUID(api_key_pk)
entity = api_key.entity
return entity, {
"tenant_id": str(api_key.tenant_id),
"sub": str(entity.id),
"api_key_prefix": api_key.prefix,
}
try:
api_key_instance = TenantAPIKey.objects.using(MainRouter.admin_db).get(
id=api_key_pk, prefix=prefix
)
except TenantAPIKey.DoesNotExist:
raise AuthenticationFailed("Invalid API Key.")
# Update last_used_at
api_key_instance.last_used_at = timezone.now()
api_key_instance.save(update_fields=["last_used_at"], using=MainRouter.admin_db)
return entity, {
"tenant_id": str(api_key_instance.tenant_id),
"sub": str(api_key_instance.entity.id),
"api_key_prefix": prefix,
}
raise AuthenticationFailed("No entity matching this api key.")
class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication):
+40
View File
@@ -0,0 +1,40 @@
import ast
import json
from typing import Any
_UNPARSED = object()
def decode_celery_field(value: Any, default: Any) -> Any:
"""Decode a Celery result field and require JSON-serializable output."""
decoded = value
for _ in range(2):
if not isinstance(decoded, str):
break
text = decoded.strip()
if not text:
decoded = default
break
parsed = _UNPARSED
for parser in (json.loads, ast.literal_eval):
try:
parsed = parser(text)
break
except (TypeError, ValueError, SyntaxError):
continue
if parsed is _UNPARSED:
raise ValueError("Unable to decode Celery result field")
decoded = parsed
decoded = default if decoded is None else decoded
try:
json.dumps(decoded, allow_nan=False)
except (TypeError, ValueError) as error:
raise ValueError(
"Decoded Celery result field is not JSON serializable"
) from error
return decoded
+29
View File
@@ -1,3 +1,4 @@
from contextlib import contextmanager
from contextvars import ContextVar
from django.conf import settings
@@ -5,6 +6,7 @@ from django.conf import settings
ALLOWED_APPS = ("django", "socialaccount", "account", "authtoken", "silk")
_read_db_alias = ContextVar("read_db_alias", default=None)
_write_db_alias = ContextVar("write_db_alias", default=None)
def set_read_db_alias(alias: str | None):
@@ -22,6 +24,30 @@ def reset_read_db_alias(token) -> None:
_read_db_alias.reset(token)
def set_write_db_alias(alias: str | None):
if not alias:
return None
return _write_db_alias.set(alias)
def get_write_db_alias() -> str | None:
return _write_db_alias.get()
def reset_write_db_alias(token) -> None:
if token is not None:
_write_db_alias.reset(token)
@contextmanager
def write_db_alias(alias: str | None):
token = set_write_db_alias(alias)
try:
yield
finally:
reset_write_db_alias(token)
class MainRouter:
default_db = "default"
admin_db = "admin"
@@ -43,6 +69,9 @@ class MainRouter:
model_table_name = model._meta.db_table
if any(model_table_name.startswith(f"{app}_") for app in ALLOWED_APPS):
return self.admin_db
write_alias = get_write_db_alias()
if write_alias:
return write_alias
return None
def allow_migrate(self, db, app_label, model_name=None, **hints): # noqa: F841
+14 -2
View File
@@ -1,3 +1,4 @@
from api.db_router import MainRouter
from api.db_utils import delete_related_daily_task
from api.models import (
LighthouseProviderConfiguration,
@@ -47,8 +48,15 @@ def revoke_user_api_keys(sender, instance, **kwargs): # noqa: F841
The entity field will be set to NULL by on_delete=SET_NULL,
but we explicitly revoke the keys to prevent further use.
The update runs on the admin connection because `api_keys` is RLS protected and its
policy denies every row when `api.tenant_id` is unset. Users are deleted through the
admin connection and may belong to several tenants, so going through the default
connection would silently revoke nothing, or only the keys of the active tenant.
"""
TenantAPIKey.objects.filter(entity=instance).update(revoked=True)
TenantAPIKey.objects.using(MainRouter.admin_db).filter(entity=instance).update(
revoked=True
)
@receiver(post_delete, sender=Membership)
@@ -58,8 +66,12 @@ def revoke_membership_api_keys(sender, instance, **kwargs): # noqa: F841
When a membership is deleted, all API keys created by that user
in that tenant should be revoked to prevent further access.
Uses the admin connection for the same reason as `revoke_user_api_keys`: the RLS
policy on `api_keys` denies every row when `api.tenant_id` is unset, which is the
case when the membership is removed as a cascade of a user deletion.
"""
TenantAPIKey.objects.filter(
TenantAPIKey.objects.using(MainRouter.admin_db).filter(
entity_id=instance.user_id, tenant_id=instance.tenant_id
).update(revoked=True)
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.37.0
version: 1.41.0
description: |-
Prowler API specification.
@@ -4,8 +4,11 @@ from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from api.db_router import MainRouter
from api.models import Membership, Role, TenantAPIKey, User, UserRoleRelationship
from api.signals import revoke_membership_api_keys, revoke_user_api_keys
from conftest import TEST_PASSWORD, get_api_tokens, get_authorization_header
from django.db.utils import ConnectionDoesNotExist
from django.urls import reverse
from drf_simple_apikey.crypto import get_crypto
from rest_framework.test import APIClient
@@ -625,6 +628,34 @@ class TestAPIKeyErrors:
assert response.status_code == 401
assert "API Key has been revoked." in response.json()["errors"][0]["detail"]
def test_orphaned_api_key_rejected(
self, create_test_user, tenants_fixture, api_keys_fixture
):
"""Key whose owning user was deleted returns 401 instead of 500."""
client = APIClient()
api_key = api_keys_fixture[0]
# `on_delete=SET_NULL` leaves the key behind with no entity when the owner goes
TenantAPIKey.objects.filter(id=api_key.id).update(entity=None)
api_key_headers = get_api_key_header(api_key._raw_key)
response = client.get(reverse("provider-list"), headers=api_key_headers)
assert response.status_code == 401
assert (
"No entity matching this api key." in response.json()["errors"][0]["detail"]
)
# The orphaned key is revoked on use; retries fail the regular revoked check
api_key.refresh_from_db()
assert api_key.revoked is True
retry_response = client.get(reverse("provider-list"), headers=api_key_headers)
assert retry_response.status_code == 401
assert (
"API Key has been revoked." in retry_response.json()["errors"][0]["detail"]
)
def test_non_existent_api_key(self, create_test_user, tenants_fixture):
"""Key UUID doesn't exist in database."""
client = APIClient()
@@ -817,6 +848,93 @@ class TestAPIKeyTenantIsolation:
error_detail = response_json["errors"][0]["detail"]
assert "revoked" in error_detail.lower()
def test_deleting_user_revokes_api_keys_in_every_tenant(self, tenants_fixture):
"""Deleting a user revokes their keys in all their tenants, not just one."""
first_tenant, second_tenant = tenants_fixture[0], tenants_fixture[1]
test_user = User.objects.create_user(
name="multi_tenant_user",
email="multi_tenant_user@prowler.com",
password=TEST_PASSWORD,
)
for tenant in (first_tenant, second_tenant):
Membership.objects.create(
user=test_user, tenant=tenant, role=Membership.RoleChoices.OWNER
)
first_key, _ = TenantAPIKey.objects.create_api_key(
name="Key in first tenant", tenant_id=first_tenant.id, entity=test_user
)
second_key, _ = TenantAPIKey.objects.create_api_key(
name="Key in second tenant", tenant_id=second_tenant.id, entity=test_user
)
test_user.delete()
first_key.refresh_from_db()
second_key.refresh_from_db()
assert first_key.revoked is True
assert second_key.revoked is True
# `on_delete=SET_NULL` orphans the keys, so revoking them is what keeps them
# from authenticating
assert first_key.entity_id is None
assert second_key.entity_id is None
def test_revoke_user_api_keys_uses_the_admin_connection(
self, monkeypatch, tenants_fixture
):
"""The revocation must not go through the default connection.
`api_keys` is RLS protected and its policy denies every row when `api.tenant_id`
is unset, which is the case while a user is deleted through the admin
connection: the update would silently revoke nothing and leave usable orphaned
keys behind.
Pointing `admin_db` at a missing alias is the only way to assert the connection
here, because the test suite runs on a single superuser database with
`MainRouter.admin_db` patched to "default" (see `conftest.py`), so RLS never
applies and both connections are otherwise indistinguishable.
"""
test_user = User.objects.create_user(
name="admin_connection_user",
email="admin_connection_user@prowler.com",
password=TEST_PASSWORD,
)
Membership.objects.create(user=test_user, tenant=tenants_fixture[0])
TenantAPIKey.objects.create_api_key(
name="Key for admin connection check",
tenant_id=tenants_fixture[0].id,
entity=test_user,
)
monkeypatch.setattr(MainRouter, "admin_db", "missing_admin_alias")
with pytest.raises(ConnectionDoesNotExist):
revoke_user_api_keys(sender=User, instance=test_user)
def test_revoke_membership_api_keys_uses_the_admin_connection(
self, monkeypatch, tenants_fixture
):
"""Same as the user deletion case: this receiver also runs as its cascade."""
test_user = User.objects.create_user(
name="admin_connection_membership_user",
email="admin_connection_membership_user@prowler.com",
password=TEST_PASSWORD,
)
membership = Membership.objects.create(
user=test_user, tenant=tenants_fixture[0]
)
TenantAPIKey.objects.create_api_key(
name="Key for membership admin connection check",
tenant_id=tenants_fixture[0].id,
entity=test_user,
)
monkeypatch.setattr(MainRouter, "admin_db", "missing_admin_alias")
with pytest.raises(ConnectionDoesNotExist):
revoke_membership_api_keys(sender=Membership, instance=membership)
@pytest.mark.django_db
class TestAPIKeyLifecycle:
@@ -1472,8 +1590,8 @@ class TestAPIKeyMultiTenantWorkflows:
tenant1 = tenants_fixture[0]
tenant2 = tenants_fixture[1]
Membership.objects.create(user=user, tenant=tenant1)
Membership.objects.create(user=user, tenant=tenant2)
membership1 = Membership.objects.create(user=user, tenant=tenant1)
membership2 = Membership.objects.create(user=user, tenant=tenant2)
role1 = Role.objects.create(
tenant_id=tenant1.id,
@@ -1528,6 +1646,27 @@ class TestAPIKeyMultiTenantWorkflows:
assert me_response1.json()["data"]["id"] == str(user.id)
assert me_response2.json()["data"]["id"] == str(user.id)
memberships1 = {
item["id"]: item["meta"]["active"]
for item in me_response1.json()["data"]["relationships"]["memberships"][
"data"
]
}
memberships2 = {
item["id"]: item["meta"]["active"]
for item in me_response2.json()["data"]["relationships"]["memberships"][
"data"
]
}
assert memberships1 == {
str(membership1.id): True,
str(membership2.id): False,
}
assert memberships2 == {
str(membership1.id): False,
str(membership2.id): True,
}
def test_api_key_cannot_access_different_tenant_resources(
self, tenants_fixture, aws_provider
):
+267 -1
View File
@@ -10,10 +10,12 @@ from allauth.socialaccount import app_settings as socialaccount_app_settings
from allauth.socialaccount.internal.flows.login import complete_login
from allauth.socialaccount.models import SocialAccount, SocialLogin
from api.adapters import ProwlerSocialAccountAdapter
from api.db_router import MainRouter
from api.db_router import MainRouter, get_write_db_alias
from api.models import Invitation, Membership, SAMLConfiguration, Tenant
from django.contrib.auth import get_user_model
from django.core import mail
from django.db import connections
from django.db import router as django_router
User = get_user_model()
@@ -109,6 +111,110 @@ def _verify_local_email(user):
)
def test_social_account_name_falls_back_to_login_for_blank_name():
adapter = ProwlerSocialAccountAdapter()
name = adapter._get_social_account_name(
{"name": " ", "login": "octocat"},
"verified@example.com",
)
assert name == "octocat"
@pytest.mark.parametrize("provider_name", [None, "", " ", 123, ["name"]])
def test_social_account_name_ignores_unusable_provider_names(provider_name):
adapter = ProwlerSocialAccountAdapter()
name = adapter._get_social_account_name(
{"name": provider_name, "login": "octocat"},
"verified@example.com",
)
assert name == "octocat"
def test_social_account_name_uses_login_when_name_is_missing():
adapter = ProwlerSocialAccountAdapter()
name = adapter._get_social_account_name(
{"login": "octocat"},
"verified@example.com",
)
assert name == "octocat"
def test_social_account_name_falls_back_to_username_then_email():
adapter = ProwlerSocialAccountAdapter()
username_name = adapter._get_social_account_name(
{"name": "ab", "login": None, "username": " monalisa "},
"verified@example.com",
)
email_name = adapter._get_social_account_name({}, " verified@example.com ")
assert username_name == "monalisa"
assert email_name == "verified@example.com"
def test_social_account_name_trims_and_limits_provider_name():
adapter = ProwlerSocialAccountAdapter()
max_length = User._meta.get_field("name").max_length
trimmed_name = adapter._get_social_account_name(
{"name": " Ada Lovelace "},
"verified@example.com",
)
limited_name = adapter._get_social_account_name(
{"name": "a" * (max_length + 1)},
"verified@example.com",
)
assert trimmed_name == "Ada Lovelace"
assert limited_name == "a" * max_length
def test_social_account_name_rejects_missing_identity():
adapter = ProwlerSocialAccountAdapter()
with pytest.raises(
ValueError,
match="Social account does not provide a valid user identity",
):
adapter._get_social_account_name({}, "")
def test_save_user_applies_normalized_social_account_name(rf):
adapter = ProwlerSocialAccountAdapter()
request = rf.post("/")
request.session = {}
sociallogin = MagicMock(spec=SocialLogin)
sociallogin.provider = MagicMock()
sociallogin.provider.id = "github"
sociallogin.account = MagicMock()
sociallogin.account.extra_data = {"name": None, "login": " octocat "}
user = User(email="verified@example.com")
user.save = MagicMock()
invitation = SimpleNamespace(tenant_id="tenant-id")
with (
patch("api.adapters.super") as mock_super,
patch("api.adapters.transaction.atomic"),
patch("api.adapters.write_db_alias"),
patch.object(adapter, "_get_invitation_token", return_value="token"),
patch(
"api.adapters.accept_invitation_for_user",
return_value=(invitation, True),
),
):
mock_super.return_value.save_user.return_value = user
saved_user = adapter.save_user(request, sociallogin)
assert saved_user.name == "octocat"
assert request.prowler_invitation_token == "token"
@pytest.mark.django_db
class TestProwlerSocialAccountAdapter:
def test_get_user_by_email_returns_user(self, create_test_user):
@@ -382,6 +488,65 @@ class TestProwlerSocialAccountAdapter:
role=Membership.RoleChoices.MEMBER,
).exists()
def test_save_user_routes_initial_allauth_write_to_admin_and_resets_on_error(
self, rf
):
adapter = ProwlerSocialAccountAdapter()
request = rf.get("/")
request.session = {}
sociallogin = _oauth_sociallogin(
User(name="Frank", email="frank-routing@example.com")
)
def fail_after_checking_write_route(*_args, **_kwargs):
assert (
MainRouter().db_for_write(User, instance=sociallogin.user)
== MainRouter.admin_db
)
raise RuntimeError("Stop after checking the write route.")
with (
patch("api.adapters.super") as mock_super,
patch("api.adapters.transaction.atomic"),
patch.object(MainRouter, "admin_db", "admin"),
pytest.raises(RuntimeError, match="Stop after checking the write route"),
):
mock_super.return_value.save_user.side_effect = (
fail_after_checking_write_route
)
adapter.save_user(request, sociallogin)
assert get_write_db_alias() is None
def test_save_user_rolls_back_all_signup_records_on_downstream_error(self, rf):
adapter = ProwlerSocialAccountAdapter()
request = rf.post("/")
request.session = {}
email = "frank-rollback@example.com"
sociallogin = _real_oauth_sociallogin(
User(name="Frank", email=email),
uid="frank-rollback-google-account",
)
tenants_before = Tenant.objects.count()
with (
patch(
"api.adapters.rls_transaction",
side_effect=RuntimeError("Simulated downstream failure."),
),
pytest.raises(RuntimeError, match="Simulated downstream failure"),
):
adapter.save_user(request, sociallogin)
assert not User.objects.filter(email=email).exists()
assert not SocialAccount.objects.filter(
provider="google",
uid="frank-rollback-google-account",
).exists()
assert not EmailAddress.objects.filter(email=email).exists()
assert Tenant.objects.count() == tenants_before
assert get_write_db_alias() is None
def test_save_user_saml_sets_session_flag(self, rf):
adapter = ProwlerSocialAccountAdapter()
request = rf.get("/")
@@ -402,3 +567,104 @@ class TestProwlerSocialAccountAdapter:
mock_super.return_value.save_user.return_value = mock_user
adapter.save_user(request, sociallogin)
assert request.session["saml_user_created"] == "123"
@pytest.mark.requires_test_admin_alias
@pytest.mark.django_db(transaction=True, databases=["default", "admin"])
class TestProwlerSocialAccountAdapterMultiDatabase:
@staticmethod
def _production_router():
return patch.object(django_router, "routers", [MainRouter()])
def test_save_user_rolls_back_across_production_database_aliases(self, rf):
adapter = ProwlerSocialAccountAdapter()
request = rf.post("/")
request.session = {}
email = "frank-multidb-rollback@example.com"
sociallogin = _real_oauth_sociallogin(
User(name="Frank", email=email),
uid="frank-multidb-rollback-google-account",
)
tenants_before = Tenant.objects.using("admin").count()
assert connections["default"] is not connections["admin"]
assert (
connections["default"].settings_dict["NAME"]
== connections["admin"].settings_dict["NAME"]
)
def fail_after_allauth_save(*_args, **_kwargs):
assert sociallogin.user._state.db == MainRouter.admin_db
assert connections["default"].get_autocommit()
assert not connections["admin"].get_autocommit()
raise RuntimeError("Simulated downstream failure.")
with (
patch.object(MainRouter, "admin_db", "admin"),
self._production_router(),
patch("api.adapters.rls_transaction", side_effect=fail_after_allauth_save),
pytest.raises(RuntimeError, match="Simulated downstream failure"),
):
adapter.save_user(request, sociallogin)
assert connections["default"].get_autocommit()
assert connections["admin"].get_autocommit()
assert not User.objects.using("default").filter(email=email).exists()
assert not User.objects.using("admin").filter(email=email).exists()
assert (
not SocialAccount.objects.using("admin")
.filter(
provider="google",
uid="frank-multidb-rollback-google-account",
)
.exists()
)
assert not EmailAddress.objects.using("admin").filter(email=email).exists()
assert Tenant.objects.using("admin").count() == tenants_before
assert get_write_db_alias() is None
def test_save_user_commits_complete_signup_across_production_aliases(self, rf):
adapter = ProwlerSocialAccountAdapter()
request = rf.post("/")
request.session = {}
email = "frank-multidb-success@example.com"
sociallogin = _real_oauth_sociallogin(
User(name="Frank", email=email),
uid="frank-multidb-success-google-account",
)
with (
patch.object(MainRouter, "admin_db", "admin"),
self._production_router(),
):
user = adapter.save_user(request, sociallogin)
user = User.objects.using("admin").get(id=user.id)
assert user.email == email
assert (
SocialAccount.objects.using("admin")
.filter(
user_id=user.id,
provider="google",
uid="frank-multidb-success-google-account",
)
.exists()
)
assert (
EmailAddress.objects.using("admin")
.filter(
user_id=user.id,
email=email,
verified=True,
)
.exists()
)
assert (
Membership.objects.using("admin")
.filter(
user_id=user.id,
role=Membership.RoleChoices.OWNER,
)
.exists()
)
assert get_write_db_alias() is None
@@ -154,6 +154,88 @@ def test_execute_query_serializes_graph(
assert result["relationships"][0]["label"] == "OWNS"
def test_execute_query_injects_provider_label_when_migrated(
attack_paths_query_definition_factory,
sink_backend_stub,
):
# On migrated graphs the predefined cypher must be scoped with the
# provider label so the planner seeds from the label index instead of a
# global label scan (the Neptune cartesian/timeout fix).
definition = attack_paths_query_definition_factory(
id="aws-iam",
name="IAM",
short_description="Short desc",
description="",
cypher="MATCH (aws:AWSAccount)--(target_role:AWSRole) RETURN target_role",
parameters=[],
)
provider_id = "test-provider-123"
plabel = get_provider_label(provider_id)
parameters = {"provider_uid": "123"}
graph_result = MagicMock()
graph_result.nodes = []
graph_result.relationships = []
sink_backend_stub.execute_read_query.return_value = graph_result
# Injection is gated on `is_migrated`, not the sink (it is a pure string
# transform), so `neo4j` exercises the same code path as Neptune here.
views_helpers.execute_query(
"db-tenant-test",
definition,
parameters,
provider_id=provider_id,
scan=MagicMock(is_migrated=True, sink_backend="neo4j"),
)
executed_cypher = sink_backend_stub.execute_read_query.call_args[0][1]
assert executed_cypher != definition.cypher
# Both node patterns are scoped - not just one. Asserting the exact rewrite
# (rather than `f":{plabel}" in executed_cypher`, which a partial injection
# would still satisfy) proves every node got the label and that injection
# inserted labels and nothing else.
assert executed_cypher == (
f"MATCH (aws:AWSAccount:{plabel})--(target_role:AWSRole:{plabel}) "
"RETURN target_role"
)
# Parameters are passed through untouched.
assert sink_backend_stub.execute_read_query.call_args[0][2] == parameters
def test_execute_query_does_not_inject_label_when_deprecated(
attack_paths_query_definition_factory,
sink_backend_stub,
):
# The pre-cutover legacy catalog runs on the old sink and is removed after
# the Neptune cutover, so it must run verbatim (no injection).
definition = attack_paths_query_definition_factory(
id="aws-iam",
name="IAM",
short_description="Short desc",
description="",
cypher="MATCH (aws:AWSAccount)--(target_role:AWSRole) RETURN target_role",
parameters=[],
)
parameters = {"provider_uid": "123"}
graph_result = MagicMock()
graph_result.nodes = []
graph_result.relationships = []
sink_backend_stub.execute_read_query.return_value = graph_result
views_helpers.execute_query(
"db-tenant-test",
definition,
parameters,
provider_id="test-provider-123",
scan=MagicMock(is_migrated=False, sink_backend="neo4j"),
)
sink_backend_stub.execute_read_query.assert_called_once_with(
"db-tenant-test", definition.cypher, parameters
)
def test_execute_query_wraps_graph_errors(
attack_paths_query_definition_factory,
sink_backend_stub,
@@ -0,0 +1,292 @@
"""
Structural validation tests for Attack Paths query definitions.
These tests verify that each query in the AWS_QUERIES registry meets the
schema and convention requirements documented in
`docs/developer-guide/attack-paths-queries.mdx` without requiring a live
graph connection. They deliberately assert the conventions that keep queries
functional and Neptune-compatible: list-typed policy properties are reached
through `HAS_*` child-item traversals (never read as node fields), predicate
functions unsupported on Neptune (`any`/`all`/`none`, regex `=~`) are absent,
the finding probe is typed and filters only on `status`, and the `RETURN`
shape preserves the `paths, dpf, dpfr` contract.
"""
import re
import pytest
from api.attack_paths.queries.aws import (
AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY,
AWS_QUERIES,
AWS_SSO_PRIVESC_PERMISSION_SET_ESCALATION,
AWS_STS_PRIVESC_CROSS_ACCOUNT_TRUST,
AWS_STS_PRIVESC_WILDCARD_TRUST,
)
from api.attack_paths.queries.types import (
AttackPathsQueryDefinition,
AttackPathsQueryOutcome,
)
# The pathfinding.cloud privilege-escalation queries added for PROWLER-2278.
NEW_PATHFINDING_QUERIES = [
AWS_STS_PRIVESC_CROSS_ACCOUNT_TRUST,
AWS_STS_PRIVESC_WILDCARD_TRUST,
AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY,
AWS_SSO_PRIVESC_PERMISSION_SET_ESCALATION,
]
# Cypher keywords that indicate a mutating query (not allowed; queries are read-only).
MUTATING_KEYWORDS = re.compile(
r"\b(CREATE|MERGE|SET|DELETE|REMOVE|DETACH)\b", re.IGNORECASE
)
# CALL subquery: unsupported by Neptune openCypher.
CALL_SUBQUERY_PATTERN = re.compile(r"\bCALL\s*\{", re.IGNORECASE)
# Predicate functions that are not part of the openCypher spec and fail on Neptune.
NEPTUNE_UNSUPPORTED_PREDICATES = re.compile(r"\b(any|all|none)\s*\(", re.IGNORECASE)
# The list-typed policy properties that are exploded into child item nodes at sync
# time and popped off the parent, so reading them as a field always yields null.
NORMALIZED_STATEMENT_FIELDS = ("action", "resource", "notaction", "notresource")
class TestNewPathfindingQueriesRegistered:
"""Every new query is present in the AWS_QUERIES registry."""
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_query_in_registry(self, query):
assert query in AWS_QUERIES
class TestNewPathfindingQueriesSchema:
"""Required fields and naming conventions for each new query."""
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_is_query_definition_instance(self, query):
assert isinstance(query, AttackPathsQueryDefinition)
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_id_is_kebab_case(self, query):
assert re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", query.id), (
f"Query id '{query.id}' is not kebab-case"
)
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_id_starts_with_aws(self, query):
assert query.id.startswith("aws-")
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_provider_is_aws(self, query):
assert query.provider == "aws"
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_has_name(self, query):
assert query.name and len(query.name) > 5
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_has_short_description(self, query):
assert query.short_description and len(query.short_description) > 10
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_has_description(self, query):
assert query.description and len(query.description) > 20
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_has_attribution(self, query):
assert query.attribution is not None
assert "pathfinding.cloud" in query.attribution.text
assert query.attribution.link.startswith("https://pathfinding.cloud/paths/")
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_parameters_is_list(self, query):
assert isinstance(query.parameters, list)
class TestNewPathfindingQueriesCypher:
"""Cypher content, conventions, and Neptune compatibility."""
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_cypher_not_empty(self, query):
assert query.cypher and len(query.cypher.strip()) > 0
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_cypher_under_10000_chars(self, query):
assert len(query.cypher) < 10000, (
f"Query {query.id} exceeds 10,000 character limit "
f"({len(query.cypher)} chars)"
)
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_cypher_uses_provider_uid_parameter(self, query):
assert "$provider_uid" in query.cypher, (
f"Query {query.id} missing $provider_uid parameter"
)
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_finding_label_interpolated(self, query):
# The f-string should have interpolated PROWLER_FINDING_LABEL already.
assert "PROWLER_FINDING_LABEL" not in query.cypher, (
f"Query {query.id} has unresolved PROWLER_FINDING_LABEL "
"(f-string not applied)"
)
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_finding_probe_is_typed_and_status_scoped(self, query):
# The finding probe must be typed HAS_FINDING (so Neptune applies an inline
# edge filter) and gate on FAIL status only. ProwlerFinding nodes carry no
# provider_uid property, so a probe that filters on it never matches.
assert re.search(
r"-\[pfr:HAS_FINDING\]-\(pf:ProwlerFinding \{status: 'FAIL'\}\)",
query.cypher,
), f"Query {query.id} does not use the typed, status-scoped finding probe"
assert "provider_uid:$provider_uid}" not in query.cypher.replace(" ", ""), (
f"Query {query.id} filters the finding node on a non-existent "
"provider_uid property"
)
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_cypher_is_read_only(self, query):
cypher_no_comments = _strip_comment_lines(query.cypher)
match = MUTATING_KEYWORDS.search(cypher_no_comments)
assert match is None, (
f"Query {query.id} contains mutating keyword: '{match.group()}'"
)
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_cypher_no_call_subquery(self, query):
assert not CALL_SUBQUERY_PATTERN.search(query.cypher), (
f"Query {query.id} uses a CALL subquery (not Neptune-compatible)"
)
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_cypher_no_neptune_unsupported_predicates(self, query):
match = NEPTUNE_UNSUPPORTED_PREDICATES.search(query.cypher)
assert match is None, (
f"Query {query.id} uses '{match.group().strip()}' predicate function; "
"use size([x IN list WHERE pred]) > 0 for Neptune compatibility"
)
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_cypher_no_regex_operator(self, query):
assert "=~" not in query.cypher, (
f"Query {query.id} uses the regex operator '=~'; "
"use CONTAINS / STARTS WITH for Neptune compatibility"
)
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_cypher_does_not_read_normalized_list_fields(self, query):
# action/resource/notaction/notresource are materialized as child item nodes
# and popped off AWSPolicyStatement, so `stmt.action` etc. are always null.
for field in NORMALIZED_STATEMENT_FIELDS:
assert not re.search(rf"\.{field}\b", query.cypher), (
f"Query {query.id} reads the normalized list field "
f"'.{field}' as a node property; traverse the HAS_"
f"{field.upper()} edge to the child item node instead"
)
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_cypher_preserves_return_contract(self, query):
assert re.search(
r"RETURN paths, collect\(DISTINCT pf\) as dpf, "
r"collect\(DISTINCT pfr\) as dpfr",
query.cypher,
), f"Query {query.id} does not preserve the 'paths, dpf, dpfr' RETURN contract"
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
def test_cypher_anchored_on_account(self, query):
assert "(aws:AWSAccount {id: $provider_uid})" in query.cypher, (
f"Query {query.id} is not anchored on the AWSAccount node"
)
class TestNewPathfindingQueriesAccuracy:
"""Query-specific contracts that prevent known false positives."""
def test_wildcard_trust_is_presented_as_a_manual_review_candidate(self):
query = AWS_STS_PRIVESC_WILDCARD_TRUST
text = f"{query.name} {query.short_description} {query.description}".lower()
assert all(
word in text
for word in ("potential", "effect", "condition", "manual review")
)
def test_permissions_boundary_removal_is_scoped_to_the_same_user(self):
query = AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY
assert "(principal:AWSUser)" in query.cypher
assert (
"(stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)"
in query.cypher
)
assert "principal.arn" in query.cypher
assert "manual review" in query.description.lower()
def test_permission_set_escalation_requires_global_resources(self):
query = AWS_SSO_PRIVESC_PERMISSION_SET_ESCALATION
for suffix in ("", "2", "3"):
resource_match = (
f"(stmt{suffix})-[:HAS_RESOURCE]->"
f"(res{suffix}:AWSPolicyStatementResourceItem)"
)
assert resource_match in query.cypher
assert f"WHERE res{suffix}.value = '*'" in query.cypher
class TestAllQueriesUniqueIds:
"""No duplicate IDs in the full registry."""
def test_no_duplicate_ids_in_aws_queries(self):
ids = [q.id for q in AWS_QUERIES]
duplicates = sorted({qid for qid in ids if ids.count(qid) > 1})
assert not duplicates, f"Duplicate query IDs found: {duplicates}"
class TestQueryOutcomes:
"""Every query carries a valid outcome (the graph's terminal impact)."""
def test_every_query_has_an_outcome(self):
# Completeness guard: a new query must be given an outcome, so the UI can
# always render a terminal outcome node.
missing = [q.id for q in AWS_QUERIES if q.outcome is None]
assert not missing, f"Queries without an outcome: {missing}"
def test_every_outcome_is_a_valid_member(self):
for query in AWS_QUERIES:
assert isinstance(query.outcome, AttackPathsQueryOutcome)
assert query.outcome.value.kind
assert query.outcome.value.label
@pytest.mark.parametrize(
"query, expected",
[
(
AWS_STS_PRIVESC_CROSS_ACCOUNT_TRUST,
AttackPathsQueryOutcome.PRIVILEGE_ESCALATION,
),
(
AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY,
AttackPathsQueryOutcome.PRIVILEGE_ESCALATION,
),
],
ids=lambda v: getattr(v, "id", getattr(v, "name", "")),
)
def test_representative_outcomes(self, query, expected):
assert query.outcome is expected
def test_inventory_outcome_is_partial(self):
assert AttackPathsQueryOutcome.RESOURCE_INVENTORY.value.partial is True
def test_realized_outcomes_are_not_partial(self):
for outcome in (
AttackPathsQueryOutcome.CODE_EXECUTION,
AttackPathsQueryOutcome.PRIVILEGE_ESCALATION,
AttackPathsQueryOutcome.PUBLIC_EXPOSURE,
):
assert outcome.value.partial is False
def _strip_comment_lines(cypher: str) -> str:
"""Drop `//` comment lines so keyword scans ignore prose in comments."""
return "\n".join(
line for line in cypher.split("\n") if not line.strip().startswith("//")
)
@@ -0,0 +1,133 @@
"""
Structural validation for the pathfinding.cloud service privilege-escalation
Attack Paths queries added in PROWLER-2279.
These assert the conventions documented in
`docs/developer-guide/attack-paths-queries.mdx`: list-typed policy properties are
reached through `HAS_*` child-item traversals (never read as node fields),
predicate functions unsupported on Neptune (`any`/`all`/`none`, regex `=~`) are
absent, the finding probe is typed and filters only on `status`, and the
`RETURN` shape preserves the `paths, dpf, dpfr` contract.
"""
import re
import pytest
from api.attack_paths.queries.aws import AWS_QUERIES
from api.attack_paths.queries.types import AttackPathsQueryDefinition
# IDs of the queries introduced for PROWLER-2279 (pathfinding.cloud coverage).
PATHFINDING_2279_QUERY_IDS = [
"aws-batch-privesc-passrole-submit-job",
"aws-braket-privesc-passrole-create-job",
"aws-cognito-privesc-passrole-set-identity-pool-roles",
"aws-ecs-privesc-passrole-start-existing-task",
"aws-emr-privesc-passrole-run-job-flow",
"aws-emrserverless-privesc-passrole-start-job",
"aws-gamelift-privesc-passrole-create-fleet",
"aws-glue-privesc-passrole-create-session",
"aws-imagebuilder-privesc-passrole-create-image",
"aws-kinesisanalytics-privesc-passrole-create-application",
"aws-omics-privesc-passrole-start-run",
"aws-scheduler-privesc-passrole-create-schedule",
"aws-ssm-privesc-passrole-automation",
"aws-stepfunctions-privesc-passrole-create-state-machine",
"aws-batch-privesc-submit-existing-job",
"aws-codedeploy-privesc-create-deployment",
"aws-stepfunctions-privesc-update-state-machine",
"aws-iam-privesc-delete-role-boundary-assume-role",
"aws-sso-privesc-attach-managed-policy-permission-set",
"aws-sso-privesc-put-inline-policy-permission-set",
]
_BY_ID = {q.id: q for q in AWS_QUERIES}
NEW_QUERIES = [_BY_ID[qid] for qid in PATHFINDING_2279_QUERY_IDS if qid in _BY_ID]
NEPTUNE_UNSUPPORTED_PREDICATES = re.compile(r"\b(any|all|none)\s*\(", re.IGNORECASE)
NORMALIZED_STATEMENT_FIELDS = ("action", "resource", "notaction", "notresource")
def test_all_2279_queries_registered():
missing = [qid for qid in PATHFINDING_2279_QUERY_IDS if qid not in _BY_ID]
assert not missing, f"queries not registered in AWS_QUERIES: {missing}"
class TestServicePrivescQuerySchema:
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_is_query_definition(self, query):
assert isinstance(query, AttackPathsQueryDefinition)
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_id_kebab_and_aws_prefixed(self, query):
assert query.id.startswith("aws-")
assert re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", query.id)
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_provider_is_aws(self, query):
assert query.provider == "aws"
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_has_metadata(self, query):
assert query.name and len(query.name) > 5
assert query.short_description and len(query.short_description) > 10
assert query.description and len(query.description) > 20
assert isinstance(query.parameters, list)
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_attribution_links_pathfinding(self, query):
assert query.attribution is not None
assert "pathfinding.cloud" in query.attribution.text
assert query.attribution.link.startswith("https://pathfinding.cloud/paths/")
class TestServicePrivescQueryCypher:
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_anchored_and_provider_scoped(self, query):
assert "(aws:AWSAccount {id: $provider_uid})" in query.cypher
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_finding_label_interpolated(self, query):
assert "PROWLER_FINDING_LABEL" not in query.cypher
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_typed_status_scoped_finding_probe(self, query):
assert re.search(
r"-\[pfr:HAS_FINDING\]-\(pf:ProwlerFinding \{status: 'FAIL'\}\)",
query.cypher,
), f"{query.id} lacks the typed, status-scoped finding probe"
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_return_contract(self, query):
assert re.search(
r"RETURN paths, collect\(DISTINCT pf\) as dpf, "
r"collect\(DISTINCT pfr\) as dpfr",
query.cypher,
)
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_no_neptune_unsupported_predicates(self, query):
m = NEPTUNE_UNSUPPORTED_PREDICATES.search(query.cypher)
assert m is None, f"{query.id} uses '{m.group().strip()}' (not Neptune-safe)"
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_no_regex_operator(self, query):
assert "=~" not in query.cypher
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_does_not_read_normalized_list_fields(self, query):
for field in NORMALIZED_STATEMENT_FIELDS:
assert not re.search(rf"\.{field}\b", query.cypher), (
f"{query.id} reads normalized list field '.{field}' as a property; "
f"traverse the HAS_{field.upper()} edge instead"
)
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_read_only(self, query):
no_comments = "\n".join(
line
for line in query.cypher.split("\n")
if not line.strip().startswith("//")
)
assert not re.search(
r"\b(CREATE|MERGE|SET|DELETE|REMOVE|DETACH)\b", no_comments, re.IGNORECASE
)
@@ -4,11 +4,17 @@ from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from api.authentication import SSEAuthentication, TenantAPIKeyAuthentication
from api.authentication import (
OrphanedAPIKeyError,
SSEAuthentication,
TenantAPIKeyAuthentication,
)
from api.db_router import MainRouter
from api.models import TenantAPIKey
from django.db import connections
from django.db.models.query import QuerySet
from django.test import RequestFactory
from django.test.utils import CaptureQueriesContext
from rest_framework.exceptions import AuthenticationFailed
@@ -38,13 +44,12 @@ class TestTenantAPIKeyAuthentication:
request = request_factory.get("/")
# Call the method
entity, auth_dict = auth_backend._authenticate_credentials(
request, encrypted_key
)
validated_key = auth_backend._authenticate_credentials(request, encrypted_key)
# Verify that the entity is the user associated with the API key
assert entity == api_key.entity
assert entity.id == api_key.entity.id
assert validated_key.id == api_key.id
assert validated_key.entity == api_key.entity
assert validated_key.entity.id == api_key.entity.id
def test_authenticate_credentials_restores_manager_on_success(
self, auth_backend, api_keys_fixture, request_factory
@@ -231,6 +236,120 @@ class TestTenantAPIKeyAuthentication:
assert str(exc_info.value.detail) == "This API Key has been revoked."
def test_authenticate_credentials_orphaned_api_key(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test credential validation fails when the owning user no longer exists."""
api_key = api_keys_fixture[0]
_, encrypted_key = api_key._raw_key.split(TenantAPIKey.objects.separator, 1)
# `entity` is what `on_delete=SET_NULL` leaves behind when the owner is deleted
TenantAPIKey.objects.filter(id=api_key.id).update(entity=None)
request = request_factory.get("/")
with pytest.raises(OrphanedAPIKeyError):
auth_backend._authenticate_credentials(request, encrypted_key)
# The orphaned key is revoked on use, so it stops showing up as active
api_key.refresh_from_db()
assert api_key.revoked is True
def test_authenticate_orphaned_api_key(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test authentication fails with a key whose owning user was deleted.
Regression test: this used to raise `AttributeError: 'NoneType' object has no
attribute 'id'` while building the auth dict, which DRF re-raises as
`WrappedAttributeError` and turns into a 500 instead of a 401.
"""
api_key = api_keys_fixture[0]
raw_key = api_key._raw_key
TenantAPIKey.objects.filter(id=api_key.id).update(entity=None)
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
with pytest.raises(AuthenticationFailed) as exc_info:
auth_backend.authenticate(request)
assert str(exc_info.value.detail) == "No entity matching this api key."
# The orphaned key is revoked on use; retries fail the regular revoked check
api_key.refresh_from_db()
assert api_key.revoked is True
with pytest.raises(AuthenticationFailed) as exc_info:
auth_backend.authenticate(request)
assert str(exc_info.value.detail) == "This API Key has been revoked."
def test_authenticate_reads_the_api_key_once_under_a_row_lock(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test the API key is read a single time and the row is locked.
Validation, the `last_used_at` update and the claims must all come from the
same authoritative row: a second, unlocked lookup would reopen the window
where a key revoked in between still authenticates.
"""
api_key = api_keys_fixture[0]
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}"
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
auth_backend.authenticate(request)
api_key_selects = [
query["sql"]
for query in captured.captured_queries
if query["sql"].startswith("SELECT") and '"api_keys"' in query["sql"]
]
assert len(api_key_selects) == 1
assert "FOR UPDATE" in api_key_selects[0]
def test_authenticate_ignores_revocation_after_the_locked_read(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test the claims describe the row that was validated, not a later state.
Regression test: the key used to be looked up again to build the auth dict,
without rechecking `revoked` or `entity`. A key revoked or orphaned between
both reads still authenticated, and the claims came from that stale row. With
a single locked read the write below cannot land mid-authentication, and the
revocation only takes effect on the next request.
"""
api_key = api_keys_fixture[0]
entity_at_validation = api_key.entity
original_save = TenantAPIKey.save
def revoke_and_orphan_before_saving(instance, *args, **kwargs):
# Runs after validation, right before the claims are built: the exact
# window a concurrent revocation or user deletion used to slip into
TenantAPIKey.objects.filter(id=api_key.id).update(revoked=True, entity=None)
return original_save(instance, *args, **kwargs)
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}"
with patch.object(TenantAPIKey, "save", revoke_and_orphan_before_saving):
entity, auth_dict = auth_backend.authenticate(request)
assert entity == entity_at_validation
assert auth_dict["sub"] == str(entity_at_validation.id)
assert auth_dict["tenant_id"] == str(api_key.tenant_id)
assert auth_dict["api_key_prefix"] == api_key.prefix
# The revoked key is rejected from the next request on
with pytest.raises(AuthenticationFailed) as exc_info:
auth_backend.authenticate(request)
assert str(exc_info.value.detail) == "This API Key has been revoked."
def test_authenticate_expired_api_key(
self, auth_backend, create_test_user, tenants_fixture, request_factory
):
@@ -1,5 +1,6 @@
"""Unit tests for the Cypher sanitizer (validation + provider-label injection)."""
import re
from unittest.mock import patch
import pytest
@@ -22,6 +23,38 @@ def _inject(cypher: str) -> str:
return inject_provider_label(cypher, PROVIDER_ID)
# String literals and line comments can contain parentheses that look like node
# patterns; strip them first. Implemented here independently of the sanitizer so
# the node count is an oracle for the injector rather than a copy of its regexes.
_STRING_OR_COMMENT_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|//[^\n]*")
# A node pattern is `(`, not preceded by a word char (which would make it a
# function call), wrapping an optional variable, zero or more `:Label`s and an
# optional `{property map}` - and nothing else, which excludes parenthesized
# expressions such as `(a OR b)` in a WHERE clause.
_NODE_PATTERN_RE = re.compile(
r"(?<![\w`])\("
r"\s*(?:[a-zA-Z_]\w*)?"
r"(?:\s*:\s*(?:`[^`]*`|[a-zA-Z_]\w*))*"
r"(?:\s*\{[^{}]*\})?"
r"\s*\)"
)
def _count_node_patterns(cypher: str) -> int:
"""Count node patterns in a query, independently of the injector.
Injection appends exactly one provider label per node pattern, so the
number of injected labels must equal this count - proving *every* node is
scoped, not just one."""
stripped = _STRING_OR_COMMENT_RE.sub("", cypher)
return sum(
1
for match in _NODE_PATTERN_RE.finditer(stripped)
if match.group(0)[1:-1].strip()
)
def test_generic_inject_label_reuses_provider_injection_pipeline():
result = inject_label("MATCH (n:AWSRole)--(m) RETURN n, m", "_Tenant_test")
@@ -427,3 +460,66 @@ class TestValidation:
)
def test_allows_clean_queries(self, cypher):
validate_custom_query(cypher)
# ---------------------------------------------------------------------------
# Predefined-catalog injection (Option 1: label-scoped predefined queries)
# ---------------------------------------------------------------------------
def _all_predefined_queries():
"""Every predefined query in the migrated catalog, as (id, cypher)."""
from api.attack_paths.queries.registry import _QUERY_DEFINITIONS
return [
(definition.id, definition.cypher)
for definitions in _QUERY_DEFINITIONS.values()
for definition in definitions
]
_PREDEFINED_QUERIES = _all_predefined_queries()
class TestPredefinedCatalogInjection:
"""`execute_query` injects the provider label into predefined queries on
migrated graphs. The injection must be *lossless* for every catalog query:
it may only insert `:_Provider_{uuid}` tokens and must not otherwise alter
the cypher (which would corrupt a hand-authored query). This runs over the
whole catalog so a regex regression is caught for all queries at once.
Injection is a pure string transform, so it is sink-independent (the same
result is sent to Neo4j and Neptune)."""
def test_catalog_is_not_empty(self):
# Guard against the parametrized tests silently covering nothing.
assert len(_PREDEFINED_QUERIES) > 0
@pytest.mark.parametrize(
"cypher",
[cypher for _, cypher in _PREDEFINED_QUERIES],
ids=[query_id for query_id, _ in _PREDEFINED_QUERIES],
)
def test_injection_is_lossless(self, cypher):
injected = _inject(cypher)
# Every node pattern is scoped - not just one. A partial-injection
# regression that missed some nodes would still satisfy a bare
# `f":{LABEL}" in injected` check, so assert the label count matches the
# number of node patterns.
assert injected.count(f":{LABEL}") == _count_node_patterns(cypher)
# Stripping the injected tokens restores the query verbatim, proving
# injection changed nothing but the labels.
assert injected.replace(f":{LABEL}", "") == cypher
@pytest.mark.parametrize(
"cypher",
[cypher for _, cypher in _PREDEFINED_QUERIES],
ids=[query_id for query_id, _ in _PREDEFINED_QUERIES],
)
def test_injection_preserves_parameter_placeholders(self, cypher):
# Label injection must never touch `$param` bindings.
original_params = sorted(set(re.findall(r"\$\w+", cypher)))
injected_params = sorted(set(re.findall(r"\$\w+", _inject(cypher))))
assert injected_params == original_params
+68 -2
View File
@@ -1,7 +1,13 @@
from unittest.mock import patch
from unittest.mock import Mock, patch
import pytest
from api.db_router import MainRouter
from api.db_router import (
MainRouter,
get_write_db_alias,
reset_write_db_alias,
set_write_db_alias,
write_db_alias,
)
from api.rls import Tenant
from config.django.base import DATABASE_ROUTERS as PROD_DATABASE_ROUTERS
from django.conf import settings
@@ -26,6 +32,66 @@ class TestMainDatabaseRouter:
assert router.allow_migrate_model(MainRouter.admin_db, api_model)
assert not router.allow_migrate_model("default", api_model)
def test_scoped_write_alias_routes_api_models(self, router):
token = set_write_db_alias(MainRouter.admin_db)
try:
assert get_write_db_alias() == MainRouter.admin_db
assert router.db_for_write(Tenant) == MainRouter.admin_db
finally:
reset_write_db_alias(token)
assert get_write_db_alias() is None
assert router.db_for_write(Tenant) == "default"
def test_scoped_write_alias_restores_nested_context(self, router):
outer_token = set_write_db_alias("outer")
try:
assert router.db_for_write(Tenant) == "outer"
inner_token = set_write_db_alias(MainRouter.admin_db)
try:
assert router.db_for_write(Tenant) == MainRouter.admin_db
finally:
reset_write_db_alias(inner_token)
assert router.db_for_write(Tenant) == "outer"
finally:
reset_write_db_alias(outer_token)
assert get_write_db_alias() is None
assert router.db_for_write(Tenant) == "default"
def test_scoped_write_alias_does_not_override_admin_models(self, router):
token = set_write_db_alias("other")
try:
assert (
router.db_for_write(MigrationRecorder.Migration) == MainRouter.admin_db
)
finally:
reset_write_db_alias(token)
assert get_write_db_alias() is None
def test_write_db_alias_context_manager_resets_after_error(self, router):
fail = Mock(side_effect=RuntimeError("Simulated failure"))
with pytest.raises(RuntimeError, match="Simulated failure"):
with write_db_alias(MainRouter.admin_db):
assert get_write_db_alias() == MainRouter.admin_db
assert router.db_for_write(Tenant) == MainRouter.admin_db
fail()
fail.assert_called_once_with()
assert get_write_db_alias() is None
assert router.db_for_write(Tenant) == "default"
def test_write_db_alias_context_manager_ignores_empty_alias(self, router):
with write_db_alias(None):
assert get_write_db_alias() is None
assert router.db_for_write(Tenant) == "default"
assert get_write_db_alias() is None
def test_router_django_models(self, router):
assert router.db_for_read(MigrationRecorder.Migration) == MainRouter.admin_db
assert not router.db_for_read(MigrationRecorder.Migration) == "default"
+641 -32
View File
@@ -2,14 +2,17 @@ import json
from unittest.mock import ANY, Mock, patch
import pytest
from api.db_utils import rls_transaction
from api.models import (
Integration,
IntegrationProviderRelationship,
Membership,
ProviderGroup,
ProviderGroupMembership,
ProviderSecret,
Role,
RoleProviderGroupRelationship,
Scan,
User,
UserRoleRelationship,
)
@@ -666,6 +669,612 @@ class TestLimitedVisibility:
limited_admin_user, tenants_fixture[0]
)
@pytest.fixture
def hidden_provider_secret(self, aws_provider_pair):
hidden_provider = aws_provider_pair[1]
return ProviderSecret.objects.create(
tenant_id=hidden_provider.tenant_id,
provider=hidden_provider,
secret_type=ProviderSecret.TypeChoices.STATIC,
secret={
"aws_access_key_id": "hidden-key",
"aws_secret_access_key": "hidden-secret",
},
name="Hidden provider secret",
)
@pytest.fixture
def limited_provider_group(self, limited_admin_user):
return ProviderGroup.objects.get(name="limited_visibility_group")
@patch("api.v1.views.enqueue_scan_execution_on_commit")
def test_scan_create_out_of_scope_provider_is_rejected(
self,
mock_enqueue_scan,
authenticated_client_rbac_limited,
aws_provider_pair,
):
hidden_provider = aws_provider_pair[1]
response = authenticated_client_rbac_limited.post(
reverse("scan-list"),
data=json.dumps(
{
"data": {
"type": "scans",
"attributes": {"name": "Out of scope scan"},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": str(hidden_provider.id),
}
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not Scan.objects.filter(
provider=hidden_provider, name="Out of scope scan"
).exists()
mock_enqueue_scan.assert_not_called()
@patch("api.v1.views.enqueue_scan_execution_on_commit")
def test_scan_create_in_scope_provider_is_accepted(
self,
mock_enqueue_scan,
authenticated_client_rbac_limited,
aws_provider,
):
response = authenticated_client_rbac_limited.post(
reverse("scan-list"),
data=json.dumps(
{
"data": {
"type": "scans",
"attributes": {"name": "In scope scan"},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": str(aws_provider.id),
}
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_202_ACCEPTED
assert Scan.objects.filter(provider=aws_provider, name="In scope scan").exists()
mock_enqueue_scan.assert_called_once()
def test_provider_secret_retrieve_out_of_scope_returns_404(
self,
authenticated_client_rbac_limited,
hidden_provider_secret,
):
response = authenticated_client_rbac_limited.get(
reverse(
"providersecret-detail",
kwargs={"pk": hidden_provider_secret.id},
)
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_provider_secret_list_excludes_out_of_scope_provider(
self,
authenticated_client_rbac_limited,
hidden_provider_secret,
):
response = authenticated_client_rbac_limited.get(reverse("providersecret-list"))
assert response.status_code == status.HTTP_200_OK
assert str(hidden_provider_secret.id) not in {
item["id"] for item in response.json()["data"]
}
def test_provider_secret_create_out_of_scope_provider_is_rejected(
self,
authenticated_client_rbac_limited,
aws_provider_pair,
):
hidden_provider = aws_provider_pair[1]
response = authenticated_client_rbac_limited.post(
reverse("providersecret-list"),
data=json.dumps(
{
"data": {
"type": "provider-secrets",
"attributes": {
"name": "Out of scope secret",
"secret_type": ProviderSecret.TypeChoices.STATIC,
"secret": {
"aws_access_key_id": "hidden-key",
"aws_secret_access_key": "hidden-secret",
},
},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": str(hidden_provider.id),
}
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not ProviderSecret.objects.filter(provider=hidden_provider).exists()
def test_provider_secret_create_in_scope_provider_is_accepted(
self,
authenticated_client_rbac_limited,
aws_provider,
):
response = authenticated_client_rbac_limited.post(
reverse("providersecret-list"),
data=json.dumps(
{
"data": {
"type": "provider-secrets",
"attributes": {
"name": "In scope secret",
"secret_type": ProviderSecret.TypeChoices.STATIC,
"secret": {
"aws_access_key_id": "visible-key",
"aws_secret_access_key": "visible-secret",
},
},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": str(aws_provider.id),
}
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
assert ProviderSecret.objects.filter(provider=aws_provider).exists()
def test_provider_secret_update_out_of_scope_returns_404(
self,
authenticated_client_rbac_limited,
hidden_provider_secret,
):
response = authenticated_client_rbac_limited.patch(
reverse(
"providersecret-detail",
kwargs={"pk": hidden_provider_secret.id},
),
data=json.dumps(
{
"data": {
"type": "provider-secrets",
"id": str(hidden_provider_secret.id),
"attributes": {"name": "Updated hidden secret"},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
hidden_provider_secret.refresh_from_db()
assert hidden_provider_secret.name == "Hidden provider secret"
def test_provider_secret_delete_out_of_scope_returns_404(
self,
authenticated_client_rbac_limited,
hidden_provider_secret,
):
response = authenticated_client_rbac_limited.delete(
reverse(
"providersecret-detail",
kwargs={"pk": hidden_provider_secret.id},
)
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert ProviderSecret.objects.filter(id=hidden_provider_secret.id).exists()
def test_provider_group_create_out_of_scope_provider_is_rejected(
self,
authenticated_client_rbac_limited,
aws_provider_pair,
):
hidden_provider = aws_provider_pair[1]
response = authenticated_client_rbac_limited.post(
reverse("providergroup-list"),
data=json.dumps(
{
"data": {
"type": "provider-groups",
"attributes": {"name": "Out of scope group"},
"relationships": {
"providers": {
"data": [
{
"type": "providers",
"id": str(hidden_provider.id),
}
]
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not ProviderGroup.objects.filter(name="Out of scope group").exists()
def test_provider_group_create_in_scope_provider_is_accepted(
self,
authenticated_client_rbac_limited,
aws_provider,
):
response = authenticated_client_rbac_limited.post(
reverse("providergroup-list"),
data=json.dumps(
{
"data": {
"type": "provider-groups",
"attributes": {"name": "In scope group"},
"relationships": {
"providers": {
"data": [
{
"type": "providers",
"id": str(aws_provider.id),
}
]
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
provider_group = ProviderGroup.objects.get(name="In scope group")
assert set(provider_group.providers.all()) == {aws_provider}
def test_provider_group_update_out_of_scope_provider_is_rejected(
self,
authenticated_client_rbac_limited,
limited_provider_group,
aws_provider_pair,
):
visible_provider, hidden_provider = aws_provider_pair
response = authenticated_client_rbac_limited.patch(
reverse(
"providergroup-detail",
kwargs={"pk": limited_provider_group.id},
),
data=json.dumps(
{
"data": {
"type": "provider-groups",
"id": str(limited_provider_group.id),
"relationships": {
"providers": {
"data": [
{
"type": "providers",
"id": str(hidden_provider.id),
}
]
}
},
}
}
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert set(limited_provider_group.providers.all()) == {visible_provider}
def test_provider_group_relationship_create_out_of_scope_provider_is_rejected(
self,
authenticated_client_rbac_limited,
limited_provider_group,
aws_provider_pair,
):
hidden_provider = aws_provider_pair[1]
response = authenticated_client_rbac_limited.post(
reverse(
"provider_group-providers-relationship",
kwargs={"pk": limited_provider_group.id},
),
data={
"data": [
{"type": "providers", "id": str(hidden_provider.id)},
]
},
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not ProviderGroupMembership.objects.filter(
provider_group=limited_provider_group,
provider=hidden_provider,
).exists()
def test_provider_group_relationship_update_out_of_scope_provider_is_rejected(
self,
authenticated_client_rbac_limited,
limited_provider_group,
aws_provider_pair,
):
visible_provider, hidden_provider = aws_provider_pair
response = authenticated_client_rbac_limited.patch(
reverse(
"provider_group-providers-relationship",
kwargs={"pk": limited_provider_group.id},
),
data={
"data": [
{"type": "providers", "id": str(hidden_provider.id)},
]
},
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert set(limited_provider_group.providers.all()) == {visible_provider}
def test_provider_group_relationship_create_in_scope_provider_is_accepted(
self,
authenticated_client_rbac_limited,
limited_provider_group,
aws_provider_pair,
):
additional_provider = aws_provider_pair[1]
additional_group = ProviderGroup.objects.create(
tenant_id=additional_provider.tenant_id,
name="Additional visible group",
)
ProviderGroupMembership.objects.create(
tenant_id=additional_provider.tenant_id,
provider_group=additional_group,
provider=additional_provider,
)
RoleProviderGroupRelationship.objects.create(
tenant_id=additional_provider.tenant_id,
role=limited_provider_group.roles.get(),
provider_group=additional_group,
)
response = authenticated_client_rbac_limited.post(
reverse(
"provider_group-providers-relationship",
kwargs={"pk": limited_provider_group.id},
),
data={
"data": [
{"type": "providers", "id": str(additional_provider.id)},
]
},
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert ProviderGroupMembership.objects.filter(
provider_group=limited_provider_group,
provider=additional_provider,
).exists()
def test_provider_group_relationship_delete_out_of_scope_group_returns_404(
self,
authenticated_client_rbac_limited,
aws_provider_pair,
):
hidden_provider = aws_provider_pair[1]
hidden_group = ProviderGroup.objects.create(
tenant_id=hidden_provider.tenant_id,
name="Unassigned provider group",
)
ProviderGroupMembership.objects.create(
tenant_id=hidden_provider.tenant_id,
provider_group=hidden_group,
provider=hidden_provider,
)
response = authenticated_client_rbac_limited.delete(
reverse(
"provider_group-providers-relationship",
kwargs={"pk": hidden_group.id},
)
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert ProviderGroupMembership.objects.filter(
provider_group=hidden_group,
provider=hidden_provider,
).exists()
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.delete_provider_task.delay")
def test_provider_delete_out_of_scope_returns_404(
self,
mock_delete_task,
mock_task_get,
authenticated_client_rbac_limited,
aws_provider_pair,
tasks_fixture,
):
hidden_provider = aws_provider_pair[1]
prowler_task = tasks_fixture[0]
mock_delete_task.return_value.id = prowler_task.id
mock_task_get.return_value = prowler_task
response = authenticated_client_rbac_limited.delete(
reverse("provider-detail", kwargs={"pk": hidden_provider.id})
)
assert response.status_code == status.HTTP_404_NOT_FOUND
hidden_provider.refresh_from_db()
assert hidden_provider.is_deleted is False
mock_delete_task.assert_not_called()
mock_task_get.assert_not_called()
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.delete_provider_task.delay")
def test_provider_delete_in_scope_returns_202(
self,
mock_delete_task,
mock_task_get,
authenticated_client_rbac_limited,
aws_provider,
tasks_fixture,
):
prowler_task = tasks_fixture[0]
mock_delete_task.return_value.id = prowler_task.id
mock_task_get.return_value = prowler_task
response = authenticated_client_rbac_limited.delete(
reverse("provider-detail", kwargs={"pk": aws_provider.id})
)
assert response.status_code == status.HTTP_202_ACCEPTED
mock_delete_task.assert_called_once_with(
provider_id=str(aws_provider.id), tenant_id=ANY
)
mock_task_get.assert_called_once_with(id=prowler_task.id)
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.check_provider_connection_task.delay")
def test_provider_connection_out_of_scope_returns_404(
self,
mock_provider_connection,
mock_task_get,
authenticated_client_rbac_limited,
aws_provider_pair,
tasks_fixture,
):
hidden_provider = aws_provider_pair[1]
prowler_task = tasks_fixture[0]
mock_provider_connection.return_value.id = prowler_task.id
mock_task_get.return_value = prowler_task
response = authenticated_client_rbac_limited.post(
reverse("provider-connection", kwargs={"pk": hidden_provider.id})
)
assert response.status_code == status.HTTP_404_NOT_FOUND
mock_provider_connection.assert_not_called()
mock_task_get.assert_not_called()
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.check_provider_connection_task.delay")
def test_provider_connection_in_scope_returns_202(
self,
mock_provider_connection,
mock_task_get,
authenticated_client_rbac_limited,
aws_provider,
tasks_fixture,
):
prowler_task = tasks_fixture[0]
mock_provider_connection.return_value.id = prowler_task.id
mock_task_get.return_value = prowler_task
response = authenticated_client_rbac_limited.post(
reverse("provider-connection", kwargs={"pk": aws_provider.id})
)
assert response.status_code == status.HTTP_202_ACCEPTED
mock_provider_connection.assert_called_once_with(
provider_id=str(aws_provider.id), tenant_id=ANY
)
mock_task_get.assert_called_once_with(id=prowler_task.id)
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.schedule_provider_scan")
def test_schedule_daily_out_of_scope_returns_404(
self,
mock_schedule_scan,
mock_task_get,
authenticated_client_rbac_limited,
aws_provider_pair,
tasks_fixture,
):
hidden_provider = aws_provider_pair[1]
prowler_task = tasks_fixture[0]
mock_schedule_scan.return_value.id = prowler_task.id
mock_task_get.return_value = prowler_task
response = authenticated_client_rbac_limited.post(
reverse("schedule-daily"),
data=json.dumps(
{
"data": {
"type": "daily-schedules",
"attributes": {"provider_id": str(hidden_provider.id)},
}
}
),
content_type="application/vnd.api+json",
)
assert response.wsgi_request.content_type == "application/vnd.api+json"
assert response.status_code == status.HTTP_404_NOT_FOUND
mock_schedule_scan.assert_not_called()
mock_task_get.assert_not_called()
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.schedule_provider_scan")
def test_schedule_daily_in_scope_returns_202(
self,
mock_schedule_scan,
mock_task_get,
authenticated_client_rbac_limited,
aws_provider,
tasks_fixture,
):
prowler_task = tasks_fixture[0]
mock_schedule_scan.return_value.id = prowler_task.id
mock_task_get.return_value = prowler_task
response = authenticated_client_rbac_limited.post(
reverse("schedule-daily"),
data=json.dumps(
{
"data": {
"type": "daily-schedules",
"attributes": {"provider_id": str(aws_provider.id)},
}
}
),
content_type="application/vnd.api+json",
)
assert response.wsgi_request.content_type == "application/vnd.api+json"
assert response.status_code == status.HTTP_202_ACCEPTED
mock_schedule_scan.assert_called_once_with(aws_provider)
mock_task_get.assert_called_once_with(id=prowler_task.id)
def test_integrations(
self, authenticated_client_rbac_limited, integrations_fixture
):
@@ -683,22 +1292,6 @@ class TestLimitedVisibility:
response.json()["data"]["relationships"]["providers"]["meta"]["count"] == 1
)
@pytest.fixture
def jira_integration(self, tenants_fixture):
# Jira is a tenant-wide integration: it is not attached to any provider
return Integration.objects.create(
tenant_id=tenants_fixture[0].id,
enabled=True,
connected=True,
integration_type=Integration.IntegrationChoices.JIRA,
configuration={"projects": {"TEST": "Test project"}},
credentials={
"domain": "test",
"user_mail": "a@b.com",
"api_token": "token",
},
)
@pytest.fixture
def out_of_scope_integration(self, tenants_fixture, provider_factory):
tenant_id = tenants_fixture[0].id
@@ -724,7 +1317,7 @@ class TestLimitedVisibility:
self,
authenticated_client_rbac_limited,
integrations_fixture,
jira_integration,
jira_integration_fixture,
aws_provider_pair,
):
# Integration 2 is attached to both providers, so make both visible to the role
@@ -740,13 +1333,16 @@ class TestLimitedVisibility:
assert response.status_code == status.HTTP_200_OK
integration_ids = [item["id"] for item in response.json()["data"]]
# The tenant-wide Jira integration is visible without unlimited visibility
assert str(jira_integration.id) in integration_ids
assert str(jira_integration_fixture.id) in integration_ids
# Integrations attached to more than one visible provider are not duplicated
assert integration_ids.count(str(integrations_fixture[1].id)) == 1
assert response.json()["meta"]["pagination"]["count"] == len(integration_ids)
def test_integrations_list_without_provider_groups_keeps_tenant_wide_integration(
self, authenticated_client_rbac_limited, integrations_fixture, jira_integration
self,
authenticated_client_rbac_limited,
integrations_fixture,
jira_integration_fixture,
):
# A role with no provider group at all sees no provider, but still needs Jira
RoleProviderGroupRelationship.objects.all().delete()
@@ -755,7 +1351,7 @@ class TestLimitedVisibility:
assert response.status_code == status.HTTP_200_OK
integration_ids = [item["id"] for item in response.json()["data"]]
assert integration_ids == [str(jira_integration.id)]
assert integration_ids == [str(jira_integration_fixture.id)]
def test_integrations_include_providers_hides_out_of_scope_providers(
self, authenticated_client_rbac_limited, integrations_fixture, aws_provider_pair
@@ -774,13 +1370,19 @@ class TestLimitedVisibility:
assert str(hidden_provider.id) not in included_ids
def test_integrations_list_with_sparse_fields(
self, authenticated_client_rbac_limited, integrations_fixture
self,
authenticated_client_rbac_limited,
integrations_fixture,
jira_integration_fixture,
):
response = authenticated_client_rbac_limited.get(
reverse("integration-list"), {"fields[integrations]": "enabled"}
)
assert response.status_code == status.HTTP_200_OK
assert str(jira_integration_fixture.id) in [
item["id"] for item in response.json()["data"]
]
assert all(
list(item["attributes"].keys()) == ["enabled"]
for item in response.json()["data"]
@@ -816,7 +1418,10 @@ class TestLimitedVisibility:
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_integration_update_allowed_when_fully_visible(
self, authenticated_client_rbac_limited, integrations_fixture, jira_integration
self,
authenticated_client_rbac_limited,
integrations_fixture,
jira_integration_fixture,
):
# Integration 1 is only related to provider1, which the role can access
integration = integrations_fixture[0]
@@ -850,20 +1455,21 @@ class TestLimitedVisibility:
payload = {
"data": {
"type": "integrations",
"id": str(jira_integration.id),
"id": str(jira_integration_fixture.id),
"attributes": {"enabled": False},
}
}
response = authenticated_client_rbac_limited.patch(
reverse("integration-detail", kwargs={"pk": jira_integration.id}),
reverse("integration-detail", kwargs={"pk": jira_integration_fixture.id}),
data=json.dumps(payload),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
jira_integration.refresh_from_db()
assert jira_integration.enabled is False
with rls_transaction(str(jira_integration_fixture.tenant_id)):
jira_integration_fixture.refresh_from_db()
assert jira_integration_fixture.enabled is False
def test_integration_create_rejects_out_of_scope_provider(
self, authenticated_client_rbac_limited, aws_provider_pair
@@ -963,7 +1569,10 @@ class TestLimitedVisibility:
assert Integration.objects.filter(id=integration.id).exists()
def test_integration_delete_allowed_when_fully_visible(
self, authenticated_client_rbac_limited, integrations_fixture, jira_integration
self,
authenticated_client_rbac_limited,
integrations_fixture,
jira_integration_fixture,
):
# Integration 1 is only related to provider1, which the role can access
integration = integrations_fixture[0]
@@ -977,20 +1586,20 @@ class TestLimitedVisibility:
# Tenant-wide integrations have no provider restricting the role
response = authenticated_client_rbac_limited.delete(
reverse("integration-detail", kwargs={"pk": jira_integration.id})
reverse("integration-detail", kwargs={"pk": jira_integration_fixture.id})
)
assert response.status_code == status.HTTP_204_NO_CONTENT
def test_jira_issue_types_allowed_without_unlimited_visibility(
self, authenticated_client_rbac_limited, jira_integration
self, authenticated_client_rbac_limited, jira_integration_fixture
):
with patch("api.v1.views.initialize_prowler_integration") as mock_jira:
mock_jira.return_value.get_available_issue_types.return_value = ["Task"]
response = authenticated_client_rbac_limited.get(
reverse(
"integration-jira-issue-types",
kwargs={"integration_pk": jira_integration.id},
kwargs={"integration_pk": jira_integration_fixture.id},
),
{"project_key": "TEST"},
)
@@ -1026,12 +1635,12 @@ class TestLimitedVisibility:
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_jira_dispatches_allowed_without_unlimited_visibility(
self, authenticated_client_rbac_limited, jira_integration
self, authenticated_client_rbac_limited, jira_integration_fixture
):
response = authenticated_client_rbac_limited.post(
reverse(
"integration-jira-dispatches",
kwargs={"integration_pk": jira_integration.id},
kwargs={"integration_pk": jira_integration_fixture.id},
),
data=json.dumps({}),
content_type="application/vnd.api+json",
+164 -1
View File
@@ -1,9 +1,10 @@
import errno
import logging
from unittest.mock import MagicMock, patch
import pytest
from config.settings import sentry as sentry_settings
from config.settings.sentry import before_send
from config.settings.sentry import before_send, errno_fingerprint
def test_initialize_sentry_skips_without_dsn():
@@ -188,3 +189,165 @@ def test_before_send_passes_non_defunct_neo4j_log():
event = MagicMock()
assert before_send(event, hint) == event
def _filesystem_hint(exception, msg="Error generating output directory"):
"""Build the hint the logging integration sends for a filesystem failure."""
exc_info = (type(exception), exception, exception.__traceback__)
log_record = _make_log_record(msg)
log_record.exc_info = exc_info
setattr(
log_record,
sentry_settings.ERROR_CATEGORY_ATTRIBUTE,
sentry_settings.FILESYSTEM_ERROR_CATEGORY,
)
return {"log_record": log_record, "exc_info": exc_info}
@pytest.mark.parametrize(
("error_number", "message", "expected_suffix"),
[
(errno.ENOSPC, "No space left on device", "errno:ENOSPC"),
(errno.ENOENT, "No such file or directory", "errno:ENOENT"),
(errno.EACCES, "Permission denied", "errno:EACCES"),
],
)
def test_before_send_fingerprints_oserror_by_errno(
error_number, message, expected_suffix
):
"""Filesystem failures raised from the same call site must not be merged."""
event = {}
result = before_send(event, _filesystem_hint(OSError(error_number, message)))
assert result is event
assert event["fingerprint"] == ["{{ default }}", expected_suffix]
def test_before_send_fingerprints_differ_per_errno():
"""ENOSPC and ENOENT from the same call site produce different issues."""
enospc_event = {}
enoent_event = {}
before_send(
enospc_event, _filesystem_hint(OSError(errno.ENOSPC, "No space left on device"))
)
before_send(
enoent_event,
_filesystem_hint(OSError(errno.ENOENT, "No such file or directory")),
)
assert enospc_event["fingerprint"] != enoent_event["fingerprint"]
def test_before_send_fingerprints_wrapped_oserror():
"""The errno is found even when the OSError is wrapped by another error."""
try:
try:
raise OSError(errno.ENOSPC, "No space left on device")
except OSError as os_error:
raise RuntimeError("Error generating output directory") from os_error
except RuntimeError as wrapper:
event = {}
before_send(event, _filesystem_hint(wrapper))
assert event["fingerprint"] == ["{{ default }}", "errno:ENOSPC"]
def test_before_send_does_not_fingerprint_non_oserror():
"""Non-filesystem exceptions keep Sentry's default grouping."""
event = {}
result = before_send(event, _filesystem_hint(ValueError("boom")))
assert result is event
assert "fingerprint" not in event
def test_before_send_does_not_fingerprint_unrelated_oserror_log():
"""Only records declaring the filesystem category opt into the errno grouping."""
exception = OSError(errno.ENOSPC, "No space left on device")
log_record = _make_log_record("Unrelated failure")
exc_info = (OSError, exception, None)
log_record.exc_info = exc_info
event = {}
result = before_send(event, {"log_record": log_record, "exc_info": exc_info})
assert result is event
assert "fingerprint" not in event
def test_before_send_does_not_fingerprint_exception_events():
"""Exception events without a log record keep Sentry's default grouping."""
event = {}
result = before_send(
event,
{"exc_info": (OSError, OSError(errno.ENOSPC, "No space left on device"), None)},
)
assert result is event
assert "fingerprint" not in event
@pytest.mark.parametrize("fingerprint", [["scope-fingerprint"], []])
def test_before_send_keeps_existing_fingerprint(fingerprint):
"""A fingerprint set by a scope or an integration is never overwritten."""
expected_fingerprint = fingerprint.copy()
event = {"fingerprint": fingerprint}
before_send(
event, _filesystem_hint(OSError(errno.ENOSPC, "No space left on device"))
)
assert event["fingerprint"] == expected_fingerprint
def test_before_send_ignores_suppressed_context():
"""`raise ... from None` hides the context, so it must not group the event."""
try:
try:
raise OSError(errno.ENOSPC, "No space left on device")
except OSError:
raise RuntimeError("Error generating output directory") from None
except RuntimeError as wrapper:
event = {}
before_send(event, _filesystem_hint(wrapper))
assert "fingerprint" not in event
def test_errno_fingerprint_follows_implicit_context():
"""An implicit `raise` during handling still exposes the original errno."""
try:
try:
raise OSError(errno.EACCES, "Permission denied")
except OSError:
raise RuntimeError("Error generating output directory")
except RuntimeError as wrapper:
assert errno_fingerprint(wrapper) == "errno:EACCES"
def test_before_send_does_not_fingerprint_oserror_without_errno():
"""An OSError without errno has nothing to split the issue by."""
event = {}
before_send(event, _filesystem_hint(OSError("no errno here")))
assert "fingerprint" not in event
def test_errno_fingerprint_uses_raw_number_for_unknown_errno():
"""Unmapped errno values still split the issue instead of being dropped."""
assert errno_fingerprint(OSError(9999, "unknown")) == "errno:9999"
def test_errno_fingerprint_stops_on_self_referencing_chain():
"""A cyclic exception chain must not hang the fingerprint lookup."""
first = ValueError("first")
second = ValueError("second")
first.__cause__ = second
second.__cause__ = first
assert errno_fingerprint(first) is None
@@ -6,10 +6,13 @@ from api.v1.serializer_utils.integrations import (
from api.v1.serializer_utils.providers import ProviderSecretField
from api.v1.serializers import (
ImageProviderSecret,
IntegrationSerializer,
IntegrationUpdateSerializer,
KubernetesProviderSecret,
OracleCloudProviderSecret,
)
from rest_framework.exceptions import ValidationError
from rest_framework.test import APIRequestFactory
class TestS3ConfigSerializer:
@@ -352,3 +355,25 @@ current-context: test-context
assert not serializer.is_valid()
assert "kubeconfig_content" in serializer.errors
@pytest.mark.django_db
class TestIntegrationSerializerJiraDomain:
"""The serialized Jira `domain` must not reach the model instance."""
@pytest.mark.parametrize(
"serializer_class", [IntegrationSerializer, IntegrationUpdateSerializer]
)
def test_to_representation_does_not_mutate_configuration(
self, serializer_class, jira_integration_fixture
):
# `IntegrationUpdateSerializer` exposes a `HyperlinkedIdentityField`
context = {"request": APIRequestFactory().get("/")}
representation = serializer_class(
jira_integration_fixture, context=context
).data
assert representation["configuration"]["domain"] == "test"
assert jira_integration_fixture.configuration == {
"projects": {"TEST": "Test project"}
}
+520 -8
View File
@@ -3,9 +3,11 @@ import io
import json
import os
import tempfile
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from threading import Event, Lock
from types import SimpleNamespace
from unittest.mock import ANY, MagicMock, Mock, patch
from urllib.parse import parse_qs, urlparse
@@ -17,10 +19,12 @@ from allauth.account.models import EmailAddress
from allauth.socialaccount.models import SocialAccount, SocialApp
from api.attack_paths import (
AttackPathsQueryDefinition,
AttackPathsQueryOutcome,
AttackPathsQueryParameterDefinition,
)
from api.compliance import get_compliance_frameworks
from api.db_router import MainRouter
from api.db_utils import rls_transaction
from api.models import (
AttackSurfaceOverview,
ComplianceOverviewSummary,
@@ -65,6 +69,7 @@ from api.v1.views import (
)
from botocore.exceptions import ClientError, NoCredentialsError
from celery import states
from celery.utils.saferepr import saferepr
from conftest import (
API_JSON_CONTENT_TYPE,
TEST_PASSWORD,
@@ -73,8 +78,9 @@ from conftest import (
today_after_n_days,
)
from django.conf import settings
from django.db import connection
from django.db import close_old_connections, connection, connections
from django.db.models import Count
from django.db.models.signals import pre_delete
from django.http import JsonResponse
from django.test import RequestFactory
from django.test.utils import CaptureQueriesContext
@@ -514,6 +520,50 @@ class TestUserViewSet:
assert error_field in response.json()["errors"][0]["source"]["pointer"]
@pytest.mark.requires_test_admin_alias
@pytest.mark.django_db(transaction=True, databases=["default", "admin"])
class TestTenantDeletionTransactions:
@patch("api.v1.views.delete_tenant_task.apply_async")
def test_delete_rolls_back_memberships_when_user_cleanup_fails(
self,
delete_tenant_mock,
authenticated_client,
tenants_fixture,
):
assert connections["default"] is not connections["admin"]
_, tenant, _ = tenants_fixture
exclusive_user = User.objects.create_user(
name="exclusive user",
password=TEST_PASSWORD,
email="exclusive-user@example.com",
)
membership = Membership.objects.create(
user=exclusive_user,
tenant=tenant,
role=Membership.RoleChoices.MEMBER,
)
def fail_user_cleanup(*, instance, **kwargs):
if instance.pk == exclusive_user.pk:
raise RuntimeError("Simulated user cleanup failure.")
pre_delete.connect(fail_user_cleanup, sender=User)
try:
with (
patch.object(MainRouter, "admin_db", "admin"),
pytest.raises(RuntimeError, match=r"Simulated user cleanup failure\."),
):
authenticated_client.delete(
reverse("tenant-detail", kwargs={"pk": tenant.id})
)
finally:
pre_delete.disconnect(fail_user_cleanup, sender=User)
assert Membership.objects.using("admin").filter(pk=membership.pk).exists()
delete_tenant_mock.assert_not_called()
@pytest.mark.django_db
class TestTenantViewSet:
@pytest.fixture
@@ -5043,11 +5093,60 @@ class TestTaskViewSet:
reverse("task-detail", kwargs={"pk": task1.id}),
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["data"]["attributes"]["task_args"] == {
"kwarg1": "value1"
}
assert (
response.json()["data"]["attributes"]["name"]
== task1.task_runner_task.task_name
)
def test_tasks_retrieve_hides_tenant_id(
self, authenticated_client, tasks_fixture, tenants_fixture
):
task, *_ = tasks_fixture
task.task_runner_task.task_kwargs = json.dumps(
repr(
{
"tenant_id": str(tenants_fixture[0].id),
"enabled": True,
"scan_id": None,
"label": "True North",
}
)
)
task.task_runner_task.save(update_fields=["task_kwargs"])
response = authenticated_client.get(
reverse("task-detail", kwargs={"pk": task.id}),
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["data"]["attributes"]["task_args"] == {
"enabled": True,
"scan_id": None,
"label": "True North",
}
def test_tasks_retrieve_with_truncated_kwargs_returns_empty_task_args(
self, authenticated_client, tasks_fixture
):
task, *_ = tasks_fixture
kwargs_repr = saferepr(
{"finding_ids": [str(uuid4()) for _ in range(30)]}, maxlen=1024
)
assert "..." in kwargs_repr
task.task_runner_task.task_kwargs = json.dumps(kwargs_repr)
task.task_runner_task.save(update_fields=["task_kwargs"])
response = authenticated_client.get(
reverse("task-detail", kwargs={"pk": task.id}),
)
assert response.status_code == status.HTTP_200_OK
assert response.headers["Content-Type"] == API_JSON_CONTENT_TYPE
assert response.json()["data"]["attributes"]["task_args"] == {}
def test_tasks_invalid_retrieve(self, authenticated_client):
response = authenticated_client.get(
reverse("task-detail", kwargs={"pk": "invalid_id"})
@@ -5335,6 +5434,72 @@ class TestAttackPathsScanViewSet:
assert payload[0]["attributes"]["name"] == "RDS inventory"
assert payload[0]["attributes"]["parameters"][0]["name"] == "ip"
def test_attack_paths_queries_expose_outcome(
self,
authenticated_client,
aws_provider,
scans_fixture,
create_attack_paths_scan,
):
provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
)
definitions = [
AttackPathsQueryDefinition(
id="aws-lambda-passrole",
name="Lambda passrole",
short_description="Pass a role to a new Lambda function.",
description="Pass a role to a new Lambda function and run code as it.",
provider=provider.provider,
cypher="MATCH (n) RETURN n",
outcome=AttackPathsQueryOutcome.CODE_EXECUTION,
),
AttackPathsQueryDefinition(
id="aws-rds-inventory",
name="RDS inventory",
short_description="List account RDS assets.",
description="List account RDS assets.",
provider=provider.provider,
cypher="MATCH (n) RETURN n",
outcome=AttackPathsQueryOutcome.RESOURCE_INVENTORY,
),
AttackPathsQueryDefinition(
id="aws-no-outcome",
name="No outcome",
short_description="A query without an outcome.",
description="A query without an outcome.",
provider=provider.provider,
cypher="MATCH (n) RETURN n",
),
]
with patch("api.v1.views.get_queries_for_provider", return_value=definitions):
response = authenticated_client.get(
reverse(
"attack-paths-scans-queries", kwargs={"pk": attack_paths_scan.id}
)
)
assert response.status_code == status.HTTP_200_OK
outcomes = {
item["id"]: item["attributes"]["outcome"]
for item in response.json()["data"]
}
assert outcomes["aws-lambda-passrole"] == {
"kind": "code_execution",
"label": "Code execution",
"partial": False,
}
assert outcomes["aws-rds-inventory"] == {
"kind": "resource_inventory",
"label": "Resource inventory",
"partial": True,
}
assert outcomes["aws-no-outcome"] is None
def test_attack_paths_queries_returns_404_when_catalog_missing(
self,
authenticated_client,
@@ -13446,6 +13611,73 @@ class TestIntegrationViewSet:
f"Expected type '{expected_type}' not found in included data"
)
# Serializing a Jira integration reads `configuration` to add the domain from the
# credentials, and a sparse fieldset can leave that field out of the representation
def test_integrations_list_sparse_fields_without_configuration(
self, authenticated_client, jira_integration_fixture
):
response = authenticated_client.get(
reverse("integration-list"),
{"fields[integrations]": "enabled,integration_type"},
)
assert response.status_code == status.HTTP_200_OK
attributes = response.json()["data"][0]["attributes"]
assert sorted(attributes.keys()) == ["enabled", "integration_type"]
def test_integrations_retrieve_sparse_fields_without_configuration(
self, authenticated_client, jira_integration_fixture
):
response = authenticated_client.get(
reverse("integration-detail", kwargs={"pk": jira_integration_fixture.id}),
{"fields[integrations]": "enabled,integration_type"},
)
assert response.status_code == status.HTTP_200_OK
assert "configuration" not in response.json()["data"]["attributes"]
def test_integrations_partial_update_sparse_fields_without_configuration(
self, authenticated_client, jira_integration_fixture
):
data = {
"data": {
"type": "integrations",
"id": str(jira_integration_fixture.id),
"attributes": {"enabled": False},
}
}
url = reverse("integration-detail", kwargs={"pk": jira_integration_fixture.id})
response = authenticated_client.patch(
f"{url}?fields[integrations]=enabled,integration_type",
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
assert "configuration" not in response.json()["data"]["attributes"]
with rls_transaction(str(jira_integration_fixture.tenant_id)):
jira_integration_fixture.refresh_from_db()
assert jira_integration_fixture.enabled is False
# Omitting `configuration` from the fieldset must not rewrite it, and the
# serialized `domain` must not leak into the stored value
assert jira_integration_fixture.configuration == {
"projects": {"TEST": "Test project"}
}
def test_integrations_retrieve_jira_keeps_domain_in_configuration(
self, authenticated_client, jira_integration_fixture
):
response = authenticated_client.get(
reverse("integration-detail", kwargs={"pk": jira_integration_fixture.id})
)
assert response.status_code == status.HTTP_200_OK
configuration = response.json()["data"]["attributes"]["configuration"]
assert configuration["domain"] == "test"
assert configuration["projects"] == {"TEST": "Test project"}
@pytest.mark.parametrize(
"integration_type, configuration, credentials",
[
@@ -14441,6 +14673,37 @@ class TestSAMLConfigurationViewSet:
assert not SAMLConfiguration.objects.filter(id=config.id).exists()
@pytest.mark.django_db
class TestSAMLACSView:
def test_get_is_not_allowed(self, client, saml_setup):
response = client.get(
reverse(
"saml_acs",
kwargs={"organization_slug": saml_setup["domain"]},
)
)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
assert response.headers["Allow"] == "POST"
assert "saml-acs-session" not in response.cookies
def test_post_is_forwarded_to_allauth(self, client, saml_setup):
response = client.post(
reverse(
"saml_acs",
kwargs={"organization_slug": saml_setup["domain"]},
),
data={"SAMLResponse": "test-saml-response"},
)
assert response.status_code == status.HTTP_302_FOUND
assert response.url == reverse(
"saml_finish_acs",
kwargs={"organization_slug": saml_setup["domain"]},
)
assert "saml-acs-session" in response.cookies
@pytest.mark.django_db
class TestTenantFinishACSView:
def test_dispatch_skips_if_user_not_authenticated(self, monkeypatch):
@@ -14793,19 +15056,94 @@ class TestTenantFinishACSView:
# Verify no new role was created
assert Role.objects.using(MainRouter.admin_db).count() == roles_before
def test_dispatch_assigns_no_role_to_new_user_when_usertype_missing(
@pytest.mark.parametrize(
(
"existing_role_attributes",
"existing_suffixes",
"expected_role_name",
"expected_role_created",
),
[
(None, (), "read_only", True),
({"unlimited_visibility": True}, (), "read_only", False),
(
{"manage_users": True, "unlimited_visibility": True},
(
("read_only_0", {"unlimited_visibility": True}),
("read_only_1", {"unlimited_visibility": True}),
),
"read_only_0",
False,
),
(
{"manage_users": True, "unlimited_visibility": True},
(
(
"read_only_0",
{"manage_users": True, "unlimited_visibility": True},
),
("read_only_1", {"unlimited_visibility": True}),
),
"read_only_1",
False,
),
({"unlimited_visibility": False}, (), "read_only_0", True),
],
ids=[
"creates-role",
"reuses-safe-role",
"reuses-first-safe-suffixed-role",
"skips-unsafe-suffixed-role",
"avoids-restricted-visibility",
],
)
def test_dispatch_assigns_read_only_role_when_usertype_missing(
self,
create_test_user,
tenants_fixture,
saml_setup,
settings,
monkeypatch,
existing_role_attributes,
existing_suffixes,
expected_role_name,
expected_role_created,
):
"""Test that a user without roles gets none assigned when userType is missing"""
"""Test safe fallback role assignment when userType is missing"""
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
user = create_test_user
tenant = tenants_fixture[0]
roles_before = Role.objects.using(MainRouter.admin_db).count()
other_tenant = tenants_fixture[1]
other_tenant_role = Role.objects.using(MainRouter.admin_db).create(
name="read_only",
tenant=other_tenant,
unlimited_visibility=True,
)
other_tenant_relationship = UserRoleRelationship.objects.using(
MainRouter.admin_db
).create(
user=user,
role=other_tenant_role,
tenant=other_tenant,
)
existing_role = None
if existing_role_attributes is not None:
existing_role = Role.objects.using(MainRouter.admin_db).create(
name="read_only",
tenant=tenant,
**existing_role_attributes,
)
for role_name, role_attributes in existing_suffixes:
Role.objects.using(MainRouter.admin_db).create(
name=role_name,
tenant=tenant,
**role_attributes,
)
roles_before = (
Role.objects.using(MainRouter.admin_db).filter(tenant=tenant).count()
)
social_account = SocialAccount(
user=user,
@@ -14858,12 +15196,44 @@ class TestTenantFinishACSView:
assert response.status_code == 302
# Verify no role was created or assigned
assert Role.objects.using(MainRouter.admin_db).count() == roles_before
assert not (
# Verify the fallback role was created or reused with read-only access
expected_role_count = roles_before + expected_role_created
assert (
Role.objects.using(MainRouter.admin_db).filter(tenant=tenant).count()
== expected_role_count
)
role = Role.objects.using(MainRouter.admin_db).get(
name=expected_role_name, tenant=tenant
)
if existing_role is not None and expected_role_name == "read_only":
assert role == existing_role
assert not role.manage_users
assert not role.manage_account
assert not role.manage_billing
assert not role.manage_providers
assert not role.manage_integrations
assert not role.manage_scans
assert role.unlimited_visibility
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=user, role=role, tenant_id=tenant.id)
.exists()
)
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(
id=other_tenant_relationship.id,
user=user,
role=other_tenant_role,
tenant_id=other_tenant.id,
)
.exists()
)
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=user, tenant_id=tenant.id)
.exists()
.count()
== 1
)
# Membership is still created so the user belongs to the tenant
@@ -14873,6 +15243,131 @@ class TestTenantFinishACSView:
.exists()
)
@pytest.mark.django_db(transaction=True)
def test_dispatch_serializes_concurrent_fallback_role_assignment(
self,
create_test_user,
tenants_fixture,
saml_setup,
monkeypatch,
):
"""Test concurrent callbacks assign only one fallback role"""
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
user = create_test_user
tenant = tenants_fixture[0]
Role.objects.using(MainRouter.admin_db).create(
name="read_only",
tenant=tenant,
manage_users=True,
unlimited_visibility=True,
)
social_account = SocialAccount(
user=user,
provider="saml",
extra_data={
"firstName": ["John"],
"lastName": ["Doe"],
"organization": ["testing_company"],
},
)
# Without the user lock, both callbacks reach this query before either
# creates a fallback. With the lock, the first callback times out here
# while the second waits for the transaction to finish.
second_role_check_reached = Event()
concurrent_role_checks_detected = Event()
role_check_count_lock = Lock()
role_check_count = 0
original_role_check = TenantFinishACSView._user_has_tenant_role
def synchronize_role_checks(user_id, tenant_id):
nonlocal role_check_count
with role_check_count_lock:
role_check_count += 1
is_first_role_check = role_check_count == 1
if role_check_count == 2:
second_role_check_reached.set()
if is_first_role_check and second_role_check_reached.wait(timeout=1):
concurrent_role_checks_detected.set()
return original_role_check(user_id, tenant_id)
def dispatch_callback():
close_old_connections()
try:
thread_user = User.objects.using(MainRouter.admin_db).get(pk=user.pk)
request = RequestFactory().get(
reverse(
"saml_finish_acs",
kwargs={"organization_slug": saml_setup["domain"]},
)
)
request.user = thread_user
request.session = {}
response = TenantFinishACSView.as_view()(
request, organization_slug=saml_setup["domain"]
)
return response
finally:
close_old_connections()
with (
patch(
"allauth.socialaccount.providers.saml.views.get_app_or_404"
) as mock_get_app_or_404,
patch(
"allauth.socialaccount.models.SocialApp.objects.get"
) as mock_socialapp_get,
patch(
"allauth.socialaccount.models.SocialAccount.objects.get"
) as mock_sa_get,
patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
patch("api.models.User.objects.get") as mock_user_get,
patch.object(
TenantFinishACSView,
"_user_has_tenant_role",
side_effect=synchronize_role_checks,
),
):
mock_get_app_or_404.return_value = MagicMock(
provider="saml",
client_id=saml_setup["domain"],
name="Test App",
settings={},
)
mock_sa_get.return_value = social_account
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
mock_saml_config_get.return_value = SimpleNamespace(
email_domain=saml_setup["domain"], tenant=tenant
)
mock_user_get.side_effect = lambda *_args, **_kwargs: User.objects.using(
MainRouter.admin_db
).get(pk=user.pk)
with ThreadPoolExecutor(max_workers=2) as executor:
responses = list(executor.map(lambda _: dispatch_callback(), range(2)))
assert role_check_count == 2
assert not concurrent_role_checks_detected.is_set()
for response in responses:
assert response.status_code == status.HTTP_302_FOUND
parsed_redirect = urlparse(response.url)
assert parsed_redirect.path == "/sso-complete"
assert set(parse_qs(parsed_redirect.query)) == {"id"}
relationships = UserRoleRelationship.objects.using(MainRouter.admin_db).filter(
user=user, tenant_id=tenant.id
)
assert relationships.count() == 1
assert relationships.get().role.name == "read_only_0"
assert (
Role.objects.using(MainRouter.admin_db)
.filter(tenant=tenant, name__startswith="read_only_")
.count()
== 1
)
def test_dispatch_skips_role_mapping_when_last_manage_account_user_maps_to_new_role(
self,
create_test_user,
@@ -15748,6 +16243,23 @@ class TestTenantApiKeyViewSet:
data = response.json()["data"]
assert len(data) == len(api_keys_fixture)
def test_api_keys_list_with_orphaned_key(
self, authenticated_client, api_keys_fixture
):
"""Test listing keys whose owner was deleted: `entity` is serialized as null."""
orphaned_key = api_keys_fixture[0]
TenantAPIKey.objects.filter(id=orphaned_key.id).update(entity=None)
response = authenticated_client.get(reverse("api-key-list"))
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == len(api_keys_fixture)
serialized_key = next(
item for item in data if item["id"] == str(orphaned_key.id)
)
assert serialized_key["relationships"]["entity"]["data"] is None
def test_api_keys_list_empty(self, authenticated_client, tenants_fixture):
"""Test listing API keys when none exist returns empty list."""
response = authenticated_client.get(reverse("api-key-list"))
+18
View File
@@ -6,9 +6,11 @@ from api.exceptions import (
TaskNotFoundException,
)
from api.models import Provider, StateChoices, Task
from api.rbac.permissions import get_providers
from api.v1.serializers import TaskSerializer
from django.http import QueryDict
from django.urls import reverse
from django.utils.functional import cached_property
from django_celery_results.models import TaskResult
from rest_framework import status
from rest_framework.exceptions import ValidationError
@@ -33,6 +35,22 @@ class DisablePaginationMixin:
return super().paginate_queryset(queryset)
class ProviderVisibilityMixin:
@cached_property
def provider_queryset(self):
if self.user_role.unlimited_visibility:
return Provider.objects.filter(tenant_id=self.request.tenant_id)
return get_providers(self.user_role)
def get_provider_queryset(self):
return self.provider_queryset
def get_serializer_context(self):
context = super().get_serializer_context()
context["provider_queryset"] = self.get_provider_queryset()
return context
class PaginateByPkMixin:
"""
Mixin to paginate on a list of PKs (cheaper than heavy JOINs),
+123 -22
View File
@@ -1,8 +1,10 @@
import base64
import json
import logging
from datetime import UTC, datetime, timedelta
import yaml
from api.celery_utils import decode_celery_field
from api.db_router import MainRouter
from api.exceptions import ConflictException
from api.models import (
@@ -59,6 +61,7 @@ from api.v1.serializer_utils.lighthouse import (
from api.v1.serializer_utils.processors import ProcessorConfigField
from api.v1.serializer_utils.providers import ProviderSecretField
from api.validators import validate_lighthouse_openai_compatible_base_url
from config.custom_logging import BackendLogger
from django.conf import settings
from django.contrib.auth import authenticate
from django.contrib.auth.models import update_last_login
@@ -79,6 +82,8 @@ from rest_framework_simplejwt.settings import api_settings
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.utils import get_md5_hash_password
logger = logging.getLogger(BackendLogger.API)
# Base
@@ -124,6 +129,20 @@ class RLSSerializer(BaseModelSerializerV1):
return super().create(validated_data)
class ScopedProviderFieldMixin:
provider_field_name = "provider"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
provider_queryset = self.context.get("provider_queryset")
provider_field = self.fields.get(self.provider_field_name)
if provider_queryset is None or provider_field is None:
return
related_field = getattr(provider_field, "child_relation", provider_field)
related_field.queryset = provider_queryset
class StateEnumSerializerField(serializers.ChoiceField):
def __init__(self, **kwargs):
kwargs["choices"] = StateChoices.choices
@@ -310,6 +329,15 @@ class TokenSwitchTenantSerializer(BaseSerializerV1):
# Users
class ActiveMembershipRelatedField(SerializerMethodResourceRelatedField):
def to_representation(self, value):
representation = super().to_representation(value)
representation["meta"] = {
"active": str(value.tenant_id) == str(self.context["request"].tenant_id),
}
return representation
class UserSerializer(BaseModelSerializerV1):
"""
Serializer for the User model.
@@ -371,6 +399,12 @@ class UserSerializer(BaseModelSerializerV1):
)
class UserMeSerializer(UserSerializer):
memberships = ActiveMembershipRelatedField(
many=True, read_only=True, source="memberships", method_name="get_memberships"
)
class UserIncludeSerializer(UserSerializer):
class Meta:
model = User
@@ -607,13 +641,24 @@ class TaskSerializer(RLSSerializer, TaskBase):
@extend_schema_field(serializers.JSONField())
def get_task_args(self, obj):
task_args = self.get_json_field(obj, "task_kwargs")
# Celery task_kwargs are stored as a double string JSON in the database when not empty
if isinstance(task_args, str):
task_args = json.loads(task_args.replace("'", '"').replace("None", "null"))
# Remove tenant_id from task_kwargs if present
task_args.pop("tenant_id", None)
task_kwargs = (
getattr(obj.task_runner_task, "task_kwargs", None)
if obj.task_runner_task
else None
)
try:
task_args = decode_celery_field(task_kwargs, {})
if not isinstance(task_args, dict):
raise ValueError("Decoded task kwargs must be a dictionary")
except ValueError:
logger.warning(
"Unable to decode task kwargs for task %s; returning empty task_args.",
obj.id,
)
return {}
task_args = task_args.copy()
task_args.pop("tenant_id", None)
return task_args
@staticmethod
@@ -693,7 +738,10 @@ class MembershipIncludeSerializer(serializers.ModelSerializer):
# Provider Groups
class ProviderGroupSerializer(RLSSerializer, BaseWriteSerializer):
class ProviderGroupSerializer(
ScopedProviderFieldMixin, RLSSerializer, BaseWriteSerializer
):
provider_field_name = "providers"
providers = serializers.ResourceRelatedField(
queryset=Provider.objects.all(), many=True, required=False
)
@@ -851,9 +899,27 @@ class ProviderGroupMembershipSerializer(RLSSerializer, BaseWriteSerializer):
help_text="List of resource identifier objects representing providers.",
)
def get_providers(self, validated_data):
provider_ids = {item["id"] for item in validated_data["providers"]}
provider_queryset = self.context.get("provider_queryset")
if provider_queryset is None:
provider_queryset = Provider.objects.filter(
tenant_id=self.context.get("tenant_id")
)
providers = list(provider_queryset.filter(id__in=provider_ids))
if {provider.id for provider in providers} != provider_ids:
raise serializers.ValidationError(
{
"providers": (
"One or more providers do not exist or are not accessible."
)
}
)
return providers
def create(self, validated_data):
provider_ids = [item["id"] for item in validated_data["providers"]]
providers = Provider.objects.filter(id__in=provider_ids)
providers = self.get_providers(validated_data)
tenant_id = self.context.get("tenant_id")
new_relationships = [
@@ -869,8 +935,7 @@ class ProviderGroupMembershipSerializer(RLSSerializer, BaseWriteSerializer):
return self.context.get("provider_group")
def update(self, instance, validated_data):
provider_ids = [item["id"] for item in validated_data["providers"]]
providers = Provider.objects.filter(id__in=provider_ids)
providers = self.get_providers(validated_data)
tenant_id = self.context.get("tenant_id")
instance.providers.clear()
@@ -1109,7 +1174,9 @@ class ScanIncludeSerializer(RLSSerializer):
}
class ScanCreateSerializer(RLSSerializer, BaseWriteSerializer):
class ScanCreateSerializer(
ScopedProviderFieldMixin, RLSSerializer, BaseWriteSerializer
):
class Meta:
model = Scan
# TODO: add mutelist when implemented
@@ -1263,6 +1330,28 @@ class AttackPathsQuerySerializer(BaseSerializerV1):
attribution = AttackPathsQueryAttributionSerializer(allow_null=True, required=False)
provider = serializers.CharField()
parameters = AttackPathsQueryParameterSerializer(many=True)
# The terminal impact the query leads to (e.g. {"kind": "code_execution",
# "label": "Code execution"}), or null if the query has none. The UI renders
# this as the graph's terminal outcome node.
outcome = serializers.SerializerMethodField()
@extend_schema_field(
{
"type": "object",
"nullable": True,
"properties": {
"kind": {"type": "string"},
"label": {"type": "string"},
"partial": {"type": "boolean"},
},
}
)
def get_outcome(self, definition):
outcome = getattr(definition, "outcome", None)
if outcome is None:
return None
meta = outcome.value
return {"kind": meta.kind, "label": meta.label, "partial": meta.partial}
class JSONAPIMeta:
resource_name = "attack-paths-queries"
@@ -1974,7 +2063,9 @@ class ProviderSecretSerializer(RLSSerializer):
]
class ProviderSecretCreateSerializer(RLSSerializer, BaseWriteProviderSecretSerializer):
class ProviderSecretCreateSerializer(
ScopedProviderFieldMixin, RLSSerializer, BaseWriteProviderSecretSerializer
):
secret = ProviderSecretField(write_only=True)
class Meta:
@@ -2952,10 +3043,15 @@ class IntegrationSerializer(IntegrationProviderVisibilityMixin, RLSSerializer):
representation = self.hide_restricted_providers(
super().to_representation(instance)
)
if instance.integration_type == Integration.IntegrationChoices.JIRA:
representation["configuration"].update(
{"domain": instance.credentials.get("domain")}
)
# `configuration` is missing when the request asks for a subset of the fields
if (
instance.integration_type == Integration.IntegrationChoices.JIRA
and "configuration" in representation
):
representation["configuration"] = {
**representation["configuration"],
"domain": instance.credentials.get("domain"),
}
return representation
@@ -3089,11 +3185,16 @@ class IntegrationUpdateSerializer(
representation = self.hide_restricted_providers(
super().to_representation(instance)
)
# Ensure JIRA integrations show updated domain in configuration from credentials
if instance.integration_type == Integration.IntegrationChoices.JIRA:
representation["configuration"].update(
{"domain": instance.credentials.get("domain")}
)
# Ensure JIRA integrations show updated domain in configuration from credentials.
# `configuration` is missing when the request asks for a subset of the fields
if (
instance.integration_type == Integration.IntegrationChoices.JIRA
and "configuration" in representation
):
representation["configuration"] = {
**representation["configuration"],
"domain": instance.credentials.get("domain"),
}
return representation
+2 -1
View File
@@ -46,6 +46,7 @@ from api.v1.views import (
from django.http import JsonResponse
from django.urls import include, path
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from drf_spectacular.views import SpectacularRedocView
from rest_framework_nested import routers
@@ -194,7 +195,7 @@ urlpatterns = [
),
path(
"accounts/saml/<organization_slug>/acs/",
ACSView.as_view(),
require_POST(ACSView.as_view()),
name="saml_acs",
),
path(
+99 -30
View File
@@ -146,6 +146,7 @@ from api.v1.mixins import (
JsonApiFilterMixin,
PaginateByPkMixin,
ProviderFilterParamsMixin,
ProviderVisibilityMixin,
TaskManagementMixin,
)
from api.v1.serializers import (
@@ -237,6 +238,7 @@ from api.v1.serializers import (
TokenSocialLoginSerializer,
TokenSwitchTenantSerializer,
UserCreateSerializer,
UserMeSerializer,
UserRoleRelationshipSerializer,
UserSerializer,
UserUpdateSerializer,
@@ -813,6 +815,21 @@ class TenantFinishACSView(FinishACSView):
User.objects.using(MainRouter.admin_db).filter(id=saml_user_id).delete()
request.session.pop("saml_user_created", None)
@staticmethod
def _user_has_tenant_role(user_id, tenant_id):
return (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user_id=user_id, tenant_id=tenant_id)
.exists()
)
@staticmethod
def _is_read_only_fallback_role(role):
return (
not any(getattr(role, permission) for permission in Role.PERMISSION_FIELDS)
and role.unlimited_visibility
)
def dispatch(self, request, organization_slug):
try:
super().dispatch(request, organization_slug)
@@ -878,11 +895,56 @@ class TenantFinishACSView(FinishACSView):
user.name = "N/A"
user.save()
# Only remap roles when the IdP provides a userType attribute.
# Without it, the user's current roles are left untouched.
# Only remap existing roles when the IdP provides a userType attribute.
# Without it, preserve current roles or assign a read-only fallback.
role_name = (
extra.get("userType", [""])[0].strip() if extra.get("userType") else ""
)
if not role_name:
with rls_transaction(str(tenant.id), using=MainRouter.admin_db):
with transaction.atomic(using=MainRouter.admin_db):
# Serialize concurrent ACS callbacks for the same user.
(
User.objects.using(MainRouter.admin_db)
.select_for_update()
.only("id")
.get(pk=user_id)
)
user_has_roles = self._user_has_tenant_role(user_id, tenant.id)
if not user_has_roles:
read_only_defaults = dict.fromkeys(
Role.PERMISSION_FIELDS, False
)
read_only_defaults["unlimited_visibility"] = True
role, role_created = Role.objects.using(
MainRouter.admin_db
).get_or_create(
name="read_only",
tenant=tenant,
defaults=read_only_defaults,
)
role_is_read_only = self._is_read_only_fallback_role(role)
if not role_created and not role_is_read_only:
suffix = 0
while not role_created and not role_is_read_only:
role, role_created = Role.objects.using(
MainRouter.admin_db
).get_or_create(
name=f"read_only_{suffix}",
tenant=tenant,
defaults=read_only_defaults,
)
role_is_read_only = self._is_read_only_fallback_role(
role
)
suffix += 1
UserRoleRelationship.objects.using(
MainRouter.admin_db
).get_or_create(
user=user,
role=role,
defaults={"tenant": tenant},
)
if role_name:
with transaction.atomic(using=MainRouter.admin_db):
role = (
@@ -1052,6 +1114,8 @@ class UserViewSet(BaseUserViewset):
return UserCreateSerializer
elif self.action == "partial_update":
return UserUpdateSerializer
elif self.action == "me":
return UserMeSerializer
else:
return UserSerializer
@@ -1069,7 +1133,7 @@ class UserViewSet(BaseUserViewset):
@action(detail=False, methods=["get"], url_name="me")
def me(self, request):
user = self.request.user
serializer = UserSerializer(user, context=self.get_serializer_context())
serializer = self.get_serializer(user)
return Response(
data=serializer.data,
status=status.HTTP_200_OK,
@@ -1381,7 +1445,7 @@ class TenantViewSet(BaseTenantViewset):
if not membership or membership.role != Membership.RoleChoices.OWNER:
raise PermissionDenied("Only owners can delete a tenant.")
with transaction.atomic():
with transaction.atomic(using=MainRouter.admin_db):
# Collect user IDs from this tenant's memberships before deleting them
tenant_user_ids = set(
Membership.objects.using(MainRouter.admin_db)
@@ -1632,7 +1696,7 @@ class TenantMembersViewSet(BaseTenantViewset):
),
update=extend_schema(exclude=True),
)
class ProviderGroupViewSet(BaseRLSViewSet):
class ProviderGroupViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
queryset = ProviderGroup.objects.all()
serializer_class = ProviderGroupSerializer
filterset_class = ProviderGroupFilter
@@ -1653,14 +1717,13 @@ class ProviderGroupViewSet(BaseRLSViewSet):
self.required_permissions = [Permissions.MANAGE_PROVIDERS]
def get_queryset(self):
user_roles = get_role(self.request.user, self.request.tenant_id)
# Check if any of the user's roles have UNLIMITED_VISIBILITY
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all provider groups
return ProviderGroup.objects.prefetch_related("providers", "roles")
# Collect provider groups associated with the user's roles
return user_roles.provider_groups.all().prefetch_related("providers", "roles")
if self.user_role.unlimited_visibility:
queryset = ProviderGroup.objects.filter(tenant_id=self.request.tenant_id)
else:
queryset = self.user_role.provider_groups.filter(
tenant_id=self.request.tenant_id
)
return queryset.prefetch_related("providers", "roles")
def get_serializer_class(self):
if self.action == "create":
@@ -1701,7 +1764,9 @@ class ProviderGroupViewSet(BaseRLSViewSet):
},
),
)
class ProviderGroupProvidersRelationshipView(RelationshipView, BaseRLSViewSet):
class ProviderGroupProvidersRelationshipView(
ProviderVisibilityMixin, RelationshipView, BaseRLSViewSet
):
queryset = ProviderGroup.objects.all()
serializer_class = ProviderGroupMembershipSerializer
resource_name = "providers"
@@ -1711,7 +1776,9 @@ class ProviderGroupProvidersRelationshipView(RelationshipView, BaseRLSViewSet):
required_permissions = [Permissions.MANAGE_PROVIDERS]
def get_queryset(self):
return ProviderGroup.objects.filter(tenant_id=self.request.tenant_id)
if self.user_role.unlimited_visibility:
return ProviderGroup.objects.filter(tenant_id=self.request.tenant_id)
return self.user_role.provider_groups.filter(tenant_id=self.request.tenant_id)
def create(self, request, *args, **kwargs):
provider_group = self.get_object()
@@ -1733,6 +1800,7 @@ class ProviderGroupProvidersRelationshipView(RelationshipView, BaseRLSViewSet):
data={"providers": request.data},
context={
"provider_group": provider_group,
"provider_queryset": self.get_provider_queryset(),
"tenant_id": self.request.tenant_id,
"request": request,
},
@@ -1747,7 +1815,11 @@ class ProviderGroupProvidersRelationshipView(RelationshipView, BaseRLSViewSet):
serializer = self.get_serializer(
instance=provider_group,
data={"providers": request.data},
context={"tenant_id": self.request.tenant_id, "request": request},
context={
"provider_queryset": self.get_provider_queryset(),
"tenant_id": self.request.tenant_id,
"request": request,
},
)
serializer.is_valid(raise_exception=True)
serializer.save()
@@ -1864,7 +1936,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
)
@action(detail=True, methods=["post"], url_name="connection")
def connection(self, request, pk=None):
get_object_or_404(Provider, pk=pk)
self.get_object()
with transaction.atomic():
task = check_provider_connection_task.delay(
provider_id=pk, tenant_id=self.request.tenant_id
@@ -1882,7 +1954,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
)
def destroy(self, request, *args, pk=None, **kwargs):
provider = get_object_or_404(Provider, pk=pk)
provider = self.get_object()
provider.is_deleted = True
provider.save()
task_name = f"scan-perform-scheduled-{pk}"
@@ -2104,7 +2176,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
)
@method_decorator(CACHE_DECORATOR, name="list")
@method_decorator(CACHE_DECORATOR, name="retrieve")
class ScanViewSet(BaseRLSViewSet):
class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
queryset = Scan.objects.all()
serializer_class = ScanSerializer
http_method_names = ["get", "post", "patch"]
@@ -2133,13 +2205,7 @@ class ScanViewSet(BaseRLSViewSet):
self.required_permissions = [Permissions.MANAGE_SCANS]
def get_queryset(self):
user_roles = get_role(self.request.user, self.request.tenant_id)
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all scans
queryset = Scan.objects.filter(tenant_id=self.request.tenant_id)
else:
# User lacks permission, filter providers based on provider groups associated with the role
queryset = Scan.objects.filter(provider__in=get_providers(user_roles))
queryset = Scan.objects.filter(provider__in=self.get_provider_queryset())
return queryset.select_related("provider", "task")
def get_serializer_class(self):
@@ -2737,6 +2803,7 @@ class ScanViewSet(BaseRLSViewSet):
provider = Provider.objects.select_for_update().get(
id=provider.id,
tenant_id=self.request.tenant_id,
id__in=self.get_provider_queryset().values("id"),
)
active_scan = get_active_provider_scan(
self.request.tenant_id, provider.id
@@ -4311,7 +4378,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
)
@method_decorator(CACHE_DECORATOR, name="list")
@method_decorator(CACHE_DECORATOR, name="retrieve")
class ProviderSecretViewSet(BaseRLSViewSet):
class ProviderSecretViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
queryset = ProviderSecret.objects.all()
serializer_class = ProviderSecretSerializer
filterset_class = ProviderSecretFilter
@@ -4327,7 +4394,7 @@ class ProviderSecretViewSet(BaseRLSViewSet):
required_permissions = [Permissions.MANAGE_PROVIDERS]
def get_queryset(self):
return ProviderSecret.objects.filter(tenant_id=self.request.tenant_id)
return ProviderSecret.objects.filter(provider__in=self.get_provider_queryset())
def get_serializer_class(self):
if self.action == "create":
@@ -6608,7 +6675,7 @@ class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet):
responses={202: OpenApiResponse(response=TaskSerializer)},
)
)
class ScheduleViewSet(BaseRLSViewSet):
class ScheduleViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
# TODO: change to Schedule when implemented
queryset = Task.objects.none()
http_method_names = ["post"]
@@ -6635,7 +6702,9 @@ class ScheduleViewSet(BaseRLSViewSet):
serializer.is_valid(raise_exception=True)
provider_id = serializer.validated_data["provider_id"]
provider_instance = get_object_or_404(Provider, pk=provider_id)
provider_instance = get_object_or_404(
self.get_provider_queryset(), pk=provider_id
)
with transaction.atomic():
task = schedule_provider_scan(provider_instance)
+65 -1
View File
@@ -1,6 +1,20 @@
from errno import errorcode
import sentry_sdk
from config.env import env
# How many links of the __cause__/__context__ chain are inspected when looking
# for the OSError that actually caused the event.
MAX_EXCEPTION_CHAIN_DEPTH = 10
# LogRecord attribute describing what kind of failure the record reports, set by
# the caller through `logger.exception(..., extra={"error_category": ...})`.
ERROR_CATEGORY_ATTRIBUTE = "error_category"
# Category of records whose events are grouped by the errno of the underlying
# OSError. Only records that declare it opt into the errno fingerprint.
FILESYSTEM_ERROR_CATEGORY = "filesystem"
IGNORED_EXCEPTIONS = [
# Provider is not connected due to credentials errors
"is not connected",
@@ -80,6 +94,38 @@ IGNORED_EXCEPTIONS = [
]
def errno_fingerprint(exception):
"""
Return an errno-based fingerprint suffix for OSError-like exceptions.
Filesystem failures such as ENOSPC (disk full), ENOENT (missing mount point)
or EACCES (wrong permissions) are all OSError raised from the same call
site, so Sentry's default grouping merges them into a single issue even
when the exception is attached to the event. Appending the errno keeps the
default grouping and splits the issue per failure cause.
Only the part of the chain Sentry itself displays is inspected: a
`raise ... from None` sets __suppress_context__, so the implicit
__context__ is dropped from the event and must not group it either.
Returns None when no OSError with an errno is found in the exception chain.
"""
seen = set()
for _ in range(MAX_EXCEPTION_CHAIN_DEPTH):
if exception is None or id(exception) in seen:
break
seen.add(id(exception))
if isinstance(exception, OSError) and exception.errno is not None:
return f"errno:{errorcode.get(exception.errno, exception.errno)}"
if exception.__cause__ is not None:
exception = exception.__cause__
elif exception.__suppress_context__:
break
else:
exception = exception.__context__
return None
def before_send(event, hint):
"""
before_send handles the Sentry events in order to send them or not
@@ -115,10 +161,28 @@ def before_send(event, hint):
# Ignore exceptions with the ignored_exceptions
if "exc_info" in hint and hint["exc_info"]:
exc_value = str(hint["exc_info"][1])
exception = hint["exc_info"][1]
exc_value = str(exception)
if any(ignored in exc_value for ignored in IGNORED_EXCEPTIONS):
return None # Explicitly return None to drop the event
# Split filesystem issues per errno instead of grouping every failure raised
# from the same call site under a single issue. Only records that declare
# themselves as filesystem failures opt in, and a fingerprint already set by
# a scope or an integration always wins.
log_record = hint.get("log_record")
exc_info = hint.get("exc_info")
if (
log_record is not None
and exc_info
and getattr(log_record, ERROR_CATEGORY_ATTRIBUTE, None)
== FILESYSTEM_ERROR_CATEGORY
and "fingerprint" not in event
):
fingerprint_suffix = errno_fingerprint(exc_info[1])
if fingerprint_suffix:
event["fingerprint"] = ["{{ default }}", fingerprint_suffix]
return event
+43 -12
View File
@@ -70,6 +70,7 @@ API_JSON_CONTENT_TYPE = "application/vnd.api+json"
NO_TENANT_HTTP_STATUS = status.HTTP_401_UNAUTHORIZED
TEST_USER = "dev@prowler.com"
TEST_PASSWORD = "testing_psswd"
TEST_ADMIN_ALIAS = "admin"
TEST_REPLICA_ALIAS = "test_replica"
@@ -1449,6 +1450,26 @@ def integrations_fixture(aws_provider_pair):
return integration1, integration2
@pytest.fixture
def jira_integration_fixture(tenants_fixture):
# Jira is a tenant-wide integration: it is not attached to any provider, and its
# `domain` is read from the credentials when the integration is serialized
tenant_id = tenants_fixture[0].id
with rls_transaction(str(tenant_id)):
return Integration.objects.create(
tenant_id=tenant_id,
enabled=True,
connected=True,
integration_type=Integration.IntegrationChoices.JIRA,
configuration={"projects": {"TEST": "Test project"}},
credentials={
"domain": "test",
"user_mail": "a@b.com",
"api_token": "token",
},
)
@pytest.fixture
def backfill_scan_metadata_fixture(scans_fixture, findings_fixture):
for scan_instance in scans_fixture:
@@ -2539,26 +2560,36 @@ def finding_groups_title_variants_fixture(
return findings
def _ensure_mirrored_test_alias(alias: str) -> None:
default_database = settings.DATABASES["default"]
if alias not in settings.DATABASES:
settings.DATABASES[alias] = {
**default_database,
"TEST": {
**default_database.get("TEST", {}),
"MIRROR": "default",
},
}
django_connections.databases[alias] = settings.DATABASES[alias]
def pytest_collection_modifyitems(items):
"""Ensure test_rbac.py is executed first."""
items.sort(key=lambda item: 0 if "test_rbac.py" in item.nodeid else 1)
if any(item.get_closest_marker("requires_test_admin_alias") for item in items):
_ensure_mirrored_test_alias(TEST_ADMIN_ALIAS)
if any(item.get_closest_marker("requires_test_replica_alias") for item in items):
default_database = settings.DATABASES["default"]
if TEST_REPLICA_ALIAS not in settings.DATABASES:
settings.DATABASES[TEST_REPLICA_ALIAS] = {
**default_database,
"TEST": {
**default_database.get("TEST", {}),
"MIRROR": "default",
},
}
django_connections.databases[TEST_REPLICA_ALIAS] = settings.DATABASES[
TEST_REPLICA_ALIAS
]
_ensure_mirrored_test_alias(TEST_REPLICA_ALIAS)
def pytest_configure(config):
config.addinivalue_line(
"markers",
"requires_test_admin_alias: creates a test-only admin alias mirrored "
"to default",
)
config.addinivalue_line(
"markers",
"requires_test_replica_alias: creates a test-only replica alias mirrored "
+4 -1
View File
@@ -1,5 +1,6 @@
import os
import time
from datetime import UTC, datetime
from glob import glob
from api.db_router import READ_REPLICA_ALIAS, MainRouter
@@ -214,8 +215,10 @@ def get_security_hub_client_from_integration(
for region in set(all_security_hub_regions):
regions_status[region] = region in connection.enabled_regions
# Save regions information in the integration configuration
# Persist the successful connection check and regions information
with rls_transaction(tenant_id, using=MainRouter.default_db):
integration.connected = True
integration.connection_last_checked_at = datetime.now(tz=UTC)
integration.configuration["regions"] = regions_status
integration.save()
+7 -34
View File
@@ -18,12 +18,11 @@ This is the shared engine behind both the periodic Beat watchdog and the
`reconcile_orphan_tasks` management command.
"""
import ast
import json
from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from api.celery_utils import decode_celery_field
from celery import current_app, states
from celery.utils.log import get_task_logger
from django.db import connections
@@ -138,34 +137,6 @@ def revoke_task(task_result, terminate: bool = True) -> None:
logger.exception(f"Failed to revoke task {task_result.task_id}")
def _decode_celery_field(value, default):
"""Decode django-celery-results' stored task_args/task_kwargs to a Python object.
The backend stores them as a (sometimes double-encoded) repr/JSON string. An
empty or missing field returns ``default``; a non-empty value that cannot be
decoded raises ``ValueError`` so the caller can avoid re-enqueuing a task with
the wrong arguments.
"""
obj = value
for _ in range(2): # values can be double-encoded (a string holding a repr)
if not isinstance(obj, str):
break
text = obj.strip()
if not text:
return default
parsed = None
for parser in (ast.literal_eval, json.loads):
try:
parsed = parser(text)
break
except (ValueError, SyntaxError, TypeError):
continue
if parsed is None:
raise ValueError(f"undecodable celery field: {text[:120]!r}")
obj = parsed
return default if obj is None else obj
def reconcile_orphans(
grace_minutes: int = 2,
max_attempts: int = 3,
@@ -313,8 +284,10 @@ def _recover_task(task_result, max_attempts: int, window_hours: int) -> str:
return "failed"
try:
args = _decode_celery_field(args_repr, [])
kwargs = _decode_celery_field(kwargs_repr, {})
args = decode_celery_field(args_repr, [])
kwargs = decode_celery_field(kwargs_repr, {})
if not isinstance(args, (list, tuple)) or not isinstance(kwargs, dict):
raise ValueError("Stored task arguments have invalid types")
except ValueError:
logger.error(
"Orphan %s (%s): could not decode stored args/kwargs, not re-enqueuing",
@@ -324,8 +297,8 @@ def _recover_task(task_result, max_attempts: int, window_hours: int) -> str:
return "failed"
new_task_id = str(uuid4())
task_obj.apply_async(
args=list(args) if isinstance(args, (list, tuple)) else [],
kwargs=kwargs if isinstance(kwargs, dict) else {},
args=list(args),
kwargs=kwargs,
task_id=new_task_id,
)
logger.info(
+10 -1
View File
@@ -13,6 +13,7 @@ from api.db_utils import rls_transaction
from api.models import Provider, Scan, ScanSummary, StateChoices, ThreatScoreSnapshot
from celery.utils.log import get_task_logger
from config.django.base import DJANGO_TMP_OUTPUT_DIRECTORY
from config.settings.sentry import ERROR_CATEGORY_ATTRIBUTE, FILESYSTEM_ERROR_CATEGORY
from prowler.lib.check.compliance_models import (
Compliance,
get_bulk_compliance_frameworks_universal,
@@ -960,7 +961,15 @@ def generate_compliance_reports(
first_output_path = next(iter(output_paths.values()))
out_dir = str(Path(first_output_path).parent.parent)
except Exception as e:
logger.error("Error generating output directory: %s", e)
# logger.exception attaches the exception (and its traceback) to the
# Sentry event and the filesystem category opts that event into the
# errno fingerprint, so ENOSPC, ENOENT and EACCES raised from this same
# call site land on separate issues.
logger.exception(
"Error generating output directory: %s",
e,
extra={ERROR_CATEGORY_ATTRIBUTE: FILESYSTEM_ERROR_CATEGORY},
)
error_dict = {"error": str(e), "upload": False, "path": ""}
if generate_threatscore:
results["threatscore"] = error_dict.copy()
@@ -1,3 +1,4 @@
from datetime import UTC, datetime
from unittest.mock import MagicMock, patch
import pytest
@@ -671,6 +672,8 @@ class TestSecurityHubIntegrationUploads:
mock_integration = MagicMock()
mock_integration.configuration = {"send_only_fails": True}
mock_integration.credentials = {} # Empty credentials, use provider
mock_integration.connected = False
mock_integration.connection_last_checked_at = None
# Mock tenant_id
tenant_id = "550e8400-e29b-41d4-a716-446655440000" # Valid UUID
@@ -723,12 +726,22 @@ class TestSecurityHubIntegrationUploads:
# Configure the test_connection to return our mock_connection
mock_security_hub_class.test_connection = mock_test_connection
checked_at_before = datetime.now(tz=UTC)
connected, security_hub = get_security_hub_client_from_integration(
mock_integration, tenant_id, mock_findings
)
checked_at_after = datetime.now(tz=UTC)
assert connected is True
assert security_hub == mock_security_hub
assert mock_integration.connected is True
assert mock_integration.connection_last_checked_at.tzinfo is UTC
assert (
checked_at_before
<= mock_integration.connection_last_checked_at
<= checked_at_after
)
mock_integration.save.assert_called_once()
# Verify SecurityHub was called once to create the client
assert mock_security_hub_class.call_count == 1
@@ -3,12 +3,13 @@ from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from api.celery_utils import decode_celery_field
from celery import states
from celery.utils.saferepr import saferepr
from django.test import override_settings
from django_celery_results.models import TaskResult
from tasks.jobs.orphan_recovery import (
_SKIP_RECOVERY,
_decode_celery_field,
_reconcile_task_results,
_recovery_attempt_count,
advisory_lock,
@@ -36,24 +37,77 @@ def _orphan_result(*, name, kwargs, worker, created_minutes_ago, status=states.S
return tr
@pytest.mark.django_db
class TestDecodeCeleryField:
def test_decodes_strict_json(self):
assert decode_celery_field('{"enabled": true, "scan_id": null}', {}) == {
"enabled": True,
"scan_id": None,
}
def test_decodes_single_encoded_repr(self):
assert _decode_celery_field("{'tenant_id': 'abc'}", {}) == {"tenant_id": "abc"}
assert decode_celery_field("{'tenant_id': 'abc'}", {}) == {"tenant_id": "abc"}
def test_decodes_double_encoded(self):
import json
stored = json.dumps(repr({"tenant_id": "abc", "scan_id": "s1"}))
assert _decode_celery_field(stored, {}) == {"tenant_id": "abc", "scan_id": "s1"}
assert decode_celery_field(stored, {}) == {
"tenant_id": "abc",
"scan_id": "s1",
}
def test_python_words_inside_strings_are_preserved(self):
stored = repr(
{
"enabled": True,
"scan_id": None,
"label": "True North",
"note": "None",
}
)
assert decode_celery_field(stored, {}) == {
"enabled": True,
"scan_id": None,
"label": "True North",
"note": "None",
}
def test_empty_returns_default(self):
assert _decode_celery_field(None, {}) == {}
assert _decode_celery_field("", []) == []
assert decode_celery_field(None, {}) == {}
assert decode_celery_field("", []) == []
assert decode_celery_field("null", {}) == {}
assert decode_celery_field("None", []) == []
def test_empty_validates_default(self):
with pytest.raises(ValueError):
decode_celery_field("", {"value": ...})
def test_unparseable_raises(self):
with pytest.raises(ValueError):
_decode_celery_field("<<not a literal>>", {})
decode_celery_field("<<not a literal>>", {})
@pytest.mark.parametrize(
"value",
(
"{'value': ...}",
"{'value': {1, 2}}",
"{'value': b'bytes'}",
'{"value": NaN}',
),
)
def test_non_json_values_raise(self, value):
with pytest.raises(ValueError):
decode_celery_field(value, {})
def test_truncated_repr_raises(self):
kwargs_repr = saferepr(
{"finding_ids": [str(uuid4()) for _ in range(30)]}, maxlen=1024
)
assert "..." in kwargs_repr
with pytest.raises(ValueError):
decode_celery_field(kwargs_repr, {})
@pytest.mark.django_db
@@ -98,6 +152,58 @@ class TestReconcileTaskResults:
assert call["kwargs"] == {"tenant_id": str(tenant.id)}
assert call["task_id"] != tr.task_id # fresh task id
def test_truncated_kwargs_are_not_reenqueued(self, tenants_fixture):
tenant = tenants_fixture[0]
tr = _orphan_result(
name="tenant-deletion",
kwargs={"tenant_id": str(tenant.id)},
worker="dead@gone",
created_minutes_ago=60,
)
tr.task_kwargs = saferepr(
{"finding_ids": [str(uuid4()) for _ in range(30)]}, maxlen=1024
)
assert "..." in tr.task_kwargs
tr.save(update_fields=["task_kwargs"])
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
with (
p_alive,
p_revoke,
p_app,
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
):
result = _reconcile_task_results(
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
assert tr.task_id in result["failed"]
mock_task.apply_async.assert_not_called()
def test_wrong_kwargs_shape_is_not_reenqueued(self, tenants_fixture):
tr = _orphan_result(
name="tenant-deletion",
kwargs={"tenant_id": str(tenants_fixture[0].id)},
worker="dead@gone",
created_minutes_ago=60,
)
tr.task_kwargs = "[]"
tr.save(update_fields=["task_kwargs"])
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
with (
p_alive,
p_revoke,
p_app,
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
):
result = _reconcile_task_results(
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
assert tr.task_id in result["failed"]
mock_task.apply_async.assert_not_called()
def test_external_integration_task_is_not_reenqueued_by_default(
self, tenants_fixture
):
@@ -1,3 +1,5 @@
import errno
import logging
import os
import time
import uuid
@@ -14,6 +16,11 @@ from api.models import (
StateChoices,
StatusChoices,
)
from config.settings.sentry import (
ERROR_CATEGORY_ATTRIBUTE,
FILESYSTEM_ERROR_CATEGORY,
before_send,
)
from prowler.lib.check.models import Severity
from reportlab.lib import colors
from tasks.jobs.report import (
@@ -1676,6 +1683,78 @@ class TestGenerateComplianceReportsCIS:
assert result["cis"]["upload"] is False
assert result["cis"]["error"] == "dir boom"
@patch("tasks.jobs.report._aggregate_requirement_statistics_from_database")
@patch("tasks.jobs.report._generate_compliance_output_directory")
@patch("tasks.jobs.report.Compliance.get_bulk")
def test_output_directory_failures_are_grouped_per_errno(
self,
mock_get_bulk,
mock_generate_output_dir,
mock_stats,
monkeypatch,
caplog,
tenants_fixture,
scans_fixture,
aws_provider,
):
"""A full disk and a missing mount point must not share a Sentry issue.
Both are OSError raised from the same ``os.makedirs`` call, so they only
stay apart if the exception reaches ``before_send``, which fingerprints
it by errno.
"""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = aws_provider
self._force_scan_has_findings(monkeypatch)
mock_stats.return_value = {}
mock_get_bulk.return_value = {"cis_5.0_aws": Mock()}
fingerprints = []
for error_number, message in (
(errno.ENOSPC, "No space left on device: '/tmp/prowler_api_output'"),
(errno.ENOENT, "No such file or directory: '/mnt/output'"),
):
mock_generate_output_dir.side_effect = OSError(error_number, message)
caplog.clear()
with caplog.at_level(logging.ERROR, logger="tasks.jobs.report"):
generate_compliance_reports(
tenant_id=str(tenant.id),
scan_id=str(scan.id),
provider_id=str(provider.id),
generate_threatscore=False,
generate_ens=False,
generate_nis2=False,
generate_csa=False,
generate_cis=True,
)
record = next(
record
for record in caplog.records
if "Error generating output directory" in record.getMessage()
)
# Without exc_info the Sentry event carries no exception at all and
# nothing can tell the two failures apart.
assert record.exc_info is not None
# The category is what scopes the errno fingerprint to this record.
assert (
getattr(record, ERROR_CATEGORY_ATTRIBUTE, None)
== FILESYSTEM_ERROR_CATEGORY
)
# Same hint the Sentry logging integration builds for this record.
event = {}
before_send(event, {"log_record": record, "exc_info": record.exc_info})
fingerprints.append(event["fingerprint"])
assert fingerprints == [
["{{ default }}", "errno:ENOSPC"],
["{{ default }}", "errno:ENOENT"],
]
class TestPickLatestCisVariant:
"""Unit tests for `_pick_latest_cis_variant` helper."""
Generated
+440 -206
View File
@@ -16,7 +16,7 @@ constraints = [
{ name = "aiobotocore", specifier = "==2.25.1" },
{ name = "aiofiles", specifier = "==24.1.0" },
{ name = "aiohappyeyeballs", specifier = "==2.6.1" },
{ name = "aiohttp", specifier = "==3.14.0" },
{ name = "aiohttp", specifier = "==3.14.3" },
{ name = "aioitertools", specifier = "==0.13.0" },
{ name = "aiosignal", specifier = "==1.4.0" },
{ name = "alibabacloud-actiontrail20200706", specifier = "==2.4.1" },
@@ -45,7 +45,7 @@ constraints = [
{ name = "alibabacloud-sls20201230", specifier = "==5.9.0" },
{ name = "alibabacloud-sts20150401", specifier = "==1.1.6" },
{ name = "alibabacloud-tea", specifier = "==0.4.3" },
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.4" },
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.6" },
{ name = "alibabacloud-tea-util", specifier = "==0.3.14" },
{ name = "alibabacloud-tea-xml", specifier = "==0.0.3" },
{ name = "alibabacloud-vpc20160428", specifier = "==6.13.0" },
@@ -127,9 +127,9 @@ constraints = [
{ name = "coverage", specifier = "==7.5.4" },
{ name = "cron-descriptor", specifier = "==1.4.5" },
{ name = "crowdstrike-falconpy", specifier = "==1.6.0" },
{ name = "cryptography", specifier = "==46.0.7" },
{ name = "cryptography", specifier = "==50.0.0" },
{ name = "cycler", specifier = "==0.12.1" },
{ name = "darabonba-core", specifier = "==1.0.5" },
{ name = "darabonba-core", specifier = "==1.0.8" },
{ name = "dash", specifier = "==3.1.1" },
{ name = "dash-bootstrap-components", specifier = "==2.0.3" },
{ name = "debugpy", specifier = "==1.8.20" },
@@ -194,7 +194,7 @@ constraints = [
{ name = "h2", specifier = "==4.3.0" },
{ name = "hpack", specifier = "==4.1.0" },
{ name = "httpcore", specifier = "==1.0.9" },
{ name = "httplib2", specifier = "==0.31.2" },
{ name = "httplib2", specifier = "==0.32.0" },
{ name = "httpx", specifier = "==0.28.1" },
{ name = "humanfriendly", specifier = "==10.0" },
{ name = "hyperframe", specifier = "==6.1.0" },
@@ -231,13 +231,13 @@ constraints = [
{ name = "matplotlib", specifier = "==3.10.8" },
{ name = "mccabe", specifier = "==0.7.0" },
{ name = "mdurl", specifier = "==0.1.2" },
{ name = "microsoft-kiota-abstractions", specifier = "==1.9.9" },
{ name = "microsoft-kiota-authentication-azure", specifier = "==1.9.9" },
{ name = "microsoft-kiota-http", specifier = "==1.9.9" },
{ name = "microsoft-kiota-serialization-form", specifier = "==1.9.9" },
{ name = "microsoft-kiota-serialization-json", specifier = "==1.9.9" },
{ name = "microsoft-kiota-serialization-multipart", specifier = "==1.9.9" },
{ name = "microsoft-kiota-serialization-text", specifier = "==1.9.9" },
{ name = "microsoft-kiota-abstractions", specifier = "==1.9.10" },
{ name = "microsoft-kiota-authentication-azure", specifier = "==1.9.10" },
{ name = "microsoft-kiota-http", specifier = "==1.9.10" },
{ name = "microsoft-kiota-serialization-form", specifier = "==1.9.10" },
{ name = "microsoft-kiota-serialization-json", specifier = "==1.9.10" },
{ name = "microsoft-kiota-serialization-multipart", specifier = "==1.9.10" },
{ name = "microsoft-kiota-serialization-text", specifier = "==1.9.10" },
{ name = "microsoft-security-utilities-secret-masker", specifier = "==1.0.0b4" },
{ name = "msal", specifier = "==1.35.0b1" },
{ name = "msal-extensions", specifier = "==1.2.0" },
@@ -254,7 +254,7 @@ constraints = [
{ name = "nltk", specifier = "==3.9.4" },
{ name = "numpy", specifier = "==2.2.6" },
{ name = "oauthlib", specifier = "==3.3.1" },
{ name = "oci", specifier = "==2.169.0" },
{ name = "oci", specifier = "==2.184.1" },
{ name = "openai", specifier = "==1.109.1" },
{ name = "openstacksdk", specifier = "==4.2.0" },
{ name = "opentelemetry-api", specifier = "==1.39.1" },
@@ -266,7 +266,7 @@ constraints = [
{ name = "pagerduty", specifier = "==6.1.0" },
{ name = "pandas", specifier = "==2.2.3" },
{ name = "pbr", specifier = "==7.0.3" },
{ name = "pillow", specifier = "==12.2.0" },
{ name = "pillow", specifier = "==12.3.0" },
{ name = "pkginfo", specifier = "==1.12.1.2" },
{ name = "platformdirs", specifier = "==4.5.1" },
{ name = "plotly", specifier = "==6.5.2" },
@@ -282,8 +282,8 @@ constraints = [
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "py-deviceid", specifier = "==0.1.1" },
{ name = "py-iam-expand", specifier = "==0.3.0" },
{ name = "py-ocsf-models", specifier = "==0.8.1" },
{ name = "pyasn1", specifier = "==0.6.3" },
{ name = "py-ocsf-models", specifier = "==0.10.0" },
{ name = "pyasn1", specifier = "==0.6.4" },
{ name = "pyasn1-modules", specifier = "==0.4.2" },
{ name = "pycodestyle", specifier = "==2.14.0" },
{ name = "pycparser", specifier = "==3.0" },
@@ -295,7 +295,7 @@ constraints = [
{ name = "pylint", specifier = "==3.2.5" },
{ name = "pymsalruntime", specifier = "==0.18.1" },
{ name = "pynacl", specifier = "==1.6.2" },
{ name = "pyopenssl", specifier = "==26.0.0" },
{ name = "pyopenssl", specifier = "==26.4.0" },
{ name = "pyparsing", specifier = "==3.3.2" },
{ name = "pyreadline3", specifier = "==3.5.4" },
{ name = "pysocks", specifier = "==1.7.1" },
@@ -364,7 +364,7 @@ constraints = [
{ name = "wcwidth", specifier = "==0.5.3" },
{ name = "websocket-client", specifier = "==1.9.0" },
{ name = "werkzeug", specifier = "==3.1.7" },
{ name = "workos", specifier = "==6.0.8" },
{ name = "workos", specifier = "==8.3.0" },
{ name = "wrapt", specifier = "==1.17.3" },
{ name = "xlsxwriter", specifier = "==3.2.9" },
{ name = "xmlsec", specifier = "==1.3.17" },
@@ -373,12 +373,19 @@ constraints = [
{ name = "zipp", specifier = "==3.23.0" },
{ name = "zope-event", specifier = "==6.1" },
{ name = "zope-interface", specifier = "==8.2" },
{ name = "zstd", specifier = "==1.5.7.3" },
{ name = "zstd", specifier = "==1.5.7.2" },
]
overrides = [
{ name = "azure-mgmt-containerservice", specifier = "==34.1.0" },
{ name = "cryptography", specifier = "==50.0.0" },
{ name = "dulwich", specifier = "==1.2.5" },
{ name = "microsoft-kiota-abstractions", specifier = "==1.9.9" },
{ name = "microsoft-kiota-abstractions", specifier = "==1.9.10" },
{ name = "microsoft-kiota-authentication-azure", specifier = "==1.9.10" },
{ name = "microsoft-kiota-http", specifier = "==1.9.10" },
{ name = "microsoft-kiota-serialization-form", specifier = "==1.9.10" },
{ name = "microsoft-kiota-serialization-json", specifier = "==1.9.10" },
{ name = "microsoft-kiota-serialization-multipart", specifier = "==1.9.10" },
{ name = "microsoft-kiota-serialization-text", specifier = "==1.9.10" },
{ name = "okta", specifier = "==3.4.2" },
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" },
]
@@ -472,7 +479,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.14.0"
version = "3.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -484,44 +491,44 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload-time = "2026-06-01T19:41:02.763Z" }
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/47/7727bfe8db93f8835a001bd4359d8480cc68d1259b8bce334668f8be97bd/aiohttp-3.14.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:54bf3522d6f7351e55f89a62d5c2bf138ad557b031670266c5df604ae88e0b5a", size = 759147, upload-time = "2026-06-01T19:37:12.918Z" },
{ url = "https://files.pythonhosted.org/packages/eb/f2/cd3fedff6fade73d71df9ec908c210cec518ef90fd00289250684b90aecf/aiohttp-3.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0746d9fb0ac4fdef643a84494efe3f06d50335dd8c7a530228b86448aae0a803", size = 513705, upload-time = "2026-06-01T19:37:14.633Z" },
{ url = "https://files.pythonhosted.org/packages/5a/fe/49746b6b610144a06323bebd8e1211a390310d8c69b98dd6d52df341bc3e/aiohttp-3.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f3a96b6d39a4872222beee72e1df41d2ff886ae96152cf3e757ef8c5673ef0e", size = 509627, upload-time = "2026-06-01T19:37:16.385Z" },
{ url = "https://files.pythonhosted.org/packages/4c/3f/28f2f6cf3d5c0e7b01b27140d0e7873fd11fb341169ad3ce78ad04aba628/aiohttp-3.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d336820adbb914debbc90a1d8c1bfc4bea55996aecf64866a989d35d1f9fd903", size = 1769293, upload-time = "2026-06-01T19:37:18.067Z" },
{ url = "https://files.pythonhosted.org/packages/97/6f/2e5f1b525d5474b12b3c60abf733a755845f3bceff21542081ada515f837/aiohttp-3.14.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:71b2604c9bfc1b115547d63a094d5244b3f02799833513a99a68aaa7b167c4cb", size = 1732363, upload-time = "2026-06-01T19:37:20.138Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ce/596120faa85ca7b19cd061e3f2f3be23aa8f11a0aedf9191db9e0da1bd76/aiohttp-3.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:610d68800435903e303ca0542b9d3e4eb72a12ff33a6d471a070c1d81eebd3c2", size = 1840375, upload-time = "2026-06-01T19:37:22.104Z" },
{ url = "https://files.pythonhosted.org/packages/72/3c/a7ffe05a757a4a7867643da69357ec41f506879fbd1b231d2ed90af246b2/aiohttp-3.14.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:514db9a79337068981ee2137310283a07b4b885c584991097a91a4da419bcb81", size = 1921484, upload-time = "2026-06-01T19:37:24.068Z" },
{ url = "https://files.pythonhosted.org/packages/93/fa/2c861170bbd4a491de93a69e081db1d971092569e0d593a98ef62c384dc1/aiohttp-3.14.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c452d17eeb95d563fc8b936f3050301dbd1d268126c4632d8b70ede9696202ee", size = 1774153, upload-time = "2026-06-01T19:37:26.256Z" },
{ url = "https://files.pythonhosted.org/packages/9d/da/1d2f5a165f47ec9b1f69d37b8b977fdc4d501aa72ffb7930db27bb9e49ea/aiohttp-3.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed94a81506e3d1bdbad5108f497a58f2a2354aedb4ca314d5326f07d1fd1ac2d", size = 1632569, upload-time = "2026-06-01T19:37:28.192Z" },
{ url = "https://files.pythonhosted.org/packages/46/1d/7a6e295c4257252f70f69e90864fdad74b6a1293054fb3f9e65a15de6d63/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1394dce36e0f0d260ac0b555a654de19cb989f3c1b8bdd24f505314dfea18a00", size = 1740325, upload-time = "2026-06-01T19:37:30.08Z" },
{ url = "https://files.pythonhosted.org/packages/f1/7e/e1899b1ca3ec62f1eab2a5cbde14039b97493f7f53eb88d9b668562ffa8d/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d1467d1e7b48a73ca7237e0ee4335f3d02b923dbc27b82fd254bc301c97d4026", size = 1748691, upload-time = "2026-06-01T19:37:32.211Z" },
{ url = "https://files.pythonhosted.org/packages/ec/54/4e6b61c1fe7d3433f82bcc6bd7e4d7c683a742a10c9b12a025fd3695c047/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6a5f3532125233c261cf61f32df4059cfcf482eb793c7d3db8452e3142028b86", size = 1814477, upload-time = "2026-06-01T19:37:34.173Z" },
{ url = "https://files.pythonhosted.org/packages/9c/38/86fd51be2e08d8e45c83d879d255f10391903cd9fe2a16512f7591a15873/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3ea81eb518a2ecb319d8ec6d1424a37c773f6634bd87d6985eb606b2faac419f", size = 1623393, upload-time = "2026-06-01T19:37:36.281Z" },
{ url = "https://files.pythonhosted.org/packages/78/49/466e947a42a88ee23c486d036e7e5d1b097f1bafd8084ad9c9a0a92f0f43/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:32e735c3182de7b64f6941a4ede48b38c7f47d9437bd615dd30b5bda8fa1bc93", size = 1824097, upload-time = "2026-06-01T19:37:38.421Z" },
{ url = "https://files.pythonhosted.org/packages/f3/89/35f3410bc284682338a1be6b6ea0c5abfa05f063942cfaa9256608440434/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c21ca9a1c63d4509158f478aeb9d02914dcc52adc68d1bc9dee2452284ee5996", size = 1764790, upload-time = "2026-06-01T19:37:40.755Z" },
{ url = "https://files.pythonhosted.org/packages/42/80/2d4291bd5724d3d17e5951aff5a3e02281483fb47295f0788276ee66cd73/aiohttp-3.14.0-cp311-cp311-win32.whl", hash = "sha256:19ca5fc84130675ba11c6ca5c7da5cb65f7bf8a32cdd2b616bf49cd334688aae", size = 454176, upload-time = "2026-06-01T19:37:42.837Z" },
{ url = "https://files.pythonhosted.org/packages/59/ed/41d0ad4f6ececffc32bdf1f7b494e5498f7ca5c849ea2e3cc9bbd1668251/aiohttp-3.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:d488e6e9d3bb8ba5ae7066d5be885ae9670eba021b8c6ccb9a3a568e6b19d6e5", size = 479334, upload-time = "2026-06-01T19:37:44.776Z" },
{ url = "https://files.pythonhosted.org/packages/d1/86/c0b5e305c770053f8c3d069bb52b8196917ba91949d1962d52eb307fb0d2/aiohttp-3.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:8b93618102caf12801638a01a2b478a55410ddd71bd41cfaf6f707953a49ac43", size = 450262, upload-time = "2026-06-01T19:37:46.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/97/2b6889bfb6b6847520d50d95eb8c4307a45e28aaca39faf4a9454b3d1b2f/aiohttp-3.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b29518c9c2ec7e373e68259206a137c7f4f5439c58baaec4b5ab3ab799850a4e", size = 750194, upload-time = "2026-06-01T19:37:48.164Z" },
{ url = "https://files.pythonhosted.org/packages/21/e2/62634b7fff918ed98c3c6b2f0e70d520f7f28846cb412d451b04354c6459/aiohttp-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbec68ce61b64cb73cab4d33df9433427b1713c8bcccb181dce695c1b6f8e87c", size = 506966, upload-time = "2026-06-01T19:37:50.014Z" },
{ url = "https://files.pythonhosted.org/packages/dd/fb/5ce075150828c797a5106f1c2fb26034e709d4289b9d2bf8b07f1e59fac6/aiohttp-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cdf534aa455593e589302990c5097aa5c92c06c4262a20da22934f9186a5fff", size = 507527, upload-time = "2026-06-01T19:37:51.96Z" },
{ url = "https://files.pythonhosted.org/packages/01/d5/405a0ae4e6b081754a3609c1c97c63a950e000a2def16046f1e736933a0e/aiohttp-3.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb6c657104393b5fbff01a5f59b2023db74058a8077d94475d6c25d03882a108", size = 1762420, upload-time = "2026-06-01T19:37:53.839Z" },
{ url = "https://files.pythonhosted.org/packages/ae/1d/e05a7c896b15a6bc6fb8fc5319eb437861c2c49c34559ef928add6590315/aiohttp-3.14.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:46fbbec4e4fab7428d4396a3823f9320e4560aa3113b89eeebce712c27c9ed5a", size = 1733672, upload-time = "2026-06-01T19:37:55.791Z" },
{ url = "https://files.pythonhosted.org/packages/cc/22/a72f7c459e195fa41bf4f7abd1f925b91fe91f8097e51c654229ba144a33/aiohttp-3.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c2c7e05dd5335b298085abf45ddf98673934c3ee1c083d0b9ea13d4186ad500", size = 1805064, upload-time = "2026-06-01T19:37:57.931Z" },
{ url = "https://files.pythonhosted.org/packages/80/50/e85bdaba0be59ca4838005ebfef4048fcdd5f35a02b07057a9a123394440/aiohttp-3.14.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c7139100fbaae76515b73051d8f0aa3a3ff02e415eec8a8eee8e2223d9ba955", size = 1902125, upload-time = "2026-06-01T19:38:00.225Z" },
{ url = "https://files.pythonhosted.org/packages/19/d8/51de5c6b971c27bb1ef620293b8d1ca611ec78736b34b3f6ccf68e4c8785/aiohttp-3.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d6f9286a629ce52728430afe18f8ed2b6c39a1fddb3802d7244b9983910ad2", size = 1783112, upload-time = "2026-06-01T19:38:02.641Z" },
{ url = "https://files.pythonhosted.org/packages/73/ae/b4402bfde77e43dfb1b6ccff83c7b7ab63ed06b50c4754f0c5423fb374fe/aiohttp-3.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3c3e12cdaeb92d7dcf13db00e9f6b1956b910e47256e696df1cfa946d02159", size = 1586356, upload-time = "2026-06-01T19:38:04.637Z" },
{ url = "https://files.pythonhosted.org/packages/bc/05/750a3265ca4dc54a460bd0cb1121a8f2ce9171fce4a135fb47ea7fd594d2/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4d6a998191f5ebe3b8c28463ff72bc030250008b3193c402464efadd08b5ca02", size = 1723119, upload-time = "2026-06-01T19:38:06.713Z" },
{ url = "https://files.pythonhosted.org/packages/37/01/8c0812c50b3b1b1c37b323bf170d6be8847a8f234060485b7d1e71953f60/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0fc2b75ae8d169d853be2862d960be8550da6c5c65711d5476407eb3fdb006bd", size = 1757216, upload-time = "2026-06-01T19:38:08.736Z" },
{ url = "https://files.pythonhosted.org/packages/47/2a/50fb98028a26887cbe48dcc1df92a90825615bc73b5584301304090cded8/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:16eee56bcc72d04600bc56c1759982c2385ec0b41d3fd3521f836bf64a0957ef", size = 1770500, upload-time = "2026-06-01T19:38:11.111Z" },
{ url = "https://files.pythonhosted.org/packages/bd/32/0ffd598a2fa2b9a423daf242e700cfdabda35d6e602394ad9ae58972c1c7/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5a2e7ca615c3ddc15b82687e05a624e5f5cba3f1d6c20cb81172d70ea498451e", size = 1576224, upload-time = "2026-06-01T19:38:13.391Z" },
{ url = "https://files.pythonhosted.org/packages/0b/f9/b9fc381dd9b66afb33f2634c40e229d106467be0afcabe79648631ab6712/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f0b7b8bbbec3ce9467ee0ebe334622fd90624f593edd3136c567811453fc4fae", size = 1794252, upload-time = "2026-06-01T19:38:15.498Z" },
{ url = "https://files.pythonhosted.org/packages/a8/fb/05d9214c975f23225a8cd5c439325e338c7c377b315480ef3871db51f54e/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ba10966d4f03dd96a14365be4b8e37c327c76f11c3ca867116966cdd9f98066", size = 1760193, upload-time = "2026-06-01T19:38:17.624Z" },
{ url = "https://files.pythonhosted.org/packages/d9/4b/02992fc4fb9e1b6673ee3f888a8e587a6447afda1f6f4aca776c148c2876/aiohttp-3.14.0-cp312-cp312-win32.whl", hash = "sha256:101df7779c80c0636014a6b2c6642acd3efb5b355d48347c9d7dfb720aee9430", size = 448650, upload-time = "2026-06-01T19:38:19.545Z" },
{ url = "https://files.pythonhosted.org/packages/39/e9/246532214c3abda518477cbaaf16d420295ad8effa5233844cbb38f299ab/aiohttp-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0a5747586d4467efd1f932710b269131c9717a872dce082cd92a00c1c13123a", size = 476145, upload-time = "2026-06-01T19:38:21.505Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c3/63f8c20090048915711598b0adf475b149216d736157961de06480a45b15/aiohttp-3.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:5f1c5be60add78fabb4aacd13c5a348ae79d2fcbfc7fa78da8f1eb192273b370", size = 444250, upload-time = "2026-06-01T19:38:24.027Z" },
{ url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" },
{ url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" },
{ url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" },
{ url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" },
{ url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" },
{ url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" },
{ url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" },
{ url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" },
{ url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" },
{ url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" },
{ url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" },
{ url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" },
{ url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" },
{ url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" },
{ url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" },
{ url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" },
{ url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" },
{ url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" },
{ url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" },
{ url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" },
{ url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" },
{ url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" },
{ url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" },
{ url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" },
{ url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" },
{ url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" },
{ url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" },
{ url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" },
{ url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" },
{ url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" },
]
[[package]]
@@ -853,7 +860,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0
[[package]]
name = "alibabacloud-tea-openapi"
version = "0.4.4"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-credentials" },
@@ -862,9 +869,9 @@ dependencies = [
{ name = "cryptography" },
{ name = "darabonba-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/30/93/138bcdc8fc596add73e37cf2073798f285284d1240bda9ee02f9384fc6be/alibabacloud_tea_openapi-0.4.4.tar.gz", hash = "sha256:1b0917bc03cd49417da64945e92731716d53e2eb8707b235f54e45b7473221ce", size = 21960, upload-time = "2026-03-26T10:16:16.792Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ab/34/1918a2d780676494365c7f945bfab397ecddb988054d78025bd26f438977/alibabacloud_tea_openapi-0.4.6.tar.gz", hash = "sha256:dafc32401712f5b21c12dc3d05ba887a91ad156d9b49a7662279f9fd90526fb2", size = 26742, upload-time = "2026-08-17T08:34:11.55Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/5a/6bfc4506438c1809c486f66217ad11eab78157192b3d5707b4e2f4212f6c/alibabacloud_tea_openapi-0.4.4-py3-none-any.whl", hash = "sha256:cea6bc1fe35b0319a8752cb99eb0ecb0dab7ca1a71b99c12970ba0867410995f", size = 26236, upload-time = "2026-03-26T10:16:15.861Z" },
{ url = "https://files.pythonhosted.org/packages/35/00/2f534f5884e5f299d9cb3a1e8be2def8071bc6a6e2a192ba4ff2a8cd5e02/alibabacloud_tea_openapi-0.4.6-py3-none-any.whl", hash = "sha256:c9e1727b9fb2936f487d050fc3590c99f9f2065256dc3a927e5b61f414674ed6", size = 33448, upload-time = "2026-08-17T08:34:10.472Z" },
]
[[package]]
@@ -2088,6 +2095,37 @@ toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
[[package]]
name = "crc32c"
version = "2.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/66/7e97aa77af7cf6afbff26e3651b564fe41932599bc2d3dce0b2f73d4829a/crc32c-2.8.tar.gz", hash = "sha256:578728964e59c47c356aeeedee6220e021e124b9d3e8631d95d9a5e5f06e261c", size = 48179, upload-time = "2025-10-17T06:20:13.61Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/0b/5e03b22d913698e9cc563f39b9f6bbd508606bf6b8e9122cd6bf196b87ea/crc32c-2.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e560a97fbb96c9897cb1d9b5076ef12fc12e2e25622530a1afd0de4240f17e1f", size = 66329, upload-time = "2025-10-17T06:19:01.771Z" },
{ url = "https://files.pythonhosted.org/packages/6b/38/2fe0051ffe8c6a650c8b1ac0da31b8802d1dbe5fa40a84e4b6b6f5583db5/crc32c-2.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6762d276d90331a490ef7e71ffee53b9c0eb053bd75a272d786f3b08d3fe3671", size = 62988, upload-time = "2025-10-17T06:19:02.953Z" },
{ url = "https://files.pythonhosted.org/packages/3e/30/5837a71c014be83aba1469c58820d287fc836512a0cad6b8fdd43868accd/crc32c-2.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:60670569f5ede91e39f48fb0cb4060e05b8d8704dd9e17ede930bf441b2f73ef", size = 61522, upload-time = "2025-10-17T06:19:03.796Z" },
{ url = "https://files.pythonhosted.org/packages/ca/29/63972fc1452778e2092ae998c50cbfc2fc93e3fa9798a0278650cd6169c5/crc32c-2.8-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:711743da6ccc70b3c6718c328947b0b6f34a1fe6a6c27cc6c1d69cc226bf70e9", size = 80200, upload-time = "2025-10-17T06:19:04.617Z" },
{ url = "https://files.pythonhosted.org/packages/cb/3a/60eb49d7bdada4122b3ffd45b0df54bdc1b8dd092cda4b069a287bdfcff4/crc32c-2.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5eb4094a2054774f13b26f21bf56792bb44fa1fcee6c6ad099387a43ffbfb4fa", size = 81757, upload-time = "2025-10-17T06:19:05.496Z" },
{ url = "https://files.pythonhosted.org/packages/f5/63/6efc1b64429ef7d23bd58b75b7ac24d15df327e3ebbe9c247a0f7b1c2ed1/crc32c-2.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fff15bf2bd3e95780516baae935ed12be88deaa5ebe6143c53eb0d26a7bdc7b7", size = 80830, upload-time = "2025-10-17T06:19:06.621Z" },
{ url = "https://files.pythonhosted.org/packages/e1/eb/0ae9f436f8004f1c88f7429e659a7218a3879bd11a6b18ed1257aad7e98b/crc32c-2.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4c0e11e3826668121fa53e0745635baf5e4f0ded437e8ff63ea56f38fc4f970a", size = 80095, upload-time = "2025-10-17T06:19:07.381Z" },
{ url = "https://files.pythonhosted.org/packages/9e/81/4afc9d468977a4cd94a2eb62908553345009a7c0d30e74463a15d4b48ec3/crc32c-2.8-cp311-cp311-win32.whl", hash = "sha256:38f915336715d1f1353ab07d7d786f8a789b119e273aea106ba55355dfc9101d", size = 64886, upload-time = "2025-10-17T06:19:08.497Z" },
{ url = "https://files.pythonhosted.org/packages/d6/e8/94e839c9f7e767bf8479046a207afd440a08f5c59b52586e1af5e64fa4a0/crc32c-2.8-cp311-cp311-win_amd64.whl", hash = "sha256:60e0a765b1caab8d31b2ea80840639253906a9351d4b861551c8c8625ea20f86", size = 66639, upload-time = "2025-10-17T06:19:09.338Z" },
{ url = "https://files.pythonhosted.org/packages/b6/36/fd18ef23c42926b79c7003e16cb0f79043b5b179c633521343d3b499e996/crc32c-2.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:572ffb1b78cce3d88e8d4143e154d31044a44be42cb3f6fbbf77f1e7a941c5ab", size = 66379, upload-time = "2025-10-17T06:19:10.115Z" },
{ url = "https://files.pythonhosted.org/packages/7f/b8/c584958e53f7798dd358f5bdb1bbfc97483134f053ee399d3eeb26cca075/crc32c-2.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cf827b3758ee0c4aacd21ceca0e2da83681f10295c38a10bfeb105f7d98f7a68", size = 63042, upload-time = "2025-10-17T06:19:10.946Z" },
{ url = "https://files.pythonhosted.org/packages/62/e6/6f2af0ec64a668a46c861e5bc778ea3ee42171fedfc5440f791f470fd783/crc32c-2.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:106fbd79013e06fa92bc3b51031694fcc1249811ed4364ef1554ee3dd2c7f5a2", size = 61528, upload-time = "2025-10-17T06:19:11.768Z" },
{ url = "https://files.pythonhosted.org/packages/17/8b/4a04bd80a024f1a23978f19ae99407783e06549e361ab56e9c08bba3c1d3/crc32c-2.8-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6dde035f91ffbfe23163e68605ee5a4bb8ceebd71ed54bb1fb1d0526cdd125a2", size = 80028, upload-time = "2025-10-17T06:19:12.554Z" },
{ url = "https://files.pythonhosted.org/packages/21/8f/01c7afdc76ac2007d0e6a98e7300b4470b170480f8188475b597d1f4b4c6/crc32c-2.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e41ebe7c2f0fdcd9f3a3fd206989a36b460b4d3f24816d53e5be6c7dba72c5e1", size = 81531, upload-time = "2025-10-17T06:19:13.406Z" },
{ url = "https://files.pythonhosted.org/packages/32/2b/8f78c5a8cc66486be5f51b6f038fc347c3ba748d3ea68be17a014283c331/crc32c-2.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ecf66cf90266d9c15cea597d5cc86c01917cd1a238dc3c51420c7886fa750d7e", size = 80608, upload-time = "2025-10-17T06:19:14.223Z" },
{ url = "https://files.pythonhosted.org/packages/db/86/fad1a94cdeeeb6b6e2323c87f970186e74bfd6fbfbc247bf5c88ad0873d5/crc32c-2.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:59eee5f3a69ad0793d5fa9cdc9b9d743b0cd50edf7fccc0a3988a821fef0208c", size = 79886, upload-time = "2025-10-17T06:19:15.345Z" },
{ url = "https://files.pythonhosted.org/packages/d5/db/1a7cb6757a1e32376fa2dfce00c815ea4ee614a94f9bff8228e37420c183/crc32c-2.8-cp312-cp312-win32.whl", hash = "sha256:a73d03ce3604aa5d7a2698e9057a0eef69f529c46497b27ee1c38158e90ceb76", size = 64896, upload-time = "2025-10-17T06:19:16.457Z" },
{ url = "https://files.pythonhosted.org/packages/bf/8e/2024de34399b2e401a37dcb54b224b56c747b0dc46de4966886827b4d370/crc32c-2.8-cp312-cp312-win_amd64.whl", hash = "sha256:56b3b7d015247962cf58186e06d18c3d75a1a63d709d3233509e1c50a2d36aa2", size = 66645, upload-time = "2025-10-17T06:19:17.235Z" },
{ url = "https://files.pythonhosted.org/packages/a7/1d/dd926c68eb8aac8b142a1a10b8eb62d95212c1cf81775644373fe7cceac2/crc32c-2.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5833f4071da7ea182c514ba17d1eee8aec3c5be927d798222fbfbbd0f5eea02c", size = 62345, upload-time = "2025-10-17T06:20:09.39Z" },
{ url = "https://files.pythonhosted.org/packages/51/be/803404e5abea2ef2c15042edca04bbb7f625044cca879e47f186b43887c2/crc32c-2.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:1dc4da036126ac07b39dd9d03e93e585ec615a2ad28ff12757aef7de175295a8", size = 61229, upload-time = "2025-10-17T06:20:10.236Z" },
{ url = "https://files.pythonhosted.org/packages/fc/3a/00cc578cd27ed0b22c9be25cef2c24539d92df9fa80ebd67a3fc5419724c/crc32c-2.8-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:15905fa78344654e241371c47e6ed2411f9eeb2b8095311c68c88eccf541e8b4", size = 64108, upload-time = "2025-10-17T06:20:11.072Z" },
{ url = "https://files.pythonhosted.org/packages/6b/bc/0587ef99a1c7629f95dd0c9d4f3d894de383a0df85831eb16c48a6afdae4/crc32c-2.8-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c596f918688821f796434e89b431b1698396c38bf0b56de873621528fe3ecb1e", size = 64815, upload-time = "2025-10-17T06:20:11.919Z" },
{ url = "https://files.pythonhosted.org/packages/73/42/94f2b8b92eae9064fcfb8deef2b971514065bd606231f8857ff8ae02bebd/crc32c-2.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8d23c4fe01b3844cb6e091044bc1cebdef7d16472e058ce12d9fadf10d2614af", size = 66659, upload-time = "2025-10-17T06:20:12.766Z" },
]
[[package]]
name = "cron-descriptor"
version = "1.4.5"
@@ -2112,47 +2150,45 @@ wheels = [
[[package]]
name = "cryptography"
version = "46.0.7"
version = "50.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
{ url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" },
{ url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" },
{ url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" },
{ url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" },
{ url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" },
{ url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" },
{ url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
{ url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
{ url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
{ url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
{ url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
{ url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
{ url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
{ url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
{ url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
{ url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
{ url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" },
{ url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
{ url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
{ url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
{ url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
{ url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
{ url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
{ url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
{ url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
{ url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
{ url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
{ url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" },
{ url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" },
{ url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" },
{ url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" },
{ url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" },
{ url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" },
{ url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" },
]
[[package]]
@@ -2166,15 +2202,17 @@ wheels = [
[[package]]
name = "darabonba-core"
version = "1.0.5"
version = "1.0.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "alibabacloud-tea" },
{ name = "requests" },
{ name = "websocket-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/83/9321ccdb7a800c2cb97d8fa34bead5f20141f27f804594fd1fd815c4cd07/darabonba_core-1.0.8.tar.gz", hash = "sha256:f1661960b368e342d3d36434be82d264b70a01c49e843921d8a4dacd217376ae", size = 27604, upload-time = "2026-07-13T02:07:34.093Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/66/d3/a7daaee544c904548e665829b51a9fa2572acb82c73ad787a8ff90273002/darabonba_core-1.0.5-py3-none-any.whl", hash = "sha256:671ab8dbc4edc2a8f88013da71646839bb8914f1259efc069353243ef52ea27c", size = 24580, upload-time = "2025-12-12T07:53:59.494Z" },
{ url = "https://files.pythonhosted.org/packages/6d/88/38800ca22f39a31fdb75c7b2867c61d3af5e2792cee0b72942a639c88a79/darabonba_core-1.0.8-py3-none-any.whl", hash = "sha256:ac093fdd40f88f2f9dfbbbfd7bc143495a3cb031f35b397c98d24edfa6b69483", size = 30957, upload-time = "2026-07-13T02:07:33.138Z" },
]
[[package]]
@@ -3327,14 +3365,14 @@ wheels = [
[[package]]
name = "httplib2"
version = "0.31.2"
version = "0.32.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyparsing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" }
sdist = { url = "https://files.pythonhosted.org/packages/84/f5/ccf58de92d61e3ad921119668f54ed36ca1d0cf5dcc5c1657dfb164fd78b/httplib2-0.32.0.tar.gz", hash = "sha256:48a0ef30a42db65d8f3399045e1d09ab0ba66e3b9efc360d07f80ea55d286025", size = 254283, upload-time = "2026-06-26T10:13:56.265Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" },
{ url = "https://files.pythonhosted.org/packages/33/a0/550eec327e5f5c7b732531c489f5307efec41f047b0d703bd4ca1e5ad2db/httplib2-0.32.0-py3-none-any.whl", hash = "sha256:dc6705cacdf3fb0a2aba7629fa33c90fd93e30035db0c157325826be177e4816", size = 93148, upload-time = "2026-06-26T10:13:54.985Z" },
]
[[package]]
@@ -3357,6 +3395,134 @@ http2 = [
{ name = "h2" },
]
[[package]]
name = "huaweicloudsdkcore"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "defusedxml" },
{ name = "pyasn1" },
{ name = "pymongo" },
{ name = "pyyaml" },
{ name = "requests-toolbelt" },
{ name = "simplejson" },
{ name = "six" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/f5/65e90764ea3bbfef50fb68cd5e12340acf1f51e9276b11745fbf5feb7e0e/huaweicloudsdkcore-3.1.204-py3-none-any.whl", hash = "sha256:9ae17744795ebdc8ce9291373a3a27bf72e90aa98677cfce0ea9394376875a95", size = 69578, upload-time = "2026-07-09T09:01:59.715Z" },
]
[[package]]
name = "huaweicloudsdkcts"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/32/d06328e35375d4aa606719a27856cdf57b1f7fb0c49d4dfd22a9609dba19/huaweicloudsdkcts-3.1.204-py3-none-any.whl", hash = "sha256:9def561aa784a6ee13b46bfc96888cd1df5bfc42f8a89e60b42c91c608bf6d60", size = 121768, upload-time = "2026-07-09T09:02:08.16Z" },
]
[[package]]
name = "huaweicloudsdkecs"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/66/f8e4a3b9ca70d3ea79c4d200f928ed9ffdf4910ac01be4864967408c8f18/huaweicloudsdkecs-3.1.204-py3-none-any.whl", hash = "sha256:dc5715d782c0260b901c793d009d5e632257acb04257b6f2c6631e415c589343", size = 765699, upload-time = "2026-07-09T09:02:39.272Z" },
]
[[package]]
name = "huaweicloudsdkelb"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/4b/9bdc7e2066419d967e9b812cfacfb9c4996a8e977dc39853309a3c1ac9e2/huaweicloudsdkelb-3.1.204-py3-none-any.whl", hash = "sha256:620247c2b2a7f20e7da8b18fe9c64e29972055f015bc35270fb5b43243dc4830", size = 1292397, upload-time = "2026-07-09T09:02:45.656Z" },
]
[[package]]
name = "huaweicloudsdkevs"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/cf/531dc55fd9d0f3bbd3eef24c7e4d78c6a1ba8eb80fa506e3574d72bcc98a/huaweicloudsdkevs-3.1.204-py3-none-any.whl", hash = "sha256:9118ac4c576e54aa7eaa926949e2b6824c5f038a2274b51d9a304d37fc0d7e2f", size = 251404, upload-time = "2026-07-09T09:02:50.05Z" },
]
[[package]]
name = "huaweicloudsdkiam"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/9a/7da0fbe9b83bc7a7f6d586366b81e829ed355a57419d2184b7dd51f8c2a3/huaweicloudsdkiam-3.1.204-py3-none-any.whl", hash = "sha256:0021e204f81ceef2640017e517adb72ba56c9ced03f071a0265b10bc9759badf", size = 1251350, upload-time = "2026-07-09T09:03:05.467Z" },
]
[[package]]
name = "huaweicloudsdkkms"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/28/3a/7392617d585cb2005f7d9ade0b0e0e493a7a88daf56e8dc39e4e219cc5d0/huaweicloudsdkkms-3.1.204-py3-none-any.whl", hash = "sha256:378986f33113ce99f445ef318d1c7dda89e361d16c008e5ef9793981d8385376", size = 275690, upload-time = "2026-07-09T09:03:29.833Z" },
]
[[package]]
name = "huaweicloudsdkobs"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/49/28a09e1e33d1c039be22ee4171efaa739351653c7aa88d3a2f7a78d90217/huaweicloudsdkobs-3.1.204-py3-none-any.whl", hash = "sha256:8c5830fa30293185964d98e524887fc510c8e17ca2fadb4563dad10910f37b13", size = 235360, upload-time = "2026-07-09T09:03:52.171Z" },
]
[[package]]
name = "huaweicloudsdkrds"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/7d/721f162c46e3de604a73674223bf6c6bc6cf7ade25b3751a71288f4dd122/huaweicloudsdkrds-3.1.204-py3-none-any.whl", hash = "sha256:a790b5b3c457a608e5679c101f463b4d037dd9a8a66f6e46144a9e5a4b37780f", size = 1626906, upload-time = "2026-07-09T09:04:06.936Z" },
]
[[package]]
name = "huaweicloudsdkvpc"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/b5/4baa27c3a275ea92806068e35e06f249a30add8dd57c777bb45841f63406/huaweicloudsdkvpc-3.1.204-py3-none-any.whl", hash = "sha256:c57d6b6d2f70deca91e86f7956b33fc9ac4991b431f0d608c3632231119f8970", size = 1124332, upload-time = "2026-07-09T09:04:39.797Z" },
]
[[package]]
name = "huaweicloudsdkwaf"
version = "3.1.204"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huaweicloudsdkcore" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/21/01590dce200be487756451f5e9efb99da7810688062d177e4b58d6062465/huaweicloudsdkwaf-3.1.204-py3-none-any.whl", hash = "sha256:b2355276e0029808f45e2d1bd3eb14b37d2da9417e61e642ffe0e2b748ca8283", size = 1337762, upload-time = "2026-07-09T09:04:43.688Z" },
]
[[package]]
name = "humanfriendly"
version = "10.0"
@@ -3920,21 +4086,21 @@ wheels = [
[[package]]
name = "microsoft-kiota-abstractions"
version = "1.9.9"
version = "1.9.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-sdk" },
{ name = "std-uritemplate" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/94/37315b82a1bcc08145e5bc2af7396a4be8160ac138ec269611c3b9589b7a/microsoft_kiota_abstractions-1.9.9.tar.gz", hash = "sha256:5df9a8e0517a4568726c2cac6d9789284cc6ffa66043b68eba42ae55749fb861", size = 24468, upload-time = "2026-03-02T21:03:50.133Z" }
sdist = { url = "https://files.pythonhosted.org/packages/45/e1/39de28380fc0eddf12f66099469fb7561bc38f577ea06e3a074751ebbcd9/microsoft_kiota_abstractions-1.9.10.tar.gz", hash = "sha256:8eb62d64c35ad0eeb4e8bcdbb143c0b308dc4a494e757f8e44cb959d34f44ecf", size = 24473, upload-time = "2026-03-12T17:27:15.398Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/6a/7d5a1a8131f0eccc6b45839c091aa00ba29661854e7defaa7936cf342fa7/microsoft_kiota_abstractions-1.9.9-py3-none-any.whl", hash = "sha256:8d0a14eda42f3f0ccac2e9512227a338f69998dc9b782fd21cb8ca7c48302caa", size = 44453, upload-time = "2026-03-02T21:03:51.11Z" },
{ url = "https://files.pythonhosted.org/packages/4d/59/bf0cb26c80fbd3fa882df8474ad87e9dbd742656c376388c427c4e314171/microsoft_kiota_abstractions-1.9.10-py3-none-any.whl", hash = "sha256:cd169067ebe48e6feea1258630807034239e0c61c2abe5fd66896a58177e8f05", size = 44462, upload-time = "2026-03-12T17:27:16.532Z" },
]
[[package]]
name = "microsoft-kiota-authentication-azure"
version = "1.9.9"
version = "1.9.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -3943,14 +4109,14 @@ dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-sdk" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ca/ce/5ae8b37ee4a50f0ed5e092c2d0105d60b592e6102a190959f76658a0994c/microsoft_kiota_authentication_azure-1.9.9.tar.gz", hash = "sha256:aca5e7dc8a0a28224f9025a479349ac2f9aaf166bfd6bc707f232658b45eec28", size = 5000, upload-time = "2026-03-02T21:04:02.355Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d5/53/7760f979c141ec590f0c1cfcb92b3e410eb2909cc19feb42f3fce78db171/microsoft_kiota_authentication_azure-1.9.10.tar.gz", hash = "sha256:b9f10a9fa86e36114abfee448d2dab91a502d6a55d349a306e2e41a1218fe1ad", size = 4999, upload-time = "2026-03-12T17:27:26.323Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/de/dc504324b776d00a420886cc6f39e04be2cf48cab0e9b18f8450a5efcc29/microsoft_kiota_authentication_azure-1.9.9-py3-none-any.whl", hash = "sha256:73dc21a1a2861ea78a135327291db3322e2255542a18b311dd03fd908342e902", size = 6951, upload-time = "2026-03-02T21:04:03.18Z" },
{ url = "https://files.pythonhosted.org/packages/1e/4a/e7852f9358d897ada1eec4e825c815761befe36df4defa79f1ae6c7b588c/microsoft_kiota_authentication_azure-1.9.10-py3-none-any.whl", hash = "sha256:b5d98b0d17173c61c0c7ab4274ea4ca69253b3c13424137758034506694964e9", size = 6961, upload-time = "2026-03-12T17:27:27.238Z" },
]
[[package]]
name = "microsoft-kiota-http"
version = "1.9.9"
version = "1.9.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx", extra = ["http2"] },
@@ -3958,57 +4124,57 @@ dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-sdk" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/3f/fc18eb0d1d845daf6355fd54fd990af7f7e10043ef6a6da39b9e5981cbaf/microsoft_kiota_http-1.9.9.tar.gz", hash = "sha256:ae672b145df71b644f8da0951767a12a4ce47a40576d86eba19b7c22d9e160f9", size = 21493, upload-time = "2026-03-02T21:04:11.662Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a7/e5/20972b620bd8cca086c284e97b285d437c108a23fee122ad7b92bd246c1a/microsoft_kiota_http-1.9.10.tar.gz", hash = "sha256:af1838d091f76426c974897357093ed977ce66f1d808cb161c190de873bb5833", size = 21493, upload-time = "2026-03-12T17:27:35.393Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/6a/cc1b1055b4b6d4dfc1be7a71917c2f0ef19c070c6a18b16d3c1032d20925/microsoft_kiota_http-1.9.9-py3-none-any.whl", hash = "sha256:a5b1b217ac9afeb4054f12515417e3b1d2be12a9385a70a41d18d64379ea2e7e", size = 31945, upload-time = "2026-03-02T21:04:12.328Z" },
{ url = "https://files.pythonhosted.org/packages/ce/f4/78ce18330a626138b2ff6bb62574adac01e8b9ee87c1349ddfeb9cab0556/microsoft_kiota_http-1.9.10-py3-none-any.whl", hash = "sha256:6127032c8d94f8607e4d36d0822b88bc8689ab368b4c00d6c7beb7d2d0f2ab10", size = 31960, upload-time = "2026-03-12T17:27:36.1Z" },
]
[[package]]
name = "microsoft-kiota-serialization-form"
version = "1.9.9"
version = "1.9.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "microsoft-kiota-abstractions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ee/b4/18e9fce60a30c8b6ea0a6278fb81cf352127340d48df2d7c52ff1b579488/microsoft_kiota_serialization_form-1.9.9.tar.gz", hash = "sha256:3cdc8b172baec5b5282af72f2ce02715edcd23252ce0b5af96075256edd75114", size = 9015, upload-time = "2026-03-02T21:04:20.39Z" }
sdist = { url = "https://files.pythonhosted.org/packages/56/90/7e1a090a2099acae1a1baa9a0762214b73b63d9268369b510994f75f54e4/microsoft_kiota_serialization_form-1.9.10.tar.gz", hash = "sha256:4c6655d8cd479d1ada63fdfe6a272e50d87d7c8369dbc8e13833ba4787fc798b", size = 9012, upload-time = "2026-03-12T17:27:44.214Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/24/eb8436b882f1473bd0a868848d214df3df2d9b3db8e5422d111032f1114f/microsoft_kiota_serialization_form-1.9.9-py3-none-any.whl", hash = "sha256:1c426d4f0d463fc9215c41d7fa0f3dc5fe8d3c80573d555cf63ea67000148d84", size = 10718, upload-time = "2026-03-02T21:04:21.25Z" },
{ url = "https://files.pythonhosted.org/packages/d9/4c/5092fc896b34c21e8b9c03c63006b313a81e2377176a69c97aa6a9c8f5bb/microsoft_kiota_serialization_form-1.9.10-py3-none-any.whl", hash = "sha256:765d3f6408668f58bfdf892c32b45967c579d9131f3ba5a6b6868cb7ab956bfe", size = 10728, upload-time = "2026-03-12T17:27:45.103Z" },
]
[[package]]
name = "microsoft-kiota-serialization-json"
version = "1.9.9"
version = "1.9.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "microsoft-kiota-abstractions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b8/2f/d36eba916c00136da122d1701acb862c5b1f2e22b6dc6fa4e0f4abda2786/microsoft_kiota_serialization_json-1.9.9.tar.gz", hash = "sha256:9b27479427f49bbac15ead8e8ff0176e47fcdf81153611acc408f5f399342079", size = 9545, upload-time = "2026-03-02T21:04:29.177Z" }
sdist = { url = "https://files.pythonhosted.org/packages/67/0e/55afd533a764ba77da988b7ca4242c84867a3a25f2ff0bf4c2b24b5e8fca/microsoft_kiota_serialization_json-1.9.10.tar.gz", hash = "sha256:6063028f30dd67afa2db20a72d9bde5e5d26d468f8bdedadd1445cf7c7630e17", size = 9746, upload-time = "2026-03-12T17:27:53.015Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/7b/b3f606ef2dcbdebe12ae27004ed6e7542370cb2494265f11a8877a1de2d1/microsoft_kiota_serialization_json-1.9.9-py3-none-any.whl", hash = "sha256:bb80b93e81bab41dc142e9b254f79bf0b7b9fe49a796ca0c8e8691925bd3967f", size = 11210, upload-time = "2026-03-02T21:04:29.844Z" },
{ url = "https://files.pythonhosted.org/packages/2f/56/d14c0185c8092abde1a60ad2bdd4480bb2ddb551ce71c6de1e6133a4d8d1/microsoft_kiota_serialization_json-1.9.10-py3-none-any.whl", hash = "sha256:0545ae910160b19caaa8c30c90c7416e1966294fbd6cc5af01f0e116a18f223a", size = 11452, upload-time = "2026-03-12T17:27:53.909Z" },
]
[[package]]
name = "microsoft-kiota-serialization-multipart"
version = "1.9.9"
version = "1.9.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "microsoft-kiota-abstractions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5f/44/24087f0fac7c5682c13c7fb61468a0c5a5185b9f243de3a99309aa6fcaa7/microsoft_kiota_serialization_multipart-1.9.9.tar.gz", hash = "sha256:f8730be6da5f6c63a6bf4ea310a9723b9998a47a04745887dc156d08f119a829", size = 5162, upload-time = "2026-03-02T21:04:48.1Z" }
sdist = { url = "https://files.pythonhosted.org/packages/24/34/eadc15c2a3131e2a76126f3112c32b73502cb5a335e2e40cac2877e5d843/microsoft_kiota_serialization_multipart-1.9.10.tar.gz", hash = "sha256:8f2da4f93e79b09f9738b6889685e47acfafcca870db94ab1d4cd233d69e4268", size = 5167, upload-time = "2026-03-12T17:28:18.507Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/db/6b988fdf771c3d07dff4a116176d575832daf2a43823444d145d71da5b61/microsoft_kiota_serialization_multipart-1.9.9-py3-none-any.whl", hash = "sha256:572e9cbafa2eb946452cdadfb019a4e9245768c0d61c3089d3436d4f5106c550", size = 6696, upload-time = "2026-03-02T21:04:48.98Z" },
{ url = "https://files.pythonhosted.org/packages/fa/40/345cbcee6c52b4261fedf4ae2ff8573aec47ce4ae2015ea8b57c75ef978b/microsoft_kiota_serialization_multipart-1.9.10-py3-none-any.whl", hash = "sha256:7cadc26483b567c738f926b044521569e0b797446053c9e8eab02269d4a81062", size = 6708, upload-time = "2026-03-12T17:28:19.397Z" },
]
[[package]]
name = "microsoft-kiota-serialization-text"
version = "1.9.9"
version = "1.9.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "microsoft-kiota-abstractions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/3c/d244ad08e03003134871698aa54de8243bcc61c0faf3ab114293bb76d6ad/microsoft_kiota_serialization_text-1.9.9.tar.gz", hash = "sha256:18bc0764dda4078a4c953300253344e05d0cdb9c17136f1a2f695d438cedb402", size = 7325, upload-time = "2026-03-02T21:04:37.567Z" }
sdist = { url = "https://files.pythonhosted.org/packages/74/a6/28a4a8d5c01f08e363135fc9585cab3c02d1b1a69c3c16032e6abb35dfed/microsoft_kiota_serialization_text-1.9.10.tar.gz", hash = "sha256:cfc433c2a95ea3c3ec43c8b09002fbf65c998c5c0571205df161fe0e9d5d8de7", size = 7326, upload-time = "2026-03-12T17:28:01.621Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/f8/43f8d00fed6e090810d3ce0c05e06c23eaa5dee6e87ab1fb89d96ca9559f/microsoft_kiota_serialization_text-1.9.9-py3-none-any.whl", hash = "sha256:84418119d4929a76fde7f31e957e240e003bf145757838b9aa3a0f36dec1b789", size = 8885, upload-time = "2026-03-02T21:04:38.76Z" },
{ url = "https://files.pythonhosted.org/packages/dd/bf/dd36e4a6d1cff3f2d30f03e2479cd38210e32d4715bb6a9f0e2737f13604/microsoft_kiota_serialization_text-1.9.10-py3-none-any.whl", hash = "sha256:742890cfd4450d12f58d42da7cfa474fe1ee5d6442e016bf70ab76e5c876c0ea", size = 8896, upload-time = "2026-03-12T17:28:02.328Z" },
]
[[package]]
@@ -4260,20 +4426,22 @@ wheels = [
[[package]]
name = "oci"
version = "2.169.0"
version = "2.184.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "circuitbreaker" },
{ name = "crc32c" },
{ name = "cryptography" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "pyopenssl" },
{ name = "python-dateutil" },
{ name = "pytz" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/11/f4/3c2eddccc75dd06a692dbb3290f20f4bc733d99dc60de21f22d65efdeae4/oci-2.169.0.tar.gz", hash = "sha256:f3c5fff00b01783b5325ea7b13bf140053ec1e9f41da20bfb9c8a349ee7662fa", size = 16885837, upload-time = "2026-03-31T06:14:58.981Z" }
sdist = { url = "https://files.pythonhosted.org/packages/74/2d/fa5368cfabb868f4111c6978e8b5f66aa3a55076c40c1a59ac3081b0227b/oci-2.184.1.tar.gz", hash = "sha256:617dad69caf8dd6e521d224dbc3e8a8bc289906943a0214fd2c3419094e26435", size = 17990631, upload-time = "2026-08-11T11:01:26.194Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/bf/19643bd939ab595193779ee25c2c12aef8e9a54e0a68de5ed79f209702e3/oci-2.169.0-py3-none-any.whl", hash = "sha256:c71bb5143f307791082b3e33cc1545c2490a518cfed85ab1948ef5107c36d30b", size = 34460447, upload-time = "2026-03-31T06:14:51.373Z" },
{ url = "https://files.pythonhosted.org/packages/5f/63/5ae22e42aaf96a5da74dc2b9de449c78b4d7418cce621d5da723b3e49f32/oci-2.184.1-py3-none-any.whl", hash = "sha256:bd814e38a70da2190e721937455a08689ab13c0750bd2ef8dd0c98b2dc5a38ea", size = 36628063, upload-time = "2026-08-11T11:01:18.178Z" },
]
[[package]]
@@ -4467,39 +4635,33 @@ wheels = [
[[package]]
name = "pillow"
version = "12.2.0"
version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" },
{ url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" },
{ url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" },
{ url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" },
{ url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" },
{ url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" },
{ url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" },
{ url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" },
{ url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" },
{ url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" },
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" },
{ url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" },
{ url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" },
{ url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" },
{ url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" },
{ url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" },
{ url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" },
{ url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
{ url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
{ url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" },
{ url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" },
{ url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" },
{ url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" },
{ url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" },
{ url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" },
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
{ url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" },
{ url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" },
{ url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" },
{ url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
]
[[package]]
@@ -4673,8 +4835,8 @@ wheels = [
[[package]]
name = "prowler"
version = "5.35.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f5ea116763aeffede9f399c8934fc280eaccd315" }
version = "5.40.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b6e9967da6bebd6c7b8b237317a2a95e2e0c65bc" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4729,6 +4891,17 @@ dependencies = [
{ name = "google-api-python-client" },
{ name = "google-auth-httplib2" },
{ name = "h2" },
{ name = "huaweicloudsdkcore" },
{ name = "huaweicloudsdkcts" },
{ name = "huaweicloudsdkecs" },
{ name = "huaweicloudsdkelb" },
{ name = "huaweicloudsdkevs" },
{ name = "huaweicloudsdkiam" },
{ name = "huaweicloudsdkkms" },
{ name = "huaweicloudsdkobs" },
{ name = "huaweicloudsdkrds" },
{ name = "huaweicloudsdkvpc" },
{ name = "huaweicloudsdkwaf" },
{ name = "jsonschema" },
{ name = "kingfisher-bin" },
{ name = "kubernetes" },
@@ -4762,7 +4935,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.37.0"
version = "1.41.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -4978,25 +5151,24 @@ wheels = [
[[package]]
name = "py-ocsf-models"
version = "0.8.1"
version = "0.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "email-validator" },
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/70/61e2f9ce3d7e83aa5339ed6ae17e473c15c7a36f161c6dbea0e939e3af0c/py_ocsf_models-0.8.1.tar.gz", hash = "sha256:c9045237857f951e073c9f9d1f57954c90d86875b469260725292d47f7a7d73c", size = 36540, upload-time = "2026-02-12T16:50:15.233Z" }
sdist = { url = "https://files.pythonhosted.org/packages/70/d6/f0787cbe953e3cf6ef4430f3cc7d66cbbaabe4b20cb82cc27cc2d21e622a/py_ocsf_models-0.10.0.tar.gz", hash = "sha256:29abaa5a3d4ebba0e2a21757508a4848fa5e1d57da233af57e580f97f0223c59", size = 36498, upload-time = "2026-07-13T07:05:44.448Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/18/63790884bf33f820e2c60f8d5038b5d6de967a03343ddf237c054e1d6d08/py_ocsf_models-0.8.1-py3-none-any.whl", hash = "sha256:061eb446c4171534c09a8b37f5a9d2a2fe9f87c5db32edbd1182446bc5fd097e", size = 64354, upload-time = "2026-02-12T16:50:12.983Z" },
{ url = "https://files.pythonhosted.org/packages/75/56/eca45ec87a02f930cc7eaa7cb36660f69fb00c3d77bb4a84bb92d6c94c25/py_ocsf_models-0.10.0-py3-none-any.whl", hash = "sha256:a9d1e245b1c9fba1d2cb8c042253ef1b83a2dbfec30ed69975bbce599b4510bb", size = 64334, upload-time = "2026-07-13T07:05:42.93Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.3"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
]
[[package]]
@@ -5187,6 +5359,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/ff/7f52c1461d8ceaefa989d2700a027f84427879bb7571145bbffdec5d5f4a/pylint-3.2.5-py3-none-any.whl", hash = "sha256:32cd6c042b5004b8e857d727708720c54a676d1e22917cf1a2df9b4d4868abd6", size = 519603, upload-time = "2024-06-28T13:10:23.526Z" },
]
[[package]]
name = "pymongo"
version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/c0c6732fbd358b75a07e17d7e588fd23d481b9812ca96ceeff90bbf879fc/pymongo-4.15.1.tar.gz", hash = "sha256:b9f379a4333dc3779a6bf7adfd077d4387404ed1561472743486a9c58286f705", size = 2470613, upload-time = "2025-09-16T16:39:47.24Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/da/89066930a70b4299844f1155fc23baaa7e30e77c8a0cbf62a2ae06ee34a5/pymongo-4.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:363445cc0e899b9e55ac9904a868c8a16a6c81f71c48dbadfd78c98e0b54de27", size = 865410, upload-time = "2025-09-16T16:38:16.279Z" },
{ url = "https://files.pythonhosted.org/packages/99/8f/a1d0402d52e5ebd14283718abefdc0c16f308cf10bee56cdff04b1f5119b/pymongo-4.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:da0a13f345f4b101776dbab92cec66f0b75015df0b007b47bd73bfd0305cc56a", size = 865695, upload-time = "2025-09-16T16:38:18.015Z" },
{ url = "https://files.pythonhosted.org/packages/53/38/d1ef69028923f86fd00638d9eb16400d4e60a89eabd2011fe631fd3186cf/pymongo-4.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9481a492851e432122a83755d4e69c06aeb087bbf8370bac9f96d112ac1303fd", size = 1434758, upload-time = "2025-09-16T16:38:20.141Z" },
{ url = "https://files.pythonhosted.org/packages/b0/eb/a8d5dff748a2dd333610b2e4c8120b623e38ea2b5e30ad190d0ce2803840/pymongo-4.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:625dec3e9cd7c3d336285a20728c01bfc56d37230a99ec537a6a8625af783a43", size = 1485716, upload-time = "2025-09-16T16:38:21.607Z" },
{ url = "https://files.pythonhosted.org/packages/c4/d4/17ba457a828b733182ddc01a202872fef3006eed6b54450b20dc95a2f77d/pymongo-4.15.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26a31af455bffcc64537a7f67e2f84833a57855a82d05a085a1030c471138990", size = 1460160, upload-time = "2025-09-16T16:38:23.509Z" },
{ url = "https://files.pythonhosted.org/packages/c3/25/42b8662c09f5ca9c81d18d160f48e58842e0fa4c314ea02613c5e5d54542/pymongo-4.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea4415970d2a074d5890696af10e174d84cb735f1fa7673020c7538431e1cb6e", size = 1439284, upload-time = "2025-09-16T16:38:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/b3/bb/46b9d978161828eb91973bd441a3f05f73c789203e976332a8de2832d5db/pymongo-4.15.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51ee050a2e026e2b224d2ed382830194be20a81c78e1ef98f467e469071df3ac", size = 1407933, upload-time = "2025-09-16T16:38:27.045Z" },
{ url = "https://files.pythonhosted.org/packages/4b/55/bd5af98f675001f4b06f7314b3918e45809424a7ad3510f823f6703cd8f2/pymongo-4.15.1-cp311-cp311-win32.whl", hash = "sha256:9aef07d33839f6429dc24f2ef36e4ec906979cb4f628c57a1c2676cc66625711", size = 844328, upload-time = "2025-09-16T16:38:28.513Z" },
{ url = "https://files.pythonhosted.org/packages/c3/78/90989a290dd458ed43a8a04fa561ac9c7b3391f395cdacd42e21f0f22ce4/pymongo-4.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ea6e5ff4d6747e7b64966629a964db3089e9c1e0206d8f9cc8720c90f5a7af1", size = 858951, upload-time = "2025-09-16T16:38:30.074Z" },
{ url = "https://files.pythonhosted.org/packages/de/bb/d4d23f06e166cd773f2324cff73841a62d78a1ad16fb799cf7c5490ce32c/pymongo-4.15.1-cp311-cp311-win_arm64.whl", hash = "sha256:bb783d9001b464a6ef3ee76c30ebbb6f977caee7bbc3a9bb1bd2ff596e818c46", size = 848290, upload-time = "2025-09-16T16:38:31.741Z" },
{ url = "https://files.pythonhosted.org/packages/7e/31/bc4525312083706a59fffe6e8de868054472308230fdee8db0c452c2b831/pymongo-4.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bab357c5ff36ba2340dfc94f3338ef399032089d35c3d257ce0c48630b7848b2", size = 920261, upload-time = "2025-09-16T16:38:33.614Z" },
{ url = "https://files.pythonhosted.org/packages/ae/55/4d99aec625494f21151b8b31e12e06b8ccd3b9dcff609b0dd1acf9bbbc0e/pymongo-4.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46d1af3eb2c274f07815372b5a68f99ecd48750e8ab54d5c3ff36a280fb41c8e", size = 919956, upload-time = "2025-09-16T16:38:35.121Z" },
{ url = "https://files.pythonhosted.org/packages/be/60/8f1afa41521df950e13f6490ecdef48155fc63b78f926e7649045e07afd1/pymongo-4.15.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7dc31357379318881186213dc5fc49b62601c955504f65c8e72032b5048950a1", size = 1698596, upload-time = "2025-09-16T16:38:36.586Z" },
{ url = "https://files.pythonhosted.org/packages/bc/3f/e48d50ee8d6aa0a4cda7889dd73076ec2ab79a232716a5eb0b9df070ffcf/pymongo-4.15.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12140d29da1ecbaefee2a9e65433ef15d6c2c38f97bc6dab0ff246a96f9d20cd", size = 1762833, upload-time = "2025-09-16T16:38:38.09Z" },
{ url = "https://files.pythonhosted.org/packages/63/87/db976859efc617f608754e051e1468459d9a818fe1ad5d0862e8af57720b/pymongo-4.15.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf193d2dcd91fa1d1dfa1fd036a3b54f792915a4842d323c0548d23d30461b59", size = 1731875, upload-time = "2025-09-16T16:38:39.742Z" },
{ url = "https://files.pythonhosted.org/packages/18/59/3643ad52a5064ad3ef8c32910de6da28eb658234c25f2db5366f16bffbfb/pymongo-4.15.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2c0bdcf4d57e4861ed323ba430b585ad98c010a83e46cb8aa3b29c248a82be1", size = 1701853, upload-time = "2025-09-16T16:38:41.333Z" },
{ url = "https://files.pythonhosted.org/packages/d8/96/441c190823f855fc6445ea574b39dca41156acf723c5e6a69ee718421700/pymongo-4.15.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43fcfc19446e0706bbfe86f683a477d1e699b02369dd9c114ec17c7182d1fe2b", size = 1660978, upload-time = "2025-09-16T16:38:42.877Z" },
{ url = "https://files.pythonhosted.org/packages/47/49/bd7e783fb78aaf9bdaa3f88cc238449be5bc5546e930ec98845ef235f809/pymongo-4.15.1-cp312-cp312-win32.whl", hash = "sha256:e5fedea0e7b3747da836cd5f88b0fa3e2ec5a394371f9b6a6b15927cfeb5455d", size = 891175, upload-time = "2025-09-16T16:38:44.658Z" },
{ url = "https://files.pythonhosted.org/packages/2e/28/7de5858bdeaa07ea4b277f9eb06123ea358003659fe55e72e4e7c898b321/pymongo-4.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:330a17c1c89e2c3bf03ed391108f928d5881298c17692199d3e0cdf097a20082", size = 910619, upload-time = "2025-09-16T16:38:46.124Z" },
{ url = "https://files.pythonhosted.org/packages/17/87/c39f4f8415e7c65f8b66413f53a9272211ff7dfe78a5128b27027bf88864/pymongo-4.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:756b7a2a80ec3dd5b89cd62e9d13c573afd456452a53d05663e8ad0c5ff6632b", size = 896229, upload-time = "2025-09-16T16:38:48.563Z" },
]
[[package]]
name = "pymsalruntime"
version = "0.18.1"
@@ -5223,15 +5426,15 @@ wheels = [
[[package]]
name = "pyopenssl"
version = "26.0.0"
version = "26.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size = 185534, upload-time = "2026-03-15T14:28:26.353Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" },
{ url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" },
]
[[package]]
@@ -5556,6 +5759,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
]
[[package]]
name = "requests-toolbelt"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
]
[[package]]
name = "requestsexceptions"
version = "1.4.0"
@@ -5774,6 +5989,37 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/c5/06/c6dcc975a1e7d89bc764fd271da8138b318e18080b48e7f1acd2ab63df28/shodan-1.31.0.tar.gz", hash = "sha256:c73275386ea02390e196c35c660706a28dd4d537c5a21eb387ab6236fac251f6", size = 57939, upload-time = "2023-12-17T01:42:02.426Z" }
[[package]]
name = "simplejson"
version = "4.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0e/2a/54837395a3487c725669428d513293612a48d82b95a0642c936932e5d898/simplejson-4.1.1.tar.gz", hash = "sha256:c08eb9f7a90f77ae470e19a07472e9a79ebc0d1c2315d86a72767665bd5ba79f", size = 118860, upload-time = "2026-04-24T19:24:59.819Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/25/39013ffe279d90093ec1c848565b3683c586906c10fa55d9000ec29d046b/simplejson-4.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2867c64d92abd1992c15666fae198203093f593e43d6b81adf176bae530d493a", size = 111538, upload-time = "2026-04-24T19:22:49.051Z" },
{ url = "https://files.pythonhosted.org/packages/f2/ae/2c272971c8a87e2539c54a98eb6ff037bee1e2e93943c3986cf7500a4f3a/simplejson-4.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c47c46e16c8ea9e4850061e6ed5aa2b9cd2074cb2274bfd9c138cba15ce7453", size = 90594, upload-time = "2026-04-24T19:22:50.408Z" },
{ url = "https://files.pythonhosted.org/packages/4e/a2/6eebfb99dedc139f549200f61ade6d1890ac5707c5d427bdfa6fe39c9313/simplejson-4.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e294e33dbf316a9bbdd4030d46503c9b0f19470ae7ad6af5bae6c426bc2e869f", size = 90718, upload-time = "2026-04-24T19:22:51.694Z" },
{ url = "https://files.pythonhosted.org/packages/80/7e/c9e6c0c4ad8415e64dad0c47f619b556b02680a41631b4dbc281d55dc54d/simplejson-4.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7ce252b28fddbdd83db5bd7d93dad2a8a591d7ada098afec9c1b23d6b722a7a4", size = 180901, upload-time = "2026-04-24T19:22:53.025Z" },
{ url = "https://files.pythonhosted.org/packages/34/09/69e331e3994b1ed9be6ce9ace4ade704e7ed503edf869929ca7bb404eda8/simplejson-4.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c44ef6b02a4eb67ed17a72342341792149b3ff46f15426c26e970e49addf327", size = 178133, upload-time = "2026-04-24T19:22:54.574Z" },
{ url = "https://files.pythonhosted.org/packages/5d/40/ed806f24afef295c1032448f5ff6f6f2979392d5645ddb9f4fed7f38194d/simplejson-4.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82bfca2b85a34178c25829c703f0a9e9f113a5af7539285bd3efb583a0bf1ba3", size = 188155, upload-time = "2026-04-24T19:22:56.044Z" },
{ url = "https://files.pythonhosted.org/packages/38/94/8d6f515b827b0f7881a49c8c1ac6920b7ae9428939ef04238c973278b42a/simplejson-4.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0e4b23f71dd781f8830f1663dc01a4944d3dbf87a1f93d78fba1cf64722d0ccf", size = 176225, upload-time = "2026-04-24T19:22:57.981Z" },
{ url = "https://files.pythonhosted.org/packages/c9/fd/6dffb4956563d48bbe46b91ff341adae34920e94008fd6b8d728072abfc7/simplejson-4.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:82fee635d7b73ad801030b05a75fbd34a098da0c2ecf600667a03636d09e1e42", size = 185535, upload-time = "2026-04-24T19:22:59.618Z" },
{ url = "https://files.pythonhosted.org/packages/de/d2/a509ee37763e79aec75d68f8521db1440306edeba3b8b4064ab4ee8bf1d9/simplejson-4.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:68e62eda21192c5ea9bb92d571ca46a4477fef48762f50d433de2b4253051551", size = 179302, upload-time = "2026-04-24T19:23:01.324Z" },
{ url = "https://files.pythonhosted.org/packages/d8/23/5b343bfd2a79d3b6818e4db3586c405a001a090d4c89d336e31273ce7177/simplejson-4.1.1-cp311-cp311-win32.whl", hash = "sha256:ffd3d82294b47f5ec64050021ace95fd62628a0c1cc8bbf4d06d2d1fb697e055", size = 88408, upload-time = "2026-04-24T19:23:02.808Z" },
{ url = "https://files.pythonhosted.org/packages/38/04/df9b37aedbd524dca20840d25ebe01d6ae486b89792aeff5d15b9c4114f7/simplejson-4.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:78a3fe0995be42bed62a26aa78e0e0b4d87c6545785346b9cc898f3389569a35", size = 90526, upload-time = "2026-04-24T19:23:04.408Z" },
{ url = "https://files.pythonhosted.org/packages/60/25/e90998fe8e480eb43b966c09e835379887d427567ebd496563d3b1e16b19/simplejson-4.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:19040a17154dc03d289bab68d73ce0a6a0be01de30c584bbdd93490bead14b22", size = 112414, upload-time = "2026-04-24T19:23:06.084Z" },
{ url = "https://files.pythonhosted.org/packages/9c/a0/abd4785f36c3400f1fbb21f517be39295a750a714f04b7ee175adf6ef580/simplejson-4.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a94ebaecdbaa80d9551a3ec6bf0c9302fc8b53ab6c1b2bfd498a1df4cb28158d", size = 91120, upload-time = "2026-04-24T19:23:07.877Z" },
{ url = "https://files.pythonhosted.org/packages/b8/78/fc060d2e3b13c6ec59288574b8efac64075e316b2afba4396a56b2422f78/simplejson-4.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:67341c95c0a168ab4a6d1e807e50463f1c8da932c3286d81e201266c427061fa", size = 91055, upload-time = "2026-04-24T19:23:09.264Z" },
{ url = "https://files.pythonhosted.org/packages/0c/b6/156a8de1e1b47694f0e7de6675866936608d45dc68388fd017d36f8693be/simplejson-4.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45ec18e337fec538b7e902d489505c450b2454653d1290f3f50385e6fd8aa607", size = 190297, upload-time = "2026-04-24T19:23:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/86/1c/e4d0eab695be3eb21d0f46bce820752031f03e7113f9c80a9b3c73ee7157/simplejson-4.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:820c69a4710400e9b248d5670647d60be58824369282d3925e516b3ff1a7cd82", size = 187002, upload-time = "2026-04-24T19:23:12.982Z" },
{ url = "https://files.pythonhosted.org/packages/76/0e/7f5a59d29426b062d5928fb88b403c3f797129d53be7102f955dbe51aa44/simplejson-4.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e708d373a10e4378ef2d59f8361850c7150fd907ed49efe49bc5492160476d1", size = 195146, upload-time = "2026-04-24T19:23:14.517Z" },
{ url = "https://files.pythonhosted.org/packages/78/18/9943db224dd4d5fa3c090c3e56a94c37b254338c83995ec5680285111c40/simplejson-4.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:980fc33353f81fd12d8c49d44f8c2760d1dc8192285e627c5180d141035b228a", size = 183931, upload-time = "2026-04-24T19:23:16.742Z" },
{ url = "https://files.pythonhosted.org/packages/c2/08/9a690da9a766161c06c627d805362cf159f1abe480969372b2897649b955/simplejson-4.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:de2ed102fff88dacf543699f53ee3a533cc11539a39baa176b7e09dd783069d6", size = 192228, upload-time = "2026-04-24T19:23:18.33Z" },
{ url = "https://files.pythonhosted.org/packages/05/88/bd8aad36b451ffb0e0a3f721d695a88befa6d1ac7d1e02ae788ca7ff4029/simplejson-4.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2785ff8edc0e28bf773a32543a6bbed46351453c997b3f6709c744e3c2f7eabb", size = 187808, upload-time = "2026-04-24T19:23:21.165Z" },
{ url = "https://files.pythonhosted.org/packages/04/ee/14f91db0d1f481533b651dafbf8cd0da088d9817f7af30c68f7f19f9c847/simplejson-4.1.1-cp312-cp312-win32.whl", hash = "sha256:2e0d5ead6d14610467ec356ec1f6b5d8a56aa216abaad8d41c8b873b16cf313f", size = 88512, upload-time = "2026-04-24T19:23:22.764Z" },
{ url = "https://files.pythonhosted.org/packages/b9/c4/90de06b2d8737c68c05ff9274113f854dbf6a5f28b7a955212111672cb57/simplejson-4.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:63a5451f557d6be48a231bae932458655c620902b868170b2f1c8afed496f6b4", size = 90748, upload-time = "2026-04-24T19:23:24.494Z" },
{ url = "https://files.pythonhosted.org/packages/ce/6a/8b74c52ffd33dbbde00fe7251fee6a0acdc8cea33f7a43805aed258fb79b/simplejson-4.1.1-py3-none-any.whl", hash = "sha256:2ce92b3748f02423e26d2bfb636fb9d7a8f67c8f5854dcae69d350d123b2eee2", size = 69195, upload-time = "2026-04-24T19:24:57.962Z" },
]
[[package]]
name = "six"
version = "1.17.0"
@@ -6203,16 +6449,16 @@ wheels = [
[[package]]
name = "workos"
version = "6.0.8"
version = "8.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "httpx" },
{ name = "pyjwt", extra = ["crypto"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/ca/0d/0a7f78912657f99412c788932ea1f3f4089916e77bdef7d2463842febe08/workos-6.0.8.tar.gz", hash = "sha256:43aa3f1992a0a4ca8933d9b6e5ada846dd3b1fe0ee10e64c876ee2000fc6090d", size = 178137, upload-time = "2026-04-24T18:48:03.203Z" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/f6/bb27fe77e70b5e2c5da72500ca0ece8b0e8318010fec92c31d68483314e3/workos-8.3.0.tar.gz", hash = "sha256:07b66c2fb287adb593e4d77a2e6cb05b48bd8ff0b2722f343d18eeb5e14f7472", size = 201587, upload-time = "2026-06-30T15:19:22.834Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/3f/3d96da80d650b2f97d58af626053354584f619dbb769051e118bd9cd1ca5/workos-6.0.8-py3-none-any.whl", hash = "sha256:a00dd4930333aded2babbba824f8032eea05c5ca8c44d04a3fa068cf6be6e21a", size = 524505, upload-time = "2026-04-24T18:48:01.389Z" },
{ url = "https://files.pythonhosted.org/packages/49/ed/7e6fe07c5bc0222fd92c1cf1f3c4c24293e4e5fc7bb5d7df90e4ee61c17f/workos-8.3.0-py3-none-any.whl", hash = "sha256:d0fa842b93bfc5fb33bf49e69cf8c379936cf54b87c6e2f50bcc6dd2e84f8fe4", size = 592275, upload-time = "2026-06-30T15:19:21.333Z" },
]
[[package]]
@@ -6377,39 +6623,27 @@ wheels = [
[[package]]
name = "zstd"
version = "1.5.7.3"
version = "1.5.7.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/62/b9c075ad664e7c4cbb3d8d2be7c246506abe1bc7f778eb58d260ef9538c8/zstd-1.5.7.3.tar.gz", hash = "sha256:403e5205f4ac04b92e6b0cda654be2f51de268228a0db0067bc087faacf2f495", size = 672559, upload-time = "2026-01-08T16:24:43.361Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/78/9a476e09c825304df47b98be80d1ffe223733b03550af71325415028f615/zstd-1.5.7.2.tar.gz", hash = "sha256:6d8684c69009be49e1b18ec251a5eb0d7e24f93624990a8a124a1da66a92fc8a", size = 670481, upload-time = "2025-06-23T12:36:08.131Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/0d/8c89c0d010b58c21a7865a239790bb1c6822029c053b1ded858d6b573e3a/zstd-1.5.7.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a3c1781a24e2ced2c0ddee11d45b1f04018b03615eeb622a62eca4d56d3358a", size = 267641, upload-time = "2026-01-08T16:30:50.812Z" },
{ url = "https://files.pythonhosted.org/packages/a3/6d/155d8c344d96eca2a5a003a5ddd63373a5f13591fd5cf2b9490250d6805a/zstd-1.5.7.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6c7c81056362b60a04baa34632e713d596662a860ec34efd8e9b109c10e6ec7", size = 230962, upload-time = "2026-01-08T16:30:49.155Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c7/ab93916a26eb58cd501ad701974c31b4bc67a7f6abd6c24bef8fe4d7649b/zstd-1.5.7.3-cp311-cp311-manylinux_2_14_x86_64.whl", hash = "sha256:e564f34a55effc7d654eb293468edc80b64d476b0f899f82760ecd8323223ff5", size = 304166, upload-time = "2026-01-10T11:17:45.697Z" },
{ url = "https://files.pythonhosted.org/packages/c2/54/27a7040a360019a4602343e3c98c0c0a140f382186002c01e1992fd21837/zstd-1.5.7.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:fbc49a57188184931d5e3c9f1133cad7eea5a370a9e9418fb8122d58c14340a5", size = 1540288, upload-time = "2026-01-08T17:50:26.913Z" },
{ url = "https://files.pythonhosted.org/packages/96/93/4a4d4edd1b2e809e0ebbb16000404bdcc9a09743c04ee1661442c9581b75/zstd-1.5.7.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:d121d3e63722819e1fe5effbcd9628d8a7cfea0cddabcc5bb37ea861a6a83424", size = 1619134, upload-time = "2026-01-08T17:50:32.324Z" },
{ url = "https://files.pythonhosted.org/packages/31/6b/cd6f0a7f4f0d98e4110aa77763cf3e85f594d983ea9ca3d64cc0cee10684/zstd-1.5.7.3-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:621f2e7ca8e9eb52a83eb9c91ec3cd283d87591bf75cc658de486b65f44742c7", size = 300166, upload-time = "2026-01-10T11:12:27.938Z" },
{ url = "https://files.pythonhosted.org/packages/05/3f/c717e0d15127d04b7fa58ba9b4c56e8b88b803048b9766cd9d158dbb22ea/zstd-1.5.7.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:c1950fcae690ba32d0f31702b335c548fb42547821565925e48576afdad774a5", size = 1525776, upload-time = "2026-01-08T17:50:35.518Z" },
{ url = "https://files.pythonhosted.org/packages/3e/a2/1813cd787d1a2f9ab8e8a90d28dcbc8e8098997dd04de38897ea8e75dd08/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bac4f0d03da69115878bedbfa03c4a3f64364e8396b432028c4ce0f05141a0fb", size = 2096057, upload-time = "2026-01-08T17:50:33.984Z" },
{ url = "https://files.pythonhosted.org/packages/36/ce/f5a3c7c12de458dd9ce15c484d627fe5412b60c155da23dacb5fcf08d9d5/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:da0ab134b7fd28023dedf013751ca850de300a090eb11f689d2a1c178c87d9dc", size = 2132659, upload-time = "2026-01-08T17:50:29.534Z" },
{ url = "https://files.pythonhosted.org/packages/f1/66/151f9546498bfd8971a0b6ad67d87c26d7a0df17d57f724da674f3778666/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b9923175842ee8f7602ec9cc578f5fc396896f0e8460d3ac9a5adc3cea77244e", size = 2124811, upload-time = "2026-01-08T17:50:37.612Z" },
{ url = "https://files.pythonhosted.org/packages/6a/34/4d2dbb36cb2373d3f115c047cb901b64f89de0703d10779da39de9453812/zstd-1.5.7.3-cp311-cp311-win32.whl", hash = "sha256:0612b604948d7b58aecc6788c7ceb53c5f21d94a155bb6ea9bd0f54ffa43725d", size = 150363, upload-time = "2026-01-08T17:11:02.392Z" },
{ url = "https://files.pythonhosted.org/packages/d9/de/f53687e0dd8c0d0ebfaed9ae88f6a96a1a0388ae7424b469e74bb17ac57d/zstd-1.5.7.3-cp311-cp311-win_amd64.whl", hash = "sha256:5b7f8c81b2bd3b62c0345242247d484cafa4b518d59d18619813d9225af5c5c3", size = 167577, upload-time = "2026-01-08T17:11:03.356Z" },
{ url = "https://files.pythonhosted.org/packages/f2/58/d4a6a902e229e953ed273fe9b78587ed31f57567aa68d3e34af6056e42af/zstd-1.5.7.3-cp311-cp311-win_arm64.whl", hash = "sha256:ea112e3acd9e1765adca35df7b54ac75b36194290f64ea03a3a59664209c8527", size = 157238, upload-time = "2026-01-08T16:36:06.25Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ed/5a3bf2e29dc56d4cc7619929bb51f0c758de6d02967cc73c5d8755a862c0/zstd-1.5.7.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01a39efb0eeab7cc45cb308618233b624b0840d5e16dcf85456b6cca0592f203", size = 268124, upload-time = "2026-01-08T16:29:57.091Z" },
{ url = "https://files.pythonhosted.org/packages/e2/1d/efc2074ac90af938e78f2ed4004639fe24f294d9086c5280f8d9a02b9897/zstd-1.5.7.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7a8e8838cf35fa3987bfe1958584cc22e1797efce8e155a63544b4144fc671f8", size = 230988, upload-time = "2026-01-08T16:29:55.604Z" },
{ url = "https://files.pythonhosted.org/packages/2a/52/178393b8d70e23fba67f42dfce4663e4e8a30867110168beb490a36d4639/zstd-1.5.7.3-cp312-cp312-manylinux_2_14_i686.whl", hash = "sha256:f3920ac1d1cc7e9f252f3e29f217fe3cd36f2191bb3dbcae826c29e189b7ad54", size = 300207, upload-time = "2026-01-10T11:26:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/6a/7a/8dcd86a2efb2ed3f9dae39545a05d3c7ed26c7678330786ce4a44cd8b099/zstd-1.5.7.3-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:143f9062953fb5590cbd47c1040d357336742c79696bf90b6d5b835279a68304", size = 304154, upload-time = "2026-01-10T11:17:40.91Z" },
{ url = "https://files.pythonhosted.org/packages/6f/ce/0c96905ab01ffe0e53a3cec8132123b82db26bd583a71608029bcc789ebc/zstd-1.5.7.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d1fd8647e47e1f21b345e192f1a279e925678c23dad8236b547d04456cd699", size = 2162222, upload-time = "2026-01-08T18:02:22.762Z" },
{ url = "https://files.pythonhosted.org/packages/11/c4/db4807d6a68b4628c74fd379de7e3c67ec34f19a2a80ac246b3837cde6cb/zstd-1.5.7.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1538db419afa62773cf534fc7f3009ff59ecf55ecee4e889587ac2ef0010ed8", size = 2201732, upload-time = "2026-01-08T18:02:20.835Z" },
{ url = "https://files.pythonhosted.org/packages/c5/99/c19a3c0f5580ff9c33a74f06d98d6060ed1fa6bd09b55aed9be852ec191f/zstd-1.5.7.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5efd16adb092e2a547a7d51cfdaf6fd5680528227684c5bafc7669ab4a55f41", size = 2096459, upload-time = "2026-01-08T18:02:25.336Z" },
{ url = "https://files.pythonhosted.org/packages/23/fd/02eac30419475dbe50212c119043a2d0698a0cbc756da85fd3fd9abddf42/zstd-1.5.7.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:39b3438e64637d80a5b1860526903b92020acb9bae9ceb5adffd9838c1441328", size = 2125442, upload-time = "2026-01-08T18:02:17.715Z" },
{ url = "https://files.pythonhosted.org/packages/bb/43/3a16ff0a8c913bb9825379db1bd533c75c57c2d2f31dd9111aa9b53711f4/zstd-1.5.7.3-cp312-cp312-win32.whl", hash = "sha256:cbf48c53461e224ffc2490cfe5120a1ff40d14c84d2b512c6d6d99fc91685cf3", size = 150367, upload-time = "2026-01-08T17:03:40.178Z" },
{ url = "https://files.pythonhosted.org/packages/46/83/b85875d7428e63dfa9247e41d17fac611443c774f7892f8643bd4164a6b2/zstd-1.5.7.3-cp312-cp312-win_amd64.whl", hash = "sha256:943a189910f2fea997462e3e4d7fbf727a06d231ef801ebee557b1c87568981c", size = 167604, upload-time = "2026-01-08T17:03:41.355Z" },
{ url = "https://files.pythonhosted.org/packages/37/42/cf291e26804de2f55500cdac93f5e9fa6267cf315def8aa402529bae3a87/zstd-1.5.7.3-cp312-cp312-win_arm64.whl", hash = "sha256:85c4d508f8109afa7c51c4960626c3325af2cf1e442c6c36ebfea15d04757e3f", size = 157241, upload-time = "2026-01-08T16:47:34.615Z" },
{ url = "https://files.pythonhosted.org/packages/04/b8/d13d584867d5eb1bc607877a870858e02a256d4706a4274e475413a000aa/zstd-1.5.7.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:76c49ea969bc08389ea59155cea7c5dea224522ffc62f443f3c0a915f5fd184d", size = 260025, upload-time = "2026-01-08T16:57:45.739Z" },
{ url = "https://files.pythonhosted.org/packages/16/a1/1e5faf75bedfd2bfccfb83e18736b115bed6e348504bd21800cd8f30dcea/zstd-1.5.7.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b1a638ff3dfce8f4cb1203c662fb5606dd99b4a62c5ddc4c406d2d1326bcfdd", size = 221038, upload-time = "2026-01-08T17:16:32.005Z" },
{ url = "https://files.pythonhosted.org/packages/b7/2c/0fe74d8b2029eef8000bc71aac5b3e5b55d00581238711cf627814183ea3/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5e96a5cb100a0edc162935227f2d9784b1031ce4a8a83e96e66eae2673c10143", size = 326792, upload-time = "2026-01-08T16:57:35.631Z" },
{ url = "https://files.pythonhosted.org/packages/96/e0/2c7f081f3524f872128ff31bea2acb6b21cb1dacccef920eb6a1a77a87c6/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bda0bbf3a9553720cd33f1f85940a259656c7ffba4be717ff82b7f062052188", size = 322283, upload-time = "2026-01-08T16:57:36.759Z" },
{ url = "https://files.pythonhosted.org/packages/c9/a7/3bebfcc18d66b90bc7b506a61b2ff4af5ee1b0b16e784ea644afa06241c5/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac36e4022422f6e49b3f07bdbb8a964fd348223d3dc9c82ad5398a4f0432a719", size = 311553, upload-time = "2026-01-08T16:57:38.465Z" },
{ url = "https://files.pythonhosted.org/packages/41/75/8a791cae2c98e5e44a158e15db50d21b7ec0b37aeaffa68d151bc8ffb6d6/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:fa4d760a220541b18ce732a3a2cf7547ea05afc76d05b3b39edebfeb721f6079", size = 317071, upload-time = "2026-01-08T16:36:07.47Z" },
{ url = "https://files.pythonhosted.org/packages/2f/25/b6624e6b08d515242154436c9d06fb20b790d300ac82e84f3c4c133e25e1/zstd-1.5.7.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a69e60146bf8aaa6a0e6c9a94a7c5f3133d68091e2e5c5a3c5ababf71fd5ec7a", size = 167654, upload-time = "2026-01-08T17:00:56.667Z" },
{ url = "https://files.pythonhosted.org/packages/43/2a/0885f6f1921ec1ef4a8f8ab29ab0a335cc867abe4c7aaa4e5031435a32a5/zstd-1.5.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f799c1e9900ad77e7a3d994b9b5146d7cfd1cbd1b61c3db53a697bf21ffcc57b", size = 269702, upload-time = "2025-06-23T12:50:11.695Z" },
{ url = "https://files.pythonhosted.org/packages/05/e6/629cf6b77e47fc7149f5724fb4853c48edcdeb10d8c64e391d7026cb10e1/zstd-1.5.7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ff4c667f29101566a7b71f06bbd677a63192818396003354131f586383db042", size = 228145, upload-time = "2025-06-23T12:50:10.411Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b8/9ddefd4670bfe9328ca6657ad335eb8d9c657466247e234a579818b6b0b9/zstd-1.5.7.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8526a32fa9f67b07fd09e62474e345f8ca1daf3e37a41137643d45bd1bc90773", size = 1536530, upload-time = "2025-06-23T13:51:38.853Z" },
{ url = "https://files.pythonhosted.org/packages/d1/6a/1bb836c18760dc1e28ca7a9706016e482ebdea633b980d8505dbb65e18f8/zstd-1.5.7.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:2cec2472760d48a7a3445beaba509d3f7850e200fed65db15a1a66e315baec6a", size = 1616141, upload-time = "2025-06-23T13:51:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/b5/7a/bb6c6e2cb2a066e347dc27d45d5205058b69d6c8b8d4ae2ee7d6b91c64a5/zstd-1.5.7.2-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:a200c479ee1bb661bc45518e016a1fdc215a1d8f7e4bf6c7de0af254976cfdf6", size = 322188, upload-time = "2025-06-23T13:01:48.704Z" },
{ url = "https://files.pythonhosted.org/packages/5a/4f/cf0669c8a89fdcc91814bf92bd05cc363d5d12a79b656418c0add6f2d266/zstd-1.5.7.2-cp311-cp311-manylinux_2_4_x86_64.whl", hash = "sha256:f5d159e57a13147aa8293c0f14803a75e9039fd8afdf6cf1c8c2289fb4d2333a", size = 302736, upload-time = "2025-06-23T13:05:33.649Z" },
{ url = "https://files.pythonhosted.org/packages/be/bc/e5f8b7f61826323e39e099db1eb5c0e09b18315df1b1ff778f7ae9aadcac/zstd-1.5.7.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:7206934a2bd390080e972a1fed5a897e184dfd71dbb54e978dc11c6b295e1806", size = 1522687, upload-time = "2025-06-23T13:51:35.494Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8c/7660a949a020ac9d02b3166a25dd1c12144572d77b11ae92a31d341016da/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e0027b20f296d1c9a8e85b8436834cf46560240a29d623aa8eaa8911832eb58", size = 2098794, upload-time = "2025-06-23T13:51:37.219Z" },
{ url = "https://files.pythonhosted.org/packages/bc/b2/730c811a78d670104d40c7f08cc8092577cdff870cba42b3158f20fceb57/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d6b17e5581dd1a13437079bd62838d2635db8eb8aca9c0e9251faa5d4d40a6d7", size = 2112266, upload-time = "2025-06-23T13:51:31.258Z" },
{ url = "https://files.pythonhosted.org/packages/44/74/2c16e1632094db36c8920d4c13b8e2e843024d548ae26888c2d22af6a676/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b13285c99cc710f60dd270785ec75233018870a1831f5655d862745470a0ca29", size = 2109465, upload-time = "2025-06-23T13:51:32.884Z" },
{ url = "https://files.pythonhosted.org/packages/58/6e/b9c9a834769d96cab2122da1be8c8c700d3f76be796d2b7516e85d2eca0e/zstd-1.5.7.2-cp311-cp311-win32.whl", hash = "sha256:cdb5ec80da299f63f8aeccec0bff3247e96252d4c8442876363ff1b438d8049b", size = 149448, upload-time = "2025-06-23T13:06:21.144Z" },
{ url = "https://files.pythonhosted.org/packages/47/b7/fc22ad6292a32d7676ab815de3a23573beac3679e8abd9914288d1496ceb/zstd-1.5.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:4f6861c8edceb25fda37cdaf422fc5f15dcc88ced37c6a5b3c9011eda51aa218", size = 166592, upload-time = "2025-06-23T13:06:22.126Z" },
{ url = "https://files.pythonhosted.org/packages/45/14/096bb77f3e5ef525b452cd6294da33de7f8a8c9647ba78293378fbb0a7ce/zstd-1.5.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ebe3e60dbace52525fa7aa604479e231dc3e4fcc76d0b4c54d8abce5e58734", size = 269408, upload-time = "2025-06-23T13:11:46.492Z" },
{ url = "https://files.pythonhosted.org/packages/08/b8/2bc2590a34c733ea0570f366e6ad7d889d05c7825bd3ccab01f36ece71c6/zstd-1.5.7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef201b6f7d3a6751d85cc52f9e6198d4d870e83d490172016b64a6dd654a9583", size = 228188, upload-time = "2025-06-23T13:11:47.539Z" },
{ url = "https://files.pythonhosted.org/packages/b7/80/6252de3a70cfd7767718ad476893f1c7dc129f942cc7ed0322e3137c03d9/zstd-1.5.7.2-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:ac7bdfedda51b1fcdcf0ab69267d01256fc97ddf666ce894fde0fae9f3630eac", size = 302720, upload-time = "2025-06-23T12:40:11.522Z" },
{ url = "https://files.pythonhosted.org/packages/af/b6/af908387814b99172d3aea6aeb24b19583aadfa45f6021e5e2a0d6d8e99a/zstd-1.5.7.2-cp312-cp312-manylinux_2_4_i686.whl", hash = "sha256:b835405cc4080b378e45029f2fe500e408d1eaedfba7dd7402aba27af16955f9", size = 322237, upload-time = "2025-06-23T13:17:35.482Z" },
{ url = "https://files.pythonhosted.org/packages/ed/d7/ab9142e002a7eaa451cb4bb37a74c390c489ba8ae75ade543840496eda04/zstd-1.5.7.2-cp312-cp312-win32.whl", hash = "sha256:e4cf97bb97ed6dbb62d139d68fd42fa1af51fd26fd178c501f7b62040e897c50", size = 149453, upload-time = "2025-06-23T13:13:02.786Z" },
{ url = "https://files.pythonhosted.org/packages/3e/c7/c182ea7bc283f591e3f3c5f0f239e7a92c9bc1f626642ae2c4dfbe51d6f2/zstd-1.5.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:55e2edc4560a5cf8ee9908595e90a15b1f47536ea9aad4b2889f0e6165890a38", size = 166628, upload-time = "2025-06-23T13:13:03.745Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c9/a6495a7bf168a78f0a0c01d61d830ebfb401315a64fd1ae8d725c458114c/zstd-1.5.7.2-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:5fb2ff5718fe89181223c23ce7308bd0b4a427239379e2566294da805d8df68a", size = 315542, upload-time = "2025-06-23T12:39:27.598Z" },
]
+13
View File
@@ -6,6 +6,19 @@ component_management:
- component_id: "api"
paths:
- "api/**"
- component_id: "mcp_server"
paths:
- "mcp_server/**"
flags:
api:
paths:
- "api/**"
carryforward: true
mcp:
paths:
- "mcp_server/**"
carryforward: true
comment:
layout: "header, diff, flags, components"
@@ -18,15 +18,12 @@ spec:
triggers:
- type: {{ .Values.worker.keda.triggerType }}
metadata:
userName: "postgres"
passwordFromEnv: POSTGRES_ADMIN_PASSWORD
host: {{ .Release.Name }}-postgresql
port: {{ .Values.postgresql.port | quote }}
dbName: {{ .Values.postgresql.auth.database | quote }}
sslmode: disable
# Query for KEDA to count the number of scans that are in executing, available, or scheduled states,
# where the scheduled time is within the last 2 hours and is before NOW(). Used for scaling workers.
query: >-
SELECT COUNT(*) FROM scans WHERE ((state='executing' OR state='available' OR state='scheduled') and scheduled_at < NOW() and scheduled_at > NOW() - INTERVAL '2 hours')
targetQueryValue: "1"
userName: {{ .Values.worker.keda.postgresql.userName | quote }}
passwordFromEnv: {{ .Values.worker.keda.postgresql.passwordFromEnv | quote }}
host: {{ .Values.worker.keda.postgresql.host | default (printf "%s-postgresql.%s.svc.cluster.local" .Release.Name .Release.Namespace) | quote }}
port: {{ .Values.worker.keda.postgresql.port | quote }}
dbName: {{ .Values.worker.keda.postgresql.database | default .Values.postgresql.auth.database | quote }}
sslmode: {{ .Values.worker.keda.postgresql.sslmode | quote }}
query: {{ .Values.worker.keda.query | quote }}
targetQueryValue: {{ .Values.worker.keda.targetQueryValue | quote }}
{{- end }}
+59 -3
View File
@@ -189,6 +189,11 @@ api:
DJANGO_STALE_WHILE_REVALIDATE: "60"
DJANGO_MANAGE_DB_PARTITIONS: "True"
DJANGO_BROKER_VISIBILITY_TIMEOUT: "86400"
# Caps the Celery prefork pool size on the worker pods. Without it, Celery
# sizes the pool from the number of visible CPUs, so on large nodes the
# worker spawns one child per CPU, each loading the full Prowler SDK, and
# OOMKills under memory pressure. Raise it on bigger workers.
DJANGO_CELERY_WORKER_CONCURRENCY: "2"
# Secret names to be used as env vars for api, worker, and worker_beat.
secrets: []
@@ -422,10 +427,61 @@ worker:
pollingInterval: 30
# -- The cooldown period in seconds for scaling
cooldownPeriod: 120
# -- The trigger type for scaling (cpu or memory)
# -- The KEDA scaler type. Only `postgresql` is supported by the default query below.
triggerType: "postgresql"
# -- The target utilization percentage for the worker pods
value: "50"
# PostgreSQL connection used by the scaler query. The KEDA operator opens this
# connection from its own namespace, so `host` must resolve from there. The
# defaults target the bundled postgresql subchart; set them explicitly when
# using an external database (postgresql.enabled: false).
postgresql:
# -- Scaler database host. Defaults to the bundled "<release>-postgresql.<namespace>.svc.cluster.local" service.
host: ""
# -- Scaler database port.
port: "5432"
# -- Scaler database name. Defaults to `postgresql.auth.database`.
database: ""
# -- User the scaler authenticates as.
userName: "postgres"
# -- Name of an env var on the worker container holding the password.
passwordFromEnv: "POSTGRES_ADMIN_PASSWORD"
# -- sslmode for the scaler connection.
sslmode: "disable"
# -- The scaler divides the query result by this value to get the desired replica count.
targetQueryValue: "1"
# -- Query the scaler runs to measure pending work. It replaces the previous
# 2-hour scheduled-only window, which missed manual scans, older backlogs and
# in-progress scans. Override to tune scaling for your workload.
#
# The default sums three signals:
# 1. Scans executing or available, bounded to rows updated in the last 24h so
# orphaned rows do not pin the worker up, plus scheduled scans that are due
# (no lower bound, so an overdue backlog still scales up).
# 2. Scan tasks published in the last 48h that no worker has finished. A PENDING
# TaskResult is written at publish time (before_task_publish in api/signals.py),
# so Beat's daily publishes are visible even with zero workers. Signal 1 alone
# deadlocks with minReplicas 0: every scan row after the first is created by
# the worker, so once the initial row ages out of the 24h bound there is
# nothing to count and nothing to create more.
# 3. Non-scan tasks pending in the last hour. Provider connection checks,
# deletions, reports and backfills never touch the scans table, so without
# this they are never picked up while the worker is scaled to zero.
# This includes reconcile-orphan-tasks, a Beat watchdog that runs every two
# minutes, so with minReplicas 0 the worker is woken about that often. Add
# it to the excluded task names below, or raise cooldownPeriod, if you would
# rather trade watchdog latency for longer idle periods.
query: >-
SELECT
(SELECT COUNT(*) FROM scans
WHERE (state IN ('executing', 'available') AND updated_at > NOW() - INTERVAL '24 hours')
OR (state = 'scheduled' AND scheduled_at < NOW()))
+ (SELECT COUNT(*) FROM django_celery_results_taskresult
WHERE task_name IN ('scan-perform', 'scan-perform-scheduled')
AND status IN ('PENDING', 'RECEIVED', 'STARTED')
AND date_created > NOW() - INTERVAL '48 hours')
+ (SELECT COUNT(*) FROM django_celery_results_taskresult
WHERE task_name NOT IN ('scan-perform', 'scan-perform-scheduled')
AND status IN ('PENDING', 'RECEIVED', 'STARTED')
AND date_created > NOW() - INTERVAL '1 hour')
worker_beat:
# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/
+3 -3
View File
@@ -64,7 +64,7 @@ services:
condition: service_healthy
postgres:
image: postgres:16.3-alpine3.20@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41
image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
hostname: "postgres-db"
volumes:
- ./_data/postgres:/var/lib/postgresql/data
@@ -88,7 +88,7 @@ services:
retries: 5
valkey:
image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc
image: valkey/valkey:8-alpine@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec
hostname: "valkey"
volumes:
- ./_data/valkey:/data
@@ -104,7 +104,7 @@ services:
retries: 3
neo4j:
image: graphstack/dozerdb:5.26.3.0@sha256:a77526ea3918fdc46d1fff70c4aea7d71d3874a26ecec059179d6775845b1247
image: graphstack/dozerdb:5.26.27.0@sha256:9b54d6b3a98a76c00bd23e8e78d8c82081ff168162aebd47b25c234e092cb0a0
hostname: "neo4j"
volumes:
- ./_data/neo4j:/data
+3 -3
View File
@@ -60,7 +60,7 @@ services:
start_period: 60s
postgres:
image: postgres:16.3-alpine3.20@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41
image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
hostname: "postgres-db"
volumes:
- ./_data/postgres:/var/lib/postgresql/data
@@ -80,7 +80,7 @@ services:
retries: 5
valkey:
image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc
image: valkey/valkey:8-alpine@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec
hostname: "valkey"
volumes:
- ./_data/valkey:/data
@@ -96,7 +96,7 @@ services:
retries: 3
neo4j:
image: graphstack/dozerdb:5.26.3.0@sha256:a77526ea3918fdc46d1fff70c4aea7d71d3874a26ecec059179d6775845b1247
image: graphstack/dozerdb:5.26.27.0@sha256:9b54d6b3a98a76c00bd23e8e78d8c82081ff168162aebd47b25c234e092cb0a0
hostname: "neo4j"
volumes:
- ./_data/neo4j:/data
+989
View File
@@ -0,0 +1,989 @@
---
title: "Changelog"
description: "New features and improvements in each Prowler release"
rss: true
---
<Update label="v5.39.0" description="August 13, 2026">
### 🤖 Lighthouse AI — Finding Skills
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Lighthouse AI now embeds a Skills menu on every finding, answering the questions an analyst actually asks. **Contextual Fix** produces the fix for the finding, **Triage Decision** judges whether it is real and closes it out when it is not, and **Systemic Scope** determines whether the problem is a one-off or everywhere. A free-form "Ask Lighthouse anything" prompt sits in the same menu, and each run shows its progress and offers follow-up actions such as creating a Jira issue or muting the finding.
![Lighthouse AI Skills menu on a finding resource](/images/changelog/v5.39.0-lighthouse-finding-skills.png)
Read more in the [Lighthouse AI documentation](https://docs.prowler.com/getting-started/products/prowler-cloud-lighthouse).
### ☁️ Azure Management Group Onboarding
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Azure subscriptions no longer onboard one at a time. Choose "Add Multiple Subscriptions With Azure Management Group" in the add-provider wizard, enter the Microsoft Entra tenant ID, and authenticate once with a single tenant-wide service principal: Prowler discovers the entire management-group hierarchy under the tenant root, lets you select the subscriptions to onboard, and creates their providers with the management-group structure preserved. Azure now matches the one-step onboarding that AWS Organizations and GCP organizations already have.
![Azure onboarding method selector with the Management Group option](/images/changelog/v5.39.0-azure-mg-selector.png)
Read more in the [Azure Management Groups documentation](https://docs.prowler.com/user-guide/tutorials/prowler-cloud-azure-management-groups).
### ✅ Findings Triage — Verify MANUAL Findings as PASS
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Checks that require human judgment report `MANUAL` findings. For these findings, and only for them, the triage status selector now offers **Resolved**: choosing it asks for the required written evidence and verifies the finding as passing. The finding then reports an effective `PASS` while preserving the raw `MANUAL` scan result, across findings, finding groups, compliance reports, and scans, with the attestation's author, evidence, and validity always visible. Attestations expire automatically after 90 days, or as soon as a new scan reports a real failure, returning the finding to the review queue.
![Triage status selector offering Resolved on a MANUAL finding](/images/changelog/v5.39.0-manual-pass-selector.png)
![Manual Pass details showing evidence, author, and validity](/images/changelog/v5.39.0-manual-pass-details.png)
Read more in the [Findings Triage documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-findings-triage#verify-a-manual-finding-as-pass).
### ☁️ Prowler Cloud MCP — Organizations Management and Grouped Jira Dispatch
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
The hosted Prowler Cloud MCP server adds eight organization tools, so an agent can onboard and manage entire cloud organizations end to end: create the organization, discover its accounts, subscriptions, and projects, apply the selection, and manage the resulting providers. The tools cover AWS Organizations, GCP organizations, and Azure tenant root management groups, and they are available to Lighthouse AI.
`prowler_send_findings_to_jira` also gains Cloud-only dispatch capabilities: select failed findings by check IDs against the latest completed scan, and send them in grouped mode, one Jira work item per check listing up to 50 affected resources, with per-group error reporting.
Read more in the [Prowler MCP tools documentation](https://docs.prowler.com/getting-started/basic-usage/prowler-mcp-tools) and its [Jira operations reference](https://docs.prowler.com/getting-started/basic-usage/prowler-mcp-tools#jira-operations).
### 🕸️ Attack Paths — Grouped Graph with Outcome Destinations
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
The Attack Paths graph now reads from source to destination. Resources of the same class collapse into a single expandable node with a count, clicking reveals its members, and every path terminates in an explicit outcome node naming the destination impact: code execution, privilege escalation, public exposure, or resource inventory. The per-account hub node is gone, and the clicked resource stays highlighted while its findings are expanded.
![Attack Paths graph from the Internet to a public exposure outcome node](/images/changelog/v5.39.0-attack-paths-graph.png)
Explore the full Attack Paths query catalog at [Prowler Hub](https://hub.prowler.com/attack-paths).
Read more in the [Attack Paths documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-attack-paths).
### 📚 New Compliance Framework — CMMC 2.0
The Cybersecurity Maturity Model Certification (CMMC) is the certification the US Department of Defense requires from contractors and suppliers that handle federal contract data. Prowler now includes CMMC 2.0 as a universal framework with all 149 requirements defined by the CMMC Program rule (32 CFR Part 170), organized in its three levels:
- **Level 1 (Foundational):** 15 requirements for the basic safeguarding of Federal Contract Information, from FAR 52.204-21.
- **Level 2 (Advanced):** 110 requirements from NIST SP 800-171 Rev 2, protecting Controlled Unclassified Information.
- **Level 3 (Expert):** 24 enhanced requirements from NIST SP 800-172 for the most sensitive programs.
Requirements map to Prowler checks across AWS, Azure, GCP, Alibaba Cloud, Oracle Cloud, and Microsoft 365, so one framework reports the compliance posture of the whole estate.
Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance).
### 🔍 Checks
#### Microsoft 365
Twenty new Entra ID checks expand the coverage of CIS Microsoft 365 Foundations Benchmark v7.0.0:
- **Password protection:** custom banned password list, on-premises enforcement, and lockout threshold and duration.
- **Default user permissions:** security group and Microsoft 365 group creation restricted, and guest invitations limited to allowed domains.
- **Conditional Access:** high and medium sign-in risk blocked, authentication transfer blocked, untrusted locations blocked, trusted named locations defined, sign-in frequency enforced, and token protection enforced.
- **Sessions and authentication methods:** idle session timeout configured, email one-time passcodes disabled, and Microsoft Authenticator context shown.
- **PIM and access reviews:** approval required to activate the Global Administrator and Privileged Role Administrator roles, and access reviews configured for guest users and privileged roles.
Explore all Microsoft 365 checks at [Prowler Hub](https://hub.prowler.com/check?provider=m365).
#### AWS
Two new checks detect hardcoded secrets:
- `batch_job_definition_no_secrets` scans Batch job definition environment variables and command parameters. Thanks to @praneetrajv!
- `awslambda_layer_no_secrets_in_content` scans Lambda layer package content. Thanks to @ganiganesh25!
Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
### 🙌 External Contributors
Thank you to our community contributors for this release!
- @praneetrajv: AWS `batch_job_definition_no_secrets` check ([#12117](https://github.com/prowler-cloud/prowler/pull/12117))
- @ganiganesh25: AWS `awslambda_layer_no_secrets_in_content` check ([#12233](https://github.com/prowler-cloud/prowler/pull/12233))
- @andoniaf: GitHub `organization_repository_creation_limited` now reports low severity when repository creation is limited to private or internal visibility ([#12164](https://github.com/prowler-cloud/prowler/pull/12164))
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.39.0) for the complete list of changes.
</Update>
<Update label="v5.38.0" description="August 6, 2026">
### 📌 Compliance Watchlist
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Compliance Watchlist keeps the frameworks an organization tracks in one shared list. Pin frameworks from any compliance view, manage several at once through a searchable catalog, and filter the Compliance section to show only the pinned frameworks.
The Overview page now reports the latest score for every pinned framework, while finding details highlight the watched frameworks associated with each check. Universal frameworks remain a single watchlist entry across provider views, keeping the organization's priorities consistent everywhere.
![Compliance Watchlist editor](/images/compliance/prowler-app-compliance-watchlist-editor.png)
Read more in the [Compliance Watchlist documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance#tracking-frameworks-with-the-compliance-watchlist).
### 🔐 SAML SSO - Multiple Email Domains
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
One SAML configuration can now authorize a primary email domain and up to 19 additional domains through the same Identity Provider. Every domain shares one stable Assertion Consumer Service (ACS) URL based on the primary domain, so subsidiaries, acquired companies, regional domains, and multiple brands no longer require separate tenants or duplicated SAML applications.
Domain ownership remains tenant-bound throughout the authentication flow. During service provider-initiated sign-in, the discovery domain and the domain asserted by the Identity Provider must resolve to the same tenant before provisioning continues.
![SAML configuration with multiple email domains](/images/prowler-app/saml/saml-multiple-domains.png)
Read more in the [SAML SSO documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-sso#add-multiple-saml-domains).
### 👥 User Sign-In Methods
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
The Users table now shows each account's sign-in methods as tags, including email/password, Google, GitHub, SAML with linked domains, and Partner SSO. Accounts without a reported method display a placeholder.
![Users table showing sign-in method tags](/images/changelog/v5.38.0-user-sign-in-methods.png)
### 🕸️ Attack Paths - Expanded AWS Privilege-Escalation Coverage
Attack Paths adds 20 AWS privilege-escalation queries from [pathfinding.cloud](https://pathfinding.cloud), while `iam_policy_allows_privilege_escalation` gains 22 additional escalation combinations.
The new coverage includes service `iam:PassRole` paths across AWS Batch, Braket, Cognito Identity, ECS, EMR, EMR Serverless, GameLift, Glue, EC2 Image Builder, Kinesis Analytics, HealthOmics, EventBridge Scheduler, Systems Manager, and Step Functions. It also covers existing-resource abuse, permissions-boundary removal, role assumption, and IAM Identity Center permission-set policy injection.
The query catalog now exposes each AWS query's outcome category, distinguishing code execution, privilege escalation, public exposure, and resource inventory.
Explore the full Attack Paths query catalog at [Prowler Hub](https://hub.prowler.com/attack-paths).
Read more in the [Attack Paths documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-attack-paths).
### 🔍 Checks
#### Microsoft 365
Twelve new checks expand the coverage of CIS Microsoft 365 Foundations Benchmark v7.0.0:
- **Admin Center:** Shared Bookings is disabled.
- **Defender:** Priority account protection and strict preset security policies are enabled.
- **Entra ID:** Six checks cover device registration restrictions, local administrator behavior, device limits, LAPS, and BitLocker key visibility.
- **Exchange Online:** Personal accounts in Outlook on the web are disabled and Direct Send is rejected.
- **Microsoft Teams:** External access from trial-only tenants is blocked.
Explore all Microsoft 365 checks at [Prowler Hub](https://hub.prowler.com/check?provider=m365).
### 🔐 Security
- Prowler API, UI, SDK, and MCP container images now publish per-architecture Software Bills of Materials (SBOMs) and build-provenance attestations. Prowler Cloud production and Prowler Private Cloud images carry the same attestations.
- SDK and API container builds verify the checksums of downloaded PowerShell, Trivy, and zizmor binaries before installation.
- Grype now complements Trivy across the container-image security gates, detecting components and vulnerabilities that manifest-based scanners can miss and blocking fixable high and critical findings.
- `aiohttp` was upgraded to 3.14.3 to address CVE-2026-69244. `cryptography` was upgraded to 50.0.0 to address CVE-2026-69247 and CVE-2026-69249.
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.38.0) for the complete list of changes.
</Update>
<Update label="v5.37.0" description="August 3, 2026">
### 💬 Lighthouse AI — Context-Aware Chat and a Bigger Toolbox
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Lighthouse AI is now aware of your working context when in Prowler Cloud. Messages carry page-aware context — the page you are on, the finding or resource open in the side panel, and its metadata — so "explain this" just works, and each page offers concise contextual suggestions to start from.
![Lighthouse AI answering "explain this finding" from the side panel, with the page context chip highlighted in the composer](/images/prowler-app/lighthouse/prowler-cloud/side-panel-context-aware.png)
Lighthouse also gained access to every tool family the Prowler MCP server advertises: scan configurations, scan scheduling, finding triage, alert rules and recipients, integrations, users, and roles. Every action remains gated by RBAC: Lighthouse AI can only do what the user asking could do themselves.
Read more in the [Lighthouse AI documentation](/getting-started/products/prowler-cloud-lighthouse).
### 🔌 Prowler MCP — Integrations, Users, and Roles
Prowler MCP gained three tool families, available on both the Cloud and the self-hosted Local MCP Server:
- **[Integrations](/getting-started/basic-usage/prowler-mcp-tools#integrations-management)** — manage where Prowler sends its results, with the full lifecycle for Amazon S3, AWS Security Hub, and Jira: create them, update credentials, configuration and attached providers, re-check connections, and delete them — plus turning findings into Jira work items directly from a conversation.
- **[Users](/getting-started/basic-usage/prowler-mcp-tools#user-management)** — read-only tools to list the tenant users with their emails and identify the authenticated user.
- **[Roles](/getting-started/basic-usage/prowler-mcp-tools#role-management)** — browse the RBAC roles defined in the tenant, inspect the capabilities each one grants, and set the role a user holds.
### ☁️ Prowler MCP — Cloud-Only Tools
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing). These tools are exposed only by the Cloud MCP Server at `https://mcp.prowler.com/mcp`; the self-hosted Local MCP Server **does not** include them.
</Note>
A new `prowler_cloud_*` namespace adds 32 tools so your AI assistant can run Prowler Cloud workflows end to end instead of only reading from them:
- **[Alerts](/getting-started/basic-usage/prowler-mcp-tools#alerts)** — create and manage alert rules and email recipients, and browse the fired-alert history. Rule conditions can be dry-run before saving, so you can see what a rule would match without persisting anything.
- **[Findings Triage](/getting-started/basic-usage/prowler-mcp-tools#findings-triage)** — set a finding's triage status and attach notes documenting the decision. Unlike muting, the finding stays visible.
- **[Scan Scheduling](/getting-started/basic-usage/prowler-mcp-tools#scan-scheduling)** — configure daily, interval, weekly, or monthly recurring scans, one provider at a time or applied across many at once.
- **[Scan Configurations](/getting-started/basic-usage/prowler-mcp-tools#scan-configurations)** — build reusable check and compliance selections and attach them to providers.
Read more in the [Prowler MCP tools reference](/getting-started/basic-usage/prowler-mcp-tools#prowler-cloud-tools).
### 🧭 Compliance — Grouped by provider of the same type
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
One framework, every provider, a single answer. Building on the cross-provider-type roll-up, the Compliance section now groups compliance for all providers of the same type: a **single-provider framework** — CIS AWS, CIS GCP, ENS for Azure — is aggregated across the latest completed scan of every provider of that type. Each framework card rolls up into a consolidated posture with a per-provider breakdown, a findings drill-down, and a combined executive PDF report. Requirement status follows the same strict precedence (FAIL over PASS over MANUAL), so one failing provider flags the requirement for the whole estate.
![Across providers compliance section](/images/compliance/prowler-app-across-providers-expanded.png)
The Compliance tabs were also renamed to say what they aggregate: "Per Scan" is now **Single Scan**, "Cross-Provider" is now **Multiple Scans**, and Compliance lands on Multiple Scans by default.
![Cross-provider compliance detail across providers](/images/compliance/prowler-app-across-providers-detail.png)
Read more in the [Cross-Provider Compliance documentation](/user-guide/compliance/tutorials/cross-provider-compliance).
### ☁️ GCP Organization Onboarding
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Onboarding an entire Google Cloud organization is now a single guided flow. Provide an organization-level credential and Prowler discovers the full hierarchy, every folder and project. Pick the folders and projects to onboard from a selection tree, set custom aliases, test the connection, and launch: each selected project is registered as a provider, with no need to add them one by one. Post-onboarding management is covered too, including credential replacement and organization-wide deletion.
Read more in the [GCP Organizations documentation](/user-guide/tutorials/prowler-cloud-gcp-organizations).
### 🕸️ Attack Paths — More Privilege Escalation Queries
Attack Paths adds four AWS privilege-escalation detection queries from [pathfinding.cloud](https://pathfinding.cloud). Thanks to @paramanandmallik!
- **[STS-002](https://hub.prowler.com/attack-paths/aws-sts-privesc-cross-account-trust)** — cross-account role trust
- **[STS-003](https://hub.prowler.com/attack-paths/aws-sts-privesc-wildcard-trust)** — wildcard role trust
- **[IAM-022](https://hub.prowler.com/attack-paths/aws-iam-privesc-delete-user-permissions-boundary)** — user permissions-boundary removal
- **[SSO-001](https://hub.prowler.com/attack-paths/aws-sso-privesc-permission-set-escalation)** — IAM Identity Center permission-set escalation
The query info panel now links every query to its page on [Prowler Hub](https://hub.prowler.com), and the IAM privilege-escalation queries were reworked to run efficiently on accounts with many IAM roles, users, or groups, fixing runtime errors and timeouts on large graphs.
Read more in the [Attack Paths documentation](/user-guide/tutorials/prowler-app-attack-paths).
### 🛡️ AWS Confidential Computing — Nitro Enclaves Checks
Prowler adds the first CSPM coverage for confidential computing workloads on AWS, with **11 new checks** for [Nitro Enclaves](https://aws.amazon.com/ec2/nitro/nitro-enclaves/), developed together with [Guillermo Ruiz](https://www.linkedin.com/in/gruizesteban/) from AWS.
- **Workload host environment (EC2)** — five `ec2_confidential_workload_host_*` checks for the parent instance: IMDSv2 not enforced, public IP exposure, unrestricted ingress, exposed vsock proxy ports, and hosts not running.
- **KMS attestation policy** — six `kms_key_enclave_*` checks for the key policies gating enclave secrets: attestation not enforced or bypassable, missing deployment binding, debug-mode attestations, PCR mismatches, and unknown enclave images.
All checks are fully passive, using AWS APIs and CloudTrail with no instance access or SSM agent required, and are mapped across 23 compliance frameworks, including NIST 800-53 Rev 5, PCI-DSS v4.0, ISO 27001:2022, SOC 2, HIPAA, and MITRE ATT&CK.
Read more about it this [blog post](https://prowler.com/blog/your-llm-runs-in-a-nitro-enclave-who-is-checking-the-enclave).
Try them out now at [cloud.prowler.com](https://cloud.prowler.com/sign-up)!
### 🏢 New Provider — Huawei Cloud
Prowler now scans [**Huawei Cloud**](https://www.huaweicloud.com/), with **25 checks** across ten services: CTS, ECS, ELB, EVS, IAM, KMS, OBS, RDS, VPC, and WAF, plus the CIS Huawei Cloud Foundations Benchmark 1.0 compliance framework. Thanks to @tomitobio for their 1st provider in Prowler!
To scan a Huawei Cloud account, export the IAM user's access key credentials and run Prowler CLI:
```bash
export HUAWEICLOUD_ACCESS_KEY_ID="your-access-key-id"
export HUAWEICLOUD_SECRET_ACCESS_KEY="your-secret-access-key"
prowler huaweicloud
```
Read more in the [Huawei Cloud documentation](/user-guide/providers/huaweicloud/getting-started-huaweicloud). Explore all Huawei Cloud checks at [Prowler Hub](https://hub.prowler.com/check?provider=huaweicloud).
### 🔍 Checks
#### AWS
- `codecommit_repository_no_secrets`, alongside the new `codecommit` service, scans files tracked at the tip of each repository's default branch for hardcoded secrets. Thanks to @Sid-0602!
- `glue_catalog_connection_no_secrets` detects secrets in Glue Data Catalog connection properties. Thanks to @l46983284-cpu, @Rishi943, and @UTKARSH698!
- `ec2_instance_stopped_older_than_specific_days` detects EC2 instances stopped longer than a configurable number of days (default 30). Thanks to @Nithin078!
- `sagemaker_endpoint_config_kms_encryption_enabled` verifies SageMaker endpoint configurations use a KMS key for storage volume encryption. Thanks to @Nithin078 and @l46983284-cpu!
Read more in the [AWS documentation](/user-guide/providers/aws/getting-started-aws). Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
### 📤 OCSF Output — MITRE ATT&CK Enrichment
OCSF detection finding output now populates `finding_info.analytic` with the Prowler check rule and `finding_info.attacks` with MITRE ATT&CK technique and tactic objects for findings with MITRE ATT&CK compliance metadata. Thanks to @AlexanderSanin!
### 🐞 Fixed
- AWS Security Hub integrations now persist successful recovery checks during finding delivery, keeping connection status and the last-checked time accurate.
- Social sign-up now creates authentication, tenant, and membership records in a single transaction, fully rolling back failed provisioning to prevent incomplete accounts.
- The SAML configuration form keeps the ACS URL field stable while generating the callback URL and exposes the copy action only after a valid URL is available.
- SAML users without a `userType` attribute and without an existing role now receive a least-privilege `read_only` fallback role, so role-dependent operations continue to work without granting management permissions.
### 🔐 Security
- Provider deletion, connection checks, scan creation, provider secrets, provider groups, and daily schedules now respect role provider-group visibility.
- HTML reports escape provider-originated finding fields, preventing stored cross-site scripting through malicious cloud resource tags. https://github.com/prowler-cloud/prowler/security/advisories/GHSA-c2jg-2778-ggm4
- Authentication with an API key whose owning user was deleted now returns `401`, and user deletion revokes the user's API keys across all their tenants.
### 🙌 External Contributors
Thank you to our community contributors for this release!
- @tomitobio: Huawei Cloud provider with CIS 1.0 benchmark ([#11950](https://github.com/prowler-cloud/prowler/pull/11950))
- @paramanandmallik: four AWS privilege-escalation Attack Paths queries ([#11460](https://github.com/prowler-cloud/prowler/pull/11460))
- @Sid-0602: AWS `codecommit` service and `codecommit_repository_no_secrets` check ([#11846](https://github.com/prowler-cloud/prowler/pull/11846))
- @l46983284-cpu, @Rishi943, and @UTKARSH698: AWS `glue_catalog_connection_no_secrets` check ([#11963](https://github.com/prowler-cloud/prowler/pull/11963))
- @Nithin078: AWS `ec2_instance_stopped_older_than_specific_days` ([#12076](https://github.com/prowler-cloud/prowler/pull/12076)) and `sagemaker_endpoint_config_kms_encryption_enabled` ([#12118](https://github.com/prowler-cloud/prowler/pull/12118), co-authored with @l46983284-cpu) checks
- @AlexanderSanin: MITRE ATT&CK enrichment in OCSF detection finding output ([#11492](https://github.com/prowler-cloud/prowler/pull/11492))
- @stefanobaldo: GCP gen2 Cloud Functions IAM policy retrieval is now thread-safe ([#12107](https://github.com/prowler-cloud/prowler/pull/12107))
- @rayair250-droid: GCP SSH and RDP firewall checks now detect exposed ports in any position within multi-port rules ([#12115](https://github.com/prowler-cloud/prowler/pull/12115))
- @jbchief-dev: secret ignore patterns now use Kingfisher-compatible LF line indexing ([#12141](https://github.com/prowler-cloud/prowler/pull/12141))
- @bmbferreira: Helm chart improvements — immutable chart versions on release ([#12056](https://github.com/prowler-cloud/prowler/pull/12056)) and capped Celery worker concurrency ([#12054](https://github.com/prowler-cloud/prowler/pull/12054))
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.37.0) for the complete list of changes.
</Update>
<Update label="v5.36.0" description="July 24, 2026">
### 🎫 Finding Groups - Jira
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Selected Findings, Finding Groups, and mixed selections can now be sent to Jira. When you select multiple findings, choose between one grouped issue or separate issues. Generated issues keep their Prowler context with deep links and filter details, while the UI provides clear dispatch and failure feedback.
![Send findings to Jira](/images/changelog/v5.36.0-finding-groups-jira.png)
Read more in the [Jira integration documentation](/user-guide/tutorials/prowler-app-jira-integration).
### 🕸️ Attack Paths - Queries
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Prowler Cloud now records which built-in Attack Paths queries returned data at the end of each scan. The query selector hides confirmed-empty queries for the selected scan, so you can focus on paths that exist without opening blank graph views. Errored, unknown, and parameterized queries remain available when they still require investigation or input.
All Attack Paths queries are now published on [Prowler Hub](https://hub.prowler.com), where you can browse the full catalog.
![Attack Paths query selector](/images/changelog/v5.36.0-attack-paths-queries.png)
Read more in the [Attack Paths documentation](/user-guide/tutorials/prowler-app-attack-paths).
### 🧑‍🏫 New Tutorials: Connect Your AI Agents to Prowler Cloud
<Note>
This feature needs a Prowler Cloud API key, so it is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
New tutorials walk you through connecting your own AI agents to Prowler Cloud, so they can query your security posture and act on it programmatically.
Read more in the [AI agents documentation](/user-guide/ai-agents/index).
### ☁️ Region-less Oracle Cloud Infrastructure Setup
Oracle Cloud Infrastructure (OCI) provider credentials no longer require a region. Existing clients can still send the legacy `region` field for compatibility, but the API ignores it before storing credentials or starting a scan. This removes an unnecessary step from OCI onboarding.
Read more in the [OCI documentation](/user-guide/providers/oci/getting-started-oci).
### 🔍 Checks
#### AWS
- `sagemaker_notebook_instance_no_secrets` scans the `OnCreate` and `OnStart` lifecycle scripts of SageMaker notebook instances for hardcoded API keys, passwords, tokens, connection strings, and other secrets. Thanks to @kiranrajsg!
Read more in the [AWS documentation](/user-guide/providers/aws/getting-started-aws). Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
### 🔐 Security
- Integration responses and operations now respect provider visibility, preventing hidden-provider disclosure and blocking unauthorized attachment, connection checks, Jira dispatches, edits, and deletion.
- Next.js was updated from 16.2.9 to 16.2.11, patching four high-severity and five medium-severity vulnerabilities.
- The unused `npm` CLI was removed from the UI container image, eliminating the bundled `node-tar` CVE-2026-59873 and reducing exposure to future bundled npm vulnerabilities.
- Vitest and its browser packages were updated from 4.1.8 to 4.1.10, resolving the critical `@vitest/browser` file-access permission bypass. These are development dependencies and have no runtime impact.
- Kubernetes kubeconfig validation now blocks legacy `auth-provider.config.cmd-path` command authentication, closing a command-execution bypass.
- `next-auth` was updated from 5.0.0-beta.30 to 5.0.0-beta.32, patching two critical Auth.js advisories: existence-based authorization checks that could fail open when a provider is misconfigured, and a homoglyph `@` bypass in email address normalization. The bump also pulls in the patched `@auth/core` 0.41.3 transitively.
### 🙌 External Contributors
Thank you to our community contributors for this release!
- @kiranrajsg: AWS `sagemaker_notebook_instance_no_secrets` check ([#11843](https://github.com/prowler-cloud/prowler/pull/11843))
- @owenchenxy: Alibaba Cloud SSH and RDP security group checks now handle capitalized `Policy="Accept"` values correctly ([#12049](https://github.com/prowler-cloud/prowler/pull/12049))
- @rsaladra: S3 bucket name validation no longer raises an invalid escape sequence `SyntaxWarning` at startup ([#12041](https://github.com/prowler-cloud/prowler/pull/12041))
- @SujayKulkarni-2211: Updated the AWS check count in the README ([#12011](https://github.com/prowler-cloud/prowler/pull/12011))
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.36.0) for the complete list of changes.
</Update>
<Update label="v5.35.0" description="July 17, 2026">
### 💬 Lighthouse AI - Side Chat
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Lighthouse AI now lives in a side panel you can open from anywhere in the app. Ask about the findings you are looking at without leaving the page, and expand to the full-page chat at any time: your draft, messages, and streaming response come along. Finding and resource details share the same panel, with tabs to switch between Details and Lighthouse AI.
![Lighthouse AI side chat](/images/changelog/v5.35.0-lighthouse-ai-side-chat.png)
Read more in the [Lighthouse AI documentation](/getting-started/products/prowler-cloud-lighthouse#side-panel).
### 🤖 Lighthouse AI - Take Action
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Lighthouse AI is no longer read-only. Ask it to do things and it will: connect or remove providers, trigger a scan, schedule daily scans, update scan settings, and manage your mutelist and mute rules, straight from the chat. Every action is gated by RBAC: Lighthouse can only do what the user asking could do themselves.
Read more in the [Lighthouse AI capabilities](/getting-started/products/prowler-cloud-lighthouse#capabilities).
### ☁️ One-step AWS Organizations onboarding
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Onboarding an entire AWS Organization is now a single step. One CloudFormation quick-create link deploys the management account role and a service-managed StackSet that rolls the role out to every member account, replacing the manual StackSet console setup. Target the whole organization or a specific Organizational Unit or Root ID, and deploy from the management account or a delegated administrator. The S3 integration quick-create link also pre-fills the bucket owner account ID, preventing a stack validation error.
![AWS Organizations onboarding wizard](/images/changelog/v5.35.0-aws-orgs-wizard.png)
Built on the full-organization CloudFormation template contributed by @jchrisfarris — thanks!
Read more in the [AWS Organizations documentation](/user-guide/tutorials/prowler-cloud-aws-organizations).
### 🎯 Scan configurations: exclude checks and services
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Scan configurations now accept `excluded_checks` and `excluded_services` to narrow the execution scope. Skip individual checks or entire services per provider, and the scan does not run them at all: less noise, faster scans, and no findings you would mute anyway.
Read more in the [Scan Configuration documentation](/user-guide/tutorials/prowler-app-scan-configuration#limiting-the-scan-scope).
### 🧭 Redesigned sidebar navigation
The sidebar was redesigned around how you actually work: grouped sections for security, settings, and help, a Home/Chat switch at the top, collapsible configuration entries, clearer active states, and a responsive mobile overlay.
![Redesigned sidebar](/images/changelog/v5.35.0-new-menu.png)
### 🔌 Prowler MCP tools renamed to `prowler_*`
Core Prowler tools in Prowler MCP moved from the `prowler_app_*` prefix to the shorter `prowler_*` namespace, and the MCP documentation was restructured around it. Legacy `prowler_app_*` names keep working in Lighthouse AI, so existing setups are not broken.
Read more in the [Prowler MCP tools reference](/getting-started/basic-usage/prowler-mcp-tools).
### 🔐 Security
- Jira integration credentials now only accept bare Atlassian site names (letters, numbers, and hyphens), and Jira tenant information requests validate site names and no longer follow redirects.
- Social account linking now requires a verified matching email from both the identity provider and the existing user account, and account connection notification emails are disabled.
- 13 advisories reported by `pnpm audit` on the UI (3 high, 9 moderate, 1 low) are resolved with patched versions of `hono`, `ws`, `vite`, `dompurify`, `js-yaml`, `@opentelemetry/core`, and `@babel/core`, including `hono` CVE-2026-59896.
### 🙌 External Contributors
No external contributors in this release.
Special mention to @jchrisfarris, whose full-organization CloudFormation template from v5.34.0 powers the new one-step AWS Organizations onboarding ([#10403](https://github.com/prowler-cloud/prowler/pull/10403)).
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.35.0) for the complete list of changes.
</Update>
<Update label="v5.34.0" description="July 15, 2026">
### 🏷️ New product names
The Prowler family has grown, and the names now say what each product is. Same products, clearer names:
**Prowler products:**
- **Prowler Cloud** — the managed cloud security platform operated by the Prowler team.
- **Prowler Private Cloud** (formerly *Prowler Enterprise*) — the self-hosted deployment of Prowler Cloud in your own environment.
- **Prowler Hub** — the free public library of versioned checks, cloud service artifacts, and compliance frameworks.
- **Prowler Lighthouse AI** — The Agentic Cloud Defender in Prowler Cloud and Prowler Private Cloud.
- **Prowler MCP** — the MCP server that connects AI assistants and agents to Prowler, including the IDE plugins.
**Open source projects:**
- **Prowler CLI** — the command-line scanner for all supported providers.
- **Prowler Local Server** (formerly *Prowler App*) — the self-hosted web application and API to run scans, visualize findings, and manage providers.
- **Prowler Local Dashboard** — the web dashboard for visualizing Prowler CLI scan results, distributed with the CLI.
- **Prowler SDK** — the Python library behind Prowler CLI and Prowler Local Server.
See the full family in the [Prowler products documentation](/getting-started/products).
### 🧭 Cross-Provider Compliance
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
One framework, every cloud, a single answer. The new **Cross-provider** tab in Compliance takes the most recent completed scan of every compatible provider and rolls them up into a single compliance posture per framework, with a per-provider breakdown and a combined executive PDF report. Requirement status follows strict precedence (FAIL over PASS over MANUAL), so one failing provider is enough to flag a requirement across your whole estate.
![Cross-provider compliance overview](/images/changelog/v5.34.0-cross-provider-compliance-overview.png)
Three universal frameworks support it today:
- **CIS Controls 8.1** — AWS, Azure, Google Cloud, Microsoft 365, Kubernetes, GitHub, Google Workspace, Okta, Oracle Cloud, Alibaba Cloud, Cloudflare, MongoDB Atlas, OpenStack, and Vercel.
- **CSA CCM 4.0** — AWS, Azure, Google Cloud, Alibaba Cloud, and Oracle Cloud.
- **DORA 2022/2554** — AWS, Azure, Google Cloud, Alibaba Cloud, and Cloudflare.
Filter by provider type, account, or provider group, drill into each framework's requirements, and export the combined PDF.
![Cross-provider compliance detail](/images/changelog/v5.34.0-cross-provider-compliance-detail.png)
Read more in the [Cross-Provider Compliance documentation](/user-guide/compliance/tutorials/cross-provider-compliance).
### 🏢 New Provider — E2E Networks
Prowler now scans [**E2E Networks**](https://www.e2enetworks.com/), with **27 checks** spanning compute nodes, networking, security groups, load balancers, block and file storage, and managed databases. Thanks to @deepak7093 for their 1st provider in Prowler!
Available in the Prowler CLI:
```bash
export E2E_NETWORKS_API_KEY="your-api-key"
export E2E_NETWORKS_AUTH_TOKEN="your-auth-token"
export E2E_NETWORKS_PROJECT_ID="your-project-id"
prowler e2enetworks
```
Read more in the [E2E Networks documentation](/user-guide/providers/e2enetworks/getting-started-e2enetworks). Explore all E2E Networks checks at [Prowler Hub](https://hub.prowler.com/check?provider=e2enetworks).
### 🔐 Security
User role relationship updates in the API are now limited to the active tenant, preserving the role assignments the same user holds in other tenants.
### 🔍 Checks
#### AWS
- `ec2_ami_account_block_public_access` — verifies AMI block public access is enabled at the account level in each Region, so AMIs cannot be shared publicly. Thanks to @goutham-hari!
- `datapipeline_pipeline_no_secrets_in_definition` — scans Data Pipeline object fields, parameter objects, and parameter values for hardcoded secrets with Kingfisher. Thanks to @YinkaMetrics!
- `elbv2_listener_pqc_tls_enabled` — verifies ELBv2 HTTPS/TLS listeners use post-quantum TLS security policies with TLS 1.2 or higher, helping reduce harvest-now-decrypt-later exposure.
- `amplify_app_no_secrets_in_environment` — scans Amplify app and branch environment variables and build settings (buildSpec) for hardcoded secrets with Kingfisher. Thanks to @Deep070203!
#### Azure
- `app_function_ensure_http_is_redirected_to_https` — verifies that Function Apps enforce HTTPS-only traffic. Thanks to @amandalal007!
#### Kubernetes
- `core_minimize_hostpath_volume_mounts` — detects Pods that use `hostPath` volumes. Thanks to @0xTaoZ!
- `core_readonly_root_filesystem_enabled` — verifies that every container in each Pod explicitly sets `readOnlyRootFilesystem: true` in its security context. Thanks to @Weedle02!
#### STACKIT
- `iaas_server_public_ip_attached` — flags IaaS servers that have a public IP address directly attached to a network interface. Thanks to @johannes-engler-mw!
Explore all checks at [Prowler Hub](https://hub.prowler.com/check).
### 🙌 External Contributors
Thank you to our community contributors for this release!
- @jchrisfarris — Deploy AWS Organizations with the CloudFormation template in one step ([#10403](https://github.com/prowler-cloud/prowler/pull/10403))
- @deepak7093 — New E2E Networks provider: 27 checks across compute nodes, networking, security groups, load balancers, block/file storage, and managed databases ([#11654](https://github.com/prowler-cloud/prowler/pull/11654))
- @goutham-hari — AWS `ec2_ami_account_block_public_access` check ([#11828](https://github.com/prowler-cloud/prowler/pull/11828))
- @YinkaMetrics — AWS `datapipeline_pipeline_no_secrets_in_definition` check ([#11821](https://github.com/prowler-cloud/prowler/pull/11821))
- @amandalal007 — Azure `app_function_ensure_http_is_redirected_to_https` check ([#11929](https://github.com/prowler-cloud/prowler/pull/11929))
- @0xTaoZ — Kubernetes `core_minimize_hostpath_volume_mounts` check ([#11837](https://github.com/prowler-cloud/prowler/pull/11837))
- @Weedle02 — Kubernetes `core_readonly_root_filesystem_enabled` check ([#11835](https://github.com/prowler-cloud/prowler/pull/11835))
- @johannes-engler-mw — STACKIT `iaas_server_public_ip_attached` check ([#11549](https://github.com/prowler-cloud/prowler/pull/11549))
- @janderik — Trailing newlines added to compliance, region, and fixture data files for POSIX compliance ([#11765](https://github.com/prowler-cloud/prowler/pull/11765))
- @Deep070203 — AWS `amplify_app_no_secrets_in_environment` check ([#11825](https://github.com/prowler-cloud/prowler/pull/11825))
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.34.0) for the complete list of changes.
</Update>
<Update label="v5.33.0" description="July 7, 2026">
### 🤖 Lighthouse AI — The Agentic Cloud Defender
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Lighthouse AI is now a full agentic assistant wired to the Prowler Cloud backend. Ask it about your findings, your compliance posture, or your riskiest resources, and watch it work: the agent discovers and runs the Prowler tools it needs to answer, with every tool call visible in the new agentic view. It reads your security data through read-only tools, so it can never touch secrets or modify your tenant.
![Lighthouse AI agentic view](/images/changelog/v5.33.0-lighthouse-ai-1.webp)
The chat experience is rebuilt around **persistent sessions**: conversations stream in real time, stay in your session history, can be archived, and a **sidebar chat mode** lets you ask questions from any page in the app without losing your place.
![Lighthouse AI sessions](/images/changelog/v5.33.0-lighthouse-ai-2.webp)
You control the brain behind it. Configure one or more LLM providers — **OpenAI**, **Amazon Bedrock**, or any **OpenAI-compatible** endpoint (OpenRouter, Ollama) — with connection testing built into the setup and per-provider model selection. Add a shared **business context** (your security goals, compliance needs, organizational priorities) and every session uses it to give answers that fit your environment.
![Lighthouse AI LLM providers](/images/changelog/v5.33.0-lighthouse-ai-3.webp)
Read more in the [Lighthouse AI documentation](/getting-started/products/prowler-cloud-lighthouse) and the [multiple LLM providers guide](/user-guide/tutorials/prowler-cloud-lighthouse-multi-llm).
### 📄 Compliance PDF Reports Without Credentials
Compliance PDF reports no longer require the provider's credentials to be present. Findings are now enriched from the provider metadata stored in the database, so a report still generates even after the provider secret has been deleted or its credentials have become invalid.
Read more in the [compliance documentation](/user-guide/compliance/tutorials/compliance).
### ⏳ Scan Queueing
Overlapping scans for the same provider now queue behind the active one instead of dispatching concurrent scan workers. Launch a manual scan while a scheduled one is running and it waits its turn. No more duplicated work or racing scans.
### 🔐 Security
The Kubernetes provider credentials now reject kubeconfigs using `exec` authentication in Prowler Cloud, at the API and in the credential form, preventing user-supplied commands from running on Cloud workers.
Read more in the [Kubernetes provider authentication documentation](/user-guide/providers/kubernetes/getting-started-k8s#step-2-configure-kubernetes-authentication).
### 🙌 External Contributors
Thank you to our community contributors for this release!
- @kratos0718 — Azure `postgresql_flexible_server_log_retention_days_greater_3` Flexible Server log retention fix ([#11761](https://github.com/prowler-cloud/prowler/pull/11761))
- @Sanjays2402 — `KeyError: 'MANUAL'` crash fix in the compliance summary table, shipped early in v5.32.1 ([#11823](https://github.com/prowler-cloud/prowler/pull/11823))
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.33.0) for the complete list of changes.
</Update>
<Update label="v5.32.0" description="July 2, 2026">
### 🔎 Findings Triage
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Triage findings straight from the Findings view. Each finding gets a triage status you can move through its lifecycle:
**Open → Under Review → Remediating → Risk Accepted → False Positive → Resolved**
Add a triage note to record the decision, mute a finding, all from the row's actions menu. The current status shows inline on every finding row, so you keep track of what has been reviewed and stop re-checking the same issues scan after scan.
![Findings triage statuses](/images/changelog/v5.32.0-triage-1.png)
The status also follows the finding automatically across scans: when a finding flips from `FAIL` to `PASS` on the next scan it moves to **Resolved**, and when it flips from `PASS` back to `FAIL` it moves to **Reopened**. You always know whether an issue is genuinely fixed or has regressed, without touching it by hand.
![Findings triage lifecycle](/images/changelog/v5.32.0-triage-2.png)
Read more in the [Findings Triage documentation](/user-guide/tutorials/prowler-app-findings-triage).
### ⚙️ Scan Configuration
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Create named, reusable scan configurations from a dedicated **Scans / Configuration** page. Each configuration is YAML that follows the structure of [`prowler/config/config.yaml`](https://github.com/prowler-cloud/prowler/blob/master/prowler/config/config.yaml), so you only include the keys you want to override; the rest fall back to the built-in defaults. Values are validated on save against a per-provider, type-safe configuration schema that range-checks each field and rejects unknown keys, so a malformed config is caught before it ever reaches a scan. Attach a configuration to one or more providers so it applies on their next scan, or save it now and attach providers later.
![Scan configuration editor](/images/changelog/v5.32.0-config-1.png)
From the Providers view you can pick which configuration a provider uses (`Default` or any of your saved ones) without leaving the page. No more passing config files around by hand.
![Scan configuration per provider](/images/changelog/v5.32.0-config-2.png)
Read more in the [Scan Configuration documentation](/user-guide/tutorials/prowler-app-scan-configuration).
### ✅ Per-Requirement Configuration Validation
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
Compliance frameworks can now declare `ConfigRequirements` on a requirement, so it's reported as **FAIL** when its mapped checks ran under a configuration too loose to satisfy it. Even if every individual finding PASSed. This applies across all compliance outputs: CSV, OCSF, and console tables, and is the engine behind Scan Configuration's "marked as FAIL" behavior described above.
![Per-requirement configuration validation](/images/changelog/v5.32.0-per-requirement-validation.png)
Read more in the [Configuration File documentation](/user-guide/cli/tutorials/configuration_file).
### ⏱️ Okta — Request Throttling & Retries
Prowler now proactively throttles Okta API requests to stay under rate limits, with reactive retries on HTTP 429 as a safety net. Both are set in the scan configuration (or their equivalent CLI flags):
- `okta_requests_per_second` (config file) / `--okta-requests-per-second` (CLI) — cap the request rate. Default: 4 req/s.
- `okta_max_retries` (config file) / `--okta-retries-max-attempts` (CLI) — bound retry attempts. Default: 5.
This makes large Okta scans more reliable and less likely to be rate-limited.
Read more in the [Okta rate limit documentation](/user-guide/providers/okta/retry-configuration#request-throttling-requests-per-second).
### 📉 AWS — Cap Resources Scanned per Service
Large AWS accounts can now cap how many resources Prowler analyzes for the highest-volume services, keeping scan time and cost under control. Set a global limit with `max_scanned_resources_per_service`, or override it per service:
- EBS snapshots (`max_ebs_snapshots`)
- Backup recovery points (`max_backup_recovery_points`)
- CloudWatch log groups (`max_cloudwatch_log_groups`)
- Lambda functions (`max_lambda_functions`)
- ECS task definitions (`max_ecs_task_definitions`)
- CodeArtifact packages (`max_codeartifact_packages`)
Limits are **disabled by default** (`0` = unlimited); only positive values cap the analyzed resources.
<Warning>
When a positive limit is set, compliance results reflect only the sampled resources, not every matching resource in the account.
</Warning>
Read more in the [configuration file documentation](/user-guide/cli/tutorials/configuration_file#supported-aws-resource-limits).
### 🏷️ Azure — Filter by Resource Group
Azure scans can now be scoped to one or more resource groups with the new `--azure-resource-group` / `--azure-resource-groups` option. This lets you run focused assessments against specific environments, teams, or workloads instead of scanning every accessible resource in the subscription. Thanks to @Legin-ML for contributing this feature!
```bash
# Single resource group
prowler azure --az-cli-auth --azure-resource-group rg-prod
# Multiple resource groups
prowler azure --az-cli-auth --azure-resource-group rg-prod1 rg-prod2
```
Read more in the [Azure Resource Groups documentation](/user-guide/providers/azure/resource-groups).
### 🧭 Provider Group Filter
Filter the **Overview, Findings, Resources, Scans, and Providers** views by provider group. Scope the whole app to a team, an environment, or a business unit in one click instead of filtering provider by provider.
![Provider group filter](/images/changelog/v5.32.0-provider-group-filter.png)
Read more about managing provider groups in the [RBAC documentation](/user-guide/tutorials/prowler-app-rbac).
### 🔬 API — Timestamp Precision in Findings Filters
The `/api/v1/findings` endpoint now accepts full timestamps on the `inserted_at` and `updated_at` filters (`filter[inserted_at__gte]`, `filter[inserted_at__lte]`, and the `updated_at` variants), so you can query narrow time windows instead of whole days. Date-only filtering keeps working, so existing integrations are unaffected.
```bash
# Findings inserted within a precise timestamp window
curl --globoff \
'http://localhost:8080/api/v1/findings?filter[inserted_at__gte]=2026-07-01T06:12:18Z&filter[inserted_at__lte]=2026-07-02T19:25:55Z' \
-H 'Authorization: Bearer <YOUR_TOKEN>' \
-H 'Accept: application/vnd.api+json'
```
### 🕸️ Attack Paths — Neptune as a persistent sink
Attack Paths can now persist its graph in **AWS Neptune** in addition to Neo4j, selectable via `ATTACK_PATHS_SINK_DATABASE=neptune` (default `neo4j`). Cartography's per-scan ingest database stays on Neo4j. The scan task preflights the ingest database and the configured sink before ingestion, and provider graph cleanup now deletes relationships in directed batches before deleting nodes.
This is the groundwork for scale: a managed graph database lets Attack Paths hold much larger graphs, extend coverage to more providers, and link resources across them so an attack path can cross provider boundaries instead of stopping at one cloud's edge.
Read more in the [Attack Paths documentation](/user-guide/tutorials/prowler-app-attack-paths).
### 🔐 New Secret-Scanning Engine — Kingfisher
Prowler's secret-scanning checks now run on [Kingfisher](https://github.com/mongodb/kingfisher) instead of `detect-secrets`. Scans run **fully offline by default**, and obvious placeholder values (e.g. `password123`, `changeme`) are no longer reported, cutting down false positives.
Opt in to **live validation** with the new `--scan-secrets-validate` flag (or the `aws.secrets_validate` config option): Prowler checks discovered secrets against the provider APIs, and any secret confirmed to be **live is reported as critical**, so you can prioritize the credentials that actually work.
<Note>
The `detect_secrets_plugins` configuration option has been removed, as it is no longer used by the new engine.
</Note>
Read more in the [secret detection documentation](/user-guide/cli/tutorials/pentesting#detect-secrets).
### 🔍 Checks
#### AWS
- `stepfunctions_statemachine_encrypted_with_cmk` — Step Functions state machines use a customer-managed KMS key for encryption at rest instead of the default AWS-owned key. Thanks to @Sid-0602!
- `waf_regional_webacl_logging_enabled` — AWS WAF Classic Regional Web ACLs have logging enabled to a Kinesis Data Firehose stream. Thanks to @Sid-0602!
- **IAM privilege escalation** — the privesc checks now cover **AWS Bedrock AgentCore** paths across Runtime, Harness, Code Interpreter, and Custom Browser. Thanks to @MrCloudSec!
- `apigateway_restapi_no_secrets_in_stage_variables` — scans API Gateway REST API stage variables for hardcoded passwords, API keys, and tokens. Thanks to @chirag1206!
- `awslambda_function_no_secrets_in_code` — this check now supports a `secrets_ignore_files` audit-config option to skip files inside the deployment package by glob pattern (e.g. `*.deps.json`), suppressing .NET dependency-manifest false positives without masking real secrets.
- `s3_bucket_object_public` — spot-checks a configurable sample of object ACLs in each bucket and flags objects granted to the `AllUsers` or `AuthenticatedUsers` groups. Disabled by default; opt in via the `s3_bucket_object_public_enabled` configuration option. Thanks to @Synchx00!
#### Microsoft 365
New **Conditional Access** hardening checks:
- `entra_conditional_access_policy_explicitly_targets_azure_devops` — at least one enabled policy explicitly includes the Azure DevOps cloud application, rather than relying on a broad "All cloud apps" policy. Thanks to @mzl2233!
- `entra_conditional_access_policy_no_exclusion_gaps` — every user, group, role, or application excluded from an enabled policy stays in scope of another enabled policy. Thanks to @UTKARSH698 with @arieleli01212 as co-author!
- `entra_conditional_access_policy_groups_management_restricted` — every security group referenced by an enabled or report-only policy is management-restricted or role-assignable. Thanks to @SAMurai-16!
- `exchange_application_access_policy_restricts_mailbox_apps` — every service principal with Microsoft Graph application-level Exchange mailbox permissions is restricted by an Exchange Online Application Access Policy. Thanks to @VasistAcharya!
### 📚 Compliance
#### CIS Benchmark Refresh — Six New Versions
Prowler ships a coordinated refresh of the CIS Benchmarks across six providers:
- **AWS** — CIS Amazon Web Services Foundations Benchmark v7.0.0, adding the new Organizations section (2.1.1-2.1.6), resource policy (2.21), web front-end access logging (4.10), and VPC Endpoints (6.8) recommendations.
- **Azure** — CIS Microsoft Azure Foundations Benchmark v6.0.0.
- **GCP** — CIS Google Cloud Platform Foundation Benchmark v5.0.0.
- **Kubernetes** — CIS Kubernetes Benchmark v2.0.1.
- **GitHub** — CIS GitHub Benchmark v1.2.0.
- **Microsoft 365** — CIS Microsoft 365 Foundations Benchmark v7.0.0.
#### CIS Controls v8.1 — Universal Framework
A new **universal** (cross-provider) compliance framework mapping existing checks across 18 providers — AWS, Azure, GCP, Kubernetes, M365, GitHub, AlibabaCloud, OracleCloud, GoogleWorkspace, Okta, Cloudflare, Vercel, MongoDB Atlas, OpenStack, Linode, StackIT, NHN, and Scaleway — to the 18 CIS Critical Security Controls and their Safeguards. Ships with a dedicated detail view and report mapping in the UI.
Read more in the [compliance documentation](/user-guide/compliance/tutorials/compliance). Explore the full compliance catalog at [Prowler Hub](https://hub.prowler.com/compliance).
### 🙌 External Contributors
Thank you to our community contributors for this release!
- @chirag1206 — `apigateway_restapi_no_secrets_in_stage_variables` check ([#11188](https://github.com/prowler-cloud/prowler/pull/11188))
- @MrCloudSec — AWS Bedrock AgentCore privilege escalation paths in the IAM privesc checks ([#11726](https://github.com/prowler-cloud/prowler/pull/11726))
- @Sid-0602 — `stepfunctions_statemachine_encrypted_with_cmk` ([#11538](https://github.com/prowler-cloud/prowler/pull/11538)) and `waf_regional_webacl_logging_enabled` ([#11539](https://github.com/prowler-cloud/prowler/pull/11539)) checks
- @mzl2233 — `entra_conditional_access_policy_explicitly_targets_azure_devops` check ([#11182](https://github.com/prowler-cloud/prowler/pull/11182))
- @UTKARSH698 with @arieleli01212 as co-author — `entra_conditional_access_policy_no_exclusion_gaps` check ([#11577](https://github.com/prowler-cloud/prowler/pull/11577))
- @SAMurai-16 — `entra_conditional_access_policy_groups_management_restricted` check ([#11342](https://github.com/prowler-cloud/prowler/pull/11342))
- @vahidg — Azure PostgreSQL flexible server collection resilience fix ([#11595](https://github.com/prowler-cloud/prowler/pull/11595))
- @davletd — Azure `keyvault_logging_enabled` `AuditEvent` category fix ([#11660](https://github.com/prowler-cloud/prowler/pull/11660))
- @VasistAcharya — `exchange_application_access_policy_restricts_mailbox_apps` ([#11247](https://github.com/prowler-cloud/prowler/pull/11247))
- @Legin-ML — Filter scans at Resource Group level ([#10657](https://github.com/prowler-cloud/prowler/pull/10657))
- @Synchx00 — `s3_bucket_object_public` check ([#9517](https://github.com/prowler-cloud/prowler/pull/9517))
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.32.0) for the complete list of changes.
</Update>
<Update label="v5.31.0" description="June 23, 2026">
### 🗓️ Flexible Scan Scheduling
<Note>
Available exclusively in **Prowler Cloud**. Prowler Local Server supports daily scans only.
</Note>
![Scan scheduling from the Providers page](/images/changelog/v5.31.0-schedule-1.png)
You can now set a per-provider scan schedule from the Providers page. Pick a **scan time** and a **repeat cadence**: Daily, Every 48 hours, Weekly (with a day-of-week selector), or Monthly. Schedules can be edited or removed at any time, and a new scan never interrupts access to existing data.
![Schedule editor](/images/changelog/v5.31.0-schedule-2.png)
All schedules are listed in one place under the **Scheduled** tab in **Scan Jobs**, showing each provider's cadence, next scan, and last scan at a glance.
![Scheduled tab in Scan Jobs](/images/changelog/v5.31.0-schedule-3.png)
Read more in the [scan scheduling documentation](/user-guide/tutorials/prowler-scan-scheduling).
### 📚 DORA — Expanded Provider Coverage
Prowler extends [**DORA**](https://www.eiopa.europa.eu/digital-operational-resilience-act-dora_en) (Digital Operational Resilience Act, Regulation (EU) 2022/2554) coverage to **Azure**, **GCP**, **Cloudflare**, and **Alibaba Cloud**, mapping each provider's existing checks across the five DORA pillars.
![DORA compliance for Alibaba Cloud](/images/changelog/v5.31.0-dora-alibaba.png)
<Note>
The framework follows the `<name>_<version>` naming convention as `DORA_2022_2554`.
</Note>
Read more in the [compliance documentation](/user-guide/compliance/tutorials/compliance).
### 🚀 Guided Onboarding
<Note>
Available exclusively in **Prowler Cloud**.
</Note>
New accounts now get a guided first-run experience. The Overview greets you with an **"Add your first provider"** prompt: connect a provider so Prowler has something to scan and assess, then get started in one click (or skip for now).
![Guided onboarding prompt](/images/changelog/v5.31.0-onboarding-1.png)
From there, contextual empty states across the product point you to the next action rather than leaving you stuck. Attack Paths, for example, explains that you need a completed scan before it can build a graph and links straight to **Scan Jobs**, with a **"See how it works"** affordance for first-timers.
![Contextual empty states](/images/changelog/v5.31.0-onboarding-2.png)
### 🔐 Optional SAML SSO `userType`
The SAML `userType` attribute is now optional. If your IdP does not send it, or sends it blank, Prowler keeps the user's existing roles unchanged instead of replacing them with a fallback role.
When `userType` is provided, Prowler still maps the user to the matching role. If that role does not exist yet, Prowler creates it with read-only access: visibility over all providers, with no management permissions.
Read more in the [SAML SSO documentation](/user-guide/tutorials/prowler-app-sso).
### 🏢 New Provider — Linode
Prowler now scans [**Linode**](https://www.linode.com/) (Akamai Cloud), covering its administration, compute, and networking services. Thanks to @varunmamillapalli for their 1st provider in Prowler!
<Note>
Linode is not officially supported. For more information, [contact us](https://prowler.com/contact).
</Note>
Read more in the [Linode documentation](/user-guide/providers/linode/getting-started-linode). Explore all Linode checks at [Prowler Hub](https://hub.prowler.com/check?provider=linode).
### 🔍 Checks
#### AWS
**Post-Quantum Cryptography readiness** — get ahead of the migration to quantum-resistant cryptography:
- `cloudfront_distributions_pqc_tls_enabled` — CloudFront distributions enforce a post-quantum TLS 1.3 security policy.
- `apigateway_domain_name_pqc_tls_enabled` — API Gateway custom domain names use a post-quantum TLS security policy.
- `transfer_server_pqc_ssh_kex_enabled` — Transfer Family servers use a post-quantum hybrid SSH key exchange.
- `acmpca_certificate_authority_pqc_key_algorithm` — Private CA authorities use a post-quantum (ML-DSA) key algorithm (new `acmpca` service).
- `rolesanywhere_trust_anchor_pqc_pki` — IAM Roles Anywhere trust anchors are backed by a post-quantum (ML-DSA) PKI (new `rolesanywhere` service).
**Organization-wide governance:**
- `securityhub_delegated_admin_enabled_all_regions` — Security Hub has a delegated administrator, active in all opted-in regions, with organization auto-enable on. Thanks to @ernestprovo23!
- `config_delegated_admin_and_org_aggregator_all_regions` — AWS Config has a delegated administrator and an organization aggregator covering all regions. Thanks to @ernestprovo23!
**Machine learning:**
- `sagemaker_clarify_exists` — verifies at least one SageMaker Clarify processing job exists per scanned region, so bias-detection and model-explainability controls are in place. Thanks to @AlexanderSanin!
#### Azure
A large batch of new Azure checks spanning data, compute, identity, and networking:
- **Cosmos DB** — automatic failover, continuous backup policy, minimum TLS 1.2, and public network access disabled.
- **MySQL & PostgreSQL Flexible Servers** — geo-redundant backup and high availability.
- **AKS** — auto-upgrade, Azure Monitor (Container Insights), local accounts disabled, and Microsoft Defender enabled.
- **Databricks** — public network access disabled and secure cluster connectivity (no public IP).
- **Defender** — CSPM on the Standard tier.
- **Networking** — NSG association on subnets and DDoS Network Protection on VNets.
- **Entra ID** — app registration credential expiry, users with recent sign-in and strong authentication enforcement.
- **Recovery Services** — vaults with at least one protected backup item and vaults with adequate backup policy.
Thanks to @s1ns3nz0 for all these contributions!
#### GCP
New coverage for high availability and public-exposure detection:
- `cloudsql_instance_high_availability_enabled` — Cloud SQL primary instances use `REGIONAL` availability for automatic zone failover.
- `cloudfunction_function_inside_vpc` — Cloud Functions use a Serverless VPC Access connector for private egress.
- `cloudfunction_function_not_publicly_accessible` — detects `allUsers` / `allAuthenticatedUsers` IAM invocation bindings.
- `secretmanager_secret_not_publicly_accessible` — detects Secret Manager secrets with public IAM bindings.
- `secretmanager_secret_rotation_enabled` — verifies Secret Manager secrets have automatic rotation configured with a period of 90 days or less and no missed rotation.
Thanks to @s1ns3nz0 for all these contributions!
#### Kubernetes
New core checks for container resource governance and reliability: CPU limits, CPU requests, memory limits, memory requests, fixed image tags, liveness probes, and readiness probes. Thanks to @Nikhilkumar2311 for all these contributions!
#### Microsoft 365
- `entra_directory_sync_object_takeover_blocked` — hybrid Entra tenants block cloud object takeover through soft-match and hard-match directory synchronization. Thanks to @PrettyFox0 and @omobolajiadeyan!
- `entra_conditional_access_policy_no_deleted_object_references` — flags Conditional Access policies that reference user, group, or role objects that no longer resolve in the directory. Thanks to @ernestprovo23!
#### Oracle Cloud Infrastructure
- `identity_storage_service_level_admins_scoped` — CIS 3.1 control 1.15, ensuring storage service-level administrators exclude delete permissions.
Explore all checks at [Prowler Hub](https://hub.prowler.com/check).
### 🐍 Python 3.13 Support
The Prowler SDK now supports **Python 3.13**. Thanks to @branchv!
### 🔐 Security Updates
- **SDK** — `pytest` 8.3.5 → 9.0.3, `black` 25.1.0 → 26.3.1, `microsoft-kiota-*` → 1.9.9, and `aiohttp` → 3.14.0, patching known CVEs.
- **API** — `aiohttp` → 3.14.0 and `idna` → 3.15, patching known CVEs.
- **UI** — bumped vulnerable `Next.js`, React, AI SDK, `postcss`, `hono`, `qs`, `esbuild`, and Alpine OpenSSL packages; `dompurify` 3.4.2 → 3.4.10, patching XSS sanitization bypass advisories.
- **Containers** — base image bumped to `python:3.12.13-slim-bookworm` (patches `libgnutls30` CVE-2026-33845 and CVE-2026-42010) and `trivy` to 0.71.0 (patches embedded `golang.org/x/crypto` and Go stdlib CVEs).
### 🙌 External Contributors
Thank you to our community contributors for this release!
- @varunmamillapalli — New Linode provider: administration, compute, and networking services ([#11633](https://github.com/prowler-cloud/prowler/pull/11633))
- @s1ns3nz0 — 20+ Azure & GCP checks across Cosmos DB, AKS, Databricks, Flexible Servers, Entra, networking, and GCP public-exposure
- @Nikhilkumar2311 — Kubernetes resource limits, requests, image tag, and probe checks ([#11373](https://github.com/prowler-cloud/prowler/pull/11373))
- @ernestprovo23 — AWS Security Hub/Config org-wide delegated admin checks ([#11259](https://github.com/prowler-cloud/prowler/pull/11259)) and M365 conditional access check ([#11236](https://github.com/prowler-cloud/prowler/pull/11236))
- @AlexanderSanin — `sagemaker_clarify_exists` check ([#11211](https://github.com/prowler-cloud/prowler/pull/11211))
- @PrettyFox0 with @omobolajiadeyan as co-author — M365 directory sync object takeover check ([#11098](https://github.com/prowler-cloud/prowler/pull/11098))
- @branchv — Python 3.13 support ([#9293](https://github.com/prowler-cloud/prowler/pull/9293))
- @alinealfa — GCP audit-filtered aggregated sinks fix ([#11575](https://github.com/prowler-cloud/prowler/pull/11575))
- @b-abderrahmane — Configurable Celery worker concurrency ([#11075](https://github.com/prowler-cloud/prowler/pull/11075))
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.31.0) for the complete list of changes.
</Update>
<Update label="Earlier releases">
Release notes for v5.30.0 and earlier, along with every patch release, are on [GitHub Releases](https://github.com/prowler-cloud/prowler/releases).
</Update>
@@ -0,0 +1,467 @@
---
title: "Attack Paths Queries"
---
This guide explains how to write and maintain Prowler Attack Paths queries: the read-only openCypher queries that traverse the Cartography-ingested cloud graph to detect privilege escalation chains, network exposure, and other graph-shaped security risks.
<Info>
**New to Attack Paths?** Start with the user documentation:
- [Attack Paths](/user-guide/tutorials/prowler-app-attack-paths) - What Attack Paths detects, how to run built-in queries, and how to explore the resulting graph.
- [Writing Custom openCypher Queries](/user-guide/tutorials/prowler-app-attack-paths#writing-custom-opencypher-queries) - Run ad-hoc read-only queries from the Prowler App.
</Info>
## Introduction
Attack Paths queries run against a property graph populated by [Cartography](https://github.com/cartography-cncf/cartography), an open-source graph ingestion framework, and enriched with Prowler findings. Every query is read-only openCypher (Version 9) so it runs on both the Neo4j and Amazon Neptune sinks.
Two categories of query exist, each with a different isolation model:
| | Predefined queries | Custom queries |
| ------------------ | ----------------------------------------------------------- | --------------------------------------------------------------------- |
| Where they live | `api/src/backend/api/attack_paths/queries/{provider}.py` | User-supplied through the custom query API endpoint |
| Provider isolation | `AWSAccount {id: $provider_uid}` anchor plus path connectivity | Automatic `_Provider_{uuid}` label injection by `cypher_sanitizer.py` |
| What to write | Chain every `MATCH` from the `aws` variable | Plain Cypher, no isolation boilerplate |
| Internal labels | Never use | Never use (system-injected) |
For **predefined queries**, every node must be reachable from the `AWSAccount` root through graph traversal. That reachability is the isolation boundary.
For **custom queries**, the runner injects a `_Provider_{uuid}` label into every node pattern, and a post-query filter handles edge cases, so query authors write natural Cypher without isolation boilerplate.
The rest of this guide focuses on predefined queries, though the graph model, list-property handling, and compatibility rules apply to both.
## The Graph Model
### Cartography Schema
Node labels, relationship types, and properties follow the upstream Cartography schema for each provider. Do not guess them, fetch the schema for the pinned Cartography version:
```bash
grep cartography api/pyproject.toml
```
Then read the schema for that exact tag:
```text
# Git pin (prowler-cloud/cartography@<TAG>):
https://raw.githubusercontent.com/prowler-cloud/cartography/refs/tags/<TAG>/docs/root/modules/{provider}/schema.md
# PyPI pin (cartography==<TAG>):
https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/<TAG>/docs/root/modules/{provider}/schema.md
```
The public schema reference for AWS is available at [Cartography AWS Schema](https://cartography-cncf.github.io/cartography/modules/aws/schema.html).
### Prowler-Specific Additions
The Prowler sync task enriches the Cartography graph with the following labels and relationships. These are not part of the upstream schema:
| Label / Relationship | Description |
| ---------------------- | ----------------------------------------------------------- |
| `ProwlerFinding` | Finding node (`status`, `severity`, `check_id`) |
| `Internet` | Internet sentinel node used to model public exposure |
| `CAN_ACCESS` | `(Internet)-[:CAN_ACCESS]->(resource)` exposure edge |
| `HAS_FINDING` | `(resource)-[:HAS_FINDING]->(:ProwlerFinding)` finding link |
| `TRUSTS_AWS_PRINCIPAL` | Role trust relationship |
| `STS_ASSUMEROLE_ALLOW` | Principal can assume a role |
### Internal Isolation Labels
The sync layer also adds internal labels used only for tenant and provider isolation: `_ProviderResource`, `_AWSResource`, `_Tenant_*`, and `_Provider_*`. These must never appear in query text, predefined or custom. The runner applies isolation automatically.
## Query Structure
### Provider Scoping Parameter
| Parameter | Property | Used on | Purpose |
| --------------- | -------- | ------------ | -------------------------------------- |
| `$provider_uid` | `id` | `AWSAccount` | Scopes the query to a specific account |
The runner binds `$provider_uid` automatically. Every other node is isolated by path connectivity from the `AWSAccount` anchor.
### Imports
```python
from api.attack_paths.queries.types import (
AttackPathsQueryAttribution,
AttackPathsQueryDefinition,
AttackPathsQueryParameterDefinition,
)
from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL
```
Always reference `PROWLER_FINDING_LABEL` through f-string interpolation, never hardcode `"ProwlerFinding"`.
### Definition Fields
- **id**: kebab-case `{provider}-{category}-{description}`, e.g. `aws-ec2-privesc-passrole-iam`.
- **name**: short, human-friendly label. Sourced queries append the reference ID: `"EC2 Instance Launch with Privileged Role (EC2-001)"`.
- **short_description**: one sentence, no technical permissions.
- **description**: full technical explanation, plain text.
- **provider**: `aws`, `azure`, `gcp`, `kubernetes`, or `github`.
- **cypher**: f-string Cypher body. Literal `{` and `}` are escaped as `{{` and `}}`.
- **parameters**: `parameters=[]` when the query takes no input.
- **attribution**: optional `AttackPathsQueryAttribution(text, link)` for sourced queries. The `link` uses the lowercase ID.
Append the constant to the `{PROVIDER}_QUERIES` list at the bottom of the provider file.
## The Predefined Query Template
The canonical shape combines a principal walk, an optional target walk, deduplicated nodes, and a typed finding overlay:
```python
AWS_QUERY_NAME = AttackPathsQueryDefinition(
id="aws-kebab-case-name",
name="Label (REFERENCE_ID)",
short_description="One sentence.",
description="Full technical explanation.",
attribution=AttackPathsQueryAttribution(
text="pathfinding.cloud - REFERENCE_ID - permission",
link="https://pathfinding.cloud/paths/reference_id_lowercase",
),
provider="aws",
cypher=f"""
// Find principals with the source permission
MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
WHERE toLower(act.value) IN ['permission_lowercase', 'service:*']
OR act.value = '*'
WITH DISTINCT aws, principal, stmt, path_principal
// Pre-aggregate the statement's resource values (see "Avoiding Cartesian Products")
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
// Match each target once against the in-memory resource list
MATCH path_target = (aws)--(target_role:AWSRole)
WITH path_principal, path_target, res_values, res_wildcard,
target_role.name AS rname, target_role.arn AS rarn
WHERE res_wildcard
OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0
WITH DISTINCT path_principal, path_target
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
)
```
Key points:
- The principal walk types the `POLICY` and `STATEMENT` hops. Both are low-fan-out (each principal has a handful of policies; each policy a handful of statements), so the typed edge lets the planner cost a cheap inline filter.
- The `(aws)--` hub hops stay anonymous. `AWSAccount` is a high-degree node that fans out to every principal, role, policy, and resource in the account; typing those edges forces the planner to enumerate from the hub and collapses performance on multi-tenant Neptune.
- Other relationship types appear only where the file's existing queries already use one (`TRUSTS_AWS_PRINCIPAL`, `STS_ASSUMEROLE_ALLOW`, `MEMBER_AWS_GROUP`, `HAS_EXECUTION_ROLE`).
- The finding probe is typed `:HAS_FINDING` and left undirected. The type lets Neptune apply an inline edge filter; the missing direction matches the convention of the rest of the file.
- Collapse duplicate rows after each permission gate with `WITH DISTINCT`, carrying only the variables needed by later clauses.
- The `RETURN` shape `paths, dpf, dpfr` is the contract the serializer and visualizer depend on. Do not change it.
## Avoiding Cartesian Products
The most common performance defect in Attack Paths queries is a Cartesian product between a target set and a policy statement's resource items. When the two are written as independent `MATCH` clauses, the planner pairs every target with every resource item before any filter runs. On accounts with many IAM principals, that multiplies into hundreds of thousands of rows and the query errors or times out.
### The Pattern That Causes It
```cypher
// One row per (target_role x resource_item): a Cartesian product
MATCH path_target = (aws)--(target_role:AWSRole)
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
WHERE res.value = '*'
OR res.value CONTAINS target_role.name
OR target_role.arn CONTAINS res.value
```
The two `MATCH` clauses share no relationship, so the engine enumerates all targets multiplied by all resource items, applies a non-indexable `CONTAINS` to each pair, then expands the finding overlay for every surviving row. Cost grows with `targets × resources`, and a second constrained statement (`stmt2`) multiplies it again.
### The Pattern That Avoids It
Collect the statement's resource values into a list once, then match each target a single time against that in-memory list:
```cypher
// Pre-aggregate the statement's resource values into a list
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
// Match each target once; bind name/arn to locals so the predicate reads them once
MATCH path_target = (aws)--(target_role:AWSRole)
WITH path_principal, path_target, res_values, res_wildcard,
target_role.name AS rname, target_role.arn AS rarn
WHERE res_wildcard
OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0
```
Cost now grows with `targets + resources` (linear) rather than `targets × resources`. The rewrite is a pure algebraic identity: the result set is unchanged.
Guidelines:
- **Aggregate resources before matching targets, not after.** `collect(DISTINCT res.value)` reduces the resource items to a single list per statement.
- **Short-circuit the wildcard grant** with `('*' IN res_values)`. When a statement grants `*`, every target matches, so the list scan is skipped entirely.
- **Bind `target.name` and `target.arn` to local variables** in a `WITH` before the predicate. The list comprehension then reads each once per target instead of re-reading the property store once per resource value.
- **Use `size([... ]) > 0`, not `any(...)`.** The `any()`, `all()`, and `none()` predicate functions are not part of the openCypher specification and fail on Amazon Neptune. See [openCypher Compatibility](#opencypher-compatibility).
- **For two-statement queries**, aggregate each statement's resources into its own list (`res_values`, `res2_values`) and combine the two `size([... ]) > 0` checks with `AND`.
Every IAM privilege escalation query in `aws.py` uses this pattern. The lateral-movement variants that constrain the target with a relationship (`STS_ASSUMEROLE_ALLOW`, `TRUSTS_AWS_PRINCIPAL`) already limit the target set before the resource filter, which keeps them efficient without further aggregation.
## Privilege Escalation Sub-Patterns
Four `path_target` shapes cover the common escalation types. Each shares the canonical template's `path_principal`, the resource pre-aggregation, the deduplication tail, and the `RETURN`; only the `path_target` `MATCH` and its resource predicate differ.
| Sub-pattern | Target | `path_target` shape | Example |
| ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------- | ------- |
| Self-escalation | Principal's own policies | `(aws)--(target_policy:AWSPolicy)--(principal)` | IAM-001 |
| Lateral to user | Other IAM users | `(aws)--(target_user:AWSUser)` | IAM-002 |
| Assume-role lateral | Assumable roles | `(aws)--(target_role:AWSRole)-[:STS_ASSUMEROLE_ALLOW]-(principal)` | IAM-014 |
| PassRole plus service | Service-trusting roles | `(aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]-(:AWSPrincipal {arn: '{service}.amazonaws.com'})` | EC2-001 |
**Multi-permission queries** (for example PassRole plus a service-create action) add permission gates before `path_target`. Reuse the per-query counter for new variables (`act2`, `policy2`, `stmt2`) and collapse rows after each gate:
```cypher
MATCH (principal)-[:POLICY]->(policy2:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {effect: 'Allow'})
MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
WHERE toLower(act2.value) IN ['service:*', 'service:createsomething']
OR act2.value = '*'
WITH DISTINCT aws, principal, stmt, stmt2, path_principal
```
When a permission is an existence-only gate whose statement resource is not checked later, keep the policy and statement anonymous and carry only the variables still needed:
```cypher
MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {effect: 'Allow'})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
WHERE toLower(act3.value) IN ['service:*', 'service:othersomething']
OR act3.value = '*'
WITH DISTINCT aws, principal, stmt, path_principal
```
## Network Exposure Pattern
The Internet node is reached through `CAN_ACCESS` from an already-scoped resource, never as a standalone lookup:
```python
cypher=f"""
// Resource scoped through the account anchor
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(resource:EC2Instance)
WHERE resource.exposed_internet = true
// Internet node reached through path connectivity from the resource
OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(resource)
WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access
UNWIND paths AS p
UNWIND nodes(p) AS n
WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes
UNWIND unique_nodes AS n
OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr,
internet, can_access
"""
```
The `CAN_ACCESS` edge stays typed and directed (`-[:CAN_ACCESS]->`); that is its canonical sync-time orientation. Network-exposure queries extend the `RETURN` contract with `internet, can_access`.
## Working with List-Typed Properties
Some Cartography node properties carry a list of values: `AWSPolicyStatement.action`, `AWSPolicyStatement.resource`, `AWSPolicyStatement.notaction`, `AWSPolicyStatement.notresource`, `KMSKey.encryption_algorithms`, `CloudFrontDistribution.aliases`, the container-definition lists on `ECSContainerDefinition`, and many others. The graph models each such property as a set of child item nodes connected to the parent by a typed edge. Queries reach the values by traversing the edge; the parent does not carry the list as a single field.
### Naming Convention
For a list-typed parent property the sink stores:
- **Child label**: `<ParentLabel><PropertyPascal>Item`. Example: `AWSPolicyStatement.resource` becomes `AWSPolicyStatementResourceItem`.
- **Edge type**: `HAS_<PROPERTY_UPPER>`. Example: `resource` becomes `HAS_RESOURCE`.
- **Child property**: `value`, a single scalar string per list element. For list-of-dict properties (rare; for example `SecretsManagerSecretVersion.tags`) the child carries the original dict keys as named fields per the catalog's `field_map`.
### Variable Naming for Child-Item Matches
`aws.py` uses a per-query counter for each `HAS_*` traversal so chained matches stay unambiguous. The counter resets at the top of every query.
| Edge | First | Second | Third |
| ----------------- | ------ | ------- | ------- |
| `HAS_ACTION` | `act` | `act2` | `act3` |
| `HAS_RESOURCE` | `res` | `res2` | `res3` |
| `HAS_NOTACTION` | `nact` | `nact2` | `nact3` |
| `HAS_NOTRESOURCE` | `nres` | `nres2` | `nres3` |
### Matching an Action
To find statements that grant `iam:PassRole`, `iam:*`, or `*`, traverse the `HAS_ACTION` edge in its own `MATCH` clause and apply the predicate in the attached `WHERE`:
```cypher
MATCH (stmt:AWSPolicyStatement {effect: 'Allow'})
MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
WHERE toLower(act.value) IN ['iam:passrole', 'iam:*']
OR act.value = '*'
```
The literal-action list is case-folded with `toLower(act.value)` because IAM authors mix case (`iam:PassRole`, `iam:passrole`); the `*` wildcard never lower-cases.
### Matching a Resource Against a Target
To find statements whose resource can target a specific node, pre-aggregate the resource values and test the target against the list once (see [Avoiding Cartesian Products](#avoiding-cartesian-products)):
```cypher
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
MATCH path_target = (aws)--(target_role:AWSRole)
WITH path_principal, path_target, res_values, res_wildcard,
target_role.name AS rname, target_role.arn AS rarn
WHERE res_wildcard
OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0
```
Three predicates cover the resource cases: full wildcard (`*`), a pattern containing the target name (`arn:aws:iam::*:role/admin*`), and a pattern that is a prefix or component of the actual ARN.
### Every-Item and Any-Item Predicates on a Custom Query
Custom queries can express list predicates directly with pattern comprehensions. To check whether *every* item satisfies a predicate, count the counter-examples and require zero, together with a guard that ensures at least one item is attached:
```cypher
MATCH (stmt:AWSPolicyStatement)
WHERE size([
(stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem)
WHERE NOT toLower(a.value) STARTS WITH 's3:'
| a
]) = 0
AND size([(stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) | a]) > 0
RETURN stmt
LIMIT 25
```
To return the list of values directly, collect them from the child items:
```cypher
MATCH (stmt:AWSPolicyStatement {effect: 'Allow'})
OPTIONAL MATCH (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem)
RETURN stmt, collect(a.value) AS actions
LIMIT 25
```
### Catalog of List Properties
The provider catalog lives in `api/src/backend/tasks/jobs/attack_paths/provider_config.py` (`AWS_NORMALIZED_LISTS`). Beyond policy statements it includes KMS algorithms, ECS container-definition lists (`entry_point`, `command`, `links`, `dns_servers`, and others), CloudFront aliases, Inspector finding URL and vulnerability lists, and RDS event-subscription categories. To query a list property that is not in the catalog, add an entry there first so the sync layer materializes it. Properties absent from the catalog are serialized to a comma-delimited string and emit a one-time warning during sync.
## Working with JSON-Encoded Properties
Some Cartography properties represent nested objects, most notably `condition` on `AWSPolicyStatement` and `S3PolicyStatement` nodes. To keep the schema portable across graph backends, object-typed properties are stored as JSON-encoded strings:
```
'{"StringEquals":{"aws:SourceAccount":"123456789012"}}'
```
No JSON parser is available at query time, so use `CONTAINS` for substring checks against keys or known values:
```cypher
MATCH (stmt:AWSPolicyStatement)
WHERE stmt.effect = 'Allow'
AND stmt.condition CONTAINS '"aws:SourceAccount"'
RETURN stmt
LIMIT 25
```
When a query needs to inspect the structured members of a condition (for example, to evaluate every operator and key), fetch the rows first and parse the JSON in application code. Cypher cannot navigate JSON object keys or values.
## openCypher Compatibility
Queries must run on both Neo4j and Amazon Neptune. Neptune implements a subset of Cypher, so several convenient constructs are unavailable. Avoid the following:
| Feature | Use instead |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| APOC procedures (`apoc.*`) | Real nodes and relationships in the graph |
| Neptune extensions | Standard openCypher |
| `any(x IN list ...)` | `size([x IN list WHERE pred]) > 0` |
| `all(x IN list ...)` | `size([x IN list WHERE pred]) = size(list)` |
| `none(x IN list ...)` | `size([x IN list WHERE pred]) = 0` |
| `reduce()` | `UNWIND` plus `collect()` |
| `FOREACH` | `WITH` plus `UNWIND` plus `SET` |
| Regex `=~` | `toLower()` plus exact match, or `STARTS WITH` / `CONTAINS` |
| `CALL () { UNION }` | Multi-label `OR` in `WHERE` |
| Carried value plus aggregate expression | Project the aggregate first (`WITH principal_paths, collect(...) AS target_paths`), then combine lists in the next `WITH` |
| `EXISTS { MATCH (pattern) WHERE pred }` | Standalone `MATCH (pattern)` plus `WHERE pred`; precede the downstream `collect(path...)` with `WITH DISTINCT <path-vars>` to dedupe the joins |
The carried-value-plus-aggregate rule is worth calling out because it is easy to hit. Neo4j 5.x rejects an expression that concatenates a carried list variable with an aggregate in the same projection:
```cypher
// Rejected: "Aggregation column contains implicit grouping expressions"
WITH principal_paths + collect(DISTINCT path_target) AS paths
```
Split it into two `WITH` clauses so the aggregation resolves before the concatenation:
```cypher
WITH principal_paths, collect(DISTINCT path_target) AS target_paths
WITH principal_paths + target_paths AS paths
```
For list-typed properties in the catalog (action, resource, and so on), traverse the `HAS_*` edges to the child item nodes rather than reading a single field; `split(...)` and comma-string predicates do not apply.
## Best Practices
1. **Chain every MATCH from the account anchor.** An unanchored `MATCH (role:AWSRole)` returns roles from every provider in the graph; `MATCH (aws)--(role:AWSRole)` is scoped. A second-permission `MATCH` such as `MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)` is safe because `principal` is already bound to the account subgraph.
2. **Pre-aggregate resource lists before matching targets** to avoid Cartesian products (see [Avoiding Cartesian Products](#avoiding-cartesian-products)).
3. **Type the finding probe.** Always `OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})`. The type lets Neptune apply an inline edge filter; an untyped probe scans every incident edge of high-degree nodes.
4. **Comment each MATCH.** One inline `// ...` line per clause explaining its role.
5. **Never use internal labels.** `_ProviderResource`, `_AWSResource`, `_Tenant_*`, and `_Provider_*` are system isolation labels and must not appear in query text.
6. **Reach the Internet node through path connectivity** with `(internet:Internet)-[:CAN_ACCESS]->(resource)`, never as a standalone match.
7. **Preserve the RETURN contract.** `paths, dpf, dpfr` for the standard shape; add `internet, can_access` for network-exposure queries. The serializer and visualizer depend on these names.
## Naming Conventions
- **ID**: kebab-case `{provider}-{category}-{description}`, e.g. `aws-ec2-privesc-passrole-iam`.
- **Constant**: `UPPER_SNAKE_CASE` `{PROVIDER}_{CATEGORY}_{DESCRIPTION}`, e.g. `AWS_EC2_PRIVESC_PASSROLE_IAM`.
## Creating a New Query
New queries come from one of two input sources: a [pathfinding.cloud](https://github.com/DataDog/pathfinding.cloud) research ID (for example `ECS-001`, `GLUE-001`) or a natural-language description from the requester. The aggregated `paths.json` is too large to fetch whole; query a single path by ID:
```bash
# Fetch a single path by ID
curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \
| jq '.[] | select(.id == "ecs-002")'
# List all path IDs and names
curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \
| jq -r '.[] | "\(.id): \(.name)"'
```
Then follow these steps:
1. **Read the queries module first** to match the existing style:
```text
api/src/backend/api/attack_paths/queries/
├── __init__.py
├── types.py # dataclass definitions
├── registry.py
└── {provider}.py
```
2. **Fetch the Cartography schema for the pinned version.** Do not guess labels, properties, or relationships. See [The Graph Model](#the-graph-model).
3. **Build the query** from the canonical template plus the appropriate sub-pattern (privilege escalation or network exposure). Pre-aggregate resource lists, traverse `HAS_*` edges for list-typed properties, and keep the `RETURN` contract.
4. **Register** the constant in the `{PROVIDER}_QUERIES` list at the bottom of the provider file.
5. **Verify compatibility** against the [openCypher Compatibility](#opencypher-compatibility) rules, and confirm the query parses and runs on Neo4j before it reaches Neptune.
<Note>
AI assistants connected through Prowler MCP Server can fetch the exact Cartography schema for the active scan with the `prowler_get_attack_paths_cartography_schema` tool, which guarantees that generated queries match the schema version pinned by the running Prowler release.
</Note>
## Reference
- **pathfinding.cloud**: [github.com/DataDog/pathfinding.cloud](https://github.com/DataDog/pathfinding.cloud) (use `curl | jq`; the aggregated `paths.json` is too large for a single fetch).
- **Cartography AWS schema**: [cartography-cncf.github.io/cartography/modules/aws/schema.html](https://cartography-cncf.github.io/cartography/modules/aws/schema.html).
- **Neptune openCypher compliance**: [docs.aws.amazon.com/neptune/latest/userguide/feature-opencypher-compliance.html](https://docs.aws.amazon.com/neptune/latest/userguide/feature-opencypher-compliance.html).
- **Neptune openCypher rewrites**: [docs.aws.amazon.com/neptune/latest/userguide/migration-opencypher-rewrites.html](https://docs.aws.amazon.com/neptune/latest/userguide/migration-opencypher-rewrites.html).
- **openCypher specification**: [github.com/opencypher/openCypher](https://github.com/opencypher/openCypher).
+4 -4
View File
@@ -39,7 +39,7 @@ The AWS provider implementation follows the general [Provider structure](/develo
- **Key AWS Responsibilities:**
- Receives an `AwsProvider` instance to access session, identity, and configuration.
- Manages clients for all services by regions.
- Provides `__threading_call__` method to make boto3 calls in parallel. By default, this calls are made by region, but it can be overridden with the first parameter of the method and use by resource.
- Provides `__threading_call__` method to make boto3 calls in parallel. By default, these calls are made by region, but it can be overridden with the first parameter of the method and use by resource.
- Exposes common audit context (`audited_account`, `audited_account_arn`, `audited_partition`, `audited_resources`) to subclasses.
### Exception Handling
@@ -59,7 +59,7 @@ The generic service pattern is described in [service page](/developer-guide/serv
- Directly in the code, in location [`prowler/providers/aws/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/aws/services)
- In the [Prowler Hub](https://hub.prowler.com/). For a more human-readable view.
The best reference to understand how to implement a new service is following the [service implementation documentation](/developer-guide/services#adding-a-new-service) and taking other services already implemented as reference. In next subsection you can find a list of common patterns that are used accross all AWS services.
The best reference to understand how to implement a new service is following the [service implementation documentation](/developer-guide/services#adding-a-new-service) and taking other services already implemented as reference. In next subsection you can find a list of common patterns that are used across all AWS services.
### AWS Service Common Patterns
@@ -68,7 +68,7 @@ The best reference to understand how to implement a new service is following the
- The constructor (`__init__`) always calls `super().__init__` with the service name and provider (e.g. `super().__init__(__class__.__name__, provider))`). Ensure that the service name in boto3 is the same that you use in the constructor. Usually is used the `__class__.__name__` to get the service name because it is the same as the class name.
- Resource containers **must** be initialized in the constructor. They should be dictionaries, with the key being the resource ARN or equivalent unique identifier and the value being the resource object.
- Resource discovery and attribute collection are parallelized using `self.__threading_call__`, typically by region or resource, for performance. The first parameter of the method is the iterator, if not provided, it will be the region; but if present indicate an array of the resources to be processed.
- Resource filtering is consistently enforced using `self.audit_resources` attribute and `is_resource_filtered` function, it is used to see if user has provided some resource that is not in the audit scope, so we can skip it in the service logic. Normally it is used befor storing the resource in the service container as follows: `if not self.audit_resources or (is_resource_filtered(resource["arn"], self.audit_resources)):`.
- Resource filtering is consistently enforced using `self.audit_resources` attribute and `is_resource_filtered` function, it is used to see if user has provided some resource that is not in the audit scope, so we can skip it in the service logic. Normally it is used before storing the resource in the service container as follows: `if not self.audit_resources or (is_resource_filtered(resource["arn"], self.audit_resources)):`.
- All AWS resources are represented as Pydantic `BaseModel` classes, providing type safety and structured access to resource attributes.
- AWS API calls are wrapped in try/except blocks, with specific handling for `ClientError` and generic exceptions, always logging errors.
- If ARN is not present for some resource, it can be constructed using string interpolation, always including partition, service, region, account, and resource ID.
@@ -162,7 +162,7 @@ When you instantiate `Check_Report_AWS`, you must provide the check metadata and
If the resource object does not contain the required attributes, you must set them manually in the check logic.
Other attributes are inherited from the `Check_Report` class, from that ones you **always** have to set the `status` and `status_extended` attributes in the check logic.
Other attributes are inherited from the `Check_Report` class, from those you **always** have to set the `status` and `status_extended` attributes in the check logic.
#### Example Usage
+1 -1
View File
@@ -57,7 +57,7 @@ The generic service pattern is described in [service page](/developer-guide/serv
- Directly in the code, in location [`prowler/providers/azure/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/azure/services)
- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view.
The best reference to understand how to implement a new service is following the [service implementation documentation](/developer-guide/services#adding-a-new-service) and taking other services already implemented as reference. In next subsection you can find a list of common patterns that are used accross all Azure services.
The best reference to understand how to implement a new service is following the [service implementation documentation](/developer-guide/services#adding-a-new-service) and taking other services already implemented as reference. In next subsection you can find a list of common patterns that are used across all Azure services.
### Azure Service Common Patterns
+3 -3
View File
@@ -172,7 +172,7 @@ else:
### Resource Identification in Prowler
Each check **must** populate the report with an unique identifier for the audited resource. This identifier or identifiers are going to depend on the provider and the resource that is being audited. Here are the criteria for each provider:
Each check **must** populate the report with a unique identifier for the audited resource. This identifier or identifiers are going to depend on the provider and the resource that is being audited. Here are the criteria for each provider:
- AWS
- Amazon Resource ID — `report.resource_id`.
@@ -224,9 +224,9 @@ Each check **must** populate the report with an unique identifier for the audite
- `resource_name`: Description of the configuration (e.g., "SharePoint Settings")
- GitHub
- Resource ID — `report.resource_id`.
- The ID of the Github resource. This is a system-generated integer that uniquely identifies the resource within the Github platform.
- The ID of the GitHub resource. This is a system-generated integer that uniquely identifies the resource within the GitHub platform.
- Resource Name — `report.resource_name`.
- The name of the Github resource. In the case of a repository, this is just the repository name. For full repository names use the resource `full_name`.
- The name of the GitHub resource. In the case of a repository, this is just the repository name. For full repository names use the resource `full_name`.
### Configurable Checks in Prowler
@@ -133,6 +133,7 @@ Only fields with a numeric range, a fixed value set, or a length cap are listed.
| `max_unused_sagemaker_access_days` | `7..180` days | |
| `max_security_group_rules` | `1..1000` | AWS hard limit is 1000 rules per security group |
| `max_ec2_instance_age_in_days` | `1..1095` days | 3 years |
| `max_ec2_instance_stopped_days` | `1..1095` days | 3 years |
| `ec2_high_risk_ports` | each port `1..65535` | port 0 is reserved |
| `max_idle_disconnect_timeout_in_seconds` | `60..1800` s | NIST AC-12: cap at 30 min |
| `max_disconnect_timeout_in_seconds` | `60..3600` s | |
+3 -3
View File
@@ -102,7 +102,7 @@ The generic service pattern is described in [service page](/developer-guide/serv
- Directly in the code, in location [`prowler/providers/gcp/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/gcp/services)
- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view.
The best reference to understand how to implement a new service is following the [service implementation documentation](/developer-guide/services#adding-a-new-service) and taking other services already implemented as reference. In next subsection you can find a list of common patterns that are used accross all GCP services.
The best reference to understand how to implement a new service is following the [service implementation documentation](/developer-guide/services#adding-a-new-service) and taking other services already implemented as reference. In next subsection you can find a list of common patterns that are used across all GCP services.
### GCP Service Common Patterns
@@ -113,7 +113,7 @@ The best reference to understand how to implement a new service is following the
- Only projects with the API enabled are included in the audit scope.
- Resource discovery and attribute collection can be parallelized using `self.__threading_call__`, typically by region/zone or resource.
- All GCP resources are represented as Pydantic `BaseModel` classes, providing type safety and structured access to resource attributes.
- Each GCP API calls are wrapped in try/except blocks, always logging errors.
- Each GCP API call is wrapped in try/except blocks, always logging errors.
- **Retry Configuration**: All `request.execute()` calls must include `num_retries=DEFAULT_RETRY_ATTEMPTS` for automatic retry on rate limiting errors (HTTP 429).
- Tags and additional attributes that cannot be retrieved from the default call should be collected and stored for each resource using dedicated methods and threading.
@@ -161,7 +161,7 @@ When you instantiate `Check_Report_GCP`, you must provide the check metadata and
- Defaults to "global" if none are available.
All these attributes can be overridden by passing the corresponding argument to the constructor. If the resource object does not contain the required attributes, you must set them manually.
Others attributes are inherited from the `Check_Report` class, from that ones you **always** have to set the `status` and `status_extended` attributes in the check logic.
Other attributes are inherited from the `Check_Report` class, from those you **always** have to set the `status` and `status_extended` attributes in the check logic.
#### Example Usage
+4 -1
View File
@@ -45,6 +45,9 @@ Prowler is constantly evolving. Contributions to checks, services, or integratio
<Card title="Adding New Integrations" icon="link" href="/developer-guide/integrations">
Prowler can work with other tools and platforms through integrations.
</Card>
<Card title="Adding New Attack Paths Queries" icon="diagram-project" href="/developer-guide/attack-paths-queries">
Want to detect new privilege escalation or exposure patterns? Contribute read-only openCypher queries that traverse the cloud graph.
</Card>
<Card title="Proposing or Implementing Features" icon="lightbulb" href="https://github.com/prowler-cloud/prowler/issues/new?template=feature-request.yml">
Propose brand-new features or enhancements to existing ones, or help implement community-requested improvements.
</Card>
@@ -72,7 +75,7 @@ Remember, our community is here to help! If you need guidance, do not hesitate t
## Setting up your development environment
## Setting Up Your Development Environment
### Prerequisites
+145 -1
View File
@@ -352,7 +352,7 @@ result = await self.api_client.poll_task_until_complete(
### Tool Docstrings
Tool docstrings become description that is going to be read by the LLM. Provide clear usage instructions and common workflows:
Tool docstrings become the description that is going to be read by the LLM. Provide clear usage instructions and common workflows:
```python
async def search_items(self, status: str = Field(...)) -> dict:
@@ -426,6 +426,150 @@ For complete installation and deployment options, see:
For development I recommend to use the [Model Context Protocol Inspector](https://github.com/modelcontextprotocol/inspector) as MCP client to test and debug your tools.
## Testing
Tests live in `mcp_server/tests/`, mirroring the source tree, and use the `test_*.py`
prefix (the same convention as the API, not the SDK's `*_test.py` suffix).
From `mcp_server/`:
```bash
cd mcp_server
uv run pytest # Whole suite
uv run pytest tests/prowler_app/models # One area
uv run pytest --cov=./prowler_mcp_server # With coverage
```
From the repository root:
```bash
make test-mcp # Runs the MCP suite exactly as CI does
```
Async tests need no marker — `asyncio_mode` is set to `auto`.
### Reading the Coverage Numbers
<Warning>
Coverage here has a high floor that means nothing. `coverage.py` measures
*statements*, and in a Pydantic model module nearly every statement is a class-body
field declaration that runs at **import** time. `prowler_app/server.py` imports
every tool module — and therefore every model module — when it is first imported,
so all of those declarations execute and count as covered before a single test runs.
Importing the package and executing no tests at all already reports **36% overall**,
with individual model modules between 54% and 84%. A model module sitting at ~68%
with no tests written for it has **none** of its behaviour covered: the covered lines
are its imports, `class` statements and `Field(...)` declarations, and the missing
ranges are its `from_api_response()` bodies.
Judge a module against that import-only floor, not against zero, and do not set a
Codecov target from the raw total.
</Warning>
### Shared Fixtures
All fixtures live in `mcp_server/tests/conftest.py`. Three are autouse and apply to
every test: the environment is pinned to deterministic values, real socket
connections are blocked, and the API client singleton registry is snapshotted and
restored.
| Fixture | What it gives you |
|---------|-------------------|
| `mock_api_client` | The API client singleton with its transport mocked. The workhorse. |
| `mock_router` | Route registry and request recorder |
| `mcp_root_server` | The mounted root server, for in-memory client tests |
| `health_client` | Starlette `TestClient` for the `/health` route |
| `http_request_headers` | Injects request headers for HTTP-transport auth tests |
| `hub_router` / `docs_router` | Mock the Hub and Docs sub-servers' sync HTTP clients |
| `api_client` / `isolated_api_client` | The live singleton / a freshly-constructed one |
Helpers live in `mcp_server/tests/helpers/`: JSON:API document builders
(`jsonapi.py`), the `MockRouter` (`http.py`), tool-contract assertions
(`assertions.py`) and fake credentials (`tokens.py`).
### Writing a Tool Test
Drive tools through an in-memory MCP client, and open the client inside the test —
FastMCP warns that holding a client in a fixture causes event-loop problems.
```python
from fastmcp import Client
from tests.helpers.jsonapi import jsonapi_collection, jsonapi_resource
FINDING_ATTRIBUTES = {
"uid": "prowler-aws-s3_bucket_public_access-123456789012-us-east-1-my-bucket",
"status": "FAIL",
"severity": "high",
"status_extended": "S3 bucket my-bucket is publicly accessible.",
"delta": "new",
"muted": False,
"muted_reason": None,
"check_metadata": {"checkid": "s3_bucket_public_access"},
}
async def test_search_without_dates_queries_the_latest_scan_endpoint(
mcp_root_server, mock_api_client, mock_router
):
"""With no date range the tool targets the cheaper `/findings/latest`."""
mock_router.add(
"GET",
"/api/v1/findings/latest",
json=jsonapi_collection(
[jsonapi_resource("findings", "f1", FINDING_ATTRIBUTES)]
),
)
async with Client(mcp_root_server) as client:
result = await client.call_tool("prowler_search_security_findings", {})
assert result.data["findings"][0]["check_id"] == "s3_bucket_public_access"
assert mock_router.paths() == ["GET /api/v1/findings/latest"]
```
The exemplar suite covers `findings` end to end — `tests/prowler_app/models/test_findings.py`
and `tests/prowler_app/tools/test_findings.py`. It is deliberately one feature
across both layers rather than a scattering of unrelated samples, and `findings`
is the feature that exercises the whole foundation: two-tier models, nested
sub-models, both relationship shapes, endpoint switching on a date range,
list-to-CSV filter encoding, and a tool that returns prose instead of a model.
Note the two files share a name. That is why `__init__.py` is required in every
`tests/` subdirectory here — without it they would collide on import.
<Warning>
Tool parameters are declared with pydantic `Field(default=...)`, and only FastMCP's
tool wrapper resolves those defaults. Calling a tool method directly with an
argument omitted leaves it as a raw `FieldInfo` object, which is truthy — so a
filter such as `if email:` silently builds a query out of the `FieldInfo` repr.
Call tools through the client, or pass every argument explicitly.
</Warning>
### Why the API Key Is Pinned, Not Stripped
`prowler_app/server.py` builds every tool at import time. Constructing a tool
reaches `ProwlerAppAuth`, which raises when `PROWLER_API_KEY` is missing, and
`load_all_tools` swallows that error per tool class. The result is that the whole
`prowler_*` namespace registers **zero** tools while the server still logs
"Successfully mounted Prowler tools server".
The suite therefore pins a fake key in `[tool.pytest_env]`, which is applied before
any test module is imported, and `tests/test_server.py` asserts each namespace is
non-empty so this failure can never return silently.
<Note>
`ProwlerAppAuth` resolves `PROWLER_MCP_TRANSPORT_MODE` and `API_BASE_URL` in its
default arguments, which Python evaluates once at module import. `monkeypatch.setenv`
cannot change them — pass `mode=` and `base_url=` explicitly in auth tests.
</Note>
For the full set of rules and templates, see the
[`prowler-test-mcp` skill](https://github.com/prowler-cloud/prowler/blob/master/skills/prowler-test-mcp/SKILL.md)
and the [official FastMCP testing guide](https://gofastmcp.com/development/tests).
## Related Documentation
<CardGroup cols={2}>

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