Compare commits

..
Author SHA1 Message Date
Toni de la FuenteandClaude Fable 5.1 51bf412ff7 feat(sdk): link the import findings tutorial from the HTML report CTA
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 10:13:52 -07:00
Toni de la FuenteandClaude Fable 5.1 21e11b46e3 chore(sdk): add changelog fragment for HTML report Cloud hand-off block
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 10:09:28 -07:00
Toni de la FuenteandClaude Fable 5.1 ed6de7c8a2 feat(sdk): add Prowler Cloud hand-off block to OSS CLI HTML report
- Add opt-in `show_cloud_cta` to the HTML output, disabled by default
- Enable it only from the OSS CLI entry point
- Omit block, styles and campaign URLs entirely when disabled
- Hide the block when printing and keep assessment content unchanged
- Cover OSS vs API conditions with tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-30 10:07:11 -07:00
Pedro Martín 67aa941012 fix(mongodbatlas): keep Atlas error detail on identity (#13214) 2026-10-30 13:03:15 +01:00
Pedro Martín f64e5e10ad fix(container): bump Python base image to 3.12.16 (#13215) 2026-10-30 12:37:56 +01:00
Alejandro BailoandClaude Opus 5.5 008864797b feat(ui): do everything about an issue from its side panel (#13207)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:03:31 +01:00
Prowler Botandprowler-bot 6999bbac9b chore(release): Bump versions to v5.48.0 (#13203)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-10-29 16:37:40 +01:00
Pedro Martín 2cc5c4b2f9 chore(changelog): v5.47.0 highlights (#13200) 2026-10-29 16:37:20 +01:00
Prowler Botandprowler-bot 1a62d7d99a chore(changelog): v5.47.0 (#13201)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-10-29 14:01:05 +01:00
Pedro Martín 162996fa0a docs(changelog): fix v5.46.0 release date (#13199) 2026-10-29 13:54:06 +01:00
Harsh Rajandpedrooot 219304848d fix(ec2): treat CIDRs that together cover every address as open to the internet (#13191)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-10-29 12:56:46 +01:00
Rubén De la Torre Vico b036b8f42d feat(mcp): mark every tool as read-only or destructive (#13196) 2026-10-29 11:48:22 +01:00
Pedro Martín cbece30d7b chore(changelog): v5.46.0 highlights (#13159) 2026-10-29 11:24:13 +01:00
Pedro Martín 90e1d21db6 docs(guides): prioritize Prowler Cloud over Local Server (#13161) 2026-10-29 11:21:15 +01:00
Babafemi Bulugbeandpedrooot 714d9283b0 feat(azure): add iam_subscription_owners_between_2_and_3 check (#13145)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-10-29 11:11:42 +01:00
Babafemi Bulugbeandpedrooot 8e83de67bc feat(azure): add Application Gateway WAF and HTTP/2 checks (#13160)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-10-29 10:49:38 +01:00
Alejandro BailoandClaude Opus 5.5 ca6ed48664 feat(ui): show the attack path in the Issues panel and rename the menu entry (#13195)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:43:42 +01:00
manuelProwlerandClaude Opus 5.5 814ae54d6a feat(ui): let Lighthouse use Bedrock without keys where the API allows it (#13101)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:57:42 -05:00
Alejandro BailoandClaude Opus 5.5 53b7b24e2b feat(ui): explain verdicts in the Issues list and open rows in a side panel (#13189)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:36:57 +01:00
Alejandro Bailo e74a9a909e fix(ui): let the Lighthouse connection guide connect the provider on request (#13188) 2026-10-28 14:54:44 +01:00
Pedro Martín 5a94fc798d fix(container): patch pymongo, dnspython and oauthlib CVEs (#13185) 2026-10-28 13:44:41 +01:00
Alejandro Bailo 78a5fa139f feat(ui): gate API key creation behind Prowler Cloud (#13187) 2026-10-28 13:20:03 +01:00
Alejandro Bailo 06a20bcc7c feat(ui): link usage-limit refusals to Prowler pricing (#13184) 2026-10-28 12:32:50 +01:00
Rubén De la Torre Vico c0891591a3 fix(mcp): patch Authlib and dnspython CVEs flagged by Trivy (#13186) 2026-10-28 12:18:44 +01:00
Alejandro Bailo 4b80c69739 feat(ui): connect every provider in one wizard step (#13181) 2026-10-28 11:48:25 +01:00
Pedro Martín 905c60fa85 fix(ui): keep compliance tab when one catalog page fails (#13183) 2026-10-28 09:51:58 +01:00
Alejandro Bailo dcd3d5a0fb fix(ui): stop the add-provider modal from shifting while it loads (#13179) 2026-10-27 20:53:00 +01:00
Alejandro Bailo 75c9a6bf51 fix(ui): stop Lighthouse replies from blinking when the stream ends (#13180) 2026-10-27 19:45:59 +01:00
Alejandro Bailo 4eb468619f feat(ui): offer Lighthouse AI help in the add-provider wizard (#13178) 2026-10-27 16:55:26 +01:00
Pedro Martín d7bffba4ac fix(ui): stop avatar from requesting the current page (#13177) 2026-10-27 15:20:58 +01:00
Pedro Martín a5995e1438 feat(ui): add NATO D32 compliance icon (#13174) 2026-10-27 12:02:24 +01:00
Alejandro Bailo 1a30b374d6 refactor(ui): remove the onboarding system and product tours (#13176) 2026-10-27 11:57:34 +01:00
Alejandro Bailo d6ea8bf274 fix(ui): use Status wording and capitalize invitation and role filters (#13175) 2026-10-27 11:00:35 +01:00
Prowler Botandprowler-bot b4bbb64c6b chore(release): Bump versions to v5.47.0 (#13165)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-10-26 20:55:18 +01:00
Prowler Botandprowler-bot fdb1bd6193 chore(changelog): v5.46.0 (#13162)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-10-26 18:46:03 +01:00
Rubén De la Torre Vicoandalejandrobailo 41a2bf605a feat(ui): bring the issues UI closer to the original prototype (#13154)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-10-26 17:24:52 +01:00
Rubén De la Torre Vicoandalejandrobailo 63b8c8969c feat(ui): show the compliance an issue puts at risk (#13153)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-10-26 16:44:04 +01:00
Rubén De la Torre Vicoandalejandrobailo 1ae3856de0 feat(ui): show what each attack path buys and where it starts (#13152)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-10-26 15:59:38 +01:00
40758818c3 feat(aws): Update regions for AWS services (#13156)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-10-26 15:10:49 +01:00
Rubén De la Torre Vicoandalejandrobailo 83152ae1a6 feat(ui): triage issues and send them to Jira (#13151)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-10-26 15:09:05 +01:00
Pedro Martín 3bbd551f0f fix(openstack): block SSRF via auth_url in connection test (#12962) 2026-10-26 13:37:46 +01:00
Pedro Martín be01a97789 fix(outputs): parse resource metadata once per batch (#13149) 2026-10-26 12:57:52 +01:00
Alejandro Bailo 7ecb6aaefd fix(ui): match the "Save and launch scan" footer in the provider E2E flows (#13133) 2026-10-26 12:53:36 +01:00
Pedro Martín 75f81cc751 ci(ui): pin Playwright jobs to ubuntu-24.04 (#13158) 2026-10-26 12:32:58 +01:00
Pedro Martín c724840e6a fix(api): compare serialized metadata on scan ingestion (#13148) 2026-10-26 12:13:02 +01:00
Pedro Martín b52b8d3678 fix(api): bump djangorestframework to 3.18.3 (#13157) 2026-10-26 11:56:25 +01:00
Alejandro Bailo c533b47aeb fix: patch critical UI advisories and SDK container CVEs (#13135) 2026-10-26 11:51:31 +01:00
Pedro MartínandRubén De la Torre Vico 669df91ac9 feat(checks): PHP runtime coverage after the October security release (#13127)
Co-authored-by: Rubén De la Torre Vico <98956809+puchy22@users.noreply.github.com>
2026-10-23 12:49:36 +02:00
Alejandro Bailo 35d25a7218 feat(ui): limit locked tenants to a ten-row findings preview (#13130) 2026-10-23 10:58:15 +02:00
Pepe Fagoaga 819f5fd7a0 chore(googleworkspace): name the Policy API reducer the precedence rule implements (#13125) 2026-10-23 08:38:13 +02:00
Pedro Martín 7d71220a25 fix(sdk): redact secret fields in resource metadata (#13129) 2026-10-23 08:37:27 +02:00
Rubén De la Torre Vico 69b80f6a3f chore(azure): raise App Service PHP latest version default to 8.5 (#13128) 2026-10-22 15:33:06 +02:00
Alejandro Bailo f26b9776d4 fix(ui): bump Node to 24.22.1 to patch the October 2026 security release (#13126) 2026-10-22 14:28:46 +02:00
Pepe Fagoaga 54f2400aee fix(googleworkspace): map Gmail spoofing actions to the right fields (#13114) 2026-10-22 08:26:11 +02:00
Pepe Fagoaga 8c5357b087 feat(googleworkspace): explain findings gated by the Workspace edition (#13113) 2026-10-22 08:25:52 +02:00
Pedro Martín 13202b8a70 fix(api): require manage_account for SAML configuration (#13121) 2026-10-22 08:24:22 +02:00
Utkarsh Batham 680acf8b31 feat(cognito): add cognito_user_pool_account_recovery_sms_not_preferred check (#12251) 2026-10-22 01:32:19 +02:00
Jonathan Nguyen 1468596b61 feat(bedrock): assess invocation log content and guardrail output filtering (#12663) 2026-10-22 01:25:39 +02:00
Pepe Fagoaga 02c8881378 fix(googleworkspace): honor the sharing mode in Drive publishing check (#13115) 2026-10-22 01:11:14 +02:00
Pepe Fagoaga eae17a5e17 feat(googleworkspace): pass app checks when the service is off for everyone (#13117) 2026-10-22 01:07:19 +02:00
Pedro Martín a59eae7b24 fix(api): harden Attack Paths custom query sanitizer (#13120) 2026-10-22 01:05:45 +02:00
Alejandro Bailo 9a89dc4a4e feat(ui): add outlined error badge variant (#13111) 2026-10-21 22:38:15 +02:00
Pedro Martín 1e2f8f3cf7 fix(container): patch Pillow, certifi, uv, aiohttp and Trivy CVEs (#13107) 2026-10-21 16:26:59 +02:00
Pepe Fagoaga d9ace56e43 docs(googleworkspace): link required APIs, handle key creation policy (#13108) 2026-10-21 15:52:48 +02:00
Pedro Martín c0ce556ea1 fix(ci): allow the Python download CDN in blocked jobs (#13109) 2026-10-21 15:45:43 +02:00
Alejandro Bailo 34e0af33c2 feat(ui): launch the initial scan by default after provider setup (#13104) 2026-10-21 15:42:41 +02:00
Toni de la Fuente 6ee0dd9730 chore: prepare Prowler Cloud plugin for the Claude directory (#13098) 2026-10-20 08:39:47 -07:00
Rajesh NadimintiandRubén De la Torre Vico 4980ded0ae feat(gcp): add vertexai workbench terminal, download and auto-upgrade… (#13077)
Co-authored-by: Rubén De la Torre Vico <98956809+puchy22@users.noreply.github.com>
2026-10-20 12:43:06 +02:00
Alejandro Bailo 4d922da2a6 refactor(ui): remove the skill launcher flag and keep the chip rail (#13097) 2026-10-19 20:19:07 +02:00
Alejandro Bailo 76cde39b08 feat(ui): redesign the Exploitability card and the issues verdict filter (#13093) 2026-10-19 17:57:15 +02:00
Pedro Martín 414dd64545 fix(api): scope metadata endpoints to role provider groups (#12964) 2026-10-19 17:15:09 +02:00
Alejandro BailoandClaude Fable 5.1 3654e5a810 fix(ui): drop the New badges from the Alerts and Scans sidebar items (#13092)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 16:32:16 +02:00
Alejandro Bailo 7f9eb2cb11 feat(ui): gate the Issues feature behind the issues-feature flag (#13090) 2026-10-19 16:00:26 +02:00
Alejandro Bailo ba0e78188b fix(ui): keep the E2E suites off the first-run redirect and pick the invite role (#13091) 2026-10-19 15:58:16 +02:00
Alejandro Bailo 1783527c11 refactor(ui): centralize PostHog feature flag reads in ui/lib/feature-flags (#13089) 2026-10-19 12:27:46 +02:00
Alejandro Bailo f46b86101a feat(ui): add issues views using the shared design system (#13068) 2026-10-19 11:34:25 +02:00
Pedro Martín f45173997b fix(api): wrap long account IDs in PDF report cover table (#13063) 2026-10-16 10:33:07 +02:00
Rajesh NadimintiandRubén De la Torre Vico 136e4efdb5 feat(gcp): add vertexai service with workbench public IP and root access checks (#13062)
Co-authored-by: Rubén De la Torre Vico <98956809+puchy22@users.noreply.github.com>
2026-10-16 10:20:34 +02:00
Pedro Martín de4db487d5 fix(container): bump Trivy to 0.76.0 in SDK and API images (#13064) 2026-10-16 09:55:18 +02:00
Prowler Botandprowler-bot f5aaec21cc chore(release): Bump versions to v5.46.0 (#13056)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-10-15 13:28:01 +02:00
Pedro Martín 64ce5a7503 fix(api): pin Redoc to 2.5.1 so the docs render (#13053) 2026-10-15 12:01:03 +02:00
César Arroba Vico 2600d49195 fix(ci): create the release tracking ref locally in prepare-release (#13052) 2026-10-15 11:51:12 +02:00
César Arroba Vico 7c4b12b655 fix(ci): fetch the release branch after creating it in prepare-release (#13050) 2026-10-15 11:23:57 +02:00
Prowler Botandprowler-bot 9b56a3b657 chore(changelog): v5.45.0 (#13049)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-10-15 11:12:38 +02:00
Pedro Martín f70b648cde chore(changelog): v5.45.0 highlights (#13048) 2026-10-15 11:05:49 +02:00
César Arroba 1f8ca695e8 fix(api): recycle gunicorn workers to cap memory growth (#13047) 2026-10-15 09:36:33 +02:00
Pepe Fagoagaandcoderabbitai[bot] d8a4e98850 docs: new listings for the AWS Marketplace (#12948)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-10-14 23:08:32 +02:00
Pedro Martín d9b53b2637 fix(iac): block SSRF in repository connection test (#12960) 2026-10-14 16:25:13 +02:00
Pedro Martín a4d07678b4 fix(outputs): neutralise injection in CSV and HTML reports (#12963) 2026-10-14 13:55:43 +02:00
Alejandro Bailo 64c19b3db6 fix(ui): show Registry subscription and setup prompts (#13038) 2026-10-14 13:51:08 +02:00
Rajesh NadimintiandRubén De la Torre Vico 286ae6c7cf feat(azure): add aiservices network ACL and prompt shield checks (#12997)
Co-authored-by: Rubén De la Torre Vico <98956809+puchy22@users.noreply.github.com>
2026-10-14 10:55:27 +02:00
Pedro Martín 8e67f0251c fix(ui): point Slack partial list to inviting the app (#13036) 2026-10-14 10:32:12 +02:00
Rajesh NadimintiandRubén De la Torre Vico 3c50cec509 feat(azure): add aiservices private endpoint, outbound, identity and logging checks (#12926)
Co-authored-by: Rubén De la Torre Vico <98956809+puchy22@users.noreply.github.com>
2026-10-14 10:26:16 +02:00
Pedro Martín fbb22834f7 fix(container): bump PowerShell to 7.5.12 in SDK and API (#13037) 2026-10-14 10:09:18 +02:00
Pedro Martín 165b62a730 ci(sdk): run Huawei Cloud tests in sdk-tests (#13035) 2026-10-14 09:22:30 +02:00
Rajesh Nadimintiandpedrooot 8b814b7643 feat(azure): add aiservices service with network, local auth and CMK checks (#12925)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-10-14 09:03:25 +02:00
Pepe Fagoaga 22468c1443 ci(trufflehog): scan full history and pin scanner image (#13032) 2026-10-14 07:53:25 +02:00
a36991f60e feat(m365): add entra_cross_tenant_access_default_outbound_restricted check (#12991)
Co-authored-by: kinomakino <3662588+kinomakino@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-10-13 16:25:42 +02:00
dd63fa960b feat(m365): add entra_policy_admin_self_service_password_reset_disabled check (#12990)
Co-authored-by: kinomakino <3662588+kinomakino@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-10-13 15:59:20 +02:00
80c4113879 feat(m365): add entra_dynamic_group_member_of_rule_not_used check (#13003)
Co-authored-by: kinomakino <3662588+kinomakino@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-10-13 15:59:01 +02:00
c438858dbd feat(m365): add entra_app_registration_redirect_uris_secure check (#12993)
Co-authored-by: kinomakino <3662588+kinomakino@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-10-13 15:42:21 +02:00
f606a3c195 feat(m365): add entra_policy_msol_powershell_blocked check (#13004)
Co-authored-by: kinomakino <3662588+kinomakino@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-10-13 15:22:01 +02:00
Pedro Martín c599a9cf1d fix(container): suppress Trivy HTTP/2 SETTINGS DoS CVE (#13028) 2026-10-13 11:55:48 +02:00
Pedro Martín 61d2cc55c4 fix(container): patch Trivy and MCP base image CVEs (#13009) 2026-10-09 18:17:48 +02:00
Pedro Martín 859a846147 fix(api): scope user memberships and roles to tenant (#12965) 2026-10-09 13:04:11 +02:00
Alejandro Bailo d8bdc8553f fix(ui): show registry provider logos and capitalize registry labels (#12985) 2026-10-08 18:16:08 +02:00
Pedro Martínandalejandrobailo f44ea4b6fa fix(api): require inline kubeconfig credentials (#12952)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-10-08 18:15:10 +02:00
César Arroba 27c4113d29 fix(aws): query Organizations in the audited partition (#12988) 2026-10-08 15:57:58 +02:00
Alejandro Bailo cf688e3fa1 fix(ui): bump Next.js to 16.3.8 to patch the image optimization SSRF advisory (#12987) 2026-10-08 15:17:59 +02:00
César Arroba 92216f1597 ci(release): auto-create minor release branch and lift freeze on publish
Create the minor release branch automatically in the release preparation workflow, check versions before creating it, match the version literally, and lift the release freeze when the release is published.
2026-10-08 12:47:38 +02:00
Alejandro Bailo b5a709664d fix(ui): show only the chosen registry credential variant (#12966) 2026-10-07 16:35:20 +02:00
Alejandro Bailo 9d8060adc5 test(ui): stabilize invite-teammate roles-unavailable test (#12973) 2026-10-07 16:14:23 +02:00
StylusFrost f1e822f7df chore(trivy): suppress @modelcontextprotocol/sdk CVE-2026-104850 (#12967) 2026-10-07 12:27:44 +02:00
StylusFrost 22bc834c09 chore(grype): suppress zlib CVE-2026-85091 until base images ship the fix (#12970) 2026-10-07 12:16:04 +02:00
StylusFrost 3f893176ba fix(ui): bump sharp to 0.35.5 to patch the librsvg advisory (#12968) 2026-10-07 11:48:19 +02:00
César Arroba d38e04dd3e chore(github): move code ownership from platform to engineering (#12958) 2026-10-06 10:42:52 +02:00
Pedro Martín 7ccdf7c0e6 fix(deps): patch pymongo CVEs and ignore proxy-addr (#12955) 2026-10-06 10:34:58 +02:00
Alejandro Bailo bee60c5471 fix(ui): stop reporting client-aborted RSC streams to Sentry (#12956) 2026-10-06 10:23:07 +02:00
Alejandro Bailo f850199645 fix(ui): pin proxy-addr to 2.0.8 to patch the IP spoofing advisory (#12957) 2026-10-06 10:10:17 +02:00
Pedro Martín 0321a4f167 fix(deps): patch Django, urllib3 and dulwich CVEs (#12953) 2026-10-06 08:48:54 +02:00
Alejandro Bailo 383a9bf903 fix(ui): bump Next.js to 16.3.6 to patch the next/og RCE advisory (#12922) 2026-10-01 11:54:44 +02:00
Alejandro Bailo 4605d9a770 feat(ui): invite a teammate from the AWS connect step (#12917) 2026-10-01 09:50:10 +02:00
César Arroba f0da33f451 revert(api): release providers blocked by scans whose worker died (#12915) 2026-09-30 13:00:50 +02:00
Alejandro Bailo a44a725507 fix(ui): retry the first-run redirect until the add-provider wizard opens (#12914) 2026-09-30 12:34:52 +02:00
César Arroba b8ca30400b fix(api): stop sending personal data to Sentry (#12912) 2026-09-30 12:28:42 +02:00
César Arroba a006525e78 fix(api): release providers blocked by scans whose worker died (#12899) 2026-09-30 11:09:21 +02:00
Pedro Martín ed510e217d chore(deps): bump pyjwt to 2.14.0 for osv-scanner (#12911) 2026-09-30 10:21:11 +02:00
Alejandro Bailo 04511f339e test(ui): stabilize attack-paths refit integration test (#12896) 2026-09-29 18:42:19 +02:00
Pedro Martín f418b32c81 fix(oci): use home region for identity bootstrap (#12865) 2026-09-29 17:50:54 +02:00
Pedro Martín 65fb146e76 chore(trivy): suppress fast-uri CVE-2026-84292 (#12907) 2026-09-29 17:04:46 +02:00
Prowler Botandprowler-bot 5ea363d582 chore(release): Bump versions to v5.45.0 (#12905)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-09-29 16:31:16 +02:00
Prowler Botandprowler-bot 3ec379a75a chore(changelog): v5.44.0 (#12900)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-09-29 13:32:41 +02:00
Pedro Martín ea020ed46e chore(changelog): v5.44.0 highlights (#12897) 2026-09-29 13:32:13 +02:00
Alejandro Bailo 60b936005c test(ui): scope E2E delete dialog and scans table locators (#12898) 2026-09-29 12:32:55 +02:00
Pedro Martín 03502c2426 fix(api): require operation permission to revoke tasks (#12893) 2026-09-28 17:15:39 +02:00
Alejandro Bailo e6320b178a fix(ui): bundle all icons so the UI renders without internet access (#12892) 2026-09-28 15:58:32 +02:00
Alejandro Bailo 4195a4f818 test(ui): add AWS provider in one step in the E2E helper (#12895) 2026-09-28 15:38:47 +02:00
César Arroba 8d003c60d0 fix(api): skip unconfigured attack paths sinks on provider deletion (#12894)
Provider deletion now skips attack path graph cleanup for a sink whose connection settings have already been removed, instead of failing. The skip is logged as a warning, while the configured active sink still raises on error as before.
2026-09-28 14:33:44 +02:00
Alejandro Bailo d5136f364c perf(ui): stream the findings page and load the Finding Group filter on open (#12891) 2026-09-28 12:21:13 +02:00
Rubén De la Torre Vico 453c953f37 fix(api): avoid field-named annotation in attack surface aggregation (#12889) 2026-09-28 11:39:36 +02:00
César Arroba c114aa304b fix(api): stop locking the API key row on every authenticated request (#12882) 2026-09-28 11:16:13 +02:00
Alejandro Bailo c2b8092461 feat(ui): hint the resource re-check beside Last seen (#12883) 2026-09-25 14:55:21 +02:00
Pedro Martín 26d9d24e5d docs(introduction): list UI and API support for Okta (#12881) 2026-09-25 10:16:53 +02:00
Alejandro Bailo ee59e35bc2 fix(ui): stop offering reports and compliance for partial scans (#12880) 2026-09-25 10:09:33 +02:00
César Arroba e0fa23b9ee fix(ui): show API error message when mute rule creation fails (#12853) 2026-09-24 15:35:19 +02:00
Alejandro Bailo 4dbc3c7e74 feat(ui): re-check a resource with a partial scan from the findings actions (#12879) 2026-09-24 13:54:15 +02:00
César Arroba 576433d85d fix(api): reap orphaned attack paths temp Neo4j databases (#12832) 2026-09-24 13:23:36 +02:00
Rubén De la Torre Vico bf179212a5 fix(api): return the new scan id when a scan is created (#12878) 2026-09-24 13:12:44 +02:00
César Arroba 60f936a10b fix(ui): wait for the full provider connection check before reporting a result (#12869) 2026-09-24 11:23:56 +02:00
César Arroba 15630f54d2 fix(aws): reuse the STS region that answered and add PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS (#12870) 2026-09-24 10:24:44 +02:00
César Arroba 706603fe4d feat(api): add an explicit endpoint for S3-compatible scan output storage (#12871) 2026-09-24 10:24:19 +02:00
Pedro Martínandalejandrobailo dc67fe4f37 feat(ui): connect and test AWS accounts in one step (#12876)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-09-24 09:57:59 +02:00
Prowler Botandprowler-bot 2c233c2f6c chore(release): Bump versions to v5.44.0 (#12860)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-09-23 13:11:45 +02:00
Alejandro Bailo 69e1d19abe feat(ui): open the paid plan upgrade modal from report downloads (#12875) 2026-09-23 13:08:49 +02:00
Alejandro Bailo 859421b0ec fix(ui): read the persisted sidebar mode without a hydration mismatch (#12873) 2026-09-23 13:03:28 +02:00
Pedro Martínandalejandrobailo ea36f12a01 feat(ui): connect AWS accounts in a single wizard step (#12852)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-09-22 18:21:55 +02:00
Pujitha Paladugu 50a9138bea fix(api): sign report download URLs with SigV4 when the bucket region is set (#12746)
When DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION is set, get_s3_presign_client() signs download URLs with SigV4, path-style, against the regional host, so SSE-KMS buckets no longer reject them with InvalidArgument. Without a region or a public endpoint, URLs are signed as before, and get_s3_client() is unchanged.

Fixes #12734
2026-09-22 16:12:44 +02:00
tejas_0007 09821e6328 fix(api): log Celery worker failures and restart compose services (#12465)
Declare the Celery worker, kombu, billiard and amqp loggers so fatal worker errors are no longer silenced by disable_existing_loggers, and log task failures from celery.app.trace at WARNING. Add restart: unless-stopped to the long-running services in docker-compose.yml.

Refs #12461
2026-09-22 12:23:51 +02:00
César Arroba 94899e20fd ci(ui): pass E2E AWS credentials through env vars (#12864) 2026-09-22 10:42:40 +02:00
Pedro Martín 79da676c86 chore(changelog): v5.43.0 highlights (#12839) 2026-09-22 09:22:26 +02:00
Rubén De la Torre Vico 8b35b69731 fix(api): make every endpoint resolve the same latest scan for a provider (#12858) 2026-09-21 17:38:11 +02:00
Prowler Botandprowler-bot 81d90fc31e chore(changelog): v5.43.0 (#12856)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-09-21 15:30:24 +02:00
César Arroba 8b5f1250a9 chore(changelog): reclassify FedRAMP 20x pilot removal as changed (#12855) 2026-09-21 15:26:00 +02:00
98d2db4e13 feat(aws): Update regions for AWS services (#12846)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
Co-authored-by: César Arroba <19954079+cesararroba@users.noreply.github.com>
2026-09-21 13:00:52 +02:00
César Arroba c9068515b2 fix(deps): bump anyio to 4.14.2 and accept unfixable CPython CVE-2026-82049 (#12848) 2026-09-21 12:51:41 +02:00
Alejandro Bailo 3823186914 fix(ui): hide Registry when it is unavailable to the deployment (#12847) 2026-09-21 12:27:22 +02:00
Alejandro BailoandClaude Fable 5.1 03cb59c20d feat(ui): install Registry checks artifacts on the API's verdict (#12843)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 16:04:58 +02:00
Pedro Martín d819639f0e fix(cloudflare): request every permission the checks need (#12842) 2026-09-18 13:13:30 +02:00
Pedro Martín 6c8d6994bb fix(api): sign report URLs against public storage host (#12552) 2026-09-18 10:16:13 +02:00
Pedro Martín 07d48ab15d fix(huaweicloud): remove mock data from SMN service (#12836) 2026-09-18 09:20:35 +02:00
Alejandro Bailo 5fe1a6713b revert: remove the onboarding profile step (#12818) (#12838) 2026-09-17 18:01:41 +02:00
Alejandro Bailo 61d13f078c fix(ui): hide the Registry tab in Add Provider when Registry is unavailable (#12837) 2026-09-17 17:22:21 +02:00
bbb297aee7 feat(providers/huaweicloud): add smn_topic_subscriptions check (#12186)
Co-authored-by: tomitobio <tomitobio@users.noreply.github.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-09-17 13:31:08 +02:00
Haitao Zheng d0d29108e0 fix(sdk): report all-users 2sv override failures (#12700) 2026-09-17 13:19:11 +02:00
Pedro Martín f382400037 fix(invitations): expire lapsed invitations on re-invite (#12831) 2026-09-17 12:12:14 +02:00
Alejandro BailoandClaude Opus 5 396ccf56bb feat(ui): offer an invite-your-team step before the onboarding checkpoint (#12819)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:58:53 +02:00
Alejandro BailoandClaude Opus 5 3069486549 feat: record the tenant profile declared at onboarding (#12818)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 10:03:59 +02:00
Pedro Martín 9f616a5d43 feat(ui): allow disabling self-registration in Cloud (#12815) 2026-09-16 14:00:54 +02:00
Alan Buscagliaandalejandrobailo 2198ba2d84 feat(ui): complete Registry provider onboarding for Private Cloud (#12494)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-09-16 12:21:25 +02:00
César Arroba 974f4251dd chore(trivy): suppress fast-uri CVE-2026-75931 from Teams SPDX manifest (#12823) 2026-09-16 10:29:50 +02:00
César Arroba 75c22df63b fix(azure): use the selected cloud endpoints in Defender and Key Vault (#12813) 2026-09-16 09:25:08 +02:00
César Arrobaandpedrooot 757cd44ecb fix(aws): try the rest of the partition when the bootstrap region is unreachable (#12799)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-09-16 09:24:41 +02:00
Pedro Martínandalejandrobailo c0fdd5bdf3 fix(ui): add FedRAMP 20x cross-provider mappers (#12810)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-09-15 11:07:31 +02:00
Pedro Martín 61ef44a03b fix(m365): skip defender preset policies without rules (#12809) 2026-09-15 09:56:50 +02:00
Pedro Martín 682353e054 fix(container): bump PowerShell to 7.5.11 in SDK and API (#12811) 2026-09-15 09:44:32 +02:00
Pedro Martín 3860cd3dce feat(compliance): FedRAMP 20x Class C FRR + AWS checks (#12808) 2026-09-14 16:35:05 +02:00
1b228d590b feat(compliance): replace FedRAMP 20x pilot KSI with Consolidated Rules 2026.06.24.01 (#11701)
Co-authored-by: Ethan Troy <ethanolivertroy@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-09-14 11:53:19 +02:00
Alejandro Bailo 8b265a8314 fix(ui): defer billing onboarding and add AWS button styling (#12803) 2026-09-14 11:18:31 +02:00
Pedro Martín ba258a5346 fix(container): patch high Debian CVEs in SDK and API (#12804) 2026-09-14 11:01:15 +02:00
Prowler Botandprowler-bot 282fe5b46b chore(release): Bump versions to v5.43.0 (#12797)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-09-11 13:23:08 +02:00
Pedro MartínandPepe Fagoaga c08cb65d84 chore(changelog): v5.42.0 highlights (#12795)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-09-11 12:11:44 +02:00
1468 changed files with 82502 additions and 19136 deletions

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "prowler-plugins",
"description": "Prowler Cloud Security for Claude Code",
"description": "Prowler Cloud security and compliance for Claude",
"owner": {
"name": "Prowler",
"email": "support@prowler.com"
@@ -9,7 +9,7 @@
{
"name": "prowler",
"source": "./claude_plugins/prowler",
"description": "Prowler for Claude Code — cloud security and compliance skills powered by the Prowler MCP server. Bundles compliance triage and remediation; more skills coming.",
"description": "Connect Claude to Prowler Cloud through the Prowler MCP server. Reads providers, scans, findings, compliance results and resources for AWS, Azure, GCP, Kubernetes, Microsoft 365, GitHub and the other providers listed at https://registry.prowler.com, and returns remediation guidance for failed checks. Writes are limited to Prowler Cloud itself: triggering scans, finding triage, mute rules and the mutelist, providers and integrations. Your cloud resources are not changed by the connector.",
"category": "security",
"homepage": "https://prowler.com"
}
+17 -1
View File
@@ -110,11 +110,27 @@ DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY=""
DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN=""
# The AWS region where your S3 bucket is located (e.g., "us-east-1")
# Required if the bucket uses SSE-KMS: download URLs are then signed with SigV4, which
# is scoped to this region, so it must match the bucket's
DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION=""
# The name of the S3 bucket where scan output should be stored
DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET=""
# The storage endpoint the API and Celery workers use to upload and list scan output
# (e.g. "http://minio:9000"). Leave empty on AWS S3. Set it when scan output is stored on
# S3-compatible object storage such as MinIO instead of real S3.
# If set without DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL below, report download URLs are
# signed against this internal host, and a browser outside the container network cannot open them.
DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL=""
# The storage address the browser can reach, used only to sign report download URLs
# (e.g. "https://storage.example.com"). Leave empty on AWS S3. Set it when storage is
# only reachable inside the container network, such as MinIO on "http://minio:9000".
# The reverse proxy in front of it must forward the Host header unchanged: SigV4 signs
# Host, so rewriting it to the internal name invalidates the signature.
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL=""
# Django settings
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,prowler-api
DJANGO_BIND_ADDRESS=0.0.0.0
@@ -158,7 +174,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.42.1
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.48.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+7 -7
View File
@@ -19,10 +19,10 @@
# AI
/mcp_server/ @prowler-cloud/engineering
# Platform
/.github/ @prowler-cloud/platform
/Makefile @prowler-cloud/platform
/kubernetes/ @prowler-cloud/platform
**/Dockerfile* @prowler-cloud/platform
**/docker-compose*.yml @prowler-cloud/platform
**/docker-compose*.yaml @prowler-cloud/platform
# CI and infrastructure
/.github/ @prowler-cloud/engineering
/Makefile @prowler-cloud/engineering
/kubernetes/ @prowler-cloud/engineering
**/Dockerfile* @prowler-cloud/engineering
**/docker-compose*.yml @prowler-cloud/engineering
**/docker-compose*.yaml @prowler-cloud/engineering
+11
View File
@@ -451,6 +451,17 @@ modules:
e2e:
- ui/tests/home/**
- name: ui-registry
match:
- ui/actions/registry/**
- ui/app/**/registry/**
- ui/components/registry/**
- ui/lib/registry/**
- ui/tests/registry/**
tests: []
e2e:
- ui/tests/registry/**
- name: ui-shadcn
match:
- ui/components/shadcn/**
+1
View File
@@ -42,6 +42,7 @@ jobs:
github.com:443
pypi.org:443
files.pythonhosted.org:443
release-assets.githubusercontent.com:443
api.github.com:443
raw.githubusercontent.com:443
@@ -81,6 +81,7 @@ jobs:
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
tuf-repo-cdn.sigstore.dev:443
raw.githubusercontent.com:443
objects.githubusercontent.com:443
grype.anchore.io:443
+1
View File
@@ -85,6 +85,7 @@ jobs:
github.com:443
pypi.org:443
files.pythonhosted.org:443
release-assets.githubusercontent.com:443
cli.codecov.io:443
keybase.io:443
raw.githubusercontent.com:443
+7 -4
View File
@@ -38,13 +38,16 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# PRs only need the diff range; push to master/release walks the new range from event.before.
# 50 is enough headroom for the longest realistic PR/push chain without paying for a full clone.
fetch-depth: 50
# Full history: a shallow clone cuts the PR's ancestry, git can no longer exclude commits
# already on the base branch, and TruffleHog scans the cut-off commit as a root (the whole
# tree as added). PRs that merge master in hit this and fail on pre-existing content.
fetch-depth: 0
persist-credentials: false
- name: Scan diff for secrets with TruffleHog
# Action auto-injects --since-commit/--branch from event payload; passing them in extra_args produces duplicate flags.
uses: trufflesecurity/trufflehog@37b77001d0174ebec2fcca2bd83ff83a6d45a3ab # v3.95.3
uses: trufflesecurity/trufflehog@e76dff8a7e57d6df0c60ad8cbf5d5cba505a8fc9 # v3.99.2
with:
# The action runs the `latest` image unless told otherwise: pin the scanner to the action's release.
version: 3.99.2@sha256:47a84bc18a0d04a165498bbbd3bacfd84176661cbc91f8e0f85467f6a771e99a
extra_args: --results=verified,unknown
@@ -77,6 +77,7 @@ jobs:
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
tuf-repo-cdn.sigstore.dev:443
ghcr.io:443
pkg-containers.githubusercontent.com:443
files.pythonhosted.org:443
+1
View File
@@ -49,6 +49,7 @@ jobs:
github.com:443
pypi.org:443
files.pythonhosted.org:443
release-assets.githubusercontent.com:443
cli.codecov.io:443
keybase.io:443
ingest.codecov.io:443
+28 -2
View File
@@ -84,6 +84,8 @@ jobs:
fi
- name: Checkout release branch
env:
GH_TOKEN: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
run: |
echo "Checking out branch $BRANCH_NAME for release $PROWLER_VERSION..."
if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then
@@ -92,8 +94,33 @@ jobs:
elif git show-ref --verify --quiet "refs/remotes/origin/$BRANCH_NAME"; then
echo "Branch $BRANCH_NAME exists remotely, checking out..."
git checkout -b "$BRANCH_NAME" "origin/$BRANCH_NAME"
elif [ "$PATCH_VERSION" -eq 0 ]; then
# Only cut the branch once the compiled changelogs are on master
MASTER_SHA=$(git rev-parse origin/master)
VERSION_REGEX="${PROWLER_VERSION//./\\.}"
FOUND_CHANGELOG=false
for changelog in prowler/CHANGELOG.md api/CHANGELOG.md ui/CHANGELOG.md mcp_server/CHANGELOG.md; do
if git show "origin/master:$changelog" 2>/dev/null | grep -qE "^## \[.*\(Prowler v?${VERSION_REGEX}\)"; then
FOUND_CHANGELOG=true
break
fi
done
if [ "$FOUND_CHANGELOG" != "true" ]; then
echo "ERROR: No changelog entry for Prowler $PROWLER_VERSION on master. Merge the compiled changelogs PR first."
exit 1
fi
# Check the SDK version on master before creating the remote branch
if ! git show "origin/master:pyproject.toml" | grep -qE "^version = \"${VERSION_REGEX}\"$" \
|| ! git show "origin/master:prowler/config/config.py" | grep -qE "^prowler_version = \"${VERSION_REGEX}\"$"; then
echo "ERROR: pyproject.toml or prowler/config/config.py on master is not at version $PROWLER_VERSION. Fix master before creating $BRANCH_NAME."
exit 1
fi
echo "Branch $BRANCH_NAME does not exist, creating it from master ($MASTER_SHA)..."
gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" -f ref="refs/heads/${BRANCH_NAME}" -f sha="${MASTER_SHA}"
git update-ref "refs/remotes/origin/${BRANCH_NAME}" "$MASTER_SHA"
git checkout -b "$BRANCH_NAME" "origin/$BRANCH_NAME"
else
echo "ERROR: Branch $BRANCH_NAME does not exist. For minor releases (X.Y.0), create it manually first. For patch releases (X.Y.Z), the branch should already exist."
echo "ERROR: Branch $BRANCH_NAME does not exist. For patch releases (X.Y.Z), the branch should already exist."
exit 1
fi
@@ -390,4 +417,3 @@ jobs:
if: always()
run: |
rm -f prowler_changelog.md api_changelog.md ui_changelog.md mcp_changelog.md combined_changelog.md
+27
View File
@@ -0,0 +1,27 @@
name: 'Tools: Release Freeze Lift'
run-name: 'Lift release freeze after Prowler ${{ github.event.release.tag_name }}'
on:
release:
types:
- 'published'
permissions: {}
jobs:
release-freeze-lift:
if: github.repository == 'prowler-cloud/prowler' && !github.event.release.prerelease
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: Disable release freeze
env:
GH_TOKEN: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
run: |
gh variable set RELEASE_FREEZE --body false --repo "${GITHUB_REPOSITORY}"
+1
View File
@@ -40,6 +40,7 @@ jobs:
github.com:443
pypi.org:443
files.pythonhosted.org:443
release-assets.githubusercontent.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -81,6 +81,7 @@ jobs:
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
tuf-repo-cdn.sigstore.dev:443
api.github.com:443
mirror.gcr.io:443
check.trivy.dev:443
@@ -76,7 +76,7 @@ jobs:
### Changes
This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` with the latest regions fetched from the OCI Identity API (`list_regions()`).
This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` and the matching list in `ui/lib/provider-credentials/oci-regions.ts` with the latest regions fetched from the OCI Identity API (`list_regions()`).
- Government regions (`OCI_GOVERNMENT_REGIONS`) are preserved unchanged
- DOD regions (`OCI_US_DOD_REGIONS`) are preserved unchanged
+24
View File
@@ -640,6 +640,30 @@ jobs:
flags: prowler-py${{ matrix.python-version }}-e2enetworks
files: ./e2enetworks_coverage.xml
# Huawei Cloud Provider
- name: Check if Huawei Cloud files changed
if: steps.check-changes.outputs.any_changed == 'true'
id: changed-huaweicloud
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: |
./prowler/**/huaweicloud/**
./tests/**/huaweicloud/**
./uv.lock
- name: Run Huawei Cloud tests
if: steps.changed-huaweicloud.outputs.any_changed == 'true'
run: uv run pytest -n auto --cov=./prowler/providers/huaweicloud --cov-report=xml:huaweicloud_coverage.xml tests/providers/huaweicloud
- name: Upload Huawei Cloud coverage to Codecov
if: steps.changed-huaweicloud.outputs.any_changed == 'true'
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
flags: prowler-py${{ matrix.python-version }}-huaweicloud
files: ./huaweicloud_coverage.xml
# External Provider (dynamic loading)
- name: Check if External Provider files changed
if: steps.check-changes.outputs.any_changed == 'true'
@@ -78,6 +78,7 @@ jobs:
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
tuf-repo-cdn.sigstore.dev:443
registry.npmjs.org:443
dl-cdn.alpinelinux.org:443
fonts.googleapis.com:443
+55 -46
View File
@@ -10,12 +10,13 @@ on:
- master
- "v5.*"
paths:
- '.github/workflows/ui-e2e-tests-v2.yml'
- '.github/test-impact.yml'
- 'ui/**'
- 'api/**' # API changes can affect UI E2E
- '!ui/CHANGELOG.md'
- '!api/CHANGELOG.md'
- ".github/workflows/ui-e2e-tests-v2.yml"
- ".github/workflows/test-impact-analysis.yml"
- ".github/test-impact.yml"
- "ui/**"
- "api/**" # API changes can affect UI E2E
- "!ui/CHANGELOG.md"
- "!api/CHANGELOG.md"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -38,13 +39,16 @@ jobs:
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
# ubuntu-latest is moving to 26.04 (rollout began October 19, 2026; completion
# targeted for November 19, 2026), where `playwright install` refuses to install
# Chromium for the pinned Playwright. Stay on 24.04 until the suite upgrades.
runs-on: ubuntu-24.04
env:
AUTH_SECRET: 'fallback-ci-secret-for-testing'
AUTH_SECRET: "fallback-ci-secret-for-testing"
AUTH_TRUST_HOST: true
NEXTAUTH_URL: 'http://localhost:3000'
AUTH_URL: 'http://localhost:3000'
UI_API_BASE_URL: 'http://localhost:8080/api/v1'
NEXTAUTH_URL: "http://localhost:3000"
AUTH_URL: "http://localhost:3000"
UI_API_BASE_URL: "http://localhost:8080/api/v1"
E2E_ADMIN_USER: ${{ secrets.E2E_ADMIN_USER }}
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
E2E_AWS_PROVIDER_ACCOUNT_ID: ${{ secrets.E2E_AWS_PROVIDER_ACCOUNT_ID }}
@@ -60,7 +64,7 @@ jobs:
E2E_M365_SECRET_ID: ${{ secrets.E2E_M365_SECRET_ID }}
E2E_M365_TENANT_ID: ${{ secrets.E2E_M365_TENANT_ID }}
E2E_M365_CERTIFICATE_CONTENT: ${{ secrets.E2E_M365_CERTIFICATE_CONTENT }}
E2E_KUBERNETES_CONTEXT: 'kind-kind'
E2E_KUBERNETES_CONTEXT: "kind-kind"
E2E_KUBERNETES_KUBECONFIG_PATH: /home/runner/.kube/config
E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY: ${{ secrets.E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY }}
E2E_GCP_PROJECT_ID: ${{ secrets.E2E_GCP_PROJECT_ID }}
@@ -237,8 +241,8 @@ jobs:
- name: Add AWS credentials for testing
run: |
echo "AWS_ACCESS_KEY_ID=${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }}" >> .env
echo "AWS_SECRET_ACCESS_KEY=${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }}" >> .env
echo "AWS_ACCESS_KEY_ID=${E2E_AWS_PROVIDER_ACCESS_KEY}" >> .env
echo "AWS_SECRET_ACCESS_KEY=${E2E_AWS_PROVIDER_SECRET_KEY}" >> .env
- name: Build API image from current code
# docker-compose.yml references prowlercloud/prowler-api:latest from the registry,
@@ -292,7 +296,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: 'ui/.nvmrc'
node-version-file: "ui/.nvmrc"
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
@@ -337,60 +341,65 @@ jobs:
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm run test:e2e:install
- name: Run E2E tests
- name: Run standard E2E tests
id: standard-e2e
working-directory: ./ui
run: |
if [[ "${RUN_ALL_TESTS}" == "true" ]]; then
echo "Running ALL E2E tests..."
echo "Running all standard E2E tests..."
pnpm run test:e2e
else
echo "Running targeted E2E tests: ${E2E_TEST_PATHS}"
# Convert glob patterns to playwright test paths
# e.g., "ui/tests/providers/**" -> "tests/providers"
echo "Running targeted standard E2E tests: ${E2E_TEST_PATHS}"
TEST_PATHS="${E2E_TEST_PATHS}"
# Remove ui/ prefix and convert ** to empty (playwright handles recursion)
TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u)
# Drop auth setup helpers (not runnable test suites)
TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^tests/setups/')
# Safety net: if bare "tests/" appears (from broad patterns like ui/tests/**),
# expand to specific subdirs to avoid Playwright discovering setup files
TEST_PATHS=$(echo "$TEST_PATHS" | grep -vE '^tests/(setups|registry)/' || true)
if echo "$TEST_PATHS" | grep -qx 'tests/'; then
echo "Expanding bare 'tests/' to specific subdirs (excluding setups)..."
SPECIFIC_DIRS=""
for dir in tests/*/; do
[[ "$dir" == "tests/setups/" ]] && continue
[[ "$dir" == "tests/setups/" || "$dir" == "tests/registry/" ]] && continue
SPECIFIC_DIRS="${SPECIFIC_DIRS}${dir}"$'\n'
done
# Replace "tests/" with specific dirs, keep other paths
TEST_PATHS=$(echo "$TEST_PATHS" | grep -vx 'tests/')
TEST_PATHS=$(echo "$TEST_PATHS" | grep -vx 'tests/' || true)
TEST_PATHS="${TEST_PATHS}"$'\n'"${SPECIFIC_DIRS}"
TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^$' | sort -u)
fi
if [[ -z "$TEST_PATHS" ]]; then
echo "No runnable E2E test paths after filtering setups"
exit 0
fi
# Filter out directories that don't contain any test files
VALID_PATHS=""
while IFS= read -r p; do
[[ -z "$p" ]] && continue
if find "$p" -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | head -1 | grep -q .; then
VALID_PATHS="${VALID_PATHS}${p}"$'\n'
while IFS= read -r path; do
[[ -z "$path" ]] && continue
if find "$path" -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | head -1 | grep -q .; then
VALID_PATHS="${VALID_PATHS}${path}"$'\n'
else
echo "Skipping empty test directory: $p"
echo "Skipping empty test directory: $path"
fi
done <<< "$TEST_PATHS"
VALID_PATHS=$(echo "$VALID_PATHS" | grep -v '^$' || true)
if [[ -z "$VALID_PATHS" ]]; then
echo "No test files found in any resolved paths — skipping E2E"
exit 0
if [[ -n "$VALID_PATHS" ]]; then
TEST_PATHS=$(echo "$VALID_PATHS" | tr '\n' ' ')
echo "Resolved standard test paths: $TEST_PATHS"
read -ra test_paths <<< "$TEST_PATHS"
pnpm exec playwright test "${test_paths[@]}"
else
echo "No standard E2E test paths selected."
fi
TEST_PATHS=$(echo "$VALID_PATHS" | tr '\n' ' ')
echo "Resolved test paths: $TEST_PATHS"
read -ra test_paths <<< "$TEST_PATHS"
pnpm exec playwright test "${test_paths[@]}"
fi
- name: Run Registry fixture E2E tests
if: |
!cancelled() &&
(steps.standard-e2e.outcome == 'success' || steps.standard-e2e.outcome == 'failure') &&
(env.RUN_ALL_TESTS == 'true' || contains(format(' {0} ', env.E2E_TEST_PATHS), ' ui/tests/registry/'))
working-directory: ./ui
run: pnpm run test:e2e:registry
# A worker that crashes on import or a connection test that never resolves
# only shows up here; Playwright just reports a missing footer button.
- name: Dump API service logs
if: failure()
run: docker compose logs --no-color --tail=300 api worker worker-beat || true
- name: Upload test reports
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: failure()
+4 -5
View File
@@ -21,7 +21,10 @@ permissions: {}
jobs:
ui-tests:
runs-on: ubuntu-latest
# ubuntu-latest is moving to 26.04 (rollout began October 19, 2026; completion
# targeted for November 19, 2026), where `playwright install` refuses to install
# Chromium for the pinned Playwright. Stay on 24.04 until the suite upgrades.
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
@@ -136,10 +139,6 @@ jobs:
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run healthcheck
- name: Check product-tour alignment
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run tour:check
- name: Run pnpm audit
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run audit
+30 -54
View File
@@ -7,66 +7,19 @@
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
# CVE-2026-84304 is the same temporary exception documented in .trivyignore.yaml:
# Trivy 0.74.0 still embeds grpc 1.82.1, while the 1.83.1 fix is not in any release.
# Prowler only runs `trivy image` / `trivy fs`, never client/server mode, so no gRPC
# endpoint exists in the image. Pinned to the embedded version so the rule stops
# matching on its own once Trivy bumps grpc. Remove with the Trivy exception by 2026-10-15.
# https://github.com/aquasecurity/trivy/pull/11176
- vulnerability: CVE-2026-84304
package:
name: google.golang.org/grpc
version: v1.82.1
# CVE-2026-84445 is the same temporary exception documented in .trivyignore.yaml:
# Trivy 0.74.0 still embeds grpc 1.82.1, while the 1.82.2 / 1.83.2 fix is not in any
# release. The panic needs a gRPC server built with `xds.NewGRPCServer()`; Prowler only
# runs `trivy image` / `trivy fs`, so the image serves no gRPC at all. Pinned to the
# embedded version so the rule stops matching on its own once Trivy bumps grpc. Remove
# with the Trivy exception by 2026-10-15.
# https://github.com/advisories/GHSA-2v4p-qf9q-27wj
- vulnerability: CVE-2026-84445
package:
name: google.golang.org/grpc
version: v1.82.1
# CVE-2026-56855 / CVE-2026-78662 are the same temporary exception documented in
# .trivyignore.yaml: Trivy 0.74.0 still embeds golang.org/x/crypto v0.55.0, while the
# 0.56.0 fix (published 2026-09-02) hasn't reached any Trivy release, or even Trivy
# main, yet. Pinned to the embedded version so the rule stops matching on its own once
# Trivy bumps it. Remove with the Trivy exception by 2026-10-15.
- vulnerability: CVE-2026-56855
package:
name: golang.org/x/crypto
version: v0.55.0
- vulnerability: CVE-2026-78662
package:
name: golang.org/x/crypto
version: v0.55.0
- 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
# Documented in .trivyignore.yaml: ships inside ExchangeOnlineManagement
# 3.10.1, the latest gallery release. Re-check on the next EXO release.
- vulnerability: CVE-2026-84008
package:
name: System.Security.Cryptography.Pkcs
version: 10.0.0
# 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
@@ -126,3 +79,26 @@ ignore:
- vulnerability: CVE-2026-82049
package:
name: python
# zlib in the Alpine base image of the MCP (python:3.13.16-alpine3.23) container.
# CVE-2026-85091 is a heap overflow in gz_vacate() reached only through non-blocking
# gzwrite() followed by gzprintf() after a write stall; the MCP server never writes
# gzip files, so the image never calls that path. The UI image no longer needs this:
# node:24.22.1-alpine is built on Alpine 3.24 and ships 1.3.2-r1. TEMPORARY: the fix
# landed in the 3.23 index on 2026-10-06, two days after the pinned python image was
# built, and could be taken with `apk add --upgrade`, but we wait for the base image
# to ship it instead (python:3.13.17-alpine3.23, built 2026-10-22, already does).
# Pinned to that version so the rule stops matching on its own once the base image
# moves forward. Remove when the MCP digest is bumped, by 2026-11-07.
# https://security.alpinelinux.org/vuln/CVE-2026-85091
- vulnerability: CVE-2026-85091
package:
name: zlib
version: 1.3.2-r0
# Documented in .trivyignore.yaml: go-jose 4.1.5 compiled into the Trivy binary (0.76.1,
# the latest release). Pinned to 4.1.5 so it stops matching with the next Trivy bump.
- vulnerability: CVE-2026-116587
package:
name: github.com/go-jose/go-jose/v4
version: v4.1.5
+98 -70
View File
@@ -68,11 +68,18 @@ vulnerabilities:
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
# (Modules/MicrosoftTeams/8.0.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.
# proxy-addr 2.0.7 (CVE-2026-90711) is one more such declaration: no file named proxy-addr
# exists in the image, only its purl inside that manifest. The UI lock lists proxy-addr 2.0.7
# through express, but the Next standalone output does not ship it, so the shared entry
# hides nothing real in the UI image either (checked on prowlercloud/prowler-ui:latest).
# @modelcontextprotocol/sdk 1.27.1 (CVE-2026-104850) is another: only its purl is in that
# manifest. The entry is pinned to 1.27.1 because the UI really depends on the SDK (1.26.0
# via @langchain/mcp-adapters), and a version-less purl would hide that finding too.
- id: CVE-2020-0606
purls:
- "pkg:nuget/Microsoft.WindowsDesktop.App.Ref"
@@ -101,6 +108,23 @@ vulnerabilities:
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
# Same manifest, pinned to 8.0.4: PowerShell's own copy is also flagged by these CVEs.
- id: CVE-2026-96222
purls:
- "pkg:nuget/System.Security.Cryptography.Xml@8.0.4"
expired_at: 2027-01-31
- id: CVE-2026-96958
purls:
- "pkg:nuget/System.Security.Cryptography.Xml@8.0.4"
expired_at: 2027-01-31
- id: CVE-2026-96959
purls:
- "pkg:nuget/System.Security.Cryptography.Xml@8.0.4"
expired_at: 2027-01-31
- id: CVE-2026-96960
purls:
- "pkg:nuget/System.Security.Cryptography.Xml@8.0.4"
expired_at: 2027-01-31
- id: CVE-2026-13676
purls:
- "pkg:npm/fast-uri"
@@ -129,80 +153,84 @@ vulnerabilities:
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-84292
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-69192
purls:
- "pkg:npm/ip-address"
expired_at: 2027-01-31
- id: CVE-2026-101898
purls:
- "pkg:npm/axios"
expired_at: 2027-01-31
- id: CVE-2026-101901
purls:
- "pkg:npm/axios"
expired_at: 2027-01-31
- id: CVE-2026-101903
purls:
- "pkg:npm/axios"
expired_at: 2027-01-31
- id: CVE-2026-101905
purls:
- "pkg:npm/axios"
expired_at: 2027-01-31
- id: CVE-2026-101906
purls:
- "pkg:npm/axios"
expired_at: 2027-01-31
- id: CVE-2026-101907
purls:
- "pkg:npm/axios"
expired_at: 2027-01-31
- id: CVE-2026-101909
purls:
- "pkg:npm/axios"
expired_at: 2027-01-31
- id: CVE-2026-90711
purls:
- "pkg:npm/proxy-addr"
expired_at: 2027-01-31
- id: CVE-2026-104850
purls:
- "pkg:npm/%40modelcontextprotocol%2Fsdk@1.27.1"
expired_at: 2027-01-31
# Two more SDK declarations and follow-redirects 1.16.0 from the same manifest. The SDK
# entries stay pinned to 1.27.1 for the reason above. No lock in this repo lists
# follow-redirects, so its pin only keeps a future real dependency from being hidden.
- id: CVE-2026-111767
purls:
- "pkg:npm/%40modelcontextprotocol%2Fsdk@1.27.1"
expired_at: 2027-01-31
- id: CVE-2026-111768
purls:
- "pkg:npm/%40modelcontextprotocol%2Fsdk@1.27.1"
expired_at: 2027-01-31
- id: CVE-2026-111755
purls:
- "pkg:npm/follow-redirects@1.16.0"
expired_at: 2027-01-31
- id: CVE-2026-111756
purls:
- "pkg:npm/follow-redirects@1.16.0"
expired_at: 2027-01-31
# CVE-2026-84304 is a DoS in grpc-go <= 1.83.0: a peer fragments a gRPC stream into
# millions of tiny HTTP/2 DATA frames until the receiver runs out of heap. Fixed in
# 1.83.1 (published 2026-09-01). Trivy 0.74.0, the latest published release and the
# version the images ship, pins 1.82.1 as an indirect dependency:
# https://github.com/aquasecurity/trivy/blob/v0.74.0/go.mod
# Upstream bump still open: https://github.com/aquasecurity/trivy/pull/11176
# Trivy only speaks gRPC in client/server mode (`trivy server`, `--server`). Prowler
# invokes it exclusively as `trivy image` and `trivy fs` on a local path, so no gRPC
# listener or connection ever exists in the image and the affected path is not
# reachable. Remove this temporary suppression as soon as a Trivy release pins
# grpc >= 1.83.1.
- id: CVE-2026-84304
# Unlike the manifest entries above, this DLL ships in the image:
# ExchangeOnlineManagement/3.10.1/netCore/System.Security.Cryptography.Pkcs.dll. It is a
# DoS in CMS/PKCS parsing; Prowler only feeds it the tenant's own certificate. 3.10.1 is
# the latest gallery release, so only Microsoft can update it. Re-check on the next EXO
# release.
- id: CVE-2026-84008
purls:
- "pkg:golang/google.golang.org/grpc"
expired_at: 2026-10-15
- "pkg:nuget/System.Security.Cryptography.Pkcs@10.0.0"
expired_at: 2026-12-15
# CVE-2026-84445 is a DoS in grpc-go servers built with `xds.NewGRPCServer()`: a request
# carrying neither `:authority` nor `Host` reaches the xDS routing interceptor, which
# indexes an empty slice of authorities and panics. The per-RPC goroutine does not
# recover, so the whole server process dies. Fixed in 1.82.2 and 1.83.2 (published
# 2026-09-08). Trivy 0.74.0, the latest published release and the version the images
# ship, pins 1.82.1 as an indirect dependency:
# https://github.com/aquasecurity/trivy/blob/v0.74.0/go.mod
# Trivy main already carries 1.83.2, but no published release includes it yet.
# The reachability argument is the one made for CVE-2026-84304 above, only narrower:
# this panic needs an xDS-managed gRPC server. Prowler invokes Trivy exclusively as
# `trivy image` and `trivy fs` on a local path, never `trivy server`, so the image runs
# no gRPC server at all, xDS or otherwise. Remove this temporary suppression as soon as
# a Trivy release pins grpc >= 1.83.2.
# https://github.com/advisories/GHSA-2v4p-qf9q-27wj
- id: CVE-2026-84445
# go-jose 4.1.5 is compiled into the Trivy binary (usr/local/bin/trivy). Trivy merged the
# 4.1.6 bump upstream (aquasecurity/trivy#11488) but 0.76.1 is still the latest release.
# Bump TRIVY_VERSION and drop this entry once a release ships it.
- id: CVE-2026-116587
purls:
- "pkg:golang/google.golang.org/grpc@v1.82.1"
expired_at: 2026-10-15
# CVE-2026-56855 and CVE-2026-78662 are DoS deadlocks in x/crypto/ssh: a malicious peer
# can flood or misuse channel messages (RFC 4254) to block the whole connection.
# Fixed in golang.org/x/crypto v0.56.0 (published 2026-09-02). Trivy 0.74.0, the latest
# published release and the version the images ship, still pins v0.55.0, and Trivy main
# has not bumped it either:
# https://github.com/aquasecurity/trivy/blob/v0.74.0/go.mod
# x/crypto/ssh is pulled in transitively through go-git's ssh transport, the same
# dependency chain as the CVE-2026-71556 entry above. Prowler invokes Trivy only with
# `fs` on an existing local path or with `image`; it never asks Trivy to clone over SSH
# or to run `trivy server`, so no SSH connection -- as client or server -- ever exists in
# the image and the affected code path is not reachable. Remove this temporary
# suppression as soon as a fixed Trivy release is available.
- id: CVE-2026-56855
purls:
- "pkg:golang/golang.org/x/crypto@v0.55.0"
expired_at: 2026-10-15
- id: CVE-2026-78662
purls:
- "pkg:golang/golang.org/x/crypto@v0.55.0"
expired_at: 2026-10-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
- "pkg:golang/github.com/go-jose/go-jose/v4@v4.1.5"
expired_at: 2026-11-20
-6
View File
@@ -51,7 +51,6 @@ Use these skills for detailed patterns on-demand:
| `django-migration-psql` | Django migration best practices for PostgreSQL | [SKILL.md](skills/django-migration-psql/SKILL.md) |
| `postgresql-indexing` | PostgreSQL indexing, EXPLAIN, monitoring, maintenance | [SKILL.md](skills/postgresql-indexing/SKILL.md) |
| `prowler-attack-paths-query` | Create Attack Paths openCypher queries | [SKILL.md](skills/prowler-attack-paths-query/SKILL.md) |
| `prowler-tour` | Keep product-tour definitions aligned with the UI | [SKILL.md](skills/prowler-tour/SKILL.md) |
| `gh-aw` | GitHub Agentic Workflows (gh-aw) | [SKILL.md](skills/gh-aw/SKILL.md) |
| `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) |
@@ -69,12 +68,10 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Adding new providers | `prowler-provider` |
| Adding privilege escalation detection queries | `prowler-attack-paths-query` |
| Adding services to existing providers | `prowler-provider` |
| Adding, updating, or removing a tour definition (*.tour.ts) | `prowler-tour` |
| After creating/modifying a skill | `skill-sync` |
| App Router / Server Actions | `nextjs-16` |
| Auditing check-to-requirement mappings as a cloud auditor | `prowler-compliance` |
| Building AI chat features | `ai-sdk-5` |
| Changing button labels or section headings on a tour-covered page | `prowler-tour` |
| Committing changes | `prowler-commit` |
| Configuring MCP servers in agentic workflows | `gh-aw` |
| Create PR that requires changelog entry | `prowler-changelog` |
@@ -94,7 +91,6 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Creating/updating compliance frameworks | `prowler-compliance` |
| Debug why a GitHub Actions job is failing | `prowler-ci` |
| Debugging gh-aw compilation errors | `gh-aw` |
| Editing a UI file containing data-tour-id attributes | `prowler-tour` |
| Fill .github/pull_request_template.md (Context/Description/Steps to review/Checklist) | `prowler-pr` |
| Fixing bug | `tdd` |
| Fixing compliance JSON bugs (duplicate IDs, empty Section, stale refs) | `prowler-compliance` |
@@ -111,8 +107,6 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` |
| Refactoring code | `tdd` |
| Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` |
| Renaming or removing a data-tour-id attribute value | `prowler-tour` |
| Restructuring routes or layouts covered by a tour | `prowler-tour` |
| Review PR requirements: template, title conventions, changelog gate | `prowler-pr` |
| Review changelog format and conventions | `prowler-changelog` |
| Reviewing JSON:API compliance | `jsonapi` |
+14 -11
View File
@@ -1,24 +1,24 @@
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS build
FROM python:3.12.16-slim-trixie@sha256:8523d6abeb48ceae0b7240d30c5808fc26cb79c1a635bacdf19cf3baecfbec07 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.11
ARG POWERSHELL_VERSION=7.5.12
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.74.0
ARG TRIVY_VERSION=0.76.1
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=82a8b13d92b0f3ae48e56cf2f3f7961679371736ca90145ca71617c2913ba9d8
ARG POWERSHELL_SHA256_ARM64=830ebda118c731ece3fa7e6b7e8573a21346387cbbca5b2f5e3b9bfe24f96672
ARG TRIVY_SHA256_AMD64=c7cbe3137fb8a48a17574bede3dd6beb16742d1e9772929940fc90082c39d9c0
ARG TRIVY_SHA256_ARM64=e174dff2a6dba5e3ae41f8d0537a6324405bd08132497340304c6b20e0f16a1f
ARG POWERSHELL_SHA256_AMD64=db85ae1f45005ffd20e456890585167257151fa99406ccd7ab344f7cf3a766fd
ARG POWERSHELL_SHA256_ARM64=29eb382cd510e4bd246902650ff6cf9f3b56f959d1bd0be75e0a1e425177c831
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
@@ -30,6 +30,7 @@ ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed9
# perl-base 5.40.1-6+deb13u1 CVE-2026-42497, -48962, -57432
# libssh2-1t64 1.11.1-1+deb13u2 CVE-2026-58050
# libpcre2-8-0 10.46-1~deb13u2 CVE-2026-86145, -89161
# libbrotli1 1.1.0-2+deb13u1 CVE-2026-111497
# Taken as a targeted --only-upgrade rather than by moving the digest: the newest
# published python:3.12-slim-trixie carries the same vulnerable versions. The three
# openssl packages are flagged separately, so all are named.
@@ -40,7 +41,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential pkg-config libzstd-dev zlib1g-dev \
&& apt-get install -y --no-install-recommends --only-upgrade \
util-linux libssl3t64 openssl openssl-provider-legacy \
libsqlite3-0 gzip perl-base libssh2-1t64 libpcre2-8-0 \
libsqlite3-0 gzip perl-base libssh2-1t64 libpcre2-8-0 libbrotli1 \
&& rm -rf /var/lib/apt/lists/*
# Install PowerShell
@@ -121,13 +122,15 @@ 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.12.0
pip install --no-cache-dir uv==0.13.4
RUN uv sync --locked --compile-bytecode && \
rm -rf ~/.cache/uv
# Install PowerShell modules
RUN .venv/bin/python prowler/providers/m365/lib/powershell/m365_powershell.py
# Install PowerShell modules. Drop the MicrosoftTeams build SBOM: Trivy reports the npm
# tooling it declares as installed, though none ships (see .trivyignore.yaml).
RUN .venv/bin/python prowler/providers/m365/lib/powershell/m365_powershell.py && \
rm -rf "${HOME}"/.local/share/powershell/Modules/MicrosoftTeams/*/_manifest
USER root
+5 -5
View File
@@ -126,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 | 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 |
| AWS | 662 | 86 | 50 | 19 | Official | UI, API, CLI |
| Azure | 191 | 22 | 25 | 16 | Official | UI, API, CLI |
| GCP | 110 | 20 | 22 | 12 | Official | UI, API, CLI |
| Kubernetes | 92 | 7 | 11 | 11 | Official | UI, API, CLI |
| GitHub | 24 | 3 | 2 | 5 | Official | UI, API, CLI |
| M365 | 143 | 10 | 6 | 10 | Official | UI, API, CLI |
| M365 | 144 | 10 | 9 | 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 |
+92
View File
@@ -4,6 +4,98 @@ All notable changes to the **Prowler API** are documented in this file.
<!-- changelog: release notes start -->
## [1.48.0] (Prowler v5.47.0)
### 🔐 Security
- pymongo to 4.18.3, dnspython to 2.9.0 and oauthlib to 4.0.0 in the API container image, with django-allauth from 65.15.0 to 65.19.7 because earlier releases cap oauthlib below 4, patching the BSON decoding and DNS compression pointer denial of service advisories and the OAuth device flow token disclosure [(#13185)](https://github.com/prowler-cloud/prowler/pull/13185)
---
## [1.47.0] (Prowler v5.46.0)
### 🐞 Fixed
- Compliance report PDF cover table wraps every value, so long account IDs such as OCI tenancy OCIDs no longer overflow their cell [(#13063)](https://github.com/prowler-cloud/prowler/pull/13063)
- Scan ingestion compares the serialized resource metadata with the stored value and serializes sets in a stable order, so unchanged resources no longer get their metadata, details and updated_at rewritten on every scan [(#13148)](https://github.com/prowler-cloud/prowler/pull/13148)
- Report generation shares the parsed resource metadata between the findings of a batch and releases each batch before building the next one [(#13149)](https://github.com/prowler-cloud/prowler/pull/13149)
### 🔐 Security
- `GET /resources/metadata`, `GET /resources/metadata/latest`, `GET /findings/metadata` and `GET /findings/metadata/latest` now honour the provider groups of the caller role instead of aggregating every provider in the tenant [(#12964)](https://github.com/prowler-cloud/prowler/pull/12964)
- Trivy from 0.75.0 to 0.76.0 in the API container image, built with Go 1.27.2 and x/net 0.60.0, patching the HTTP/2 and TLS CVEs bundled in Trivy 0.75.0 [(#13064)](https://github.com/prowler-cloud/prowler/pull/13064)
- Pillow from 12.3.0 to 13.0.0, certifi to 2026.10.19, aiohttp to 3.14.5 with yarl 1.25.1, uv to 0.13.4 and Trivy to 0.76.1 in the API container image, patching the Pillow JPEG decoder heap overflow, the compromised emSign root CAs bundled in certifi and uv, the aiohttp resource exhaustion advisories and the Go 1.27.2 stdlib CVE in the Trivy binary [(#13107)](https://github.com/prowler-cloud/prowler/pull/13107)
- Attack Paths custom queries strip block comments before the blocked-operation check and drop APOC virtual nodes and relationships from results, so `LOAD CSV` can no longer be split with a comment nor its response returned under a forged provider label [(#13120)](https://github.com/prowler-cloud/prowler/pull/13120)
- SAML SSO configuration endpoints require the `manage_account` permission instead of `manage_integrations`, since the configured IdP controls who signs in and which role they receive [(#13121)](https://github.com/prowler-cloud/prowler/pull/13121)
- djangorestframework from 3.15.2 to 3.18.3 in the API, patching the JSON parser charset and Accept header denial of service and the TokenProxy revocation bypass, and the API container image no longer ships the MicrosoftTeams build SBOM that Trivy read as installed zod and body-parser [(#13157)](https://github.com/prowler-cloud/prowler/pull/13157)
---
## [1.46.0] (Prowler v5.45.0)
### 🐞 Fixed
- OCI provider secrets keep the region as home region for credential validation and scans, instead of always using us-ashburn-1 [(#12865)](https://github.com/prowler-cloud/prowler/pull/12865)
- Gunicorn workers are now recycled after 10000 requests (plus up to 1000 of jitter) to cap memory growth, configurable with `GUNICORN_MAX_REQUESTS` and `GUNICORN_MAX_REQUESTS_JITTER` [(#13047)](https://github.com/prowler-cloud/prowler/pull/13047)
### 🔐 Security
- `pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9 [(#12911)](https://github.com/prowler-cloud/prowler/pull/12911)
- Sentry error events no longer include user identity, IP addresses, cookies, headers or request bodies, which could contain personal data or provider credentials [(#12912)](https://github.com/prowler-cloud/prowler/pull/12912)
- Kubernetes kubeconfig validation accepts only inline credentials and known cluster fields, rejecting file-path fields (`tokenFile`, `client-certificate`, `client-key`, `certificate-authority`), command-based authentication (`exec`, `cmd-path`), `auth-provider` directives, proxy URLs and unknown user or cluster keys; stored kubeconfigs are re-validated before each scan and connection test [(#12952)](https://github.com/prowler-cloud/prowler/pull/12952)
- `django` from 5.1.15 to 5.2.17, patching CVE-2026-15307 [(#12953)](https://github.com/prowler-cloud/prowler/pull/12953)
- `dulwich` from 1.2.5 to 1.2.9, patching GHSA-5fqc-mrg8-w798, GHSA-8mcx-5rqc-vhmf, GHSA-8w8g-wq8h-fq33 and GHSA-cm62-gvxx-vmxx [(#12953)](https://github.com/prowler-cloud/prowler/pull/12953)
- `urllib3` from 2.7.0 to 2.8.0, patching CVE-2026-97687 and CVE-2026-97689 [(#12953)](https://github.com/prowler-cloud/prowler/pull/12953)
- `pymongo` from 4.15.1 to 4.18.2, patching CVE-2026-96748 and CVE-2026-96749 [(#12955)](https://github.com/prowler-cloud/prowler/pull/12955)
- `GET /api/v1/users/{id}` no longer exposes another user's memberships, roles or tenant names from tenants other than the one in the request token [(#12965)](https://github.com/prowler-cloud/prowler/pull/12965)
- Python base image from 3.12.13 to 3.12.15 and Trivy from 0.74.0 to 0.75.0 in the API container image, patching CVE-2026-19445, CVE-2026-19553 and the grpc-go and x/crypto CVEs bundled in Trivy [(#13009)](https://github.com/prowler-cloud/prowler/pull/13009)
- PowerShell from 7.5.11 to 7.5.12 in the API container image, bundling .NET runtime 9.0.21 [(#13037)](https://github.com/prowler-cloud/prowler/pull/13037)
---
## [1.45.0] (Prowler v5.44.0)
### 🚀 Added
- Scan output uploads and downloads can now target S3-compatible object storage such as MinIO directly via `DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL`, instead of relying on process-wide AWS environment variables that also hijacked unrelated AWS API calls [(#12871)](https://github.com/prowler-cloud/prowler/pull/12871)
### 🔄 Changed
- Unify how every endpoint resolves a provider latest completed scan, so overlapping scans no longer make findings, compliance and mute rules read from different scans [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858)
### 🐞 Fixed
- Celery loggers are now declared explicitly in `custom_logging.py` so fatal worker errors are no longer silenced by `disable_existing_loggers=True`. All long-running services in `docker-compose.yml` now have `restart: unless-stopped` so containers recover automatically after unexpected crashes. [(#12465)](https://github.com/prowler-cloud/prowler/pull/12465)
- Scan report downloads from an S3 bucket with default SSE-KMS encryption no longer fail with an `InvalidArgument` error: when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is set, presigned download URLs are signed with AWS Signature Version 4 for that region [(#12746)](https://github.com/prowler-cloud/prowler/pull/12746)
- Adds a periodic sweep that drops orphaned Attack Paths temp Neo4j scan databases left behind when a worker or Neo4j crashes mid-scan, before they accumulate unbounded [(#12832)](https://github.com/prowler-cloud/prowler/pull/12832)
- Providers whose most recent completed scan has no `completed_at` timestamp are no longer missing from every endpoint that reports a provider's latest scan, which now falls back to scan creation order instead of skipping the provider [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858)
- Resources no longer keep a stale failed findings count forever when a scoped or imported scan for the same provider completes after a full scan, which used to make the full scan skip its own cleanup [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858)
- `POST /api/v1/scans` again returns the new scan id in the response `task_args`, which had been empty since the scan broker publish moved to transaction commit [(#12878)](https://github.com/prowler-cloud/prowler/pull/12878)
- API key authentication no longer locks the key row on every request and now throttles `last_used_at` updates to once per 60 seconds, preventing a hot key from serializing all its requests onto a single locked row [(#12882)](https://github.com/prowler-cloud/prowler/pull/12882)
- Provider deletion no longer fails when the provider has Attack Paths scans recorded on a sink that is no longer configured, such as Neptune after moving back to Neo4j [(#12894)](https://github.com/prowler-cloud/prowler/pull/12894)
### 🔐 Security
- `DELETE /api/v1/tasks/{id}` requires the permission of the operation that queued the task and rejects provider deletions, and `GET /api/v1/tasks` hides tasks of providers outside the visibility of the role [(#12893)](https://github.com/prowler-cloud/prowler/pull/12893)
---
## [1.44.0] (Prowler v5.43.0)
### 🐞 Fixed
- Report download URLs can be signed against a browser-reachable storage host via `DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL`, so downloads complete on deployments where storage is only reachable inside the container network [(#12552)](https://github.com/prowler-cloud/prowler/pull/12552)
- A scan report download no longer fails with a server error when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is unset, which is common on storage with no meaningful region [(#12552)](https://github.com/prowler-cloud/prowler/pull/12552)
- Lapsed pending invitations are reported as expired and no longer block a new invitation for the same email [(#12831)](https://github.com/prowler-cloud/prowler/pull/12831)
### 🔐 Security
- `libsqlite3-0`, `gzip`, `perl-base` and `libpcre2-8-0` upgraded in the API container image, patching high Debian CVEs [(#12804)](https://github.com/prowler-cloud/prowler/pull/12804)
- PowerShell from 7.5.9 to 7.5.11 in the API container image, bundling .NET runtime 9.0.20 and patching CVE-2026-62901 [(#12811)](https://github.com/prowler-cloud/prowler/pull/12811)
- Bumped `anyio` to 4.14.2 to resolve CVE-2026-63374 [(#12848)](https://github.com/prowler-cloud/prowler/pull/12848)
---
## [1.43.0] (Prowler v5.42.0)
### 🔄 Changed
+12 -10
View File
@@ -1,23 +1,23 @@
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS build
FROM python:3.12.16-slim-trixie@sha256:8523d6abeb48ceae0b7240d30c5808fc26cb79c1a635bacdf19cf3baecfbec07 AS build
LABEL maintainer="https://github.com/prowler-cloud/api"
ARG POWERSHELL_VERSION=7.5.11
ARG POWERSHELL_VERSION=7.5.12
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.74.0
ARG TRIVY_VERSION=0.76.1
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=82a8b13d92b0f3ae48e56cf2f3f7961679371736ca90145ca71617c2913ba9d8
ARG POWERSHELL_SHA256_ARM64=830ebda118c731ece3fa7e6b7e8573a21346387cbbca5b2f5e3b9bfe24f96672
ARG TRIVY_SHA256_AMD64=c7cbe3137fb8a48a17574bede3dd6beb16742d1e9772929940fc90082c39d9c0
ARG TRIVY_SHA256_ARM64=e174dff2a6dba5e3ae41f8d0537a6324405bd08132497340304c6b20e0f16a1f
ARG POWERSHELL_SHA256_AMD64=db85ae1f45005ffd20e456890585167257151fa99406ccd7ab344f7cf3a766fd
ARG POWERSHELL_SHA256_ARM64=29eb382cd510e4bd246902650ff6cf9f3b56f959d1bd0be75e0a1e425177c831
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
@@ -125,7 +125,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.12.0
pip install --no-cache-dir uv==0.13.4
ENV PATH="/home/prowler/.local/bin:$PATH"
@@ -134,8 +134,10 @@ RUN uv sync --locked --no-install-project && \
rm -rf ~/.cache/uv
# Invoked as a module so the base image's Python minor version is not baked
# into a site-packages path.
RUN .venv/bin/python -m prowler.providers.m365.lib.powershell.m365_powershell
# into a site-packages path. Drop the MicrosoftTeams build SBOM: Trivy reports the npm
# tooling it declares as installed, though none ships (see .trivyignore.yaml).
RUN .venv/bin/python -m prowler.providers.m365.lib.powershell.m365_powershell && \
rm -rf /home/prowler/.local/share/powershell/Modules/MicrosoftTeams/*/_manifest
USER root
@@ -1 +0,0 @@
Bumped `anyio` to 4.14.2 to resolve CVE-2026-63374
@@ -1 +0,0 @@
`libsqlite3-0`, `gzip`, `perl-base` and `libpcre2-8-0` upgraded in the API container image, patching high Debian CVEs
@@ -1 +0,0 @@
PowerShell from 7.5.9 to 7.5.11 in the API container image, bundling .NET runtime 9.0.20 and patching CVE-2026-62901
@@ -0,0 +1 @@
Python base image from 3.12.15 to 3.12.16 in the API container image, patching CVE-2026-115557
@@ -1 +0,0 @@
Lapsed pending invitations are reported as expired and no longer block a new invitation for the same email
+26 -24
View File
@@ -25,8 +25,8 @@ authors = [{name = "Prowler Engineering", email = "engineering@prowler.com"}]
dependencies = [
"celery (==5.6.2)",
"dj-rest-auth[with_social,jwt] (==7.0.1)",
"django (==5.1.15)",
"django-allauth[saml] (==65.15.0)",
"django (==5.2.17)",
"django-allauth[saml] (==65.19.7)",
"django-celery-beat (==2.9.0)",
"django-celery-results (==2.6.0)",
"django-cors-headers==4.4.0",
@@ -34,7 +34,7 @@ dependencies = [
"django-filter==24.3",
"django-guid==3.5.0",
"django-postgres-extra (==2.0.9)",
"djangorestframework==3.15.2",
"djangorestframework==3.18.3",
"djangorestframework-jsonapi==7.0.2",
"djangorestframework-simplejwt (==5.5.1)",
"drf-nested-routers (==0.95.0)",
@@ -45,7 +45,7 @@ dependencies = [
"gunicorn==26.0.0",
"uvloop==0.22.1",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.42",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.43.1"
version = "1.49.0"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
@@ -100,7 +100,7 @@ constraint-dependencies = [
"aiobotocore==2.25.1",
"aiofiles==24.1.0",
"aiohappyeyeballs==2.6.1",
"aiohttp==3.14.3",
"aiohttp==3.14.5",
"aioitertools==0.13.0",
"aiosignal==1.4.0",
"alibabacloud-actiontrail20200706==2.4.1",
@@ -196,7 +196,7 @@ constraint-dependencies = [
"botocore==1.40.61",
"cartography==0.138.1",
"celery==5.6.2",
"certifi==2026.1.4",
"certifi==2026.10.19",
"cffi==2.0.0",
"charset-normalizer==3.4.4",
"circuitbreaker==2.1.3",
@@ -222,8 +222,8 @@ constraint-dependencies = [
"dill==0.4.1",
"distro==1.9.0",
"dj-rest-auth==7.0.1",
"django==5.1.15",
"django-allauth==65.15.0",
"django==5.2.17",
"django-allauth==65.19.7",
"django-celery-beat==2.9.0",
"django-celery-results==2.6.0",
"django-cors-headers==4.4.0",
@@ -234,10 +234,10 @@ constraint-dependencies = [
"django-postgres-extra==2.0.9",
"django-silk==5.3.2",
"django-timezone-field==7.2.1",
"djangorestframework==3.15.2",
"djangorestframework==3.18.3",
"djangorestframework-jsonapi==7.0.2",
"djangorestframework-simplejwt==5.5.1",
"dnspython==2.8.0",
"dnspython==2.9.0",
"docker==7.1.0",
"dogpile-cache==1.5.0",
"dparse==0.6.4",
@@ -246,7 +246,7 @@ constraint-dependencies = [
"drf-simple-apikey==2.2.1",
"drf-spectacular==0.27.2",
"drf-spectacular-jsonapi==0.5.1",
"dulwich==1.2.5",
"dulwich==1.2.9",
"duo-client==5.5.0",
"durationpy==0.10",
"email-validator==2.2.0",
@@ -337,7 +337,7 @@ constraint-dependencies = [
"nest-asyncio==1.6.0",
"nltk==3.9.4",
"numpy==2.2.6",
"oauthlib==3.3.1",
"oauthlib==4.0.0",
"oci==2.184.1",
"openai==1.109.1",
"openstacksdk==4.2.0",
@@ -350,7 +350,7 @@ constraint-dependencies = [
"pagerduty==6.1.0",
"pandas==2.2.3",
"pbr==7.0.3",
"pillow==12.3.0",
"pillow==13.0.0",
"pkginfo==1.12.1.2",
"platformdirs==4.5.1",
"plotly==6.5.2",
@@ -375,7 +375,7 @@ constraint-dependencies = [
"pydantic-core==2.41.5",
"pygithub==2.8.0",
"pygments==2.20.0",
"pyjwt==2.13.0",
"pyjwt==2.14.0",
"pylint==3.2.5",
"pymsalruntime==0.18.1",
"pynacl==1.6.2",
@@ -439,7 +439,7 @@ constraint-dependencies = [
"tzdata==2025.3",
"tzlocal==5.3.1",
"uritemplate==4.2.0",
"urllib3==2.7.0",
"urllib3==2.8.0",
"uuid6==2024.7.10",
"uvicorn==0.49.0",
"uvloop==0.22.1",
@@ -453,7 +453,7 @@ constraint-dependencies = [
"xlsxwriter==3.2.9",
"xmlsec==1.3.17",
"xmltodict==1.0.2",
"yarl==1.22.0",
"yarl==1.25.1",
"zipp==3.23.0",
"zope-event==6.1",
"zope-interface==8.2",
@@ -475,15 +475,17 @@ constraint-dependencies = [
# 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
# patches PYSEC-2026-179 (HMAC/JWK key-confusion); a constraint cannot satisfy these
# against the SDK's hard pins, so override them to the patched versions until the SDK
# bump propagates to the pinned master rev. pyjwt keeps the [crypto] extra because an
# The API installs prowler from a pinned master git rev whose dulwich and pyjwt pins trail
# the root pyproject.toml. dulwich 1.2.9 patches GHSA-5fqc-mrg8-w798, GHSA-8mcx-5rqc-vhmf,
# GHSA-8w8g-wq8h-fq33 and GHSA-cm62-gvxx-vmxx, and pyjwt 2.14.0 patches GHSA-ffc3-869f-jxw9,
# so override both until that rev is refreshed. pyjwt keeps the [crypto] extra because an
# override replaces the whole requirement; bare pyjwt would drop it from the consumers
# that request pyjwt[crypto] and leave cryptography (needed for RS256) only transitive.
override-dependencies = [
"okta==3.4.2",
# huaweicloudsdkcore (via prowler) pins pymongo==4.15.1 exactly; CVE-2026-96748 and
# CVE-2026-96749 are fixed in 4.18.2. Only bson types are used, so the override is safe.
"pymongo==4.18.3",
# 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
@@ -499,6 +501,6 @@ override-dependencies = [
"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"
"dulwich==1.2.9",
"pyjwt[crypto]==2.14.0"
]
@@ -12,7 +12,7 @@ Two responsibilities:
Label-injection pipeline:
1. **Protect** string literals and line comments (placeholder replacement).
1. **Protect** string literals and comments (placeholder replacement).
2. **Split** by top-level clause keywords to track clause context.
3. **Pass A** - inject into *labeled* node patterns in ALL segments.
4. **Pass B** - inject into *bare* node patterns in MATCH segments only.
@@ -25,10 +25,12 @@ from rest_framework.exceptions import ValidationError
from tasks.jobs.attack_paths.config import get_provider_label
# Step 1 - String / comment protection
# Single combined regex: strings first, then line comments
# Single combined regex: strings first, then line and block comments
# The regex engine finds the leftmost match, so a string like 'https://prowler.com'
# is consumed as a string before the // inside it can match as a comment
_PROTECTED_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|//[^\n]*")
_PROTECTED_RE = re.compile(
r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|//[^\n]*|/\*[\s\S]*?\*/"
)
# Step 2 - Clause splitting
# `OPTIONAL MATCH` must come before `MATCH` to avoid partial matching
@@ -143,18 +145,26 @@ def inject_label(cypher: str, label: str) -> str:
# Patterns that indicate SSRF or dangerous procedure calls
# Defense-in-depth layer - the primary control is `neo4j.READ_ACCESS`
# Neo4j accepts whitespace (and therefore comments) around the dots of a
# qualified procedure name, so `apoc . load` is `apoc.load`.
_BLOCKED_APOC_NAMESPACES = (
"load",
"import",
"export",
"cypher",
"systemdb",
"config",
"periodic",
"do",
"trigger",
"custom",
)
_BLOCKED_PATTERNS = [
re.compile(r"\bLOAD\s+CSV\b", re.IGNORECASE),
re.compile(r"\bapoc\.load\b", re.IGNORECASE),
re.compile(r"\bapoc\.import\b", re.IGNORECASE),
re.compile(r"\bapoc\.export\b", re.IGNORECASE),
re.compile(r"\bapoc\.cypher\b", re.IGNORECASE),
re.compile(r"\bapoc\.systemdb\b", re.IGNORECASE),
re.compile(r"\bapoc\.config\b", re.IGNORECASE),
re.compile(r"\bapoc\.periodic\b", re.IGNORECASE),
re.compile(r"\bapoc\.do\b", re.IGNORECASE),
re.compile(r"\bapoc\.trigger\b", re.IGNORECASE),
re.compile(r"\bapoc\.custom\b", re.IGNORECASE),
*(
re.compile(rf"\bapoc\s*\.\s*{namespace}\b", re.IGNORECASE)
for namespace in _BLOCKED_APOC_NAMESPACES
),
]
@@ -162,10 +172,11 @@ def validate_custom_query(cypher: str) -> None:
"""Reject queries containing known SSRF or dangerous procedure patterns.
Raises ValidationError if a blocked pattern is found.
String literals and comments are stripped before matching to avoid
false positives.
String literals and comments are replaced by a space before matching:
literals cannot trigger false positives and a comment cannot split a
keyword pair such as `LOAD /**/ CSV`, which Neo4j still parses as `LOAD CSV`.
"""
stripped = _PROTECTED_RE.sub("", cypher)
stripped = _PROTECTED_RE.sub(" ", cypher)
for pattern in _BLOCKED_PATTERNS:
if pattern.search(stripped):
raise ValidationError({"query": "Query contains a blocked operation"})
@@ -207,6 +207,11 @@ def drop_database(database: str) -> None:
sink_module.get_backend().drop_database(database)
def list_databases() -> list[str]:
"""List database names on the ingest cluster. Temp scan DBs always live here."""
return ingest.list_databases()
def drop_subgraph(database: str, provider_id: str) -> int:
return sink_module.get_backend().drop_subgraph(database, provider_id)
@@ -13,6 +13,7 @@ from api.attack_paths.ingest.driver import (
get_session,
get_uri,
init_driver,
list_databases,
run_cypher,
)
@@ -25,5 +26,6 @@ __all__ = [
"get_session",
"get_uri",
"init_driver",
"list_databases",
"run_cypher",
]
@@ -165,6 +165,14 @@ def drop_database(database: str) -> None:
session.run(f"DROP DATABASE `{database}` IF EXISTS DESTROY DATA")
def list_databases() -> list[str]:
"""List every database name on the Neo4j temp-database cluster."""
# A cluster returns one row per hosting server, so dedupe on name
with get_session() as session:
result = session.run("SHOW DATABASES YIELD name RETURN DISTINCT name")
return [record["name"] for record in result]
def clear_cache(database: str) -> None:
"""Best-effort cache clear for a Neo4j database."""
from api.attack_paths.database import GraphDatabaseQueryException
@@ -45,6 +45,10 @@ class SinkDatabase(Protocol):
parameters: dict[str, Any] | None = None,
) -> neo4j.graph.Graph: ...
def is_persisted_element_id(self, element_id: str) -> bool:
"""Whether an element id belongs to a stored entity rather than one minted at query time."""
return True
def create_database(self, database: str) -> None: ...
def drop_database(self, database: str) -> None: ...
@@ -7,6 +7,7 @@ path while phase-1 drains tenant DBs.
import atexit
import logging
import re
import threading
import time
from collections.abc import Iterator
@@ -186,6 +187,13 @@ class Neo4jSink(SinkDatabase):
return session.execute_read(_run)
# APOC virtual nodes report a bare negative number and virtual
# relationships a random UUID, neither in the stored-entity format.
_PERSISTED_ELEMENT_ID_RE = re.compile(r"^\d+:[^:]+:\d+$")
def is_persisted_element_id(self, element_id: str) -> bool:
return bool(self._PERSISTED_ELEMENT_ID_RE.match(element_id))
def create_database(self, database: str) -> None:
with self.get_session() as session:
session.run(
@@ -15,6 +15,7 @@ from api.attack_paths.queries.schema import (
RAW_SCHEMA_URL,
get_cartography_schema_query,
)
from api.attack_paths.sink.base import SinkDatabase
from api.models import AttackPathsScan
from config.custom_logging import BackendLogger
from config.env import env
@@ -135,7 +136,7 @@ def execute_query(
cypher = inject_provider_label(cypher, provider_id)
graph = backend.execute_read_query(database_name, cypher, parameters)
return _serialize_graph(graph, provider_id)
return _serialize_graph(graph, provider_id, backend)
except graph_database.WriteQueryNotAllowedException:
raise PermissionDenied(
@@ -174,6 +175,8 @@ def execute_custom_query(
# 1. `neo4j.READ_ACCESS` - prevents mutations at the driver level
# 2. `inject_provider_label()` - regex-based label injection scopes node patterns
# 3. `_serialize_graph()` - post-query filter drops nodes without the provider label
# and any node or relationship the backend did not store (APOC virtual
# entities can carry an arbitrary label and arbitrary properties)
# 4. `USING QUERY:TIMEOUTMILLISECONDS` on Neptune - server-side runaway cutoff
#
# Layer 2 is best-effort (regex can't fully parse Cypher);
@@ -193,7 +196,7 @@ def execute_custom_query(
try:
graph = backend.execute_read_query(database_name, cypher, None)
serialized = _serialize_graph(graph, provider_id)
serialized = _serialize_graph(graph, provider_id, backend)
return _truncate_graph(serialized)
except graph_database.ClientStatementException as exc:
@@ -265,13 +268,15 @@ def _truncate_graph(graph: dict[str, Any]) -> dict[str, Any]:
return graph
def _serialize_graph(graph, provider_id: str) -> dict[str, Any]:
def _serialize_graph(graph, provider_id: str, backend: SinkDatabase) -> dict[str, Any]:
provider_label = get_provider_label(provider_id)
nodes = []
kept_node_ids = set()
for node in graph.nodes:
if provider_label not in node.labels:
if provider_label not in node.labels or not backend.is_persisted_element_id(
node.element_id
):
continue
kept_node_ids.add(node.element_id)
@@ -286,7 +291,7 @@ def _serialize_graph(graph, provider_id: str) -> dict[str, Any]:
filtered_count = len(graph.nodes) - len(nodes)
if filtered_count > 0:
logger.debug(
f"Filtered {filtered_count} nodes without provider label {provider_label}"
f"Filtered {filtered_count} nodes without provider label {provider_label} or not stored"
)
relationships = []
@@ -294,6 +299,7 @@ def _serialize_graph(graph, provider_id: str) -> dict[str, Any]:
if (
relationship.start_node.element_id not in kept_node_ids
or relationship.end_node.element_id not in kept_node_ids
or not backend.is_persisted_element_id(relationship.element_id)
):
continue
+45 -33
View File
@@ -1,4 +1,5 @@
import logging
from datetime import timedelta
from math import isfinite
from uuid import UUID
@@ -6,7 +7,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.db.models import Q
from django.utils import timezone
from drf_simple_apikey.backends import APIKeyAuthentication as BaseAPIKeyAuth
from drf_simple_apikey.crypto import get_crypto
@@ -18,12 +19,15 @@ from rest_framework_simplejwt.authentication import JWTAuthentication
logger = logging.getLogger(__name__)
# Writing on every request makes all requests of a busy key contend on one row
API_KEY_LAST_USED_AT_THROTTLE_SECONDS = 60
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`.
The revocation is written by a plain `update()` before this is raised, so it is
already persisted by the time `authenticate` catches it and rejects the request.
"""
@@ -37,8 +41,9 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
"""
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)`.
Returns the validated API key row from a single read. `authenticate` builds
the auth claims from that same row instead of looking it up again, so a key
revoked or orphaned right after validation can't still authenticate.
"""
try:
payload = self.key_crypto.decrypt(key)
@@ -67,9 +72,11 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
raise AuthenticationFailed("API Key has already expired.")
try:
# Loading `entity` in the same query keeps a user deleted after this read
# from turning the later `api_key.entity` access into a 500
api_key = (
self.model.objects.using(MainRouter.admin_db)
.select_for_update()
.select_related("entity")
.get(id=api_key_pk)
)
except ObjectDoesNotExist:
@@ -85,8 +92,9 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
# 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)
self.model.objects.using(MainRouter.admin_db).filter(
id=api_key.id, revoked=False
).update(revoked=True)
logger.warning(
"Revoked orphaned API key: prefix=%s tenant=%s",
api_key.prefix,
@@ -112,34 +120,38 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
except ValueError:
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.")
try:
api_key = self._authenticate_credentials(request, key)
except OrphanedAPIKeyError:
raise AuthenticationFailed("No entity matching this api key.")
api_key.last_used_at = timezone.now()
api_key.save(update_fields=["last_used_at"], using=MainRouter.admin_db)
# The prefix used to be checked by the second lookup
if api_key.prefix != prefix:
raise AuthenticationFailed("Invalid API Key.")
entity = api_key.entity
return entity, {
"tenant_id": str(api_key.tenant_id),
"sub": str(entity.id),
"api_key_prefix": api_key.prefix,
}
self._throttled_touch_last_used_at(api_key)
raise AuthenticationFailed("No entity matching this api key.")
entity = api_key.entity
return entity, {
"tenant_id": str(api_key.tenant_id),
"sub": str(entity.id),
"api_key_prefix": api_key.prefix,
}
@staticmethod
def _throttled_touch_last_used_at(api_key: TenantAPIKey) -> None:
"""Write `last_used_at` at most once per throttle interval, without locking the row."""
now = timezone.now()
stale_before = now - timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS)
if api_key.last_used_at is not None and api_key.last_used_at >= stale_before:
return
TenantAPIKey.objects.using(MainRouter.admin_db).filter(
id=api_key.id, revoked=False
).filter(
Q(last_used_at__isnull=True) | Q(last_used_at__lt=stale_before)
).update(last_used_at=now)
class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication):
+1 -1
View File
@@ -409,7 +409,7 @@ def batch_delete(tenant_id, queryset, batch_size=settings.DJANGO_DELETION_BATCH_
Args:
tenant_id (str): Tenant ID the queryset belongs to.
queryset (QuerySet): The queryset of objects to delete.
queryset: The queryset of objects to delete.
batch_size (int): The number of objects to delete in each batch.
Returns:
@@ -0,0 +1,116 @@
import uuid
import api.rls
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0097_attack_paths_scan_db_defaults"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="TenantOnboardingProfile",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
(
"declared_cloud_accounts",
models.CharField(
blank=True,
choices=[
("1", "1"),
("2-10", "2-10"),
("11-50", "11-50"),
("51-200", "51-200"),
("200+", "200+"),
],
max_length=16,
null=True,
),
),
(
"declared_role",
models.CharField(
blank=True,
choices=[
("security", "Security"),
("devops_platform", "DevOps / Platform"),
("developer", "Developer"),
("compliance_grc", "Compliance / GRC"),
("other", "Other"),
],
max_length=32,
null=True,
),
),
(
"declared_seniority",
models.CharField(
blank=True,
choices=[
("practitioner", "Practitioner / IC"),
("lead", "Team lead / Manager"),
("director", "Director / Head of"),
("executive", "VP / C-level"),
("founder", "Founder / Owner"),
],
max_length=32,
null=True,
),
),
("skipped", models.BooleanField(default=False)),
(
"submitted_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="tenant_onboarding_profiles",
related_query_name="tenant_onboarding_profile",
to=settings.AUTH_USER_MODEL,
),
),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "tenant_onboarding_profiles",
"abstract": False,
},
),
migrations.AddConstraint(
model_name="tenantonboardingprofile",
constraint=models.UniqueConstraint(
fields=("tenant_id",), name="unique_tenant_onboarding_profile"
),
),
migrations.AddConstraint(
model_name="tenantonboardingprofile",
# `statements` written out explicitly: RowLevelSecurityConstraint
# .deconstruct() does not serialize it, so an autogenerated
# migration falls back to ["SELECT"] and leaves the table without
# INSERT/UPDATE/DELETE policies.
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_tenantonboardingprofile",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
]
@@ -0,0 +1,15 @@
from django.db import migrations
class Migration(migrations.Migration):
# The onboarding profile step was reverted after 0098 had been merged, so
# the table goes away through a new migration rather than by deleting 0098.
dependencies = [
("api", "0098_tenant_onboarding_profile"),
]
operations = [
migrations.DeleteModel(
name="TenantOnboardingProfile",
),
]
@@ -0,0 +1,48 @@
from django.db import migrations
TASK_NAME = "attack-paths-reap-orphaned-tmp-databases"
INTERVAL_HOURS = 6
def create_periodic_task(apps, schema_editor):
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
schedule, _ = IntervalSchedule.objects.get_or_create(
every=INTERVAL_HOURS,
period="hours",
)
PeriodicTask.objects.update_or_create(
name=TASK_NAME,
defaults={
"task": TASK_NAME,
"interval": schedule,
"enabled": True,
},
)
def delete_periodic_task(apps, schema_editor):
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
PeriodicTask.objects.filter(name=TASK_NAME).delete()
# Clean up the schedule if no other task references it
IntervalSchedule.objects.filter(
every=INTERVAL_HOURS,
period="hours",
periodictask__isnull=True,
).delete()
class Migration(migrations.Migration):
dependencies = [
("api", "0099_delete_tenant_onboarding_profile"),
("django_celery_beat", "0019_alter_periodictasks_options"),
]
operations = [
migrations.RunPython(create_periodic_task, delete_periodic_task),
]
+65 -2
View File
@@ -617,9 +617,66 @@ class Task(RowLevelSecurityProtectedModel):
resource_name = "tasks"
class ScanQuerySet(models.QuerySet):
"""Shared selectors for "the latest scan of a provider".
The queryset must already be scoped by the caller: manager, tenant, RBAC,
providers and database alias.
"""
# How "which completed scan is the provider's current one" is ordered.
LATEST_ORDER_BY = (
models.F("completed_at").desc(nulls_last=True),
models.F("inserted_at").desc(),
models.F("id").desc(),
)
def _eligible_for_latest(self) -> "ScanQuerySet":
"""Restrict to the scans that may be a provider's latest.
Returns:
ScanQuerySet: The completed scans.
"""
return self.filter(state=StateChoices.COMPLETED)
def latest_per_provider(self) -> "ScanQuerySet":
"""Pick each provider's latest scan with `DISTINCT ON (provider_id)`.
Returns:
ScanQuerySet: One scan per provider, the latest one.
"""
return (
self._eligible_for_latest()
.order_by("provider_id", *self.LATEST_ORDER_BY)
.distinct("provider_id")
)
def latest_ids_per_provider(self) -> list[UUID]:
"""Evaluate `latest_per_provider` and return the scan ids.
The ids are materialised so callers can pass them as a literal `IN`
list; as a subquery Postgres misestimates the row count and picks a
slow nested loop.
Returns:
list[UUID]: The id of each provider's latest scan.
"""
return list(self.latest_per_provider().values_list("id", flat=True))
def latest_first(self) -> "ScanQuerySet":
"""Order eligible scans newest first, without deduplicating per provider.
Expects the queryset to be already filtered to a single provider.
Returns:
ScanQuerySet: The eligible scans, latest first.
"""
return self._eligible_for_latest().order_by(*self.LATEST_ORDER_BY)
class Scan(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager()
all_objects = models.Manager()
objects = ActiveProviderManager.from_queryset(ScanQuerySet)()
all_objects = ScanQuerySet.as_manager()
_SCOPING_SCANNER_ARG_KEYS_CACHE: tuple[str, ...] | None = None
@@ -726,6 +783,12 @@ class Scan(RowLevelSecurityProtectedModel):
name="scans_prov_state_ins_desc_idx",
),
# TODO This might replace `scans_prov_state_ins_desc_idx` completely. Review usage
# Since `ScanQuerySet`, no code path reads a provider's
# completed scans by `-inserted_at`. The only query left that
# matches this index (and `scans_prov_state_ins_desc_idx` above)
# is `GET /scans?filter[provider]=…&filter[state]=completed` with
# the default sort. Both are candidates to drop in a follow-up
# once production `pg_stat_user_indexes.idx_scan` confirms it.
models.Index(
fields=["tenant_id", "provider_id", "-inserted_at"],
condition=Q(state=StateChoices.COMPLETED),
+66 -11
View File
@@ -1,7 +1,7 @@
from enum import Enum
from api.db_router import MainRouter
from api.models import Integration, Provider, Role, User
from api.models import Integration, Provider, Role, Task, User
from django.db.models import Q, QuerySet
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import BasePermission
@@ -17,6 +17,50 @@ class Permissions(Enum):
UNLIMITED_VISIBILITY = "unlimited_visibility"
# Revoking a task needs the permission of the operation that queued it.
# None and unmapped names are not revocable; a revoked provider deletion
# would leave the provider soft-deleted with nothing re-queuing the cleanup.
TASK_REVOKE_PERMISSIONS: dict[str, list[Permissions] | None] = {
"provider-connection-check": [Permissions.MANAGE_PROVIDERS],
"provider-deletion": None,
"integration-connection-check": [Permissions.MANAGE_INTEGRATIONS],
"integration-s3": [Permissions.MANAGE_INTEGRATIONS],
"integration-security-hub": [Permissions.MANAGE_INTEGRATIONS],
"integration-jira": [Permissions.MANAGE_INTEGRATIONS],
"scan-perform": [Permissions.MANAGE_SCANS],
"scan-perform-scheduled": [Permissions.MANAGE_SCANS],
"scan-compliance-overviews": [Permissions.MANAGE_SCANS],
"scan-compliance-reports": [Permissions.MANAGE_SCANS],
"scan-finding-group-summaries": [Permissions.MANAGE_SCANS],
"scan-report": [Permissions.MANAGE_SCANS],
"attack-paths-scan-perform": [Permissions.MANAGE_SCANS],
"findings-mute-latest-scans": [Permissions.MANAGE_SCANS],
"lighthouse-connection-check": [],
"lighthouse-provider-connection-check": [],
"lighthouse-provider-models-refresh": [],
}
def get_user_roles(user: User, tenant_id: str) -> list[Role]:
"""Return every role assigned to the user in the tenant."""
return list(
User.objects.using(MainRouter.admin_db)
.get(id=user.id)
.roles.using(MainRouter.admin_db)
.filter(tenant_id=tenant_id)
)
def roles_have_permissions(
roles: list[Role], required_permissions: list[Permissions]
) -> bool:
"""Return True when every required permission is granted by at least one role."""
return all(
any(getattr(role, permission.value, False) for role in roles)
for permission in required_permissions
)
class HasPermissions(BasePermission):
"""
Custom permission to check if the user's role has the required permissions.
@@ -34,19 +78,11 @@ class HasPermissions(BasePermission):
if not tenant_id:
return False
user_roles = list(
User.objects.using(MainRouter.admin_db)
.get(id=request.user.id)
.roles.using(MainRouter.admin_db)
.filter(tenant_id=tenant_id)
)
user_roles = get_user_roles(request.user, tenant_id)
if not user_roles:
return False
return all(
any(getattr(role, permission.value, False) for role in user_roles)
for permission in required_permissions
)
return roles_have_permissions(user_roles, required_permissions)
def get_role(user: User, tenant_id: str) -> Role:
@@ -85,6 +121,25 @@ def get_providers(role: Role) -> QuerySet[Provider]:
).distinct()
def get_tasks(role: Role) -> QuerySet[Task]:
"""Return the tasks visible to the role: tenant-wide ones and those of its providers."""
queryset = Task.objects.filter(tenant_id=role.tenant_id)
if role.unlimited_visibility:
return queryset
# Task has no provider FK, so match provider ids inside the stored kwargs.
# all_objects keeps a soft-deleted provider visible to its own groups, so the
# role that queued its deletion can still follow the task.
hidden = Q()
for provider_id in (
Provider.all_objects.filter(tenant_id=role.tenant_id)
.exclude(provider_groups__in=role.provider_groups.all())
.values_list("id", flat=True)
):
hidden |= Q(task_runner_task__task_kwargs__contains=str(provider_id))
return queryset.exclude(hidden) if hidden else queryset
def get_integrations(
role: Role, providers: QuerySet[Provider] | None = None
) -> QuerySet[Integration]:
+9 -4
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.43.1
version: 1.49.0
description: |-
Prowler API specification.
@@ -14823,7 +14823,9 @@ paths:
get:
operationId: api_v1_tasks_list
description: Retrieve a list of all tasks with options for filtering by name,
state, and other criteria.
state, and other criteria. Tasks that reference a provider are only returned
when the role can access it; tasks without a provider reference are returned
for every role.
summary: List all tasks
parameters:
- in: query
@@ -14922,7 +14924,8 @@ paths:
/api/v1/tasks/{id}:
get:
operationId: api_v1_tasks_retrieve
description: Fetch detailed information about a specific task by its ID.
description: Fetch detailed information about a specific task by its ID. Tasks
tied to a provider outside the visibility of the role are not found.
summary: Retrieve data from a specific task
parameters:
- in: query
@@ -14963,7 +14966,9 @@ paths:
delete:
operationId: api_v1_tasks_destroy
description: Try to revoke a task using its ID. Only tasks that are not yet
in progress can be revoked.
in progress can be revoked, and the caller needs the same permission as the
operation that queued the task (for example MANAGE_SCANS for a scan). Provider
deletions cannot be revoked.
summary: Revoke a task
parameters:
- in: path
@@ -1,9 +1,9 @@
import json
import time
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from api.authentication import API_KEY_LAST_USED_AT_THROTTLE_SECONDS
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
@@ -11,6 +11,7 @@ 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 freezegun import freeze_time
from rest_framework.test import APIClient
from rest_framework_simplejwt.token_blacklist.models import (
BlacklistedToken,
@@ -527,7 +528,7 @@ class TestAPIKeyAuthentication:
def test_last_used_at_tracking(
self, create_test_user, tenants_fixture, api_keys_fixture
):
"""Verify last_used_at timestamp updates on each authentication."""
"""Verify last_used_at timestamp is set on first use and throttled after that."""
client = APIClient()
api_key = api_keys_fixture[0]
@@ -536,7 +537,11 @@ class TestAPIKeyAuthentication:
# Use API key to authenticate
api_key_headers = get_api_key_header(api_key._raw_key)
first_response = client.get(reverse("provider-list"), headers=api_key_headers)
start = datetime.now(UTC)
with freeze_time(start):
first_response = client.get(
reverse("provider-list"), headers=api_key_headers
)
assert first_response.status_code == 200
# Reload from database and check last_used_at is set
@@ -544,17 +549,23 @@ class TestAPIKeyAuthentication:
first_used_at = api_key.last_used_at
assert first_used_at is not None
# Use the same key again after a small delay
time.sleep(0.1)
# Using the same key again within the throttle interval does not rewrite it
second_response = client.get(reverse("provider-list"), headers=api_key_headers)
assert second_response.status_code == 200
# Reload and verify last_used_at was updated
api_key.refresh_from_db()
second_used_at = api_key.last_used_at
assert second_used_at is not None
assert second_used_at > first_used_at
assert api_key.last_used_at == first_used_at
# Past the throttle interval, the next use refreshes it
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
with freeze_time(later):
third_response = client.get(
reverse("provider-list"), headers=api_key_headers
)
assert third_response.status_code == 200
api_key.refresh_from_db()
assert api_key.last_used_at > first_used_at
@pytest.mark.django_db
@@ -1441,6 +1452,7 @@ class TestAPIKeyRLSBypass:
The update to last_used_at during authentication must also use the
admin database since it occurs before RLS context is established.
Past the throttle interval, using the key again refreshes the timestamp.
"""
client = APIClient()
api_key = api_keys_fixture[0]
@@ -1448,7 +1460,11 @@ class TestAPIKeyRLSBypass:
assert api_key.last_used_at is None
api_key_headers = get_api_key_header(api_key._raw_key)
first_response = client.get(reverse("provider-list"), headers=api_key_headers)
start = datetime.now(UTC)
with freeze_time(start):
first_response = client.get(
reverse("provider-list"), headers=api_key_headers
)
assert first_response.status_code == 200
@@ -1456,9 +1472,11 @@ class TestAPIKeyRLSBypass:
first_timestamp = api_key.last_used_at
assert first_timestamp is not None
time.sleep(0.1)
second_response = client.get(reverse("provider-list"), headers=api_key_headers)
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
with freeze_time(later):
second_response = client.get(
reverse("provider-list"), headers=api_key_headers
)
assert second_response.status_code == 200
api_key.refresh_from_db()
+66 -1
View File
@@ -321,7 +321,7 @@ def test_serialize_graph_filters_by_provider_label(attack_paths_graph_stub_class
relationships=[rel_keep, rel_drop_orphaned],
)
result = views_helpers._serialize_graph(graph, provider_id)
result = views_helpers._serialize_graph(graph, provider_id, _all_persisted())
assert len(result["nodes"]) == 1
assert result["nodes"][0]["id"] == "n1"
@@ -329,6 +329,71 @@ def test_serialize_graph_filters_by_provider_label(attack_paths_graph_stub_class
assert result["relationships"][0]["id"] == "r1"
def _all_persisted():
return SimpleNamespace(is_persisted_element_id=lambda element_id: True)
def test_serialize_graph_drops_virtual_entities(attack_paths_graph_stub_classes):
provider_id = "provider-keep"
plabel = get_provider_label(provider_id)
stored_ids = {"n1", "n2", "r1"}
backend = SimpleNamespace(
is_persisted_element_id=lambda element_id: element_id in stored_ids
)
node_1 = attack_paths_graph_stub_classes.Node("n1", ["AWSAccount", plabel], {})
node_2 = attack_paths_graph_stub_classes.Node("n2", ["AWSRole", plabel], {})
# apoc.create.vNode can carry the provider label and any payload
node_virtual = attack_paths_graph_stub_classes.Node(
"-1", ["AWSAccount", plabel], {"body": "fetched"}
)
rel_stored = attack_paths_graph_stub_classes.Relationship(
"r1", "OWNS", node_1, node_2, {}
)
# apoc.create.vRelationship between two stored nodes passes the endpoint check
rel_virtual = attack_paths_graph_stub_classes.Relationship(
"45cc10ea-9618-47fb-abaa-8e82a58d6b40", "V", node_1, node_2, {"body": "fetched"}
)
rel_to_virtual = attack_paths_graph_stub_classes.Relationship(
"r2", "OWNS", node_1, node_virtual, {}
)
graph = SimpleNamespace(
nodes=[node_1, node_2, node_virtual],
relationships=[rel_stored, rel_virtual, rel_to_virtual],
)
result = views_helpers._serialize_graph(graph, provider_id, backend)
assert [node["id"] for node in result["nodes"]] == ["n1", "n2"]
assert [rel["id"] for rel in result["relationships"]] == ["r1"]
assert "fetched" not in str(result)
@pytest.mark.parametrize(
"element_id, persisted",
[
("4:9f1d9d76-bb08-478c-b25d-7e577f967732:0", True),
("5:9f1d9d76-bb08-478c-b25d-7e577f967732:12", True),
("-2", False),
("45cc10ea-9618-47fb-abaa-8e82a58d6b40", False),
("", False),
],
ids=[
"stored_node",
"stored_relationship",
"virtual_node",
"virtual_relationship",
"empty",
],
)
def test_neo4j_sink_is_persisted_element_id(element_id, persisted):
from api.attack_paths.sink.neo4j import Neo4jSink
assert Neo4jSink().is_persisted_element_id(element_id) is persisted
# -- serialize_graph_as_text -------------------------------------------------------
@@ -187,6 +187,27 @@ class TestRoutingByDatabasePrefix:
sink_backend_stub.drop_database.assert_called_once_with("db-tenant-abc")
mock_ingest.drop_database.assert_not_called()
def test_list_databases_always_routes_to_ingest(self, sink_backend_stub):
with patch("api.attack_paths.database.ingest") as mock_ingest:
mock_ingest.list_databases.return_value = ["db-tmp-scan-uuid-1"]
assert db_module.list_databases() == ["db-tmp-scan-uuid-1"]
mock_ingest.list_databases.assert_called_once_with()
def test_ingest_list_databases_dedupes_cluster_rows(self):
from api.attack_paths.ingest import driver as ingest_driver
with patch.object(ingest_driver, "get_session") as mock_get_session:
session = mock_get_session.return_value.__enter__.return_value
session.run.return_value = [{"name": "db-tmp-scan-uuid-1"}]
assert ingest_driver.list_databases() == ["db-tmp-scan-uuid-1"]
session.run.assert_called_once_with(
"SHOW DATABASES YIELD name RETURN DISTINCT name"
)
def test_clear_cache_routes_temp_to_ingest(self, sink_backend_stub):
with patch("api.attack_paths.database.ingest") as mock_ingest:
db_module.clear_cache("db-tmp-scan-uuid-1")
+137 -24
View File
@@ -5,16 +5,18 @@ from uuid import uuid4
import pytest
from api.authentication import (
API_KEY_LAST_USED_AT_THROTTLE_SECONDS,
OrphanedAPIKeyError,
SSEAuthentication,
TenantAPIKeyAuthentication,
)
from api.db_router import MainRouter
from api.models import TenantAPIKey
from api.models import TenantAPIKey, User
from django.db import connections
from django.db.models.query import QuerySet
from django.test import RequestFactory
from django.test.utils import CaptureQueriesContext
from freezegun import freeze_time
from rest_framework.exceptions import AuthenticationFailed
@@ -286,14 +288,15 @@ class TestTenantAPIKeyAuthentication:
assert str(exc_info.value.detail) == "This API Key has been revoked."
def test_authenticate_reads_the_api_key_once_under_a_row_lock(
def test_authenticate_reads_the_api_key_once_without_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.
"""Test the API key is read a single time and no 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.
same authoritative row: a second lookup would reopen the window where a key
revoked in between still authenticates. `SELECT ... FOR UPDATE` serialized
every request for a hot key onto one locked row and is not used any more.
"""
api_key = api_keys_fixture[0]
@@ -310,33 +313,40 @@ class TestTenantAPIKeyAuthentication:
]
assert len(api_key_selects) == 1
assert "FOR UPDATE" in api_key_selects[0]
assert "FOR UPDATE" not in api_key_selects[0]
def test_authenticate_ignores_revocation_after_the_locked_read(
def test_authenticate_ignores_revocation_after_the_single_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.
both reads still authenticated, and the claims came from that stale row.
There is now only a single read, so this race is closed by construction 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
original_authenticate_credentials = (
TenantAPIKeyAuthentication._authenticate_credentials
)
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
def revoke_and_orphan_after_reading(self, request, key):
# Runs right after the single read `authenticate` will use to build the
# claims: the exact window a concurrent revocation used to slip into
result = original_authenticate_credentials(self, request, key)
TenantAPIKey.objects.filter(id=api_key.id).update(revoked=True, entity=None)
return original_save(instance, *args, **kwargs)
return result
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):
with patch.object(
TenantAPIKeyAuthentication,
"_authenticate_credentials",
revoke_and_orphan_after_reading,
):
entity, auth_dict = auth_backend.authenticate(request)
assert entity == entity_at_validation
@@ -350,6 +360,43 @@ class TestTenantAPIKeyAuthentication:
assert str(exc_info.value.detail) == "This API Key has been revoked."
def test_authenticate_survives_owner_deleted_after_the_single_read(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test a user deleted right after the read does not turn into a 500.
Without the row lock a concurrent user deletion can land between the read
and building the claims. `entity` is loaded by the same query, so no later
lookup can raise `DoesNotExist`.
"""
api_key = api_keys_fixture[0]
owner_id = api_key.entity_id
original_authenticate_credentials = (
TenantAPIKeyAuthentication._authenticate_credentials
)
def delete_owner_after_reading(self, request, key):
result = original_authenticate_credentials(self, request, key)
User.objects.using(MainRouter.admin_db).filter(id=owner_id).delete()
return result
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}"
with patch.object(
TenantAPIKeyAuthentication,
"_authenticate_credentials",
delete_owner_after_reading,
):
entity, auth_dict = auth_backend.authenticate(request)
assert auth_dict["sub"] == str(owner_id)
assert entity.id == owner_id
# From the next request on, the orphaned key is rejected with a 401
with pytest.raises(AuthenticationFailed):
auth_backend.authenticate(request)
def test_authenticate_expired_api_key(
self, auth_backend, create_test_user, tenants_fixture, request_factory
):
@@ -421,24 +468,90 @@ class TestTenantAPIKeyAuthentication:
if original_last_used:
assert api_key.last_used_at > original_last_used
def test_authenticate_saves_to_admin_database(
def test_authenticate_updates_last_used_at_on_admin_database(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test that the API key save operation uses admin database."""
"""Test that the `last_used_at` update runs against the admin database."""
api_key = api_keys_fixture[0]
raw_key = api_key._raw_key
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
# Mock the save method to verify it's called with using='admin'
with patch.object(TenantAPIKey, "save") as mock_save:
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
auth_backend.authenticate(request)
# Verify save was called with using=admin_db
mock_save.assert_called_once_with(
update_fields=["last_used_at"], using=MainRouter.admin_db
)
api_key_updates = [
query["sql"]
for query in captured.captured_queries
if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"]
]
assert len(api_key_updates) == 1
assert "last_used_at" in api_key_updates[0]
def test_authenticate_does_not_rewrite_last_used_at_within_throttle_interval(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test that a second authentication within the throttle interval is a no-op write."""
api_key = api_keys_fixture[0]
raw_key = api_key._raw_key
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
# First call sets last_used_at
auth_backend.authenticate(request)
api_key.refresh_from_db()
first_used_at = api_key.last_used_at
assert first_used_at is not None
# Second call, still within the throttle interval, must issue no UPDATE
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
auth_backend.authenticate(request)
api_key_updates = [
query["sql"]
for query in captured.captured_queries
if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"]
]
assert api_key_updates == []
api_key.refresh_from_db()
assert api_key.last_used_at == first_used_at
def test_authenticate_rewrites_last_used_at_after_throttle_interval(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test that `last_used_at` is refreshed once it is older than the throttle interval."""
api_key = api_keys_fixture[0]
raw_key = api_key._raw_key
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
start = datetime.now(UTC)
with freeze_time(start):
auth_backend.authenticate(request)
api_key.refresh_from_db()
first_used_at = api_key.last_used_at
assert first_used_at is not None
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
with freeze_time(later):
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
auth_backend.authenticate(request)
api_key_updates = [
query["sql"]
for query in captured.captured_queries
if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"]
]
assert len(api_key_updates) == 1
api_key.refresh_from_db()
assert api_key.last_used_at > first_used_at
def test_authenticate_returns_correct_auth_dict(
self, auth_backend, api_keys_fixture, request_factory
@@ -222,6 +222,12 @@ class TestProtection:
result = _inject(cypher)
assert result.count(LABEL) == 1
def test_block_comment_with_node_pattern(self):
cypher = "MATCH (n:AWSRole) /* (m:Fake)\n(k) */ RETURN n"
result = _inject(cypher)
assert "/* (m:Fake)\n(k) */" in result
assert result.count(LABEL) == 1
# ---------------------------------------------------------------------------
# Clause splitting
@@ -439,6 +445,35 @@ class TestValidation:
assert "blocked operation" in str(exc.value.detail)
@pytest.mark.parametrize(
"cypher",
[
"LOAD /* c */ CSV FROM 'http://169.254.169.254/' AS x RETURN x",
"LOAD/**/CSV FROM 'http://evil.com' AS x RETURN x",
"LOAD /* a */ /* b */ CSV FROM 'http://evil.com' AS x RETURN x",
"LOAD /* multi\nline */ CSV FROM 'http://evil.com' AS x RETURN x",
"CALL apoc./**/load.json('http://evil.com/') YIELD value RETURN value",
"CALL apoc . load.json('http://evil.com/') YIELD value RETURN value",
"CALL apoc.load . json('http://evil.com/') YIELD value RETURN value",
"CALL apoc\n.cypher.run('CREATE (n)', {}) YIELD value RETURN value",
],
ids=[
"LOAD_CSV_block_comment",
"LOAD_CSV_empty_block_comment",
"LOAD_CSV_two_block_comments",
"LOAD_CSV_multiline_block_comment",
"apoc.load_block_comment",
"apoc.load_spaces_around_dot",
"apoc.load_spaces_before_procedure",
"apoc.cypher_newline_before_dot",
],
)
def test_rejects_blocked_patterns_split_by_comments_or_whitespace(self, cypher):
with pytest.raises(ValidationError) as exc:
validate_custom_query(cypher)
assert "blocked operation" in str(exc.value.detail)
@pytest.mark.parametrize(
"cypher",
[
@@ -448,6 +483,8 @@ class TestValidation:
"CALL apoc.create.vNode(['Label'], {}) YIELD node RETURN node",
"MATCH (n) WHERE n.name = 'apoc.load.json' RETURN n",
'MATCH (n) WHERE n.description = "LOAD CSV is cool" RETURN n',
"MATCH (n) /* LOAD CSV is documented here */ RETURN n",
"MATCH (n) WHERE n.note = '/* LOAD CSV */' RETURN n",
],
ids=[
"simple_match",
@@ -456,6 +493,8 @@ class TestValidation:
"apoc_virtual_node",
"apoc_load_inside_single_quotes",
"load_csv_inside_double_quotes",
"load_csv_inside_block_comment",
"block_comment_inside_string",
],
)
def test_allows_clean_queries(self, cypher):
+226 -1
View File
@@ -1,14 +1,16 @@
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
import pytest
from allauth.socialaccount.models import SocialApp
from api.db_router import MainRouter
from api.models import (
Provider,
ProviderComplianceScore,
Resource,
ResourceTag,
SAMLConfiguration,
SAMLDomainIndex,
Scan,
StateChoices,
StatusChoices,
TenantComplianceSummary,
@@ -524,3 +526,226 @@ class TestTenantComplianceSummaryModel:
assert summary1.id != summary2.id
assert summary1.requirements_passed != summary2.requirements_passed
def _latest_scan_fixture(tenant, provider, *, completed_at, inserted_at=None, **kwargs):
scan = Scan.objects.create(
tenant_id=tenant.id,
provider=provider,
trigger=kwargs.pop("trigger", Scan.TriggerChoices.MANUAL),
state=kwargs.pop("state", StateChoices.COMPLETED),
completed_at=completed_at,
**kwargs,
)
if inserted_at is not None:
# `inserted_at` is auto_now_add, so it has to be forced after the fact.
Scan.all_objects.filter(pk=scan.pk).update(inserted_at=inserted_at)
scan.refresh_from_db()
return scan
@pytest.mark.django_db
class TestScanQuerySetOrdering:
def test_completed_later_wins_over_inserted_later(
self, tenants_fixture, aws_provider
):
"""The scan that FINISHED last is current, not the one that started last."""
tenant, *_ = tenants_fixture
now = datetime.now(UTC)
finished_last = _latest_scan_fixture(
tenant,
aws_provider,
inserted_at=now - timedelta(hours=3),
completed_at=now,
)
_latest_scan_fixture(
tenant,
aws_provider,
inserted_at=now - timedelta(hours=1),
completed_at=now - timedelta(hours=1),
)
assert Scan.all_objects.filter(
tenant_id=tenant.id
).latest_ids_per_provider() == [finished_last.id]
def test_null_completed_at_provider_is_still_returned(
self, tenants_fixture, aws_provider
):
"""NULLS LAST, not `completed_at__isnull=False`.
Excluding NULL `completed_at` would drop the provider from every
"latest" endpoint instead of falling back to `inserted_at`.
"""
tenant, *_ = tenants_fixture
only_scan = _latest_scan_fixture(tenant, aws_provider, completed_at=None)
assert Scan.all_objects.filter(
tenant_id=tenant.id
).latest_ids_per_provider() == [only_scan.id]
def test_null_completed_at_never_outranks_a_finished_scan(
self, tenants_fixture, aws_provider
):
"""Postgres sorts NULLs first under DESC; NULLS LAST is what fixes it."""
tenant, *_ = tenants_fixture
now = datetime.now(UTC)
finished = _latest_scan_fixture(
tenant,
aws_provider,
inserted_at=now - timedelta(hours=2),
completed_at=now - timedelta(hours=2),
)
_latest_scan_fixture(
tenant,
aws_provider,
inserted_at=now,
completed_at=None,
)
assert Scan.all_objects.filter(
tenant_id=tenant.id
).latest_ids_per_provider() == [finished.id]
def test_id_breaks_an_exact_timestamp_tie_deterministically(
self, tenants_fixture, aws_provider
):
tenant, *_ = tenants_fixture
now = datetime.now(UTC)
scans = [
_latest_scan_fixture(
tenant, aws_provider, inserted_at=now, completed_at=now
)
for _ in range(3)
]
expected = max(scan.id for scan in scans)
picks = {
Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider()[0]
for _ in range(5)
}
assert picks == {expected}
@pytest.mark.django_db
class TestScanQuerySetEligibility:
def test_unfinished_scans_are_excluded(self, tenants_fixture, aws_provider):
tenant, *_ = tenants_fixture
_latest_scan_fixture(
tenant,
aws_provider,
completed_at=None,
state=StateChoices.EXECUTING,
)
assert (
Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider() == []
)
@pytest.mark.django_db
class TestScanQuerySetManagerChoice:
def test_active_manager_hides_soft_deleted_providers(
self, tenants_fixture, aws_provider
):
"""`Scan.objects` drops soft-deleted providers, `all_objects` keeps them.
The queryset must not decide this for the caller.
"""
tenant, *_ = tenants_fixture
scan = _latest_scan_fixture(
tenant, aws_provider, completed_at=datetime.now(UTC)
)
Provider.all_objects.filter(pk=aws_provider.pk).update(is_deleted=True)
assert Scan.all_objects.filter(
tenant_id=tenant.id
).latest_ids_per_provider() == [scan.id]
assert Scan.objects.filter(tenant_id=tenant.id).latest_ids_per_provider() == []
@pytest.mark.django_db
class TestScanQuerySetPerProviderScoping:
def test_one_scan_per_provider(self, tenants_fixture, aws_provider_pair):
tenant, *_ = tenants_fixture
provider_one, provider_two = aws_provider_pair
now = datetime.now(UTC)
newest_one = _latest_scan_fixture(tenant, provider_one, completed_at=now)
_latest_scan_fixture(tenant, provider_one, completed_at=now - timedelta(days=1))
newest_two = _latest_scan_fixture(tenant, provider_two, completed_at=now)
assert set(
Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider()
) == {newest_one.id, newest_two.id}
def test_caller_filters_are_preserved(self, tenants_fixture, aws_provider_pair):
tenant, *_ = tenants_fixture
provider_one, provider_two = aws_provider_pair
now = datetime.now(UTC)
scan_one = _latest_scan_fixture(tenant, provider_one, completed_at=now)
_latest_scan_fixture(tenant, provider_two, completed_at=now)
assert Scan.all_objects.filter(
tenant_id=tenant.id, provider__in=[provider_one]
).latest_ids_per_provider() == [scan_one.id]
def test_latest_first_is_ordered_not_deduplicated(
self, tenants_fixture, aws_provider
):
tenant, *_ = tenants_fixture
now = datetime.now(UTC)
newest = _latest_scan_fixture(tenant, aws_provider, completed_at=now)
older = _latest_scan_fixture(
tenant, aws_provider, completed_at=now - timedelta(days=1)
)
ordered = list(
Scan.all_objects.filter(
tenant_id=tenant.id, provider_id=aws_provider.id
).latest_first()
)
assert [scan.id for scan in ordered] == [newest.id, older.id]
def test_tenant_isolation(self, tenants_fixture, aws_provider):
tenant, other_tenant, *_ = tenants_fixture
_latest_scan_fixture(tenant, aws_provider, completed_at=datetime.now(UTC))
assert (
Scan.all_objects.filter(tenant_id=other_tenant.id).latest_ids_per_provider()
== []
)
def test_empty_queryset_returns_empty_list(self, tenants_fixture):
tenant, *_ = tenants_fixture
assert (
Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider() == []
)
@pytest.mark.django_db
class TestScanQuerySetPerProviderQuerysetShape:
def test_returns_a_queryset_not_a_list(self, tenants_fixture, aws_provider):
tenant, *_ = tenants_fixture
_latest_scan_fixture(tenant, aws_provider, completed_at=datetime.now(UTC))
qs = Scan.all_objects.filter(tenant_id=tenant.id).latest_per_provider()
# Callers chain .values(...) / .values_list(...) onto this.
assert qs.values_list("provider_id", flat=True).count() == 1
@pytest.mark.django_db
class TestScanQuerySetRelatedManager:
def test_reverse_relation_exposes_the_methods(self, tenants_fixture, aws_provider):
tenant, *_ = tenants_fixture
now = datetime.now(UTC)
newest = _latest_scan_fixture(tenant, aws_provider, completed_at=now)
_latest_scan_fixture(tenant, aws_provider, completed_at=now - timedelta(days=1))
assert aws_provider.scans.latest_first().first().id == newest.id
+85
View File
@@ -12,6 +12,7 @@ from api.models import (
ProviderSecret,
Role,
RoleProviderGroupRelationship,
SAMLConfiguration,
Scan,
User,
UserRoleRelationship,
@@ -1896,6 +1897,90 @@ class TestUserRoleLinkPermissions:
assert response.status_code == status.HTTP_403_FORBIDDEN
SAML_METADATA_XML = """<?xml version='1.0' encoding='UTF-8'?>
<md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
<md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
<md:KeyDescriptor use='signing'>
<ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
<ds:X509Data>
<ds:X509Certificate>TEST</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://TEST/sso/saml'/>
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' Location='https://TEST/sso/saml'/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>
"""
@pytest.mark.django_db
class TestSAMLConfigurationPermissions:
def _create(self, client, email_domain):
return client.post(
reverse("saml-config-list"),
data={"email_domain": email_domain, "metadata_xml": SAML_METADATA_XML},
format="json",
)
def test_manage_integrations_only_cannot_create(
self, authenticated_client_rbac_manage_integrations_only
):
response = self._create(
authenticated_client_rbac_manage_integrations_only,
"integrations-only-create.com",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert not SAMLConfiguration.objects.filter(
email_domain="integrations-only-create.com"
).exists()
def test_manage_integrations_only_cannot_read_or_replace(
self, authenticated_client_rbac_manage_integrations_only
):
client = authenticated_client_rbac_manage_integrations_only
tenant = Membership.objects.filter(user=client.user).first().tenant
config = SAMLConfiguration.objects.create(
tenant_id=str(tenant.id),
email_domain="integrations-only-replace.com",
metadata_xml=SAML_METADATA_XML,
)
list_response = client.get(reverse("saml-config-list"))
patch_response = client.patch(
reverse("saml-config-detail", kwargs={"pk": config.id}),
data={"metadata_xml": SAML_METADATA_XML},
format="json",
)
delete_response = client.delete(
reverse("saml-config-detail", kwargs={"pk": config.id})
)
assert list_response.status_code == status.HTTP_403_FORBIDDEN
assert patch_response.status_code == status.HTTP_403_FORBIDDEN
assert delete_response.status_code == status.HTTP_403_FORBIDDEN
assert SAMLConfiguration.objects.filter(id=config.id).exists()
def test_manage_account_only_can_create_and_delete(
self, authenticated_client_rbac_manage_account
):
client = authenticated_client_rbac_manage_account
create_response = self._create(client, "account-only.com")
assert create_response.status_code == status.HTTP_201_CREATED
config = SAMLConfiguration.objects.get(email_domain="account-only.com")
delete_response = client.delete(
reverse("saml-config-detail", kwargs={"pk": config.id})
)
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
assert not SAMLConfiguration.objects.filter(id=config.id).exists()
@pytest.mark.django_db
class TestCrossTenantRoleLeak:
"""Regression tests for get_role() cross-tenant privilege leak.
+15
View File
@@ -30,6 +30,21 @@ def test_initialize_sentry_uses_configured_dsn():
assert mock_init.call_args.kwargs["before_send"] is sentry_settings.before_send
def test_initialize_sentry_sends_no_personal_data():
with (
patch.object(
sentry_settings.env,
"str",
return_value="https://fake-public-key@sentry.example.invalid/1",
),
patch.object(sentry_settings.sentry_sdk, "init") as mock_init,
):
sentry_settings.initialize_sentry()
assert mock_init.call_args.kwargs["send_default_pii"] is False
assert mock_init.call_args.kwargs["max_request_body_size"] == "never"
def _make_log_record(msg, level=logging.ERROR, name="test", args=None):
"""Build a real LogRecord so getMessage() works like in production."""
record = logging.LogRecord(
+295 -23
View File
@@ -5,6 +5,8 @@ from api.v1.serializer_utils.integrations import (
)
from api.v1.serializer_utils.providers import ProviderSecretField
from api.v1.serializers import (
KUBERNETES_KUBECONFIG_INVALID_ERROR,
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
ImageProviderSecret,
IntegrationSerializer,
IntegrationUpdateSerializer,
@@ -215,36 +217,34 @@ class TestOracleCloudProviderSecret:
assert serializer.is_valid(), serializer.errors
assert "region" not in serializer.validated_data
def test_accepts_and_ignores_region_field(self):
secret = self.valid_secret(region="us-phoenix-1")
serializer = OracleCloudProviderSecret(data=secret)
assert serializer.is_valid(), serializer.errors
assert "region" not in serializer.validated_data
@pytest.mark.parametrize(
"legacy_field, legacy_value",
[
("region", None),
("region", ""),
("region", {"name": "us-ashburn-1"}),
],
)
def test_accepts_and_ignores_any_legacy_region_value(
self, legacy_field, legacy_value
):
def test_keeps_region_as_home_region(self):
serializer = OracleCloudProviderSecret(
data=self.valid_secret(**{legacy_field: legacy_value})
data=self.valid_secret(region=" me-abudhabi-1 ")
)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["region"] == "me-abudhabi-1"
assert legacy_field not in serializer.validated_data
def test_rejects_unknown_region(self):
serializer = OracleCloudProviderSecret(
data=self.valid_secret(region="mars-north-1")
)
assert not serializer.is_valid()
assert "region" in serializer.errors
@pytest.mark.parametrize("legacy_value", [None, "", {"name": "us-ashburn-1"}])
def test_drops_blank_or_non_string_region(self, legacy_value):
serializer = OracleCloudProviderSecret(
data=self.valid_secret(region=legacy_value)
)
assert serializer.is_valid(), serializer.errors
assert "region" not in serializer.validated_data
class TestProviderSecretFieldSchema:
def test_oraclecloud_schema_includes_legacy_region_field(self):
def test_oraclecloud_schema_region_is_not_deprecated(self):
schema = ProviderSecretField._spectacular_annotation["field"]
oraclecloud_schema = next(
credential_schema
@@ -253,7 +253,7 @@ class TestProviderSecretFieldSchema:
== "Oracle Cloud Infrastructure (OCI) API Key Credentials"
)
assert oraclecloud_schema["properties"]["region"]["deprecated"] is True
assert "deprecated" not in oraclecloud_schema["properties"]["region"]
class TestKubernetesProviderSecret:
@@ -342,6 +342,278 @@ current-context: test-context
assert not serializer.is_valid()
assert "kubeconfig_content" in serializer.errors
@staticmethod
def _kubeconfig(user_extra="", cluster_extra=""):
return f"""
apiVersion: v1
kind: Config
clusters:
- name: test-cluster
cluster:
server: https://kubernetes.example.test
{cluster_extra}
users:
- name: test-user
user:
token: test-token
{user_extra}
contexts:
- name: test-context
context:
cluster: test-cluster
user: test-user
current-context: test-context
"""
@staticmethod
def _assert_rejected_without_echo(
kubeconfig_content, leaked_value, expected_message
):
serializer = KubernetesProviderSecret(
data={"kubeconfig_content": kubeconfig_content}
)
assert serializer.is_valid() is False
assert serializer.errors["kubeconfig_content"] == [expected_message]
assert leaked_value not in str(serializer.errors)
def test_inline_token_with_certificate_authority_data_is_accepted(self):
kubeconfig_content = self._kubeconfig(
cluster_extra=" certificate-authority-data: dGVzdA=="
)
serializer = KubernetesProviderSecret(
data={"kubeconfig_content": kubeconfig_content}
)
assert serializer.is_valid()
def test_kubeconfig_with_token_file_is_rejected(self):
self._assert_rejected_without_echo(
self._kubeconfig(user_extra=" tokenFile: /etc/passwd"),
"/etc/passwd",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
def test_kubeconfig_with_client_certificate_file_is_rejected(self):
self._assert_rejected_without_echo(
self._kubeconfig(user_extra=" client-certificate: /etc/passwd"),
"/etc/passwd",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
def test_kubeconfig_with_client_key_file_is_rejected(self):
self._assert_rejected_without_echo(
self._kubeconfig(user_extra=" client-key: /etc/passwd"),
"/etc/passwd",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
def test_kubeconfig_with_certificate_authority_file_is_rejected(self):
self._assert_rejected_without_echo(
self._kubeconfig(cluster_extra=" certificate-authority: /etc/passwd"),
"/etc/passwd",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
def test_kubeconfig_with_cluster_proxy_url_is_rejected(self):
self._assert_rejected_without_echo(
self._kubeconfig(cluster_extra=" proxy-url: http://proxy.evil.test"),
"proxy.evil.test",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
@pytest.mark.parametrize("provider_name", ["gcp", "oidc", "azure"])
def test_kubeconfig_with_any_auth_provider_is_rejected(self, provider_name):
self._assert_rejected_without_echo(
self._kubeconfig(
user_extra=(
" auth-provider:\n"
f" name: {provider_name}\n"
" config:\n"
" idp-issuer-url: https://idp.evil.test"
)
),
"idp.evil.test",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
def test_kubeconfig_with_unknown_user_key_is_rejected(self):
self._assert_rejected_without_echo(
self._kubeconfig(user_extra=" bogus: /etc/passwd"),
"/etc/passwd",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
def test_kubeconfig_with_unknown_cluster_key_is_rejected(self):
self._assert_rejected_without_echo(
self._kubeconfig(cluster_extra=" bogus: /etc/passwd"),
"/etc/passwd",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
def test_kubeconfig_with_allowlisted_cluster_keys_is_accepted(self):
serializer = KubernetesProviderSecret(
data={
"kubeconfig_content": self._kubeconfig(
cluster_extra=(
" certificate-authority-data: dGVzdA==\n"
" insecure-skip-tls-verify: false\n"
" tls-server-name: kubernetes.example.test\n"
" disable-compression: true\n"
" extensions: []"
)
)
}
)
assert serializer.is_valid(), serializer.errors
def test_kubeconfig_with_non_list_clusters_is_rejected(self):
kubeconfig_content = """
apiVersion: v1
kind: Config
clusters:
name: /etc/passwd
"""
self._assert_rejected_without_echo(
kubeconfig_content, "/etc/passwd", KUBERNETES_KUBECONFIG_INVALID_ERROR
)
def test_kubeconfig_with_non_dict_cluster_entry_is_rejected(self):
kubeconfig_content = """
apiVersion: v1
kind: Config
clusters:
- /etc/passwd
"""
self._assert_rejected_without_echo(
kubeconfig_content, "/etc/passwd", KUBERNETES_KUBECONFIG_INVALID_ERROR
)
def test_kubeconfig_with_non_dict_cluster_value_is_rejected(self):
kubeconfig_content = """
apiVersion: v1
kind: Config
clusters:
- name: test-cluster
cluster: /etc/passwd
"""
self._assert_rejected_without_echo(
kubeconfig_content, "/etc/passwd", KUBERNETES_KUBECONFIG_INVALID_ERROR
)
@pytest.mark.parametrize(
"user_extra",
[
" token: test-token",
" username: test-user\n password: test-password",
" client-certificate-data: dGVzdA==\n client-key-data: dGVzdA==",
" token: test-token\n as: other-user",
" token: test-token\n as-uid: 1234",
" token: test-token\n as-groups:\n - group-a",
" token: test-token\n as-user-extra:\n scopes:\n - read",
],
ids=[
"token",
"username-password",
"client-cert-data",
"as",
"as-uid",
"as-groups",
"as-user-extra",
],
)
def test_kubeconfig_with_allowlisted_user_keys_is_accepted(self, user_extra):
kubeconfig_content = f"""
apiVersion: v1
kind: Config
clusters:
- name: test-cluster
cluster:
server: https://kubernetes.example.test
certificate-authority-data: dGVzdA==
users:
- name: test-user
user:
{user_extra}
contexts:
- name: test-context
context:
cluster: test-cluster
user: test-user
current-context: test-context
"""
serializer = KubernetesProviderSecret(
data={"kubeconfig_content": kubeconfig_content}
)
assert serializer.is_valid()
@pytest.mark.parametrize(
"kubeconfig_content",
[
"""
apiVersion: v1
kind: Config
clusters:
- name: test-cluster
cluster:
server: https://kubernetes.example.test
users:
- name: good-user
user:
token: test-token
- name: bad-user
user:
tokenFile: /etc/passwd
""",
"""
apiVersion: v1
kind: Config
clusters:
- name: good-cluster
cluster:
server: https://kubernetes.example.test
- name: bad-cluster
cluster:
server: https://kubernetes.example.test
certificate-authority: /etc/passwd
users:
- name: test-user
user:
token: test-token
""",
],
ids=["second-user", "second-cluster"],
)
def test_kubeconfig_with_bad_second_entry_is_rejected(self, kubeconfig_content):
self._assert_rejected_without_echo(
kubeconfig_content,
"/etc/passwd",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
def test_kubeconfig_with_indented_document_is_rejected(self):
kubeconfig_content = (
" wrapper:\n"
" users:\n"
" - name: u\n"
" user:\n"
" tokenFile: /etc/passwd\n"
" clusters:\n"
" - name: c\n"
" cluster:\n"
" server: https://example.test\n"
)
self._assert_rejected_without_echo(
kubeconfig_content,
"/etc/passwd",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
def test_malformed_kubeconfig_is_rejected(self):
serializer = KubernetesProviderSecret(
data={"kubeconfig_content": "apiVersion: ["}
+130 -12
View File
@@ -14,6 +14,7 @@ from api.utils import (
return_prowler_provider,
validate_invitation,
)
from api.v1.serializers import KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR
from prowler.providers.alibabacloud.alibabacloud_provider import AlibabacloudProvider
from prowler.providers.aws.aws_provider import AwsProvider
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection
@@ -35,6 +36,34 @@ from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvid
from prowler.providers.vercel.vercel_provider import VercelProvider
from rest_framework.exceptions import NotFound, ValidationError
VALID_KUBECONFIG = """\
apiVersion: v1
kind: Config
clusters:
- name: test-cluster
cluster:
server: https://example.invalid:6443
certificate-authority-data: Y2E=
users:
- name: test-user
user:
token: test-token
contexts:
- name: test-context
context:
cluster: test-cluster
user: test-user
current-context: test-context
"""
TOKEN_FILE_KUBECONFIG = VALID_KUBECONFIG.replace(
"token: test-token", "tokenFile: /etc/passwd"
)
EXEC_KUBECONFIG = VALID_KUBECONFIG.replace(
"token: test-token", "exec:\n command: test-command"
)
class TestMergeDicts:
def test_simple_merge(self):
@@ -172,7 +201,7 @@ class TestInitializeProwlerProvider:
)
@patch("api.utils.return_prowler_provider")
def test_initialize_oraclecloud_provider_removes_region_string(
def test_initialize_oraclecloud_provider_passes_region_as_home_region(
self, mock_return_prowler_provider
):
provider = MagicMock()
@@ -182,7 +211,7 @@ class TestInitializeProwlerProvider:
"fingerprint": "00:11:22:33:44:55:66:77",
"key_content": "fake-base64-key-content",
"tenancy": "ocid1.tenancy.oc1..fake",
"region": "us-ashburn-1",
"region": "me-abudhabi-1",
}
mock_return_prowler_provider.return_value = MagicMock()
@@ -193,6 +222,7 @@ class TestInitializeProwlerProvider:
fingerprint="00:11:22:33:44:55:66:77",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..fake",
home_region="me-abudhabi-1",
)
@patch("api.utils.return_prowler_provider")
@@ -254,15 +284,73 @@ class TestProwlerProviderConnectionTest:
fingerprint="00:11:22:33:44:55:66:77",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
region=getattr(
OraclecloudProvider,
"_bootstrap_region",
OraclecloudProvider._home_region,
),
region=OraclecloudProvider._bootstrap_region,
provider_id="ocid1.tenancy.oc1..aaaaaaaexample",
raise_on_exception=False,
)
@patch("api.utils.return_prowler_provider")
def test_oraclecloud_connection_test_uses_stored_region_for_identity(
self, mock_return_prowler_provider
):
provider = MagicMock()
provider.uid = "ocid1.tenancy.oc1..aaaaaaaexample"
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
provider.secret.secret = {
"user": "ocid1.user.oc1..aaaaaaaexample",
"fingerprint": "00:11:22:33:44:55:66:77",
"key_content": "fake-base64-key-content",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
"region": "me-abudhabi-1",
}
mock_return_prowler_provider.return_value = MagicMock()
prowler_provider_connection_test(provider)
mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
user="ocid1.user.oc1..aaaaaaaexample",
fingerprint="00:11:22:33:44:55:66:77",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
region="me-abudhabi-1",
provider_id="ocid1.tenancy.oc1..aaaaaaaexample",
raise_on_exception=False,
)
@patch("api.utils.return_prowler_provider")
def test_kubernetes_connection_test_rejects_invalid_kubeconfig(
self, mock_return_prowler_provider
):
provider = MagicMock()
provider.provider = Provider.ProviderChoices.KUBERNETES.value
provider.uid = "provider_uid"
provider.secret.secret = {"kubeconfig_content": TOKEN_FILE_KUBECONFIG}
connection = prowler_provider_connection_test(provider)
assert connection.is_connected is False
assert (
str(connection.error) == KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR
)
mock_return_prowler_provider.return_value.test_connection.assert_not_called()
@patch("api.utils.return_prowler_provider")
def test_kubernetes_connection_test_with_valid_kubeconfig(
self, mock_return_prowler_provider
):
provider = MagicMock()
provider.provider = Provider.ProviderChoices.KUBERNETES.value
provider.uid = "provider_uid"
provider.secret.secret = {"kubeconfig_content": VALID_KUBECONFIG}
prowler_provider_connection_test(provider)
mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
kubeconfig_content=VALID_KUBECONFIG,
provider_id="provider_uid",
raise_on_exception=False,
)
@pytest.mark.django_db
@patch("api.utils.return_prowler_provider")
def test_prowler_provider_connection_test_without_secret(
@@ -380,10 +468,6 @@ class TestGetProwlerProviderKwargs:
Provider.ProviderChoices.GOOGLEWORKSPACE.value,
{},
),
(
Provider.ProviderChoices.KUBERNETES.value,
{"context": "provider_uid"},
),
(
Provider.ProviderChoices.M365.value,
{},
@@ -434,7 +518,40 @@ class TestGetProwlerProviderKwargs:
expected_result = {**secret_dict, **expected_extra_kwargs}
assert result == expected_result
def test_get_prowler_provider_kwargs_oraclecloud_removes_region(
def test_get_prowler_provider_kwargs_kubernetes_valid_kubeconfig(self):
provider = MagicMock()
provider.provider = Provider.ProviderChoices.KUBERNETES.value
provider.uid = "provider_uid"
provider.secret.secret = {"kubeconfig_content": VALID_KUBECONFIG}
assert get_prowler_provider_kwargs(provider) == {
"kubeconfig_content": VALID_KUBECONFIG,
"context": "provider_uid",
}
@pytest.mark.parametrize(
"secret",
[
{"kubeconfig_content": TOKEN_FILE_KUBECONFIG},
{"kubeconfig_content": EXEC_KUBECONFIG},
{"kubeconfig_content": "[]"},
{"key": "value"},
],
)
def test_get_prowler_provider_kwargs_kubernetes_rejects_invalid_kubeconfig(
self, secret
):
provider = MagicMock()
provider.provider = Provider.ProviderChoices.KUBERNETES.value
provider.uid = "provider_uid"
provider.secret.secret = secret
with pytest.raises(ValidationError) as exc_info:
get_prowler_provider_kwargs(provider)
assert "/etc/passwd" not in str(exc_info.value)
def test_get_prowler_provider_kwargs_oraclecloud_maps_region_to_home_region(
self,
):
secret_dict = {
@@ -461,6 +578,7 @@ class TestGetProwlerProviderKwargs:
"key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----",
"tenancy": "ocid1.tenancy.oc1..fake",
"pass_phrase": "fake-passphrase",
"home_region": "us-ashburn-1",
}
def test_get_prowler_provider_kwargs_with_mutelist(self):
+712 -7
View File
@@ -60,8 +60,10 @@ from api.models import (
User,
UserRoleRelationship,
)
from api.rbac.permissions import TASK_REVOKE_PERMISSIONS
from api.rls import Tenant
from api.uuid_utils import datetime_to_uuid7
from api.v1.serializers import MembershipIncludeSerializer
from api.v1.views import (
ComplianceOverviewViewSet,
CustomSAMLLoginView,
@@ -82,7 +84,7 @@ 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 import RequestFactory, override_settings
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from django_celery_results.models import TaskResult
@@ -96,6 +98,11 @@ from rest_framework_simplejwt.token_blacklist.models import (
OutstandingToken,
)
from rest_framework_simplejwt.tokens import RefreshToken
from tasks.jobs.backfill import (
aggregate_scan_category_summaries,
aggregate_scan_resource_group_summaries,
backfill_resource_scan_summaries,
)
class TestViewSet:
@@ -132,6 +139,145 @@ class TestUserViewSet:
assert response.status_code == status.HTTP_200_OK
assert response.json()["data"]["attributes"]["email"] == create_test_user.email
@staticmethod
def _shared_user_in_two_tenants(tenant_a, tenant_b):
shared_user = User.objects.create_user(
name="shared_user",
email=f"shared-user-{uuid4()}@prowler.com",
password="TmpPass123@",
)
Membership.objects.create(user=shared_user, tenant=tenant_a)
Membership.objects.create(user=shared_user, tenant=tenant_b)
role_a = Role.objects.create(name="role-a", tenant=tenant_a)
role_b = Role.objects.create(name="role-b", tenant=tenant_b)
UserRoleRelationship.objects.create(
user=shared_user, role=role_a, tenant=tenant_a
)
UserRoleRelationship.objects.create(
user=shared_user, role=role_b, tenant=tenant_b
)
return shared_user, role_a, role_b
@staticmethod
def _user_in_two_tenants_with_unique_roles(tenant_a, tenant_b):
suffix = uuid4()
user = User.objects.create_user(
name="scaled_user",
email=f"scaled-user-{suffix}@prowler.com",
password="TmpPass123@",
)
for tenant in (tenant_a, tenant_b):
Membership.objects.create(user=user, tenant=tenant)
role = Role.objects.create(name=f"role-{tenant.id}-{suffix}", tenant=tenant)
UserRoleRelationship.objects.create(user=user, role=role, tenant=tenant)
return user
def test_users_list_query_count_does_not_grow_with_user_count(
self, authenticated_client, tenants_fixture
):
tenant_a, tenant_b, _ = tenants_fixture
self._user_in_two_tenants_with_unique_roles(tenant_a, tenant_b)
params = {"include": "memberships,roles"}
with CaptureQueriesContext(connection) as baseline:
response = authenticated_client.get(reverse("user-list"), params)
assert response.status_code == status.HTTP_200_OK
baseline_users = len(response.json()["data"])
for _ in range(3):
self._user_in_two_tenants_with_unique_roles(tenant_a, tenant_b)
with CaptureQueriesContext(connection) as scaled:
response = authenticated_client.get(reverse("user-list"), params)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) > baseline_users
assert len(scaled.captured_queries) == len(baseline.captured_queries), (
"Tenant-scoped relations are re-querying per user: "
f"{len(baseline.captured_queries)} queries for {baseline_users} users "
f"vs {len(scaled.captured_queries)} for "
f"{len(response.json()['data'])}. The prefetch is being discarded."
)
def test_users_retrieve_scopes_relations_to_active_tenant(
self, authenticated_client, tenants_fixture
):
tenant_a, tenant_b, _ = tenants_fixture
shared_user, role_a, role_b = self._shared_user_in_two_tenants(
tenant_a, tenant_b
)
response = authenticated_client.get(
reverse("user-detail", kwargs={"pk": shared_user.id}),
{"include": "memberships,roles,memberships.tenant"},
)
assert response.status_code == status.HTTP_200_OK
payload = response.json()
relationships = payload["data"]["relationships"]
assert len(relationships["memberships"]["data"]) == 1
assert [item["id"] for item in relationships["roles"]["data"]] == [
str(role_a.id)
]
included = payload["included"]
included_memberships = [i for i in included if i["type"] == "memberships"]
assert len(included_memberships) == 1
assert included_memberships[0]["relationships"]["tenant"]["data"]["id"] == str(
tenant_a.id
)
assert {i["id"] for i in included if i["type"] == "roles"} == {str(role_a.id)}
assert {
i["attributes"]["name"] for i in included if i["type"] == "tenants"
} == {tenant_a.name}
body = response.content.decode()
assert tenant_b.name not in body
assert str(tenant_b.id) not in body
assert str(role_b.id) not in body
def test_users_me_keeps_own_memberships_and_scopes_roles(
self, authenticated_client, create_test_user, tenants_fixture
):
tenant_a, tenant_b, _ = tenants_fixture
response = authenticated_client.get(
reverse("user-me"), {"include": "memberships,roles,memberships.tenant"}
)
assert response.status_code == status.HTTP_200_OK
payload = response.json()
relationships = payload["data"]["relationships"]
assert len(relationships["memberships"]["data"]) == 2
assert {
i["attributes"]["name"]
for i in payload["included"]
if i["type"] == "tenants"
} == {tenant_a.name, tenant_b.name}
active_roles = create_test_user.roles.filter(tenant_id=tenant_a.id)
assert {i["id"] for i in relationships["roles"]["data"]} == {
str(role.id) for role in active_roles
}
def test_membership_include_serializer_hides_tenant_of_non_member(
self, create_test_user, tenants_fixture
):
tenant_a, _, isolated_tenant = tenants_fixture
outsider = User.objects.create_user(
name="outsider",
email=f"outsider-{uuid4()}@prowler.com",
password="TmpPass123@",
)
foreign_membership = Membership.objects.create(
user=outsider, tenant=isolated_tenant
)
own_membership = Membership.objects.get(user=create_test_user, tenant=tenant_a)
request = SimpleNamespace(user=create_test_user, tenant_id=str(tenant_a.id))
serializer = MembershipIncludeSerializer(context={"request": request})
assert serializer.get_tenant(own_membership) == tenant_a
assert serializer.get_tenant(foreign_membership) is None
def test_users_create(self, client):
valid_user_payload = {
"name": "test",
@@ -3362,7 +3508,7 @@ current-context: test-context
provider_secret = ProviderSecret.objects.get()
assert "region" not in provider_secret.secret
def test_provider_secrets_create_oraclecloud_accepts_and_ignores_region(
def test_provider_secrets_create_oraclecloud_stores_region(
self,
authenticated_client,
oraclecloud_provider,
@@ -3371,14 +3517,14 @@ current-context: test-context
authenticated_client,
oraclecloud_provider,
self._oraclecloud_secret(
key_content=" test-key-content ", region=" us-ashburn-1 "
key_content=" test-key-content ", region=" me-abudhabi-1 "
),
)
assert response.status_code == status.HTTP_201_CREATED
provider_secret = ProviderSecret.objects.get()
assert provider_secret.secret["key_content"] == "test-key-content"
assert "region" not in provider_secret.secret
assert provider_secret.secret["region"] == "me-abudhabi-1"
def test_provider_secrets_update_oraclecloud_without_region_stores_no_region(
self,
@@ -3411,7 +3557,7 @@ current-context: test-context
provider_secret.refresh_from_db()
assert "region" not in provider_secret.secret
def test_provider_secrets_update_oraclecloud_accepts_and_ignores_region(
def test_provider_secrets_update_oraclecloud_stores_region(
self,
authenticated_client,
oraclecloud_provider,
@@ -3429,7 +3575,7 @@ current-context: test-context
"type": "provider-secrets",
"id": str(provider_secret.id),
"attributes": {
"secret": self._oraclecloud_secret(region=" us-ashburn-1 ")
"secret": self._oraclecloud_secret(region=" me-abudhabi-1 ")
},
}
}
@@ -3442,7 +3588,7 @@ current-context: test-context
assert response.status_code == status.HTTP_200_OK
provider_secret.refresh_from_db()
assert "region" not in provider_secret.secret
assert provider_secret.secret["region"] == "me-abudhabi-1"
@pytest.mark.parametrize(
"attributes, error_code, error_pointer",
@@ -3951,6 +4097,43 @@ class TestScanViewSet:
mock_enqueue_scan_execution.assert_called_once()
# assert scan.scanner_args == expected_scanner_args
@patch("api.v1.views.enqueue_scan_execution_on_commit")
def test_scans_create_returns_the_scan_id_in_task_args(
self,
mock_enqueue_scan_execution,
authenticated_client,
okta_provider,
):
"""The 202 is a task, so `task_args` is the only place the scan id is.
It is serialized before the on_commit publish that would otherwise fill
the kwargs, so the record has to carry them from the start.
"""
payload = {
"data": {
"type": "scans",
"attributes": {"name": "New Scan"},
"relationships": {
"provider": {
"data": {"type": "providers", "id": str(okta_provider.id)}
}
},
}
}
response = authenticated_client.post(
reverse("scan-list"),
data=payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert response.status_code == status.HTTP_202_ACCEPTED
scan = Scan.objects.get()
assert response.json()["data"]["attributes"]["task_args"] == {
"scan_id": str(scan.id),
"provider_id": str(okta_provider.id),
}
@patch("tasks.tasks.perform_scan_task.apply_async")
def test_scans_create_queues_scan_when_provider_has_active_scan(
self,
@@ -4540,6 +4723,52 @@ class TestScanViewSet:
assert response.status_code == status.HTTP_302_FOUND
assert response["Location"] == presigned_url
@override_settings(
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com",
DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID="access-key",
DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY="secret-key",
DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN="",
DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION="eu-west-1",
)
def test_report_s3_redirects_to_the_public_storage_host(
self, authenticated_client, scans_fixture, monkeypatch
):
"""The object is looked up internally but the redirect the browser follows is public."""
scan = scans_fixture[0]
bucket = "test-bucket"
key = "report.zip"
scan.output_location = f"s3://{bucket}/{key}"
scan.state = StateChoices.COMPLETED
scan.save()
monkeypatch.setattr(
"api.v1.views.env",
type("env", (), {"str": lambda self, *_args, **_kwargs: bucket})(),
)
head_calls = []
class InternalS3Client:
def head_object(self, Bucket, Key):
head_calls.append((Bucket, Key))
return {}
def generate_presigned_url(self, *_args, **_kwargs):
raise AssertionError("the internal client must not sign the redirect")
monkeypatch.setattr("api.v1.views.get_s3_client", lambda: InternalS3Client())
url = reverse("scan-report", kwargs={"pk": scan.id})
response = authenticated_client.get(url)
assert response.status_code == status.HTTP_302_FOUND
assert head_calls == [(bucket, key)]
location = urlparse(response["Location"])
assert location.netloc == "storage.example.com"
assert location.path == f"/{bucket}/{key}"
assert "X-Amz-Signature" in parse_qs(location.query)
def test_report_s3_success_no_local_files(
self, authenticated_client, scans_fixture, monkeypatch
):
@@ -5156,6 +5385,7 @@ class TestTaskViewSet:
@patch("api.v1.views.AsyncResult", return_value=Mock())
def test_tasks_revoke(self, mock_async_result, authenticated_client, tasks_fixture):
_, task2 = tasks_fixture
self._set_task_name(task2, "scan-perform")
response = authenticated_client.delete(
reverse("task-detail", kwargs={"pk": task2.id})
)
@@ -5171,12 +5401,311 @@ class TestTaskViewSet:
def test_tasks_revoke_invalid_status(self, authenticated_client, tasks_fixture):
task1, _ = tasks_fixture
self._set_task_name(task1, "scan-perform")
response = authenticated_client.delete(
reverse("task-detail", kwargs={"pk": task1.id})
)
# Task status is SUCCESS
assert response.status_code == status.HTTP_400_BAD_REQUEST
@staticmethod
def _set_task_name(task, name):
task.task_runner_task.task_name = name
task.task_runner_task.save(update_fields=["task_name"])
@staticmethod
def _set_task_kwargs(task, kwargs):
task.task_runner_task.task_kwargs = json.dumps(repr(kwargs))
task.task_runner_task.save(update_fields=["task_kwargs"])
@staticmethod
def _client_with_role(tenant, factory, **permissions):
user = User.objects.create_user(
name=f"revoker-{uuid4()}",
email=f"revoker-{uuid4()}@prowler.com",
password=TEST_PASSWORD,
)
Membership.objects.create(
user=user, tenant=tenant, role=Membership.RoleChoices.MEMBER
)
flags = {
"manage_users": False,
"manage_account": False,
"manage_billing": False,
"manage_providers": False,
"manage_integrations": False,
"manage_scans": False,
"unlimited_visibility": True,
**permissions,
}
role = Role.objects.create(
name=f"revoker-{uuid4()}", tenant_id=tenant.id, **flags
)
UserRoleRelationship.objects.create(user=user, role=role, tenant_id=tenant.id)
return factory(user, tenant)
@patch("api.v1.views.AsyncResult")
def test_tasks_revoke_without_permission_is_forbidden(
self, mock_async_result, authenticated_client_no_permissions_rbac, tasks_fixture
):
_, pending_task = tasks_fixture
self._set_task_name(pending_task, "provider-connection-check")
response = authenticated_client_no_permissions_rbac.delete(
reverse("task-detail", kwargs={"pk": pending_task.id})
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_async_result.return_value.revoke.assert_not_called()
@pytest.mark.parametrize(
"task_name, permissions, expected_status",
[
(
"provider-connection-check",
{"manage_providers": True},
status.HTTP_202_ACCEPTED,
),
(
"provider-connection-check",
{"manage_scans": True},
status.HTTP_403_FORBIDDEN,
),
("scan-perform", {"manage_scans": True}, status.HTTP_202_ACCEPTED),
(
"scan-perform-scheduled",
{"manage_providers": True},
status.HTTP_403_FORBIDDEN,
),
(
"integration-jira",
{"manage_integrations": True},
status.HTTP_202_ACCEPTED,
),
("integration-jira", {"manage_providers": True}, status.HTTP_403_FORBIDDEN),
("lighthouse-connection-check", {}, status.HTTP_202_ACCEPTED),
],
)
@patch("api.v1.views.AsyncResult")
def test_tasks_revoke_requires_originating_operation_permission(
self,
mock_async_result,
authenticated_client_for_tenant_factory,
tenants_fixture,
tasks_fixture,
task_name,
permissions,
expected_status,
):
tenant, *_ = tenants_fixture
_, pending_task = tasks_fixture
self._set_task_name(pending_task, task_name)
client = self._client_with_role(
tenant, authenticated_client_for_tenant_factory, **permissions
)
response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id}))
assert response.status_code == expected_status
if expected_status == status.HTTP_202_ACCEPTED:
mock_async_result.return_value.revoke.assert_called_once()
else:
mock_async_result.return_value.revoke.assert_not_called()
@patch("api.v1.views.AsyncResult")
def test_tasks_revoke_provider_deletion_is_forbidden_even_for_admin(
self, mock_async_result, authenticated_client, tasks_fixture
):
_, pending_task = tasks_fixture
self._set_task_name(pending_task, "provider-deletion")
response = authenticated_client.delete(
reverse("task-detail", kwargs={"pk": pending_task.id})
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_async_result.return_value.revoke.assert_not_called()
@patch("api.v1.views.AsyncResult")
def test_tasks_revoke_unmapped_task_is_forbidden(
self, mock_async_result, authenticated_client, tasks_fixture
):
_, pending_task = tasks_fixture
assert pending_task.task_runner_task.task_name not in TASK_REVOKE_PERMISSIONS
response = authenticated_client.delete(
reverse("task-detail", kwargs={"pk": pending_task.id})
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_async_result.return_value.revoke.assert_not_called()
def test_every_rls_task_has_revoke_permissions(self):
from config.celery import RLSTask, celery_app
rls_task_names = {
name for name, task in celery_app.tasks.items() if isinstance(task, RLSTask)
}
assert rls_task_names
assert rls_task_names <= set(TASK_REVOKE_PERMISSIONS)
@patch("api.v1.views.AsyncResult")
def test_tasks_hidden_for_providers_outside_role_visibility(
self,
mock_async_result,
authenticated_client_no_permissions_rbac,
tasks_fixture,
aws_provider_pair,
):
client = authenticated_client_no_permissions_rbac
limited_user = client.user
tenant = Membership.objects.filter(user=limited_user).first().tenant
allowed_provider, denied_provider = aws_provider_pair
allowed_task, denied_task = tasks_fixture
self._set_task_kwargs(
allowed_task,
{"tenant_id": str(tenant.id), "provider_id": str(allowed_provider.id)},
)
self._set_task_name(denied_task, "provider-deletion")
self._set_task_kwargs(
denied_task,
{"tenant_id": str(tenant.id), "provider_id": str(denied_provider.id)},
)
provider_group = ProviderGroup.objects.create(
name="limited-task-group", tenant_id=tenant.id
)
ProviderGroupMembership.objects.create(
tenant_id=tenant.id,
provider_group=provider_group,
provider=allowed_provider,
)
RoleProviderGroupRelationship.objects.create(
tenant_id=tenant.id,
role=limited_user.roles.first(),
provider_group=provider_group,
)
response = client.get(reverse("task-list"))
assert response.status_code == status.HTTP_200_OK
assert [item["id"] for item in response.json()["data"]] == [
str(allowed_task.id)
]
response = client.get(reverse("task-detail", kwargs={"pk": denied_task.id}))
assert response.status_code == status.HTTP_404_NOT_FOUND
response = client.delete(reverse("task-detail", kwargs={"pk": denied_task.id}))
assert response.status_code == status.HTTP_404_NOT_FOUND
mock_async_result.return_value.revoke.assert_not_called()
@patch("api.v1.views.AsyncResult")
def test_tasks_of_soft_deleted_provider_stay_visible_to_its_groups(
self,
mock_async_result,
authenticated_client_for_tenant_factory,
tenants_fixture,
tasks_fixture,
aws_provider_pair,
):
tenant, *_ = tenants_fixture
provider, _ = aws_provider_pair
finished_task, pending_task = tasks_fixture
client = self._client_with_role(
tenant,
authenticated_client_for_tenant_factory,
manage_providers=True,
unlimited_visibility=False,
)
provider_group = ProviderGroup.objects.create(
name="own-group", tenant_id=tenant.id
)
ProviderGroupMembership.objects.create(
tenant_id=tenant.id, provider_group=provider_group, provider=provider
)
RoleProviderGroupRelationship.objects.create(
tenant_id=tenant.id,
role=client.user.roles.first(),
provider_group=provider_group,
)
for task, name in (
(finished_task, "provider-deletion"),
(pending_task, "provider-connection-check"),
):
self._set_task_name(task, name)
self._set_task_kwargs(
task, {"tenant_id": str(tenant.id), "provider_id": str(provider.id)}
)
provider.is_deleted = True
provider.save()
response = client.get(reverse("task-detail", kwargs={"pk": finished_task.id}))
assert response.status_code == status.HTTP_200_OK
response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id}))
assert response.status_code == status.HTTP_202_ACCEPTED
mock_async_result.return_value.revoke.assert_called_once()
def test_tasks_without_provider_stay_visible_for_limited_roles(
self, authenticated_client_no_permissions_rbac, tasks_fixture, aws_provider_pair
):
response = authenticated_client_no_permissions_rbac.get(reverse("task-list"))
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == len(tasks_fixture)
def test_tasks_list_without_role_is_forbidden(
self, authenticated_client_rbac_noroles, tasks_fixture
):
response = authenticated_client_rbac_noroles.get(reverse("task-list"))
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_tasks_revoke_without_permission_hides_task_status(
self, authenticated_client_no_permissions_rbac, tasks_fixture
):
finished_task, _ = tasks_fixture
self._set_task_name(finished_task, "provider-connection-check")
response = authenticated_client_no_permissions_rbac.delete(
reverse("task-detail", kwargs={"pk": finished_task.id})
)
assert response.status_code == status.HTTP_403_FORBIDDEN
@patch("api.v1.views.AsyncResult")
def test_tasks_revoke_unauthenticated_returns_401(
self, mock_async_result, tasks_fixture
):
from rest_framework.test import APIClient
_, pending_task = tasks_fixture
self._set_task_name(pending_task, "scan-perform")
response = APIClient().delete(
reverse("task-detail", kwargs={"pk": pending_task.id})
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
mock_async_result.return_value.revoke.assert_not_called()
@patch("api.v1.views.AsyncResult")
def test_tasks_revoke_foreign_tenant_task_returns_404(
self,
mock_async_result,
authenticated_client_for_tenant_factory,
tenants_fixture,
tasks_fixture,
):
_, foreign_tenant, *_ = tenants_fixture
_, pending_task = tasks_fixture
self._set_task_name(pending_task, "scan-perform")
client = self._client_with_role(
foreign_tenant, authenticated_client_for_tenant_factory, manage_scans=True
)
response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id}))
assert response.status_code == status.HTTP_404_NOT_FOUND
mock_async_result.return_value.revoke.assert_not_called()
@pytest.mark.django_db
class TestAttackPathsScanViewSet:
@@ -6565,6 +7094,99 @@ class TestAttackPathsScanViewSet:
assert response.status_code == status.HTTP_400_BAD_REQUEST
def _client_restricted_to_provider(tenant, provider, factory):
"""Authenticated client for a fresh member whose role only sees `provider`."""
user = User.objects.create_user(
name=f"restricted-{uuid4()}",
email=f"restricted-{uuid4()}@prowler.com",
password=TEST_PASSWORD,
)
Membership.objects.create(
user=user, tenant=tenant, role=Membership.RoleChoices.MEMBER
)
role = Role.objects.create(
name=f"restricted-{uuid4()}",
tenant_id=tenant.id,
manage_users=False,
manage_account=False,
manage_billing=False,
manage_providers=False,
manage_integrations=False,
manage_scans=False,
unlimited_visibility=False,
)
UserRoleRelationship.objects.create(user=user, role=role, tenant_id=tenant.id)
provider_group = ProviderGroup.objects.create(
name=f"restricted-{uuid4()}", tenant_id=tenant.id
)
ProviderGroupMembership.objects.create(
tenant_id=tenant.id, provider_group=provider_group, provider=provider
)
RoleProviderGroupRelationship.objects.create(
tenant_id=tenant.id, role=role, provider_group=provider_group
)
return factory(user, tenant)
def _completed_scan_with_summaries(
tenant, provider, resources, *, categories, resource_group
):
"""Completed scan for `provider` with one finding over `resources`, summary tables filled."""
tenant_id = str(tenant.id)
scan = Scan.objects.create(
name=f"scan-{uuid4()}",
provider=provider,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant_id=tenant_id,
)
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"finding-{uuid4()}",
scan=scan,
delta="new",
status=Status.FAIL,
status_extended="test status",
impact=Severity.critical,
impact_extended="test impact",
severity=Severity.critical,
raw_result={"status": Status.FAIL},
check_id="scoped_check",
check_metadata={"CheckId": "scoped_check"},
categories=categories,
resource_groups=resource_group,
first_seen_at="2024-01-02T00:00:00Z",
)
finding.add_resources(resources)
backfill_resource_scan_summaries(tenant_id, str(scan.id))
aggregate_scan_category_summaries(tenant_id, str(scan.id))
aggregate_scan_resource_group_summaries(tenant_id, str(scan.id))
return scan
@pytest.fixture
def scans_per_provider_fixture(tenants_fixture, aws_provider_pair, resources_fixture):
"""One completed scan per provider; provider 2 only owns the `test` typed resource."""
tenant = tenants_fixture[0]
provider1, provider2 = aws_provider_pair
resource1, resource2, resource3 = resources_fixture
_completed_scan_with_summaries(
tenant,
provider1,
[resource1, resource2],
categories=["iam"],
resource_group="identity",
)
_completed_scan_with_summaries(
tenant,
provider2,
[resource3],
categories=["data-protection"],
resource_group="data",
)
return tenant, provider2
@pytest.mark.django_db
class TestResourceViewSet:
def test_resources_list_none(self, authenticated_client):
@@ -6936,6 +7558,42 @@ class TestResourceViewSet:
assert attributes["types"] == [latest_scan_resource.type]
assert "groups" in attributes
def test_resources_metadata_restricted_to_provider_group(
self, authenticated_client_for_tenant_factory, scans_per_provider_fixture
):
tenant, provider2 = scans_per_provider_fixture
client = _client_restricted_to_provider(
tenant, provider2, authenticated_client_for_tenant_factory
)
response = client.get(
reverse("resource-metadata"), {"filter[updated_at]": TODAY}
)
assert response.status_code == status.HTTP_200_OK
attributes = response.json()["data"]["attributes"]
assert set(attributes["services"]) == {"ec2"}
assert set(attributes["regions"]) == {"us-east-1"}
assert set(attributes["types"]) == {"test"}
assert set(attributes["groups"]) == {"compute"}
def test_resources_metadata_latest_restricted_to_provider_group(
self, authenticated_client_for_tenant_factory, scans_per_provider_fixture
):
tenant, provider2 = scans_per_provider_fixture
client = _client_restricted_to_provider(
tenant, provider2, authenticated_client_for_tenant_factory
)
response = client.get(reverse("resource-metadata_latest"))
assert response.status_code == status.HTTP_200_OK
attributes = response.json()["data"]["attributes"]
assert set(attributes["services"]) == {"ec2"}
assert set(attributes["regions"]) == {"us-east-1"}
assert set(attributes["types"]) == {"test"}
assert set(attributes["groups"]) == {"compute"}
def test_resources_latest_filter_by_provider_id(
self, authenticated_client, latest_scan_resource
):
@@ -8568,6 +9226,53 @@ class TestFindingViewSet:
assert "groups" in attributes
assert "ai_ml" in attributes["groups"]
@patch("api.v1.views.backfill_scan_resource_summaries_task.apply_async")
def test_findings_metadata_restricted_to_provider_group(
self,
mock_backfill,
authenticated_client_for_tenant_factory,
scans_per_provider_fixture,
):
tenant, provider2 = scans_per_provider_fixture
client = _client_restricted_to_provider(
tenant, provider2, authenticated_client_for_tenant_factory
)
response = client.get(
reverse("finding-metadata"), {"filter[inserted_at]": TODAY}
)
assert response.status_code == status.HTTP_200_OK
mock_backfill.assert_not_called()
attributes = response.json()["data"]["attributes"]
assert set(attributes["services"]) == {"ec2"}
assert set(attributes["regions"]) == {"us-east-1"}
assert set(attributes["resource_types"]) == {"test"}
assert set(attributes["categories"]) == {"data-protection"}
@patch("api.v1.views.backfill_scan_resource_summaries_task.apply_async")
def test_findings_metadata_latest_restricted_to_provider_group(
self,
mock_backfill,
authenticated_client_for_tenant_factory,
scans_per_provider_fixture,
):
tenant, provider2 = scans_per_provider_fixture
client = _client_restricted_to_provider(
tenant, provider2, authenticated_client_for_tenant_factory
)
response = client.get(reverse("finding-metadata_latest"))
assert response.status_code == status.HTTP_200_OK
mock_backfill.assert_not_called()
attributes = response.json()["data"]["attributes"]
assert set(attributes["services"]) == {"ec2"}
assert set(attributes["regions"]) == {"us-east-1"}
assert set(attributes["resource_types"]) == {"test"}
assert set(attributes["categories"]) == {"data-protection"}
assert set(attributes["groups"]) == {"data"}
def test_findings_filter_by_category(
self, authenticated_client, findings_with_categories
):
+33 -8
View File
@@ -17,7 +17,7 @@ from api.models import (
Role,
UserRoleRelationship,
)
from api.v1.serializers import FindingMetadataSerializer
from api.v1.serializers import FindingMetadataSerializer, KubernetesProviderSecret
from django.contrib.postgres.aggregates import ArrayAgg
from django.db import transaction
from django.db.models import Subquery
@@ -201,6 +201,13 @@ def return_prowler_provider(
return prowler_provider
def _validate_kubernetes_secret(secret: dict) -> None:
"""Re-validate a stored kubeconfig at use time; errors never echo its content."""
KubernetesProviderSecret(
data={"kubeconfig_content": secret.get("kubeconfig_content")}
).is_valid(raise_exception=True)
def get_prowler_provider_kwargs(
provider: Provider, mutelist_processor: Processor | None = None
) -> dict:
@@ -225,6 +232,7 @@ def get_prowler_provider_kwargs(
"project_ids": [provider.uid],
}
elif provider.provider == Provider.ProviderChoices.KUBERNETES.value:
_validate_kubernetes_secret(prowler_provider_kwargs)
prowler_provider_kwargs = {**prowler_provider_kwargs, "context": provider.uid}
elif provider.provider == Provider.ProviderChoices.GITHUB.value:
if provider.uid:
@@ -302,17 +310,26 @@ def get_prowler_provider_kwargs(
def _normalize_oraclecloud_provider_kwargs(secret: dict) -> dict:
"""Normalize external OCI secret fields into SDK provider kwargs."""
prowler_provider_kwargs = secret.copy()
prowler_provider_kwargs.pop("region", None)
home_region = _oraclecloud_home_region(prowler_provider_kwargs.pop("region", None))
if home_region:
prowler_provider_kwargs["home_region"] = home_region
return prowler_provider_kwargs
def _oraclecloud_home_region(region) -> str | None:
"""Return the stored OCI region as a home region, ignoring blank or non-string legacy values."""
if isinstance(region, str) and region.strip():
return region.strip()
return None
def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict:
"""Normalize external OCI secret fields into test_connection kwargs."""
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
prowler_provider_kwargs = secret.copy()
prowler_provider_kwargs.pop("region", None)
home_region = _oraclecloud_home_region(prowler_provider_kwargs.pop("region", None))
if (
prowler_provider_kwargs.get("user")
@@ -323,11 +340,9 @@ def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict:
or prowler_provider_kwargs.get("key_file")
)
):
# Connection validation needs one OCI endpoint, but scans remain unfiltered.
prowler_provider_kwargs["region"] = getattr(
OraclecloudProvider,
"_bootstrap_region",
OraclecloudProvider._home_region,
# Identity calls only succeed in a region the tenancy is subscribed to.
prowler_provider_kwargs["region"] = (
home_region or OraclecloudProvider._bootstrap_region
)
return prowler_provider_kwargs
@@ -385,6 +400,16 @@ def prowler_provider_connection_test(provider: Provider) -> Connection:
except Provider.secret.RelatedObjectDoesNotExist as secret_error:
return Connection(is_connected=False, error=secret_error)
if provider.provider == Provider.ProviderChoices.KUBERNETES.value:
# A rejected stored kubeconfig is a failed connection, not a task error.
try:
_validate_kubernetes_secret(prowler_provider_kwargs)
except ValidationError as validation_error:
return Connection(
is_connected=False,
error=Exception(validation_error.detail["kubeconfig_content"][0]),
)
# For IaC provider, construct the kwargs properly for test_connection
if provider.provider == Provider.ProviderChoices.IAC.value:
# Don't pass repository_url from secret, use scan_repository_url with the UID
@@ -214,7 +214,8 @@ from rest_framework_json_api import serializers
"kubeconfig_content": {
"type": "string",
"description": "The content of the Kubernetes kubeconfig file, encoded as a string. "
"Kubeconfig command-based authentication is not supported in Prowler Cloud for security reasons.",
"Only inline credentials are supported: token, username/password, or client-certificate-data with client-key-data for users, and certificate-authority-data for clusters. "
"File-path and auth-provider kubeconfig fields are not supported.",
}
},
"required": ["kubeconfig_content"],
@@ -301,8 +302,7 @@ from rest_framework_json_api import serializers
},
"region": {
"type": "string",
"deprecated": True,
"description": "Legacy OCI region field accepted for backwards compatibility but ignored; OCI scans all regions.",
"description": "Optional OCI home region (or any region the tenancy is subscribed to) used to validate the credentials. It does not filter the scan, which covers all subscribed regions. Defaults to us-ashburn-1.",
},
},
"required": ["user", "fingerprint", "tenancy"],
+122 -38
View File
@@ -71,6 +71,7 @@ from django.db import IntegrityError, transaction
from drf_spectacular.utils import extend_schema_field
from jwt.exceptions import InvalidKeyError
from prowler.lib.mutelist.mutelist import Mutelist
from prowler.providers.oraclecloud.config import OCI_REGIONS
from rest_framework.reverse import reverse
from rest_framework.validators import UniqueTogetherValidator
from rest_framework_json_api import serializers
@@ -376,27 +377,48 @@ class UserSerializer(BaseModelSerializerV1):
def _can_view_relationships(self, instance) -> bool:
"""Allow self to view own relationships. Require manage_account to view others."""
role = self.context.get("role")
return self._is_self(instance) or bool(role and role.manage_account)
def _is_self(self, instance) -> bool:
request = self.context.get("request")
is_self = bool(
return bool(
request
and getattr(request, "user", None)
and getattr(instance, "id", None) == request.user.id
)
return is_self or (role and role.manage_account)
def _active_tenant_id(self):
return getattr(self.context.get("request"), "tenant_id", None)
@staticmethod
def _in_tenant(related_manager, tenant_id):
"""Filter a prefetched related manager in memory.
``.filter()`` clones the queryset without its result cache, so it would
discard the viewset's prefetch and re-query once per serialized user.
"""
return [
item
for item in related_manager.all()
if str(item.tenant_id) == str(tenant_id)
]
def get_roles(self, instance):
return (
instance.roles.all()
if self._can_view_relationships(instance)
else Role.objects.none()
)
tenant_id = self._active_tenant_id()
if not tenant_id or not self._can_view_relationships(instance):
return Role.objects.none()
return self._in_tenant(instance.roles, tenant_id)
def get_memberships(self, instance):
return (
instance.memberships.all()
if self._can_view_relationships(instance)
else Membership.objects.none()
)
if not self._can_view_relationships(instance):
return Membership.objects.none()
# Own memberships span tenants on purpose: the tenant switcher reads them.
if self._is_self(instance):
return instance.memberships.all()
tenant_id = self._active_tenant_id()
if not tenant_id:
return Membership.objects.none()
return self._in_tenant(instance.memberships, tenant_id)
class UserMeSerializer(UserSerializer):
@@ -730,11 +752,32 @@ class MembershipIncludeSerializer(serializers.ModelSerializer):
included_serializers = {"tenant": "api.v1.serializers.TenantIncludeSerializer"}
_REQUESTER_TENANTS_ATTR = "_prowler_requester_tenants"
def _requester_tenants(self):
"""Tenants the requester belongs to, resolved once per request.
Memoised on the request because DRF builds a serializer instance per
included object, so a per-instance cache would still query per membership.
"""
request = self.context.get("request")
user = getattr(request, "user", None)
if user is None:
return {}
tenants = getattr(request, self._REQUESTER_TENANTS_ATTR, None)
if tenants is None:
tenants = {
tenant.id: tenant
for tenant in Tenant.objects.using(MainRouter.admin_db).filter(
membership__user_id=user.id
)
}
setattr(request, self._REQUESTER_TENANTS_ATTR, tenants)
return tenants
def get_tenant(self, instance):
try:
return Tenant.objects.using(MainRouter.admin_db).get(id=instance.tenant_id)
except Tenant.DoesNotExist:
return None
"""Resolve the tenant only when the requester is a member of it."""
return self._requester_tenants().get(instance.tenant_id)
# Provider Groups
@@ -1658,14 +1701,43 @@ class FindingMetadataSerializer(BaseSerializerV1):
# Provider secrets
KUBERNETES_KUBECONFIG_UNSUPPORTED_COMMAND_AUTH_ERROR = (
"Kubernetes kubeconfig command-based authentication is not supported in "
"Prowler Cloud for security reasons."
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR = (
"Only inline Kubernetes credentials are supported. "
"For user authentication use token, username/password, or client-certificate-data with client-key-data; "
"clusters may use certificate-authority-data. "
"File-path fields (tokenFile, client-certificate, client-key, certificate-authority), "
"command-based authentication (exec, cmd-path), auth-provider directives, and proxy URLs are not supported."
)
KUBERNETES_KUBECONFIG_INVALID_ERROR = "Invalid Kubernetes kubeconfig content."
def kubeconfig_contains_unsupported_command_auth(kubeconfig: dict) -> bool:
KUBECONFIG_ALLOWED_USER_KEYS = frozenset(
{
"token",
"username",
"password",
"client-certificate-data",
"client-key-data",
"as",
"as-uid",
"as-groups",
"as-user-extra",
}
)
KUBECONFIG_ALLOWED_CLUSTER_KEYS = frozenset(
{
"server",
"certificate-authority-data",
"insecure-skip-tls-verify",
"tls-server-name",
"disable-compression",
"extensions",
}
)
def kubeconfig_is_inline_only_credentials(kubeconfig: dict) -> bool:
"""Accept only inline credentials; reject file-reference and auth-provider fields."""
users = kubeconfig.get("users", [])
if not isinstance(users, list):
raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
@@ -1678,21 +1750,25 @@ def kubeconfig_contains_unsupported_command_auth(kubeconfig: dict) -> bool:
if not isinstance(user, dict):
raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
if "exec" in user:
return True
if any(key not in KUBECONFIG_ALLOWED_USER_KEYS for key in user):
return False
auth_provider = user.get("auth-provider", {})
if not isinstance(auth_provider, dict):
continue
clusters = kubeconfig.get("clusters", [])
if not isinstance(clusters, list):
raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
auth_provider_config = auth_provider.get("config", {})
if not isinstance(auth_provider_config, dict):
continue
for cluster_entry in clusters:
if not isinstance(cluster_entry, dict):
raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
if "cmd-path" in auth_provider_config:
return True
cluster = cluster_entry.get("cluster", {})
if not isinstance(cluster, dict):
raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
return False
if any(key not in KUBECONFIG_ALLOWED_CLUSTER_KEYS for key in cluster):
return False
return True
class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
@@ -1875,7 +1951,7 @@ class MongoDBAtlasProviderSecret(serializers.Serializer):
class KubernetesProviderSecret(serializers.Serializer):
kubeconfig_content = serializers.CharField()
kubeconfig_content = serializers.CharField(trim_whitespace=False)
def validate_kubeconfig_content(self, kubeconfig_content):
try:
@@ -1888,9 +1964,9 @@ class KubernetesProviderSecret(serializers.Serializer):
if not isinstance(kubeconfig, dict):
raise serializers.ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
if kubeconfig_contains_unsupported_command_auth(kubeconfig):
if not kubeconfig_is_inline_only_credentials(kubeconfig):
raise serializers.ValidationError(
KUBERNETES_KUBECONFIG_UNSUPPORTED_COMMAND_AUTH_ERROR
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR
)
return kubeconfig_content
@@ -1917,9 +1993,16 @@ class IacProviderSecret(serializers.Serializer):
resource_name = "provider-secrets"
class LegacyOCIRegionField(serializers.Field):
class OCIHomeRegionField(serializers.Field):
"""Optional OCI home region; blank or non-string legacy values are dropped."""
def to_internal_value(self, data):
return data
if not isinstance(data, str) or not data.strip():
return None
region = data.strip()
if region not in OCI_REGIONS:
raise serializers.ValidationError(f"Invalid OCI region: {region}")
return region
def to_representation(self, value):
return value
@@ -1932,10 +2015,11 @@ class OracleCloudProviderSecret(serializers.Serializer):
key_content = serializers.CharField(required=False)
tenancy = serializers.CharField()
pass_phrase = serializers.CharField(required=False)
region = LegacyOCIRegionField(required=False, allow_null=True)
region = OCIHomeRegionField(required=False, allow_null=True)
def validate(self, attrs):
attrs.pop("region", None)
if not attrs.get("region"):
attrs.pop("region", None)
if "key_file" not in attrs and "key_content" not in attrs:
raise serializers.ValidationError(
+135 -95
View File
@@ -125,10 +125,14 @@ from api.models import (
)
from api.pagination import ComplianceOverviewPagination
from api.rbac.permissions import (
TASK_REVOKE_PERMISSIONS,
Permissions,
get_integrations,
get_providers,
get_role,
get_tasks,
get_user_roles,
roles_have_permissions,
)
from api.renderers import APIJSONRenderer, PlainTextRenderer
from api.rls import Tenant
@@ -326,7 +330,7 @@ from rest_framework_simplejwt.token_blacklist.models import (
)
from tasks.beat import schedule_provider_scan
from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils
from tasks.jobs.export import get_s3_client
from tasks.jobs.export import get_s3_client, get_s3_presign_client
from tasks.tasks import (
QUEUED_SCAN_TASK_STATE,
backfill_compliance_summaries_task,
@@ -347,6 +351,15 @@ from tasks.tasks import (
logger = logging.getLogger(BackendLogger.API)
def _scans_visible_to(role, tenant_id):
"""Tenant scans, narrowed to the role's provider groups unless it has unlimited visibility."""
scans = Scan.all_objects.filter(tenant_id=tenant_id)
if role.unlimited_visibility:
return scans
return scans.filter(provider__in=get_providers(role))
CACHE_DECORATOR = cache_control(
max_age=django_settings.CACHE_MAX_AGE,
stale_while_revalidate=django_settings.CACHE_STALE_WHILE_REVALIDATE,
@@ -795,7 +808,9 @@ class SAMLConfigurationViewSet(BaseRLSViewSet):
"""
serializer_class = SAMLConfigurationSerializer
required_permissions = [Permissions.MANAGE_INTEGRATIONS]
# The IdP behind this configuration decides who signs in and which role
# they get, so writing it is account administration, not an integration.
required_permissions = [Permissions.MANAGE_ACCOUNT]
queryset = SAMLConfiguration.objects.all()
def get_queryset(self):
@@ -1892,7 +1907,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
self.required_permissions = [Permissions.MANAGE_PROVIDERS]
def get_queryset(self):
user_roles = get_role(self.request.user, self.request.tenant_id)
user_roles = self.user_role
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all providers
queryset = Provider.objects.filter(tenant_id=self.request.tenant_id)
@@ -2407,7 +2422,8 @@ class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
}
if content_type:
params["ResponseContentType"] = content_type
url = client.generate_presigned_url(
# The browser follows this URL, so it is signed against the public host.
url = (get_s3_presign_client() or client).generate_presigned_url(
"get_object",
Params=params,
ExpiresIn=300,
@@ -2821,6 +2837,15 @@ class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
tenant_id=self.request.tenant_id,
task_id=pre_task_id,
task_status=(QUEUED_SCAN_TASK_STATE if active_scan else None),
# This response is serialized before the on_commit publish,
# so without these the caller gets a task id and no scan id.
# Kept in step with what `enqueue_scan_execution_on_commit`
# publishes below.
task_kwargs={
"tenant_id": str(self.request.tenant_id),
"scan_id": str(scan.id),
"provider_id": str(scan.provider_id),
},
)
if not active_scan:
@@ -2848,17 +2873,29 @@ class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
list=extend_schema(
tags=["Task"],
summary="List all tasks",
description="Retrieve a list of all tasks with options for filtering by name, state, and other criteria.",
description=(
"Retrieve a list of all tasks with options for filtering by name, state, and other "
"criteria. Tasks that reference a provider are only returned when the role can "
"access it; tasks without a provider reference are returned for every role."
),
),
retrieve=extend_schema(
tags=["Task"],
summary="Retrieve data from a specific task",
description="Fetch detailed information about a specific task by its ID.",
description=(
"Fetch detailed information about a specific task by its ID. Tasks tied to a provider "
"outside the visibility of the role are not found."
),
),
destroy=extend_schema(
tags=["Task"],
summary="Revoke a task",
description="Try to revoke a task using its ID. Only tasks that are not yet in progress can be revoked.",
description=(
"Try to revoke a task using its ID. Only tasks that are not yet in progress can be "
"revoked, and the caller needs the same permission as the operation that queued "
"the task (for example MANAGE_SCANS for a scan). Provider deletions cannot be "
"revoked."
),
responses={202: OpenApiResponse(response=TaskSerializer)},
),
)
@@ -2874,13 +2911,26 @@ class TaskViewSet(BaseRLSViewSet):
required_permissions = []
def get_queryset(self):
return Task.objects.annotate(
name=F("task_runner_task__task_name"),
state=F("task_runner_task__status"),
).select_related("task_runner_task")
return (
get_tasks(self.user_role)
.annotate(
name=F("task_runner_task__task_name"),
state=F("task_runner_task__status"),
)
.select_related("task_runner_task")
)
def destroy(self, request, *args, pk=None, **kwargs):
task = get_object_or_404(Task, pk=pk)
task = self.get_object()
required_permissions = TASK_REVOKE_PERMISSIONS.get(
task.task_runner_task.task_name
)
# Same multi-role semantics as HasPermissions.
if required_permissions is None or not roles_have_permissions(
get_user_roles(request.user, request.tenant_id), required_permissions
):
raise PermissionDenied("You do not have permission to revoke this task.")
if task.task_runner_task.status not in ["PENDING", "RECEIVED"]:
serializer = TaskSerializer(task)
return Response(
@@ -3019,7 +3069,7 @@ class AttackPathsScanViewSet(BaseRLSViewSet):
return super().get_serializer_class()
def get_queryset(self):
user_roles = get_role(self.request.user, self.request.tenant_id)
user_roles = self.user_role
base_queryset = AttackPathsScan.objects.filter(tenant_id=self.request.tenant_id)
if user_roles.unlimited_visibility:
@@ -3373,7 +3423,7 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
required_permissions = []
def get_queryset(self):
user_roles = get_role(self.request.user, self.request.tenant_id)
user_roles = self.user_role
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all scans
queryset = Resource.all_objects.filter(tenant_id=self.request.tenant_id)
@@ -3477,12 +3527,8 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
filtered_queryset = self.filter_queryset(self.get_queryset())
latest_scans = (
Scan.all_objects.filter(
tenant_id=tenant_id,
state=StateChoices.COMPLETED,
)
.order_by("provider_id", "-inserted_at")
.distinct("provider_id")
Scan.all_objects.filter(tenant_id=tenant_id)
.latest_per_provider()
.values("provider_id")
)
@@ -3509,8 +3555,15 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
tenant_id = request.tenant_id
query_params = request.query_params
user_roles = self.user_role
queryset = ResourceScanSummary.objects.filter(tenant_id=tenant_id)
if not user_roles.unlimited_visibility:
queryset = queryset.filter(
scan_id__in=_scans_visible_to(user_roles, tenant_id).values_list(
"id", flat=True
)
)
if scans := query_params.get("filter[scan__in]") or query_params.get(
"filter[scan]"
@@ -3588,7 +3641,10 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
all_groups = Resource.objects.filter(
tenant_id=tenant_id,
groups__isnull=False,
).values_list("groups", flat=True)
)
if not user_roles.unlimited_visibility:
all_groups = all_groups.filter(provider__in=get_providers(user_roles))
all_groups = all_groups.values_list("groups", flat=True)
groups = sorted(
{g for groups_list in all_groups if groups_list for g in groups_list}
)
@@ -3613,12 +3669,11 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
def metadata_latest(self, request):
tenant_id = request.tenant_id
query_params = request.query_params
user_roles = self.user_role
latest_scans_queryset = (
Scan.all_objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
.order_by("provider_id", "-inserted_at")
.distinct("provider_id")
)
latest_scans_queryset = _scans_visible_to(
user_roles, tenant_id
).latest_per_provider()
queryset = ResourceScanSummary.objects.filter(
tenant_id=tenant_id,
@@ -3658,7 +3713,10 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
all_groups = Resource.objects.filter(
tenant_id=tenant_id,
groups__isnull=False,
).values_list("groups", flat=True)
)
if not user_roles.unlimited_visibility:
all_groups = all_groups.filter(provider__in=get_providers(user_roles))
all_groups = all_groups.values_list("groups", flat=True)
groups = sorted(
{g for groups_list in all_groups if groups_list for g in groups_list}
)
@@ -3995,7 +4053,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
def get_queryset(self):
tenant_id = self.request.tenant_id
user_roles = get_role(self.request.user, self.request.tenant_id)
user_roles = self.user_role
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all findings
queryset = Finding.all_objects.filter(tenant_id=tenant_id)
@@ -4069,8 +4127,16 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
tenant_id = request.tenant_id
query_params = request.query_params
user_roles = self.user_role
visible_scan_ids = (
None
if user_roles.unlimited_visibility
else _scans_visible_to(user_roles, tenant_id).values_list("id", flat=True)
)
queryset = ResourceScanSummary.objects.filter(tenant_id=tenant_id)
if not user_roles.unlimited_visibility:
queryset = queryset.filter(scan_id__in=visible_scan_ids)
scan_based_filters = {}
category_scan_filters = {} # Filters for ScanCategorySummary
@@ -4128,7 +4194,10 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
if not queryset.exists():
raw_scans_ids = Scan.objects.filter(
tenant_id=tenant_id, **scan_based_filters
).values_list("id", "unique_resource_count")
)
if not user_roles.unlimited_visibility:
raw_scans_ids = raw_scans_ids.filter(id__in=visible_scan_ids)
raw_scans_ids = raw_scans_ids.values_list("id", "unique_resource_count")
scan_ids = [
scan_id for scan_id, count in raw_scans_ids if count and count > 0
]
@@ -4170,11 +4239,13 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
)
# Get categories from ScanCategorySummary using same scan filters
category_summaries = ScanCategorySummary.objects.filter(
tenant_id=tenant_id, **category_scan_filters
)
if not user_roles.unlimited_visibility:
category_summaries = category_summaries.filter(scan_id__in=visible_scan_ids)
categories = list(
ScanCategorySummary.objects.filter(
tenant_id=tenant_id, **category_scan_filters
)
.values_list("category", flat=True)
category_summaries.values_list("category", flat=True)
.distinct()
.order_by("category")
)
@@ -4205,12 +4276,9 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
tenant_id = request.tenant_id
filtered_queryset = self.filter_queryset(self.get_queryset())
latest_scan_ids = list(
Scan.all_objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
.order_by("provider_id", "-inserted_at")
.distinct("provider_id")
.values_list("id", flat=True)
)
latest_scan_ids = Scan.all_objects.filter(
tenant_id=tenant_id
).latest_ids_per_provider()
filtered_queryset = filtered_queryset.filter(
tenant_id=tenant_id, scan_id__in=latest_scan_ids
)
@@ -4232,12 +4300,11 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
def metadata_latest(self, request):
tenant_id = request.tenant_id
query_params = request.query_params
user_roles = self.user_role
latest_scans_queryset = (
Scan.all_objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
.order_by("provider_id", "-inserted_at")
.distinct("provider_id")
)
latest_scans_queryset = _scans_visible_to(
user_roles, tenant_id
).latest_per_provider()
raw_latest_scans_ids = list(
latest_scans_queryset.values_list("id", "unique_resource_count")
)
@@ -4978,11 +5045,7 @@ class ComplianceOverviewViewSet(
if provider_filters:
scans = scans.filter(**provider_filters)
return list(
scans.order_by("provider_id", "-inserted_at")
.distinct("provider_id")
.values_list("id", flat=True)
)
return scans.latest_ids_per_provider()
def _filtered_queryset_for_latest_provider_scans(self, latest_scan_ids=None):
if latest_scan_ids is None:
@@ -5724,14 +5787,9 @@ class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet):
else {}
)
latest_scan_ids = (
Scan.all_objects.filter(
tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter
)
.order_by("provider_id", "-inserted_at")
.distinct("provider_id")
.values_list("id", flat=True)
)
latest_scan_ids = Scan.all_objects.filter(
tenant_id=tenant_id, **provider_filter
).latest_ids_per_provider()
return filtered_queryset.filter(
tenant_id=tenant_id, scan_id__in=latest_scan_ids
@@ -5758,16 +5816,10 @@ class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet):
def _latest_scan_ids_for_allowed_providers(self, tenant_id, provider_filters=None):
provider_filter = self._get_provider_filter()
queryset = Scan.all_objects.filter(
tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter
)
queryset = Scan.all_objects.filter(tenant_id=tenant_id, **provider_filter)
if provider_filters:
queryset = queryset.filter(**provider_filters)
return (
queryset.order_by("provider_id", "-inserted_at")
.distinct("provider_id")
.values_list("id", flat=True)
)
return queryset.latest_ids_per_provider()
@action(detail=False, methods=["get"], url_name="providers")
def providers(self, request):
@@ -5779,14 +5831,9 @@ class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet):
else {}
)
latest_scan_ids = (
Scan.all_objects.filter(
tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter
)
.order_by("provider_id", "-inserted_at")
.distinct("provider_id")
.values_list("id", flat=True)
)
latest_scan_ids = Scan.all_objects.filter(
tenant_id=tenant_id, **provider_filter
).latest_ids_per_provider()
findings_aggregated = (
queryset.filter(scan_id__in=latest_scan_ids)
@@ -7933,18 +7980,13 @@ class FindingGroupViewSet(JsonApiFilterMixin, BaseRLSViewSet):
def _get_latest_findings_per_provider(self, filtered_queryset):
"""Keep only findings from each provider's most recent completed scan."""
# Materialize to a literal IN list. Left as a subquery, Postgres can't
# estimate the match count and picks a serial nested loop on
# resource_finding_mappings when one scan dominates findings
latest_scan_ids = list(
Scan.objects.filter(
tenant_id=self.request.tenant_id,
state=StateChoices.COMPLETED,
)
.order_by("provider_id", "-completed_at", "-inserted_at")
.distinct("provider_id")
.values_list("id", flat=True)
)
# `latest_ids_per_provider` materializes to a literal IN list.
# Left as a subquery, Postgres can't estimate the match count and picks
# a serial nested loop on resource_finding_mappings when one scan
# dominates findings
latest_scan_ids = Scan.objects.filter(
tenant_id=self.request.tenant_id
).latest_ids_per_provider()
return filtered_queryset.filter(scan_id__in=latest_scan_ids)
def _post_process_aggregation(self, aggregated_data):
@@ -8890,16 +8932,14 @@ class FindingGroupViewSet(JsonApiFilterMixin, BaseRLSViewSet):
tenant_id = request.tenant_id
queryset = self._get_finding_queryset()
# Order by -completed_at (matching the /latest summary path and the
# daily summary upsert keyed on midnight(completed_at)) so that
# overlapping scans do not make /resources and /latest read from
# different scans and report diverging counts.
latest_scan_ids = (
Scan.objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
.order_by("provider_id", "-completed_at", "-inserted_at")
.distinct("provider_id")
.values_list("id", flat=True)
)
# The shared selector orders by -completed_at (matching the /latest
# summary path and the daily summary upsert keyed on
# midnight(completed_at)) so that overlapping scans do not make
# /resources and /latest read from different scans and report
# diverging counts.
latest_scan_ids = Scan.objects.filter(
tenant_id=tenant_id
).latest_ids_per_provider()
normalized_params = self._normalize_jsonapi_params(request.query_params)
# Remove date filters since we're using latest
+56
View File
@@ -231,6 +231,62 @@ LOGGING = {
"level": LEVEL,
"propagate": False,
},
# Celery loggers must be declared explicitly because
# disable_existing_loggers=True silences any logger that exists at
# dictConfig time but is not named here. Without these, fatal worker
# errors (e.g. celery.worker CRITICAL) produce no output.
# "celery" must keep propagating: get_task_logger() parents task
# loggers under celery.task, so blocking here hides them from root.
"celery": {
"level": LEVEL,
"propagate": True,
},
"celery.worker": {
"handlers": ["tasks_console"],
"level": LEVEL,
"propagate": False,
},
"celery.worker.consumer": {
"handlers": ["tasks_console"],
"level": LEVEL,
"propagate": False,
},
"celery.worker.consumer.consumer": {
"handlers": ["tasks_console"],
"level": LEVEL,
"propagate": False,
},
"kombu": {
"handlers": ["tasks_console"],
"level": LEVEL,
"propagate": False,
},
"kombu.transport.redis": {
"handlers": ["tasks_console"],
"level": LEVEL,
"propagate": False,
},
"billiard": {
"handlers": ["tasks_console"],
"level": LEVEL,
"propagate": False,
},
"amqp": {
"handlers": ["tasks_console"],
"level": LEVEL,
"propagate": False,
},
# WARNING keeps task failures but skips one "succeeded" line per task.
"celery.app.trace": {
"handlers": ["tasks_console"],
"level": "WARNING",
"propagate": False,
},
"celery.beat": {
"handlers": ["tasks_console"],
"level": LEVEL,
"propagate": False,
},
},
# Gunicorn required configuration
"root": {
+22
View File
@@ -7,6 +7,7 @@ from config.settings.eventstream import * # noqa
from config.settings.partitions import * # noqa
from config.settings.sentry import * # noqa
from config.settings.social_login import * # noqa
from django.core.exceptions import ImproperlyConfigured
SECRET_KEY = env("SECRET_KEY", default="secret")
DEBUG = env.bool("DJANGO_DEBUG", default=False)
@@ -129,6 +130,8 @@ REST_FRAMEWORK = {
SPECTACULAR_SETTINGS = {
"SERVE_INCLUDE_SCHEMA": False,
"COMPONENT_SPLIT_REQUEST": True,
# redoc@latest is 3.x, whose standalone bundle fails as a classic script
"REDOC_DIST": "https://cdn.jsdelivr.net/npm/redoc@2.5.1",
"PREPROCESSING_HOOKS": [
"drf_spectacular_jsonapi.hooks.fix_nested_path_parameters",
],
@@ -295,6 +298,14 @@ DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY = env.str(
)
DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN = env.str("DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN", "")
DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION = env.str("DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION", "")
# Storage endpoint the API and Celery workers use to talk to S3-compatible object storage
# such as MinIO. Empty means the real AWS S3 endpoint, which is unaffected.
DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL = env.str("DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL", "")
# Browser-reachable storage host used to sign download URLs. Empty means sign against the
# same endpoint the API talks to, which is what Prowler Cloud on S3 does.
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL = env.str(
"DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL", ""
)
# HTTP Security Headers
SECURE_CONTENT_TYPE_NOSNIFF = True
@@ -320,6 +331,17 @@ ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int(
"ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 960
) # 16h
# Minimum age (of the scan row, or of the scan id itself when the row is gone) before
# the periodic reaper will drop an orphaned temp Neo4j database. Keeps a scan that is
# still legitimately in flight from ever losing its staging database mid-run.
ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS = env.int(
"ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS", 6
)
if ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS <= 0:
raise ImproperlyConfigured(
"ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS must be a positive number of hours"
)
# Selects where the persistent attack-paths graph is stored. The scan
# temporary database is always Neo4j; only the sink is configurable.
# Valid values: "neo4j" (default, OSS and local dev), "neptune" (hosted).
+4
View File
@@ -53,6 +53,10 @@ preload_app = not DEBUG
# that may take longer, such as complex API operations.
timeout = env.int("GUNICORN_TIMEOUT", default=120)
# Recycle workers after N requests (+ jitter) to cap memory growth. GUNICORN_MAX_REQUESTS=0 disables.
max_requests = env.int("GUNICORN_MAX_REQUESTS", default=10000)
max_requests_jitter = env.int("GUNICORN_MAX_REQUESTS_JITTER", default=1000)
# Logging
logconfig_dict = DJANGO_LOGGERS
gunicorn_logger = logging.getLogger(BackendLogger.GUNICORN)
+3 -3
View File
@@ -193,10 +193,10 @@ def initialize_sentry():
sentry_sdk.init(
dsn=sentry_dsn,
# Add data like request headers and IP for users,
# see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info
before_send=before_send,
send_default_pii=True,
# No user identity, IPs, cookies, headers or request bodies: bodies carry emails and provider details.
send_default_pii=False,
max_request_body_size="never",
traces_sample_rate=env.float("DJANGO_SENTRY_TRACES_SAMPLE_RATE", default=0.02),
_experiments={
# Set continuous_profiling_auto_start to True
+41
View File
@@ -443,6 +443,47 @@ def authenticated_client_rbac_manage_users_only(
)
@pytest.fixture(scope="function")
def create_test_user_rbac_manage_integrations_only(django_db_setup, django_db_blocker):
"""User with only manage_integrations permission (no manage_account)."""
with django_db_blocker.unblock():
user = User.objects.create_user(
name="testing_manage_integrations_only",
email="rbac_manage_integrations_only@rbac.com",
password=TEST_PASSWORD,
)
tenant = Tenant.objects.create(name="Tenant Test Manage Integrations Only")
Membership.objects.create(
user=user,
tenant=tenant,
role=Membership.RoleChoices.OWNER,
)
role = Role.objects.create(
name="manage_integrations_only",
tenant_id=tenant.id,
manage_users=False,
manage_account=False,
manage_billing=False,
manage_providers=False,
manage_integrations=True,
manage_scans=False,
unlimited_visibility=False,
)
UserRoleRelationship.objects.create(user=user, role=role, tenant_id=tenant.id)
return user
@pytest.fixture
def authenticated_client_rbac_manage_integrations_only(
create_test_user_rbac_manage_integrations_only, client
):
return authenticate_client_for_tenant(
client,
create_test_user_rbac_manage_integrations_only,
first_membership_tenant(create_test_user_rbac_manage_integrations_only),
)
@pytest.fixture
def authenticated_client_rbac(create_test_user_rbac, tenants_fixture, client):
return authenticate_client_for_tenant(
@@ -0,0 +1,107 @@
"""Periodic reaper for orphaned temp Neo4j scan databases.
`scan.py` creates a throw-away `db-tmp-scan-<attack_paths_scan_id>` database per
scan and drops it once the scan finishes, success or failure. When the worker
or Neo4j itself dies mid-scan, that drop never runs and nothing else ever
revisits the database - it sits there forever. This sweep lists every temp
database on the ingest cluster and drops the ones whose scan is gone or has
been finished for longer than the configured safety margin.
"""
from datetime import UTC, datetime, timedelta
from api.attack_paths import database as graph_database
from api.db_router import MainRouter
from api.models import AttackPathsScan, StateChoices
from api.uuid_utils import datetime_from_uuid7
from celery.utils.log import get_task_logger
from config.django.base import ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS
from uuid6 import UUID as UUID7
logger = get_task_logger(__name__)
TERMINAL_STATES = (
StateChoices.COMPLETED,
StateChoices.FAILED,
StateChoices.CANCELLED,
)
def reap_orphaned_tmp_databases() -> dict:
"""Drop temp Neo4j scan databases whose scan is gone or long finished.
A failure listing databases aborts the whole sweep (nothing to iterate).
A failure reaping one database is logged and skipped so the rest of the
sweep still runs.
"""
now = datetime.now(tz=UTC)
safety_margin = timedelta(hours=ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS)
try:
databases = graph_database.list_databases()
except Exception:
logger.exception("Failed to list ingest Neo4j databases for temp-db reap")
return {"dropped_count": 0, "databases": []}
tmp_databases = [
name for name in databases if name.startswith(graph_database.TEMP_DB_PREFIX)
]
dropped: list[str] = []
for database in tmp_databases:
try:
if _is_orphaned(database, now, safety_margin):
graph_database.drop_database(database)
dropped.append(database)
logger.info(f"Dropped orphaned temp Neo4j database `{database}`")
except Exception:
logger.exception(f"Failed to reap temp Neo4j database `{database}`")
logger.info(f"Temp Neo4j database reap: {len(dropped)} dropped")
return {"dropped_count": len(dropped), "databases": dropped}
def _is_orphaned(database: str, now: datetime, safety_margin: timedelta) -> bool:
"""Decide whether a temp database is safe to drop.
No scan row: the row was hard-deleted (tenant/provider cleanup) or was
never created. Falls back to the scan id's own UUIDv7 timestamp so a
database created moments ago is never touched even without a row to check.
Scan row present: only reapable once it reached a terminal state and has
been finished for longer than the safety margin, so a scan still
legitimately executing is never touched.
"""
scan_id = database[len(graph_database.TEMP_DB_PREFIX) :]
try:
scan_uuid = UUID7(scan_id)
except ValueError:
logger.warning(
f"Temp database `{database}` has an unparseable scan id, skipping"
)
return False
# Global sweep with no tenant context: admin_db bypasses RLS on purpose, the same
# way cleanup_stale_attack_paths_scans finds stale scans across every tenant.
scan = (
AttackPathsScan.all_objects.using(MainRouter.admin_db)
.filter(id=scan_uuid)
.first()
)
if scan is None:
if scan_uuid.version != 7:
logger.warning(
f"Temp database `{database}` has no scan row and a non-UUIDv7 id, "
"skipping"
)
return False
return now - datetime_from_uuid7(scan_uuid) >= safety_margin
if scan.state not in TERMINAL_STATES:
return False
# `mark_scan_finished` does not touch `updated_at`, so prefer `completed_at`
finished_at = scan.completed_at or scan.updated_at
return now - finished_at >= safety_margin
+6 -3
View File
@@ -499,10 +499,13 @@ def backfill_provider_compliance_scores(tenant_id: str) -> dict:
provider_id__in=existing_providers
)
# `completed_scans` keeps its own `completed_at__isnull=False`: this
# task writes a *dated* ProviderComplianceScore row, so unlike the read
# paths it genuinely cannot use a scan without a `completed_at`.
scan_info = list(
completed_scans.order_by("provider_id", "-completed_at")
.distinct("provider_id")
.values("id", "provider_id", "completed_at")
completed_scans.latest_per_provider().values(
"id", "provider_id", "completed_at"
)
)
if not scan_info:
+14 -3
View File
@@ -13,6 +13,7 @@ from api.models import (
Tenant,
)
from celery.utils.log import get_task_logger
from django.conf import settings
from django.db import DatabaseError
from tasks.jobs.queries import (
COMPLIANCE_DELETE_EMPTY_TENANT_SUMMARY_SQL,
@@ -106,9 +107,19 @@ def delete_provider(tenant_id: str, pk: str):
try:
if attack_paths_sink_backends:
for sink_backend in attack_paths_sink_backends:
sink_module.get_backend_for_name(sink_backend).drop_subgraph(
tenant_database_name, str(pk)
)
try:
backend = sink_module.get_backend_for_name(sink_backend)
except RuntimeError as sink_error:
# A retired sink has no connection settings left, and no graph left to drop
if sink_backend == settings.ATTACK_PATHS_SINK_DATABASE.lower():
raise
logger.warning(
f"Skipping graph cleanup on unconfigured sink {sink_backend}: {sink_error}"
)
continue
backend.drop_subgraph(tenant_database_name, str(pk))
else:
graph_database.drop_subgraph(tenant_database_name, str(pk))
+63 -4
View File
@@ -6,6 +6,7 @@ import boto3
import config.django.base as base
from api.db_utils import rls_transaction
from api.models import Scan
from botocore.config import Config
from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
from celery.utils.log import get_task_logger
from django.conf import settings
@@ -206,15 +207,21 @@ def get_s3_client():
This function attempts to initialize an S3 client by reading the AWS access key, secret key,
session token, and region from environment variables. It then validates the client by listing
available S3 buckets. If an error occurs during this process (for example, due to missing or
invalid credentials), it falls back to creating an S3 client without explicitly provided credentials,
which may rely on other configuration sources (e.g., IAM roles).
invalid credentials), it falls back to creating an S3 client without explicitly provided
credentials, which may rely on other configuration sources (e.g., IAM roles).
That fallback is only safe when no explicit endpoint is configured: with an endpoint set, the
explicit client already targets the intended S3-compatible storage, and the fallback client
would go to the AWS default provider chain instead, an unrelated real-AWS account reachable
from the host. So when an endpoint is configured, the original error propagates instead.
Returns:
boto3.client: A configured S3 client instance.
Raises:
ClientError, NoCredentialsError, or ParamValidationError if both attempts to create a client fail.
ClientError, NoCredentialsError, or ParamValidationError if the client cannot be created.
"""
endpoint = settings.DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL
s3_client = None
try:
s3_client = boto3.client(
@@ -222,16 +229,68 @@ def get_s3_client():
aws_access_key_id=settings.DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY,
aws_session_token=settings.DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN,
region_name=settings.DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION,
# Storage that has no meaningful region, MinIO among it, is usually configured
# without one, and botocore rejects an empty region before any request is made.
region_name=settings.DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION or "us-east-1",
endpoint_url=endpoint or None,
)
s3_client.list_buckets()
except (ClientError, NoCredentialsError, ParamValidationError, ValueError):
if endpoint:
raise
s3_client = boto3.client("s3")
s3_client.list_buckets()
return s3_client
def get_s3_presign_client():
"""Return a client that signs download URLs with SigV4.
It is used when a public or internal storage host is configured, or when the bucket's
region is: boto3 otherwise presigns S3 URLs with SigV2, which S3 rejects for SSE-KMS
objects. None means none of those is set and the caller should presign with its own
client, which leaves those deployments with the URL they get today.
The public endpoint wins when both are set: the internal endpoint may only be reachable
from inside the cluster, and a URL signed against it would not open in a browser.
"""
endpoint = (
settings.DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL
or settings.DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL
)
if not endpoint and not settings.DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION:
return None
# Blank keys are signed as-is (empty credential scope) instead of deferring to the
# provider chain, so static credentials are only passed when they are set.
credentials = {}
if (
settings.DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID
and settings.DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY
):
credentials = {
"aws_access_key_id": settings.DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID,
"aws_secret_access_key": settings.DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY,
# An empty string is a token as far as botocore is concerned: it appends an
# empty X-Amz-Security-Token that storage counts when it recomputes the signature.
"aws_session_token": settings.DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN or None,
}
return boto3.client(
"s3",
**credentials,
# SigV4 puts the region in the credential scope, and MinIO answers to us-east-1
# unless it was told otherwise, so an empty region would sign an unusable URL.
region_name=settings.DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION or "us-east-1",
endpoint_url=endpoint or None,
# The signature covers the host, so the addressing style has to be pinned rather
# than guessed from the endpoint: MinIO serves path-style, and on AWS it keeps the
# regional host instead of the global one, which redirects for new buckets.
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
def _upload_to_s3(
tenant_id: str, scan_id: str, local_path: str, relative_key: str
) -> str | None:
+4 -12
View File
@@ -1,7 +1,7 @@
from collections.abc import Iterable
from api.db_utils import rls_transaction
from api.models import Finding, MuteRule, Scan, StateChoices
from api.models import Finding, MuteRule, Scan
from celery.utils.log import get_task_logger
logger = get_task_logger(__name__)
@@ -39,17 +39,9 @@ def mute_findings_in_latest_scans(
with rls_transaction(tenant_id):
mute_rule = MuteRule.objects.get(id=mute_rule_id, tenant_id=tenant_id)
latest_scans = list(
Scan.objects.filter(
tenant_id=tenant_id,
provider_id__in=provider_ids,
state=StateChoices.COMPLETED,
completed_at__isnull=False,
)
.order_by("provider_id", "-completed_at", "-inserted_at", "-id")
.distinct("provider_id")
.values_list("id", flat=True)
)
latest_scans = Scan.objects.filter(
tenant_id=tenant_id, provider_id__in=provider_ids
).latest_ids_per_provider()
changed_scan_ids = []
findings_muted = 0
@@ -84,6 +84,7 @@ _SKIP_RECOVERY = {
"scan-perform-scheduled",
"attack-paths-scan-perform",
"attack-paths-cleanup-stale-scans",
"attack-paths-reap-orphaned-tmp-databases",
"reconcile-orphan-tasks",
}
+4 -8
View File
@@ -172,14 +172,10 @@ class CISReportGenerator(BaseComplianceReportGenerator):
# Metadata table via base class helper
info_rows = self._build_info_rows(data, language=self.config.language)
metadata_data = []
for label, value in info_rows:
if label in ("Name:", "Description:") and value:
metadata_data.append(
[label, Paragraph(str(value), self.styles["normal_center"])]
)
else:
metadata_data.append([label, value])
metadata_data = [
[label, Paragraph(escape_html(str(value)), self.styles["normal_center"])]
for label, value in info_rows
]
metadata_table = Table(metadata_data, colWidths=[2 * inch, 4 * inch])
metadata_table.setStyle(
+5 -10
View File
@@ -13,7 +13,7 @@ from .base import (
get_requirement_metadata,
)
from .charts import create_horizontal_bar_chart, create_radar_chart
from .components import get_color_for_compliance
from .components import escape_html, get_color_for_compliance
from .config import (
COLOR_BG_BLUE,
COLOR_BLUE,
@@ -99,15 +99,10 @@ class ENSReportGenerator(BaseComplianceReportGenerator):
# Compliance info table - use base class helper for consistency
info_rows = self._build_info_rows(data, language="es")
# Convert tuples to lists and wrap long text in Paragraphs
info_data = []
for label, value in info_rows:
if label in ("Nombre:", "Descripción:") and value:
info_data.append(
[label, Paragraph(value, self.styles["normal_center"])]
)
else:
info_data.append([label, value])
info_data = [
[label, Paragraph(escape_html(str(value)), self.styles["normal_center"])]
for label, value in info_rows
]
info_table = Table(info_data, colWidths=[2 * inch, 4 * inch])
info_table.setStyle(
+5 -9
View File
@@ -11,6 +11,7 @@ from .base import (
get_requirement_metadata,
)
from .charts import create_horizontal_bar_chart, get_chart_color_for_percentage
from .components import escape_html
from .config import (
COLOR_BORDER_GRAY,
COLOR_DARK_GRAY,
@@ -111,15 +112,10 @@ class NIS2ReportGenerator(BaseComplianceReportGenerator):
# Compliance metadata table - use base class helper for consistency
info_rows = self._build_info_rows(data, language="en")
# Convert tuples to lists and wrap long text in Paragraphs
metadata_data = []
for label, value in info_rows:
if label in ("Name:", "Description:") and value:
metadata_data.append(
[label, Paragraph(value, self.styles["normal_center"])]
)
else:
metadata_data.append([label, value])
metadata_data = [
[label, Paragraph(escape_html(str(value)), self.styles["normal_center"])]
for label, value in info_rows
]
metadata_table = Table(metadata_data, colWidths=[2 * inch, 4 * inch])
metadata_table.setStyle(
+39 -19
View File
@@ -997,11 +997,12 @@ def _process_finding_micro_batch(
snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.type = finding.resource_type
updated = True
if resource_instance.metadata != finding.resource_metadata:
serialized_metadata = json.dumps(
finding.resource_metadata, cls=CustomEncoder
)
if resource_instance.metadata != serialized_metadata:
snapshot_resource_fields(resource_uid, resource_instance)
resource_instance.metadata = json.dumps(
finding.resource_metadata, cls=CustomEncoder
)
resource_instance.metadata = serialized_metadata
updated = True
if resource_instance.details != finding.resource_details:
snapshot_resource_fields(resource_uid, resource_instance)
@@ -2113,7 +2114,9 @@ def aggregate_attack_surface(tenant_id: str, scan_id: str):
.annotate(
total=Count("id"),
failed=Count("id", filter=Q(status="FAIL", muted=False)),
muted=Count("id", filter=Q(status="FAIL", muted=True)),
# Not `muted`: an annotation named after a model field comes
# back as `muted_new` from the psqlextra queryset.
muted_count=Count("id", filter=Q(status="FAIL", muted=True)),
)
)
@@ -2124,7 +2127,7 @@ def aggregate_attack_surface(tenant_id: str, scan_id: str):
aggregated_counts[attack_surface_type]["total"] += stats["total"] or 0
aggregated_counts[attack_surface_type]["failed"] += stats["failed"] or 0
aggregated_counts[attack_surface_type]["muted"] += stats["muted"] or 0
aggregated_counts[attack_surface_type]["muted"] += stats["muted_count"] or 0
overview_objects = []
for attack_surface_type, counts in aggregated_counts.items():
@@ -2700,20 +2703,37 @@ def reset_ephemeral_resource_findings_count(tenant_id: str, scan_id: str) -> dic
# refreshed). Wiping based on the older scan would zero counts the newer
# scan just set. Skip and let the newer scan's reset task do the work; if
# this task was delayed in the queue, that's the correct outcome.
# `completed_at__isnull=False` is required: Postgres orders NULL first in
# DESC, so a sibling COMPLETED scan with a missing completed_at would sort
# as "newest" and incorrectly cause us to skip.
#
# The comparison must be against the newest *full-scope* scan, which is
# what this variable has always been named after but did not use to be:
# the query filtered nothing about scope, so any newer scan that is not
# full-scope (an imported one, for instance) made the full-scope scan
# skip its own cleanup and leave ephemeral resources with a stale
# failed_findings_count permanently.
#
# `is_full_scope()` reads `trigger` plus the scoping keys inside
# `scanner_args`, which is not expressible as a WHERE clause, so the
# candidates are walked newest-first in Python until the first full-scope
# one. The walk needs no cap: `scan` is itself a full-scope candidate, so
# it stops at `scan` at the latest, after reading only the scans newer than
# it. A fixed window would return None once more newer scoped scans had
# landed than it inspected, and skip the cleanup exactly like the bug above.
#
# NULL `completed_at` no longer needs an explicit filter here: the shared
# ordering in `ScanQuerySet.LATEST_ORDER_BY` sorts NULLs last
# rather than excluding them, which also fixes the case where a provider
# whose completed scans all have a NULL `completed_at` resolved to None and
# therefore never ran the reset at all.
with rls_transaction(tenant_id):
latest_full_scope_scan_id = (
Scan.objects.filter(
tenant_id=tenant_id,
provider_id=scan.provider_id,
state=StateChoices.COMPLETED,
completed_at__isnull=False,
)
.order_by("-completed_at", "-inserted_at")
.values_list("id", flat=True)
.first()
candidates = (
Scan.objects.filter(tenant_id=tenant_id, provider_id=scan.provider_id)
.latest_first()
.only("id", "trigger", "scanner_args")
.iterator(chunk_size=100)
)
latest_full_scope_scan_id = next(
(candidate.id for candidate in candidates if candidate.is_full_scope()),
None,
)
if latest_full_scope_scan_id != scan.id:
logger.info(
+32 -2
View File
@@ -1,3 +1,4 @@
import json
import os
from datetime import UTC, datetime, timedelta
from pathlib import Path
@@ -42,6 +43,7 @@ from tasks.jobs.attack_paths import (
)
from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
from tasks.jobs.backfill import (
aggregate_scan_category_summaries,
aggregate_scan_resource_group_summaries,
@@ -163,13 +165,28 @@ def create_scan_task_record(
task_id: str,
task_name: str = "scan-perform",
task_status: str | None = states.PENDING,
task_kwargs: dict | None = None,
) -> Task:
"""Pre-create the TaskResult + Task rows for a pre-generated task id.
Pass ``task_kwargs`` when the response built from this record is serialized
before the broker publish. ``task_kwargs`` is otherwise only written by the
``before_task_publish`` signal (``api/signals.py``), and the scan publish is
deferred to ``on_commit``, so the 202 would carry an empty ``task_args`` and
the caller would have no way to learn the scan id it was just handed a task
for. The publish later overwrites the field with the same kwargs as a Python
repr; both forms decode to the same dict (``decode_celery_field``).
"""
if task_status is None:
task_status = states.PENDING
defaults = {"status": task_status, "task_name": task_name}
if task_kwargs is not None:
defaults["task_kwargs"] = json.dumps(task_kwargs)
task_result, _ = TaskResult.objects.update_or_create(
task_id=str(task_id),
defaults={"status": task_status, "task_name": task_name},
defaults=defaults,
)
prowler_task, _ = Task.objects.update_or_create(
id=str(task_id),
@@ -711,6 +728,13 @@ def cleanup_stale_attack_paths_scans_task():
return cleanup_stale_attack_paths_scans()
@shared_task(
name="attack-paths-reap-orphaned-tmp-databases", queue="attack-paths-scans"
)
def reap_orphaned_attack_paths_tmp_databases_task():
return reap_orphaned_tmp_databases()
@shared_task(name="reconcile-orphan-tasks", queue="celery")
def reconcile_orphan_tasks_task():
"""Periodic watchdog: recover tasks whose worker is gone (deploys, crashes)."""
@@ -873,8 +897,12 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
)
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
for batch, is_last in batched(qs, DJANGO_FINDINGS_BATCH_SIZE):
metadata_cache = {}
fos = [
FindingOutput.transform_api_finding(f, prowler_provider) for f in batch
FindingOutput.transform_api_finding(
f, prowler_provider, metadata_cache=metadata_cache
)
for f in batch
]
# Outputs
@@ -955,6 +983,8 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
writer.batch_write_data_to_file()
writer._data.clear()
del fos, metadata_cache
compressed = _compress_output_files(out_dir)
upload_uri = _upload_to_s3(
@@ -0,0 +1,286 @@
from datetime import UTC, datetime, timedelta
from unittest.mock import patch
from uuid import uuid4
import pytest
from api.attack_paths.database import TEMP_DB_PREFIX
from api.models import AttackPathsScan, StateChoices
from api.uuid_utils import datetime_to_uuid7
from config.django.base import ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS
MARGIN = timedelta(hours=ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS)
def _tmp_db_name(scan_uuid) -> str:
return f"{TEMP_DB_PREFIX}{scan_uuid}"
@pytest.mark.django_db
class TestReapOrphanedTmpDatabases:
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
def test_ignores_databases_without_the_temp_prefix(self, mock_list, mock_drop):
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
mock_list.return_value = ["db-tenant-abc123", "system", "neo4j"]
result = reap_orphaned_tmp_databases()
assert result == {"dropped_count": 0, "databases": []}
mock_drop.assert_not_called()
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
def test_drops_temp_db_with_no_scan_row_past_safety_margin(
self, mock_list, mock_drop
):
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
old_scan_id = datetime_to_uuid7(
datetime.now(tz=UTC) - MARGIN - timedelta(hours=1)
)
database = _tmp_db_name(old_scan_id)
mock_list.return_value = [database]
result = reap_orphaned_tmp_databases()
assert result == {"dropped_count": 1, "databases": [database]}
mock_drop.assert_called_once_with(database)
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
def test_preserves_temp_db_with_no_scan_row_inside_safety_margin(
self, mock_list, mock_drop
):
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
recent_scan_id = datetime_to_uuid7(datetime.now(tz=UTC) - timedelta(minutes=5))
database = _tmp_db_name(recent_scan_id)
mock_list.return_value = [database]
result = reap_orphaned_tmp_databases()
assert result == {"dropped_count": 0, "databases": []}
mock_drop.assert_not_called()
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
def test_preserves_temp_db_with_unparseable_scan_id(self, mock_list, mock_drop):
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
database = f"{TEMP_DB_PREFIX}not-a-uuid"
mock_list.return_value = [database]
result = reap_orphaned_tmp_databases()
assert result == {"dropped_count": 0, "databases": []}
mock_drop.assert_not_called()
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
def test_drops_terminal_scan_past_safety_margin(
self, mock_list, mock_drop, tenants_fixture, aws_provider
):
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
tenant = tenants_fixture[0]
old_updated_at = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1)
scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
provider=aws_provider,
state=StateChoices.COMPLETED,
)
AttackPathsScan.objects.filter(id=scan.id).update(updated_at=old_updated_at)
database = _tmp_db_name(scan.id)
mock_list.return_value = [database]
result = reap_orphaned_tmp_databases()
assert result == {"dropped_count": 1, "databases": [database]}
mock_drop.assert_called_once_with(database)
@pytest.mark.parametrize(
"state",
[StateChoices.FAILED, StateChoices.CANCELLED],
)
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
def test_drops_other_terminal_states_past_safety_margin(
self, mock_list, mock_drop, tenants_fixture, aws_provider, state
):
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
tenant = tenants_fixture[0]
old_updated_at = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1)
scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
provider=aws_provider,
state=state,
)
AttackPathsScan.objects.filter(id=scan.id).update(updated_at=old_updated_at)
database = _tmp_db_name(scan.id)
mock_list.return_value = [database]
result = reap_orphaned_tmp_databases()
assert result["dropped_count"] == 1
mock_drop.assert_called_once_with(database)
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
def test_preserves_terminal_scan_inside_safety_margin(
self, mock_list, mock_drop, tenants_fixture, aws_provider
):
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
tenant = tenants_fixture[0]
scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
provider=aws_provider,
state=StateChoices.COMPLETED,
)
database = _tmp_db_name(scan.id)
mock_list.return_value = [database]
result = reap_orphaned_tmp_databases()
assert result == {"dropped_count": 0, "databases": []}
mock_drop.assert_not_called()
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
def test_margin_counts_from_completed_at_not_updated_at(
self, mock_list, mock_drop, tenants_fixture, aws_provider
):
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
tenant = tenants_fixture[0]
old = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1)
scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
provider=aws_provider,
state=StateChoices.COMPLETED,
)
AttackPathsScan.objects.filter(id=scan.id).update(
updated_at=old, completed_at=datetime.now(tz=UTC)
)
database = _tmp_db_name(scan.id)
mock_list.return_value = [database]
result = reap_orphaned_tmp_databases()
assert result == {"dropped_count": 0, "databases": []}
mock_drop.assert_not_called()
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
def test_drops_scan_completed_past_safety_margin(
self, mock_list, mock_drop, tenants_fixture, aws_provider
):
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
tenant = tenants_fixture[0]
old = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1)
scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
provider=aws_provider,
state=StateChoices.FAILED,
)
AttackPathsScan.objects.filter(id=scan.id).update(
updated_at=old, completed_at=old
)
database = _tmp_db_name(scan.id)
mock_list.return_value = [database]
result = reap_orphaned_tmp_databases()
assert result == {"dropped_count": 1, "databases": [database]}
mock_drop.assert_called_once_with(database)
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
def test_never_drops_an_executing_scan_regardless_of_age(
self, mock_list, mock_drop, tenants_fixture, aws_provider
):
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
tenant = tenants_fixture[0]
very_old = datetime.now(tz=UTC) - timedelta(days=30)
scan = AttackPathsScan.objects.create(
tenant_id=tenant.id,
provider=aws_provider,
state=StateChoices.EXECUTING,
)
AttackPathsScan.objects.filter(id=scan.id).update(updated_at=very_old)
database = _tmp_db_name(scan.id)
mock_list.return_value = [database]
result = reap_orphaned_tmp_databases()
assert result == {"dropped_count": 0, "databases": []}
mock_drop.assert_not_called()
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
def test_one_failed_drop_does_not_stop_the_rest_of_the_sweep(
self, mock_list, mock_drop
):
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
old_time = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1)
failing_scan_id = datetime_to_uuid7(old_time)
succeeding_scan_id = datetime_to_uuid7(old_time)
failing_db = _tmp_db_name(failing_scan_id)
succeeding_db = _tmp_db_name(succeeding_scan_id)
mock_list.return_value = [failing_db, succeeding_db]
mock_drop.side_effect = [Exception("boom"), None]
result = reap_orphaned_tmp_databases()
assert result == {"dropped_count": 1, "databases": [succeeding_db]}
assert mock_drop.call_count == 2
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
def test_returns_empty_result_when_listing_databases_fails(self, mock_list):
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
mock_list.side_effect = Exception("neo4j unreachable")
result = reap_orphaned_tmp_databases()
assert result == {"dropped_count": 0, "databases": []}
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database")
@patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases")
def test_preserves_temp_db_with_random_uuid_and_no_row(self, mock_list, mock_drop):
"""A non-UUIDv7 id with no matching row has no reliable timestamp, so it
must be left alone rather than guessed at."""
from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases
database = _tmp_db_name(uuid4())
mock_list.return_value = [database]
result = reap_orphaned_tmp_databases()
assert result == {"dropped_count": 0, "databases": []}
mock_drop.assert_not_called()
class TestReapOrphanedTmpDatabasesTask:
@patch(
"tasks.tasks.reap_orphaned_tmp_databases",
return_value={"dropped_count": 2, "databases": ["db-tmp-scan-a"]},
)
def test_task_invokes_the_reaper(self, mock_reap):
from tasks.tasks import reap_orphaned_attack_paths_tmp_databases_task
result = reap_orphaned_attack_paths_tmp_databases_task.run()
assert result == {"dropped_count": 2, "databases": ["db-tmp-scan-a"]}
mock_reap.assert_called_once_with()
+39 -3
View File
@@ -3,7 +3,8 @@ from datetime import UTC, datetime
from unittest.mock import MagicMock, patch
import pytest
from api.models import Integration, LighthouseConfiguration, Provider
from api.models import Integration, LighthouseConfiguration, Provider, ProviderSecret
from api.v1.serializers import KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR
from tasks.jobs.connection import (
check_integration_connection,
check_lighthouse_connection,
@@ -65,17 +66,52 @@ def test_check_provider_connection_exception(
mock_provider_connection_test.return_value = MagicMock()
mock_provider_connection_test.return_value.is_connected = False
mock_provider_connection_test.return_value.error = Exception()
mock_provider_connection_test.return_value.error = Exception(
"Unable to assume role"
)
result = check_provider_connection(provider_id="provider_id")
assert result["connected"] is False
assert result["error"] is not None
assert result["error"] == "Unable to assume role"
mock_provider_instance.save.assert_called_once()
assert mock_provider_instance.connected is False
@pytest.mark.django_db
def test_check_provider_connection_with_stored_non_inline_kubeconfig(
kubernetes_provider,
):
"""A kubeconfig stored before the inline-only rule marks the provider as disconnected."""
kubernetes_provider.connected = True
kubernetes_provider.save()
ProviderSecret.objects.create(
tenant_id=kubernetes_provider.tenant_id,
provider=kubernetes_provider,
secret_type=ProviderSecret.TypeChoices.STATIC,
secret={
"kubeconfig_content": (
"apiVersion: v1\n"
"kind: Config\n"
"users:\n"
"- name: test-user\n"
" user:\n"
" tokenFile: /etc/passwd\n"
)
},
)
result = check_provider_connection(provider_id=str(kubernetes_provider.id))
kubernetes_provider.refresh_from_db()
assert result == {
"connected": False,
"error": KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
}
assert kubernetes_provider.connected is False
@pytest.mark.parametrize(
"lighthouse_data",
[
+56 -1
View File
@@ -4,6 +4,7 @@ import pytest
from api.attack_paths import database as graph_database
from api.models import Provider, Tenant, TenantComplianceSummary
from django.core.exceptions import ObjectDoesNotExist
from django.test import override_settings
from tasks.jobs.deletion import delete_provider, delete_tenant
@@ -123,6 +124,60 @@ class TestDeleteProvider:
"tenant-db", str(instance.id)
)
@override_settings(ATTACK_PATHS_SINK_DATABASE="neo4j")
def test_delete_provider_skips_unconfigured_retired_sink(
self, aws_provider, create_attack_paths_scan
):
instance = aws_provider
tenant_id = str(instance.tenant_id)
create_attack_paths_scan(instance, sink_backend="neo4j")
create_attack_paths_scan(instance, sink_backend="neptune")
neo4j_backend = MagicMock()
def get_backend_for_name(name):
if name == "neptune":
raise RuntimeError("NEPTUNE_WRITER_ENDPOINT and AWS_REGION must be set")
return neo4j_backend
with (
patch(
"tasks.jobs.deletion.graph_database.get_database_name",
return_value="tenant-db",
),
patch(
"tasks.jobs.deletion.sink_module.get_backend_for_name",
side_effect=get_backend_for_name,
),
patch("tasks.jobs.deletion.graph_database.drop_database"),
):
result = delete_provider(tenant_id, instance.id)
assert result
assert not Provider.all_objects.filter(pk=instance.id).exists()
neo4j_backend.drop_subgraph.assert_called_once_with(
"tenant-db", str(instance.id)
)
@override_settings(ATTACK_PATHS_SINK_DATABASE="neo4j")
def test_delete_provider_raises_when_active_sink_unconfigured(
self, aws_provider, create_attack_paths_scan
):
instance = aws_provider
tenant_id = str(instance.tenant_id)
create_attack_paths_scan(instance, sink_backend="neo4j")
with (
patch(
"tasks.jobs.deletion.sink_module.get_backend_for_name",
side_effect=RuntimeError("NEO4J_HOST / NEO4J_PORT must be set"),
),
patch("tasks.jobs.deletion.graph_database.drop_database"),
pytest.raises(RuntimeError),
):
delete_provider(tenant_id, instance.id)
assert Provider.all_objects.filter(pk=instance.id).exists()
def test_delete_provider_continues_when_temp_db_drop_fails(
self, aws_provider, create_attack_paths_scan
):
@@ -149,10 +204,10 @@ class TestDeleteProvider:
assert result
assert not Provider.all_objects.filter(pk=instance.id).exists()
@pytest.mark.usefixtures("provider_compliance_scores_fixture")
def test_delete_provider_recalculates_tenant_compliance_summary(
self,
aws_provider_pair,
provider_compliance_scores_fixture,
):
instance = aws_provider_pair[0]
tenant_id = instance.tenant_id
+213 -1
View File
@@ -3,16 +3,20 @@ import uuid
import zipfile
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, call, patch
from urllib.parse import parse_qs, urlparse
import boto3
import pytest
from botocore.exceptions import ClientError
from django.test import override_settings
from tasks.jobs.export import (
_compress_output_files,
_generate_compliance_output_directory,
_generate_output_directory,
_upload_to_s3,
get_s3_client,
get_s3_presign_client,
)
@@ -47,15 +51,59 @@ class TestOutputs:
assert client is not None
client_mock.list_buckets.assert_called()
@patch("tasks.jobs.export.boto3.client")
@override_settings(
DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID="access-key",
DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY="secret-key",
DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN="",
DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION="",
)
def test_get_s3_client_without_a_region_uses_a_default(self, mock_boto_client):
"""botocore rejects an empty region up front, and the download views do not catch it."""
get_s3_client()
assert mock_boto_client.call_args.kwargs["region_name"] == "us-east-1"
@patch("tasks.jobs.export.boto3.client")
@override_settings(DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="http://minio:9000")
def test_get_s3_client_passes_the_endpoint_when_set(self, mock_boto_client):
get_s3_client()
assert mock_boto_client.call_args.kwargs["endpoint_url"] == "http://minio:9000"
@patch("tasks.jobs.export.boto3.client")
@override_settings(DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="")
def test_get_s3_client_endpoint_empty_by_default(self, mock_boto_client):
"""Empty keeps today's behavior: no endpoint override, real S3 is used."""
get_s3_client()
assert mock_boto_client.call_args.kwargs["endpoint_url"] is None
@patch("tasks.jobs.export.boto3.client")
@override_settings(DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="http://minio:9000")
def test_get_s3_client_does_not_fall_back_when_endpoint_set(self, mock_boto_client):
"""A configured endpoint means the explicit client failed talking to it. The fallback
goes to the default provider chain (e.g. an EC2 instance role) against real AWS, so it
must not be used: the original error propagates instead."""
error = ClientError({"Error": {"Code": "403"}}, "ListBuckets")
mock_boto_client.side_effect = error
with pytest.raises(ClientError):
get_s3_client()
mock_boto_client.assert_called_once()
@patch("tasks.jobs.export.boto3.client")
@patch("tasks.jobs.export.settings")
def test_get_s3_client_fallback(self, mock_settings, mock_boto_client):
mock_settings.DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL = ""
mock_boto_client.side_effect = [
ClientError({"Error": {"Code": "403"}}, "ListBuckets"),
MagicMock(),
]
client = get_s3_client()
assert client is not None
assert mock_boto_client.call_args_list[1] == call("s3")
@patch("tasks.jobs.export.get_s3_client")
@patch("tasks.jobs.export.base")
@@ -243,3 +291,167 @@ class TestOutputs:
assert os.path.isdir(os.path.dirname(ens))
assert threatscore.endswith(f"aws-test-check-{expected_timestamp}")
assert ens.endswith(f"aws-test-check-{expected_timestamp}")
PRESIGN_SETTINGS = {
"DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID": "access-key",
"DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY": "secret-key",
"DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN": "",
"DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION": "eu-west-1",
}
def _presign(client):
return client.generate_presigned_url(
"get_object",
Params={"Bucket": "output-bucket", "Key": "tenant/scan/report.zip"},
ExpiresIn=300,
)
class TestS3PresignClient:
@override_settings(
**{**PRESIGN_SETTINGS, "DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION": ""},
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="",
)
def test_no_public_endpoint_and_no_region_returns_none(self):
# Without a region, SigV4 would have to guess one and break other regions.
assert get_s3_presign_client() is None
@override_settings(**PRESIGN_SETTINGS, DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="")
def test_region_without_public_endpoint_signs_sigv4_on_the_regional_host(self):
# SSE-KMS objects reject the SigV2 URLs boto3 presigns by default, and the
# global host redirects for new buckets, which breaks a SigV4 signature.
url = urlparse(_presign(get_s3_presign_client()))
query = parse_qs(url.query)
assert url.netloc == "s3.eu-west-1.amazonaws.com"
assert url.path == "/output-bucket/tenant/scan/report.zip"
assert query["X-Amz-Algorithm"] == ["AWS4-HMAC-SHA256"]
assert "/eu-west-1/s3/aws4_request" in query["X-Amz-Credential"][0]
@override_settings(
**{
**PRESIGN_SETTINGS,
"DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID": "",
"DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY": "",
},
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="",
)
def test_region_without_static_keys_signs_with_the_default_chain(self, monkeypatch):
# An ECS task role reaches boto3 through the default chain, like the env here.
# A fresh default session keeps these keys from being cached for later tests.
monkeypatch.setattr(boto3, "DEFAULT_SESSION", None)
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "role-access-key")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "role-secret-key")
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
query = parse_qs(urlparse(_presign(get_s3_presign_client())).query)
assert query["X-Amz-Credential"][0].startswith("role-access-key/")
assert "/eu-west-1/s3/aws4_request" in query["X-Amz-Credential"][0]
@override_settings(
**{**PRESIGN_SETTINGS, "DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION": ""},
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="",
DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="http://minio:9000",
)
def test_internal_endpoint_without_public_endpoint_signs_against_it(self):
# No browser-reachable host was configured, so the internal one is the best
# available target instead of falling through to the real AWS host.
url = urlparse(_presign(get_s3_presign_client()))
assert url.netloc == "minio:9000"
assert url.path == "/output-bucket/tenant/scan/report.zip"
@override_settings(
**PRESIGN_SETTINGS,
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com",
DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="http://minio:9000",
)
def test_public_endpoint_wins_over_the_internal_endpoint(self):
url = urlparse(_presign(get_s3_presign_client()))
assert url.netloc == "storage.example.com"
@override_settings(
**PRESIGN_SETTINGS,
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com",
)
def test_url_targets_the_public_host_in_path_style(self):
url = urlparse(_presign(get_s3_presign_client()))
assert url.netloc == "storage.example.com"
assert url.path == "/output-bucket/tenant/scan/report.zip"
@override_settings(
**PRESIGN_SETTINGS,
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com",
)
def test_signature_covers_the_public_host(self):
query = parse_qs(urlparse(_presign(get_s3_presign_client())).query)
assert query["X-Amz-SignedHeaders"] == ["host"]
assert "/eu-west-1/s3/aws4_request" in query["X-Amz-Credential"][0]
@override_settings(
**PRESIGN_SETTINGS,
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com",
)
def test_signature_is_bound_to_the_host_it_was_signed_against(self):
"""Rewriting the host afterwards cannot work, which is why the endpoint is a setting."""
public = parse_qs(urlparse(_presign(get_s3_presign_client())).query)
with override_settings(
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="http://minio:9000"
):
internal = parse_qs(urlparse(_presign(get_s3_presign_client())).query)
assert public["X-Amz-Signature"] != internal["X-Amz-Signature"]
@override_settings(
**{**PRESIGN_SETTINGS, "DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION": ""},
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com",
)
def test_region_falls_back_to_the_minio_default(self):
query = parse_qs(urlparse(_presign(get_s3_presign_client())).query)
assert "/us-east-1/s3/aws4_request" in query["X-Amz-Credential"][0]
@override_settings(
**PRESIGN_SETTINGS,
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com",
)
def test_unset_session_token_is_left_out_of_the_url(self):
"""An empty token still reaches the URL as a blank param that storage signs over."""
url = _presign(get_s3_presign_client())
query = parse_qs(urlparse(url).query, keep_blank_values=True)
assert "X-Amz-Security-Token" not in query
@override_settings(
**{**PRESIGN_SETTINGS, "DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN": "session-token"},
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com",
)
def test_session_token_is_forwarded_when_set(self):
query = parse_qs(urlparse(_presign(get_s3_presign_client())).query)
assert query["X-Amz-Security-Token"] == ["session-token"]
@override_settings(
**{
**PRESIGN_SETTINGS,
"DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID": "",
"DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY": "",
},
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com",
)
def test_blank_static_credentials_defer_to_the_provider_chain(self, monkeypatch):
"""Empty keys would otherwise be signed as-is, yielding a blank credential scope."""
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "chain-key")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "chain-secret")
monkeypatch.delenv("AWS_SESSION_TOKEN", raising=False)
query = parse_qs(urlparse(_presign(get_s3_presign_client())).query)
assert query["X-Amz-Credential"][0].startswith("chain-key/")
@@ -2,6 +2,7 @@ from unittest.mock import Mock, patch
import pytest
from api.models import StatusChoices
from reportlab.lib.units import inch
from reportlab.platypus import Image, LongTable, Paragraph, Table
from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData
from tasks.jobs.reports import cis as cis_report_module
@@ -582,3 +583,24 @@ class TestCISCoverPage:
paragraphs = [e for e in elements if isinstance(e, Paragraph)]
content = " ".join(str(p.text) for p in paragraphs)
assert "AWS" in content
def test_cover_page_wraps_long_account_id(
self, cis_generator, basic_cis_compliance_data
):
provider = Mock()
provider.provider = "oraclecloud"
provider.uid = "ocid1.tenancy.oc1.." + "a" * 70
provider.alias = None
basic_cis_compliance_data.provider_obj = provider
elements = cis_generator.create_cover_page(basic_cis_compliance_data)
table = next(
e
for e in elements
if isinstance(e, Table)
and any(row[0] == "Account ID:" for row in e._cellvalues)
)
values = [row[1] for row in table._cellvalues]
assert all(isinstance(v, Paragraph) for v in values)
account_id = next(v for v in values if v.text == provider.uid)
_, height = account_id.wrap(4 * inch, 1000)
assert height > account_id.style.leading
@@ -2,6 +2,7 @@ import io
from unittest.mock import Mock, patch
import pytest
from reportlab.lib.units import inch
from reportlab.platypus import Image, PageBreak, Paragraph, Table
from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData
from tasks.jobs.reports import ens as ens_report_module
@@ -189,6 +190,31 @@ class TestENSCoverPage:
tables = [e for e in elements if isinstance(e, Table)]
assert len(tables) >= 1 # At least info table
def test_cover_page_wraps_long_account_id(
self, ens_generator, basic_ens_compliance_data
):
provider = Mock()
provider.provider = "oraclecloud"
provider.uid = "ocid1.tenancy.oc1.." + "a" * 70
provider.alias = None
basic_ens_compliance_data.provider_obj = provider
basic_ens_compliance_data.requirements = []
basic_ens_compliance_data.attributes_by_requirement_id = {}
elements = ens_generator.create_cover_page(basic_ens_compliance_data)
table = next(
e
for e in elements
if isinstance(e, Table)
and any(row[0] == "Account ID:" for row in e._cellvalues)
)
values = [row[1] for row in table._cellvalues]
assert all(isinstance(v, Paragraph) for v in values)
account_id = next(v for v in values if v.text == provider.uid)
_, height = account_id.wrap(4 * inch, 1000)
assert height > account_id.style.leading
def test_cover_page_has_warning_about_manual(
self, ens_generator, basic_ens_compliance_data
):
@@ -2,6 +2,7 @@ import io
from unittest.mock import Mock, patch
import pytest
from reportlab.lib.units import inch
from reportlab.platypus import Image, PageBreak, Paragraph, Table
from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData
from tasks.jobs.reports import nis2 as nis2_report_module
@@ -214,6 +215,31 @@ class TestNIS2CoverPage:
tables = [e for e in elements if isinstance(e, Table)]
assert len(tables) >= 1
def test_cover_page_wraps_long_account_id(
self, nis2_generator, basic_nis2_compliance_data
):
provider = Mock()
provider.provider = "oraclecloud"
provider.uid = "ocid1.tenancy.oc1.." + "a" * 70
provider.alias = None
basic_nis2_compliance_data.provider_obj = provider
basic_nis2_compliance_data.requirements = []
basic_nis2_compliance_data.attributes_by_requirement_id = {}
elements = nis2_generator.create_cover_page(basic_nis2_compliance_data)
table = next(
e
for e in elements
if isinstance(e, Table)
and any(row[0] == "Account ID:" for row in e._cellvalues)
)
values = [row[1] for row in table._cellvalues]
assert all(isinstance(v, Paragraph) for v in values)
account_id = next(v for v in values if v.text == provider.uid)
_, height = account_id.wrap(4 * inch, 1000)
assert height > account_id.style.leading
# =============================================================================
# Executive Summary Tests
+250 -5
View File
@@ -12,6 +12,7 @@ from api.db_router import MainRouter
from api.db_utils import rls_transaction
from api.exceptions import ProviderConnectionError, ProviderDeletedException
from api.models import (
AttackSurfaceOverview,
Finding,
MuteRule,
Provider,
@@ -2348,6 +2349,78 @@ class TestProcessFindingMicroBatch:
assert resource_cache[finding.resource_uid].service == finding.service_name
assert tag_cache.keys() == {("team", "devsec")}
def test_process_finding_micro_batch_skips_unchanged_resource(
self, tenants_fixture, scans_fixture
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = scan.provider
resource_metadata = {"owner": "platform", "ports": [22, 443]}
existing_resource = Resource.objects.create(
tenant_id=tenant.id,
provider=provider,
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-001",
name="i-001",
region="us-east-1",
service="ec2",
type="instance",
metadata=json.dumps(resource_metadata, cls=CustomEncoder),
details="details",
partition="aws",
)
finding = FakeFinding(
uid="finding-unchanged",
status=StatusChoices.PASS,
status_extended="passing",
severity=Severity.low,
check_id="ec2_public_instance",
resource_uid=existing_resource.uid,
resource_name=existing_resource.name,
region=existing_resource.region,
service_name=existing_resource.service,
resource_type=existing_resource.type,
resource_tags={},
resource_metadata=resource_metadata,
resource_details=existing_resource.details,
partition=existing_resource.partition,
raw={"status": "PASS"},
compliance={},
metadata={"source": "prowler"},
muted=False,
)
with (
patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction),
patch("api.db_utils.rls_transaction", new=noop_rls_transaction),
patch(
"tasks.jobs.scan.Resource.objects.bulk_update",
wraps=Resource.objects.bulk_update,
) as bulk_update,
):
_process_finding_micro_batch(
str(tenant.id),
[finding],
scan,
provider,
{existing_resource.uid: existing_resource},
{},
{},
{existing_resource.uid: 0},
set(),
set(),
{},
{},
{},
{},
)
bulk_update.assert_not_called()
assert Finding.objects.filter(
tenant_id=tenant.id, scan_id=scan.id, uid=finding.uid
).exists()
def test_process_finding_micro_batch_refreshes_empty_resource_name(
self, tenants_fixture, scans_fixture
):
@@ -5327,8 +5400,13 @@ class TestAggregateAttackSurface:
mock_queryset = MagicMock()
mock_queryset.values.return_value = mock_queryset
mock_queryset.annotate.return_value = [
{"check_id": "check_internet_1", "total": 10, "failed": 3, "muted": 1},
{"check_id": "check_secrets_1", "total": 5, "failed": 2, "muted": 0},
{
"check_id": "check_internet_1",
"total": 10,
"failed": 3,
"muted_count": 1,
},
{"check_id": "check_secrets_1", "total": 5, "failed": 2, "muted_count": 0},
]
ctx = MagicMock()
@@ -5377,7 +5455,7 @@ class TestAggregateAttackSurface:
mock_queryset = MagicMock()
mock_queryset.values.return_value = mock_queryset
mock_queryset.annotate.return_value = [
{"check_id": "check_internet_1", "total": 5, "failed": 1, "muted": 0},
{"check_id": "check_internet_1", "total": 5, "failed": 1, "muted_count": 0},
]
ctx = MagicMock()
@@ -5460,8 +5538,13 @@ class TestAggregateAttackSurface:
mock_queryset = MagicMock()
mock_queryset.values.return_value = mock_queryset
mock_queryset.annotate.return_value = [
{"check_id": "check_internet_1", "total": 10, "failed": 3, "muted": 1},
{"check_id": "check_internet_2", "total": 5, "failed": 2, "muted": 0},
{
"check_id": "check_internet_1",
"total": 10,
"failed": 3,
"muted_count": 1,
},
{"check_id": "check_internet_2", "total": 5, "failed": 2, "muted_count": 0},
]
ctx = MagicMock()
@@ -5482,6 +5565,62 @@ class TestAggregateAttackSurface:
assert overview.failed_findings == 5 # 3 + 2
assert overview.muted_failed_findings == 1 # 1 + 0
@patch("tasks.jobs.scan._get_attack_surface_mapping_from_provider")
def test_aggregate_attack_surface_counts_real_findings(
self, mock_get_mapping, tenants_fixture, scans_fixture
):
"""Run the aggregation query against real Finding rows.
The other tests mock the queryset, so they never execute the real
`annotate`. This one guards the row keys the query returns."""
tenant = tenants_fixture[0]
scan = scans_fixture[0]
mock_get_mapping.return_value = {
"privilege-escalation": {"check_privesc_1"},
"secrets": {"check_secrets_1"},
}
def create_finding(uid, check_id, status, muted):
Finding.objects.create(
tenant_id=tenant.id,
uid=uid,
scan=scan,
status=status,
status_extended="status extended",
impact=Severity.high,
severity=Severity.high,
raw_result={"status": status},
check_id=check_id,
check_metadata={"CheckId": check_id},
muted=muted,
first_seen_at="2024-01-02T00:00:00Z",
)
create_finding("privesc_fail", "check_privesc_1", Status.FAIL, False)
create_finding("privesc_fail_2", "check_privesc_1", Status.FAIL, False)
create_finding("privesc_fail_muted", "check_privesc_1", Status.FAIL, True)
create_finding("privesc_pass", "check_privesc_1", Status.PASS, False)
create_finding("secrets_pass_muted", "check_secrets_1", Status.PASS, True)
create_finding("unmapped_fail", "check_unmapped", Status.FAIL, False)
aggregate_attack_surface(str(tenant.id), str(scan.id))
overviews = {
overview.attack_surface_type: overview
for overview in AttackSurfaceOverview.objects.filter(
tenant_id=tenant.id, scan_id=scan.id
)
}
assert set(overviews) == {"privilege-escalation", "secrets"}
assert overviews["privilege-escalation"].total_findings == 4
assert overviews["privilege-escalation"].failed_findings == 2
assert overviews["privilege-escalation"].muted_failed_findings == 1
assert overviews["secrets"].total_findings == 1
assert overviews["secrets"].failed_findings == 0
assert overviews["secrets"].muted_failed_findings == 0
@patch("tasks.jobs.scan.Scan.all_objects.select_related")
@patch("tasks.jobs.scan.rls_transaction")
def test_aggregate_attack_surface_uses_select_related(
@@ -5967,6 +6106,112 @@ class TestResetEphemeralResourceFindingsCount:
resource2.refresh_from_db()
assert resource2.failed_findings_count == 5
def test_runs_when_newer_scan_is_not_full_scope(
self, tenants_fixture, scans_fixture, aws_provider, resources_fixture
):
"""A newer scoped scan must not block the full-scope scan's cleanup.
The race guard used to pick the newest COMPLETED scan of any scope
despite being named after full-scope ones, so a single scoped scan
landing after a complete one made the complete scan skip its cleanup
and leave ephemeral resources with a stale count permanently.
"""
from datetime import timedelta
tenant, *_ = tenants_fixture
scan1, *_ = scans_fixture
resource1, resource2, _ = resources_fixture
Resource.objects.filter(id=resource2.id).update(failed_findings_count=5)
self._make_scan_summary(tenant.id, scan1.id, resource1)
newer_completed_at = scan1.completed_at + timedelta(minutes=5)
Scan.objects.create(
name="Newer scoped scan",
provider=aws_provider,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant_id=tenant.id,
started_at=newer_completed_at,
completed_at=newer_completed_at,
scanner_args={"checks": ["check1"]},
)
result = reset_ephemeral_resource_findings_count(
tenant_id=str(tenant.id), scan_id=str(scan1.id)
)
assert result["status"] == "completed"
resource2.refresh_from_db()
assert resource2.failed_findings_count == 0
def test_runs_when_many_newer_scans_are_not_full_scope(
self, tenants_fixture, scans_fixture, aws_provider, resources_fixture
):
"""The walk must not give up before it reaches the full-scope scan.
A fixed look-back window returned None once more newer scoped scans
had landed than it inspected, and skipped the cleanup exactly like the
original bug did with one.
"""
from datetime import timedelta
tenant, *_ = tenants_fixture
scan1, *_ = scans_fixture
resource1, resource2, _ = resources_fixture
Resource.objects.filter(id=resource2.id).update(failed_findings_count=5)
self._make_scan_summary(tenant.id, scan1.id, resource1)
for minutes in range(1, 41):
newer_completed_at = scan1.completed_at + timedelta(minutes=minutes)
Scan.objects.create(
name=f"Newer scoped scan {minutes}",
provider=aws_provider,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant_id=tenant.id,
started_at=newer_completed_at,
completed_at=newer_completed_at,
scanner_args={"checks": ["check1"]},
)
result = reset_ephemeral_resource_findings_count(
tenant_id=str(tenant.id), scan_id=str(scan1.id)
)
assert result["status"] == "completed"
resource2.refresh_from_db()
assert resource2.failed_findings_count == 0
def test_runs_when_completed_at_is_null(
self, tenants_fixture, scans_fixture, aws_provider, resources_fixture
):
"""NULL `completed_at` used to make the reset never run at all.
The old guard filtered `completed_at__isnull=False`, so a provider
whose completed scans all had a NULL `completed_at` resolved the
"latest" scan to None, which never equals `scan.id`.
"""
tenant, *_ = tenants_fixture
scan1, *_ = scans_fixture
resource1, resource2, _ = resources_fixture
Scan.all_objects.filter(id=scan1.id).update(completed_at=None)
Resource.objects.filter(id=resource2.id).update(failed_findings_count=5)
self._make_scan_summary(tenant.id, scan1.id, resource1)
result = reset_ephemeral_resource_findings_count(
tenant_id=str(tenant.id), scan_id=str(scan1.id)
)
assert result["status"] == "completed"
resource2.refresh_from_db()
assert resource2.failed_findings_count == 0
def test_does_not_touch_other_providers_resources(
self, tenants_fixture, scans_fixture, aws_provider, resources_fixture
):
+80 -1
View File
@@ -1,3 +1,4 @@
import json
import uuid
from contextlib import contextmanager
from datetime import UTC, datetime
@@ -14,6 +15,7 @@ from api.models import (
StateChoices,
Task,
)
from api.v1.serializers import TaskSerializer
from botocore.exceptions import ClientError
from celery import states
from django_celery_beat.models import IntervalSchedule, PeriodicTask
@@ -32,6 +34,7 @@ from tasks.tasks import (
_scan_tmp_output_directory,
check_integrations_task,
check_lighthouse_provider_connection_task,
create_scan_task_record,
generate_outputs_task,
mute_findings_in_latest_scans_task,
perform_attack_paths_scan_task,
@@ -739,7 +742,7 @@ class TestGenerateOutputs:
patch("tasks.tasks.FindingOutput._transform_findings_stats"),
patch(
"tasks.tasks.FindingOutput.transform_api_finding",
side_effect=lambda f, _prov: f,
side_effect=lambda f, _prov, **_kw: f,
),
patch("tasks.tasks._compress_output_files", return_value="outdir.zip"),
patch("tasks.tasks._upload_to_s3", return_value="s3://bucket/outdir.zip"),
@@ -3384,3 +3387,79 @@ class TestTaskTimeLimits:
"lighthouse-provider-connection-check",
):
assert celery_app.tasks[name].time_limit < default
@pytest.mark.django_db
class TestCreateScanTaskRecord:
"""`task_kwargs` is what a response built before the publish can report."""
def _scan(self, tenant, provider):
"""A manual scan, like the one `POST /api/v1/scans` creates."""
return Scan.objects.create(
tenant_id=tenant.id,
provider=provider,
name="Manual scan",
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.AVAILABLE,
)
def _publish_kwargs(self, tenant, scan):
"""What `enqueue_scan_execution_on_commit` publishes for this scan."""
return {
"tenant_id": str(tenant.id),
"scan_id": str(scan.id),
"provider_id": str(scan.provider_id),
}
def _task_args(self, task):
"""Read the record back the way `TaskSerializer` does."""
return TaskSerializer(task).data["task_args"]
def test_the_stored_kwargs_are_the_ones_the_publish_would_send(
self, tenants_fixture, aws_provider
):
"""The 202 reports what is stored here, so it has to be the dispatch kwargs."""
tenant = tenants_fixture[0]
scan = self._scan(tenant, aws_provider)
task = create_scan_task_record(
tenant_id=str(tenant.id),
task_id=str(uuid.uuid4()),
task_kwargs=self._publish_kwargs(tenant, scan),
)
assert self._task_args(task) == {
"scan_id": str(scan.id),
"provider_id": str(aws_provider.id),
}
def test_a_record_created_without_kwargs_reports_none(self, tenants_fixture):
"""The argument is optional, so the other callers keep their behaviour."""
task = create_scan_task_record(
tenant_id=str(tenants_fixture[0].id),
task_id=str(uuid.uuid4()),
)
assert self._task_args(task) == {}
def test_the_publish_can_overwrite_the_stored_kwargs(
self, tenants_fixture, aws_provider
):
"""django-celery-results stores a Python repr; both must decode alike."""
tenant = tenants_fixture[0]
scan = self._scan(tenant, aws_provider)
task_id = str(uuid.uuid4())
kwargs = self._publish_kwargs(tenant, scan)
task = create_scan_task_record(
tenant_id=str(tenant.id), task_id=task_id, task_kwargs=kwargs
)
before = self._task_args(task)
# What `before_task_publish` writes once the task reaches the broker.
task_result = TaskResult.objects.get(task_id=task_id)
task_result.task_kwargs = json.dumps(repr(kwargs))
task_result.save(update_fields=["task_kwargs"])
task.refresh_from_db()
assert self._task_args(task) == before
+17 -1
View File
@@ -1,10 +1,11 @@
import json
from datetime import UTC, datetime, timedelta
from unittest.mock import patch
import pytest
from django_celery_beat.models import IntervalSchedule, PeriodicTask
from django_celery_results.models import TaskResult
from tasks.utils import batched, get_next_execution_datetime
from tasks.utils import CustomEncoder, batched, get_next_execution_datetime
@pytest.mark.django_db
@@ -100,3 +101,18 @@ class TestBatchedFunction:
result = list(batched([1, 2, 3], 5))
expected = [([1, 2, 3], True)]
assert result == expected
class TestCustomEncoder:
def test_set_serialized_in_stable_order(self):
zones = {"us-east-1d", "us-east-1a", "us-east-1c", "us-east-1b"}
assert json.dumps({"zones": zones}, cls=CustomEncoder) == json.dumps(
{"zones": "{'us-east-1a', 'us-east-1b', 'us-east-1c', 'us-east-1d'}"}
)
def test_empty_set_keeps_str_representation(self):
assert json.dumps(set(), cls=CustomEncoder) == json.dumps("set()")
def test_frozenset_serialized_like_set(self):
assert json.dumps(frozenset({2, 1}), cls=CustomEncoder) == json.dumps("{1, 2}")
+5
View File
@@ -19,6 +19,11 @@ class CustomEncoder(json.JSONEncoder):
return o.isoformat(timespec="seconds")
if isinstance(o, timedelta):
return o.total_seconds()
# Same representation as str(set), with a stable order across processes
if isinstance(o, (set, frozenset)):
if not o:
return "set()"
return "{" + ", ".join(repr(item) for item in sorted(o, key=repr)) + "}"
# Custom object serialization
try:
Generated
+189 -180
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.3" },
{ name = "aiohttp", specifier = "==3.14.5" },
{ name = "aioitertools", specifier = "==0.13.0" },
{ name = "aiosignal", specifier = "==1.4.0" },
{ name = "alibabacloud-actiontrail20200706", specifier = "==2.4.1" },
@@ -112,7 +112,7 @@ constraints = [
{ name = "botocore", specifier = "==1.40.61" },
{ name = "cartography", specifier = "==0.138.1" },
{ name = "celery", specifier = "==5.6.2" },
{ name = "certifi", specifier = "==2026.1.4" },
{ name = "certifi", specifier = "==2026.10.19" },
{ name = "cffi", specifier = "==2.0.0" },
{ name = "charset-normalizer", specifier = "==3.4.4" },
{ name = "circuitbreaker", specifier = "==2.1.3" },
@@ -138,8 +138,8 @@ constraints = [
{ name = "dill", specifier = "==0.4.1" },
{ name = "distro", specifier = "==1.9.0" },
{ name = "dj-rest-auth", specifier = "==7.0.1" },
{ name = "django", specifier = "==5.1.15" },
{ name = "django-allauth", specifier = "==65.15.0" },
{ name = "django", specifier = "==5.2.17" },
{ name = "django-allauth", specifier = "==65.19.7" },
{ name = "django-celery-beat", specifier = "==2.9.0" },
{ name = "django-celery-results", specifier = "==2.6.0" },
{ name = "django-cors-headers", specifier = "==4.4.0" },
@@ -150,10 +150,10 @@ constraints = [
{ name = "django-postgres-extra", specifier = "==2.0.9" },
{ name = "django-silk", specifier = "==5.3.2" },
{ name = "django-timezone-field", specifier = "==7.2.1" },
{ name = "djangorestframework", specifier = "==3.15.2" },
{ name = "djangorestframework", specifier = "==3.18.3" },
{ name = "djangorestframework-jsonapi", specifier = "==7.0.2" },
{ name = "djangorestframework-simplejwt", specifier = "==5.5.1" },
{ name = "dnspython", specifier = "==2.8.0" },
{ name = "dnspython", specifier = "==2.9.0" },
{ name = "docker", specifier = "==7.1.0" },
{ name = "dogpile-cache", specifier = "==1.5.0" },
{ name = "dparse", specifier = "==0.6.4" },
@@ -162,7 +162,7 @@ constraints = [
{ name = "drf-simple-apikey", specifier = "==2.2.1" },
{ name = "drf-spectacular", specifier = "==0.27.2" },
{ name = "drf-spectacular-jsonapi", specifier = "==0.5.1" },
{ name = "dulwich", specifier = "==1.2.5" },
{ name = "dulwich", specifier = "==1.2.9" },
{ name = "duo-client", specifier = "==5.5.0" },
{ name = "durationpy", specifier = "==0.10" },
{ name = "email-validator", specifier = "==2.2.0" },
@@ -253,7 +253,7 @@ constraints = [
{ name = "nest-asyncio", specifier = "==1.6.0" },
{ name = "nltk", specifier = "==3.9.4" },
{ name = "numpy", specifier = "==2.2.6" },
{ name = "oauthlib", specifier = "==3.3.1" },
{ name = "oauthlib", specifier = "==4.0.0" },
{ name = "oci", specifier = "==2.184.1" },
{ name = "openai", specifier = "==1.109.1" },
{ name = "openstacksdk", specifier = "==4.2.0" },
@@ -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.3.0" },
{ name = "pillow", specifier = "==13.0.0" },
{ name = "pkginfo", specifier = "==1.12.1.2" },
{ name = "platformdirs", specifier = "==4.5.1" },
{ name = "plotly", specifier = "==6.5.2" },
@@ -291,7 +291,7 @@ constraints = [
{ name = "pydantic-core", specifier = "==2.41.5" },
{ name = "pygithub", specifier = "==2.8.0" },
{ name = "pygments", specifier = "==2.20.0" },
{ name = "pyjwt", specifier = "==2.13.0" },
{ name = "pyjwt", specifier = "==2.14.0" },
{ name = "pylint", specifier = "==3.2.5" },
{ name = "pymsalruntime", specifier = "==0.18.1" },
{ name = "pynacl", specifier = "==1.6.2" },
@@ -355,7 +355,7 @@ constraints = [
{ name = "tzdata", specifier = "==2025.3" },
{ name = "tzlocal", specifier = "==5.3.1" },
{ name = "uritemplate", specifier = "==4.2.0" },
{ name = "urllib3", specifier = "==2.7.0" },
{ name = "urllib3", specifier = "==2.8.0" },
{ name = "uuid6", specifier = "==2024.7.10" },
{ name = "uvicorn", specifier = "==0.49.0" },
{ name = "uvloop", specifier = "==0.22.1" },
@@ -369,7 +369,7 @@ constraints = [
{ name = "xlsxwriter", specifier = "==3.2.9" },
{ name = "xmlsec", specifier = "==1.3.17" },
{ name = "xmltodict", specifier = "==1.0.2" },
{ name = "yarl", specifier = "==1.22.0" },
{ name = "yarl", specifier = "==1.25.1" },
{ name = "zipp", specifier = "==3.23.0" },
{ name = "zope-event", specifier = "==6.1" },
{ name = "zope-interface", specifier = "==8.2" },
@@ -378,7 +378,7 @@ constraints = [
overrides = [
{ name = "azure-mgmt-containerservice", specifier = "==34.1.0" },
{ name = "cryptography", specifier = "==50.0.0" },
{ name = "dulwich", specifier = "==1.2.5" },
{ name = "dulwich", specifier = "==1.2.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" },
@@ -387,7 +387,8 @@ overrides = [
{ 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" },
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.14.0" },
{ name = "pymongo", specifier = "==4.18.3" },
]
[[package]]
@@ -479,7 +480,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.14.3"
version = "3.14.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -491,44 +492,45 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "yarl" },
]
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" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/4c/bdccd81e9ee225b69c60e7766c9a5b05364f118f4d383713b89a682d772d/aiohttp-3.14.5.tar.gz", hash = "sha256:5558a7f5a05af9ecf744af91e5baefc436f93c9333e656c27ec253f9a6bbe178", size = 8078052, upload-time = "2026-10-11T01:05:12.408Z" }
wheels = [
{ 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" },
{ url = "https://files.pythonhosted.org/packages/d8/f3/8997f18890a92c79f77fcfbb4f78ca17c2d4ab109e9eb6d31b4e9de194a0/aiohttp-3.14.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d51db97c96384fbfcaf8f4c65922183a68b94f891c3c10c862ef5f6df2adbb1f", size = 832358, upload-time = "2026-10-11T00:59:42.195Z" },
{ url = "https://files.pythonhosted.org/packages/8e/42/084651e9efb5cadb99265f786df60f2a7b353cead7bfd2c87003cb4867ad/aiohttp-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ae53924aa853a7a2ca20ed4142c7c6b56338e4d4cd999e2980075b9efc2e257a", size = 559636, upload-time = "2026-10-11T00:59:43.677Z" },
{ url = "https://files.pythonhosted.org/packages/3d/fe/92838944e601f0fe195fd3f3ada37e29fbee3d19bfd323fe15937ec79d36/aiohttp-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2c473a355f9239efcb72c92d5abfd8fcdb0cc78c8e9af607e72ca12dbb36593", size = 552588, upload-time = "2026-10-11T00:59:45.592Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b8/dd9b95c5c20ceae1b47738e656bd79e9883e3117a9cc8b143901eb604e9a/aiohttp-3.14.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e8fa6644e541fcd7e02430588c7fc93b602c1778ea0bc345505db76b61cfb4", size = 1928224, upload-time = "2026-10-11T00:59:47.294Z" },
{ url = "https://files.pythonhosted.org/packages/c6/47/70010cd2ba8746968d53d433ff328c33f06a66c99da5f3de759c64a6b2eb/aiohttp-3.14.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:579f97d5120f2971876d2ddca2968135f6944d00c44c3a6590ad7d86ca9b403f", size = 1877674, upload-time = "2026-10-11T00:59:49.472Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a1/b026f071dbd87f0bdba86b92d47e46f3899e3ad7143b6e0fe7e7887090b4/aiohttp-3.14.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:24409db442e2fb6e766bc7f3943851a8381dec3098140e43bb2e843b79e31b12", size = 1985850, upload-time = "2026-10-11T00:59:51.296Z" },
{ url = "https://files.pythonhosted.org/packages/36/d1/f7b6f6f8c3cb5a71b53baeddc2e70c224c28b12a23a55c42994c504019b7/aiohttp-3.14.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e8f97c0488ffda3082766ac0f2c8150a9a58c4d05788330e479cfd449b37939", size = 2105471, upload-time = "2026-10-11T00:59:53.191Z" },
{ url = "https://files.pythonhosted.org/packages/42/74/2a2b22953de15c6c8a80debf26db3047e4e8a5a5db6dd7c2c6c01eec0605/aiohttp-3.14.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50a195903119008fe9cc68710535eb37f556ffffd6a7759afe70a2c145587045", size = 1929915, upload-time = "2026-10-11T00:59:55.609Z" },
{ url = "https://files.pythonhosted.org/packages/33/ad/f80e8d33933d0eacd0217efa3b7fb32c9f48ed480ed0db53cf9948bf474f/aiohttp-3.14.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0133c3c3b54a0bf1e71fa5c1ad95c93f07fd54e24ef1fe182f5122e1573d2bf1", size = 1773832, upload-time = "2026-10-11T00:59:57.611Z" },
{ url = "https://files.pythonhosted.org/packages/04/a4/0273d239f3e3bb69438f209c64c82e1f98dc60d5db264795e41f7dd05dcb/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c172db893e516e1358e65a95ee20b7ce7173963eefe318b6ab2a2220688b999e", size = 1901001, upload-time = "2026-10-11T01:00:00.605Z" },
{ url = "https://files.pythonhosted.org/packages/16/ed/7dd439d26c654645bc34ca3c9a822fc11c1c51801fed1cefdfe1cc41c1f6/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f2a7966bda23dd85051f1661ce0ace38d6890e05ec6c357ecae9d2479cba377e", size = 1907218, upload-time = "2026-10-11T01:00:02.699Z" },
{ url = "https://files.pythonhosted.org/packages/32/dd/86249c3b8248562a17fd3e22ee0c164378d65766f01aceef62cf52783710/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4887d130a7bbfed3a85493bb5a25e5b5b558d40c1d986dd16970d2bb26d63793", size = 1958650, upload-time = "2026-10-11T01:00:04.851Z" },
{ url = "https://files.pythonhosted.org/packages/bc/0b/ca4d53d68f683ce195807fd0aeb063bf6d1c6b39343fff234c9524e8d5a9/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:225c579c23b68b343cccea27a7e06e3bd8ec23a09c30b427eb3f1e4ca6239b20", size = 1760057, upload-time = "2026-10-11T01:00:06.978Z" },
{ url = "https://files.pythonhosted.org/packages/6b/42/005437ffd7fa56c2ce3347add40404b654a32754ce91f2e2a14b9e5ab2bc/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ab52d8f1fc1b64821c1fbad64a647ed6203627004059a6d1ed4f0858a1499703", size = 1978081, upload-time = "2026-10-11T01:00:08.868Z" },
{ url = "https://files.pythonhosted.org/packages/03/71/3a5b66fe1b7b23d3c6524350817ed55de5feb7fbb4cccba7cbe044d118dd/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb131d775a1573c1aee66656bd78b023577bbdb6cb8349a07773bd4f73e68a6e", size = 1922954, upload-time = "2026-10-11T01:00:11.035Z" },
{ url = "https://files.pythonhosted.org/packages/69/84/d08a554f281d42fd7c6b2b6dd7738f3e3a7a9f657ddd87e11ca2091cdfe3/aiohttp-3.14.5-cp311-cp311-win32.whl", hash = "sha256:e87046c8ff77a8decdb6a41d8ab25824b47531b2da933aeab0c1e21c7acff329", size = 488101, upload-time = "2026-10-11T01:00:12.933Z" },
{ url = "https://files.pythonhosted.org/packages/0f/15/52b5e65f02b33686ae49f1518548ae4f5a7adaf31d9d3c54fa12a143137b/aiohttp-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:6f275c11d1aa6d4c458e05a68be084efe3c55a113d99e3f46a318098e52948fc", size = 512575, upload-time = "2026-10-11T01:00:14.743Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b9/bb75012635f3defb0f7cbb7c4ae391e36bb9cb3fbdca3b9944e2ebebf743/aiohttp-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:b032a0023eb41d768ce77d83210ab2a3c389bc0b09313273c7e1eca48c10a755", size = 493179, upload-time = "2026-10-11T01:00:16.961Z" },
{ url = "https://files.pythonhosted.org/packages/d5/94/6ba86efddcb616c811b40e6a0dfdd862738f647e4e3860a961075d5e9ed8/aiohttp-3.14.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df37b620684e19b5e25724412518ccafc3b1a49cdac706fdbd2f983fad943450", size = 817499, upload-time = "2026-10-11T01:00:18.901Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e1/7bca6d84dabd228aa8eb4b7f9feac2586aaa9be5505d7d65bf287a615c43/aiohttp-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef60869969180ec2464f1349aff07138ae35ca2200f0946cb3552e49e8f301a8", size = 549467, upload-time = "2026-10-11T01:00:20.854Z" },
{ url = "https://files.pythonhosted.org/packages/64/91/11b89f45ca486252dd67dd5f3231fec04bf5518da39a95cb3997619f17fb/aiohttp-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d079c0a0135c36e7beb6f1c88087c8f108dc5891cdd0b5eafa778421bda70ed2", size = 548067, upload-time = "2026-10-11T01:00:22.659Z" },
{ url = "https://files.pythonhosted.org/packages/22/ff/c6615806c14aab34f82b9424ccde8ce6e417315fd57ce1c5b4d4747888e1/aiohttp-3.14.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abfda5cb094a829f7bc25216a32f7db2e85cc65bd59910f8e7b40b3d9b224764", size = 1896245, upload-time = "2026-10-11T01:00:24.638Z" },
{ url = "https://files.pythonhosted.org/packages/39/b2/25a8c971ae6a92c8394d77e42422d7f38989e05cf41a5ceb92d73d67ab7e/aiohttp-3.14.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9cc882cf8619109583c906b4d4a85d6a111a98afa34b7a450d1e08118d016820", size = 1872180, upload-time = "2026-10-11T01:00:26.838Z" },
{ url = "https://files.pythonhosted.org/packages/2f/d5/99f93ea36cc5205e47c1e5a803e087f2ad21b5430b5db2e942cb6e988a37/aiohttp-3.14.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7457580535e019e1247ea35d6a02bf081ad30c26d0cbc210c93f6c3ab67a0835", size = 1937261, upload-time = "2026-10-11T01:00:28.74Z" },
{ url = "https://files.pythonhosted.org/packages/40/a6/9ac9c9e6695040bd73d2584a1b59a9388f6433c76d5294a7bf591e21ffe5/aiohttp-3.14.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5ed596aedb9c42afd3fe0aae3117725378ac73d2cc5ddc735056fbdb96c5d02", size = 2064994, upload-time = "2026-10-11T01:00:30.623Z" },
{ url = "https://files.pythonhosted.org/packages/da/e4/aa172eb534b7f02f1f8ff1c3213347eaf3cf218db91a727c6863c22f1035/aiohttp-3.14.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20f085697d7e911f1f73c43ed03fafbed1e7121797e2eb5428efa80398060584", size = 1919630, upload-time = "2026-10-11T01:00:32.548Z" },
{ url = "https://files.pythonhosted.org/packages/06/7d/4eedafc5bababa8932636c141e346806966eade12c0b7e5946d43bf8218b/aiohttp-3.14.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74b0a9c8270f9b0a11410e124ff8d4f18bfc1f1837440ec84da5ae7b50927b5d", size = 1710016, upload-time = "2026-10-11T01:00:34.471Z" },
{ url = "https://files.pythonhosted.org/packages/b2/94/eee018537ba19da0ceb2ac79cab83faed4ac49568e08376e2799043f2538/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:19e2ba471507c34f8252402ab50f5ab512398b9ea8c8f1cb26beb3f75793ba30", size = 1858112, upload-time = "2026-10-11T01:00:36.581Z" },
{ url = "https://files.pythonhosted.org/packages/68/76/354653a306547238f3427283905972d796ba7c292ba9977abec9f2b6f260/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d418ce2af40c6bb685b3f663e9e8de27cb0a22431d8e88a167348d7f01878073", size = 1896371, upload-time = "2026-10-11T01:00:38.478Z" },
{ url = "https://files.pythonhosted.org/packages/f2/ec/63e8c7136b570e356345ad3174e3820fdc973ea10712cb6c649bf875755a/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:70cb4008ac2ed1e0ca9e824deb4b53d3aa0d939109698ebf1e723a84337bd794", size = 1905836, upload-time = "2026-10-11T01:00:40.527Z" },
{ url = "https://files.pythonhosted.org/packages/57/d8/11365bda144b127928cd42533d0eff78a55613c9e28f81941bd6630ea887/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a23fe35d776bc03cb495938b9594450d047e3bc08c5255315a82323e9cb7d2dd", size = 1696534, upload-time = "2026-10-11T01:00:42.686Z" },
{ url = "https://files.pythonhosted.org/packages/2f/3d/82df0461b18e00b2998f205c03e0d3010222478c43640aceb8e03dcd7e8f/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3e0eb43bed3c6801a6cee315195377789e90b2a72c2277a475b578535312488d", size = 1933970, upload-time = "2026-10-11T01:00:44.71Z" },
{ url = "https://files.pythonhosted.org/packages/03/ad/6ddfe0aacd931c17b53533336d97e9d11a98b96d6ae815a9da0b19f82ccf/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be7dd397d64ca3e1869626fa9318aaebb54b7bf93bc72d7a205448d83e4f748", size = 1899501, upload-time = "2026-10-11T01:00:46.629Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8e/189bdd9ae4793059bb09f6dc880f211a6c6c7859cebcbf33912dbfab7dd7/aiohttp-3.14.5-cp312-cp312-win32.whl", hash = "sha256:eb324e2009fb54db30a071dad7caf6998ee2879c4704007efb244514dad1fec1", size = 480370, upload-time = "2026-10-11T01:00:48.468Z" },
{ url = "https://files.pythonhosted.org/packages/ae/ce/1f08114679d49655b30a6e0a29858375c94b82c1de1a0bd0a20c2fee8b02/aiohttp-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:2cc38a4f2b516bef1714e690df87a0e043faf1a7693c82d860091684453d5111", size = 506141, upload-time = "2026-10-11T01:00:50.272Z" },
{ url = "https://files.pythonhosted.org/packages/79/d4/c7b4f60b16a1b7e43249fa9031ae05e7e8c341ba4b7d1866f914dafeaa0e/aiohttp-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:a63afd1f757de949028387e65a7127b61ad0f775432dbb0e62816ae619fe69ac", size = 488108, upload-time = "2026-10-11T01:00:52.321Z" },
{ url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" },
]
[[package]]
@@ -1847,11 +1849,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2026.1.4"
version = "2026.10.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
]
[[package]]
@@ -2329,29 +2331,29 @@ with-social = [
[[package]]
name = "django"
version = "5.1.15"
version = "5.2.17"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/10/45/1ac68964193cfcc0b0912a0f68025d5bdb54f71ba7b8716e85b959874bd0/django-5.1.15.tar.gz", hash = "sha256:46a356b5ff867bece73fc6365e081f21c569973403ee7e9b9a0316f27d0eb947", size = 10719662, upload-time = "2025-12-02T14:01:31.931Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d5/d8/43e9d000519adceb189620b6869ff88031e046df91c2e9da72f8f6918399/django-5.2.17.tar.gz", hash = "sha256:9d4d93be539a18ab80d058eb515900e10951e04c537c5a6b394fc49528d3251f", size = 10889740, upload-time = "2026-08-04T15:04:03.173Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/79/372e091f0eba4ddb8228245ccd1baaa140e9658711f5e3a0056e540b4c1e/django-5.1.15-py3-none-any.whl", hash = "sha256:117871e58d6eda37f09870b7d73a3d66567b03aecd515b386b1751177c413432", size = 8260901, upload-time = "2025-12-02T14:01:27.352Z" },
{ url = "https://files.pythonhosted.org/packages/df/f8/ce120525ca78f12b07daf65786679c5d0b54a75285a8958d3ae55e39da35/django-5.2.17-py3-none-any.whl", hash = "sha256:f04fb3b36ee119e1af4fa1d397d5fd6cf12700f49321e84d4f4c642c5b1973db", size = 8315563, upload-time = "2026-08-04T15:03:59.1Z" },
]
[[package]]
name = "django-allauth"
version = "65.15.0"
version = "65.19.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/84/c1/d3385f4c3169c1d6eea3c63aed0f36af51478c1d72e46db12bb1a08f8034/django_allauth-65.15.0.tar.gz", hash = "sha256:b404d48cf0c3ee14dacc834c541f30adedba2ff1c433980ecc494d6cb0b395a8", size = 2215709, upload-time = "2026-03-09T13:51:28.675Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c4/33/7c8f8fc85f4ee644ee3acb20c1fe4d04516298ab5903f812e1556b65f947/django_allauth-65.19.7.tar.gz", hash = "sha256:c7749551b659ca954e483f6f634cd0c262d65dd8144f5219b3a31cba0426e981", size = 2296178, upload-time = "2026-10-01T12:35:24.152Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/b8/c8411339171bd8bc075c09ef190fb42195e9a2149e5c5026e094fe62fce0/django_allauth-65.15.0-py3-none-any.whl", hash = "sha256:ad9fc49c49a9368eaa5bb95456b76e2a4f377b3c6862ee8443507816578c098d", size = 2022994, upload-time = "2026-03-09T13:51:19.711Z" },
{ url = "https://files.pythonhosted.org/packages/30/c4/a42cc4439b18b8d2767f8c54999e011b9f620a478806ed265baed7c3dc01/django_allauth-65.19.7-py3-none-any.whl", hash = "sha256:8899377f38afabf10445c9ee2f808a730304ca8aecc540657d51a8b3928283d6", size = 2076429, upload-time = "2026-10-01T12:35:20.844Z" },
]
[package.optional-dependencies]
@@ -2508,14 +2510,14 @@ wheels = [
[[package]]
name = "djangorestframework"
version = "3.15.2"
version = "3.18.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2c/ce/31482eb688bdb4e271027076199e1aa8d02507e530b6d272ab8b4481557c/djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad", size = 1067420, upload-time = "2024-06-19T07:59:32.891Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8d/b2/1cee76b188301f7d03169ca77fab2b3d3ec5d01b3469189f944af2d7f11a/djangorestframework-3.18.3.tar.gz", hash = "sha256:446a9b352e7eff630421ab3f2328bd2401b109a9470afa4a31189994911ed030", size = 923563, upload-time = "2026-10-06T21:33:51.521Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/b6/fa99d8f05eff3a9310286ae84c4059b08c301ae4ab33ae32e46e8ef76491/djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20", size = 1071235, upload-time = "2024-06-19T07:59:26.106Z" },
{ url = "https://files.pythonhosted.org/packages/c4/dd/de5f4543765bec94e036a5e9593a42f8fa7efec8de78eb56b9ca834385f3/djangorestframework-3.18.3-py3-none-any.whl", hash = "sha256:8544bb674846731b1e3c9b309236ee1dc412905a0aa725be2ec193ca950a7d12", size = 903986, upload-time = "2026-10-06T21:33:49.917Z" },
]
[[package]]
@@ -2548,11 +2550,11 @@ wheels = [
[[package]]
name = "dnspython"
version = "2.8.0"
version = "2.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ef/4a/50822184bd67cc6493f0fb6a880749158fcd31ab3fa07409acfd91f9fc85/dnspython-2.9.0.tar.gz", hash = "sha256:b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1", size = 423560, upload-time = "2026-10-09T00:07:24.352Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
{ url = "https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl", hash = "sha256:9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9", size = 354822, upload-time = "2026-10-09T00:07:22.622Z" },
]
[[package]]
@@ -2667,27 +2669,27 @@ wheels = [
[[package]]
name = "dulwich"
version = "1.2.5"
version = "1.2.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7f/85/ceb8ecff5cdeee4ceeebb86b599476dee559041dacc6c2c50cc0d4711549/dulwich-1.2.5.tar.gz", hash = "sha256:0395b2c8924c3424bafe2d9c1edd5348cc4b21ce9c1d6655bf01f9a5c47164c8", size = 1253230, upload-time = "2026-05-28T22:27:55.17Z" }
sdist = { url = "https://files.pythonhosted.org/packages/de/17/0a0fa2b998cd9a852b1aa901e3f6145f13a4334f64b91564847e5880968d/dulwich-1.2.9.tar.gz", hash = "sha256:b395466b78eb5c36baf34381b8b831fd26da8a44b428a2eea1d57ba53f68a234", size = 1309797, upload-time = "2026-07-06T20:57:23.202Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/4a/654ae1671610fdf6b65a64586ad67ddd8550d4d08a632b2a4b9614754b6d/dulwich-1.2.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:556593fd11637f80f6018bee1916b1a84f5b420423b470ebb3f1a782ad6ef081", size = 1399277, upload-time = "2026-05-28T22:27:00.801Z" },
{ url = "https://files.pythonhosted.org/packages/85/d8/06ee3bc8eded4bd7adf8adf0c9ea5f19bf96f7e5e626bfaf7311cde4208a/dulwich-1.2.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a70477c991e96cfe8fdd7c866e7251faf71b38bfeb51d6f27554c9cce1caabf3", size = 1382310, upload-time = "2026-05-28T22:27:02.216Z" },
{ url = "https://files.pythonhosted.org/packages/07/17/a03adf50b9095f9f5d863393f21d585dea39bdc4fdf60788ff3a9407a512/dulwich-1.2.5-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9008ef25cabd379cda4fa86000fc38ca14b72afe17db798a8c85c0b2b7ce4d1e", size = 1470993, upload-time = "2026-05-28T22:27:04.075Z" },
{ url = "https://files.pythonhosted.org/packages/60/58/1dc352d2a5e80befe4338af7208febb44bcfd7496b0dde5ac6dacb07b031/dulwich-1.2.5-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a5549f4afc973e0a15ea6b0244d57f848d3f3ee13dac557eb311024aebebf128", size = 1497820, upload-time = "2026-05-28T22:27:05.549Z" },
{ url = "https://files.pythonhosted.org/packages/c1/a8/e058959a87e7df7753b112ef66a43ccbc57338c1bbdc23a0edf3833396df/dulwich-1.2.5-cp311-cp311-win32.whl", hash = "sha256:5108acead814d1de8b6262d6d8fb90af7e82f5a4d83788b6b48e39d01800a92f", size = 1066549, upload-time = "2026-05-28T22:27:06.832Z" },
{ url = "https://files.pythonhosted.org/packages/33/91/ff0b444f686718635348986bd73dfce42e947912417893de35de399b878b/dulwich-1.2.5-cp311-cp311-win_amd64.whl", hash = "sha256:5e067b7feceb7034bc99e7c7143a704f1d97d4be7027d9a0aa5a83c0657ff091", size = 1079481, upload-time = "2026-05-28T22:27:08.33Z" },
{ url = "https://files.pythonhosted.org/packages/19/22/4f75770bbe5521cac61c4820ef46d4fbf8c2175d3519ba3d0378d4ba798e/dulwich-1.2.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:701a9ecf7a8a44f5e2459e46befa93530cf36a8b1ae3140aefc007db1d7d0207", size = 1396522, upload-time = "2026-05-28T22:27:09.997Z" },
{ url = "https://files.pythonhosted.org/packages/e5/b1/c07c347681c0cf6acd4b189bf6e8d6207c71a1347b7a1e865eb40faa46b9/dulwich-1.2.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f90d68bfa97c4ca71de7507984365aefe27b6d248cb28dc99644d0f3ae8c60b", size = 1334826, upload-time = "2026-05-28T22:27:11.582Z" },
{ url = "https://files.pythonhosted.org/packages/13/80/6818eb7ce492e18ab2efa92ab901d173b4b0b159e5681c1424f329600c40/dulwich-1.2.5-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:00b54a1d56ddbacdd8eadd6d4787a51b3a05fefa30eadbf9165fd283a00b90ed", size = 1416616, upload-time = "2026-05-28T22:27:13.195Z" },
{ url = "https://files.pythonhosted.org/packages/14/a7/9790e60d19870f6554f7583722bb324c1355784316f20aeda1c0b5b1491a/dulwich-1.2.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d8f7ea8f47e38e5b0de3fab97e07e9c9161ffddc90b3964512cab2b7749df4e6", size = 1441354, upload-time = "2026-05-28T22:27:14.683Z" },
{ url = "https://files.pythonhosted.org/packages/91/44/0ea8a69c24aa1254ff5996d682eae2eab287d471b937dcdb26d9ea9720b4/dulwich-1.2.5-cp312-cp312-win32.whl", hash = "sha256:8929134acf4ff967203df7600b38535f9b5b590462067a7e30dbce01acb97af9", size = 1017058, upload-time = "2026-05-28T22:27:16.121Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/2fcddda7faec3bae52db7c64bfcb5dc756f597f33fae90e8d4e4b4d3b39b/dulwich-1.2.5-cp312-cp312-win_amd64.whl", hash = "sha256:9693d2c9e226b2ea855c1dc3a87e2f4d972f7523fc0f7924e5997e9f4c23d97f", size = 1031731, upload-time = "2026-05-28T22:27:17.633Z" },
{ url = "https://files.pythonhosted.org/packages/07/4b/4a18a59ad230581cd0ef460e96001f90762e566dc2dfdba22aa358eb5a0e/dulwich-1.2.5-py3-none-any.whl", hash = "sha256:1679b376433a0fc7f36586afda1d4ed7427afa7a79d4bf17e5014474eea69fa4", size = 686745, upload-time = "2026-05-28T22:27:53.695Z" },
{ url = "https://files.pythonhosted.org/packages/95/70/0345eb2b07a9b26337d7e9ee2c0fc634be81de9969d683b72eca04878f72/dulwich-1.2.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b01fd4d8bd4fdd2bd99eb465e101b4fac2b50436e317b971da7ac3a1bd3a83d8", size = 1378231, upload-time = "2026-07-06T20:56:20.301Z" },
{ url = "https://files.pythonhosted.org/packages/56/dd/a699facf7371cb26233ea1f659ff172995cc06a999ba07992d2762a7dc1f/dulwich-1.2.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e92f76f0048b076ef692094d28645c5e0f65dae1d5e393fbb2e8db2edc017cd3", size = 1371812, upload-time = "2026-07-06T20:56:21.958Z" },
{ url = "https://files.pythonhosted.org/packages/35/02/73baf09f61b66431adb1cd01a9fbe748369a62814b6e27362daaaa924f04/dulwich-1.2.9-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:ac47e02988bdcde0c23f98275908390448617d491ad5d9cb9f33cf3eb8ad512b", size = 1493888, upload-time = "2026-07-06T20:56:23.697Z" },
{ url = "https://files.pythonhosted.org/packages/48/e4/aa2dff296c4a9cd769b5a55a8de68622c1605ad39d75b741527e63117acf/dulwich-1.2.9-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:5e6eb07fbd288059da71019f6746655cd1e136db2f1664fa3e1c81bc11973187", size = 1523051, upload-time = "2026-07-06T20:56:25.525Z" },
{ url = "https://files.pythonhosted.org/packages/24/da/73162905ffb2041a8f484e46c7bb269cb39cc984335b1e66eef23d38cb1d/dulwich-1.2.9-cp311-cp311-win32.whl", hash = "sha256:e96f5d29bf509ceb2dbf9cf4de67fe6e5d785b22f721814360563be6757e7473", size = 1043350, upload-time = "2026-07-06T20:56:27.443Z" },
{ url = "https://files.pythonhosted.org/packages/ab/e6/8e40755771b5d05c9268771f26cef8f2ba5f7a849bedcee06504d7f842ae/dulwich-1.2.9-cp311-cp311-win_amd64.whl", hash = "sha256:255c4c3605582fe8958afeff94fc1f0883aedc37495c43a74e031b904f3bb86d", size = 1103738, upload-time = "2026-07-06T20:56:29.145Z" },
{ url = "https://files.pythonhosted.org/packages/ab/b0/e4f14c603fa42e07d8a7f1d5a73f123a8148d52b79b3cc478797126dcdd3/dulwich-1.2.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d5a2896b6dc3f0c794d34b3eb1af317a43b557fefbee0099ef83d35bb6e102f", size = 1372001, upload-time = "2026-07-06T20:56:30.605Z" },
{ url = "https://files.pythonhosted.org/packages/c9/b7/11c40f1c3ce304e7c2e0e759c589430f043a7967689e61ecb0142ad1ebdc/dulwich-1.2.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:30e72110630e2990d11770227191dfa730b7c045030f818eca5803c6b89bbc5b", size = 1356130, upload-time = "2026-07-06T20:56:32.176Z" },
{ url = "https://files.pythonhosted.org/packages/6d/1a/10d61b784c295a4567615579272d91ede81793c72ead207484c0ed845ddd/dulwich-1.2.9-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5c636fcb33c4db5495a2dc7686cf6a26cf537060a859982e104dcf67635ea7f7", size = 1437923, upload-time = "2026-07-06T20:56:33.741Z" },
{ url = "https://files.pythonhosted.org/packages/6e/68/3073e2737e2c3f299e736490ec2e8b5864593858b9d08f36fbcbca80a862/dulwich-1.2.9-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1aa1b55f59a046fc8f25a67560473c789b7ec113b85c6a08021aae10c6f78945", size = 1463517, upload-time = "2026-07-06T20:56:35.812Z" },
{ url = "https://files.pythonhosted.org/packages/f2/61/f96c0da72b08bfbc4e38571374c3000dee13e6d72dd06f25281d36d5b0b7/dulwich-1.2.9-cp312-cp312-win32.whl", hash = "sha256:e27a06f9f71275d712b616ad23008df5f2dcf54a06cb8e2a24be8819ee1ce13a", size = 1082071, upload-time = "2026-07-06T20:56:38.139Z" },
{ url = "https://files.pythonhosted.org/packages/38/96/567f32885e780aaa7441f9f5c6f162e7271d149e27d3cb661caf939cd671/dulwich-1.2.9-cp312-cp312-win_amd64.whl", hash = "sha256:81e1005ffc5d5c872bfc87ac3442f722bce27ba10e435543d5302853c34abf56", size = 1053612, upload-time = "2026-07-06T20:56:39.977Z" },
{ url = "https://files.pythonhosted.org/packages/cd/f9/a6677c0e698b5b781d663eb394bd15cc69c4a270b11cdcc128076753d75c/dulwich-1.2.9-py3-none-any.whl", hash = "sha256:79ff0ff35c4e17ccd58f9bfb23f4f8d416b0b09337b8c99ee3b4f10459bb0d68", size = 711459, upload-time = "2026-07-06T20:57:21.516Z" },
]
[[package]]
@@ -4417,11 +4419,11 @@ wheels = [
[[package]]
name = "oauthlib"
version = "3.3.1"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7a/d8/a1bcc8ba112a627f8ffbdc212a78ce18d3ac07e91a5ca65d27918eee25a1/oauthlib-4.0.0.tar.gz", hash = "sha256:efb274799819440f95b4ab3b818869f1ce9ae26c5beacba0201d1a1b76b54f86", size = 187232, upload-time = "2026-09-28T06:01:18.77Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
{ url = "https://files.pythonhosted.org/packages/d9/f4/78229a1066068ca14fc60fb26cf7381cabe4382261392b90e5f9552722d4/oauthlib-4.0.0-py3-none-any.whl", hash = "sha256:624c28c13a0a59cabf9747dfa52af63be3e512a7f2714df16e91b5b3a145e6cd", size = 159715, upload-time = "2026-09-28T06:01:17.008Z" },
]
[[package]]
@@ -4635,33 +4637,38 @@ wheels = [
[[package]]
name = "pillow"
version = "12.3.0"
version = "13.0.0"
source = { registry = "https://pypi.org/simple" }
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" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/a4/9cc30690a394a8305e8be4c9036aa16d0dfddadd09079860b582f562496d/pillow-13.0.0.tar.gz", hash = "sha256:cdcd1ba258178f7356d74170c9a81ba4eab6a5d55748bc402a5a2a6fe6be3510", size = 47058631, upload-time = "2026-10-16T22:18:09.718Z" }
wheels = [
{ 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" },
{ url = "https://files.pythonhosted.org/packages/6c/d7/af6af1b8cc55e07cb0489d5b0950bf5a9bb1461eeba825700b6d8d0e26ea/pillow-13.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:6f38f3f885a09cafc93fd667b1173a92f379e43785d635e7983c893f0da319fb", size = 5592324, upload-time = "2026-10-16T22:15:24.672Z" },
{ url = "https://files.pythonhosted.org/packages/0b/d2/2c12cb4fbb5d95f38d1001ff3d95fafcb830077018cba40e56a8f932dfbc/pillow-13.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37290fbef0c73e2d51d594eb78fded7dc7170d1c3ad0a890269e6fd8c8c4e061", size = 4856298, upload-time = "2026-10-16T22:15:27.669Z" },
{ url = "https://files.pythonhosted.org/packages/5c/ee/229b27472483a4801ea94990815e710391836afdf987b4b722b62bd75db6/pillow-13.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c88c10dd432f3b39ccdafc9ed81d0c21b8fd634502574da76d1bfa4fe2c76d0", size = 6517304, upload-time = "2026-10-16T22:15:29.43Z" },
{ url = "https://files.pythonhosted.org/packages/36/46/f509b74787f40ce0627501089d630b2c741b63b244c1f40b640b3b01e181/pillow-13.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a625df9a2a5597fa5e5266e01aefc82604bb85d291e3547981e863560d75da6c", size = 7140667, upload-time = "2026-10-16T22:15:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/aa/a4/5a398b8ec2c37145397d6461c0b551c23ec03072aa64f357aed25e582b00/pillow-13.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:10b5019883c98f1dd819ca48ffc6f33a91cee04948fe637c605f4345c7cafd6f", size = 6610583, upload-time = "2026-10-16T22:15:34.025Z" },
{ url = "https://files.pythonhosted.org/packages/4f/7b/f2f1109b230fd4e2d57ab2305de31adc37ea9b4ff8719436c623cb484415/pillow-13.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f391dc92a015c478745192fe4f2e120c2af2aba18d9fe0616341cbf537599806", size = 7218241, upload-time = "2026-10-16T22:15:36.214Z" },
{ url = "https://files.pythonhosted.org/packages/ab/d3/069edb9083b267746baf59756282cacef904d3475c5288c7216fb5195a98/pillow-13.0.0-cp311-cp311-win32.whl", hash = "sha256:6a0920cb55df5a4d3f6862964908078c0bd59332bdd1bcd20d14e8c70ef27cc5", size = 6515278, upload-time = "2026-10-16T22:15:38.129Z" },
{ url = "https://files.pythonhosted.org/packages/7d/f6/6e2d96413e4c9dbec12ea584d9e646d602877d4cce3ea669099f959deac6/pillow-13.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:d7710eb676e9bb68965cb045be0d333b4c0d7f9dfbaa7fbf121385b1588683d3", size = 7278471, upload-time = "2026-10-16T22:15:40.286Z" },
{ url = "https://files.pythonhosted.org/packages/70/e7/baff21f8cb60496c8c250609501b88919eeedb4088d3da106f6c62f3f3c0/pillow-13.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:12fbb851cf323cc19aac3eea1df5ad17b57e91e9fe3cfd06100dba7bb8208c13", size = 2698981, upload-time = "2026-10-16T22:15:42.637Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b8/58d79332229482eb0d27c82973850808c19d37e5bdc14443d3417bd373f5/pillow-13.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:67c8d44ad89fa7eb88de5d6ef6199b1e03dd24974f85a67639fc8badb801fe79", size = 5568643, upload-time = "2026-10-16T22:15:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/39/4b/f2ed53d83aa44ac19599ac0a54897f867fafb95f406980d1e24083f8cbe3/pillow-13.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc481f742ccda20e9ed760b8f978dff50f2a4e9bc253ac167fb0a05b0783ef52", size = 4852329, upload-time = "2026-10-16T22:15:46.284Z" },
{ url = "https://files.pythonhosted.org/packages/3b/bb/9e30e8ed18c20800ee327a12107d5249ad8dc444867b3adfcc21654e3c71/pillow-13.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bf295fe459343bc6093c8e5e0df60bfa189e0fcb775a22e2c2488fdeccc6c0d", size = 6519309, upload-time = "2026-10-16T22:15:48.14Z" },
{ url = "https://files.pythonhosted.org/packages/e6/99/fdf65cc539e266ab93546c261bdf98411fe165c6e4e13f9dbf597097192b/pillow-13.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42d63be3af4f9b951b199b0a9d23365d37ab14687e6d77b74940432c8515c4ca", size = 7144542, upload-time = "2026-10-16T22:15:50.093Z" },
{ url = "https://files.pythonhosted.org/packages/26/9b/f38b7c4d8f599537e2d1a6d546cec9beef7536ff8f5c49a3f9c78179d8ac/pillow-13.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bd024d567eca931b668978d672dba914d4b8e2628e2c3073a2ad178de8a2620", size = 6618042, upload-time = "2026-10-16T22:15:52.054Z" },
{ url = "https://files.pythonhosted.org/packages/a8/7c/45ee28531f89ee4582967d8a17f7a26d71ca64cd902b93c269590cd60a00/pillow-13.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23edf4ec4b6f4f240e8df6e4985e9ee2ced86474632433e55fb1e2548744bf40", size = 7228137, upload-time = "2026-10-16T22:15:54.009Z" },
{ url = "https://files.pythonhosted.org/packages/01/bc/9c2a969cb1b70201d04fa17fd5aedf02a732870b8da86bceaaa30b0ca385/pillow-13.0.0-cp312-cp312-win32.whl", hash = "sha256:02464da86f561f410e13b061ec37c3068a9cb557a095494157742d07b3c24a27", size = 6515423, upload-time = "2026-10-16T22:15:56.25Z" },
{ url = "https://files.pythonhosted.org/packages/ff/cd/73a79ecdc822076002eb24b5e1e4a530b0bb5310fcb6d239abbec877110b/pillow-13.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:ff3fe9eb621fbb9db4c4be33ab946f6bac5f98ab50ca8d3260503653656c961b", size = 7272166, upload-time = "2026-10-16T22:15:58.597Z" },
{ url = "https://files.pythonhosted.org/packages/4a/a3/be5a1236efd085b3984e4305da8f179ae9dab80c991d2ad78f2bd5c4c843/pillow-13.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3bc8ee0271387e0b1e3ad0ff893645990a656e79bf9c42f4f734a3642b11d51", size = 2698943, upload-time = "2026-10-16T22:16:00.637Z" },
{ url = "https://files.pythonhosted.org/packages/68/c0/fb7b1caf25ed5ac5d46116f09b8b0706b9559833c65c667cecb1a53153f2/pillow-13.0.0-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:405be6058e206a86fedce2f715ecc2343f94072d7925251632dffa2c05f70dca", size = 5521630, upload-time = "2026-10-16T22:17:50.008Z" },
{ url = "https://files.pythonhosted.org/packages/1a/e6/4d11d5b1137f4fc080605cce410cfaae4f303ad400bdec75d27d3083fa29/pillow-13.0.0-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:b84be41387fc00db28c13c02e30d42b02d8d8f6a4a741a1aee6012ca7f1f6039", size = 4806023, upload-time = "2026-10-16T22:17:51.932Z" },
{ url = "https://files.pythonhosted.org/packages/f1/45/0923202a658e8ff6aafd72c1a4395ebadcdebaa182c437d8595d3dd3db32/pillow-13.0.0-pp311-pypy311_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a192360d512305fe92d3053e4f7627bbad1f7708f1f4ad38cccd9336acabe9ad", size = 5464831, upload-time = "2026-10-16T22:17:53.742Z" },
{ url = "https://files.pythonhosted.org/packages/07/52/d3e38f0cad1dadea2a658b9a7d6ba8a04048f5ae14f011b806e5c3bd6bcb/pillow-13.0.0-pp311-pypy311_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83b37abd87b590acd8f47ca8a61f70893322b11834d36e07c2298dacad4d14d2", size = 6043133, upload-time = "2026-10-16T22:17:55.625Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f9/2ed7685980c1d64f8131a7d87a19c369424747577152622b73ddeff163b7/pillow-13.0.0-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:73ee25cf8cf8b75d5a5aeed7cb6588f5e5dbfbb0fde51707b630bbb23897645c", size = 7365208, upload-time = "2026-10-16T22:17:57.509Z" },
{ url = "https://files.pythonhosted.org/packages/60/da/f6d644e631c5b370c5d0b899f3ccadd2780aab070b29a110527cf4e1fd2c/pillow-13.0.0-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:8619685cad8efc4c38c66d3b36aad854523297c43d748b3b6f0cc03c90ba3463", size = 5523188, upload-time = "2026-10-16T22:17:59.334Z" },
{ url = "https://files.pythonhosted.org/packages/e4/b0/2594f202d743a35d4fe80682bfd96f277e5ca7aecb27cf64ef5bfc1d000b/pillow-13.0.0-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:7e91e97a929232cdb828889ee6ad0e48d7f40f4ec38818f4e4a8968b2b1af22e", size = 4806808, upload-time = "2026-10-16T22:18:01.188Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ed/d504165ef79af6fafe25c76726cf30f544cc236002f7ed054f1d51adc4ff/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93ff9c9c3663ce23aaa5d0b6b6abac55359e2a0c06c7e30f3771829af816be63", size = 5464358, upload-time = "2026-10-16T22:18:02.779Z" },
{ url = "https://files.pythonhosted.org/packages/7b/fe/a10aba5503ca13e9e009b8bd0a1a4ce5584bdce246245fd6f3a83701369c/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e65dd6e75de67b2362a4fa3e16aa627595f65054dc4ec73d64680c84d592fa61", size = 6043514, upload-time = "2026-10-16T22:18:04.538Z" },
{ url = "https://files.pythonhosted.org/packages/c0/37/66b12dc476041fe523bca29c9d58fbf1ea92a4eaf73acfb9e993948f1191/pillow-13.0.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:567f20f1c75897d7663830da6ea8af719e03d712b1d9ee7f813319f9ece42e5a", size = 7366030, upload-time = "2026-10-16T22:18:06.638Z" },
]
[[package]]
@@ -4835,8 +4842,8 @@ wheels = [
[[package]]
name = "prowler"
version = "5.42.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.42#4727da7ca71a87be629a888184705eb3f2e4324e" }
version = "5.41.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f05a490cd74a2c0f11a5d66d8ce29d03fa5c64a2" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4938,7 +4945,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.43.1"
version = "1.49.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -5012,8 +5019,8 @@ requires-dist = [
{ name = "celery", specifier = "==5.6.2" },
{ name = "defusedxml", specifier = "==0.7.1" },
{ name = "dj-rest-auth", extras = ["with-social", "jwt"], specifier = "==7.0.1" },
{ name = "django", specifier = "==5.1.15" },
{ name = "django-allauth", extras = ["saml"], specifier = "==65.15.0" },
{ name = "django", specifier = "==5.2.17" },
{ name = "django-allauth", extras = ["saml"], specifier = "==65.19.7" },
{ name = "django-celery-beat", specifier = "==2.9.0" },
{ name = "django-celery-results", specifier = "==2.6.0" },
{ name = "django-cors-headers", specifier = "==4.4.0" },
@@ -5022,7 +5029,7 @@ requires-dist = [
{ name = "django-filter", specifier = "==24.3" },
{ name = "django-guid", specifier = "==3.5.0" },
{ name = "django-postgres-extra", specifier = "==2.0.9" },
{ name = "djangorestframework", specifier = "==3.15.2" },
{ name = "djangorestframework", specifier = "==3.18.3" },
{ name = "djangorestframework-jsonapi", specifier = "==7.0.2" },
{ name = "djangorestframework-simplejwt", specifier = "==5.5.1" },
{ name = "drf-nested-routers", specifier = "==0.95.0" },
@@ -5038,7 +5045,7 @@ requires-dist = [
{ name = "matplotlib", specifier = "==3.10.8" },
{ name = "neo4j", specifier = "==6.1.0" },
{ name = "openai", specifier = "==1.109.1" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.42" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
@@ -5332,11 +5339,11 @@ wheels = [
[[package]]
name = "pyjwt"
version = "2.13.0"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
{ url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" },
]
[package.optional-dependencies]
@@ -5364,33 +5371,33 @@ wheels = [
[[package]]
name = "pymongo"
version = "4.15.1"
version = "4.18.3"
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" }
sdist = { url = "https://files.pythonhosted.org/packages/42/d8/2421a5ae0d6dcdaad2a0fb75d4071eaede9f764e73b829c62b6185c3ee6b/pymongo-4.18.3.tar.gz", hash = "sha256:5dd6e659b6014288a1c53458929402a58f44a032e6f29bcef44e7477c5268e48", size = 2747872, upload-time = "2026-10-08T19:44:08.343Z" }
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" },
{ url = "https://files.pythonhosted.org/packages/58/a6/63bdeb527d98998b8ea2c667eca00d48f22dd42af281e9a2d7090632d54d/pymongo-4.18.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4f00cb357d7cc7f2798116e2377732a409c43a6dc882f0241eafed7ffed50655", size = 818805, upload-time = "2026-10-08T19:42:09.125Z" },
{ url = "https://files.pythonhosted.org/packages/d0/e9/35602972d9fa98b894d1e5feef4db2f5d275298430e99b54b39f01125efa/pymongo-4.18.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fe2ef9c6eb6b75689e10b20a3d8119da87302481b0a7029f9399b35142adfd8", size = 819303, upload-time = "2026-10-08T19:42:10.749Z" },
{ url = "https://files.pythonhosted.org/packages/d8/e1/468f2c69b32565c93a56623535f248b091cf52e83c3509fa4b791427889f/pymongo-4.18.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba6090d4bed582c97e38fa818c0a2b7443f203cb28882900b433ff713465f158", size = 1023754, upload-time = "2026-10-08T19:42:12.683Z" },
{ url = "https://files.pythonhosted.org/packages/e1/24/8af75e8af2427a47cfcc996444df934990ac4890d855f2ec064409d8b94b/pymongo-4.18.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f9903d0a089317422f52bbc25f5827e6656f0c42c43ed7d799bd02748e79a1", size = 1031920, upload-time = "2026-10-08T19:42:14.443Z" },
{ url = "https://files.pythonhosted.org/packages/78/d0/96fa79fb7cb47e6f09e58ccbe1a726330b5d052f395b65d57e286849ed43/pymongo-4.18.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac9bf2304c2b092ccf04261ab0cddb7fd65df1cc1ae0fa57312b03396c00d28c", size = 1053859, upload-time = "2026-10-08T19:42:16.315Z" },
{ url = "https://files.pythonhosted.org/packages/4d/99/1b3f48bd3580c53e4a0e89bdc8cd8c15af94ca944f9de96582cb3ebfa5d0/pymongo-4.18.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f37095428af3042f6bb1ebe269fedcbb645d9e0642b274e1cff026d3979500b", size = 1047418, upload-time = "2026-10-08T19:42:18.023Z" },
{ url = "https://files.pythonhosted.org/packages/08/1e/ab9148b15dcefd3d02852a65e4ac3a1df86248c227dd532dc53b41f33697/pymongo-4.18.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:16ade5053ab6c712fd25d3f878e38441b169d607d1326d708844a131911d029f", size = 1033358, upload-time = "2026-10-08T19:42:19.746Z" },
{ url = "https://files.pythonhosted.org/packages/39/93/bbbd0edfa33b10e7a86c864478dbb12163c946c142fd1e7b1bd0b6490353/pymongo-4.18.3-cp311-cp311-win32.whl", hash = "sha256:463c09e2cc208a65d35a1af3c613360cff6d58c8aef652273da07250bb214dba", size = 814425, upload-time = "2026-10-08T19:42:21.501Z" },
{ url = "https://files.pythonhosted.org/packages/1c/13/7515f91ed9e80968cc5dc9321ac97fafde07afb75123adb9e294f50c8a10/pymongo-4.18.3-cp311-cp311-win_amd64.whl", hash = "sha256:1d7d0474012def6113c224b167aae661b926ac3b788219426830013ea25acd33", size = 821387, upload-time = "2026-10-08T19:42:23.349Z" },
{ url = "https://files.pythonhosted.org/packages/89/59/f54d5ee7d95ec4ed1f31bff014a0f61caa3e888d7a89a0585f3eb4be164b/pymongo-4.18.3-cp311-cp311-win_arm64.whl", hash = "sha256:83dff65baa6f2423857598ffc371d7412fa4d2a07c618bdc8d5053ade65de664", size = 817697, upload-time = "2026-10-08T19:42:25.128Z" },
{ url = "https://files.pythonhosted.org/packages/05/d5/4775a2891396ad125545e23b3024adae4bfac9553b70c924f1f372269dbf/pymongo-4.18.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ea78719dd05de3a919a52b94bec790c0d0cb7d07d2f7271711832664502a0782", size = 819374, upload-time = "2026-10-08T19:42:26.931Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0b/89ad56f43c3da6cbde100699f6b99528e78eba3c6740d8dad4ea2516aa45/pymongo-4.18.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6029d14761ba7243e6c5e464592013b519ad4dd3e4cfb75ddec39f4b5910711b", size = 819694, upload-time = "2026-10-08T19:42:28.76Z" },
{ url = "https://files.pythonhosted.org/packages/84/b4/b68ffc205441b0a6d36d6299e35e063a5d0d3264fd685428920e1f82b634/pymongo-4.18.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9536fb3820f721290f03ad07472ec2266d8f364f91de628679a7146c9c1dbe35", size = 1040136, upload-time = "2026-10-08T19:42:30.852Z" },
{ url = "https://files.pythonhosted.org/packages/c1/40/e779ff3d9165316c35a2f9742a42b9c3e3a678e9e2a9f6fe4128b7c551eb/pymongo-4.18.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e461bfca4861057929efa4215730b28b93b2adb4d07828d0b65475755bbf63f5", size = 1051430, upload-time = "2026-10-08T19:42:32.533Z" },
{ url = "https://files.pythonhosted.org/packages/07/9b/443ee038a739cc65a75f2078c9ef725c1cb4881545d2e9d7941c46f64a6c/pymongo-4.18.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1fef248623ed5e7406902a68d49dc0b1db434f19489f8d2fc9fe512c3c08bb1", size = 1075464, upload-time = "2026-10-08T19:42:34.325Z" },
{ url = "https://files.pythonhosted.org/packages/36/4b/d80518f675cd4c1215b770444bb83002454574dae0e69760af10703ed1e8/pymongo-4.18.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:213eaed8fc4f2b0f9c84323a229dea699e01e18b8fb39723f430123b6ee77813", size = 1069046, upload-time = "2026-10-08T19:42:36.105Z" },
{ url = "https://files.pythonhosted.org/packages/e5/77/f2e9648c62e423c3b9dab1e16491a6c33250487c819e5c75784b35d16047/pymongo-4.18.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa6f363ff648bf061335d2190dd580cbf465b1308a7e6acb992d128d6a16a3bd", size = 1050321, upload-time = "2026-10-08T19:42:38.052Z" },
{ url = "https://files.pythonhosted.org/packages/a6/e4/3236e3a87b29fc4c502ad7dd1521c20d4a29faf1db6fde6ae94d05c5ec27/pymongo-4.18.3-cp312-cp312-win32.whl", hash = "sha256:28ba8cae86ea02d7ffdf0eea81be69be80d35d6a4a3eba4dc436d3194341805a", size = 815300, upload-time = "2026-10-08T19:42:40.062Z" },
{ url = "https://files.pythonhosted.org/packages/1e/18/3fa9d86ba32386c02ea991f10875a6a066dd5e5d80790243be3c141b0e73/pymongo-4.18.3-cp312-cp312-win_amd64.whl", hash = "sha256:dc8ccf72b76c99a6b9fd05f8b89fe4a693128c5cfdba70f70e5792a6a563f6b0", size = 822079, upload-time = "2026-10-08T19:42:42.089Z" },
{ url = "https://files.pythonhosted.org/packages/03/50/65a7cefd3891b77994841992b2c5b59394667df64ef21377b8ac7ecdef47/pymongo-4.18.3-cp312-cp312-win_arm64.whl", hash = "sha256:4a1f7c7dc1d554449a1695d897eb42b6080a2f1e9ccd81385dfa00204979c54d", size = 817935, upload-time = "2026-10-08T19:42:43.98Z" },
]
[[package]]
@@ -6332,11 +6339,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.7.0"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
]
[[package]]
@@ -6566,48 +6573,50 @@ wheels = [
[[package]]
name = "yarl"
version = "1.22.0"
version = "1.25.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" }
sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" },
{ url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" },
{ url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" },
{ url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" },
{ url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" },
{ url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" },
{ url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" },
{ url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" },
{ url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" },
{ url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" },
{ url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" },
{ url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" },
{ url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" },
{ url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" },
{ url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" },
{ url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" },
{ url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" },
{ url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" },
{ url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" },
{ url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" },
{ url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" },
{ url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" },
{ url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" },
{ url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" },
{ url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" },
{ url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" },
{ url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" },
{ url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" },
{ url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" },
{ url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" },
{ url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
{ url = "https://files.pythonhosted.org/packages/83/b3/2cea721d495ca57f8f414aa4867ee263f486b274e07463158cf52f02ba7f/yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865", size = 143794, upload-time = "2026-09-15T19:30:26.946Z" },
{ url = "https://files.pythonhosted.org/packages/55/e6/cd145cff8e5cf60b8b3c41fbecfa2a45028a8dec3fbc52bec03595ff3d3b/yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d", size = 103993, upload-time = "2026-09-15T19:30:28.661Z" },
{ url = "https://files.pythonhosted.org/packages/ce/7c/fbb40fe2d53747c40aa36a9e2bd2178a202f942bda0b670f3306d4aefbce/yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d", size = 104010, upload-time = "2026-09-15T19:30:31.069Z" },
{ url = "https://files.pythonhosted.org/packages/f0/0b/5a516f70641092283f57cf3670bdb75e7327bcc0dcb5038697e4dfbfd569/yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420", size = 116444, upload-time = "2026-09-15T19:30:32.894Z" },
{ url = "https://files.pythonhosted.org/packages/b6/8a/d1a627f827b0a404ae0f5647cab0534959081cde13b0756a783469fb3b5e/yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7", size = 107565, upload-time = "2026-09-15T19:30:35.188Z" },
{ url = "https://files.pythonhosted.org/packages/aa/cf/c8e0aaec886840a6c4480fe44eaec7cd4319f79a563b79321473be79c56f/yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3", size = 125006, upload-time = "2026-09-15T19:30:37.1Z" },
{ url = "https://files.pythonhosted.org/packages/50/26/0cce366d54a93cdc8342965dc7663385e4db161625cc1e8b18e786753d24/yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492", size = 128717, upload-time = "2026-09-15T19:30:38.904Z" },
{ url = "https://files.pythonhosted.org/packages/95/0c/a71501bbc1a674ff72c4d6c2b75f4d9a5af819f5244c3a7558080a8802c5/yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2", size = 117728, upload-time = "2026-09-15T19:30:40.809Z" },
{ url = "https://files.pythonhosted.org/packages/e7/6f/c3267ca01defeed9ed9c4ff9b17bd54915432c405945233265b707475d1c/yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17", size = 116224, upload-time = "2026-09-15T19:30:42.818Z" },
{ url = "https://files.pythonhosted.org/packages/8f/69/fad57ee52d648431718ee0f1f68966a99c1352c3924688ffbdcc9d3fe51a/yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de", size = 116299, upload-time = "2026-09-15T19:30:44.966Z" },
{ url = "https://files.pythonhosted.org/packages/2a/d4/6a8c1e29f33338687ca278cba0a8fbf6525a322c2c02a9a500ccbe041152/yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345", size = 108625, upload-time = "2026-09-15T19:30:46.874Z" },
{ url = "https://files.pythonhosted.org/packages/e8/d2/3a35ae791c9cb6522c106923ff25c3230d999091e5e65511bca23bbd9914/yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40", size = 124515, upload-time = "2026-09-15T19:30:49.082Z" },
{ url = "https://files.pythonhosted.org/packages/be/fd/2b022109a6b4af0f7dc371cf7500af380b0d4f034010243e1b0ce218dc93/yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258", size = 115711, upload-time = "2026-09-15T19:30:51.208Z" },
{ url = "https://files.pythonhosted.org/packages/18/59/f7586271136c3ddb0126bbfe661844699369b76b555fe38c4efe86870b2e/yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1", size = 122751, upload-time = "2026-09-15T19:30:53.535Z" },
{ url = "https://files.pythonhosted.org/packages/a0/0b/07f7a2d881f7e16c385b47fc1753382600847cad305dcc7fa0c25828acf8/yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b", size = 117983, upload-time = "2026-09-15T19:30:55.277Z" },
{ url = "https://files.pythonhosted.org/packages/aa/9d/8cdceec66a9b940700cb45931741403f045b162afd79bcb93c41cadd0972/yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e", size = 102894, upload-time = "2026-09-15T19:30:57.606Z" },
{ url = "https://files.pythonhosted.org/packages/17/f1/7ec357db1d3ad2863542d71e8fe64a126bbec17b134cd7d30951196809a5/yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35", size = 98609, upload-time = "2026-09-15T19:30:59.705Z" },
{ url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" },
{ url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" },
{ url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" },
{ url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" },
{ url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" },
{ url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" },
{ url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" },
{ url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" },
{ url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" },
{ url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" },
{ url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" },
{ url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" },
{ url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" },
{ url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" },
{ url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" },
{ url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" },
{ url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" },
]
[[package]]
@@ -1,22 +1,38 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "prowler",
"displayName": "Prowler Cloud",
"version": "0.1.0",
"description": "Prowler for Claude Code — cloud security and compliance skills powered by the Prowler MCP server. Bundles compliance triage and remediation; more skills coming.",
"description": "Connect Claude to Prowler Cloud through the Prowler MCP server. Reads providers, scans, findings, compliance results and resources for AWS, Azure, GCP, Kubernetes, Microsoft 365, GitHub and the other providers listed at https://registry.prowler.com, and returns remediation guidance for failed checks. Writes are limited to Prowler Cloud itself: triggering scans, finding triage, mute rules and the mutelist, providers and integrations. Your cloud resources are not changed by the connector.",
"author": {
"name": "Prowler",
"email": "support@prowler.com",
"url": "https://prowler.com"
},
"homepage": "https://docs.prowler.com",
"homepage": "https://docs.prowler.com/user-guide/ai-agents",
"repository": "https://github.com/prowler-cloud/prowler",
"license": "Apache-2.0",
"keywords": [
"alibabacloud",
"aws",
"azure",
"cloud-security",
"cloudflare",
"compliance",
"gcp",
"github",
"huaweicloud",
"iac",
"kubernetes",
"m365",
"mcp",
"mongodbatlas",
"okta",
"openstack",
"oraclecloud",
"prowler",
"security",
"compliance",
"cloud-security",
"mcp"
"vercel"
],
"userConfig": {
"api_key": {
+7 -5
View File
@@ -1,9 +1,11 @@
{
"prowler": {
"type": "http",
"url": "https://mcp.prowler.com/mcp",
"headers": {
"Authorization": "Bearer ${user_config.api_key}"
"mcpServers": {
"prowler": {
"type": "http",
"url": "https://mcp.prowler.com/mcp",
"headers": {
"Authorization": "Bearer ${user_config.api_key}"
}
}
}
}
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright @ 2024 Toni de la Fuente
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+17 -3
View File
@@ -1,9 +1,17 @@
# Prowler for Claude Code
# Prowler Cloud
End-to-end cloud security and compliance from inside [Claude Code](https://www.claude.com/product/claude-code), powered by the [Prowler MCP server](https://docs.prowler.com/projects/prowler-mcp/). The plugin lets Claude walk a Prowler Cloud-connected account through a compliance assessment and remediate findings until the chosen security or industry framework is compliant.
Cloud security and compliance in Claude, powered by the [Prowler MCP server](https://docs.prowler.com/projects/prowler-mcp/). Claude reads your Prowler Cloud providers, scans, findings and compliance results, returns Prowler's remediation guidance for each failed check, and manages triage and mute state. The connector never changes your cloud resources: any fix is applied with your own tools (Terraform, a cloud CLI or the console), and only after you approve it.
> **Preview**: this plugin is under active development. Report issues at <https://github.com/prowler-cloud/prowler/issues> or join the [Slack community](https://goto.prowler.com/slack).
## What it does
Prowler Cloud connects Claude to your cloud security posture. The bundled `framework-compliance-triage` skill checks that a provider account is connected to Prowler Cloud, reads the latest scan and compliance report for the framework you pick (CIS, PCI-DSS, NIST, Prowler ThreatScore and more), and then walks you through fixing each failed requirement. Every change is proposed first and applied only after you approve it.
## Supported providers
Any provider Prowler Cloud supports, including AWS, Azure, GCP, Kubernetes, Microsoft 365, GitHub, Google Workspace, Cloudflare, Oracle Cloud, Alibaba Cloud, Huawei Cloud, OpenStack, Linode, Scaleway, STACKIT, E2E Networks, NHN Cloud, MongoDB Atlas, Okta, Vercel, VCF, Infrastructure as Code, container images and LLMs. The full, growing list lives in the [Prowler Registry](https://registry.prowler.com/providers). Prowler also supports custom providers, so you can add your own and use them with this plugin.
## Requirements
- [Claude Code](https://www.claude.com/product/claude-code) installed and signed in.
@@ -12,6 +20,8 @@ End-to-end cloud security and compliance from inside [Claude Code](https://www.c
## Installation
From the Claude directory, open **Customize > Plugins > Discover**, search for **Prowler Cloud** and select **Add**. In Claude Code you can also install it from the command line:
Inside a Claude Code session:
```text
@@ -58,6 +68,10 @@ You pick a **primary tool** (Terraform, gh / az / aws CLI, web console, or mixed
Claude tracks progress in a markdown report under `.prowler/` at your project root — one file per framework × account. Open it any time to see exactly where the flow is. When all findings are addressed, Claude proposes a fresh Prowler scan to verify everything end-to-end.
## Data and privacy
The plugin stores nothing itself. It connects to the Prowler MCP server at `https://mcp.prowler.com/mcp`, authenticating with the API key you provide (kept in your system keychain and sent only as a bearer token to that server). Through it, Claude reads your Prowler Cloud providers, scans, findings, compliance results and resource metadata. It can also write to Prowler Cloud: trigger scans, set finding triage, create mute rules, replace the mutelist, and add or remove providers and integrations. Mutes and deletions are marked as destructive, so your client can ask before running them. It also queries the public Prowler Hub and Prowler documentation. The remediation report is written locally under `.prowler/` in your project. Nothing is sent to any other service. See the [Prowler privacy policy](https://prowler.com/privacy-policy) for how Prowler Cloud handles account data.
## Uninstalling
```text
@@ -77,4 +91,4 @@ The stored API key is removed automatically.
## License
Apache 2.0 — see [LICENSE](../../LICENSE).
Apache 2.0 — see [LICENSE](LICENSE).
+32 -1
View File
@@ -164,6 +164,28 @@ mainConfig:
# aws.appstream_fleet_maximum_session_duration
max_session_duration_seconds: 36000 # 10 Hours
# AWS Elastic Beanstalk Configuration
# aws.elasticbeanstalk_environment_php_platform_updated
# Oldest PHP branch still receiving security fixes
elasticbeanstalk_php_min_branch: "8.2"
# Oldest Amazon Linux 2023 PHP platform version bundling the fixed PHP release
elasticbeanstalk_php_min_platform_version: "4.13.13"
# AWS Lightsail Configuration
# aws.lightsail_instance_php_blueprint_updated
# Blueprint id prefixes that ship PHP and require manual runtime verification
lightsail_php_blueprints:
[
"wordpress",
"lamp",
"nginx",
"drupal",
"joomla",
"magento",
"prestashop",
"plesk",
"cpanel",
]
# AWS Lambda Configuration
# aws.awslambda_function_using_supported_runtimes
obsolete_lambda_runtimes:
@@ -212,6 +234,14 @@ mainConfig:
# MEDIUM
ecr_repository_vulnerability_minimum_severity: "MEDIUM"
# AWS Inspector2
# aws.inspector2_coverage_recently_scanned
# Maximum days since Inspector2 last scanned an actively covered resource
inspector2_max_days_since_last_scan: 3
# aws.inspector2_active_findings_within_max_age
# Maximum days an Inspector2 finding can stay active since it was first observed
inspector2_active_finding_max_age_days: 192
# AWS Trusted Advisor
# aws.trustedadvisor_premium_support_plan_subscribed
verify_premium_support_plans: True
@@ -493,8 +523,9 @@ mainConfig:
shodan_api_key: null
# Azure App Service
# Minimum runtime versions; any newer version passes
# azure.app_ensure_php_version_is_latest
php_latest_version: "8.2"
php_latest_version: "8.5"
# azure.app_ensure_python_version_is_latest
python_latest_version: "3.12"
# azure.app_ensure_java_version_is_latest
@@ -1,46 +0,0 @@
import warnings
from dashboard.common_methods import get_section_containers_cis
warnings.filterwarnings("ignore")
def get_table(data):
aux = data[
[
"REQUIREMENTS_ID",
"REQUIREMENTS_DESCRIPTION",
"REQUIREMENTS_ATTRIBUTES_SECTION",
"CHECKID",
"STATUS",
"REGION",
"ACCOUNTID",
"RESOURCEID",
]
].copy()
# Shorten the long FedRAMP KSI descriptions for better display
ksi_short_names = {
"A secure cloud service offering will protect user data, control access, and apply zero trust principles": "Identity and Access Management",
"A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system": "Cloud Native Architecture",
"A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly": "Change Management",
"A secure cloud service provider will continuously educate their employees on cybersecurity measures, testing them regularly": "Cybersecurity Education",
"A secure cloud service offering will document, report, and analyze security incidents to ensure regulatory compliance and continuous security improvement": "Incident Reporting",
"A secure cloud service offering will monitor, log, and audit all important events, activity, and changes": "Monitoring, Logging, and Auditing",
"A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured": "Policy and Inventory",
"A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss": "Recovery Planning",
"A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources": "Service Configuration",
"A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources": "Third-Party Information Resources",
}
# Replace long descriptions with short names - use contains for partial matching
if not aux.empty:
for long_desc, short_name in ksi_short_names.items():
mask = aux["REQUIREMENTS_DESCRIPTION"].str.contains(
long_desc, na=False, regex=False
)
aux.loc[mask, "REQUIREMENTS_DESCRIPTION"] = short_name
return get_section_containers_cis(
aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION"
)
@@ -1,46 +0,0 @@
import warnings
from dashboard.common_methods import get_section_containers_cis
warnings.filterwarnings("ignore")
def get_table(data):
aux = data[
[
"REQUIREMENTS_ID",
"REQUIREMENTS_DESCRIPTION",
"REQUIREMENTS_ATTRIBUTES_SECTION",
"CHECKID",
"STATUS",
"REGION",
"ACCOUNTID",
"RESOURCEID",
]
].copy()
# Shorten the long FedRAMP KSI descriptions for better display
ksi_short_names = {
"A secure cloud service offering will protect user data, control access, and apply zero trust principles": "Identity and Access Management",
"A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system": "Cloud Native Architecture",
"A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly": "Change Management",
"A secure cloud service provider will continuously educate their employees on cybersecurity measures, testing them regularly": "Cybersecurity Education",
"A secure cloud service offering will document, report, and analyze security incidents to ensure regulatory compliance and continuous security improvement": "Incident Reporting",
"A secure cloud service offering will monitor, log, and audit all important events, activity, and changes": "Monitoring, Logging, and Auditing",
"A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured": "Policy and Inventory",
"A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss": "Recovery Planning",
"A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources": "Service Configuration",
"A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources": "Third-Party Information Resources",
}
# Replace long descriptions with short names - use contains for partial matching
if not aux.empty:
for long_desc, short_name in ksi_short_names.items():
mask = aux["REQUIREMENTS_DESCRIPTION"].str.contains(
long_desc, na=False, regex=False
)
aux.loc[mask, "REQUIREMENTS_DESCRIPTION"] = short_name
return get_section_containers_cis(
aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION"
)
@@ -1,46 +0,0 @@
import warnings
from dashboard.common_methods import get_section_containers_cis
warnings.filterwarnings("ignore")
def get_table(data):
aux = data[
[
"REQUIREMENTS_ID",
"REQUIREMENTS_DESCRIPTION",
"REQUIREMENTS_ATTRIBUTES_SECTION",
"CHECKID",
"STATUS",
"REGION",
"ACCOUNTID",
"RESOURCEID",
]
].copy()
# Shorten the long FedRAMP KSI descriptions for better display
ksi_short_names = {
"A secure cloud service offering will protect user data, control access, and apply zero trust principles": "Identity and Access Management",
"A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system": "Cloud Native Architecture",
"A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly": "Change Management",
"A secure cloud service provider will continuously educate their employees on cybersecurity measures, testing them regularly": "Cybersecurity Education",
"A secure cloud service offering will document, report, and analyze security incidents to ensure regulatory compliance and continuous security improvement": "Incident Reporting",
"A secure cloud service offering will monitor, log, and audit all important events, activity, and changes": "Monitoring, Logging, and Auditing",
"A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured": "Policy and Inventory",
"A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss": "Recovery Planning",
"A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources": "Service Configuration",
"A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources": "Third-Party Information Resources",
}
# Replace long descriptions with short names - use contains for partial matching
if not aux.empty:
for long_desc, short_name in ksi_short_names.items():
mask = aux["REQUIREMENTS_DESCRIPTION"].str.contains(
long_desc, na=False, regex=False
)
aux.loc[mask, "REQUIREMENTS_DESCRIPTION"] = short_name
return get_section_containers_cis(
aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION"
)
+8
View File
@@ -15,6 +15,7 @@ services:
api:
hostname: "prowler-api"
image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable}
restart: unless-stopped
env_file:
- path: .env
required: false
@@ -44,6 +45,7 @@ services:
ui:
image: prowlercloud/prowler-ui:${PROWLER_UI_VERSION:-stable}
restart: unless-stopped
env_file:
- path: .env
required: false
@@ -61,6 +63,7 @@ services:
postgres:
image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
restart: unless-stopped
hostname: "postgres-db"
volumes:
- ./_data/postgres:/var/lib/postgresql/data
@@ -81,6 +84,7 @@ services:
valkey:
image: valkey/valkey:8-alpine@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec
restart: unless-stopped
hostname: "valkey"
volumes:
- ./_data/valkey:/data
@@ -97,6 +101,7 @@ services:
neo4j:
image: graphstack/dozerdb:5.26.27.0@sha256:9b54d6b3a98a76c00bd23e8e78d8c82081ff168162aebd47b25c234e092cb0a0
restart: unless-stopped
hostname: "neo4j"
volumes:
- ./_data/neo4j:/data
@@ -129,6 +134,7 @@ services:
worker:
image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable}
restart: unless-stopped
# Give Celery soft shutdown time to drain/re-queue in-flight tasks on stop.
stop_grace_period: 120s
env_file:
@@ -149,6 +155,7 @@ services:
worker-beat:
image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable}
restart: unless-stopped
env_file:
- path: ./.env
required: false
@@ -165,6 +172,7 @@ services:
mcp-server:
image: prowlercloud/prowler-mcp:${PROWLER_MCP_VERSION:-stable}
restart: unless-stopped
environment:
- PROWLER_MCP_TRANSPORT_MODE=http
env_file:
+3 -1
View File
@@ -80,6 +80,8 @@ Prowler Features are considered proper nouns. They are to be referenced without
Prowler ships two product families. Use these names exactly; the former names Prowler App (now Prowler Local Server) and Prowler Enterprise (now Prowler Private Cloud) must not appear in new writing. The only allowed former-name notes are on the Prowler Product Families page (`getting-started/products/index.mdx`) and in the site-wide banner, which document the mapping.
Web UI guides are written for Prowler Cloud. Steps that open the application link to `https://cloud.prowler.com/sign-in`, and the scope is stated once at the top of the section with the AppliesTo banner (see [AppliesTo Banner for Product Scope](#appliesto-banner-for-product-scope)). Do not offer Prowler Local Server as an inline alternative in a step list ("Go to Prowler Cloud or launch Prowler Local Server"). Mention Prowler Local Server inline only where its behavior differs, for example a feature that is not available or an environment variable that must be set.
Prowler Products:
* **Prowler Cloud**
@@ -573,7 +575,7 @@ The default covers Prowler Cloud, Prowler Private Cloud, and Prowler Local Serve
<AppliesTo products={["Prowler Cloud", "Prowler Private Cloud"]} />
```
Place it on its own line below the Version Badge when one is present, otherwise directly after the imports.
Place it on its own line below the Version Badge when one is present, otherwise directly after the imports. On a page that also covers Prowler CLI, such as the provider getting-started guides, place it under the `## Prowler Cloud` heading instead, so it scopes only the web UI section.
### Cloud Marker for Subscription Content
Loaded 100 of 1468 files, more files were not shown because too many files have changed in this diff. Show more