Compare commits

..
287 Commits
Author SHA1 Message Date
Prowler BotandAlejandro Bailo 48cadb65a8 fix(ui): retry the first-run redirect until the add-provider wizard opens (#12916)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-10-01 11:55:45 +02:00
Prowler BotandPedro Martín c611978a6f chore(deps): bump pyjwt to 2.14.0 for osv-scanner (#12913)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-09-30 11:40:15 +02:00
Prowler BotandPedro Martín 01d7df6fce fix(oci): use home region for identity bootstrap (#12909)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-09-29 18:08:45 +02:00
Prowler BotandPedro Martín f23b38a269 chore(trivy): suppress fast-uri CVE-2026-84292 (#12908)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-09-29 17:14:56 +02:00
Prowler Botandprowler-bot 5dab1ccabc chore(release): Bump versions to v5.44.1 (#12906)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-09-29 16:31:08 +02:00
Prowler Botandprowler-bot 499fd637f3 chore(api): Update prowler dependency to v5.44 for release 5.44.0 (#12901)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-09-29 13:44:54 +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
Prowler Botandprowler-bot 4727da7ca7 chore(changelog): v5.42.0 (#12794)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-09-11 10:21:17 +02:00
Pedro Martín b378f15798 fix(aws): guard checks reading iam roles when unlisted (#12785) 2026-09-11 08:37:20 +02:00
StylusFrostandpedrooot f9c02da90a feat(aws): support the ISO partitions for region resolution and scanning (#12759)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-09-10 17:15:13 +02:00
Pedro Martín 865eebe7fb fix(aws): configurable boto3 timeouts, 10s connect default (#12774) 2026-09-10 08:21:27 +02:00
Alejandro Bailo 8270979ec8 fix(ui): align scan filters and actions (#12781) 2026-09-09 23:05:09 +02:00
César Arrobaandpedrooot 369f852837 fix(aws): lead the partition bootstrap regions with the configured region (#12764)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-09-09 18:07:22 +02:00
César Arroba 1e8454a3cb fix(mcp): patch the six high libuuid CVEs in the container image (#12780) 2026-09-09 17:21:27 +02:00
César Arrobaandalejandrobailo 6f6ae88a66 fix(ui): patch the Next.js and sharp image-handling vulnerabilities (#12778)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-09-09 17:11:43 +02:00
César Arrobaandpedrooot c71f226e5c fix(image): honour TRIVY_CACHE_DIR when it is set (#12773)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-09-09 17:00:50 +02:00
Pedro Martín bbf5e1fa9f fix(tests): isolate secretsmanager policy test (#12782) 2026-09-09 16:58:35 +02:00
César Arroba 9cab9b8653 fix(ci): suppress grpc xDS DoS CVE from the Trivy binary (#12777) 2026-09-09 14:30:22 +02:00
César Arroba 806be2d061 chore(ci): bump agilepathway/label-checker to v1.6.66 (#12760) 2026-09-08 11:58:06 +02:00
Alejandro Bailo 2769cb9876 fix(ui): patch dependency vulnerabilities flagged by dependabot and pnpm audit (#12758) 2026-09-08 11:42:09 +02:00
César Arroba 623dc3125a chore(codeowners): consolidate retired teams under engineering (#12755) 2026-09-07 19:11:56 +02:00
Pedro Martínandalejandrobailo 1edcf6e5de fix(jira): fix connection check timeout (#12742)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-09-04 15:51:09 +02:00
Pedro Martín 8bdb597921 perf(api): speed up compliance overview ingestion (#12738) 2026-09-04 11:13:25 +02:00
Pedro Martín 1746e1052b fix(ci): suppress unfixed x/crypto CVEs from Trivy binary (#12740) 2026-09-04 10:09:17 +02:00
Pedro Martín 6827eef347 fix(ci): don't fail setup-python-uv on empty grep match (#12737) 2026-09-04 09:12:15 +02:00
90fc815d3c chore(release): Bump versions to v5.42.0 (#12713)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-09-03 14:23:50 +02:00
Pedro Martín 8f35fff255 fix(compliance): remove duplicate ids and stale check refs (#12717) 2026-09-03 13:39:12 +02:00
Pedro Martín ab51d09543 fix(tests): isolate mock class attrs leaking across tests (#12728) 2026-09-03 12:20:33 +02:00
Pedro Martín 12faeb9aa2 chore(trivy): suppress fast-uri CVEs from Teams SPDX manifest (#12727) 2026-09-03 11:09:13 +02:00
Alejandro Bailo 9ed07de610 feat(ui): separate PostHog hosts and enable Toolbar in development (#12582) 2026-09-03 10:36:47 +02:00
Alejandro Bailo 8007501574 fix(ui): avoid missing selector in scan tour (#12705) 2026-09-03 10:23:24 +02:00
Pedro Martín 36514534cb chore(trivy): suppress CVE-2026-84304 in embedded grpc (#12720) 2026-09-03 10:17:14 +02:00
Pedro Martín 9621bdfb9c fix(tests): isolate provider mock in agentcore passrole (#12724) 2026-09-03 10:15:49 +02:00
f013a5e1ad chore(changelog): v5.41.0 highlights (#12709)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-09-02 13:51:45 +02:00
Prowler Botandprowler-bot 18453e592e chore(changelog): v5.41.0 (#12707)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-09-02 11:23:18 +02:00
Jonathan Nguyen 86e4408f29 feat(ecr): assess enhanced scanning on registries holding repositories (#12660) 2026-09-02 08:53:52 +02:00
Jonathan Nguyen ae43d21efb fix(sagemaker): read DirectInternetAccess instead of RootAccess on notebook instances (#12659) 2026-09-01 18:22:41 +02:00
Pedro Martín 7c84822fa3 fix(image): skip non-image OCI artifacts in registry scan (#12695) 2026-09-01 17:18:47 +02:00
Daniel Barranquero 51c5fa7168 fix(checks): report MANUAL instead of FAIL on permission and data-availability errors (#12645) 2026-09-01 17:16:56 +02:00
Jonathan Nguyen e9121f5f1a feat(cloudwatch): add agentcore log group data protection policy check (#12662) 2026-09-01 17:04:16 +02:00
Jonathan Nguyen 821fe43efd feat(iam): scope AgentCore PassRole and workload token grants, and flag unbound service trust (#12664) 2026-09-01 17:02:05 +02:00
Jonathan Nguyen 9ffbb4b758 fix(ecr): read each registry scanning rule's frequency instead of assuming scan on push (#12560) 2026-09-01 16:53:58 +02:00
Jonathan Nguyen 9c5285adc3 fix(cloudwatch): skip metric filters whose log group was not retrieved (#12561) 2026-09-01 14:00:41 +02:00
Pedro Martín f295d290dd feat(image): private network allowlist for SSRF guard (#12678) 2026-09-01 14:00:04 +02:00
Jonathan Nguyen e5df95c259 feat(eks): assess Kubernetes network policy enforcement in the Amazon VPC CNI add-on (#12661) 2026-09-01 13:40:45 +02:00
Jonathan Nguyen b6a8af3c54 feat(guardduty): assess unified Runtime Monitoring and AI Protection (#12564) 2026-09-01 13:18:57 +02:00
Pedro MartínandLydia Vilchez fb7064401b feat(compliance): add CIS 1.4 google workspace compliance (#12513)
Co-authored-by: Lydia Vilchez <lydiavilchezlopez@gmail.com>
2026-09-01 09:35:39 +02:00
Josema Camacho 8d60f9703a fix(api): limit mute rules to current and future scans (#12681) 2026-09-01 09:33:15 +02:00
Pablo Fernandez Guerra (PFE) ceb601028e fix(ui): apply Slack integration design feedback (#12677) 2026-08-31 18:27:11 +02:00
Pedro MartínandDavid 6422178b76 feat(sdk): AWS partition selection via PROWLER_AWS_PARTITION (#12680)
Co-authored-by: David <david.copo@gmail.com>
2026-08-31 18:13:30 +02:00
Daniel BarranqueroandJosema Camacho 587c47bfe2 feat(api): add finding labels, finding URL and tenant info to Jira issues (#12540)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-08-31 18:00:46 +02:00
Rubén De la Torre VicoandClaude Opus 5 13a31d9225 feat(mcp): raise instead of returning error objects in the Prowler Docs tools (#12534)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 17:36:19 +02:00
Pablo Fernandez Guerra (PFE)andalejandrobailo 0715619435 feat(ui): import Prowler OCSF findings from the Scans page (#12554)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-31 17:14:12 +02:00
Rubén De la Torre Vico 1679094f22 feat(mcp): raise instead of returning error objects in the Prowler Hub tools (#12533) 2026-08-31 13:09:34 +02:00
Rubén De la Torre Vico f05a490cd7 feat(mcp): raise instead of returning error objects in the Prowler App tools (#12532) 2026-08-31 10:57:58 +02:00
Alejandro Bailo 89988dada5 fix(ui): refresh cached permissions after token rotation (#12640) 2026-08-31 10:49:10 +02:00
ye11oc4tandDaniel Barranquero 0326844527 fix(github): paginate repository discovery (#12460)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-31 10:15:29 +02:00
mintlify[bot] 73d5c6952b docs: fix typos and grammar (#12672)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-08-31 09:18:08 +02:00
mintlify[bot] ad76b3026f docs: brand tone and writing style fixes (#12671)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-08-31 09:15:46 +02:00
Utkarsh Batham e21946874f feat(memorydb): add memorydb_cluster_in_transit_encryption_enabled check (#12246) 2026-08-28 14:03:10 +02:00
SejalandDaniel Barranquero 2cae2058e9 feat(aws): add elasticbeanstalk_environment_no_secrets_in_configuration check (#12378)
Signed-off-by: unknown <sej1306kook@gmail.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-28 13:54:23 +02:00
Daniel BarranqueroandJosema Camacho c88f745038 feat(jira): return the created issue, sanitize labels and add bulk status lookup (#12539)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-08-28 13:47:12 +02:00
Prowler Botandprowler-bot c923c58a39 chore(release): Bump versions to v5.41.0 (#12647)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-28 13:16:00 +02:00
c3bee8c21e chore(changelog): v5.40.0 highlights (#12569)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-08-28 13:10:06 +02:00
Prowler Botandprowler-bot 4d13e8432e chore(changelog): v5.40.0 (#12642)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-28 11:51:30 +02:00
Pedro Martín 5f24bec9fe fix(compliance): correct Cyber Essentials mappings and remediation text (#12596) 2026-08-28 11:08:15 +02:00
César Arroba afefb8f333 fix(api): apply findings partition max age in months, not days (#12580) 2026-08-28 10:34:12 +02:00
Rubén De la Torre Vico 2b81fdcc04 fix(mcp): call the Mintlify search endpoint the documentation moved to (#12578) 2026-08-27 16:48:38 +02:00
Pablo Fernandez Guerra (PFE) db298c1d48 fix(ui): redirect the Slack OAuth callback relative to the browser's origin (#12577) 2026-08-27 16:13:36 +02:00
2877c3d6c0 fix(slack): handle scans that produce no findings (#12229)
Co-authored-by: Juhef <117518034+juheff@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-27 14:03:33 +02:00
ee64c17108 fix(kubernetes): return empty list when resource gather fails (#12225)
Co-authored-by: Juhef <117518034+juheff@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-27 13:55:46 +02:00
Pablo Fernandez Guerra (PFE)andalejandrobailo a610314eba fix(ui): complete Slack OAuth callback server-side to avoid router race (#12572)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-27 13:54:15 +02:00
Muhammad Ibrahimandpedrooot 301edea7ce feat(compliance): add Cyber Essentials 3.3 for Azure (#11588)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-08-27 13:31:18 +02:00
c89d900aae fix(iac): raise typed exceptions instead of sys.exit on provider failures (#12227)
Co-authored-by: Juhef <117518034+juheff@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-27 12:15:50 +02:00
Hugo Pereira Brito 4cfb4eeb96 fix(sdk): use system trust store for push-to-cloud (#12485) 2026-08-27 11:07:14 +01:00
Pedro Martínandalejandrobailo f19478f2f6 fix(compliance): discover universal frameworks from entry point (#12536)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-27 09:17:39 +02:00
Chethas DileepandDaniel Barranquero 2f11b16299 feat(github): add repository_default_workflow_permissions_read_only check (#12143)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-27 09:00:42 +02:00
Pablo Fernandez Guerra (PFE) 654d2c9f17 docs: document Slack channel destinations for alerts (#12496) 2026-08-27 08:53:07 +02:00
Chethas DileepandDaniel Barranquero 2721d42594 feat(github): add organization_actions_pull_request_approval_disabled check (#12394)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-27 08:25:32 +02:00
Daniel Barranquero bd1956446d fix(alibabacloud): read OSS bucket sub-resource configs via the SDK execute path (#12546) 2026-08-26 16:41:13 +02:00
Pablo Fernandez Guerra (PFE) d26d7cf91a feat(ui): show a rule's destinations in the alerts list (#12493) 2026-08-26 13:36:31 +02:00
Pablo Fernandez Guerra (PFE) 4c20bf1fac feat(ui): let alert rules target authorized Slack channels (#12492) 2026-08-26 12:55:49 +02:00
Chethas DileepandDaniel Barranquero 9dc53ffc60 feat(github): add organization_default_workflow_permissions_read_only check (#12122)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-26 12:21:49 +02:00
Alan Buscagliaandalejandrobailo 95642fb220 feat(ui): add Lighthouse request outcome feedback (#12419)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-26 11:34:40 +02:00
Pedro Martín 6449f3a592 fix(container): patch the high OpenSSL CVEs for container img (#12549) 2026-08-26 11:10:55 +02:00
Rubén De la Torre Vico 91e6cb798d feat(mcp): classify shared tool failures and stop relaying upstream bodies (#12531) 2026-08-26 11:06:07 +02:00
Rubén De la Torre Vico bcd37988b5 fix(mcp): patch the high openssl CVE in the container image (#12547) 2026-08-26 10:45:41 +02:00
Alejandro Bailo 7a64e1a1d1 feat(ui): add cancelled subscription variant to the trial sidebar banner (#12538) 2026-08-26 08:59:00 +02:00
Pablo Fernandez Guerra (PFE) 39c85ffb77 feat(ui): authorize multiple Slack destination channels (#12491) 2026-08-26 08:52:56 +02:00
Alex ChenandDaniel Barranquero 301ca50541 feat(alibabacloud): add oss_bucket_server_side_encryption_enabled check (#11981)
Signed-off-by: Alex Chen <l46983284@gmail.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-25 17:41:52 +02:00
Rubén De la Torre Vico f299e1d9ac fix(mcp): patch the two high sqlite CVEs in the container image (#12537) 2026-08-25 17:41:44 +02:00
João Mesquitaandpedrooot 465e35bf54 fix(compliance): correct AWS FSBP check mapping for IAM.9 and EKS.1 (#12372)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-08-25 17:38:33 +02:00
Pablo Fernandez Guerra (PFE) 829af2e3f7 docs: add Slack integration guide (#12438) 2026-08-25 15:44:34 +02:00
Hugo Pereira Brito 51c65bdc69 docs: restructure organization documentation (#12521) 2026-08-25 12:49:23 +01:00
Hugo Pereira Brito d0c088dd0a docs: explain AWS Organization account membership updates (#12512) 2026-08-25 12:47:23 +01:00
abidedavanaandDaniel Barranquero 411d112165 feat(alibabacloud): add oss_bucket_versioning_enabled check (#11913)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-25 13:03:25 +02:00
Gabrielandpedrooot 8a4cc8780d feat(kubernetes): include cluster name in compliance reports (#12506)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-08-25 11:08:04 +02:00
Pepe Fagoaga cd4d2a27e3 chore(lighthouse): rename not tested to not connected (#12523) 2026-08-25 09:21:18 +02:00
9898cf7364 feat(m365): add defender_domain_dmarc_records_published check (#11936)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: hdy2001 <56308320+hdy2001@users.noreply.github.com>
2026-08-24 16:49:36 +02:00
83d8cfa829 fix(aws): treat security groups on Batch compute environments as used (#12458)
Co-authored-by: hackertwinten <193916571+hackertwinten@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-24 16:18:56 +02:00
mintlify[bot] 5202a68cf0 docs: brand tone and writing style fixes (#12510)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-08-24 11:17:37 +02:00
Hugo Pereira Brito e3a3acc799 fix(api): upgrade sqlparse to 0.6.0 (#12509) 2026-08-24 08:47:29 +02:00
Pablo Fernandez Guerra (PFE) 3e000faa31 feat(ui): add Slack disconnect and revoked-credential recovery (#12437) 2026-08-21 12:42:47 +02:00
Jonathan NguyenandHugo P.Brito f39c92b8f8 feat(bedrock): add model artifact and guardrail grounding security checks for the AWS provider (#12459)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-21 10:50:34 +01:00
Pablo Fernandez Guerra (PFE) 823efc5ab1 feat(ui): pick a Slack channel and verify it (#12436) 2026-08-21 11:45:24 +02:00
Pablo Fernandez Guerra (PFE) 75d7fa5006 feat(ui): add Slack integration connect flow (#12435) 2026-08-21 10:49:46 +02:00
Prowler Botandprowler-bot db25484ccb feat(aws): Update regions for AWS services (#12472)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-20 16:02:56 +01:00
Johannes EnglerandHugo P.Brito 3da4209ee7 feat(stackit): add ske_cluster_no_public_endpoint check (#11943)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-20 13:01:01 +01:00
acb6ff0425 feat(providers/huaweicloud): add vpc_security_group_open_egress check (#12209)
Co-authored-by: tomitobio <tomitobio@users.noreply.github.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-20 10:52:13 +01:00
Pablo Fernandez Guerra (PFE) ba564af4f4 fix(ci): unblock the Python runtime download in blocked-egress jobs (#12490) 2026-08-20 10:48:16 +02:00
Eugene C.andHugo P.Brito 0b9791ffdc feat(ecr): add ecr_repository_image_no_secrets check (#12123)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-18 11:10:07 +01:00
Prowler Botandprowler-bot f6defefb58 chore(changelog): v5.39.1 forward-sync to master (#12483)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-18 11:32:20 +02:00
ye11oc4tandHugo P.Brito f3224d0988 fix(ses): evaluate all identity authorization policies (#12464)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-17 14:19:45 +01:00
Pepe Fagoaga 450e6ba553 chore(api): drop temporary SDK pin overrides after cryptography cap bump (#12473) 2026-08-17 13:42:09 +02:00
2cd93fe119 fix(sdk): skip undescribed ECS task definitions (#12217)
Co-authored-by: Nguyễn Công Thuận Huy <nguyencongthuanhuy@gmail.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-17 12:42:06 +01:00
Pablo Fernandez Guerra (PFE) f807b22ea6 ci: lint .github markdown and fix the violations it exposed (#12290) 2026-08-17 13:00:32 +02:00
Pepe Fagoaga b6e9967da6 fix(deps): make published wheels installable and add package checks (#12467) 2026-08-17 12:34:11 +02:00
Hugo Pereira Brito 16e62f7514 ci(labeler): cover existing provider labels (#12476) 2026-08-17 11:33:22 +01:00
Adrián Peña 13ce9436b3 chore: update Trivy to 0.74.0 (#12466) 2026-08-17 10:25:28 +02:00
mintlify[bot] d3ced63397 fix(docs): typos and grammar (#12468)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-08-17 09:21:56 +02:00
Adrián Peña 758b696ca5 feat(ui): highlight imported providers (#12447) 2026-08-17 08:53:14 +02:00
Pepe Fagoaga 0d3ce45374 fix(pypi): bump to pypa/gh-action-pypi-publish v1.14.2 (#12456) 2026-08-14 14:57:07 +02:00
Alejandro Bailo f35666ff0a fix(ui): settle scan auto-refresh safely (#12455) 2026-08-14 11:48:08 +02:00
Pedro Martín 0758c3585d feat(rolesanywhere): flag profiles with unscoped sessions (#12416) 2026-08-13 17:06:24 +02:00
dd882c70e7 chore(release): Bump versions to v5.40.0 (#12443)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-08-13 13:49:26 +02:00
Hugo Pereira BritoandPablo F.G d05c9fbb31 feat(ui): add trial usage sidebar banner (#12420)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
2026-08-13 11:36:59 +01:00
Josema Camacho 7bde42ffb9 docs: update attack paths documentation for grouped graphs (#12440) 2026-08-13 12:25:14 +02:00
Pepe Fagoaga 0d3df0fd0b chore(changelog): v5.39.0 release highlights (#12432) 2026-08-13 12:08:15 +02:00
Pedro Martín ab996417e6 fix(ci): bump Trivy to v0.73.0 to fix CVE-2026-46600 (#12444) 2026-08-13 12:04:51 +02:00
Prowler Botandprowler-bot 5f109bc00e chore(changelog): v5.39.0 (#12433)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-13 09:01:51 +02:00
Pepe Fagoaga 472e04f4cc docs(triage): manual PASS verification for MANUAL findings (#12431) 2026-08-12 15:46:54 +02:00
Rubén De la Torre Vico b848aace33 docs(mcp): document the Cloud organization tools and Jira dispatch options (#12427) 2026-08-12 15:05:24 +02:00
Pedro Martín 94c20eb9fe feat(ui): add CMMC compliance framework (#12414) 2026-08-12 14:52:09 +02:00
02df22ca19 fix(html): escape provider identity fields in report header (#12424)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pedrooot <56402503+pedrooot@users.noreply.github.com>
2026-08-12 13:58:31 +02:00
Hugo Pereira BritoandJosema Camacho de64df11b9 fix(api): normalize social account names (#12413)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-08-12 12:41:44 +01:00
Pedro Martín 37ebd9b6fd fix(cmmc): remove stale config_requirements (#12425) 2026-08-12 12:29:43 +02:00
Pedro Martín a28487cbff fix(ci): suppress .NET runtime CVE temporarily (#12426) 2026-08-12 12:16:14 +02:00
Rubén De la Torre Vico 68471d2a0e feat(ui): add Manage Lighthouse AI role permission (#12412) 2026-08-12 10:19:20 +02:00
Pablo Fernandez Guerra (PFE) d41b2eaa0f docs: add the Azure Management Groups onboarding tutorial (#12389) 2026-08-12 09:15:32 +02:00
Pablo Fernandez Guerra (PFE) b480907484 feat(ui): onboard Azure subscriptions from a Management Group (#12386) 2026-08-12 09:07:43 +02:00
Pablo Fernandez Guerra (PFE) 6d7bc8a86e test(ui): consolidate the providers page integration suites (#12383) 2026-08-11 18:50:10 +02:00
Hugo Pereira Britoandalejandrobailo 8bfca81e4b feat(ui): add manual pass triage workflow (#12253)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-11 15:09:08 +01:00
Pablo Fernandez Guerra (PFE) 931612443a test(ui): drop organization unit tests restated by integration (#12382) 2026-08-11 15:41:34 +02:00
Daniel Barranqueroandalejandrobailo 3074f02a63 feat(ui): grouped Attack Paths graph with expandable resource classes and outcome node (#12381)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-11 14:05:12 +02:00
Pablo Fernandez Guerra (PFE) 5cfc22040a fix(ui): open scan findings using the scan's UTC day (#12411) 2026-08-11 12:54:47 +02:00
Hugo Pereira Brito 48ba1692e1 docs: update provider check counts (#12418) 2026-08-11 10:37:48 +01:00
Alejandro Bailo a8b12813f9 feat(ui): add Lighthouse AI Skills on findings (#12355) 2026-08-11 11:05:04 +02:00
Pedro Martín 85c36bb812 feat(compliance): add CMMC 2.0 compliance framework (#12401) 2026-08-10 12:48:26 -07:00
ce037318cd feat(m365): add CIS M365 v7.0.0 entra authentication method, PIM and access review checks (#12155)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-10 15:37:48 +01:00
Pedro MartínandHugo P.Brito 356036fe1f feat(m365): add CIS M365 v7.0.0 entra conditional access and session checks (#12154)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-10 11:17:19 +01:00
Andoni AlonsoandLydia Vilchez 286685a4f3 feat(github): scale organization_repository_creation_limited severity by repository visibility (#12164)
Co-authored-by: Lydia Vilchez <lydiavilchezlopez@gmail.com>
2026-08-10 11:51:38 +02:00
Hugo Pereira Brito 9daca2e4df fix(ci): suppress Trivy go-git vulnerability temporarily (#12405) 2026-08-10 10:41:19 +01:00
Hugo Pereira Brito 3ca3a977a9 test(m365): avoid Lob secret pattern in test name (#12395) 2026-08-10 08:35:28 +01:00
mintlify[bot] 561a1390be docs: fix typos and grammar (#12404)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-08-10 09:22:41 +02:00
Pedro MartínandHugo P.Brito f2a00f19aa feat(m365): add CIS M365 v7.0.0 entra password protection and default user permission checks (#12153)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-07 13:42:52 +01:00
Adrián Peña 34b4e6f016 fix(api): enforce POST on SAML ACS endpoint (#12393) 2026-08-07 14:24:45 +02:00
praneetrajvandDaniel Barranquero 94594d6766 feat(batch): add batch_job_definition_no_secrets check (#12117)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-07 11:57:58 +02:00
6e71dee85d feat(awslambda): add awslambda_layer_no_secrets_in_content check (#12233)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 11:33:54 +02:00
Alejandro Bailo 90712c9ad7 test(ui): preload lazy survey chunk to fix flaky feedback tests (#12387) 2026-08-07 11:04:38 +02:00
Josema Camacho 3672b17a00 feat(api): identify active membership in current user response (#12388) 2026-08-07 10:40:21 +02:00
Adrián Peña fd555e2989 docs: v5.38.0 changelog highlights (#12363) 2026-08-06 18:38:57 +02:00
Adrián Peña cf558c5f0a fix(api): make tenant deletion cleanup atomic (#12379) 2026-08-06 17:36:52 +02:00
lydiavilchez e2cae35d38 feat(ui): pre-fill Cloudflare and GitHub token creation URLs (#12349) 2026-08-06 16:36:10 +02:00
Prowler Botandprowler-bot d8c8027215 chore(release): Bump versions to v5.39.0 (#12376)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-06 15:59:35 +02:00
Prowler Botandprowler-bot 226504982b chore(changelog): v5.38.0 (#12373)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-06 13:25:12 +02:00
César Arroba a700865340 ci: always report from actionlint, skipping the work when nothing changed (#12371) 2026-08-06 12:53:43 +02:00
César Arroba 07faddd64e ci: fix the remaining shellcheck findings and enable the check (#12367) 2026-08-06 12:35:55 +02:00
César Arroba 1b9a44b164 fix(ci): keep the cloud sync dispatch payload within the 10-property limit (#12370) 2026-08-06 12:31:37 +02:00
Alan Buscagliaandalejandrobailo ff0ee666e3 fix(ui): prevent feedback widget overlaps (#12282)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-06 12:28:32 +02:00
César Arroba b684ad06f3 ci: forward stacked PR metadata in the cloud sync dispatch (#12368) 2026-08-06 12:24:48 +02:00
Alan Buscagliaandalejandrobailo cd4693168a fix(ui): align overview metric cards (#12323)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-06 12:21:14 +02:00
Alejandro Bailo f727f1bb50 fix(ui): handle pending compliance overview responses (#12358) 2026-08-06 12:00:30 +02:00
César Arroba 76d7a2882c ci: quote the unquoted shell expansions in workflows (#12365) 2026-08-06 11:59:40 +02:00
Josema Camacho c7adc14729 fix(ui): correct GCP organization credential labels (#12362) 2026-08-06 11:28:22 +02:00
César Arroba 31d8faccfa ci: check GitHub Actions schemas with actionlint (#12361) 2026-08-06 11:01:53 +02:00
Hugo Pereira Brito d0da56f352 fix(alibabacloud): retry STS connection failures (#12353) 2026-08-06 09:44:00 +01:00
Adrián Peña 5e41b2054d docs: document multiple SAML domains (#12351) 2026-08-06 10:07:41 +02:00
Josema Camacho 058db7bcc9 fix(ui): launch organization scans through the bulk endpoint (#12350) 2026-08-06 09:48:53 +02:00
César Arroba d1a37039fd fix(deps): upgrade cryptography to 50.0.0 (#12356) 2026-08-05 19:16:11 +02:00
Rubén De la Torre VicoandClaude Opus 5 97c342ad80 test(mcp): cover the integrations tools and models (#12343)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 16:47:11 +02:00
César Arroba f3c602a5ac feat(container): ship an SBOM and provenance with the published images (#12352) 2026-08-05 16:19:42 +02:00
Josema Camacho d7816f1179 feat(ui): show sign-in methods in the Cloud users table (#12268) 2026-08-05 14:41:59 +02:00
Daniel Barranquero dd61c417b7 feat(attack-paths): add outcome to query metadata (#12344) 2026-08-05 14:13:24 +02:00
Pablo Fernandez Guerra (PFE) 2fdd46336c fix(ui): keep page-size selector when table fits one page (#12299) 2026-08-05 14:13:11 +02:00
Pablo Fernandez Guerra (PFE) ae8c86ecb5 fix(ui): always show Scans page so imported scans are visible (#12025) 2026-08-05 14:12:17 +02:00
Pedro MartínandDaniel Barranquero aaa29d3528 feat(m365): add CIS M365 v7.0.0 entra device registration checks (#12152)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-05 13:51:29 +02:00
Pepe Fagoaga 06799dcaa8 docs(compliance): watchlist review (#12348) 2026-08-05 13:44:03 +02:00
Pepe Fagoaga b53c5a4e70 docs(compliance): watchlist (#12347) 2026-08-05 13:29:45 +02:00
César Arroba af757a4d69 ci(container): pin Trivy to v0.72.0 across the estate (#12346) 2026-08-05 13:12:24 +02:00
Pedro MartínandDaniel Barranquero f87522e423 feat(m365): add CIS M365 v7.0.0 teams external access check (#12151)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-05 12:24:44 +02:00
Adrián Peña d3524d50fb feat(ui): support multiple SAML domains in Cloud (#12332) 2026-08-05 11:49:59 +02:00
Pedro Martín 5b1bd146be docs(m365): highlight key terms in check Risk descriptions (#12156) 2026-08-05 10:49:53 +01:00
Pablo Fernandez Guerra (PFE) 34431b5b88 test(ui): stabilize browser-mode test harnesses (#12301) 2026-08-05 11:37:58 +02:00
Pedro Martínandalejandrobailo 5285d25cfd feat(ui): add compliance watchlist (#12300)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-05 11:18:45 +02:00
Pedro MartínandAlan Buscaglia a6d5dbacd9 fix(api): log comp report output dir failures with exc_info (#12142)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
2026-08-05 11:12:35 +02:00
César Arroba 3b577907e4 ci(container): gate Grype only on fixable findings, and comment results on the PR (#12341) 2026-08-05 10:23:29 +02:00
Pedro Martín 8bf788ea95 fix(aws): delegated administrator lookup and reporting (#12319) 2026-08-05 09:48:00 +02:00
César Arroba 87bc1eceae ci(container): scan images with Grype alongside Trivy, blocking on critical and high (#12340) 2026-08-04 18:49:53 +02:00
César Arroba 635e451d9b fix(container): verify the checksum of downloaded third-party binaries (#12334) 2026-08-04 18:07:46 +02:00
Pedro MartínandAlan Buscaglia bd6aec8c20 fix(sdk): only report JDBC strings with embedded credentials (#12288)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
2026-08-04 17:22:31 +02:00
Pedro Martín 3c14df7e5b fix(oci): handle null fields in identity service (#12327) 2026-08-04 17:11:26 +02:00
César Arroba 531f61df2f chore(docs): add the Prowler logo to the Docker Hub overview (#12336) 2026-08-04 17:10:31 +02:00
César Arroba 765a1596f9 chore(docs): sync Docker Hub repository overviews from a single source (#12333) 2026-08-04 16:53:59 +02:00
Rubén De la Torre Vico 138d643119 test(mcp): add test foundation for the MCP server (#12291) 2026-08-04 16:19:10 +02:00
Alejandro Bailo c74eac1369 refactor(ui): type overview action results as ApiResult (#12287) 2026-08-04 13:41:44 +02:00
Alejandro Bailo f9dbb0eee9 fix(ui): harden Lighthouse context compiler against invalid items and budget pressure (#12286) 2026-08-04 13:41:20 +02:00
Alejandro Bailo ab13d111c2 feat(ui): capture Lighthouse context on tenant admin pages (#12285) 2026-08-04 13:40:34 +02:00
Alejandro Bailo f0d2972969 fix(ui): guard Overview Lighthouse context against API error responses (#12284) 2026-08-04 13:37:47 +02:00
Pedro MartínandDaniel Barranquero 90905dcc9f feat(m365): add CIS M365 v7.0.0 exchange checks (#12149)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-04 13:37:28 +02:00
César Arroba 5cf49805a2 fix(ci): make the YAML Trivy suppressions actually apply (#12326) 2026-08-04 13:05:46 +02:00
c610d9ac31 chore(changelog): v5.37.1 forward-sync to master (#12322)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
Co-authored-by: César Arroba <cesar@prowler.com>
2026-08-04 12:12:15 +02:00
fb9d989be8 fix(ui): deep-link wizard docs link to provider authentication guide (#12218)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-08-04 11:57:56 +02:00
César Arroba 64c0cf900f fix(compose): bump DozerDB to 5.26.27.0 (PROWLER-2308) (#12320) 2026-08-04 11:41:23 +02:00
Pedro MartínandDaniel Barranquero 0f39665ece feat(m365): add CIS M365 v7.0.0 defender preset policy checks (#12148)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-04 11:31:50 +02:00
Alan Buscaglia 162c6560d9 fix(ui): stabilize overview responsive layout (#12317) 2026-08-04 11:18:10 +02:00
César Arroba 681be7537d chore(security): migrate suppressions to .trivyignore.yaml (PROWLER-2327) (#12314) 2026-08-04 11:13:16 +02:00
César Arroba 94254555a4 fix(ui): drop apk upgrade and move the base digest forward (PROWLER-2323) (#12313) 2026-08-04 11:07:34 +02:00
2646068e7e fix(app): correct the worker KEDA PostgreSQL scaler (#12055)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Utwo <mihai.legat@gmail.com>
2026-08-04 10:47:02 +02:00
Daniel Barranquero caf27de6ee fix(m365): bump microsoft-kiota packages to 1.9.10 so guest-user CA checks parse guestOrExternalUserTypes (#12280) 2026-08-04 10:37:04 +02:00
César Arroba aab8154139 ci(workflows): align container build-push workflows across api, ui, mcp and sdk (#12310) 2026-08-04 10:25:31 +02:00
1de779c978 fix(sdk): resolve entry-point checks on built-in providers (#12294)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-04 10:24:55 +02:00
César Arroba f4d6cd8609 fix(deps): restore the SDK dependency to master (PROWLER-2328) (#12309) 2026-08-04 09:59:21 +02:00
Pedro MartínandDaniel Barranquero b3d174d0c1 feat(m365): add CIS M365 v7.0.0 admincenter shared bookings check (#12147)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-08-04 09:46:47 +02:00
César Arroba 8ebb4a1ee7 fix(container): clear the Private Cloud image vulnerabilities (PROWLER-2291) (#12258) 2026-08-04 09:41:47 +02:00
Maringanti Vasist Acharya abf660ce06 fix(sdk): renumber Huawei Cloud off E2E Networks' range, and correct the M365 header comment (#12296) 2026-08-04 09:36:46 +02:00
Daniel Barranquero a19fd70001 feat(aws): add pathfinding.cloud privilege-escalation coverage (#12237) 2026-08-04 08:59:52 +02:00
Pepe Fagoaga 9b6a239abe fix(docs): v5.37.0 changelog date (#12304) 2026-08-03 18:31:30 +02:00
8c0fbf5073 docs: v5.37.0 changelog highlights (#12264)
Co-authored-by: Rubén De la Torre Vico <ruben@prowler.com>
Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-08-03 18:24:44 +02:00
Prowler Botandprowler-bot ce77eb7f41 chore(release): Bump versions to v5.38.0 (#12302)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-08-03 18:20:09 +02:00
CopilotandPepe Fagoaga e15f6970ef fix(ci): use PROWLER_BOT_ACCESS_TOKEN for release freeze (#12295)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-08-03 14:45:23 +02:00
1832 changed files with 163138 additions and 15393 deletions
+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.37.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.44.1
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+13 -13
View File
@@ -1,23 +1,23 @@
# SDK
/* @prowler-cloud/detection-remediation
/prowler/ @prowler-cloud/detection-remediation
/tests/ @prowler-cloud/detection-remediation
/dashboard/ @prowler-cloud/detection-remediation
/docs/ @prowler-cloud/detection-remediation
/examples/ @prowler-cloud/detection-remediation
/util/ @prowler-cloud/detection-remediation
/contrib/ @prowler-cloud/detection-remediation
/permissions/ @prowler-cloud/detection-remediation
/codecov.yml @prowler-cloud/detection-remediation @prowler-cloud/api
/* @prowler-cloud/engineering
/prowler/ @prowler-cloud/engineering
/tests/ @prowler-cloud/engineering
/dashboard/ @prowler-cloud/engineering
/docs/ @prowler-cloud/engineering
/examples/ @prowler-cloud/engineering
/util/ @prowler-cloud/engineering
/contrib/ @prowler-cloud/engineering
/permissions/ @prowler-cloud/engineering
/codecov.yml @prowler-cloud/engineering
# API
/api/ @prowler-cloud/api
/api/ @prowler-cloud/engineering
# UI
/ui/ @prowler-cloud/ui
/ui/ @prowler-cloud/engineering
# AI
/mcp_server/ @prowler-cloud/detection-remediation
/mcp_server/ @prowler-cloud/engineering
# Platform
/.github/ @prowler-cloud/platform
+6
View File
@@ -0,0 +1,6 @@
# Generated by gh-aw and marked DO NOT EDIT. It uses concurrency options newer than
# actionlint knows, so the findings are about actionlint's schema, not our workflows.
paths:
.github/workflows/**/*.lock.yml:
ignore:
- '.*'
+179
View File
@@ -0,0 +1,179 @@
name: 'Container Security Scan with Grype'
description: 'Scans container images for vulnerabilities using Grype and reports results'
author: 'Prowler'
inputs:
image-name:
description: 'Container image name to scan'
required: true
image-tag:
description: 'Container image tag to scan'
required: true
default: ${{ github.sha }}
fail-on-severity:
description: 'Fail the build on findings at this severity or above: critical, high, or none'
required: false
default: 'high'
upload-sarif:
description: 'Upload results to GitHub Security tab'
required: false
default: 'true'
create-pr-comment:
description: 'Create a comment on the PR with scan results'
required: false
default: 'true'
artifact-retention-days:
description: 'Days to retain the Grype report artifact'
required: false
default: '2'
outputs:
critical-count:
description: 'Number of critical vulnerabilities found'
value: ${{ steps.security-check.outputs.critical }}
high-count:
description: 'Number of high vulnerabilities found'
value: ${{ steps.security-check.outputs.high }}
total-count:
description: 'Total number of vulnerabilities found'
value: ${{ steps.security-check.outputs.total }}
runs:
using: 'composite'
steps:
- name: Run Grype vulnerability scan (JSON)
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
with:
image: ${{ inputs.image-name }}:${{ inputs.image-tag }}
output-format: 'json'
output-file: 'grype-report.json'
fail-build: 'false'
by-cve: 'true' # Report CVE ids rather than GHSA, so findings line up with Trivy's
only-fixed: 'true' # A finding with no available fix is not actionable, so it must not gate
cache-db: 'true'
grype-version: 'v0.116.1'
- name: Run Grype vulnerability scan (SARIF)
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
with:
image: ${{ inputs.image-name }}:${{ inputs.image-tag }}
output-format: 'sarif'
output-file: 'grype-results.sarif'
fail-build: 'false'
severity-cutoff: 'high'
by-cve: 'true'
only-fixed: 'true' # A finding with no available fix is not actionable, so it must not gate
cache-db: 'true'
grype-version: 'v0.116.1'
- name: Upload Grype results to GitHub Security tab
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
uses: github/codeql-action/upload-sarif@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
with:
sarif_file: 'grype-results.sarif'
category: 'grype-container'
- name: Upload Grype report artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: always()
with:
name: grype-scan-report-${{ inputs.image-name }}-${{ inputs.image-tag }}
path: grype-report.json
retention-days: ${{ inputs.artifact-retention-days }}
- name: Generate security summary
id: security-check
shell: bash
run: |
CRITICAL=$(jq '[.matches[]? | select(.vulnerability.severity=="Critical")] | length' grype-report.json)
HIGH=$(jq '[.matches[]? | select(.vulnerability.severity=="High")] | length' grype-report.json)
TOTAL=$(jq '[.matches[]?] | length' grype-report.json)
echo "critical=$CRITICAL" >> $GITHUB_OUTPUT
echo "high=$HIGH" >> $GITHUB_OUTPUT
echo "total=$TOTAL" >> $GITHUB_OUTPUT
echo "### 🔎 Container Security Scan (Grype)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Image:** \`${INPUTS_IMAGE_NAME}:${INPUTS_IMAGE_TAG}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- 🔴 Critical: $CRITICAL" >> $GITHUB_STEP_SUMMARY
echo "- 🟠 High: $HIGH" >> $GITHUB_STEP_SUMMARY
echo "- **Total**: $TOTAL" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Reported alongside Trivy, not instead of it. Counts differ by design." >> $GITHUB_STEP_SUMMARY
env:
INPUTS_IMAGE_NAME: ${{ inputs.image-name }}
INPUTS_IMAGE_TAG: ${{ inputs.image-tag }}
# Before the gate, so the comment is there to explain a failure rather than absent because of it
- name: Comment scan results on PR
if: >-
inputs.create-pr-comment == 'true'
&& github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
IMAGE_NAME: ${{ inputs.image-name }}
GITHUB_SHA: ${{ inputs.image-tag }}
CUTOFF: ${{ inputs.fail-on-severity }}
with:
script: |
const comment = require('./.github/scripts/grype-pr-comment.js');
// Unique identifier to find our comment
const marker = `<!-- grype-scan-comment:${process.env.IMAGE_NAME} -->`;
const body = marker + '\n' + comment;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existingComment = comments.find(c => c.body?.includes(marker));
if (existingComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body: body
});
console.log('✅ Updated existing Grype scan comment');
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body
});
console.log('✅ Created new Grype scan comment');
}
- name: Check for blocking vulnerabilities
if: inputs.fail-on-severity != 'none'
shell: bash
run: |
if [ "$CUTOFF" = "critical" ]; then
BLOCKING=$CRITICAL
SEVERITIES='["Critical"]'
else
BLOCKING=$((CRITICAL + HIGH))
SEVERITIES='["Critical","High"]'
fi
if [ "$BLOCKING" -gt 0 ]; then
echo "::error::Found $BLOCKING vulnerabilities at severity ${CUTOFF} or above ($CRITICAL critical, $HIGH high)"
echo "::warning::Update the package, or add it to .grype.yaml with a reason if nothing can be done"
jq -r --argjson severities "$SEVERITIES" \
'.matches[] | select(.vulnerability.severity | IN($severities[]))
| " \(.vulnerability.severity)\t\(.vulnerability.id)\t\(.artifact.name) \(.artifact.version)"' \
grype-report.json | sort -u
exit 1
fi
env:
CUTOFF: ${{ inputs.fail-on-severity }}
CRITICAL: ${{ steps.security-check.outputs.critical }}
HIGH: ${{ steps.security-check.outputs.high }}
@@ -46,6 +46,17 @@ runs:
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
if grep -q "prowler-cloud/prowler" uv.lock; then
:
else
status=$?
if [ "$status" -ne 1 ]; then
echo "::error::grep failed reading uv.lock (exit code $status)."
exit "$status"
fi
echo "No prowler-cloud/prowler entry in uv.lock, nothing to update."
exit 0
fi
LATEST_COMMIT=$(curl -sf --retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 60 \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
@@ -66,6 +77,17 @@ runs:
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
if grep -q "prowler-cloud/prowler" uv.lock; then
:
else
status=$?
if [ "$status" -ne 1 ]; then
echo "::error::grep failed reading uv.lock (exit code $status)."
exit "$status"
fi
echo "No prowler-cloud/prowler entry in uv.lock, nothing to update."
exit 0
fi
LATEST_COMMIT=$(curl -sf --retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 60 \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
+34 -11
View File
@@ -14,10 +14,10 @@ inputs:
description: 'Severities to scan for (comma-separated)'
required: false
default: 'CRITICAL,HIGH,MEDIUM,LOW'
fail-on-critical:
description: 'Fail the build if critical vulnerabilities are found'
fail-on-severity:
description: 'Fail the build on findings at this severity or above: critical, high, or none'
required: false
default: 'false'
default: 'high'
upload-sarif:
description: 'Upload results to GitHub Security tab'
required: false
@@ -62,8 +62,12 @@ runs:
severity: ${{ inputs.severity }}
exit-code: '0'
scanners: 'vuln'
ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate
timeout: '5m'
version: 'v0.71.2'
version: 'v0.74.0'
# Not trivyignores: that input drops the .yaml extension Trivy parses by.
env:
TRIVY_IGNOREFILE: '.trivyignore.yaml'
- name: Run Trivy vulnerability scan (SARIF)
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
@@ -75,8 +79,12 @@ runs:
severity: 'CRITICAL,HIGH'
exit-code: '0'
scanners: 'vuln'
ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate
timeout: '5m'
version: 'v0.71.2'
version: 'v0.74.0'
# Not trivyignores: that input drops the .yaml extension Trivy parses by.
env:
TRIVY_IGNOREFILE: '.trivyignore.yaml'
- name: Upload Trivy results to GitHub Security tab
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
@@ -163,13 +171,28 @@ runs:
console.log('✅ Created new Trivy scan comment');
}
- name: Check for critical vulnerabilities
if: inputs.fail-on-critical == 'true' && steps.security-check.outputs.critical != '0'
- name: Check for blocking vulnerabilities
if: inputs.fail-on-severity != 'none'
shell: bash
run: |
echo "::error::Found ${STEPS_SECURITY_CHECK_OUTPUTS_CRITICAL} critical vulnerabilities"
echo "::warning::Please update packages or use a different base image"
exit 1
if [ "$CUTOFF" = "critical" ]; then
BLOCKING=$CRITICAL
SEVERITIES='["CRITICAL"]'
else
BLOCKING=$((CRITICAL + HIGH))
SEVERITIES='["CRITICAL","HIGH"]'
fi
if [ "$BLOCKING" -gt 0 ]; then
echo "::error::Found $BLOCKING vulnerabilities at severity ${CUTOFF} or above ($CRITICAL critical, $HIGH high)"
echo "::warning::Update the package, or add it to .trivyignore.yaml with a reason if nothing can be done"
jq -r --argjson severities "$SEVERITIES" \
'.Results[]?.Vulnerabilities[]? | select(.Severity | IN($severities[]))
| " \(.Severity)\t\(.VulnerabilityID)\t\(.PkgName) \(.InstalledVersion)"' \
trivy-report.json | sort -u
exit 1
fi
env:
STEPS_SECURITY_CHECK_OUTPUTS_CRITICAL: ${{ steps.security-check.outputs.critical }}
CUTOFF: ${{ inputs.fail-on-severity }}
CRITICAL: ${{ steps.security-check.outputs.critical }}
HIGH: ${{ steps.security-check.outputs.high }}
+6 -6
View File
@@ -199,7 +199,7 @@ You MUST structure your response using this EXACT format. Do NOT include anythin
### For Check Logic Bug
```
```markdown
### AI Assessment [Experimental]: Check Logic Bug
**Component**: {component from issue template}
@@ -297,7 +297,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns
### For Bug (non-check)
```
```markdown
### AI Assessment [Experimental]: Bug
**Component**: {CLI/SDK | API | UI | Dashboard | MCP Server | Other}
@@ -378,7 +378,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns
### For Already Fixed
```
```markdown
### AI Assessment [Experimental]: Already Fixed
**Component**: {component}
@@ -401,7 +401,7 @@ Upgrade to the latest version. Close the issue as resolved.
### For Feature Request
```
```markdown
### AI Assessment [Experimental]: Feature Request
**Component**: {component}
@@ -419,7 +419,7 @@ Upgrade to the latest version. Close the issue as resolved.
### For Not a Bug
```
```markdown
### AI Assessment [Experimental]: Not a Bug
**Component**: {component}
@@ -440,7 +440,7 @@ Upgrade to the latest version. Close the issue as resolved.
### For Needs More Information
```
```markdown
### AI Assessment [Experimental]: Needs More Information
**Component**: {component or "Unknown"}
+15
View File
@@ -52,6 +52,16 @@ provider/alibabacloud:
- any-glob-to-any-file: "prowler/providers/alibabacloud/**"
- any-glob-to-any-file: "tests/providers/alibabacloud/**"
provider/huaweicloud:
- changed-files:
- any-glob-to-any-file: "prowler/providers/huaweicloud/**"
- any-glob-to-any-file: "tests/providers/huaweicloud/**"
provider/image:
- changed-files:
- any-glob-to-any-file: "prowler/providers/image/**"
- any-glob-to-any-file: "tests/providers/image/**"
provider/cloudflare:
- changed-files:
- any-glob-to-any-file: "prowler/providers/cloudflare/**"
@@ -82,6 +92,11 @@ provider/linode:
- any-glob-to-any-file: "prowler/providers/linode/**"
- any-glob-to-any-file: "tests/providers/linode/**"
provider/stackit:
- changed-files:
- any-glob-to-any-file: "prowler/providers/stackit/**"
- any-glob-to-any-file: "tests/providers/stackit/**"
github_actions:
- changed-files:
- any-glob-to-any-file: ".github/workflows/*"
+100
View File
@@ -0,0 +1,100 @@
const fs = require('fs');
// Configuration from environment variables
const REPORT_FILE = process.env.GRYPE_REPORT_FILE || 'grype-report.json';
const IMAGE_NAME = process.env.IMAGE_NAME || 'container-image';
const GITHUB_SHA = process.env.GITHUB_SHA || 'unknown';
const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY || '';
const GITHUB_RUN_ID = process.env.GITHUB_RUN_ID || '';
const CUTOFF = process.env.CUTOFF || 'high';
// A cutoff of 'critical' blocks only on critical; anything else blocks on high and above
const blocking = CUTOFF === 'critical' ? ['Critical'] : ['Critical', 'High'];
const report = JSON.parse(fs.readFileSync(REPORT_FILE, 'utf-8'));
const matches = Array.isArray(report.matches) ? report.matches : [];
const ignored = Array.isArray(report.ignoredMatches) ? report.ignoredMatches : [];
const counts = { Critical: 0, High: 0, Medium: 0, Low: 0, Negligible: 0, Unknown: 0 };
const blockers = new Map();
for (const match of matches) {
const severity = match.vulnerability.severity;
if (counts[severity] !== undefined) {
counts[severity]++;
}
if (blocking.includes(severity)) {
const artifact = match.artifact;
const fixedIn = (match.vulnerability.fix && match.vulnerability.fix.versions || []).join(', ');
// Same CVE can match several install paths of one package; collapse them
blockers.set(`${match.vulnerability.id}|${artifact.name}`, {
id: match.vulnerability.id,
severity,
name: artifact.name,
version: artifact.version,
fixedIn
});
}
}
const ignoredBlocking = ignored.filter(m => blocking.includes(m.vulnerability.severity)).length;
const shortSha = GITHUB_SHA.substring(0, 7);
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
const severityConfig = {
Critical: { icon: '🔴', label: 'Critical' },
High: { icon: '🟠', label: 'High' },
Medium: { icon: '🟡', label: 'Medium' },
Low: { icon: '🔵', label: 'Low' }
};
let comment = '## 🔎 Container Security Scan (Grype)\n\n';
comment += `**Image:** \`${IMAGE_NAME}:${shortSha}\`\n`;
comment += `**Last scan:** ${timestamp}\n\n`;
if (blockers.size === 0) {
comment += '### ✅ Nothing Blocking\n\n';
comment += `No findings at **${blocking.join(' or ').toLowerCase()}** severity.\n`;
} else {
comment += `### ⚠️ ${blockers.size} Finding(s) Blocking This PR\n\n`;
comment += '| Severity | CVE | Package | Installed | Fixed in |\n';
comment += '|---|---|---|---|---|\n';
const order = { Critical: 0, High: 1 };
const rows = [...blockers.values()].sort((a, b) =>
(order[a.severity] - order[b.severity]) || a.name.localeCompare(b.name));
for (const row of rows) {
const config = severityConfig[row.severity];
comment += `| ${config.icon} ${config.label} | \`${row.id}\` | \`${row.name}\` | ${row.version} | ${row.fixedIn || '—'} |\n`;
}
comment += '\n**What to do:**\n';
comment += '- Upgrade the package to the version in the "Fixed in" column.\n';
comment += '- If it is pinned by another dependency, or the fix is otherwise out of reach, add it to `.grype.yaml` **with the reason**.\n';
comment += '- Findings with no published fix never appear here: the scan runs with `only-fixed`, so it reports only what can actually be acted on.\n';
}
const otherCounts = Object.entries(counts)
.filter(([severity, count]) => !blocking.includes(severity) && count > 0)
.map(([severity, count]) => `${severity.toLowerCase()}: ${count}`);
if (otherCounts.length > 0) {
comment += `\nNot blocking at this cutoff — ${otherCounts.join(', ')}.\n`;
}
if (ignoredBlocking > 0) {
comment += `\n${ignoredBlocking} finding(s) excluded by \`.grype.yaml\`, each with a documented reason.\n`;
}
comment += '\n---\n';
comment += '📋 **Resources:**\n';
if (GITHUB_REPOSITORY && GITHUB_RUN_ID) {
comment += `- [Download full report](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) (see artifacts)\n`;
}
comment += '- [View in Security tab](https://github.com/' + (GITHUB_REPOSITORY || 'repository') + '/security/code-scanning)\n';
comment += '- Scanned with [Grype](https://github.com/anchore/grype), alongside Trivy\n';
module.exports = comment;
+5 -5
View File
@@ -8,11 +8,11 @@ These JSON templates are used with the `slackapi/slack-github-action` using the
### Available Templates
**Container Releases**
#### Container Releases
- `container-release-started.json`: Simple one-line notification when container push starts
- `container-release-completed.json`: Simple one-line notification when container release completes
**Deployments**
#### Deployments
- `deployment-started.json`: Deployment start notification with Block Kit formatting
- `deployment-completed.json`: Deployment completion notification (updates the start message)
@@ -416,17 +416,17 @@ For deployments that start with one message and update it with the final status:
### Container Release (Simple One-Line)
**Start message:**
```
```text
API container release 4.5.0 push started... View run
```
**Completion message (success):**
```
```text
[✓] API container release 4.5.0 push completed successfully! View run
```
**Completion message (failure):**
```
```text
[✗] API container release 4.5.0 push failed View run
```
+11
View File
@@ -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/**
+26 -12
View File
@@ -42,6 +42,7 @@ jobs:
timeout-minutes: 5
outputs:
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
created: ${{ steps.set-short-sha.outputs.created }}
permissions:
contents: read
steps:
@@ -52,7 +53,9 @@ jobs:
- name: Calculate short SHA
id: set-short-sha
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
run: |
echo "short-sha=${GITHUB_SHA::7}" >> "${GITHUB_OUTPUT}"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
notify-release-started:
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
@@ -159,9 +162,20 @@ jobs:
with:
context: ${{ env.WORKING_DIRECTORY }}
push: true
sbom: true
# max, not the default min: min records little beyond the build ref.
provenance: mode=max
platforms: ${{ matrix.platform }}
tags: |
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }}
labels: |
org.opencontainers.image.title=Prowler Local Server API
org.opencontainers.image.description=API for Prowler Local Server (Django/DRF)
org.opencontainers.image.vendor=ProwlerPro, Inc.
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
@@ -179,12 +193,12 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
github.com:443
release-assets.githubusercontent.com:443
registry-1.docker.io:443
auth.docker.io:443
github.com:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
registry-1.docker.io:443
release-assets.githubusercontent.com:443
- name: Login to DockerHub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
@@ -196,9 +210,9 @@ jobs:
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -206,10 +220,10 @@ jobs:
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG} \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG}" \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -249,9 +263,9 @@ jobs:
id: outcome
run: |
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
echo "outcome=success" >> $GITHUB_OUTPUT
echo "outcome=success" >> "$GITHUB_OUTPUT"
else
echo "outcome=failure" >> $GITHUB_OUTPUT
echo "outcome=failure" >> "$GITHUB_OUTPUT"
fi
env:
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
+18 -2
View File
@@ -81,6 +81,10 @@ jobs:
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
raw.githubusercontent.com:443
objects.githubusercontent.com:443
grype.anchore.io:443
get.anchore.io:443
debian.map.fastlydns.net:80
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
@@ -107,7 +111,11 @@ jobs:
with:
files: |
api/**
.trivyignore.yaml
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
.grype.yaml
.github/scripts/grype-pr-comment.js
files_ignore: |
api/docs/**
api/README.md
@@ -145,5 +153,13 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'true'
severity: 'CRITICAL'
fail-on-severity: 'high'
severity: 'CRITICAL,HIGH'
- name: Scan container with Grype
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/grype-scan
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
- name: Check labels
id: label_check
uses: agilepathway/label-checker@c3d16ad512e7cea5961df85ff2486bb774caf3c5 # v1.6.65
uses: agilepathway/label-checker@c324842522fbd012e4f590afe3b4e591301322ed # v1.6.66
with:
allow_failure: true
prefix_mode: true
+63
View File
@@ -0,0 +1,63 @@
name: 'CI: Actionlint'
on:
push:
branches:
- 'master'
pull_request:
branches:
- 'master'
schedule:
- cron: '45 06 * * *'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
actionlint:
if: github.repository == 'prowler-cloud/prowler'
name: GitHub Actions Schema Check
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
auth.docker.io:443
registry-1.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
# zizmor: ignore[artipacked]
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
# Always runs so it always reports; the lint is skipped when nothing changed.
- name: Check for workflow changes
id: check-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: .github/**
# SC2129 is style only: it suggests grouping consecutive redirects.
- name: Run actionlint
if: steps.check-changes.outputs.any_changed == 'true'
env:
SHELLCHECK_OPTS: '-e SC2129'
run: |
docker run --rm -v "$PWD:/repo" --workdir /repo -e SHELLCHECK_OPTS \
rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 \
-color
+2 -2
View File
@@ -32,7 +32,7 @@ jobs:
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
echo "Removing 'status/awaiting-response' label from #$ISSUE_NUMBER"
gh api /repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels/status%2Fawaiting-response \
gh api "/repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels/status%2Fawaiting-response" \
-X DELETE
- name: Add 'status/waiting-for-revision' label
@@ -41,6 +41,6 @@ jobs:
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
echo "Adding 'status/waiting-for-revision' label to #$ISSUE_NUMBER"
gh api /repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels \
gh api "/repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels" \
-X POST \
-f labels[]='status/waiting-for-revision'
+1
View File
@@ -61,6 +61,7 @@ jobs:
api.github.com:443
github.com:443
objects.githubusercontent.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
@@ -0,0 +1,92 @@
name: 'Tools: Sync Docker Hub Descriptions'
on:
push:
branches:
- 'master'
paths:
- 'docs/dockerhub/README.md'
- '.github/workflows/dockerhub-descriptions.yml'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
env:
OVERVIEW_FILE: docs/dockerhub/README.md
permissions: {}
jobs:
prowlercloud:
if: github.repository == 'prowler-cloud/prowler' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- repository: prowlercloud/prowler
short_description: 'Prowler CLI: the Open Cloud Security tool for AWS, Azure, Google Cloud, Kubernetes, M365 and GitHub'
- repository: prowlercloud/prowler-api
short_description: 'Prowler Local Server - API: the JSON API and Task Runner components of Prowler'
- repository: prowlercloud/prowler-ui
short_description: 'Prowler Local Server - UI: the web interface to run Prowler scans and explore findings'
- repository: prowlercloud/prowler-mcp
short_description: 'Prowler MCP: the interface for agents, including IDE plugins and agent integrations'
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
hub.docker.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Update Docker Hub description for ${{ matrix.repository }}
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: ${{ matrix.repository }}
short-description: ${{ matrix.short_description }}
readme-filepath: ${{ env.OVERVIEW_FILE }}
toniblyx:
if: github.repository == 'prowler-cloud/prowler' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
hub.docker.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Update Docker Hub description for toniblyx/prowler
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
with:
username: ${{ secrets.TONIBLYX_DOCKERHUB_USERNAME }}
password: ${{ secrets.TONIBLYX_DOCKERHUB_PASSWORD }}
repository: toniblyx/prowler
short-description: 'Prowler CLI (legacy repository, mirrors prowlercloud/prowler)'
readme-filepath: ${{ env.OVERVIEW_FILE }}
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
- name: Login to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${GITHUB_ACTOR} --password-stdin
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin
- name: Update chart dependencies
run: helm dependency update ${{ env.CHART_PATH }}
+2 -2
View File
@@ -85,10 +85,10 @@ jobs:
# Check if author is in the org members list
if printf '%s\n' "${ORG_MEMBERS[@]}" | grep -q "^${AUTHOR}$"; then
echo "is_member=true" >> $GITHUB_OUTPUT
echo "is_member=true" >> "$GITHUB_OUTPUT"
echo "$AUTHOR is an organization member"
else
echo "is_member=false" >> $GITHUB_OUTPUT
echo "is_member=false" >> "$GITHUB_OUTPUT"
echo "$AUTHOR is not an organization member"
fi
+5 -1
View File
@@ -55,6 +55,10 @@ jobs:
# Pin must match .pre-commit-config.yaml so prek and CI behave identically.
# pnpm dlx doesn't accept --ignore-scripts as a flag; the env var
# disables postinstall scripts on transitives the same way.
#
# Files come from `git ls-files` because markdownlint doesn't traverse
# dot-directories, so `.github/**/*.md` went unlinted.
# `.markdownlintignore` still applies to the listed paths.
env:
pnpm_config_ignore_scripts: 'true'
run: pnpm dlx markdownlint-cli@0.45.0 '**/*.md'
run: git ls-files -z '*.md' | xargs -0 -r pnpm dlx markdownlint-cli@0.45.0 --
+27 -19
View File
@@ -41,6 +41,7 @@ jobs:
timeout-minutes: 5
outputs:
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
created: ${{ steps.set-short-sha.outputs.created }}
permissions:
contents: read
steps:
@@ -51,7 +52,9 @@ jobs:
- name: Calculate short SHA
id: set-short-sha
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
run: |
echo "short-sha=${GITHUB_SHA::7}" >> "${GITHUB_OUTPUT}"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
notify-release-started:
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
@@ -110,15 +113,17 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
github.com:443
registry-1.docker.io:443
auth.docker.io:443
dl-cdn.alpinelinux.org:443
dualstack.j.sni.global.fastly.net:443
files.pythonhosted.org:443
ghcr.io:443
github.com:443
pkg-containers.githubusercontent.com:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
ghcr.io:443
pkg-containers.githubusercontent.com:443
files.pythonhosted.org:443
pypi.org:443
registry-1.docker.io:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -141,17 +146,20 @@ jobs:
with:
context: ${{ env.WORKING_DIRECTORY }}
push: true
sbom: true
# max, not the default min: min records little beyond the build ref.
provenance: mode=max
platforms: ${{ matrix.platform }}
tags: |
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }}
labels: |
org.opencontainers.image.title=Prowler MCP Server
org.opencontainers.image.title=Prowler MCP
org.opencontainers.image.description=Model Context Protocol server for Prowler
org.opencontainers.image.vendor=ProwlerPro, Inc.
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ github.event_name == 'release' && github.event.release.published_at || github.event.head_commit.timestamp }}
${{ github.event_name == 'release' && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
@@ -169,11 +177,11 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
registry-1.docker.io:443
auth.docker.io:443
github.com:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
github.com:443
registry-1.docker.io:443
release-assets.githubusercontent.com:443
- name: Login to DockerHub
@@ -187,9 +195,9 @@ jobs:
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -197,10 +205,10 @@ jobs:
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG} \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG}" \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -240,9 +248,9 @@ jobs:
id: outcome
run: |
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
echo "outcome=success" >> $GITHUB_OUTPUT
echo "outcome=success" >> "$GITHUB_OUTPUT"
else
echo "outcome=failure" >> $GITHUB_OUTPUT
echo "outcome=failure" >> "$GITHUB_OUTPUT"
fi
env:
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
+19 -2
View File
@@ -81,12 +81,17 @@ jobs:
pkg-containers.githubusercontent.com:443
files.pythonhosted.org:443
pypi.org:443
dl-cdn.alpinelinux.org:443
dualstack.j.sni.global.fastly.net:443
api.github.com:443
mirror.gcr.io:443
check.trivy.dev:443
get.trivy.dev:443
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
raw.githubusercontent.com:443
grype.anchore.io:443
get.anchore.io:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -100,7 +105,11 @@ jobs:
with:
files: |
mcp_server/**
.trivyignore.yaml
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
.grype.yaml
.github/scripts/grype-pr-comment.js
files_ignore: |
mcp_server/README.md
mcp_server/CHANGELOG.md
@@ -127,5 +136,13 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'true'
severity: 'CRITICAL'
fail-on-severity: 'high'
severity: 'CRITICAL,HIGH'
- name: Scan MCP container with Grype
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/grype-scan
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
+1 -1
View File
@@ -113,7 +113,7 @@ jobs:
- name: Publish prowler-mcp package to PyPI
if: steps.pypi-check.outputs.skip != 'true'
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
packages-dir: ${{ env.WORKING_DIRECTORY }}/dist/
print-hash: true
+99
View File
@@ -0,0 +1,99 @@
name: 'MCP: Tests'
on:
push:
branches:
- 'master'
- 'v5.*'
pull_request:
branches:
- 'master'
- 'v5.*'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
MCP_WORKING_DIR: ./mcp_server
permissions: {}
jobs:
mcp-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
strategy:
matrix:
# requires-python is >=3.12 while the shipped image is 3.13; testing both
# is what keeps that floor honest.
python-version:
- '3.12'
- '3.13'
defaults:
run:
working-directory: ./mcp_server
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
# hub.prowler.com and raw.githubusercontent.com are deliberately absent:
# the suite mocks every outbound call, so a real one must fail the job.
# The sentry.io entry is not the test suite: the Codecov uploader sends
# its own telemetry there, so api-tests.yml and sdk-tests.yml allow it too.
allowed-endpoints: >
github.com:443
pypi.org:443
files.pythonhosted.org:443
cli.codecov.io:443
keybase.io:443
ingest.codecov.io:443
o26192.ingest.us.sentry.io:443
storage.googleapis.com:443
api.github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# zizmor: ignore[artipacked]
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
- name: Check for MCP server changes
id: check-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: |
mcp_server/**
.github/workflows/mcp-tests.yml
codecov.yml
files_ignore: |
mcp_server/README.md
mcp_server/CHANGELOG.md
mcp_server/changelog.d/**
mcp_server/AGENTS.md
mcp_server/Dockerfile
mcp_server/.dockerignore
mcp_server/entrypoint.sh
- name: Setup Python with uv
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ matrix.python-version }}
working-directory: ./mcp_server
- name: Run tests with pytest
if: steps.check-changes.outputs.any_changed == 'true'
run: uv run pytest --cov=./prowler_mcp_server --cov-report=xml tests
- name: Upload coverage reports to Codecov
if: steps.check-changes.outputs.any_changed == 'true'
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
flags: mcp
+1 -1
View File
@@ -35,6 +35,7 @@ jobs:
api.github.com:443
github.com:443
objects.githubusercontent.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
@@ -129,7 +130,6 @@ jobs:
handwritten_changelogs=""
all_changed=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n')
added=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" | tr ' ' '\n')
added_or_renamed=$(printf '%s\n%s' "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_RENAMED_FILES}" | tr ' ' '\n')
added_modified_or_renamed=$(printf '%s\n%s\n%s' "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_MODIFIED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_RENAMED_FILES}" | tr ' ' '\n')
@@ -111,7 +111,7 @@ jobs:
done
if [ -n "$found_in" ]; then
found_in=$(echo "$found_in" | sed 's/, $//')
found_in="${found_in%, }"
MAPPED="${MAPPED}- \`${check_id}\` (\`${provider}\`): ${found_in}"$'\n'
else
UNMAPPED="${UNMAPPED}- \`${check_id}\` (\`${provider}\`)"$'\n'
+3 -3
View File
@@ -74,15 +74,15 @@ jobs:
done <<< "$STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES"
if [ "$HAS_CONFLICTS" = true ]; then
echo "has_conflicts=true" >> $GITHUB_OUTPUT
echo "has_conflicts=true" >> "$GITHUB_OUTPUT"
{
echo "conflict_files<<EOF"
echo "$CONFLICT_FILES"
echo "EOF"
} >> $GITHUB_OUTPUT
} >> "$GITHUB_OUTPUT"
echo "Conflict markers detected"
else
echo "has_conflicts=false" >> $GITHUB_OUTPUT
echo "has_conflicts=false" >> "$GITHUB_OUTPUT"
echo "No conflict markers found in changed files"
fi
env:
+5 -4
View File
@@ -36,7 +36,7 @@ jobs:
id: vars
run: |
SHORT_SHA="${GITHUB_EVENT_PULL_REQUEST_MERGE_COMMIT_SHA}"
echo "short_sha=${SHORT_SHA::7}" >> $GITHUB_OUTPUT
echo "short_sha=${SHORT_SHA::7}" >> "$GITHUB_OUTPUT"
env:
GITHUB_EVENT_PULL_REQUEST_MERGE_COMMIT_SHA: ${{ github.event.pull_request.merge_commit_sha }}
@@ -46,6 +46,7 @@ jobs:
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
repository: ${{ secrets.CLOUD_DISPATCH }}
event-type: prowler-pull-request-merged
# repository_dispatch caps client_payload at 10 properties; this is exactly at the cap.
client-payload: |
{
"PROWLER_COMMIT_SHA": "${{ github.event.pull_request.merge_commit_sha }}",
@@ -54,8 +55,8 @@ jobs:
"PROWLER_PR_TITLE": ${{ toJson(github.event.pull_request.title) }},
"PROWLER_PR_LABELS": ${{ toJson(github.event.pull_request.labels.*.name) }},
"PROWLER_PR_BODY": ${{ toJson(github.event.pull_request.body) }},
"PROWLER_PR_URL": ${{ toJson(github.event.pull_request.html_url) }},
"PROWLER_PR_MERGED_BY": "${{ github.event.pull_request.merged_by.login }}",
"PROWLER_PR_BASE_BRANCH": ${{ toJson(github.event.pull_request.base.ref) }},
"PROWLER_PR_HEAD_BRANCH": ${{ toJson(github.event.pull_request.head.ref) }}
"PROWLER_PR_STACK_NUMBER": "${{ github.event.pull_request.stack.number }}",
"PROWLER_PR_STACK_POSITION": "${{ github.event.pull_request.stack.position }}",
"PROWLER_PR_STACK_SIZE": "${{ github.event.pull_request.stack.size }}"
}
+12 -11
View File
@@ -77,7 +77,7 @@ jobs:
echo "Prowler version: $PROWLER_VERSION"
echo "Branch name: $BRANCH_NAME"
echo "Is minor release: $([ $PATCH_VERSION -eq 0 ] && echo 'true' || echo 'false')"
echo "Is minor release: $([ "$PATCH_VERSION" -eq 0 ] && echo 'true' || echo 'false')"
else
echo "Invalid version syntax: '$PROWLER_VERSION' (must be N.N.N)" >&2
exit 1
@@ -107,7 +107,8 @@ jobs:
if [ -f "$changelog_file" ]; then
# Extract version that matches this Prowler release
# Format: ## [version] (Prowler X.Y.Z) or ## [vversion] (Prowler vX.Y.Z)
local version=$(grep '^## \[' "$changelog_file" | grep "(Prowler v\?${prowler_version})" | head -1 | sed 's/^## \[\(.*\)\].*/\1/' | sed 's/^v//' | tr -d '[:space:]')
local version
version=$(grep '^## \[' "$changelog_file" | grep "(Prowler v\?${prowler_version})" | head -1 | sed 's/^## \[\(.*\)\].*/\1/' | sed 's/^v//' | tr -d '[:space:]')
echo "$version"
else
echo ""
@@ -178,55 +179,55 @@ jobs:
# Determine if components have changes for this specific release
if [ -n "$SDK_VERSION" ]; then
echo "HAS_SDK_CHANGES=true" >> $GITHUB_ENV
echo "HAS_SDK_CHANGES=true" >> "$GITHUB_ENV"
HAS_SDK_CHANGES="true"
echo "✓ SDK changes detected - version: $SDK_VERSION"
extract_changelog "prowler/CHANGELOG.md" "$SDK_VERSION" "prowler_changelog.md"
else
echo "HAS_SDK_CHANGES=false" >> $GITHUB_ENV
echo "HAS_SDK_CHANGES=false" >> "$GITHUB_ENV"
HAS_SDK_CHANGES="false"
echo "ℹ No SDK changes for this release"
touch "prowler_changelog.md"
fi
if [ -n "$API_VERSION" ]; then
echo "HAS_API_CHANGES=true" >> $GITHUB_ENV
echo "HAS_API_CHANGES=true" >> "$GITHUB_ENV"
HAS_API_CHANGES="true"
echo "✓ API changes detected - version: $API_VERSION"
extract_changelog "api/CHANGELOG.md" "$API_VERSION" "api_changelog.md"
else
echo "HAS_API_CHANGES=false" >> $GITHUB_ENV
echo "HAS_API_CHANGES=false" >> "$GITHUB_ENV"
HAS_API_CHANGES="false"
echo "ℹ No API changes for this release"
touch "api_changelog.md"
fi
if [ -n "$UI_VERSION" ]; then
echo "HAS_UI_CHANGES=true" >> $GITHUB_ENV
echo "HAS_UI_CHANGES=true" >> "$GITHUB_ENV"
HAS_UI_CHANGES="true"
echo "✓ UI changes detected - version: $UI_VERSION"
extract_changelog "ui/CHANGELOG.md" "$UI_VERSION" "ui_changelog.md"
else
echo "HAS_UI_CHANGES=false" >> $GITHUB_ENV
echo "HAS_UI_CHANGES=false" >> "$GITHUB_ENV"
HAS_UI_CHANGES="false"
echo "ℹ No UI changes for this release"
touch "ui_changelog.md"
fi
if [ -n "$MCP_VERSION" ]; then
echo "HAS_MCP_CHANGES=true" >> $GITHUB_ENV
echo "HAS_MCP_CHANGES=true" >> "$GITHUB_ENV"
HAS_MCP_CHANGES="true"
echo "✓ MCP changes detected - version: $MCP_VERSION"
extract_changelog "mcp_server/CHANGELOG.md" "$MCP_VERSION" "mcp_changelog.md"
else
echo "HAS_MCP_CHANGES=false" >> $GITHUB_ENV
echo "HAS_MCP_CHANGES=false" >> "$GITHUB_ENV"
HAS_MCP_CHANGES="false"
echo "ℹ No MCP changes for this release"
touch "mcp_changelog.md"
fi
# Combine changelogs in order: UI, API, SDK, MCP
> combined_changelog.md
: > combined_changelog.md
if [ "$HAS_UI_CHANGES" = "true" ] && [ -s "ui_changelog.md" ]; then
echo "## UI" >> combined_changelog.md
+44 -31
View File
@@ -54,6 +54,7 @@ jobs:
timeout-minutes: 5
outputs:
prowler_version: ${{ steps.get-prowler-version.outputs.prowler_version }}
created: ${{ steps.get-prowler-version.outputs.created }}
latest_tag: ${{ steps.get-prowler-version.outputs.latest_tag }}
stable_tag: ${{ steps.get-prowler-version.outputs.stable_tag }}
permissions:
@@ -64,9 +65,9 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
files.pythonhosted.org:443
github.com:443
pypi.org:443
files.pythonhosted.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -86,6 +87,7 @@ jobs:
fi
echo "latest_tag=latest" >> "${GITHUB_OUTPUT}"
echo "stable_tag=stable" >> "${GITHUB_OUTPUT}"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
notify-release-started:
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
@@ -146,24 +148,24 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
_http._tcp.deb.debian.org:443
aka.ms:443
api.ecr-public.us-east-1.amazonaws.com:443
public.ecr.aws:443
sts.amazonaws.com:443
sts.us-east-1.amazonaws.com:443
registry-1.docker.io:443
auth.docker.io:443
cdn.powershellgallery.com:443
debian.map.fastlydns.net:80
files.pythonhosted.org:443
github.com:443
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
auth.docker.io:443
debian.map.fastlydns.net:80
github.com:443
release-assets.githubusercontent.com:443
public.ecr.aws:443
pypi.org:443
files.pythonhosted.org:443
registry-1.docker.io:443
release-assets.githubusercontent.com:443
sts.amazonaws.com:443
sts.us-east-1.amazonaws.com:443
www.powershellgallery.com:443
aka.ms:443
cdn.powershellgallery.com:443
_http._tcp.deb.debian.org:443
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -198,9 +200,20 @@ jobs:
context: .
file: ${{ env.DOCKERFILE_PATH }}
push: true
sbom: true
# max, not the default min: min records little beyond the build ref.
provenance: mode=max
platforms: ${{ matrix.platform }}
tags: |
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-${{ matrix.arch }}
labels: |
org.opencontainers.image.title=Prowler CLI
org.opencontainers.image.description=Open Source security tool for cloud security assessments, audits, incident response, continuous monitoring, hardening and forensics readiness
org.opencontainers.image.vendor=ProwlerPro, Inc.
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
org.opencontainers.image.version=${{ needs.setup.outputs.prowler_version }}
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
@@ -219,14 +232,14 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
registry-1.docker.io:443
api.ecr-public.us-east-1.amazonaws.com:443
auth.docker.io:443
public.ecr.aws:443
github.com:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
github.com:443
public.ecr.aws:443
registry-1.docker.io:443
release-assets.githubusercontent.com:443
api.ecr-public.us-east-1.amazonaws.com:443
sts.amazonaws.com:443
sts.us-east-1.amazonaws.com:443
@@ -252,10 +265,10 @@ jobs:
if: github.event_name == 'push'
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG} \
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}" \
-t "${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64"
env:
NEEDS_SETUP_OUTPUTS_LATEST_TAG: ${{ needs.setup.outputs.latest_tag }}
@@ -263,12 +276,12 @@ jobs:
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG} \
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION}" \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG}" \
-t "${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION}" \
-t "${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64"
env:
NEEDS_SETUP_OUTPUTS_PROWLER_VERSION: ${{ needs.setup.outputs.prowler_version }}
NEEDS_SETUP_OUTPUTS_STABLE_TAG: ${{ needs.setup.outputs.stable_tag }}
@@ -293,7 +306,7 @@ jobs:
if: needs.setup.outputs.latest_tag == 'latest' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
run: |
docker buildx imagetools create \
-t ${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
-t "${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION}" \
-t ${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:stable \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:stable
env:
@@ -343,9 +356,9 @@ jobs:
id: outcome
run: |
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
echo "outcome=success" >> $GITHUB_OUTPUT
echo "outcome=success" >> "$GITHUB_OUTPUT"
else
echo "outcome=failure" >> $GITHUB_OUTPUT
echo "outcome=failure" >> "$GITHUB_OUTPUT"
fi
env:
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
+19 -2
View File
@@ -10,6 +10,7 @@ on:
- 'Dockerfile*'
- 'pyproject.toml'
- 'uv.lock'
- '.trivyignore.yaml'
- '.github/workflows/sdk-container-checks.yml'
pull_request:
branches:
@@ -83,6 +84,10 @@ jobs:
api.github.com:443
mirror.gcr.io:443
check.trivy.dev:443
raw.githubusercontent.com:443
objects.githubusercontent.com:443
grype.anchore.io:443
get.anchore.io:443
debian.map.fastlydns.net:80
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
@@ -112,8 +117,12 @@ jobs:
Dockerfile*
pyproject.toml
uv.lock
.trivyignore.yaml
.github/workflows/sdk-container-checks.yml
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
.grype.yaml
.github/scripts/grype-pr-comment.js
files_ignore: |
prowler/CHANGELOG.md
prowler/changelog.d/**
@@ -140,5 +149,13 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'true'
severity: 'CRITICAL'
fail-on-severity: 'high'
severity: 'CRITICAL,HIGH'
- name: Scan SDK container with Grype
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/grype-scan
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
+196
View File
@@ -0,0 +1,196 @@
name: 'SDK: Package Checks'
# Rehearses the PyPI release on every packaging change and once a week, from the
# consumer's side. Two incidents this guards against:
#
# - 5.38.0 shipped an unsatisfiable Requires-Dist (cryptography==50.0.0 while
# alibabacloud-tea-openapi and pyopenssl cap it below 49). A [tool.uv] override hid
# the conflict inside the repo; pip could not install the wheel and silently
# resolved `pip install prowler` to 5.37.1 for a week.
# - 5.39.0 never published: an unpinned build backend started emitting core metadata
# 2.5 and the twine bundled in the publish action rejected it.
#
# Both were only detectable at release time because nothing built and installed the
# artifact earlier. The weekly run also catches releases yanked from PyPI after we
# pinned them (zstd 1.5.7.3, "buggy - not thread safe", sat in uv.lock for months).
on:
push:
branches:
- 'master'
- 'v5.*'
pull_request:
branches:
- 'master'
- 'v5.*'
schedule:
# Monday 06:00 UTC. Yanks and upstream releases happen without a commit here.
- cron: '0 6 * * 1'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
env:
# Must equal the twine bundled in the pypa/gh-action-pypi-publish pin used by
# sdk-pypi-release.yml (requirements/runtime.txt in that repo at the pinned tag).
# A metadata check that passes here must pass there.
TWINE_VERSION: '7.0.0'
jobs:
changes:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
# Scheduled and manual runs always execute; pushes and PRs only when a packaging
# input changed. Jobs skipped this way still report success to branch protection.
run: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || steps.filter.outputs.any_changed == 'true' }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
- name: Checkout repository
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# zizmor: ignore[artipacked]
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
- name: Detect packaging changes
if: github.event_name == 'push' || github.event_name == 'pull_request'
id: filter
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: |
pyproject.toml
uv.lock
README.md
util/replicate_pypi_package.py
util/check_yanked_pins.py
api/pyproject.toml
api/uv.lock
mcp_server/pyproject.toml
mcp_server/uv.lock
.github/workflows/sdk-package-checks.yml
.github/workflows/sdk-pypi-release.yml
.github/actions/setup-python-uv/**
install-from-wheel:
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
strategy:
fail-fast: false
matrix:
python-version:
- '3.10'
- '3.11'
- '3.12'
- '3.13'
package:
- 'prowler'
include:
# prowler-cloud is the same tree renamed by util/replicate_pypi_package.py;
# one Python is enough to prove the rename and its build still work.
- python-version: '3.12'
package: 'prowler-cloud'
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Python with uv
uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ matrix.python-version }}
install-dependencies: 'false'
- name: Rename package to prowler-cloud
if: matrix.package == 'prowler-cloud'
run: |
pip install --no-cache-dir toml
python util/replicate_pypi_package.py
- name: Build sdist and wheel
run: uv build
- name: Check metadata with the release workflow's twine
run: uvx --from "twine==${TWINE_VERSION}" twine check --strict dist/*
- name: Install the wheel with pip into a clean virtualenv
# Plain pip, --isolated, from outside the repo: consumers never see [tool.uv]
# override-dependencies or constraint-dependencies, so neither does this step.
run: |
python -m venv "${RUNNER_TEMP}/consumer"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir "${GITHUB_WORKSPACE}"/dist/*.whl
- name: Smoke test the installed CLI
run: |
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/prowler" --version
# Loads every AWS check module from the installed wheel: catches files missing
# from the package. grep fails the step if the summary line never appears.
"${RUNNER_TEMP}/consumer/bin/prowler" aws --list-checks | grep 'available checks'
pinned-releases-not-yanked:
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: '3.12'
- name: Check every pinned and locked release against PyPI
run: python util/check_yanked_pins.py . api mcp_server
+22 -2
View File
@@ -84,8 +84,18 @@ jobs:
- name: Build Prowler package
run: uv build
- name: Verify the wheel installs with pip
# Same check as "SDK: Package Checks", repeated on the exact artifact about to be
# published. Plain pip, --isolated, from outside the repo: an unsatisfiable
# Requires-Dist fails here instead of on users' machines (5.38.0 shipped one).
run: |
python -m venv "${RUNNER_TEMP}/consumer"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl
- name: Publish Prowler package to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
print-hash: true
@@ -128,7 +138,17 @@ jobs:
- name: Build prowler-cloud package
run: uv build
- name: Verify the wheel installs with pip
# Same check as "SDK: Package Checks", repeated on the exact artifact about to be
# published. Plain pip, --isolated, from outside the repo: an unsatisfiable
# Requires-Dist fails here instead of on users' machines (5.38.0 shipped one).
run: |
python -m venv "${RUNNER_TEMP}/consumer"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl
- name: Publish prowler-cloud package to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
print-hash: true
@@ -44,7 +44,10 @@ jobs:
cache: 'pip'
- name: Install dependencies
run: pip install boto3
# Pinned to the versions in pyproject.toml: the ISO partitions region
# data comes from the endpoints.json bundled with botocore, so the
# botocore version is itself a data source and must be deterministic
run: pip install boto3==1.40.61 botocore==1.40.61
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
@@ -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
+2 -1
View File
@@ -216,7 +216,8 @@ jobs:
elif [ -z "${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}" ]; then
echo "No AWS service paths detected; skipping AWS tests."
else
uv run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml ${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}
read -ra service_paths <<< "${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}"
uv run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml "${service_paths[@]}"
fi
env:
STEPS_AWS_SERVICES_OUTPUTS_RUN_ALL: ${{ steps.aws-services.outputs.run_all }}
+21 -19
View File
@@ -57,6 +57,7 @@ jobs:
egress-policy: block
allowed-endpoints: >
github.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
@@ -84,7 +85,8 @@ jobs:
echo "Changed files:"
echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n'
echo ""
python .github/scripts/test-impact.py ${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}
read -ra changed <<< "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}"
python .github/scripts/test-impact.py "${changed[@]}"
env:
STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
@@ -92,21 +94,21 @@ jobs:
id: set-flags
run: |
if [[ -n "${STEPS_IMPACT_OUTPUTS_SDK_TESTS}" ]]; then
echo "has-sdk-tests=true" >> $GITHUB_OUTPUT
echo "has-sdk-tests=true" >> "$GITHUB_OUTPUT"
else
echo "has-sdk-tests=false" >> $GITHUB_OUTPUT
echo "has-sdk-tests=false" >> "$GITHUB_OUTPUT"
fi
if [[ -n "${STEPS_IMPACT_OUTPUTS_API_TESTS}" ]]; then
echo "has-api-tests=true" >> $GITHUB_OUTPUT
echo "has-api-tests=true" >> "$GITHUB_OUTPUT"
else
echo "has-api-tests=false" >> $GITHUB_OUTPUT
echo "has-api-tests=false" >> "$GITHUB_OUTPUT"
fi
if [[ -n "${STEPS_IMPACT_OUTPUTS_UI_E2E}" ]]; then
echo "has-ui-e2e=true" >> $GITHUB_OUTPUT
echo "has-ui-e2e=true" >> "$GITHUB_OUTPUT"
else
echo "has-ui-e2e=false" >> $GITHUB_OUTPUT
echo "has-ui-e2e=false" >> "$GITHUB_OUTPUT"
fi
env:
STEPS_IMPACT_OUTPUTS_SDK_TESTS: ${{ steps.impact.outputs.sdk-tests }}
@@ -115,22 +117,22 @@ jobs:
- name: Summary
run: |
echo "## Test Impact Analysis" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Test Impact Analysis" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [[ "${STEPS_IMPACT_OUTPUTS_RUN_ALL}" == "true" ]]; then
echo "🚨 **Critical path changed - running ALL tests**" >> $GITHUB_STEP_SUMMARY
echo "🚨 **Critical path changed - running ALL tests**" >> "$GITHUB_STEP_SUMMARY"
else
echo "### Affected Modules" >> $GITHUB_STEP_SUMMARY
echo "\`${STEPS_IMPACT_OUTPUTS_MODULES}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Affected Modules" >> "$GITHUB_STEP_SUMMARY"
echo "\`${STEPS_IMPACT_OUTPUTS_MODULES}\`" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### Tests to Run" >> $GITHUB_STEP_SUMMARY
echo "| Category | Paths |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| SDK Tests | \`${STEPS_IMPACT_OUTPUTS_SDK_TESTS:-none}\` |" >> $GITHUB_STEP_SUMMARY
echo "| API Tests | \`${STEPS_IMPACT_OUTPUTS_API_TESTS:-none}\` |" >> $GITHUB_STEP_SUMMARY
echo "| UI E2E | \`${STEPS_IMPACT_OUTPUTS_UI_E2E:-none}\` |" >> $GITHUB_STEP_SUMMARY
echo "### Tests to Run" >> "$GITHUB_STEP_SUMMARY"
echo "| Category | Paths |" >> "$GITHUB_STEP_SUMMARY"
echo "|----------|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| SDK Tests | \`${STEPS_IMPACT_OUTPUTS_SDK_TESTS:-none}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| API Tests | \`${STEPS_IMPACT_OUTPUTS_API_TESTS:-none}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| UI E2E | \`${STEPS_IMPACT_OUTPUTS_UI_E2E:-none}\` |" >> "$GITHUB_STEP_SUMMARY"
fi
env:
+32 -18
View File
@@ -41,17 +41,20 @@ jobs:
timeout-minutes: 5
outputs:
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
created: ${{ steps.set-short-sha.outputs.created }}
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
egress-policy: block
- name: Calculate short SHA
id: set-short-sha
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
run: |
echo "short-sha=${GITHUB_SHA::7}" >> "${GITHUB_OUTPUT}"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
notify-release-started:
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
@@ -111,15 +114,15 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
registry-1.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
auth.docker.io:443
registry.npmjs.org:443
dl-cdn.alpinelinux.org:443
fonts.googleapis.com:443
fonts.gstatic.com:443
github.com:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
registry-1.docker.io:443
registry.npmjs.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -144,9 +147,20 @@ jobs:
build-args: |
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('v{0}', env.RELEASE_TAG) || needs.setup.outputs.short-sha }}
push: true
sbom: true
# max, not the default min: min records little beyond the build ref.
provenance: mode=max
platforms: ${{ matrix.platform }}
tags: |
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }}
labels: |
org.opencontainers.image.title=Prowler Local Server UI
org.opencontainers.image.description=Web UI for Prowler Local Server (Next.js)
org.opencontainers.image.vendor=ProwlerPro, Inc.
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
@@ -164,12 +178,12 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
github.com:443
release-assets.githubusercontent.com:443
registry-1.docker.io:443
auth.docker.io:443
github.com:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
registry-1.docker.io:443
release-assets.githubusercontent.com:443
- name: Login to DockerHub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
@@ -182,9 +196,9 @@ jobs:
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -192,10 +206,10 @@ jobs:
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG} \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG}" \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -235,9 +249,9 @@ jobs:
id: outcome
run: |
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
echo "outcome=success" >> $GITHUB_OUTPUT
echo "outcome=success" >> "$GITHUB_OUTPUT"
else
echo "outcome=failure" >> $GITHUB_OUTPUT
echo "outcome=failure" >> "$GITHUB_OUTPUT"
fi
env:
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
+17 -2
View File
@@ -88,6 +88,9 @@ jobs:
get.trivy.dev:443
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
raw.githubusercontent.com:443
grype.anchore.io:443
get.anchore.io:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -101,7 +104,11 @@ jobs:
with:
files: |
ui/**
.trivyignore.yaml
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
.grype.yaml
.github/scripts/grype-pr-comment.js
files_ignore: |
ui/CHANGELOG.md
ui/changelog.d/**
@@ -132,5 +139,13 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-critical: 'true'
severity: 'CRITICAL'
fail-on-severity: 'high'
severity: 'CRITICAL,HIGH'
- name: Scan UI container with Grype
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/grype-scan
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
+59 -58
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 }}
@@ -40,11 +41,11 @@ jobs:
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
runs-on: ubuntu-latest
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 +61,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 }}
@@ -108,14 +109,14 @@ jobs:
- name: Show test scope
run: |
echo "## E2E Test Scope" >> $GITHUB_STEP_SUMMARY
echo "## E2E Test Scope" >> "$GITHUB_STEP_SUMMARY"
if [[ "${RUN_ALL_TESTS}" == "true" ]]; then
echo "Running **ALL** E2E tests (critical path changed)" >> $GITHUB_STEP_SUMMARY
echo "Running **ALL** E2E tests (critical path changed)" >> "$GITHUB_STEP_SUMMARY"
else
echo "Running tests matching: \`${E2E_TEST_PATHS}\`" >> $GITHUB_STEP_SUMMARY
echo "Running tests matching: \`${E2E_TEST_PATHS}\`" >> "$GITHUB_STEP_SUMMARY"
fi
echo ""
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> $GITHUB_STEP_SUMMARY
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> "$GITHUB_STEP_SUMMARY"
env:
NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES: ${{ needs.impact-analysis.outputs.modules }}
@@ -233,12 +234,12 @@ jobs:
yq -i '.services.worker.networks = ["kind","default"]' docker-compose.yml
- name: Fix API data directory permissions
run: docker run --rm -v $(pwd)/_data/api:/data alpine chown -R 1000:1000 /data
run: docker run --rm -v "$(pwd)/_data/api:/data" alpine chown -R 1000:1000 /data
- name: Add AWS credentials for testing
run: |
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,
@@ -267,7 +268,7 @@ jobs:
timeout=150
elapsed=0
while [ $elapsed -lt $timeout ]; do
if curl -s ${UI_API_BASE_URL}/docs >/dev/null 2>&1; then
if curl -s "${UI_API_BASE_URL}/docs" >/dev/null 2>&1; then
echo "Prowler API is ready!"
exit 0
fi
@@ -292,7 +293,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
@@ -301,7 +302,7 @@ jobs:
run_install: false
- name: Get pnpm store directory
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV"
- name: Setup pnpm and Next.js cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -337,59 +338,59 @@ 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"
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
- name: Upload test reports
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: failure()
@@ -444,12 +445,12 @@ jobs:
- name: No E2E tests needed
run: |
echo "## E2E Tests Skipped" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "No UI E2E tests needed for this change." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "To run all tests, modify a file in a critical path (e.g., \`ui/lib/**\`)." >> $GITHUB_STEP_SUMMARY
echo "## E2E Tests Skipped" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "No UI E2E tests needed for this change." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "To run all tests, modify a file in a critical path (e.g., \`ui/lib/**\`)." >> "$GITHUB_STEP_SUMMARY"
env:
NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES: ${{ needs.impact-analysis.outputs.modules }}
+3 -2
View File
@@ -113,7 +113,7 @@ jobs:
- name: Get pnpm store directory
if: steps.check-changes.outputs.any_changed == 'true'
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV"
- name: Setup pnpm and Next.js cache
if: steps.check-changes.outputs.any_changed == 'true'
@@ -157,7 +157,8 @@ jobs:
echo "${STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES}"
# Convert space-separated to vitest related format (remove ui/ prefix for relative paths)
CHANGED_FILES=$(echo "${STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | sed 's|^ui/||' | tr '\n' ' ')
pnpm exec vitest related $CHANGED_FILES --run --project unit
read -ra changed <<< "$CHANGED_FILES"
pnpm exec vitest related "${changed[@]}" --run --project unit
env:
STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-source.outputs.all_changed_files }}
+128
View File
@@ -0,0 +1,128 @@
# Findings excluded from the Grype gate, each with a reason.
# Anything not listed here blocks the pull request at critical or high severity.
# Pairs are explicit: a new CVE against an already-listed package still blocks.
#
# Every entry below has a published fix we cannot take. Findings with no fix at all are
# not listed: the scan runs with only-fixed, so they never reach the gate.
ignore:
# Modules compiled into the Trivy binary we ship.
# Only a Trivy rebuild by its vendor can change these; the version is pinned in our Dockerfile.
# CVE-2026-71556 is the same temporary exception documented in .trivyignore.yaml:
# Trivy 0.73.0 still embeds go-git 5.19.1, while the 5.19.2 fix is merged only on
# Trivy main. Remove this entry with the Trivy exception by 2026-09-15.
# https://github.com/aquasecurity/trivy/blob/v0.73.0/go.mod#L46
# https://github.com/aquasecurity/trivy/commit/a2edba9a03987ba0d2ebc8212c1a9a1e6979497b
- vulnerability: CVE-2026-71556
package:
name: github.com/go-git/go-git/v5
# 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
# The .NET runtime bundled inside the PowerShell tarball the Dockerfile pins.
# CVE-2026-62901 is the same temporary exception documented in .trivyignore.yaml:
# fixed in .NET 9.0.19 / 10.0.11 (2026-08-11), but no published PowerShell release
# ships a patched runtime yet (7.5.9 bundles 9.0.18; 7.6.4 bundles 10.0.x < 10.0.11).
# pwsh runs only local M365 module cmdlets; nothing listens for inbound WebSocket
# connections. Remove with the Trivy exception by 2026-09-15.
- vulnerability: CVE-2026-62901
package:
name: Microsoft.NETCore.App.Runtime.linux-x64
- vulnerability: CVE-2026-62901
package:
name: Microsoft.NETCore.App.Runtime.linux-arm64
# The CPython interpreter, compiled into the official base image.
# TEMPORARY, unlike the entries above: moving to Python 3.13 clears seven of these, and
# that is a runtime upgrade pending its own evaluation. The remaining three need 3.15 and
# are unfixable either way -- the MCP image already runs 3.13.14 and still reports them.
- vulnerability: CVE-2026-11940
package:
name: python
- vulnerability: CVE-2026-11972
package:
name: python
- vulnerability: CVE-2026-15308
package:
name: python
- vulnerability: CVE-2026-3298
package:
name: python
- vulnerability: CVE-2026-3644
package:
name: python
- vulnerability: CVE-2026-4224
package:
name: python
- vulnerability: CVE-2026-4786
package:
name: python
- vulnerability: CVE-2026-6100
package:
name: python
- vulnerability: CVE-2026-7210
package:
name: python
- vulnerability: CVE-2026-9669
package:
name: python
# CVE-2026-82049 (tarfile data/tar filter bypass via a hard link to a symlink) has no
# fixed CPython release on any branch: the fix is merged on main and 3.13 only, and the
# 3.12 backport is still open. Grype records 3.14.0b1 as the fix, so only-fixed does not
# drop it, yet python:3.12.14-slim-trixie reports it too. Prowler never extracts tar
# archives to disk: the ECR image inspection reads members in memory with extractfile().
# Remove once the base image ships a 3.12 release that includes the backport.
# https://github.com/python/cpython/issues/157190
# https://github.com/python/cpython/pull/157454
- vulnerability: CVE-2026-82049
package:
name: python
+8
View File
@@ -47,6 +47,14 @@ repos:
priority: 20
## GITHUB ACTIONS
- repo: https://github.com/rhysd/actionlint
rev: v1.7.12
hooks:
- id: actionlint
# SC2129 only suggests grouping consecutive redirects; not worth restructuring for.
args: ['-shellcheck=-e SC2129']
priority: 30
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.24.1
hooks:
-123
View File
@@ -1,123 +0,0 @@
# Trivy ignore file for prowlercloud/prowler SDK container image.
# Each entry below documents (a) the affected package and why it ships in the
# image, (b) why the CVE is not exploitable in Prowler's runtime, and (c) the
# upstream fix status. Entries carry an expiry so they auto-force re-review.
# Entries are scoped per-package so suppressions cannot drift onto unrelated
# packages that may be assigned the same CVE in the future.
#
# Scanned by: .github/actions/trivy-scan via .github/workflows/sdk-container-checks.yml
# CVE-2026-42496 — perl-archive-tar path traversal via crafted symlinks.
# CVE-2026-8376 — perl heap buffer overflow when compiling regex.
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be
# removed without breaking dpkg. The Prowler SDK does not invoke perl at runtime;
# neither vulnerable code path (Archive::Tar parsing or regex compilation of
# attacker-controlled input) is reachable from Prowler. No Debian bookworm fix
# is available yet.
CVE-2026-42496 pkg:perl exp:2026-08-15
CVE-2026-42496 pkg:perl-base exp:2026-08-15
CVE-2026-42496 pkg:perl-modules-5.36 exp:2026-08-15
CVE-2026-42496 pkg:libperl5.36 exp:2026-08-15
CVE-2026-8376 pkg:perl exp:2026-08-15
CVE-2026-8376 pkg:perl-base exp:2026-08-15
CVE-2026-8376 pkg:perl-modules-5.36 exp:2026-08-15
CVE-2026-8376 pkg:libperl5.36 exp:2026-08-15
# CVE-2026-13221 - Perl regex trie overflow.
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
# Why ignored: upstream confirms Perl 5.36.0 is not affected; the regression
# was introduced after this version. Debian currently marks bookworm as
# vulnerable, which causes Trivy to report a false positive.
# Ref: https://github.com/Perl/perl5/issues/23388
CVE-2026-13221 pkg:perl exp:2026-08-15
CVE-2026-13221 pkg:perl-base exp:2026-08-15
CVE-2026-13221 pkg:perl-modules-5.36 exp:2026-08-15
CVE-2026-13221 pkg:libperl5.36 exp:2026-08-15
# CVE-2026-57433 — Perl Storable signed integer overflow when deserializing a
# crafted SX_HOOK record (retrieve_hook_common passes a wrapped negative count
# to av_extend).
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be
# removed without breaking dpkg. Prowler does not invoke perl at runtime and
# never calls Storable's thaw/retrieve on attacker-controlled blobs, so the
# vulnerable deserialization path is unreachable. Fixed upstream in
# Storable 3.41; no Debian bookworm fix is available yet.
CVE-2026-57433 pkg:perl exp:2026-08-15
CVE-2026-57433 pkg:perl-base exp:2026-08-15
CVE-2026-57433 pkg:perl-modules-5.36 exp:2026-08-15
CVE-2026-57433 pkg:libperl5.36 exp:2026-08-15
# CVE-2025-7458 — SQLite integer overflow.
# Package: libsqlite3-0.
# Why ignored: transitive dependency of CPython's stdlib sqlite3 module. The
# Prowler SDK does not open user-supplied SQLite databases; SQLite usage is
# internal and bounded. No Debian bookworm fix is available.
CVE-2025-7458 pkg:libsqlite3-0 exp:2026-08-15
# CVE-2026-43185 — Linux kernel ksmbd signedness bug.
# Package: linux-libc-dev.
# Why ignored: linux-libc-dev ships kernel headers for build-time compilation,
# not a running kernel. Containers execute against the host kernel, so these
# headers are inert at runtime. The upstream fix landed in kernel 7.0-rc2 and
# has not been backported to Debian's 6.1 LTS line.
CVE-2026-43185 pkg:linux-libc-dev exp:2026-08-15
# CVE-2023-45853 — zlib MiniZip integer overflow / heap overflow in
# zipOpenNewFileInZip4_64.
# Packages: zlib1g, zlib1g-dev.
# Why ignored: Debian Security Tracker status for bookworm is <ignored>, with
# the published rationale "contrib/minizip not built and src:zlib not producing
# binary packages" — i.e. the vulnerable symbol is not present in the libz.so
# shipped by Debian. Real-not-affected, not unpatched. Upstream fix is in
# zlib 1.3.1, available in Debian trixie (13); migrating the base image would
# clear it fully.
# Ref: https://security-tracker.debian.org/tracker/CVE-2023-45853
CVE-2023-45853 pkg:zlib1g exp:2026-08-15
CVE-2023-45853 pkg:zlib1g-dev exp:2026-08-15
# CVE-2026-55200 — libssh2 out-of-bounds write in ssh2_transport_read() due to
# an unchecked packet_length field in transport.c (heap corruption, possible RCE).
# Package: libssh2-1.
# Why ignored: libssh2-1 is pulled in only as a transitive dependency of libcurl4
# (installed in the SDK Dockerfile for the networking/PowerShell stack). The
# vulnerable path is reached exclusively when libssh2 acts as an SSH/SCP/SFTP
# client parsing transport packets from a server. Prowler never uses libcurl's
# SSH/SCP/SFTP transports; it talks to cloud provider HTTPS endpoints only, so the
# affected code is unreachable at runtime. Fixed upstream in libssh2 commit
# 97acf3df (PR #2052); no Debian bookworm fix is available yet.
# Ref: https://security-tracker.debian.org/tracker/CVE-2026-55200
CVE-2026-55200 pkg:libssh2-1 exp:2026-08-15
# --- API container image (api/Dockerfile) ---
# The entries below are specific to the Prowler API image, which ships
# PowerShell and additional build tooling on top of the same bookworm base.
# CVE-2026-7210 — CPython/Expat hash-flooding denial of service in
# `xml.parsers.expat` and `xml.etree.ElementTree`.
# Packages: the Debian system Python 3.11 (python3.11*, libpython3.11*).
# Why ignored: the API runs under the Python 3.12 interpreter shipped in its
# `.venv`; the system `python3.11` is only present because `python3-dev` is
# pulled in to compile native extensions (xmlsec, lxml) and is never executed
# at runtime. The vulnerable path requires parsing attacker-controlled XML with
# the affected interpreter, which Prowler does not do with the system Python.
# Full mitigation also needs libexpat >= 2.8.0; no Debian bookworm fix yet.
CVE-2026-7210 pkg:python3.11 exp:2026-08-15
CVE-2026-7210 pkg:python3.11-dev exp:2026-08-15
CVE-2026-7210 pkg:python3.11-minimal exp:2026-08-15
CVE-2026-7210 pkg:libpython3.11 exp:2026-08-15
CVE-2026-7210 pkg:libpython3.11-dev exp:2026-08-15
CVE-2026-7210 pkg:libpython3.11-minimal exp:2026-08-15
CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-08-15
# CVE-2026-33278 — Unbound DNSSEC validator use-after-free (DoS, possible RCE).
# CVE-2026-42960 — Unbound DNS cache poisoning via promiscuous additional records.
# Package: libunbound8.
# Why ignored: libunbound8 is a transitive apt dependency of the TLS/networking
# stack (GnuTLS DANE support); only the shared library ships in the image. Both
# vulnerabilities require operating a live Unbound recursive DNSSEC validator
# that processes attacker-influenced DNS responses. Prowler never starts an
# Unbound resolver, so neither code path is reachable. No Debian bookworm fix yet.
CVE-2026-33278 pkg:libunbound8 exp:2026-08-15
CVE-2026-42960 pkg:libunbound8 exp:2026-08-15
+212
View File
@@ -0,0 +1,212 @@
# Trivy suppressions for the prowlercloud/prowler SDK and API container images.
#
# This file replaces the classic .trivyignore, which parsed only the CVE id: the
# `pkg:` selector written on each line was documentation and the entry suppressed
# its CVE across every package in the image. The `purls` field below is honoured,
# so each entry is scoped to the package it names. Verified against Trivy 0.71.2:
# an entry given the wrong purl leaves the finding reported, where the classic
# format suppressed it.
#
# `expired_at` forces re-review. Keep the dates staggered.
#
# The four entries below are currently redundant: the scan runs with ignore-unfixed,
# and none of them has a published fix, so they never reach the gate either way. They
# are kept because the reasoning is what justifies accepting them, and because they
# apply again the moment any of them gains a fix we do not take.
#
# perl-base is Debian "Essential: yes". Trivy spreads src:perl CVEs across every
# binary package built from that source, so perl-base is flagged for modules only
# perl-modules-* ships. Neither image installs those, and nothing in either
# invokes perl.
#
# Why these four are accepted rather than fixed (reviewed 2026-07-31):
#
# 1. No fix exists. All four report no fixed version on perl-base 5.40.1-6.
# Debian marks CVE-2026-42496 "fix_deferred" and the other three "affected".
# A newer base image, apt upgrade, or a newer Debian release changes nothing.
# 2. The package cannot be removed. "Essential: yes" means removal needs
# dpkg --force-remove-essential, which breaks apt for anything built
# downstream from these images.
# 3. Changing base distribution was evaluated and rejected. Alpine drops perl
# entirely, but PowerShell publishes no linux-musl-arm64 build in any
# release, so M365 scanning would break on arm64 -- which is what we run in
# production. Wolfi keeps glibc and drops perl, but pinnable versioned tags
# are a paid tier, so builds would not be reproducibly pinnable.
#
# Not-invoked claim verified by sweeping both images for files with a perl
# shebang, shell/python callers of perl, ELF binaries containing "perl", and
# .pl/.pm files or perl subprocess calls anywhere in site-packages. The only
# consumers found are dpkg/debconf/adduser/pam tooling, none of which runs at
# runtime, plus one build-time script inside the ExchangeOnlineManagement
# PowerShell module that is never invoked.
vulnerabilities:
# Archive::Tar path traversal. Not installed: `perl -MArchive::Tar -e1` cannot locate it.
- id: CVE-2026-42496
purls:
- "pkg:deb/debian/perl-base"
expired_at: 2027-01-31
# Storable integer overflow. Not installed: `perl -MStorable -e1` cannot locate it.
- id: CVE-2026-57433
purls:
- "pkg:deb/debian/perl-base"
expired_at: 2027-01-31
# Regex heap overflow on 32-bit builds only; both published arches are 64-bit.
- id: CVE-2026-8376
purls:
- "pkg:deb/debian/perl-base"
expired_at: 2027-01-31
# Regex trie bug giving silently wrong matches above 65535 alternation branches.
# perl 5.40.1 is in range, so this rests on nothing invoking perl. Short expiry
# to force a re-look. Ref: https://github.com/Perl/perl5/issues/23388
- id: CVE-2026-13221
purls:
- "pkg:deb/debian/perl-base"
expired_at: 2026-11-30
# Declared in the SPDX manifest that ships inside PowerShell's MicrosoftTeams module
# (Modules/MicrosoftTeams/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.
- id: CVE-2020-0606
purls:
- "pkg:nuget/Microsoft.WindowsDesktop.App.Ref"
expired_at: 2027-01-31
- id: CVE-2019-0820
purls:
- "pkg:nuget/System.Text.RegularExpressions"
expired_at: 2027-01-31
- id: CVE-2026-47302
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
- id: CVE-2026-47304
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
- id: CVE-2026-50525
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
- id: CVE-2026-50527
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
- id: CVE-2026-50648
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
- id: CVE-2026-13676
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-16221
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-18446
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-75899
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-75975
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-76172
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-75931
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
# 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
purls:
- "pkg:golang/google.golang.org/grpc"
expired_at: 2026-10-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
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
+50 -5
View File
@@ -1,23 +1,46 @@
FROM python:3.12.13-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS build
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS build
LABEL maintainer="https://github.com/prowler-cloud/prowler"
LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
ARG POWERSHELL_VERSION=7.5.0
ARG POWERSHELL_VERSION=7.5.11
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
ENV POWERSHELL_TELEMETRY_OPTOUT=1
ARG TRIVY_VERSION=0.71.2
ARG TRIVY_VERSION=0.74.0
ENV TRIVY_VERSION=${TRIVY_VERSION}
ARG ZIZMOR_VERSION=1.24.1
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
ARG TRIVY_SHA256_AMD64=2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a
ARG TRIVY_SHA256_ARM64=b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5
ARG POWERSHELL_SHA256_AMD64=82a8b13d92b0f3ae48e56cf2f3f7961679371736ca90145ca71617c2913ba9d8
ARG POWERSHELL_SHA256_ARM64=830ebda118c731ece3fa7e6b7e8573a21346387cbbca5b2f5e3b9bfe24f96672
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
# High CVEs fixed in Debian trixie but not yet in the pinned base image:
# openssl/libssl3t64/openssl-provider-legacy 3.5.7-1~deb13u2 CVE-2026-14456,
# -14457, -18798, -54874, -63072, -63073, -63074, -63075, -63076, -75803
# libsqlite3-0 3.46.1-7+deb13u2 CVE-2026-11822, -11824
# gzip 1.13-1+deb13u1 CVE-2026-41992
# 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
# 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.
# Drop each one once the base image ships its fixed version.
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
wget libicu72 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
wget libicu76 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
build-essential pkg-config libzstd-dev zlib1g-dev \
&& apt-get install -y --no-install-recommends --only-upgrade \
util-linux libssl3t64 openssl openssl-provider-legacy \
libsqlite3-0 gzip perl-base libssh2-1t64 libpcre2-8-0 \
&& rm -rf /var/lib/apt/lists/*
# Install PowerShell
@@ -29,6 +52,9 @@ RUN ARCH=$(uname -m) && \
else \
echo "Unsupported architecture: $ARCH" && exit 1 ; \
fi && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$POWERSHELL_SHA256_AMD64" ; else EXPECT="$POWERSHELL_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/powershell.tar.gz" > /tmp/powershell.sha256 && \
sha256sum -c /tmp/powershell.sha256 && rm /tmp/powershell.sha256 && \
mkdir -p /opt/microsoft/powershell/7 && \
tar zxf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 && \
chmod +x /opt/microsoft/powershell/7/pwsh && \
@@ -45,6 +71,9 @@ RUN ARCH=$(uname -m) && \
echo "Unsupported architecture for Trivy: $ARCH" && exit 1 ; \
fi && \
wget --progress=dot:giga "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_${TRIVY_ARCH}.tar.gz" -O /tmp/trivy.tar.gz && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$TRIVY_SHA256_AMD64" ; else EXPECT="$TRIVY_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/trivy.tar.gz" > /tmp/trivy.sha256 && \
sha256sum -c /tmp/trivy.sha256 && rm /tmp/trivy.sha256 && \
tar zxf /tmp/trivy.tar.gz -C /tmp && \
mv /tmp/trivy /usr/local/bin/trivy && \
chmod +x /usr/local/bin/trivy && \
@@ -63,6 +92,9 @@ RUN ARCH=$(uname -m) && \
echo "Unsupported architecture for zizmor: $ARCH" && exit 1 ; \
fi && \
wget --progress=dot:giga "https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-${ZIZMOR_ARCH}.tar.gz" -O /tmp/zizmor.tar.gz && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$ZIZMOR_SHA256_AMD64" ; else EXPECT="$ZIZMOR_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/zizmor.tar.gz" > /tmp/zizmor.sha256 && \
sha256sum -c /tmp/zizmor.sha256 && rm /tmp/zizmor.sha256 && \
mkdir -p /tmp/zizmor-extract && \
tar zxf /tmp/zizmor.tar.gz -C /tmp/zizmor-extract && \
mv /tmp/zizmor-extract/zizmor /usr/local/bin/zizmor && \
@@ -89,7 +121,7 @@ ENV HOME='/home/prowler'
ENV PATH="${HOME}/.local/bin:${PATH}"
#hadolint ignore=DL3013
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir uv==0.11.14
pip install --no-cache-dir uv==0.12.0
RUN uv sync --locked --compile-bytecode && \
rm -rf ~/.cache/uv
@@ -105,6 +137,9 @@ RUN apt-get purge -y --auto-remove \
pkg-config \
libzstd-dev \
zlib1g-dev \
wget \
gnupg \
apt-transport-https \
&& rm -rf /var/lib/apt/lists/*
USER prowler
@@ -113,5 +148,15 @@ USER prowler
RUN pip uninstall dash-html-components -y && \
pip uninstall dash-core-components -y
USER root
# pip is build-only; the entrypoint runs the venv directly.
RUN rm -rf /usr/local/lib/python3.12/site-packages/pip \
/usr/local/lib/python3.12/site-packages/pip-*.dist-info \
/home/prowler/.local/lib/python3.12/site-packages/pip \
/home/prowler/.local/lib/python3.12/site-packages/pip-*.dist-info \
/usr/local/bin/pip /usr/local/bin/pip3 /usr/local/bin/pip3.12 \
/home/prowler/.local/bin/pip /home/prowler/.local/bin/pip3 /home/prowler/.local/bin/pip3.12
USER prowler
ENTRYPOINT ["/home/prowler/.venv/bin/prowler"]
+3
View File
@@ -34,6 +34,9 @@ test: ## Test with pytest
rm -rf .coverage && \
pytest -n auto -vvv -s --cov=./prowler --cov-report=xml tests
test-mcp: ## Test MCP server with pytest (mirrors CI)
cd mcp_server && uv run pytest --cov=./prowler_mcp_server --cov-report=term-missing tests
coverage: ## Show Test Coverage
coverage run --skip-covered -m pytest -v && \
coverage report -m && \
+7 -7
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 | 621 | 86 | 47 | 19 | Official | UI, API, CLI |
| Azure | 191 | 22 | 21 | 16 | Official | UI, API, CLI |
| GCP | 109 | 20 | 19 | 12 | Official | UI, API, CLI |
| Kubernetes | 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 | 111 | 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 |
@@ -139,7 +139,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| MongoDB Atlas | 10 | 3 | 1 | 8 | Official | UI, API, CLI |
| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI |
| Image | N/A | N/A | N/A | N/A | Official | UI, API, CLI |
| Google Workspace | 65 | 11 | 3 | 6 | Official | UI, API, CLI |
| Google Workspace | 65 | 11 | 4 | 6 | Official | UI, API, CLI |
| OpenStack | 34 | 5 | 1 | 9 | Official | UI, API, CLI |
| Vercel | 26 | 6 | 1 | 8 | Official | UI, API, CLI |
| Okta | 29 | 8 | 2 | 2 | Official | UI, API, CLI |
@@ -147,7 +147,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| Huawei Cloud [Contact us](https://prowler.com/contact) | 25 | 10 | 1 | 6 | Unofficial | CLI |
| E2E Networks [Contact us](https://prowler.com/contact) | 27 | 6 | 0 | 2 | Unofficial | CLI |
| Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 1 | 1 | Unofficial | CLI |
| StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 1 | 3 | Unofficial | CLI |
| StackIT [Contact us](https://prowler.com/contact) | 8 | 2 | 1 | 3 | Unofficial | CLI |
| NHN | 6 | 2 | 2 | 0 | Unofficial | CLI |
> [!Note]
+4
View File
@@ -59,5 +59,9 @@ DJANGO_GITHUB_OAUTH_CLIENT_ID=""
DJANGO_GITHUB_OAUTH_CLIENT_SECRET=""
DJANGO_GITHUB_OAUTH_CALLBACK_URL=""
# Public base URL of the Prowler UI, used to link Jira issues back to findings.
# Leave empty to omit the link.
DJANGO_UI_BASE_URL=""
# Deletion Task Batch Size
DJANGO_DELETION_BATCH_SIZE=5000
+123
View File
@@ -4,6 +4,129 @@ All notable changes to the **Prowler API** are documented in this file.
<!-- changelog: release notes start -->
## [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
- Speed up compliance overview ingestion by reading ThreatScore mappings from the compliance template instead of each finding, generating time-ordered `uuid7` row ids and grouping inserted rows by framework and requirement [(#12738)](https://github.com/prowler-cloud/prowler/pull/12738)
---
## [1.42.0] (Prowler v5.41.0)
### 🚀 Added
- Jira issues created from Prowler Cloud now carry the `prowler`, `prowler-<provider>`, `prowler-<severity>`, `prowler-<check-id>`, and `prowler-finding-<finding-uid>` labels, a link back to the finding when `DJANGO_UI_BASE_URL` is configured, and the tenant name [(#12540)](https://github.com/prowler-cloud/prowler/pull/12540)
### 🐞 Fixed
- `POST /api/v1/mute-rules` now updates only each affected provider's latest completed scan and future scans, preventing historical reaggregation from flooding Celery queues [(#12681)](https://github.com/prowler-cloud/prowler/pull/12681)
---
## [1.41.0] (Prowler v5.40.0)
### 🐞 Fixed
- `FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS` is now applied in months instead of days, and negative values are rejected [(#12580)](https://github.com/prowler-cloud/prowler/pull/12580)
### 🔐 Security
- `sqlparse` upgraded to 0.6.0, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491 [(#12509)](https://github.com/prowler-cloud/prowler/pull/12509)
- `openssl`, `libssl3t64` and `openssl-provider-legacy` upgraded to 3.5.7-1~deb13u2 in the API container image, patching ten high OpenSSL CVEs [(#12549)](https://github.com/prowler-cloud/prowler/pull/12549)
---
## [1.40.1] (Prowler v5.39.1)
### 🔄 Changed
- Bump alibabacloud-tea-openapi to 0.4.6, oci to 2.184.1 and pyopenssl to 26.4.0 to match the SDK; the cryptography override now names its actual blockers (azure-cli-core pins msal below 1.37, workos 8.3.0 requires cryptography 48) [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
### 🐞 Fixed
- Pin zstd to 1.5.7.2; 1.5.7.3 was yanked from PyPI as not thread safe [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
### 🔐 Security
- Trivy from v0.72.0 to v0.73.0 in the container image, fixing HIGH CVE-2026-46600 in the bundled `golang.org/x/net` [(#12445)](https://github.com/prowler-cloud/prowler/pull/12445)
- Trivy v0.74.0 and Debian util-linux 2.41.5-0+deb13u1 in the API container image, patching Go standard library vulnerabilities and CVE-2026-53615 [(#12470)](https://github.com/prowler-cloud/prowler/pull/12470)
---
## [1.40.0] (Prowler v5.39.0)
### 🔄 Changed
- `GET /api/v1/users/me` membership relationships identify the active tenant with `meta.active` for JWT and API key authentication [(#12388)](https://github.com/prowler-cloud/prowler/pull/12388)
### 🐞 Fixed
- Tenant deletion no longer leaves memberships partially removed when exclusive-user cleanup fails [(#12379)](https://github.com/prowler-cloud/prowler/pull/12379)
- `/api/v1/accounts/saml/{organization_slug}/acs/` rejects non-POST requests before SAML response processing [(#12393)](https://github.com/prowler-cloud/prowler/pull/12393)
- Social login derives a valid user name when identity providers omit the profile name [(#12413)](https://github.com/prowler-cloud/prowler/pull/12413)
---
## [1.39.0] (Prowler v5.38.0)
### 🚀 Added
- Attack Paths adds 20 AWS privilege-escalation detection queries from pathfinding.cloud, covering service PassRole escalations (Batch, Braket, Cognito Identity, ECS, EMR, EMR Serverless, GameLift, Glue, EC2 Image Builder, Kinesis Analytics, HealthOmics, EventBridge Scheduler, SSM, Step Functions), CodeDeploy and Step Functions existing-resource abuse, role permissions-boundary removal with role assumption, and IAM Identity Center permission-set policy injection [(#12237)](https://github.com/prowler-cloud/prowler/pull/12237)
- Attack Paths query metadata now carries an outcome (Code execution, Privilege escalation, Public exposure, or Resource inventory), exposed on the queries endpoint so the graph can show a terminal outcome node [(#12344)](https://github.com/prowler-cloud/prowler/pull/12344)
- Container images now ship an SBOM and build provenance as OCI attestations [(#12352)](https://github.com/prowler-cloud/prowler/pull/12352)
### 🔄 Changed
- Pin the container vulnerability scanner to Trivy v0.72.0, matching prowler-registry and partner-portal [(#12346)](https://github.com/prowler-cloud/prowler/pull/12346)
### 🐞 Fixed
- Compliance report output directory failures are now logged with the exception attached and fingerprinted by `errno` in Sentry, so `ENOSPC`, `ENOENT` and `EACCES` no longer share a single issue [(#12142)](https://github.com/prowler-cloud/prowler/pull/12142)
- Restored the SDK dependency to `@master` now that the dependency bumps have landed there, and regenerated the lock. The API image no longer builds against a temporary integration branch [(#12309)](https://github.com/prowler-cloud/prowler/pull/12309)
### 🔐 Security
- The API container image now verifies the checksum of every third-party binary it downloads (PowerShell, Trivy, zizmor) before installing it [(#12334)](https://github.com/prowler-cloud/prowler/pull/12334)
- Upgrade aiohttp to 3.14.3 to pick up the fix for CVE-2026-69244 [(#12340)](https://github.com/prowler-cloud/prowler/pull/12340)
- Upgrade cryptography to 50.0.0, closing CVE-2026-69247 and CVE-2026-69249 [(#12356)](https://github.com/prowler-cloud/prowler/pull/12356)
---
## [1.38.1] (Prowler v5.37.1)
### 🐞 Fixed
- Entra Conditional Access guest-user checks no longer report false FAILs in M365 scans: microsoft-kiota packages overridden to 1.9.10 so `guestOrExternalUserTypes` (a flags enum Graph serializes as a comma-separated string) deserializes correctly instead of returning an empty list [(#12315)](https://github.com/prowler-cloud/prowler/pull/12315)
### 🔐 Security
- The API container image now builds on Debian 13 (trixie), taking its critical CVE count from 18 to 4 [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
- Bumped PowerShell, Trivy and uv in the API container image, clearing 14 high-severity CVEs [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
- Bumped `workos` and `pyopenssl` so the API can move to `cryptography` 48.0.1 [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
- Removed `gnupg` and `apt-transport-https` from the API container image [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
- The API container image no longer ships `git`; removing it also dropped `perl`, `perl-modules`, `libperl` and `liberror-perl`, clearing 12 critical CVEs. Only `perl-base` remains, which Debian marks Essential and cannot be removed [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
- Removed `pip` from the API container image, clearing two high-severity CVEs in the vendored copies of `setuptools` and `msgpack` [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
- Bumped `pillow` to 12.3.0, `httplib2` to 0.32.0 and `pyasn1` to 0.6.4 to resolve known CVEs [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
---
## [1.38.0] (Prowler v5.37.0)
### 🚀 Added
+52 -6
View File
@@ -1,23 +1,43 @@
FROM python:3.12.13-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS build
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS build
LABEL maintainer="https://github.com/prowler-cloud/api"
ARG POWERSHELL_VERSION=7.5.0
ARG POWERSHELL_VERSION=7.5.11
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
ENV POWERSHELL_TELEMETRY_OPTOUT=1
ARG TRIVY_VERSION=0.71.2
ARG TRIVY_VERSION=0.74.0
ENV TRIVY_VERSION=${TRIVY_VERSION}
ARG ZIZMOR_VERSION=1.24.1
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
ARG TRIVY_SHA256_AMD64=2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a
ARG TRIVY_SHA256_ARM64=b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5
ARG POWERSHELL_SHA256_AMD64=82a8b13d92b0f3ae48e56cf2f3f7961679371736ca90145ca71617c2913ba9d8
ARG POWERSHELL_SHA256_ARM64=830ebda118c731ece3fa7e6b7e8573a21346387cbbca5b2f5e3b9bfe24f96672
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
# High CVEs fixed in Debian trixie but not yet in the pinned base image:
# openssl/libssl3t64/openssl-provider-legacy 3.5.7-1~deb13u2 CVE-2026-14456,
# -14457, -18798, -54874, -63072, -63073, -63074, -63075, -63076, -75803
# libsqlite3-0 3.46.1-7+deb13u2 CVE-2026-11822, -11824
# gzip 1.13-1+deb13u1 CVE-2026-41992
# 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
# 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.
# Drop each one once the base image ships its fixed version.
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
git \
libicu72 \
libicu76 \
gcc \
g++ \
make \
@@ -28,7 +48,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libtool \
libxslt1-dev \
python3-dev \
git \
&& apt-get install -y --no-install-recommends --only-upgrade \
util-linux libssl3t64 openssl openssl-provider-legacy \
libsqlite3-0 gzip perl-base libssh2-1t64 libpcre2-8-0 \
&& rm -rf /var/lib/apt/lists/*
# Install PowerShell
@@ -40,6 +62,9 @@ RUN ARCH=$(uname -m) && \
else \
echo "Unsupported architecture: $ARCH" && exit 1 ; \
fi && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$POWERSHELL_SHA256_AMD64" ; else EXPECT="$POWERSHELL_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/powershell.tar.gz" > /tmp/powershell.sha256 && \
sha256sum -c /tmp/powershell.sha256 && rm /tmp/powershell.sha256 && \
mkdir -p /opt/microsoft/powershell/7 && \
tar zxf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 && \
chmod +x /opt/microsoft/powershell/7/pwsh && \
@@ -56,6 +81,9 @@ RUN ARCH=$(uname -m) && \
echo "Unsupported architecture for Trivy: $ARCH" && exit 1 ; \
fi && \
wget --progress=dot:giga "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_${TRIVY_ARCH}.tar.gz" -O /tmp/trivy.tar.gz && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$TRIVY_SHA256_AMD64" ; else EXPECT="$TRIVY_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/trivy.tar.gz" > /tmp/trivy.sha256 && \
sha256sum -c /tmp/trivy.sha256 && rm /tmp/trivy.sha256 && \
tar zxf /tmp/trivy.tar.gz -C /tmp && \
mv /tmp/trivy /usr/local/bin/trivy && \
chmod +x /usr/local/bin/trivy && \
@@ -74,6 +102,9 @@ RUN ARCH=$(uname -m) && \
echo "Unsupported architecture for zizmor: $ARCH" && exit 1 ; \
fi && \
wget --progress=dot:giga "https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-${ZIZMOR_ARCH}.tar.gz" -O /tmp/zizmor.tar.gz && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$ZIZMOR_SHA256_AMD64" ; else EXPECT="$ZIZMOR_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/zizmor.tar.gz" > /tmp/zizmor.sha256 && \
sha256sum -c /tmp/zizmor.sha256 && rm /tmp/zizmor.sha256 && \
mkdir -p /tmp/zizmor-extract && \
tar zxf /tmp/zizmor.tar.gz -C /tmp/zizmor-extract && \
mv /tmp/zizmor-extract/zizmor /usr/local/bin/zizmor && \
@@ -94,7 +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.11.14
pip install --no-cache-dir uv==0.12.0
ENV PATH="/home/prowler/.local/bin:$PATH"
@@ -109,19 +140,34 @@ RUN .venv/bin/python -m prowler.providers.m365.lib.powershell.m365_powershell
USER root
# Remove build-only packages from the final image after Python dependencies are installed.
# git is only needed by uv sync for the `prowler @ git+...` dependency; purging it drops perl too.
# wget stays: the compose healthcheck shells out to it.
RUN apt-get purge -y --auto-remove \
gcc \
g++ \
git \
make \
libxml2-dev \
libxmlsec1-dev \
libxmlsec1-openssl \
libxmlsec1t64 \
libxmlsec1t64-openssl \
pkg-config \
libtool \
libxslt1-dev \
python3-dev \
gnupg \
apt-transport-https \
&& rm -rf /var/lib/apt/lists/*
# pip is build-only; the entrypoint runs uv against the prepared venv. uv stays.
RUN rm -rf /usr/local/lib/python3.12/site-packages/pip \
/usr/local/lib/python3.12/site-packages/pip-*.dist-info \
/home/prowler/.local/lib/python3.12/site-packages/pip \
/home/prowler/.local/lib/python3.12/site-packages/pip-*.dist-info \
/usr/local/bin/pip /usr/local/bin/pip3 /usr/local/bin/pip3.12 \
/home/prowler/.local/bin/pip /home/prowler/.local/bin/pip3 /home/prowler/.local/bin/pip3.12
USER prowler
COPY --chown=prowler:prowler src/backend/ ./backend/
@@ -0,0 +1 @@
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
@@ -0,0 +1 @@
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
@@ -0,0 +1 @@
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
@@ -0,0 +1 @@
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
@@ -0,0 +1 @@
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
@@ -0,0 +1 @@
OCI provider secrets keep the region as home region for credential validation and scans, instead of always using us-ashburn-1
@@ -0,0 +1 @@
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
+1
View File
@@ -0,0 +1 @@
`pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9
@@ -0,0 +1 @@
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
@@ -0,0 +1 @@
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
@@ -0,0 +1 @@
`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
@@ -0,0 +1 @@
`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
@@ -0,0 +1 @@
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.
+51 -34
View File
@@ -45,7 +45,7 @@ dependencies = [
"gunicorn==26.0.0",
"uvloop==0.22.1",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.37",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.44",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
@@ -61,9 +61,9 @@ dependencies = [
"cartography (==0.138.1)",
"gevent (==25.9.1)",
"werkzeug (==3.1.7)",
"sqlparse (==0.5.5)",
"sqlparse (==0.6.0)",
"fonttools (==4.62.1)",
"uvicorn-worker (==0.4.0)",
"uvicorn-worker (==0.4.0)"
]
description = "Prowler's API (Django/DRF)"
license = "Apache-2.0"
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.38.0"
version = "1.45.1"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
@@ -92,6 +92,7 @@ extend-select = [
[tool.uv]
# Transitive pins matching master to avoid silent drift; bump deliberately.
# workos is api-only; pyopenssl matches master (PROWLER-2310).
constraint-dependencies = [
"about-time==4.2.1",
"adal==1.2.7",
@@ -99,7 +100,7 @@ constraint-dependencies = [
"aiobotocore==2.25.1",
"aiofiles==24.1.0",
"aiohappyeyeballs==2.6.1",
"aiohttp==3.14.0",
"aiohttp==3.14.3",
"aioitertools==0.13.0",
"aiosignal==1.4.0",
"alibabacloud-actiontrail20200706==2.4.1",
@@ -128,7 +129,7 @@ constraint-dependencies = [
"alibabacloud-sls20201230==5.9.0",
"alibabacloud-sts20150401==1.1.6",
"alibabacloud-tea==0.4.3",
"alibabacloud-tea-openapi==0.4.4",
"alibabacloud-tea-openapi==0.4.6",
"alibabacloud-tea-util==0.3.14",
"alibabacloud-tea-xml==0.0.3",
"alibabacloud-vpc20160428==6.13.0",
@@ -136,7 +137,7 @@ constraint-dependencies = [
"aliyun-log-fastpb==0.2.0",
"amqp==5.3.1",
"annotated-types==0.7.0",
"anyio==4.12.1",
"anyio==4.14.2",
"applicationinsights==0.11.10",
"apscheduler==3.11.2",
"argcomplete==3.5.3",
@@ -210,9 +211,9 @@ constraint-dependencies = [
"coverage==7.5.4",
"cron-descriptor==1.4.5",
"crowdstrike-falconpy==1.6.0",
"cryptography==46.0.7",
"cryptography==50.0.0",
"cycler==0.12.1",
"darabonba-core==1.0.5",
"darabonba-core==1.0.8",
"dash==3.1.1",
"dash-bootstrap-components==2.0.3",
"debugpy==1.8.20",
@@ -277,7 +278,7 @@ constraint-dependencies = [
"h2==4.3.0",
"hpack==4.1.0",
"httpcore==1.0.9",
"httplib2==0.31.2",
"httplib2==0.32.0",
"httpx==0.28.1",
"humanfriendly==10.0",
"hyperframe==6.1.0",
@@ -314,13 +315,13 @@ constraint-dependencies = [
"matplotlib==3.10.8",
"mccabe==0.7.0",
"mdurl==0.1.2",
"microsoft-kiota-abstractions==1.9.9",
"microsoft-kiota-authentication-azure==1.9.9",
"microsoft-kiota-http==1.9.9",
"microsoft-kiota-serialization-form==1.9.9",
"microsoft-kiota-serialization-json==1.9.9",
"microsoft-kiota-serialization-multipart==1.9.9",
"microsoft-kiota-serialization-text==1.9.9",
"microsoft-kiota-abstractions==1.9.10",
"microsoft-kiota-authentication-azure==1.9.10",
"microsoft-kiota-http==1.9.10",
"microsoft-kiota-serialization-form==1.9.10",
"microsoft-kiota-serialization-json==1.9.10",
"microsoft-kiota-serialization-multipart==1.9.10",
"microsoft-kiota-serialization-text==1.9.10",
"microsoft-security-utilities-secret-masker==1.0.0b4",
"msal==1.35.0b1",
"msal-extensions==1.2.0",
@@ -337,7 +338,7 @@ constraint-dependencies = [
"nltk==3.9.4",
"numpy==2.2.6",
"oauthlib==3.3.1",
"oci==2.169.0",
"oci==2.184.1",
"openai==1.109.1",
"openstacksdk==4.2.0",
"opentelemetry-api==1.39.1",
@@ -349,7 +350,7 @@ constraint-dependencies = [
"pagerduty==6.1.0",
"pandas==2.2.3",
"pbr==7.0.3",
"pillow==12.2.0",
"pillow==12.3.0",
"pkginfo==1.12.1.2",
"platformdirs==4.5.1",
"plotly==6.5.2",
@@ -365,8 +366,8 @@ constraint-dependencies = [
"psycopg2-binary==2.9.9",
"py-deviceid==0.1.1",
"py-iam-expand==0.3.0",
"py-ocsf-models==0.8.1",
"pyasn1==0.6.3",
"py-ocsf-models==0.10.0",
"pyasn1==0.6.4",
"pyasn1-modules==0.4.2",
"pycodestyle==2.14.0",
"pycparser==3.0",
@@ -374,11 +375,11 @@ 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",
"pyopenssl==26.0.0",
"pyopenssl==26.4.0",
"pyparsing==3.3.2",
"pyreadline3==3.5.4",
"pysocks==1.7.1",
@@ -422,7 +423,7 @@ constraint-dependencies = [
"six==1.17.0",
"slack-sdk==3.39.0",
"sniffio==1.3.1",
"sqlparse==0.5.5",
"sqlparse==0.6.0",
"statsd==4.0.1",
"std-uritemplate==2.0.8",
"stevedore==5.6.0",
@@ -447,7 +448,7 @@ constraint-dependencies = [
"wcwidth==0.5.3",
"websocket-client==1.9.0",
"werkzeug==3.1.7",
"workos==6.0.8",
"workos==8.3.0",
"wrapt==1.17.3",
"xlsxwriter==3.2.9",
"xmlsec==1.3.17",
@@ -456,7 +457,7 @@ constraint-dependencies = [
"zipp==3.23.0",
"zope-event==6.1",
"zope-interface==8.2",
"zstd==1.5.7.3"
"zstd==1.5.7.2"
]
# prowler@master needs okta==3.4.2, but cartography 0.138.1 requires okta<1.0.0.
# Attack Paths does not ingest Okta today, so override the Cartography
@@ -466,22 +467,38 @@ constraint-dependencies = [
# 0.138.1 requires azure-mgmt-containerservice>=41.0.0. Attack Paths does not
# ingest Azure today, so override the Cartography dependency to the Prowler pin.
#
# prowler@master hard-pins microsoft-kiota-abstractions==1.9.2 in [project.dependencies].
# The microsoft-kiota-http security bump to 1.9.9 (GHSA-7j59-v9qr-6fq9) requires
# microsoft-kiota-abstractions>=1.9.9, which a constraint cannot satisfy against the
# SDK's hard pin; override it to the patched, kiota-aligned version.
# prowler@master hard-pins the microsoft-kiota packages in [project.dependencies].
# microsoft-kiota-serialization-json 1.9.10 fixes get_collection_of_enum_values
# returning [] for flags enums serialized as CSV strings (microsoft/kiota-python#515),
# which broke the Entra Conditional Access guest-user checks; the kiota packages
# release in lockstep and 1.9.10 requires microsoft-kiota-abstractions>=1.9.10, which
# a constraint cannot satisfy against the SDK's hard pins, so override the whole set
# to 1.9.10 until the SDK bump propagates to the pinned master rev.
#
# prowler@master hard-pins dulwich==0.23.0 and pyjwt==2.12.1 in [project.dependencies].
# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.13.0
# patches PYSEC-2026-179 (HMAC/JWK key-confusion); a constraint cannot satisfy these
# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.14.0
# patches GHSA-ffc3-869f-jxw9 (HMAC/PEM 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
# 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",
# prowler requires cryptography==50.0.0. Two api-only dependencies still cap it below
# 49 and cannot move yet: msal, pinned exactly by azure-cli-core (2.83.0 -> 1.35.0b1,
# 2.89.1 -> 1.36.0, both <49; cartography needs azure-cli-core), and workos 8.3.0
# (~=48.0; workos 10.1.1+ needs ~=50.0 and is a separate SDK upgrade). This api is
# deployed from this lock with `uv sync --locked`, so the override applies to what runs.
# Remove when azure-cli-core pins msal>=1.37.0 and workos is on 10.x.
"cryptography==50.0.0",
"azure-mgmt-containerservice==34.1.0",
"microsoft-kiota-abstractions==1.9.9",
"microsoft-kiota-abstractions==1.9.10",
"microsoft-kiota-authentication-azure==1.9.10",
"microsoft-kiota-http==1.9.10",
"microsoft-kiota-serialization-form==1.9.10",
"microsoft-kiota-serialization-json==1.9.10",
"microsoft-kiota-serialization-multipart==1.9.10",
"microsoft-kiota-serialization-text==1.9.10",
"dulwich==1.2.5",
"pyjwt[crypto]==2.13.0"
"pyjwt[crypto]==2.14.0"
]
+27 -4
View File
@@ -12,11 +12,37 @@ from api.models import (
UserRoleRelationship,
)
from api.utils import accept_invitation_for_user
from django.core.exceptions import ValidationError
from django.db import transaction
from django.http import HttpResponseForbidden
class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
@staticmethod
def _get_social_account_name(extra_data: dict, email: str) -> str:
name_field = User._meta.get_field("name")
for value in (
extra_data.get("name"),
extra_data.get("login"),
extra_data.get("username"),
email,
):
if not isinstance(value, str):
continue
candidate = value.strip()[: name_field.max_length].rstrip()
if not candidate:
continue
try:
name_field.run_validators(candidate)
except ValidationError:
continue
return candidate
raise ValueError("Social account does not provide a valid user identity.")
@staticmethod
def get_user_by_email(email: str):
try:
@@ -116,11 +142,8 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
if provider != "saml":
# Handle other providers (e.g., GitHub, Google)
user.name = self._get_social_account_name(extra, user.email)
user.save(using=MainRouter.admin_db)
social_account_name = extra.get("name")
if social_account_name:
user.name = social_account_name
user.save(using=MainRouter.admin_db)
invitation_token = self._get_invitation_token(request)
if invitation_token:
@@ -1,5 +1,6 @@
from api.attack_paths.queries import (
AttackPathsQueryDefinition,
AttackPathsQueryOutcome,
AttackPathsQueryParameterDefinition,
get_queries_for_provider,
get_query_by_id,
@@ -7,6 +8,7 @@ from api.attack_paths.queries import (
__all__ = [
"AttackPathsQueryDefinition",
"AttackPathsQueryOutcome",
"AttackPathsQueryParameterDefinition",
"get_queries_for_provider",
"get_query_by_id",
@@ -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
@@ -4,11 +4,13 @@ from api.attack_paths.queries.registry import (
)
from api.attack_paths.queries.types import (
AttackPathsQueryDefinition,
AttackPathsQueryOutcome,
AttackPathsQueryParameterDefinition,
)
__all__ = [
"AttackPathsQueryDefinition",
"AttackPathsQueryOutcome",
"AttackPathsQueryParameterDefinition",
"get_queries_for_provider",
"get_query_by_id",
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,38 @@
from dataclasses import dataclass, field
from enum import Enum
@dataclass(frozen=True)
class AttackPathsQueryOutcomeMeta:
"""Display metadata for an outcome kind.
`label` and `partial` are properties of the outcome *kind*, not of an
individual query, so they live here once and every query just references a
kind. `partial` marks a latent/posture outcome (e.g. inventory) that the UI
renders as a marker rather than a full realized outcome.
"""
kind: str
label: str
partial: bool = False
class AttackPathsQueryOutcome(Enum):
"""The terminal impact an attack-path query leads to.
Set per query and exposed by the API so the UI can render the graph's
terminal outcome node. The taxonomy is shared with Prowler Hub's attack-path
diagram (whose terminal labels match these values).
"""
CODE_EXECUTION = AttackPathsQueryOutcomeMeta("code_execution", "Code execution")
PRIVILEGE_ESCALATION = AttackPathsQueryOutcomeMeta(
"privilege_escalation", "Privilege escalation"
)
PUBLIC_EXPOSURE = AttackPathsQueryOutcomeMeta("public_exposure", "Public exposure")
RESOURCE_INVENTORY = AttackPathsQueryOutcomeMeta(
"resource_inventory", "Resource inventory", partial=True
)
@dataclass
@@ -36,4 +70,5 @@ class AttackPathsQueryDefinition:
provider: str
cypher: str
attribution: AttackPathsQueryAttribution | None = None
outcome: AttackPathsQueryOutcome | None = None
parameters: list[AttackPathsQueryParameterDefinition] = field(default_factory=list)
+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:
+14 -2
View File
@@ -1439,8 +1439,20 @@ class InvitationFilter(FilterSet):
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
updated_at = DateFilter(field_name="updated_at", lookup_expr="date")
expires_at = DateFilter(field_name="expires_at", lookup_expr="date")
state = ChoiceFilter(choices=Invitation.State.choices)
state__in = ChoiceInFilter(choices=Invitation.State.choices, lookup_expr="in")
state = ChoiceFilter(choices=Invitation.State.choices, method="filter_state")
state__in = ChoiceInFilter(
choices=Invitation.State.choices, lookup_expr="in", method="filter_state_in"
)
def filter_state(self, queryset, name, value):
return self.filter_state_in(queryset, name, [value])
def filter_state_in(self, queryset, name, value):
lapsed = Invitation.lapsed_q()
query = Q(state__in=value) & ~lapsed
if Invitation.State.EXPIRED in value:
query |= lapsed
return queryset.filter(query)
class Meta:
model = Invitation
@@ -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),
]
+74 -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),
@@ -1380,6 +1443,15 @@ class Invitation(RowLevelSecurityProtectedModel):
self.email = self.email.strip().lower()
super().save(*args, **kwargs)
@classmethod
def lapsed_q(cls):
"""Pending invitations whose expiry date has already passed."""
return Q(state=cls.State.PENDING, expires_at__lte=datetime.now(UTC))
@property
def is_lapsed(self):
return self.state == self.State.PENDING and self.expires_at <= datetime.now(UTC)
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "invitations"
+13 -5
View File
@@ -6,6 +6,7 @@ from api.rls import RowLevelSecurityConstraint
from api.uuid_utils import datetime_to_uuid7
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from psqlextra.partitioning import (
PostgresPartitioningError,
PostgresPartitioningManager,
@@ -153,10 +154,17 @@ class PostgresUUIDv7PartitioningStrategy(PostgresRangePartitioningStrategy):
)
def relative_days_or_none(value):
if value is None:
def relative_months_or_none(value):
# A negative value would set the cutoff in the future and delete every
# partition, so it is rejected rather than silently ignored.
if value is not None and value < 0:
raise ImproperlyConfigured(
"FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS must not be negative; "
"leave it unset or use 0 to keep partitions indefinitely"
)
if not value:
return None
return relativedelta(days=value)
return relativedelta(months=value)
#
@@ -173,7 +181,7 @@ manager = PostgresPartitioningManager(
months=settings.FINDINGS_TABLE_PARTITION_MONTHS
),
count=settings.FINDINGS_TABLE_PARTITION_COUNT,
max_age=relative_days_or_none(
max_age=relative_months_or_none(
settings.FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS
),
name_format="%Y_%b",
@@ -189,7 +197,7 @@ manager = PostgresPartitioningManager(
months=settings.FINDINGS_TABLE_PARTITION_MONTHS
),
count=settings.FINDINGS_TABLE_PARTITION_COUNT,
max_age=relative_days_or_none(
max_age=relative_months_or_none(
settings.FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS
),
name_format="%Y_%b",
+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.38.0
version: 1.45.1
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()
@@ -1590,8 +1608,8 @@ class TestAPIKeyMultiTenantWorkflows:
tenant1 = tenants_fixture[0]
tenant2 = tenants_fixture[1]
Membership.objects.create(user=user, tenant=tenant1)
Membership.objects.create(user=user, tenant=tenant2)
membership1 = Membership.objects.create(user=user, tenant=tenant1)
membership2 = Membership.objects.create(user=user, tenant=tenant2)
role1 = Role.objects.create(
tenant_id=tenant1.id,
@@ -1646,6 +1664,27 @@ class TestAPIKeyMultiTenantWorkflows:
assert me_response1.json()["data"]["id"] == str(user.id)
assert me_response2.json()["data"]["id"] == str(user.id)
memberships1 = {
item["id"]: item["meta"]["active"]
for item in me_response1.json()["data"]["relationships"]["memberships"][
"data"
]
}
memberships2 = {
item["id"]: item["meta"]["active"]
for item in me_response2.json()["data"]["relationships"]["memberships"][
"data"
]
}
assert memberships1 == {
str(membership1.id): True,
str(membership2.id): False,
}
assert memberships2 == {
str(membership1.id): False,
str(membership2.id): True,
}
def test_api_key_cannot_access_different_tenant_resources(
self, tenants_fixture, aws_provider
):
+104
View File
@@ -111,6 +111,110 @@ def _verify_local_email(user):
)
def test_social_account_name_falls_back_to_login_for_blank_name():
adapter = ProwlerSocialAccountAdapter()
name = adapter._get_social_account_name(
{"name": " ", "login": "octocat"},
"verified@example.com",
)
assert name == "octocat"
@pytest.mark.parametrize("provider_name", [None, "", " ", 123, ["name"]])
def test_social_account_name_ignores_unusable_provider_names(provider_name):
adapter = ProwlerSocialAccountAdapter()
name = adapter._get_social_account_name(
{"name": provider_name, "login": "octocat"},
"verified@example.com",
)
assert name == "octocat"
def test_social_account_name_uses_login_when_name_is_missing():
adapter = ProwlerSocialAccountAdapter()
name = adapter._get_social_account_name(
{"login": "octocat"},
"verified@example.com",
)
assert name == "octocat"
def test_social_account_name_falls_back_to_username_then_email():
adapter = ProwlerSocialAccountAdapter()
username_name = adapter._get_social_account_name(
{"name": "ab", "login": None, "username": " monalisa "},
"verified@example.com",
)
email_name = adapter._get_social_account_name({}, " verified@example.com ")
assert username_name == "monalisa"
assert email_name == "verified@example.com"
def test_social_account_name_trims_and_limits_provider_name():
adapter = ProwlerSocialAccountAdapter()
max_length = User._meta.get_field("name").max_length
trimmed_name = adapter._get_social_account_name(
{"name": " Ada Lovelace "},
"verified@example.com",
)
limited_name = adapter._get_social_account_name(
{"name": "a" * (max_length + 1)},
"verified@example.com",
)
assert trimmed_name == "Ada Lovelace"
assert limited_name == "a" * max_length
def test_social_account_name_rejects_missing_identity():
adapter = ProwlerSocialAccountAdapter()
with pytest.raises(
ValueError,
match="Social account does not provide a valid user identity",
):
adapter._get_social_account_name({}, "")
def test_save_user_applies_normalized_social_account_name(rf):
adapter = ProwlerSocialAccountAdapter()
request = rf.post("/")
request.session = {}
sociallogin = MagicMock(spec=SocialLogin)
sociallogin.provider = MagicMock()
sociallogin.provider.id = "github"
sociallogin.account = MagicMock()
sociallogin.account.extra_data = {"name": None, "login": " octocat "}
user = User(email="verified@example.com")
user.save = MagicMock()
invitation = SimpleNamespace(tenant_id="tenant-id")
with (
patch("api.adapters.super") as mock_super,
patch("api.adapters.transaction.atomic"),
patch("api.adapters.write_db_alias"),
patch.object(adapter, "_get_invitation_token", return_value="token"),
patch(
"api.adapters.accept_invitation_for_user",
return_value=(invitation, True),
),
):
mock_super.return_value.save_user.return_value = user
saved_user = adapter.save_user(request, sociallogin)
assert saved_user.name == "octocat"
assert request.prowler_invitation_token == "token"
@pytest.mark.django_db
class TestProwlerSocialAccountAdapter:
def test_get_user_by_email_returns_user(self, create_test_user):
@@ -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")
@@ -22,7 +22,10 @@ from api.attack_paths.queries.aws import (
AWS_STS_PRIVESC_CROSS_ACCOUNT_TRUST,
AWS_STS_PRIVESC_WILDCARD_TRUST,
)
from api.attack_paths.queries.types import AttackPathsQueryDefinition
from api.attack_paths.queries.types import (
AttackPathsQueryDefinition,
AttackPathsQueryOutcome,
)
# The pathfinding.cloud privilege-escalation queries added for PROWLER-2278.
NEW_PATHFINDING_QUERIES = [
@@ -238,6 +241,50 @@ class TestAllQueriesUniqueIds:
assert not duplicates, f"Duplicate query IDs found: {duplicates}"
class TestQueryOutcomes:
"""Every query carries a valid outcome (the graph's terminal impact)."""
def test_every_query_has_an_outcome(self):
# Completeness guard: a new query must be given an outcome, so the UI can
# always render a terminal outcome node.
missing = [q.id for q in AWS_QUERIES if q.outcome is None]
assert not missing, f"Queries without an outcome: {missing}"
def test_every_outcome_is_a_valid_member(self):
for query in AWS_QUERIES:
assert isinstance(query.outcome, AttackPathsQueryOutcome)
assert query.outcome.value.kind
assert query.outcome.value.label
@pytest.mark.parametrize(
"query, expected",
[
(
AWS_STS_PRIVESC_CROSS_ACCOUNT_TRUST,
AttackPathsQueryOutcome.PRIVILEGE_ESCALATION,
),
(
AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY,
AttackPathsQueryOutcome.PRIVILEGE_ESCALATION,
),
],
ids=lambda v: getattr(v, "id", getattr(v, "name", "")),
)
def test_representative_outcomes(self, query, expected):
assert query.outcome is expected
def test_inventory_outcome_is_partial(self):
assert AttackPathsQueryOutcome.RESOURCE_INVENTORY.value.partial is True
def test_realized_outcomes_are_not_partial(self):
for outcome in (
AttackPathsQueryOutcome.CODE_EXECUTION,
AttackPathsQueryOutcome.PRIVILEGE_ESCALATION,
AttackPathsQueryOutcome.PUBLIC_EXPOSURE,
):
assert outcome.value.partial is False
def _strip_comment_lines(cypher: str) -> str:
"""Drop `//` comment lines so keyword scans ignore prose in comments."""
return "\n".join(
@@ -0,0 +1,133 @@
"""
Structural validation for the pathfinding.cloud service privilege-escalation
Attack Paths queries added in PROWLER-2279.
These assert the conventions documented in
`docs/developer-guide/attack-paths-queries.mdx`: list-typed policy properties are
reached through `HAS_*` child-item traversals (never read as node fields),
predicate functions unsupported on Neptune (`any`/`all`/`none`, regex `=~`) are
absent, the finding probe is typed and filters only on `status`, and the
`RETURN` shape preserves the `paths, dpf, dpfr` contract.
"""
import re
import pytest
from api.attack_paths.queries.aws import AWS_QUERIES
from api.attack_paths.queries.types import AttackPathsQueryDefinition
# IDs of the queries introduced for PROWLER-2279 (pathfinding.cloud coverage).
PATHFINDING_2279_QUERY_IDS = [
"aws-batch-privesc-passrole-submit-job",
"aws-braket-privesc-passrole-create-job",
"aws-cognito-privesc-passrole-set-identity-pool-roles",
"aws-ecs-privesc-passrole-start-existing-task",
"aws-emr-privesc-passrole-run-job-flow",
"aws-emrserverless-privesc-passrole-start-job",
"aws-gamelift-privesc-passrole-create-fleet",
"aws-glue-privesc-passrole-create-session",
"aws-imagebuilder-privesc-passrole-create-image",
"aws-kinesisanalytics-privesc-passrole-create-application",
"aws-omics-privesc-passrole-start-run",
"aws-scheduler-privesc-passrole-create-schedule",
"aws-ssm-privesc-passrole-automation",
"aws-stepfunctions-privesc-passrole-create-state-machine",
"aws-batch-privesc-submit-existing-job",
"aws-codedeploy-privesc-create-deployment",
"aws-stepfunctions-privesc-update-state-machine",
"aws-iam-privesc-delete-role-boundary-assume-role",
"aws-sso-privesc-attach-managed-policy-permission-set",
"aws-sso-privesc-put-inline-policy-permission-set",
]
_BY_ID = {q.id: q for q in AWS_QUERIES}
NEW_QUERIES = [_BY_ID[qid] for qid in PATHFINDING_2279_QUERY_IDS if qid in _BY_ID]
NEPTUNE_UNSUPPORTED_PREDICATES = re.compile(r"\b(any|all|none)\s*\(", re.IGNORECASE)
NORMALIZED_STATEMENT_FIELDS = ("action", "resource", "notaction", "notresource")
def test_all_2279_queries_registered():
missing = [qid for qid in PATHFINDING_2279_QUERY_IDS if qid not in _BY_ID]
assert not missing, f"queries not registered in AWS_QUERIES: {missing}"
class TestServicePrivescQuerySchema:
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_is_query_definition(self, query):
assert isinstance(query, AttackPathsQueryDefinition)
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_id_kebab_and_aws_prefixed(self, query):
assert query.id.startswith("aws-")
assert re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", query.id)
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_provider_is_aws(self, query):
assert query.provider == "aws"
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_has_metadata(self, query):
assert query.name and len(query.name) > 5
assert query.short_description and len(query.short_description) > 10
assert query.description and len(query.description) > 20
assert isinstance(query.parameters, list)
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_attribution_links_pathfinding(self, query):
assert query.attribution is not None
assert "pathfinding.cloud" in query.attribution.text
assert query.attribution.link.startswith("https://pathfinding.cloud/paths/")
class TestServicePrivescQueryCypher:
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_anchored_and_provider_scoped(self, query):
assert "(aws:AWSAccount {id: $provider_uid})" in query.cypher
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_finding_label_interpolated(self, query):
assert "PROWLER_FINDING_LABEL" not in query.cypher
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_typed_status_scoped_finding_probe(self, query):
assert re.search(
r"-\[pfr:HAS_FINDING\]-\(pf:ProwlerFinding \{status: 'FAIL'\}\)",
query.cypher,
), f"{query.id} lacks the typed, status-scoped finding probe"
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_return_contract(self, query):
assert re.search(
r"RETURN paths, collect\(DISTINCT pf\) as dpf, "
r"collect\(DISTINCT pfr\) as dpfr",
query.cypher,
)
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_no_neptune_unsupported_predicates(self, query):
m = NEPTUNE_UNSUPPORTED_PREDICATES.search(query.cypher)
assert m is None, f"{query.id} uses '{m.group().strip()}' (not Neptune-safe)"
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_no_regex_operator(self, query):
assert "=~" not in query.cypher
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_does_not_read_normalized_list_fields(self, query):
for field in NORMALIZED_STATEMENT_FIELDS:
assert not re.search(rf"\.{field}\b", query.cypher), (
f"{query.id} reads normalized list field '.{field}' as a property; "
f"traverse the HAS_{field.upper()} edge instead"
)
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_read_only(self, query):
no_comments = "\n".join(
line
for line in query.cypher.split("\n")
if not line.strip().startswith("//")
)
assert not re.search(
r"\b(CREATE|MERGE|SET|DELETE|REMOVE|DETACH)\b", no_comments, re.IGNORECASE
)
+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
+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
@@ -0,0 +1,63 @@
from datetime import UTC, datetime
from itertools import islice
import pytest
from api.partitions import (
PostgresUUIDv7PartitioningStrategy,
relative_months_or_none,
)
from dateutil.relativedelta import relativedelta
from django.core.exceptions import ImproperlyConfigured
from psqlextra.partitioning import PostgresTimePartitionSize
def build_strategy(max_age):
return PostgresUUIDv7PartitioningStrategy(
size=PostgresTimePartitionSize(months=1),
count=1,
start_date=datetime.now(UTC),
max_age=max_age,
name_format="%Y_%b",
)
class TestRelativeMonthsOrNone:
@pytest.mark.parametrize("value", [None, 0])
def test_unset_or_zero_keeps_partitions_indefinitely(self, value):
assert relative_months_or_none(value) is None
@pytest.mark.parametrize("months", [1, 3, 12])
def test_value_is_interpreted_as_months(self, months):
assert relative_months_or_none(months) == relativedelta(months=months)
def test_value_is_not_interpreted_as_days(self):
assert relative_months_or_none(12) != relativedelta(days=12)
def test_negative_is_rejected(self):
with pytest.raises(ImproperlyConfigured):
relative_months_or_none(-12)
class TestToDelete:
@pytest.mark.parametrize("max_age", [None, relative_months_or_none(0)])
def test_nothing_is_deleted_without_max_age(self, max_age):
strategy = build_strategy(max_age)
assert list(islice(strategy.to_delete(), 5)) == []
def test_first_deleted_partition_is_max_age_old(self):
months = 3
strategy = build_strategy(relative_months_or_none(months))
first = next(strategy.to_delete())
expected = strategy.get_start_datetime() - relativedelta(months=months)
assert first.name() == expected.strftime("%Y_%b").lower()
def test_deleted_partitions_go_further_back_in_time(self):
strategy = build_strategy(relative_months_or_none(3))
names = [p.name() for p in islice(strategy.to_delete(), 3)]
starts = [datetime.strptime(n, "%Y_%b") for n in names]
assert starts == sorted(starts, reverse=True)
+164 -1
View File
@@ -1,9 +1,10 @@
import errno
import logging
from unittest.mock import MagicMock, patch
import pytest
from config.settings import sentry as sentry_settings
from config.settings.sentry import before_send
from config.settings.sentry import before_send, errno_fingerprint
def test_initialize_sentry_skips_without_dsn():
@@ -188,3 +189,165 @@ def test_before_send_passes_non_defunct_neo4j_log():
event = MagicMock()
assert before_send(event, hint) == event
def _filesystem_hint(exception, msg="Error generating output directory"):
"""Build the hint the logging integration sends for a filesystem failure."""
exc_info = (type(exception), exception, exception.__traceback__)
log_record = _make_log_record(msg)
log_record.exc_info = exc_info
setattr(
log_record,
sentry_settings.ERROR_CATEGORY_ATTRIBUTE,
sentry_settings.FILESYSTEM_ERROR_CATEGORY,
)
return {"log_record": log_record, "exc_info": exc_info}
@pytest.mark.parametrize(
("error_number", "message", "expected_suffix"),
[
(errno.ENOSPC, "No space left on device", "errno:ENOSPC"),
(errno.ENOENT, "No such file or directory", "errno:ENOENT"),
(errno.EACCES, "Permission denied", "errno:EACCES"),
],
)
def test_before_send_fingerprints_oserror_by_errno(
error_number, message, expected_suffix
):
"""Filesystem failures raised from the same call site must not be merged."""
event = {}
result = before_send(event, _filesystem_hint(OSError(error_number, message)))
assert result is event
assert event["fingerprint"] == ["{{ default }}", expected_suffix]
def test_before_send_fingerprints_differ_per_errno():
"""ENOSPC and ENOENT from the same call site produce different issues."""
enospc_event = {}
enoent_event = {}
before_send(
enospc_event, _filesystem_hint(OSError(errno.ENOSPC, "No space left on device"))
)
before_send(
enoent_event,
_filesystem_hint(OSError(errno.ENOENT, "No such file or directory")),
)
assert enospc_event["fingerprint"] != enoent_event["fingerprint"]
def test_before_send_fingerprints_wrapped_oserror():
"""The errno is found even when the OSError is wrapped by another error."""
try:
try:
raise OSError(errno.ENOSPC, "No space left on device")
except OSError as os_error:
raise RuntimeError("Error generating output directory") from os_error
except RuntimeError as wrapper:
event = {}
before_send(event, _filesystem_hint(wrapper))
assert event["fingerprint"] == ["{{ default }}", "errno:ENOSPC"]
def test_before_send_does_not_fingerprint_non_oserror():
"""Non-filesystem exceptions keep Sentry's default grouping."""
event = {}
result = before_send(event, _filesystem_hint(ValueError("boom")))
assert result is event
assert "fingerprint" not in event
def test_before_send_does_not_fingerprint_unrelated_oserror_log():
"""Only records declaring the filesystem category opt into the errno grouping."""
exception = OSError(errno.ENOSPC, "No space left on device")
log_record = _make_log_record("Unrelated failure")
exc_info = (OSError, exception, None)
log_record.exc_info = exc_info
event = {}
result = before_send(event, {"log_record": log_record, "exc_info": exc_info})
assert result is event
assert "fingerprint" not in event
def test_before_send_does_not_fingerprint_exception_events():
"""Exception events without a log record keep Sentry's default grouping."""
event = {}
result = before_send(
event,
{"exc_info": (OSError, OSError(errno.ENOSPC, "No space left on device"), None)},
)
assert result is event
assert "fingerprint" not in event
@pytest.mark.parametrize("fingerprint", [["scope-fingerprint"], []])
def test_before_send_keeps_existing_fingerprint(fingerprint):
"""A fingerprint set by a scope or an integration is never overwritten."""
expected_fingerprint = fingerprint.copy()
event = {"fingerprint": fingerprint}
before_send(
event, _filesystem_hint(OSError(errno.ENOSPC, "No space left on device"))
)
assert event["fingerprint"] == expected_fingerprint
def test_before_send_ignores_suppressed_context():
"""`raise ... from None` hides the context, so it must not group the event."""
try:
try:
raise OSError(errno.ENOSPC, "No space left on device")
except OSError:
raise RuntimeError("Error generating output directory") from None
except RuntimeError as wrapper:
event = {}
before_send(event, _filesystem_hint(wrapper))
assert "fingerprint" not in event
def test_errno_fingerprint_follows_implicit_context():
"""An implicit `raise` during handling still exposes the original errno."""
try:
try:
raise OSError(errno.EACCES, "Permission denied")
except OSError:
raise RuntimeError("Error generating output directory")
except RuntimeError as wrapper:
assert errno_fingerprint(wrapper) == "errno:EACCES"
def test_before_send_does_not_fingerprint_oserror_without_errno():
"""An OSError without errno has nothing to split the issue by."""
event = {}
before_send(event, _filesystem_hint(OSError("no errno here")))
assert "fingerprint" not in event
def test_errno_fingerprint_uses_raw_number_for_unknown_errno():
"""Unmapped errno values still split the issue instead of being dropped."""
assert errno_fingerprint(OSError(9999, "unknown")) == "errno:9999"
def test_errno_fingerprint_stops_on_self_referencing_chain():
"""A cyclic exception chain must not hang the fingerprint lookup."""
first = ValueError("first")
second = ValueError("second")
first.__cause__ = second
second.__cause__ = first
assert errno_fingerprint(first) is None
+21 -23
View File
@@ -215,36 +215,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 +251,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:
+34 -8
View File
@@ -172,7 +172,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 +182,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 +193,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,11 +255,35 @@ 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,
)
@@ -434,7 +459,7 @@ 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_oraclecloud_maps_region_to_home_region(
self,
):
secret_dict = {
@@ -461,6 +486,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):
+789 -35
View File
@@ -19,6 +19,7 @@ from allauth.account.models import EmailAddress
from allauth.socialaccount.models import SocialAccount, SocialApp
from api.attack_paths import (
AttackPathsQueryDefinition,
AttackPathsQueryOutcome,
AttackPathsQueryParameterDefinition,
)
from api.compliance import get_compliance_frameworks
@@ -59,6 +60,7 @@ 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.views import (
@@ -77,10 +79,11 @@ from conftest import (
today_after_n_days,
)
from django.conf import settings
from django.db import close_old_connections, connection
from django.db import close_old_connections, connection, connections
from django.db.models import Count
from django.db.models.signals import pre_delete
from django.http import JsonResponse
from django.test import RequestFactory
from django.test import RequestFactory, override_settings
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from django_celery_results.models import TaskResult
@@ -518,6 +521,50 @@ class TestUserViewSet:
assert error_field in response.json()["errors"][0]["source"]["pointer"]
@pytest.mark.requires_test_admin_alias
@pytest.mark.django_db(transaction=True, databases=["default", "admin"])
class TestTenantDeletionTransactions:
@patch("api.v1.views.delete_tenant_task.apply_async")
def test_delete_rolls_back_memberships_when_user_cleanup_fails(
self,
delete_tenant_mock,
authenticated_client,
tenants_fixture,
):
assert connections["default"] is not connections["admin"]
_, tenant, _ = tenants_fixture
exclusive_user = User.objects.create_user(
name="exclusive user",
password=TEST_PASSWORD,
email="exclusive-user@example.com",
)
membership = Membership.objects.create(
user=exclusive_user,
tenant=tenant,
role=Membership.RoleChoices.MEMBER,
)
def fail_user_cleanup(*, instance, **kwargs):
if instance.pk == exclusive_user.pk:
raise RuntimeError("Simulated user cleanup failure.")
pre_delete.connect(fail_user_cleanup, sender=User)
try:
with (
patch.object(MainRouter, "admin_db", "admin"),
pytest.raises(RuntimeError, match=r"Simulated user cleanup failure\."),
):
authenticated_client.delete(
reverse("tenant-detail", kwargs={"pk": tenant.id})
)
finally:
pre_delete.disconnect(fail_user_cleanup, sender=User)
assert Membership.objects.using("admin").filter(pk=membership.pk).exists()
delete_tenant_mock.assert_not_called()
@pytest.mark.django_db
class TestTenantViewSet:
@pytest.fixture
@@ -3316,7 +3363,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,
@@ -3325,14 +3372,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,
@@ -3365,7 +3412,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,
@@ -3383,7 +3430,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 ")
},
}
}
@@ -3396,7 +3443,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",
@@ -3905,6 +3952,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,
@@ -4494,6 +4578,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
):
@@ -5110,6 +5240,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})
)
@@ -5125,12 +5256,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:
@@ -5388,6 +5818,72 @@ class TestAttackPathsScanViewSet:
assert payload[0]["attributes"]["name"] == "RDS inventory"
assert payload[0]["attributes"]["parameters"][0]["name"] == "ip"
def test_attack_paths_queries_expose_outcome(
self,
authenticated_client,
aws_provider,
scans_fixture,
create_attack_paths_scan,
):
provider = aws_provider
attack_paths_scan = create_attack_paths_scan(
provider,
scan=scans_fixture[0],
)
definitions = [
AttackPathsQueryDefinition(
id="aws-lambda-passrole",
name="Lambda passrole",
short_description="Pass a role to a new Lambda function.",
description="Pass a role to a new Lambda function and run code as it.",
provider=provider.provider,
cypher="MATCH (n) RETURN n",
outcome=AttackPathsQueryOutcome.CODE_EXECUTION,
),
AttackPathsQueryDefinition(
id="aws-rds-inventory",
name="RDS inventory",
short_description="List account RDS assets.",
description="List account RDS assets.",
provider=provider.provider,
cypher="MATCH (n) RETURN n",
outcome=AttackPathsQueryOutcome.RESOURCE_INVENTORY,
),
AttackPathsQueryDefinition(
id="aws-no-outcome",
name="No outcome",
short_description="A query without an outcome.",
description="A query without an outcome.",
provider=provider.provider,
cypher="MATCH (n) RETURN n",
),
]
with patch("api.v1.views.get_queries_for_provider", return_value=definitions):
response = authenticated_client.get(
reverse(
"attack-paths-scans-queries", kwargs={"pk": attack_paths_scan.id}
)
)
assert response.status_code == status.HTTP_200_OK
outcomes = {
item["id"]: item["attributes"]["outcome"]
for item in response.json()["data"]
}
assert outcomes["aws-lambda-passrole"] == {
"kind": "code_execution",
"label": "Code execution",
"partial": False,
}
assert outcomes["aws-rds-inventory"] == {
"kind": "resource_inventory",
"label": "Resource inventory",
"partial": True,
}
assert outcomes["aws-no-outcome"] is None
def test_attack_paths_queries_returns_404_when_catalog_missing(
self,
authenticated_client,
@@ -8672,6 +9168,190 @@ class TestInvitationViewSet:
user.id
)
@staticmethod
def _invitation_create_payload(email, role):
return json.dumps(
{
"data": {
"type": "invitations",
"attributes": {"email": email},
"relationships": {
"roles": {"data": [{"type": "roles", "id": str(role.id)}]}
},
}
}
)
@staticmethod
def _create_lapsed_invitation(email, tenant, inviter):
return Invitation.objects.create(
email=email,
state=Invitation.State.PENDING,
expires_at=datetime.now(UTC) - timedelta(days=1),
inviter=inviter,
tenant=tenant,
)
def test_invitations_create_with_lapsed_pending_invitation_for_same_email(
self,
authenticated_client,
create_test_user,
tenants_fixture,
invitations_fixture,
roles_fixture,
):
lapsed_invitation, expired_invitation = invitations_fixture
lapsed_invitation.expires_at = datetime.now(UTC) - timedelta(days=1)
lapsed_invitation.save()
other_email_lapsed_invitation = self._create_lapsed_invitation(
"other@prowler.com", tenants_fixture[0], create_test_user
)
response = authenticated_client.post(
reverse("invitation-list"),
data=self._invitation_create_payload(
lapsed_invitation.email, roles_fixture[0]
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
new_invitation = Invitation.objects.get(id=response.json()["data"]["id"])
assert new_invitation.email == lapsed_invitation.email
assert new_invitation.state == Invitation.State.PENDING
lapsed_invitation.refresh_from_db()
assert lapsed_invitation.state == Invitation.State.EXPIRED
expired_invitation.refresh_from_db()
assert expired_invitation.state == Invitation.State.EXPIRED
other_email_lapsed_invitation.refresh_from_db()
assert other_email_lapsed_invitation.state == Invitation.State.PENDING
def test_invitations_create_with_active_pending_invitation_for_same_email(
self,
authenticated_client,
create_test_user,
tenants_fixture,
invitations_fixture,
roles_fixture,
):
active_invitation, _ = invitations_fixture
self._create_lapsed_invitation(
active_invitation.email, tenants_fixture[0], create_test_user
)
invitation_count = Invitation.objects.count()
response = authenticated_client.post(
reverse("invitation-list"),
data=self._invitation_create_payload(
active_invitation.email, roles_fixture[0]
),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert (
response.json()["errors"][0]["source"]["pointer"]
== "/data/attributes/email"
)
assert Invitation.objects.count() == invitation_count
active_invitation.refresh_from_db()
assert active_invitation.state == Invitation.State.PENDING
def test_invitations_create_ignores_pending_invitations_from_other_tenants(
self, authenticated_client, create_test_user, tenants_fixture, roles_fixture
):
email = "cross_tenant@prowler.com"
other_tenant = tenants_fixture[1]
other_tenant_lapsed_invitation = self._create_lapsed_invitation(
email, other_tenant, create_test_user
)
Invitation.objects.create(
email=email, inviter=create_test_user, tenant=other_tenant
)
response = authenticated_client.post(
reverse("invitation-list"),
data=self._invitation_create_payload(email, roles_fixture[0]),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_201_CREATED
other_tenant_lapsed_invitation.refresh_from_db()
assert other_tenant_lapsed_invitation.state == Invitation.State.PENDING
def test_invitations_report_lapsed_pending_invitation_as_expired(
self,
authenticated_client,
create_test_user,
tenants_fixture,
invitations_fixture,
):
active_invitation, expired_invitation = invitations_fixture
lapsed_invitation = self._create_lapsed_invitation(
"lapsed@prowler.com", tenants_fixture[0], create_test_user
)
list_response = authenticated_client.get(reverse("invitation-list"))
retrieve_response = authenticated_client.get(
reverse("invitation-detail", kwargs={"pk": lapsed_invitation.id})
)
assert list_response.status_code == status.HTTP_200_OK
assert retrieve_response.status_code == status.HTTP_200_OK
assert {
invitation["id"]: invitation["attributes"]["state"]
for invitation in list_response.json()["data"]
} == {
str(active_invitation.id): Invitation.State.PENDING.value,
str(expired_invitation.id): Invitation.State.EXPIRED.value,
str(lapsed_invitation.id): Invitation.State.EXPIRED.value,
}
assert (
retrieve_response.json()["data"]["attributes"]["state"]
== Invitation.State.EXPIRED.value
)
@pytest.mark.parametrize(
"filter_name, filter_value, expected_invitations",
[
("state", "pending", {"active"}),
("state", "expired", {"expired", "lapsed"}),
("state", "accepted", set()),
("state__in", "pending", {"active"}),
("state__in", "expired", {"expired", "lapsed"}),
("state__in", "pending,expired", {"active", "expired", "lapsed"}),
("state__in", "accepted,revoked", set()),
],
)
def test_invitations_filter_state_treats_lapsed_pending_as_expired(
self,
authenticated_client,
create_test_user,
tenants_fixture,
invitations_fixture,
filter_name,
filter_value,
expected_invitations,
):
active_invitation, expired_invitation = invitations_fixture
lapsed_invitation = self._create_lapsed_invitation(
"lapsed@prowler.com", tenants_fixture[0], create_test_user
)
invitation_ids = {
"active": str(active_invitation.id),
"expired": str(expired_invitation.id),
"lapsed": str(lapsed_invitation.id),
}
response = authenticated_client.get(
reverse("invitation-list"), {f"filter[{filter_name}]": filter_value}
)
assert response.status_code == status.HTTP_200_OK
assert {invitation["id"] for invitation in response.json()["data"]} == {
invitation_ids[name] for name in expected_invitations
}
@pytest.mark.parametrize(
"email",
[
@@ -8679,8 +9359,10 @@ class TestInvitationViewSet:
"invalid_email@",
# There is a pending invitation with this email
"testing@prowler.com",
"TESTING@prowler.com",
# User is already a member of the tenant
TEST_USER,
TEST_USER.upper(),
],
)
def test_invitations_create_invalid_email(
@@ -8935,6 +9617,56 @@ class TestInvitationViewSet:
== "This invitation cannot be revoked."
)
def test_invitations_delete_lapsed_invitation(
self, authenticated_client, invitations_fixture
):
invitation, *_ = invitations_fixture
invitation.expires_at = datetime.now(UTC) - timedelta(days=1)
invitation.save()
response = authenticated_client.delete(
reverse("invitation-detail", kwargs={"pk": str(invitation.id)})
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert (
response.json()["errors"][0]["detail"]
== "This invitation cannot be revoked."
)
invitation.refresh_from_db()
assert invitation.state == Invitation.State.PENDING
def test_invitations_partial_update_lapsed_invitation(
self, authenticated_client, invitations_fixture
):
invitation, *_ = invitations_fixture
invitation.expires_at = datetime.now(UTC) - timedelta(days=1)
invitation.save()
data = {
"data": {
"id": str(invitation.id),
"type": "invitations",
"attributes": {
"email": invitation.email,
"expires_at": self.TOMORROW_ISO,
},
}
}
response = authenticated_client.patch(
reverse("invitation-detail", kwargs={"pk": str(invitation.id)}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert (
response.json()["errors"][0]["detail"]
== "This invitation cannot be updated."
)
invitation.refresh_from_db()
assert invitation.is_lapsed
def test_invitations_accept_invitation_new_user(self, client, invitations_fixture):
invitation, *_ = invitations_fixture
@@ -14561,6 +15293,37 @@ class TestSAMLConfigurationViewSet:
assert not SAMLConfiguration.objects.filter(id=config.id).exists()
@pytest.mark.django_db
class TestSAMLACSView:
def test_get_is_not_allowed(self, client, saml_setup):
response = client.get(
reverse(
"saml_acs",
kwargs={"organization_slug": saml_setup["domain"]},
)
)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
assert response.headers["Allow"] == "POST"
assert "saml-acs-session" not in response.cookies
def test_post_is_forwarded_to_allauth(self, client, saml_setup):
response = client.post(
reverse(
"saml_acs",
kwargs={"organization_slug": saml_setup["domain"]},
),
data={"SAMLResponse": "test-saml-response"},
)
assert response.status_code == status.HTTP_302_FOUND
assert response.url == reverse(
"saml_finish_acs",
kwargs={"organization_slug": saml_setup["domain"]},
)
assert "saml-acs-session" in response.cookies
@pytest.mark.django_db
class TestTenantFinishACSView:
def test_dispatch_skips_if_user_not_authenticated(self, monkeypatch):
@@ -18190,19 +18953,14 @@ class TestMuteRuleViewSet:
assert len(data) == 2
assert data[0]["id"] == str(mute_rules_fixture[first_index].id)
@patch("api.v1.views.chain")
@patch("api.v1.views.reaggregate_all_finding_group_summaries_task.si")
@patch("api.v1.views.mute_historical_findings_task.si")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
@patch("api.v1.views.transaction.on_commit", side_effect=lambda fn: fn())
def test_mute_rules_create_valid(
self,
_mock_on_commit,
mock_mute_signature,
mock_reaggregate_signature,
mock_chain,
mock_mute_task,
authenticated_client,
findings_fixture,
create_test_user,
):
"""Test creating a valid mute rule."""
finding_ids = [str(findings_fixture[0].id)]
@@ -18229,24 +18987,20 @@ class TestMuteRuleViewSet:
assert response_data["attributes"]["name"] == "New Mute Rule"
assert response_data["attributes"]["reason"] == "Security exception approved"
# Verify the finding was immediately muted
from api.models import Finding
finding = Finding.objects.get(id=findings_fixture[0].id)
assert finding.muted is True
assert finding.muted_at is not None
assert finding.muted_reason == "Security exception approved"
assert finding.muted is False
assert finding.muted_at is None
assert finding.muted_reason is None
# Verify background task chain was called: mute → reaggregate all
mock_mute_signature.assert_called_once()
mock_reaggregate_signature.assert_called_once()
mock_chain.assert_called_once_with(
mock_mute_signature.return_value,
mock_reaggregate_signature.return_value,
mock_mute_task.assert_called_once_with(
kwargs={
"tenant_id": str(finding.tenant_id),
"mute_rule_id": response_data["id"],
"provider_ids": [str(finding.scan.provider_id)],
}
)
mock_chain.return_value.apply_async.assert_called_once()
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_create_converts_finding_ids_to_uids(
self,
mock_task,
@@ -18282,7 +19036,7 @@ class TestMuteRuleViewSet:
]
assert set(mute_rule.finding_uids) == set(expected_uids)
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_deduplicates_uids(
self,
mock_task,
@@ -18349,10 +19103,10 @@ class TestMuteRuleViewSet:
finding1.refresh_from_db()
finding2.refresh_from_db()
assert finding1.muted is True
assert finding2.muted is True
assert finding1.muted is False
assert finding2.muted is False
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_create_overlap_detection_active(
self,
mock_task,
@@ -18385,7 +19139,7 @@ class TestMuteRuleViewSet:
"already muted" in error_detail.lower() or "overlap" in error_detail.lower()
)
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_create_no_overlap_with_inactive(
self,
mock_task,
@@ -18441,7 +19195,7 @@ class TestMuteRuleViewSet:
== "/data/attributes/finding_ids"
)
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async")
def test_mute_rules_create_invalid_finding_ids(
self, mock_task, authenticated_client
):
+14 -7
View File
@@ -302,17 +302,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 +332,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
@@ -301,8 +301,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"],
+64 -7
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
@@ -329,6 +330,15 @@ class TokenSwitchTenantSerializer(BaseSerializerV1):
# Users
class ActiveMembershipRelatedField(SerializerMethodResourceRelatedField):
def to_representation(self, value):
representation = super().to_representation(value)
representation["meta"] = {
"active": str(value.tenant_id) == str(self.context["request"].tenant_id),
}
return representation
class UserSerializer(BaseModelSerializerV1):
"""
Serializer for the User model.
@@ -390,6 +400,12 @@ class UserSerializer(BaseModelSerializerV1):
)
class UserMeSerializer(UserSerializer):
memberships = ActiveMembershipRelatedField(
many=True, read_only=True, source="memberships", method_name="get_memberships"
)
class UserIncludeSerializer(UserSerializer):
class Meta:
model = User
@@ -1315,6 +1331,28 @@ class AttackPathsQuerySerializer(BaseSerializerV1):
attribution = AttackPathsQueryAttributionSerializer(allow_null=True, required=False)
provider = serializers.CharField()
parameters = AttackPathsQueryParameterSerializer(many=True)
# The terminal impact the query leads to (e.g. {"kind": "code_execution",
# "label": "Code execution"}), or null if the query has none. The UI renders
# this as the graph's terminal outcome node.
outcome = serializers.SerializerMethodField()
@extend_schema_field(
{
"type": "object",
"nullable": True,
"properties": {
"kind": {"type": "string"},
"label": {"type": "string"},
"partial": {"type": "boolean"},
},
}
)
def get_outcome(self, definition):
outcome = getattr(definition, "outcome", None)
if outcome is None:
return None
meta = outcome.value
return {"kind": meta.kind, "label": meta.label, "partial": meta.partial}
class JSONAPIMeta:
resource_name = "attack-paths-queries"
@@ -1880,9 +1918,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
@@ -1895,10 +1940,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(
@@ -2112,6 +2158,12 @@ class InvitationSerializer(RLSSerializer):
if tenant_id is not None:
self.fields["roles"].queryset = Role.objects.filter(tenant_id=tenant_id)
def to_representation(self, instance):
data = super().to_representation(instance)
if instance.is_lapsed:
data["state"] = Invitation.State.EXPIRED.value
return data
class Meta:
model = Invitation
fields = [
@@ -2138,6 +2190,7 @@ class InvitationBaseWriteSerializer(BaseWriteSerializer):
self.fields["roles"].queryset = Role.objects.filter(tenant_id=tenant_id)
def validate_email(self, value):
value = value.strip().lower()
user = User.objects.filter(email=value).first()
tenant_id = self.context["tenant_id"]
if user and Membership.objects.filter(user=user, tenant=tenant_id).exists():
@@ -2145,9 +2198,13 @@ class InvitationBaseWriteSerializer(BaseWriteSerializer):
"The user may already be a member of the tenant or there was an issue with the "
"email provided."
)
if Invitation.objects.filter(
email=value, state=Invitation.State.PENDING
).exists():
pending_invitations = Invitation.objects.filter(
tenant_id=tenant_id, email=value, state=Invitation.State.PENDING
)
pending_invitations.filter(Invitation.lapsed_q()).update(
state=Invitation.State.EXPIRED
)
if pending_invitations.filter(expires_at__gt=datetime.now(UTC)).exists():
raise ValidationError(
"Unable to process your request. Please check the information provided and "
"try again."
+2 -1
View File
@@ -46,6 +46,7 @@ from api.v1.views import (
from django.http import JsonResponse
from django.urls import include, path
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from drf_spectacular.views import SpectacularRedocView
from rest_framework_nested import routers
@@ -194,7 +195,7 @@ urlpatterns = [
),
path(
"accounts/saml/<organization_slug>/acs/",
ACSView.as_view(),
require_POST(ACSView.as_view()),
name="saml_acs",
),
path(

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