From 03dfa3816d1b211b4b78b920205d8650f3a1bb6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 27 May 2026 17:26:50 +0200 Subject: [PATCH 01/14] docs: fix alerts/import-findings URLs and pricing note (#11378) --- README.md | 2 +- action.yml | 4 ++-- docs/docs.json | 12 ++++++++++-- docs/user-guide/cookbooks/cicd-pipeline.mdx | 6 +++--- docs/user-guide/cookbooks/kubernetes-in-cluster.mdx | 6 +++--- .../{prowler-app-alerts.mdx => prowler-alerts.mdx} | 2 +- .../tutorials/prowler-app-github-action.mdx | 4 ++-- docs/user-guide/tutorials/prowler-app-rbac.mdx | 2 +- ...port-findings.mdx => prowler-import-findings.mdx} | 2 +- 9 files changed, 24 insertions(+), 16 deletions(-) rename docs/user-guide/tutorials/{prowler-app-alerts.mdx => prowler-alerts.mdx} (97%) rename docs/user-guide/tutorials/{prowler-app-import-findings.mdx => prowler-import-findings.mdx} (99%) diff --git a/README.md b/README.md index ad28c81e3d..41ad716c2b 100644 --- a/README.md +++ b/README.md @@ -293,7 +293,7 @@ python prowler-cli.py -v # ๐Ÿ›ก๏ธ GitHub Action -The official **Prowler GitHub Action** runs Prowler scans in your GitHub workflows using the official [`prowlercloud/prowler`](https://hub.docker.com/r/prowlercloud/prowler) Docker image. Scans run on any [supported provider](https://docs.prowler.com/user-guide/providers/), with optional [`--push-to-cloud`](https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings) to send findings to Prowler Cloud and optional SARIF upload so findings show up in the repo's **Security โ†’ Code scanning** tab and as inline PR annotations. +The official **Prowler GitHub Action** runs Prowler scans in your GitHub workflows using the official [`prowlercloud/prowler`](https://hub.docker.com/r/prowlercloud/prowler) Docker image. Scans run on any [supported provider](https://docs.prowler.com/user-guide/providers/), with optional [`--push-to-cloud`](https://docs.prowler.com/user-guide/tutorials/prowler-import-findings) to send findings to Prowler Cloud and optional SARIF upload so findings show up in the repo's **Security โ†’ Code scanning** tab and as inline PR annotations. ```yaml name: Prowler IaC Scan diff --git a/action.yml b/action.yml index a93c67d4e8..2be0649402 100644 --- a/action.yml +++ b/action.yml @@ -22,7 +22,7 @@ inputs: required: false default: json-ocsf push-to-cloud: - description: Push scan findings to Prowler Cloud. Requires the PROWLER_CLOUD_API_KEY environment variable. See https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings#using-the-cli + description: Push scan findings to Prowler Cloud. Requires the PROWLER_CLOUD_API_KEY environment variable. See https://docs.prowler.com/user-guide/tutorials/prowler-import-findings#using-the-cli required: false default: "false" flags: @@ -299,7 +299,7 @@ runs: echo "" echo "**Get started in 3 steps:**" echo "1. Create an account at [cloud.prowler.com](https://cloud.prowler.com)" - echo "2. Generate a Prowler Cloud API key ([docs](https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings#using-the-cli))" + echo "2. Generate a Prowler Cloud API key ([docs](https://docs.prowler.com/user-guide/tutorials/prowler-import-findings#using-the-cli))" echo "3. Add \`PROWLER_CLOUD_API_KEY\` to your GitHub secrets and set \`push-to-cloud: true\` on this action" echo "" echo "See [prowler.com/pricing](https://prowler.com/pricing) for plan details." diff --git a/docs/docs.json b/docs/docs.json index 8c3c79e8be..836ff50513 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -124,8 +124,8 @@ "user-guide/tutorials/prowler-app-rbac", "user-guide/tutorials/prowler-app-multi-tenant", "user-guide/tutorials/prowler-app-api-keys", - "user-guide/tutorials/prowler-app-import-findings", - "user-guide/tutorials/prowler-app-alerts", + "user-guide/tutorials/prowler-import-findings", + "user-guide/tutorials/prowler-alerts", { "group": "Mutelist", "expanded": true, @@ -576,6 +576,14 @@ { "source": "/contact", "destination": "/support" + }, + { + "source": "/user-guide/tutorials/prowler-app-import-findings", + "destination": "/user-guide/tutorials/prowler-import-findings" + }, + { + "source": "/user-guide/tutorials/prowler-app-alerts", + "destination": "/user-guide/tutorials/prowler-alerts" } ] } diff --git a/docs/user-guide/cookbooks/cicd-pipeline.mdx b/docs/user-guide/cookbooks/cicd-pipeline.mdx index ee7d49eb91..a88513d901 100644 --- a/docs/user-guide/cookbooks/cicd-pipeline.mdx +++ b/docs/user-guide/cookbooks/cicd-pipeline.mdx @@ -6,7 +6,7 @@ title: 'Run Prowler in CI/CD and Send Findings to Prowler Cloud' For new projects, use the official [Prowler GitHub Action](/user-guide/tutorials/prowler-app-github-action) โ€” a Docker-based reusable action that runs scans, optionally pushes findings to Prowler Cloud, and uploads SARIF results to GitHub Code Scanning. The GitHub Actions examples below document the legacy pip-based flow. -This cookbook demonstrates how to integrate Prowler into CI/CD pipelines so that security scans run automatically and findings are sent to Prowler Cloud via [Import Findings](/user-guide/tutorials/prowler-app-import-findings). Examples cover GitHub Actions and GitLab CI. +This cookbook demonstrates how to integrate Prowler into CI/CD pipelines so that security scans run automatically and findings are sent to Prowler Cloud via [Import Findings](/user-guide/tutorials/prowler-import-findings). Examples cover GitHub Actions and GitLab CI. ## Prerequisites @@ -19,7 +19,7 @@ This cookbook demonstrates how to integrate Prowler into CI/CD pipelines so that Prowler CLI provides the `--push-to-cloud` flag, which uploads scan results directly to Prowler Cloud after a scan completes. Combined with the `PROWLER_CLOUD_API_KEY` environment variable, this enables fully automated ingestion without manual file uploads. -For full details on the flag and API, refer to the [Import Findings](/user-guide/tutorials/prowler-app-import-findings) documentation. +For full details on the flag and API, refer to the [Import Findings](/user-guide/tutorials/prowler-import-findings) documentation. The examples in this guide use AWS as the target provider, but the same approach applies to any provider supported by Prowler (Azure, GCP, Kubernetes, and others). Replace `prowler aws` with the desired provider command (e.g., `prowler gcp`, `prowler azure`) and configure the corresponding credentials in the CI/CD environment. @@ -195,7 +195,7 @@ By default, Prowler exits with a non-zero code when it finds failing checks. Thi * **GitLab CI**: Add `allow_failure: true` to the job -Ingestion failures (e.g., network issues reaching Prowler Cloud) do not affect the Prowler exit code. The scan completes normally and only a warning is emitted. See [Import Findings troubleshooting](/user-guide/tutorials/prowler-app-import-findings#troubleshooting) for details. +Ingestion failures (e.g., network issues reaching Prowler Cloud) do not affect the Prowler exit code. The scan completes normally and only a warning is emitted. See [Import Findings troubleshooting](/user-guide/tutorials/prowler-import-findings#troubleshooting) for details. ### Caching Prowler Installation diff --git a/docs/user-guide/cookbooks/kubernetes-in-cluster.mdx b/docs/user-guide/cookbooks/kubernetes-in-cluster.mdx index 765bcf9313..661eeb17da 100644 --- a/docs/user-guide/cookbooks/kubernetes-in-cluster.mdx +++ b/docs/user-guide/cookbooks/kubernetes-in-cluster.mdx @@ -2,7 +2,7 @@ title: 'Run Kubernetes In-Cluster and Send Findings to Prowler Cloud' --- -This cookbook walks through deploying Prowler inside a Kubernetes cluster on a recurring schedule and automatically sending findings to Prowler Cloud via [Import Findings](/user-guide/tutorials/prowler-app-import-findings). By the end, security scan results from the cluster appear in Prowler Cloud without any manual file uploads. +This cookbook walks through deploying Prowler inside a Kubernetes cluster on a recurring schedule and automatically sending findings to Prowler Cloud via [Import Findings](/user-guide/tutorials/prowler-import-findings). By the end, security scan results from the cluster appear in Prowler Cloud without any manual file uploads. ## Prerequisites @@ -181,7 +181,7 @@ Once the job completes and findings are pushed: 2. Open the "Scans" section to verify the ingestion job status 3. Browse findings under the Kubernetes provider -For details on the ingestion workflow and status tracking, refer to the [Import Findings](/user-guide/tutorials/prowler-app-import-findings) documentation. +For details on the ingestion workflow and status tracking, refer to the [Import Findings](/user-guide/tutorials/prowler-import-findings) documentation. ## Tips and Troubleshooting @@ -204,4 +204,4 @@ For details on the ingestion workflow and status tracking, refer to the [Import --namespace prowler-ns ``` -* **Failed uploads**: If the push to Prowler Cloud fails, the scan still completes and findings are saved locally in the container. Check the [Import Findings troubleshooting section](/user-guide/tutorials/prowler-app-import-findings#troubleshooting) for common error messages. +* **Failed uploads**: If the push to Prowler Cloud fails, the scan still completes and findings are saved locally in the container. Check the [Import Findings troubleshooting section](/user-guide/tutorials/prowler-import-findings#troubleshooting) for common error messages. diff --git a/docs/user-guide/tutorials/prowler-app-alerts.mdx b/docs/user-guide/tutorials/prowler-alerts.mdx similarity index 97% rename from docs/user-guide/tutorials/prowler-app-alerts.mdx rename to docs/user-guide/tutorials/prowler-alerts.mdx index f607844f97..3fa49f84ff 100644 --- a/docs/user-guide/tutorials/prowler-app-alerts.mdx +++ b/docs/user-guide/tutorials/prowler-alerts.mdx @@ -10,7 +10,7 @@ import { VersionBadge } from "/snippets/version-badge.mdx" Alerts notify recipients by email when security findings match saved filter conditions. Use Alerts to track high-priority findings, monitor specific providers or services, and keep teams informed about scan results that match defined criteria. -This feature is available exclusively in **Prowler Cloud** with a paid subscription. +This feature is available exclusively in **Prowler Cloud** and **Prowler Enterprise** with a [paid subscription](https://prowler.com/pricing). ## Prerequisites diff --git a/docs/user-guide/tutorials/prowler-app-github-action.mdx b/docs/user-guide/tutorials/prowler-app-github-action.mdx index 5b80511b89..c874a37164 100644 --- a/docs/user-guide/tutorials/prowler-app-github-action.mdx +++ b/docs/user-guide/tutorials/prowler-app-github-action.mdx @@ -18,7 +18,7 @@ Source: [`prowler-cloud/prowler`](https://github.com/prowler-cloud/prowler) ยท M | `provider` | yes | โ€” | Cloud provider to scan (`aws`, `azure`, `gcp`, `github`, `kubernetes`, `iac`, `cloudflare`, etc.) | | `image-tag` | no | `stable` | Docker image tag โ€” `stable` (latest release), `latest` (master, not stable), or `` (pinned). See [available tags](https://hub.docker.com/r/prowlercloud/prowler/tags). | | `output-formats` | no | `json-ocsf` | Output format(s) for scan results. Space-separated (e.g. `sarif json-ocsf`) | -| `push-to-cloud` | no | `false` | Push findings to [Prowler Cloud](/user-guide/tutorials/prowler-app-import-findings). When `true`, `PROWLER_CLOUD_API_KEY` is auto-forwarded | +| `push-to-cloud` | no | `false` | Push findings to [Prowler Cloud](/user-guide/tutorials/prowler-import-findings). When `true`, `PROWLER_CLOUD_API_KEY` is auto-forwarded | | `flags` | no | `""` | Additional CLI flags (e.g. `--severity critical high`). Values with spaces can be quoted: `--resource-tag 'Environment=My Server'` | | `extra-env` | no | `""` | Space-, newline-, or comma-separated list of env var **names** to forward to the container (see [Authentication](#authentication)) | | `upload-sarif` | no | `false` | Upload SARIF results to GitHub Code Scanning | @@ -43,7 +43,7 @@ Source: [`prowler-cloud/prowler`](https://github.com/prowler-cloud/prowler) ยท M ### Push findings to Prowler Cloud -Send scan results directly to [Prowler Cloud](/user-guide/tutorials/prowler-app-import-findings) for centralized visibility, compliance tracking, and team collaboration. +Send scan results directly to [Prowler Cloud](/user-guide/tutorials/prowler-import-findings) for centralized visibility, compliance tracking, and team collaboration. ```yaml - uses: prowler-cloud/prowler@5.25 diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx index cccbbc27cc..c6319591cf 100644 --- a/docs/user-guide/tutorials/prowler-app-rbac.mdx +++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx @@ -239,7 +239,7 @@ To grant all administrative permissions, select the **Grant all admin permission The following permissions are available exclusively in **Prowler Cloud**: -**Manage Ingestions:** Submit and manage findings ingestion jobs via the API. Required to upload OCSF scan results using the `--push-to-cloud` CLI flag or the ingestion endpoints. See [Import Findings](/user-guide/tutorials/prowler-app-import-findings) for details. +**Manage Ingestions:** Submit and manage findings ingestion jobs via the API. Required to upload OCSF scan results using the `--push-to-cloud` CLI flag or the ingestion endpoints. See [Import Findings](/user-guide/tutorials/prowler-import-findings) for details. **Manage Billing:** Access and manage billing settings, subscription plans, and payment methods. diff --git a/docs/user-guide/tutorials/prowler-app-import-findings.mdx b/docs/user-guide/tutorials/prowler-import-findings.mdx similarity index 99% rename from docs/user-guide/tutorials/prowler-app-import-findings.mdx rename to docs/user-guide/tutorials/prowler-import-findings.mdx index 9f1002c2a9..d4c8f8a74d 100644 --- a/docs/user-guide/tutorials/prowler-app-import-findings.mdx +++ b/docs/user-guide/tutorials/prowler-import-findings.mdx @@ -10,7 +10,7 @@ import { VersionBadge } from "/snippets/version-badge.mdx" Findings Ingestion enables uploading OCSF (Open Cybersecurity Schema Framework) scan results to Prowler Cloud. This feature supports importing findings from Prowler CLI output files that use the [Detection Finding](https://schema.ocsf.io/classes/detection_finding) class. -This feature is available exclusively in **Prowler Cloud** with a paid subscription. +This feature is available exclusively in **Prowler Cloud** and **Prowler Enterprise** with a [paid subscription](https://prowler.com/pricing). ## OCSF Detection Finding format From d98933c2e7142af64a914c94a80d6a832838a4aa Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Thu, 28 May 2026 09:37:28 +0200 Subject: [PATCH 02/14] fix(ui): improve invitation error messages (#11376) --- ui/CHANGELOG.md | 1 + ui/actions/auth/auth.test.ts | 66 +++++ ui/actions/auth/auth.ts | 2 +- .../invitation/_lib/invitation-errors.test.ts | 235 ++++++++++++++++++ .../invitation/_lib/invitation-errors.ts | 125 ++++++++++ .../accept/accept-invitation-client.tsx | 68 +---- ui/components/auth/oss/sign-up-form.tsx | 46 +++- ui/types/components.ts | 12 +- 8 files changed, 483 insertions(+), 72 deletions(-) create mode 100644 ui/actions/auth/auth.test.ts create mode 100644 ui/app/(auth)/invitation/_lib/invitation-errors.test.ts create mode 100644 ui/app/(auth)/invitation/_lib/invitation-errors.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index d0bbe038df..064d723a9b 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### ๐Ÿž Fixed - Compliance page now loads the most recent scan when opened from the sidebar instead of showing the "no compliance data available" alert [(#11374)](https://github.com/prowler-cloud/prowler/pull/11374) +- Invitation links now show specific expired, no-longer-valid, and invalid-token messages based on API error responses [(#11376)](https://github.com/prowler-cloud/prowler/pull/11376) --- diff --git a/ui/actions/auth/auth.test.ts b/ui/actions/auth/auth.test.ts new file mode 100644 index 0000000000..80409dd7e3 --- /dev/null +++ b/ui/actions/auth/auth.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchMock } = vi.hoisted(() => ({ + fetchMock: vi.fn(), +})); + +vi.mock("next-auth", () => ({ + AuthError: class AuthError extends Error {}, +})); + +vi.mock("@/auth.config", () => ({ + signIn: vi.fn(), + signOut: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", +})); + +vi.mock("@/lib/sentry-breadcrumbs", () => ({ + addAuthEvent: vi.fn(), +})); + +import { createNewUser } from "./auth"; + +describe("auth actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + }); + + it("should preserve HTTP status when user creation fails", async () => { + // Given + const apiResponse = { + errors: [ + { + status: "400", + code: "invalid", + detail: "Invalid invitation code.", + source: { pointer: "/data/attributes/invitation_token" }, + }, + ], + }; + fetchMock.mockResolvedValue( + new Response(JSON.stringify(apiResponse), { + status: 400, + headers: { "Content-Type": "application/json" }, + }), + ); + + // When + const result = await createNewUser({ + name: "Jane Doe", + email: "jane@example.com", + password: "TestPassword123!", + confirmPassword: "TestPassword123!", + company: "Prowler", + invitationToken: "invitation-token", + termsAndConditions: undefined, + isSamlMode: false, + }); + + // Then + expect(result).toEqual({ ...apiResponse, status: 400 }); + }); +}); diff --git a/ui/actions/auth/auth.ts b/ui/actions/auth/auth.ts index ca6fd2d099..f59d031918 100644 --- a/ui/actions/auth/auth.ts +++ b/ui/actions/auth/auth.ts @@ -78,7 +78,7 @@ export const createNewUser = async (formData: SignUpFormData) => { const parsedResponse = await response.json(); if (!response.ok) { - return parsedResponse; + return { ...parsedResponse, status: response.status }; } return parsedResponse; diff --git a/ui/app/(auth)/invitation/_lib/invitation-errors.test.ts b/ui/app/(auth)/invitation/_lib/invitation-errors.test.ts new file mode 100644 index 0000000000..f988eff408 --- /dev/null +++ b/ui/app/(auth)/invitation/_lib/invitation-errors.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from "vitest"; + +import { + getInvitationErrorDisplay, + INVITATION_ERROR_FLOW, + INVITATION_ERROR_MESSAGES, + isInvitationTokenError, +} from "./invitation-errors"; + +describe("getInvitationErrorDisplay", () => { + describe("when mapping invitation accept errors", () => { + it("should show expired message for token_expired responses", () => { + // Given + const response = { + status: 410, + errors: [ + { + status: "410", + code: "token_expired", + detail: "The invitation token has expired and is no longer valid.", + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.EXPIRED); + expect(result.canRetry).toBe(false); + }); + + it("should show no-longer-valid message for already accepted or revoked invitations", () => { + // Given + const response = { + status: 400, + errors: [ + { + status: "400", + code: "invalid", + detail: "This invitation is no longer valid.", + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.NO_LONGER_VALID); + expect(result.canRetry).toBe(false); + }); + + it("should show not-valid message for missing invitation tokens", () => { + // Given + const response = { + status: 404, + errors: [ + { + status: "404", + code: "not_found", + detail: "Invitation is not valid.", + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.NOT_VALID); + expect(result.canRetry).toBe(false); + }); + + it("should not allow retry for client-side malformed tokens", () => { + // Given + const response = { + error: "Invalid invitation token", + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.INVALID_FALLBACK); + expect(result.canRetry).toBe(false); + }); + }); + + describe("when mapping invitation signup errors", () => { + it("should not identify generic data errors as invitation token errors", () => { + // Given + const error = { + status: "400", + code: "invalid", + detail: "Invalid request data.", + source: { pointer: "/data" }, + }; + + // When + const result = isInvitationTokenError(error); + + // Then + expect(result).toBe(false); + }); + + it("should identify invitation token field errors", () => { + // Given + const error = { + status: "400", + code: "invalid", + detail: "Invalid invitation code.", + source: { pointer: "/data/attributes/invitation_token" }, + }; + + // When + const result = isInvitationTokenError(error); + + // Then + expect(result).toBe(true); + }); + + it("should use generic invalid fallback for non-invitation signup errors", () => { + // Given + const response = { + status: 400, + errors: [ + { + status: "400", + code: "invalid", + detail: "Invalid email address.", + source: { pointer: "/data/attributes/email" }, + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.SIGNUP, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.INVALID_FALLBACK); + expect(result.canRetry).toBe(false); + }); + + it("should show not-valid message for signup invalid invitation tokens", () => { + // Given + const response = { + status: 400, + errors: [ + { + status: "400", + code: "invalid", + detail: "Invalid invitation code.", + source: { pointer: "/data/attributes/invitation_token" }, + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.SIGNUP, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.NOT_VALID); + expect(result.canRetry).toBe(false); + }); + }); + + describe("when the response is unexpected", () => { + it("should use generic invalid fallback for unmapped invalid responses", () => { + // Given + const response = { + status: 400, + errors: [ + { + status: "400", + code: "invalid", + detail: "Unexpected invalid invitation response.", + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.INVALID_FALLBACK); + expect(result.canRetry).toBe(false); + }); + + it("should allow retry for unknown responses", () => { + // Given + const response = { + status: 500, + errors: [ + { + status: "500", + code: "server_error", + detail: "Something exploded.", + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.UNEXPECTED); + expect(result.canRetry).toBe(true); + }); + }); +}); diff --git a/ui/app/(auth)/invitation/_lib/invitation-errors.ts b/ui/app/(auth)/invitation/_lib/invitation-errors.ts new file mode 100644 index 0000000000..295035c484 --- /dev/null +++ b/ui/app/(auth)/invitation/_lib/invitation-errors.ts @@ -0,0 +1,125 @@ +import type { ApiError, ApiResponse } from "@/types"; + +const CLIENT_INVITATION_ERROR = { + INVALID_TOKEN: "Invalid invitation token", +} as const; + +const INVITATION_ERROR_DETAIL = { + NO_LONGER_VALID: "This invitation is no longer valid.", +} as const; + +const INVITATION_ERROR_POINTER = { + INVITATION_TOKEN: "/data/attributes/invitation_token", +} as const; + +const INVITATION_ERROR_CODE = { + INVALID: "invalid", + NOT_FOUND: "not_found", + TOKEN_EXPIRED: "token_expired", +} as const; + +export const INVITATION_ERROR_FLOW = { + ACCEPT: "accept", + SIGNUP: "signup", +} as const; + +type InvitationErrorFlow = + (typeof INVITATION_ERROR_FLOW)[keyof typeof INVITATION_ERROR_FLOW]; + +export const INVITATION_ERROR_MESSAGES = { + EXPIRED: + "This invitation has expired. Please contact your administrator for a new one.", + NO_LONGER_VALID: + "This invitation is no longer valid. Please contact your administrator for a new invitation.", + NOT_VALID: + "This invitation is not valid. Please check the link you received.", + INVALID_FALLBACK: + "This invitation is invalid. Please check the link or contact your administrator.", + UNEXPECTED: "Something went wrong while accepting the invitation.", +} as const; + +interface InvitationErrorDisplay { + message: string; + canRetry: boolean; +} + +interface InvitationErrorResponse + extends Pick { + errors?: ApiError[]; +} + +function getFirstError( + response: InvitationErrorResponse, +): ApiError | undefined { + return response.errors?.[0]; +} + +export function isInvitationTokenError(error: ApiError): boolean { + return error.source?.pointer === INVITATION_ERROR_POINTER.INVITATION_TOKEN; +} + +export function getInvitationErrorDisplay( + response: InvitationErrorResponse, + flow: InvitationErrorFlow, +): InvitationErrorDisplay { + const firstError = getFirstError(response); + const code = firstError?.code; + const detail = firstError?.detail; + + if (response.error === CLIENT_INVITATION_ERROR.INVALID_TOKEN) { + return { + message: INVITATION_ERROR_MESSAGES.INVALID_FALLBACK, + canRetry: false, + }; + } + + if (response.status === 410 && code === INVITATION_ERROR_CODE.TOKEN_EXPIRED) { + return { + message: INVITATION_ERROR_MESSAGES.EXPIRED, + canRetry: false, + }; + } + + if ( + response.status === 400 && + code === INVITATION_ERROR_CODE.INVALID && + detail === INVITATION_ERROR_DETAIL.NO_LONGER_VALID + ) { + return { + message: INVITATION_ERROR_MESSAGES.NO_LONGER_VALID, + canRetry: false, + }; + } + + if (response.status === 404 && code === INVITATION_ERROR_CODE.NOT_FOUND) { + return { + message: INVITATION_ERROR_MESSAGES.NOT_VALID, + canRetry: false, + }; + } + + if ( + flow === INVITATION_ERROR_FLOW.SIGNUP && + response.status === 400 && + code === INVITATION_ERROR_CODE.INVALID && + firstError && + isInvitationTokenError(firstError) + ) { + return { + message: INVITATION_ERROR_MESSAGES.NOT_VALID, + canRetry: false, + }; + } + + if (code === INVITATION_ERROR_CODE.INVALID) { + return { + message: INVITATION_ERROR_MESSAGES.INVALID_FALLBACK, + canRetry: false, + }; + } + + return { + message: INVITATION_ERROR_MESSAGES.UNEXPECTED, + canRetry: true, + }; +} diff --git a/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx b/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx index f334053b48..0ed6346de7 100644 --- a/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx +++ b/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx @@ -3,53 +3,21 @@ import { Icon } from "@iconify/react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { signOut } from "next-auth/react"; import { useEffect, useRef, useState } from "react"; import { acceptInvitation } from "@/actions/invitations"; +import { + getInvitationErrorDisplay, + INVITATION_ERROR_FLOW, +} from "@/app/(auth)/invitation/_lib/invitation-errors"; import { Button } from "@/components/shadcn"; type AcceptState = | { kind: "no-token" } | { kind: "accepting" } - | { kind: "error"; message: string; canRetry: boolean; needsSignOut: boolean } + | { kind: "error"; message: string; canRetry: boolean } | { kind: "choose" }; -function mapApiError(status: number | undefined): { - message: string; - canRetry: boolean; - needsSignOut: boolean; -} { - switch (status) { - case 410: - return { - message: - "This invitation has expired. Please contact your administrator for a new one.", - canRetry: false, - needsSignOut: false, - }; - case 400: - return { - message: "This invitation has already been used.", - canRetry: false, - needsSignOut: false, - }; - case 404: - return { - message: - "This invitation was sent to a different email address. Please sign in with the correct account.", - canRetry: false, - needsSignOut: true, - }; - default: - return { - message: "Something went wrong while accepting the invitation.", - canRetry: true, - needsSignOut: false, - }; - } -} - export function AcceptInvitationClient({ isAuthenticated, token, @@ -72,20 +40,16 @@ export function AcceptInvitationClient({ const result = await acceptInvitation(token); if (result?.error) { - const { message, canRetry, needsSignOut } = mapApiError(result.status); - setState({ kind: "error", message, canRetry, needsSignOut }); + const { message, canRetry } = getInvitationErrorDisplay( + result, + INVITATION_ERROR_FLOW.ACCEPT, + ); + setState({ kind: "error", message, canRetry }); } else { router.push("/"); } } - async function handleSignOutAndRedirect() { - if (!token) return; - const callbackPath = `/invitation/accept?invitation_token=${encodeURIComponent(token)}`; - await signOut({ redirect: false }); - router.push(`/sign-in?callbackUrl=${encodeURIComponent(callbackPath)}`); - } - useEffect(() => { if (hasStartedRef.current) return; hasStartedRef.current = true; @@ -153,15 +117,9 @@ export function AcceptInvitationClient({

{state.message}

{state.canRetry && } - {state.needsSignOut ? ( - - ) : ( - - )} +
)} diff --git a/ui/components/auth/oss/sign-up-form.tsx b/ui/components/auth/oss/sign-up-form.tsx index 6d971b5dab..e18ded5845 100644 --- a/ui/components/auth/oss/sign-up-form.tsx +++ b/ui/components/auth/oss/sign-up-form.tsx @@ -6,6 +6,11 @@ import { useRouter } from "next/navigation"; import { useForm, useWatch } from "react-hook-form"; import { createNewUser } from "@/actions/auth"; +import { + getInvitationErrorDisplay, + INVITATION_ERROR_FLOW, + isInvitationTokenError, +} from "@/app/(auth)/invitation/_lib/invitation-errors"; import { AuthDivider } from "@/components/auth/oss/auth-divider"; import { AuthFooterLink } from "@/components/auth/oss/auth-footer-link"; import { AuthLayout } from "@/components/auth/oss/auth-layout"; @@ -28,7 +33,10 @@ const AUTH_ERROR_PATHS = { EMAIL: "/data/attributes/email", PASSWORD: "/data/attributes/password", COMPANY_NAME: "/data/attributes/company_name", - INVITATION_TOKEN: "/data", +} as const; + +const FORM_ERROR_TYPE = { + SERVER: "server", } as const; export const SignUpForm = ({ @@ -86,31 +94,47 @@ export const SignUpForm = ({ router.push("/sign-in"); } } else { + const invitationTokenError = newUser.errors.find((error: ApiError) => + isInvitationTokenError(error), + ); + + if (invitationToken && invitationTokenError) { + const { message } = getInvitationErrorDisplay( + { status: newUser.status, errors: [invitationTokenError] }, + INVITATION_ERROR_FLOW.SIGNUP, + ); + form.setError("invitationToken", { + type: FORM_ERROR_TYPE.SERVER, + message, + }); + return; + } + newUser.errors.forEach((error: ApiError) => { const errorMessage = error.detail; const pointer = error.source?.pointer; switch (pointer) { case AUTH_ERROR_PATHS.NAME: - form.setError("name", { type: "server", message: errorMessage }); + form.setError("name", { + type: FORM_ERROR_TYPE.SERVER, + message: errorMessage, + }); break; case AUTH_ERROR_PATHS.EMAIL: - form.setError("email", { type: "server", message: errorMessage }); + form.setError("email", { + type: FORM_ERROR_TYPE.SERVER, + message: errorMessage, + }); break; case AUTH_ERROR_PATHS.COMPANY_NAME: form.setError("company", { - type: "server", + type: FORM_ERROR_TYPE.SERVER, message: errorMessage, }); break; case AUTH_ERROR_PATHS.PASSWORD: form.setError("password", { - type: "server", - message: errorMessage, - }); - break; - case AUTH_ERROR_PATHS.INVITATION_TOKEN: - form.setError("invitationToken", { - type: "server", + type: FORM_ERROR_TYPE.SERVER, message: errorMessage, }); break; diff --git a/ui/types/components.ts b/ui/types/components.ts index ba8cb53144..a375d6e15a 100644 --- a/ui/types/components.ts +++ b/ui/types/components.ts @@ -419,13 +419,15 @@ export interface SearchParamsProps { [key: string]: string | string[] | undefined; } +export interface ApiErrorSource { + pointer?: string; +} + export interface ApiError { detail: string; - status: string; - source: { - pointer: string; - }; - code: string; + status?: string; + source?: ApiErrorSource; + code?: string; } export type ApiResponse = { From b4befe3a108de885ca0e571bfa7b242df382c7c4 Mon Sep 17 00:00:00 2001 From: lydiavilchez <114735608+lydiavilchez@users.noreply.github.com> Date: Thu, 28 May 2026 10:15:10 +0200 Subject: [PATCH 03/14] feat(googleworkspace): add security service checks (#11356) Co-authored-by: pedrooot Co-authored-by: Hugo P.Brito --- .../googleworkspace/authentication.mdx | 8 +- prowler/CHANGELOG.md | 1 + .../cis_1.3_googleworkspace.json | 52 +- .../cisa_scuba_0.6_googleworkspace.json | 53 +- .../services/security/__init__.py | 0 .../security_2sv_enforced/__init__.py | 0 .../security_2sv_enforced.metadata.json | 39 ++ .../security_2sv_enforced.py | 59 +++ .../__init__.py | 0 ...ity_2sv_hardware_keys_admins.metadata.json | 39 ++ .../security_2sv_hardware_keys_admins.py | 64 +++ .../__init__.py | 0 ...vanced_protection_configured.metadata.json | 40 ++ ...security_advanced_protection_configured.py | 66 +++ .../__init__.py | 0 ...curity_app_access_restricted.metadata.json | 39 ++ .../security_app_access_restricted.py | 62 +++ .../services/security/security_client.py | 6 + .../__init__.py | 0 ...y_dlp_drive_rules_configured.metadata.json | 37 ++ .../security_dlp_drive_rules_configured.py | 50 ++ .../__init__.py | 0 ...curity_internal_apps_trusted.metadata.json | 39 ++ .../security_internal_apps_trusted.py | 56 ++ .../__init__.py | 0 ...ty_less_secure_apps_disabled.metadata.json | 37 ++ .../security_less_secure_apps_disabled.py | 56 ++ .../__init__.py | 0 ..._login_challenges_configured.metadata.json | 39 ++ .../security_login_challenges_configured.py | 62 +++ .../__init__.py | 0 ...urity_password_policy_strong.metadata.json | 37 ++ .../security_password_policy_strong.py | 76 +++ .../services/security/security_service.py | 281 ++++++++++ .../__init__.py | 0 ...ity_session_duration_limited.metadata.json | 37 ++ .../security_session_duration_limited.py | 75 +++ .../__init__.py | 0 ...uper_admin_recovery_disabled.metadata.json | 39 ++ .../security_super_admin_recovery_disabled.py | 55 ++ .../__init__.py | 0 ...curity_user_recovery_enabled.metadata.json | 39 ++ .../security_user_recovery_enabled.py | 56 ++ .../googleworkspace_security_service_test.py | 499 ++++++++++++++++++ .../security_2sv_enforced_test.py | 156 ++++++ .../security_2sv_hardware_keys_admins_test.py | 126 +++++ ...ity_advanced_protection_configured_test.py | 163 ++++++ .../security_app_access_restricted_test.py | 124 +++++ ...ecurity_dlp_drive_rules_configured_test.py | 124 +++++ .../security_internal_apps_trusted_test.py | 127 +++++ ...security_less_secure_apps_disabled_test.py | 127 +++++ ...curity_login_challenges_configured_test.py | 127 +++++ .../security_password_policy_strong_test.py | 210 ++++++++ .../security_session_duration_limited_test.py | 152 ++++++ ...rity_super_admin_recovery_disabled_test.py | 127 +++++ .../security_user_recovery_enabled_test.py | 124 +++++ 56 files changed, 3755 insertions(+), 30 deletions(-) create mode 100644 prowler/providers/googleworkspace/services/security/__init__.py create mode 100644 prowler/providers/googleworkspace/services/security/security_2sv_enforced/__init__.py create mode 100644 prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.metadata.json create mode 100644 prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.py create mode 100644 prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/__init__.py create mode 100644 prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.metadata.json create mode 100644 prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.py create mode 100644 prowler/providers/googleworkspace/services/security/security_advanced_protection_configured/__init__.py create mode 100644 prowler/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured.metadata.json create mode 100644 prowler/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured.py create mode 100644 prowler/providers/googleworkspace/services/security/security_app_access_restricted/__init__.py create mode 100644 prowler/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted.metadata.json create mode 100644 prowler/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted.py create mode 100644 prowler/providers/googleworkspace/services/security/security_client.py create mode 100644 prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/__init__.py create mode 100644 prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.metadata.json create mode 100644 prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.py create mode 100644 prowler/providers/googleworkspace/services/security/security_internal_apps_trusted/__init__.py create mode 100644 prowler/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted.metadata.json create mode 100644 prowler/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted.py create mode 100644 prowler/providers/googleworkspace/services/security/security_less_secure_apps_disabled/__init__.py create mode 100644 prowler/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled.metadata.json create mode 100644 prowler/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled.py create mode 100644 prowler/providers/googleworkspace/services/security/security_login_challenges_configured/__init__.py create mode 100644 prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.metadata.json create mode 100644 prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.py create mode 100644 prowler/providers/googleworkspace/services/security/security_password_policy_strong/__init__.py create mode 100644 prowler/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong.metadata.json create mode 100644 prowler/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong.py create mode 100644 prowler/providers/googleworkspace/services/security/security_service.py create mode 100644 prowler/providers/googleworkspace/services/security/security_session_duration_limited/__init__.py create mode 100644 prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.metadata.json create mode 100644 prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.py create mode 100644 prowler/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/__init__.py create mode 100644 prowler/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled.metadata.json create mode 100644 prowler/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled.py create mode 100644 prowler/providers/googleworkspace/services/security/security_user_recovery_enabled/__init__.py create mode 100644 prowler/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled.metadata.json create mode 100644 prowler/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled.py create mode 100644 tests/providers/googleworkspace/services/security/googleworkspace_security_service_test.py create mode 100644 tests/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced_test.py create mode 100644 tests/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins_test.py create mode 100644 tests/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured_test.py create mode 100644 tests/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted_test.py create mode 100644 tests/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured_test.py create mode 100644 tests/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted_test.py create mode 100644 tests/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled_test.py create mode 100644 tests/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured_test.py create mode 100644 tests/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong_test.py create mode 100644 tests/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited_test.py create mode 100644 tests/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled_test.py create mode 100644 tests/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled_test.py diff --git a/docs/user-guide/providers/googleworkspace/authentication.mdx b/docs/user-guide/providers/googleworkspace/authentication.mdx index efed268b9a..049b4fb4ae 100644 --- a/docs/user-guide/providers/googleworkspace/authentication.mdx +++ b/docs/user-guide/providers/googleworkspace/authentication.mdx @@ -18,7 +18,7 @@ Prowler requests the following read-only OAuth 2.0 scopes: | `https://www.googleapis.com/auth/admin.directory.domain.readonly` | Read access to domain information | | `https://www.googleapis.com/auth/admin.directory.customer.readonly` | Read access to customer information (Customer ID) | | `https://www.googleapis.com/auth/admin.directory.orgunit.readonly` | Read access to organizational unit hierarchy (identifies the root OU for policy filtering) | -| `https://www.googleapis.com/auth/cloud-identity.policies.readonly` | Read access to domain-level application policies (required for Calendar, Gmail, Chat, and Drive service checks) | +| `https://www.googleapis.com/auth/cloud-identity.policies.readonly` | Read access to domain-level application policies (required for Calendar, Chat, Drive, Gmail, Groups, Marketplace, Security, and Sites service checks) | | `https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly` | Read access to admin roles and role assignments | @@ -40,7 +40,7 @@ In the [Google Cloud Console](https://console.cloud.google.com), select the targ | API | Required For | |-----|--------------| | **Admin SDK API** | Directory service checks (users, roles, domains) | -| **Cloud Identity API** | Calendar, Gmail, Chat, and Drive service checks (domain-level application policies) | +| **Cloud Identity API** | All service checks except Directory (domain-level application policies) | For each API: @@ -49,7 +49,7 @@ For each API: 3. Click **Enable** -Both APIs must be enabled in the same GCP project that hosts the Service Account. Calendar, Gmail, Chat, and Drive checks will return no findings if the Cloud Identity API is not enabled. +Both APIs must be enabled in the same GCP project that hosts the Service Account. All service checks except Directory will return no findings if the Cloud Identity API is not enabled. ### Step 3: Create a Service Account @@ -178,7 +178,7 @@ If Prowler connects but returns empty results or permission errors for specific ### Policy API Checks Return No Findings -If the Directory checks run successfully but the Calendar, Gmail, Chat, or Drive checks return no findings, the Cloud Identity Policy API is not reachable for this Service Account. Verify: +If the Directory checks run successfully but other service checks (Calendar, Chat, Drive, Gmail, Groups, Marketplace, Security, Sites) return no findings, the Cloud Identity Policy API is not reachable for this Service Account. Verify: - The **Cloud Identity API** is enabled in the GCP project hosting the Service Account (Step 2) - The scope `https://www.googleapis.com/auth/cloud-identity.policies.readonly` is included in the Domain-Wide Delegation OAuth scopes list in the Admin Console (Step 5) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 467f2d8ef0..ef37ca39b5 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `application` service for Okta provider with `application_admin_console_session_idle_timeout_15min`, `application_admin_console_mfa_required`, `application_admin_console_phishing_resistant_authentication`, `application_dashboard_mfa_required`, `application_dashboard_phishing_resistant_authentication`, and `application_authentication_policy_network_zone_enforced` checks [(#11358)](https://github.com/prowler-cloud/prowler/pull/11358) - AWS AI Security Framework compliance for AWS provider [(#11353)](https://github.com/prowler-cloud/prowler/pull/11353) - `storage_account_public_network_access_disabled` check for Azure provider and remapped the Azure CIS "Public Network Access is Disabled" requirements to it [(#11334)](https://github.com/prowler-cloud/prowler/pull/11334) +- 12 Security service checks for Google Workspace provider using the Cloud Identity Policy API [(#11356)](https://github.com/prowler-cloud/prowler/pull/11356) ### ๐Ÿž Fixed diff --git a/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json b/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json index 0867da103e..972cdfe7a2 100644 --- a/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json +++ b/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json @@ -1360,7 +1360,9 @@ { "Id": "4.1.1.1", "Description": "Ensure 2-Step Verification (Multi-Factor Authentication) is enforced for all users in administrative roles", - "Checks": [], + "Checks": [ + "security_2sv_enforced" + ], "Attributes": [ { "Section": "4 Security", @@ -1381,7 +1383,9 @@ { "Id": "4.1.1.2", "Description": "Ensure hardware security keys are used for all users in administrative roles and other high-value accounts", - "Checks": [], + "Checks": [ + "security_2sv_hardware_keys_admins" + ], "Attributes": [ { "Section": "4 Security", @@ -1402,7 +1406,9 @@ { "Id": "4.1.1.3", "Description": "Ensure 2-Step Verification (Multi-Factor Authentication) is enforced for all users", - "Checks": [], + "Checks": [ + "security_2sv_enforced" + ], "Attributes": [ { "Section": "4 Security", @@ -1423,7 +1429,9 @@ { "Id": "4.1.2.1", "Description": "Ensure Super Admin account recovery is disabled", - "Checks": [], + "Checks": [ + "security_super_admin_recovery_disabled" + ], "Attributes": [ { "Section": "4 Security", @@ -1444,7 +1452,9 @@ { "Id": "4.1.2.2", "Description": "Ensure User account recovery is enabled", - "Checks": [], + "Checks": [ + "security_user_recovery_enabled" + ], "Attributes": [ { "Section": "4 Security", @@ -1465,7 +1475,9 @@ { "Id": "4.1.3.1", "Description": "Ensure Advanced Protection Program is configured", - "Checks": [], + "Checks": [ + "security_advanced_protection_configured" + ], "Attributes": [ { "Section": "4 Security", @@ -1486,7 +1498,9 @@ { "Id": "4.1.4.1", "Description": "Ensure login challenges are enforced", - "Checks": [], + "Checks": [ + "security_login_challenges_configured" + ], "Attributes": [ { "Section": "4 Security", @@ -1507,7 +1521,9 @@ { "Id": "4.1.5.1", "Description": "Ensure password policy is configured for enhanced security", - "Checks": [], + "Checks": [ + "security_password_policy_strong" + ], "Attributes": [ { "Section": "4 Security", @@ -1528,7 +1544,9 @@ { "Id": "4.2.1.1", "Description": "Ensure application access to Google services is restricted", - "Checks": [], + "Checks": [ + "security_app_access_restricted" + ], "Attributes": [ { "Section": "4 Security", @@ -1570,7 +1588,9 @@ { "Id": "4.2.1.3", "Description": "Ensure internal apps can access Google Workspace APIs", - "Checks": [], + "Checks": [ + "security_internal_apps_trusted" + ], "Attributes": [ { "Section": "4 Security", @@ -1633,7 +1653,9 @@ { "Id": "4.2.3.1", "Description": "Ensure DLP policies for Google Drive are configured", - "Checks": [], + "Checks": [ + "security_dlp_drive_rules_configured" + ], "Attributes": [ { "Section": "4 Security", @@ -1654,7 +1676,9 @@ { "Id": "4.2.4.1", "Description": "Ensure Google session control is configured", - "Checks": [], + "Checks": [ + "security_session_duration_limited" + ], "Attributes": [ { "Section": "4 Security", @@ -1696,7 +1720,9 @@ { "Id": "4.2.6.1", "Description": "Ensure less secure app access is disabled", - "Checks": [], + "Checks": [ + "security_less_secure_apps_disabled" + ], "Attributes": [ { "Section": "4 Security", diff --git a/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json b/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json index 0ce4d2ef6a..8fb840ae96 100644 --- a/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json +++ b/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json @@ -8,7 +8,10 @@ { "Id": "GWS.COMMONCONTROLS.1.1", "Description": "Phishing-resistant MFA SHALL be required for all users", - "Checks": [], + "Checks": [ + "security_2sv_enforced", + "security_2sv_hardware_keys_admins" + ], "Attributes": [ { "Section": "Common Controls", @@ -21,7 +24,9 @@ { "Id": "GWS.COMMONCONTROLS.1.2", "Description": "If phishing-resistant MFA is not yet tenable, an MFA method from the list of acceptable MFA methods SHALL be used as an interim solution", - "Checks": [], + "Checks": [ + "security_2sv_enforced" + ], "Attributes": [ { "Section": "Common Controls", @@ -112,7 +117,9 @@ { "Id": "GWS.COMMONCONTROLS.4.1", "Description": "Google Workspace sessions SHALL re-authenticate after 12 hours", - "Checks": [], + "Checks": [ + "security_session_duration_limited" + ], "Attributes": [ { "Section": "Common Controls", @@ -125,7 +132,9 @@ { "Id": "GWS.COMMONCONTROLS.5.1", "Description": "Password strength SHALL be enforced", - "Checks": [], + "Checks": [ + "security_password_policy_strong" + ], "Attributes": [ { "Section": "Common Controls", @@ -138,7 +147,9 @@ { "Id": "GWS.COMMONCONTROLS.5.2", "Description": "Minimum password length SHALL be at least 12 characters", - "Checks": [], + "Checks": [ + "security_password_policy_strong" + ], "Attributes": [ { "Section": "Common Controls", @@ -151,7 +162,9 @@ { "Id": "GWS.COMMONCONTROLS.5.3", "Description": "Minimum password length SHOULD be at least 15 characters", - "Checks": [], + "Checks": [ + "security_password_policy_strong" + ], "Attributes": [ { "Section": "Common Controls", @@ -164,7 +177,9 @@ { "Id": "GWS.COMMONCONTROLS.5.4", "Description": "Password policy SHALL be enforced at next sign-in", - "Checks": [], + "Checks": [ + "security_password_policy_strong" + ], "Attributes": [ { "Section": "Common Controls", @@ -177,7 +192,9 @@ { "Id": "GWS.COMMONCONTROLS.5.5", "Description": "Password reuse SHALL be restricted", - "Checks": [], + "Checks": [ + "security_password_policy_strong" + ], "Attributes": [ { "Section": "Common Controls", @@ -244,7 +261,9 @@ { "Id": "GWS.COMMONCONTROLS.8.1", "Description": "Account recovery for super admins SHALL be disabled", - "Checks": [], + "Checks": [ + "security_super_admin_recovery_disabled" + ], "Attributes": [ { "Section": "Common Controls", @@ -283,7 +302,9 @@ { "Id": "GWS.COMMONCONTROLS.9.1", "Description": "Privileged accounts SHALL be enrolled in the Advanced Protection Program", - "Checks": [], + "Checks": [ + "security_advanced_protection_configured" + ], "Attributes": [ { "Section": "Common Controls", @@ -296,7 +317,9 @@ { "Id": "GWS.COMMONCONTROLS.9.2", "Description": "Sensitive user accounts SHOULD be enrolled in the Advanced Protection Program", - "Checks": [], + "Checks": [ + "security_advanced_protection_configured" + ], "Attributes": [ { "Section": "Common Controls", @@ -361,7 +384,9 @@ { "Id": "GWS.COMMONCONTROLS.10.5", "Description": "Internal apps SHALL be allowed to access restricted Google Workspace APIs", - "Checks": [], + "Checks": [ + "security_internal_apps_trusted" + ], "Attributes": [ { "Section": "Common Controls", @@ -506,7 +531,9 @@ { "Id": "GWS.COMMONCONTROLS.18.1", "Description": "A DLP policy SHALL be configured for Drive", - "Checks": [], + "Checks": [ + "security_dlp_drive_rules_configured" + ], "Attributes": [ { "Section": "Common Controls", diff --git a/prowler/providers/googleworkspace/services/security/__init__.py b/prowler/providers/googleworkspace/services/security/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_2sv_enforced/__init__.py b/prowler/providers/googleworkspace/services/security/security_2sv_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.metadata.json b/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.metadata.json new file mode 100644 index 0000000000..659360aac5 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_2sv_enforced", + "CheckTitle": "2-Step Verification is enforced for all users", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level policy **enforces 2-Step Verification (Multi-Factor Authentication)** for all users. 2-Step Verification requires users to present a second form of authentication beyond their password, significantly reducing the risk of account compromise.", + "Risk": "Without 2-Step Verification enforcement, users can access their accounts with **only a password**. If credentials are compromised through phishing, credential stuffing, or data breaches, attackers gain **immediate access** to the user's account and organizational data without any additional verification.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/security/protect-your-business-with-2-step-verification", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Security** > **2-Step Verification**\n3. Check **Allow users to turn on 2-Step Verification**\n4. Set **Enforcement** to **On**\n5. Set **New user enrollment period** to **2 weeks**\n6. Under **Frequency**, uncheck **Allow user to trust device**\n7. Under **Methods**, select **Any except verification codes via text, phone call**\n8. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enforce **2-Step Verification** for all users to require a second authentication factor beyond passwords, protecting accounts from credential-based attacks.", + "Url": "https://hub.prowler.com/check/security_2sv_enforced" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "security_2sv_hardware_keys_admins" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.py b/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.py new file mode 100644 index 0000000000..7cf17b348e --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.py @@ -0,0 +1,59 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_2sv_enforced(Check): + """Check that 2-Step Verification is enforced for all users. + + This check verifies that the domain-level policy enforces 2-Step + Verification (Multi-Factor Authentication) for all users, reducing + the risk of account compromise through stolen credentials. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + enforced_from = security_client.policies.two_sv_enforced_from + # The API returns "1970-01-01T00:00:00Z" (protobuf zero-value + # Timestamp) when enforcement is OFF, not null or empty. + enforcement_off_epoch = "1970-01-01T00:00:00Z" + + if enforced_from and enforced_from != enforcement_off_epoch: + report.status = "PASS" + report.status_extended = ( + f"2-Step Verification enforcement is active " + f"(enforced from {enforced_from}) " + f"in domain {security_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + if enforced_from is None: + report.status_extended = ( + f"2-Step Verification enforcement is not configured " + f"in domain {security_client.provider.identity.domain}. " + f"The default is OFF. 2-Step Verification should be " + f"enforced for all users." + ) + else: + report.status_extended = ( + f"2-Step Verification enforcement is set to OFF " + f"in domain {security_client.provider.identity.domain}. " + f"2-Step Verification should be enforced for all users." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/__init__.py b/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.metadata.json b/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.metadata.json new file mode 100644 index 0000000000..586cfc766a --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_2sv_hardware_keys_admins", + "CheckTitle": "Hardware security keys are required for 2-Step Verification", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level 2-Step Verification policy requires **hardware security keys only** as the allowed sign-in factor, providing the strongest phishing-resistant authentication. **Note**: the Policy API returns domain-wide policies only and cannot verify admin role-specific enforcement.", + "Risk": "When 2SV methods include **SMS, phone calls, or software-based authenticators**, users are vulnerable to **SIM swapping, SS7 attacks, and real-time phishing proxies** that can intercept one-time codes. Hardware security keys are resistant to all known remote phishing techniques.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/security/protect-your-business-with-2-step-verification", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Security** > **Authentication** > **2-Step Verification**\n3. Select the appropriate group with **ALL ADMIN ROLES** (create this group if needed)\n4. Under **Methods**, select **Only security key**\n5. Under **2-Step Verification policy suspension grace period**, select **1 day**\n6. Under **Security codes**, select **Don't allow users to generate security codes**\n7. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Require **hardware security keys only** for 2-Step Verification to provide the strongest phishing-resistant authentication for all users, particularly those in administrative roles.", + "Url": "https://hub.prowler.com/check/security_2sv_hardware_keys_admins" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "security_2sv_enforced" + ], + "Notes": "The Cloud Identity Policy API returns domain-wide policies only. It cannot verify that hardware keys are enforced specifically for admin roles versus all users. This check evaluates the customer-level enforcement factor, which applies to all users including administrators." +} diff --git a/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.py b/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.py new file mode 100644 index 0000000000..5913f448e1 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.py @@ -0,0 +1,64 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_2sv_hardware_keys_admins(Check): + """Check that 2SV enforcement requires hardware security keys. + + This check verifies that the domain-level 2-Step Verification enforcement + factor is set to security keys only, providing the strongest protection + against phishing attacks. Note: the Cloud Identity Policy API returns + domain-wide policies โ€” it cannot verify enforcement for admin roles + specifically. This check evaluates the customer-level policy which + applies to all users including administrators. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + factor_set = security_client.policies.two_sv_allowed_factor_set + + if factor_set == "PASSKEY_ONLY": + report.status = "PASS" + report.status_extended = ( + f"2-Step Verification enforcement requires security keys only " + f"in domain {security_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + if factor_set is None: + report.status_extended = ( + f"2-Step Verification enforcement factor is not configured " + f"in domain {security_client.provider.identity.domain}. " + f"The default allows all methods including SMS and phone call. " + f"Security keys should be required for administrative accounts. " + f"Note: this check evaluates the domain-wide policy, the Policy " + f"API does not expose role-specific 2SV enforcement." + ) + else: + report.status_extended = ( + f"2-Step Verification enforcement factor is set to " + f"{factor_set} " + f"in domain {security_client.provider.identity.domain}. " + f"Only security keys (PASSKEY_ONLY) should be allowed for " + f"administrative accounts. " + f"Note: this check evaluates the domain-wide policy, the Policy " + f"API does not expose role-specific 2SV enforcement." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_advanced_protection_configured/__init__.py b/prowler/providers/googleworkspace/services/security/security_advanced_protection_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured.metadata.json b/prowler/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured.metadata.json new file mode 100644 index 0000000000..6e9d72bfdf --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_advanced_protection_configured", + "CheckTitle": "Advanced Protection Program is configured", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level policy enables the **Advanced Protection Program** with user self-enrollment and **blocks the use of security codes**. The Advanced Protection Program is Google's strongest account security offering, requiring hardware security keys and applying a curated set of high-security policies.", + "Risk": "Without the Advanced Protection Program, user accounts rely on standard security controls that are vulnerable to **sophisticated phishing attacks, targeted credential theft, and third-party app data access**. Allowing security codes alongside Advanced Protection weakens its protections by providing an alternative authentication path that can be intercepted.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/security/protect-users-with-the-advanced-protection-program", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Security** > **Advanced Protection Program**\n3. Under **Enrollment**, select **Enable user enrollment**\n4. Under **Security Codes**, select **Do not allow users to generate security codes**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable the **Advanced Protection Program** with user self-enrollment and block **security codes** to enforce the strongest available account protection.", + "Url": "https://hub.prowler.com/check/security_advanced_protection_configured" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "security_2sv_enforced", + "security_2sv_hardware_keys_admins" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured.py b/prowler/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured.py new file mode 100644 index 0000000000..ab32837515 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured.py @@ -0,0 +1,66 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_advanced_protection_configured(Check): + """Check that the Advanced Protection Program is configured. + + This check verifies that the domain-level policy enables Advanced + Protection Program self-enrollment and blocks the use of security codes, + as recommended by CIS 4.1.3.1. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + enrollment = security_client.policies.advanced_protection_enrollment + code_option = ( + security_client.policies.advanced_protection_security_code_option + ) + domain = security_client.provider.identity.domain + + enrollment_ok = enrollment is True + codes_ok = code_option == "CODES_NOT_ALLOWED" + + if enrollment_ok and codes_ok: + report.status = "PASS" + report.status_extended = ( + f"Advanced Protection Program is configured with enrollment " + f"enabled and security codes blocked in domain {domain}." + ) + else: + report.status = "FAIL" + issues = [] + if not enrollment_ok: + issues.append( + "enrollment is not configured" + if enrollment is None + else "enrollment is disabled" + ) + if not codes_ok: + issues.append( + f"security codes are " + f"{code_option or 'using default (allowed without remote access)'} " + f"(should be CODES_NOT_ALLOWED)" + ) + report.status_extended = ( + f"Advanced Protection Program is not properly configured " + f"in domain {domain}: {'; '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_app_access_restricted/__init__.py b/prowler/providers/googleworkspace/services/security/security_app_access_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted.metadata.json b/prowler/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted.metadata.json new file mode 100644 index 0000000000..9a165141b6 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_app_access_restricted", + "CheckTitle": "Application access to Google services is restricted", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level API controls configuration **restricts third-party app access** to at least one Google service. This check verifies that the administrator has configured API access controls rather than leaving all services at the unrestricted default. The CIS benchmark recommends restricting access to all applicable services, particularly high-risk scopes like Drive and Gmail.", + "Risk": "When application access to Google services is unrestricted, **any third-party app** that users consent to can access sensitive organizational data through Google APIs. This includes apps that may request **broad OAuth scopes** for Drive, Gmail, and other services, potentially leading to **data exfiltration** through unvetted applications.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/apps/control-which-apps-access-google-workspace-data", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Security** > **Access and Data Control** > **API Controls**\n3. Click **App access control** > **MANAGE GOOGLE SERVICES**\n4. Select **ALL applicable Google Services**\n5. Click **Change access**\n6. Select **Restricted: Only trusted apps can access a service**\n7. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict **application access to Google services** to trusted apps only, particularly for high-risk scopes like **Drive and Gmail**, to prevent unvetted third-party apps from accessing sensitive organizational data.", + "Url": "https://hub.prowler.com/check/security_app_access_restricted" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "security_internal_apps_trusted" + ], + "Notes": "This check verifies that at least one Google service has API access restricted, serving as a signal that the administrator has configured API access controls. The CIS benchmark recommends restricting access to all applicable services." +} diff --git a/prowler/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted.py b/prowler/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted.py new file mode 100644 index 0000000000..4db6a95b83 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted.py @@ -0,0 +1,62 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_app_access_restricted(Check): + """Check that application access to Google services is restricted. + + This check verifies that at least one Google service has API access + restricted for third-party apps, indicating that the administrator + has reviewed and configured API access controls. The CIS benchmark + recommends restricting access to all applicable services, particularly + high-risk scopes like Drive and Gmail. This check serves as a signal + that API access controls have been configured rather than left at the + unrestricted default. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + restricted = security_client.policies.google_services_restricted + domain = security_client.provider.identity.domain + + if restricted is True: + report.status = "PASS" + report.status_extended = ( + f"Application access to Google services is restricted " + f"in domain {domain}. At least one Google service has " + f"API access limited to trusted apps." + ) + else: + report.status = "FAIL" + if restricted is None: + report.status_extended = ( + f"Application access to Google services is not configured " + f"in domain {domain}. The default is unrestricted. " + f"API access should be restricted for all applicable " + f"Google services, particularly high-risk scopes." + ) + else: + report.status_extended = ( + f"Application access to Google services is unrestricted " + f"in domain {domain}. " + f"API access should be restricted for all applicable " + f"Google services, particularly high-risk scopes." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_client.py b/prowler/providers/googleworkspace/services/security/security_client.py new file mode 100644 index 0000000000..5c8930edf1 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.googleworkspace.services.security.security_service import ( + Security, +) + +security_client = Security(Provider.get_global_provider()) diff --git a/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/__init__.py b/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.metadata.json b/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.metadata.json new file mode 100644 index 0000000000..e25e45c395 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_dlp_drive_rules_configured", + "CheckTitle": "DLP policies for Google Drive are configured", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "At least one active **Data Loss Prevention (DLP) rule** targeting Google Drive file sharing is configured. DLP policies detect and prevent users from sharing sensitive information such as credit card numbers, identity numbers, and other regulated data through Drive.", + "Risk": "Without DLP policies, users can **freely share files containing sensitive information** through Google Drive without any detection or prevention controls. This increases the risk of **accidental data exposure, regulatory non-compliance**, and data breaches through oversharing.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/security/about-dlp", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Security** > **Access and Data Control** > **Data protection**\n3. Click **Manage Rules**\n4. Click **ADD RULE** and select **New rule** or **New rule from template**\n5. Set the rule name and scope\n6. Set triggers by checking **File modified** under **Google Drive**\n7. Add conditions (Field, Comparison Operator, Content to match)\n8. Under **Actions**, select the desired action for each incident\n9. Under **Alerting**, set severity and select **Send to alert center**\n10. Click **Create**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure **DLP policies for Google Drive** to detect and prevent sharing of sensitive information such as credit card numbers, identity numbers, and other regulated data.", + "Url": "https://hub.prowler.com/check/security_dlp_drive_rules_configured" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.py b/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.py new file mode 100644 index 0000000000..37304d0636 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured.py @@ -0,0 +1,50 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_dlp_drive_rules_configured(Check): + """Check that DLP policies for Google Drive are configured. + + This check verifies that at least one active Data Loss Prevention (DLP) + rule targeting Google Drive file sharing exists, helping to prevent + unintended exposure of sensitive information. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + dlp_exists = security_client.policies.dlp_drive_rules_exist + domain = security_client.provider.identity.domain + + if dlp_exists is True: + report.status = "PASS" + report.status_extended = ( + f"DLP policies for Google Drive are configured " + f"in domain {domain}. At least one active DLP rule " + f"targeting Drive file sharing exists." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"No active DLP policies for Google Drive are configured " + f"in domain {domain}. DLP rules should be configured " + f"to detect and prevent sharing of sensitive information " + f"through Drive." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_internal_apps_trusted/__init__.py b/prowler/providers/googleworkspace/services/security/security_internal_apps_trusted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted.metadata.json b/prowler/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted.metadata.json new file mode 100644 index 0000000000..abf11509f4 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_internal_apps_trusted", + "CheckTitle": "Internal apps can access Google Workspace APIs", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level API controls configuration **trusts internal domain-owned apps** to access restricted Google Workspace APIs. This avoids the need to individually trust each internal app.", + "Risk": "When internal apps are not trusted, legitimate **organization-built applications** cannot access restricted Google Workspace API scopes, potentially **breaking internal workflows** and forcing administrators to trust each app individually, which increases administrative overhead.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/apps/control-which-apps-access-google-workspace-data", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Security** > **Access and Data Control** > **API Controls**\n3. Click **App access control** > **Settings**\n4. Check **Trust internal, domain-owned apps**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable **trust for internal domain-owned apps** so organization-built applications can access restricted Google Workspace APIs without individual trust configuration.", + "Url": "https://hub.prowler.com/check/security_internal_apps_trusted" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "security_app_access_restricted" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted.py b/prowler/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted.py new file mode 100644 index 0000000000..8a62124125 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted.py @@ -0,0 +1,56 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_internal_apps_trusted(Check): + """Check that internal apps can access Google Workspace APIs. + + This check verifies that the domain-level policy trusts internal + domain-owned apps, allowing them to access restricted Google Workspace + APIs without requiring individual trust configuration. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + trust_internal = security_client.policies.trust_internal_apps + + if trust_internal is True: + report.status = "PASS" + report.status_extended = ( + f"Internal domain-owned apps are trusted to access " + f"Google Workspace APIs " + f"in domain {security_client.provider.identity.domain}." + ) + elif trust_internal is None: + report.status = "PASS" + report.status_extended = ( + f"Internal domain-owned apps use Google's secure default " + f"configuration (trusted) " + f"in domain {security_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Internal domain-owned apps are not trusted to access " + f"Google Workspace APIs " + f"in domain {security_client.provider.identity.domain}. " + f"Internal apps should be trusted to access restricted APIs." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_less_secure_apps_disabled/__init__.py b/prowler/providers/googleworkspace/services/security/security_less_secure_apps_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled.metadata.json b/prowler/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled.metadata.json new file mode 100644 index 0000000000..ef4531b758 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_less_secure_apps_disabled", + "CheckTitle": "Less secure app access is disabled", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level policy **disables access to less secure apps** that do not use modern security standards such as OAuth. Blocking these apps helps keep user accounts and organizational data safe.", + "Risk": "When less secure app access is enabled, users can allow apps that use **basic authentication** (username and password only) to access their Google account. These apps are more vulnerable to **credential theft** and do not support 2-Step Verification, increasing the risk of account compromise.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/apps/control-access-to-less-secure-apps", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Security** > **Access and Data Control** > **Less secure apps**\n3. Select **Disable access to less secure apps (Recommended)**\n4. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable **less secure app access** to prevent users from allowing apps that do not use modern authentication standards to access their accounts.", + "Url": "https://hub.prowler.com/check/security_less_secure_apps_disabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled.py b/prowler/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled.py new file mode 100644 index 0000000000..a430f9d1e9 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled.py @@ -0,0 +1,56 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_less_secure_apps_disabled(Check): + """Check that less secure app access is disabled. + + This check verifies that the domain-level policy prevents users from + allowing access to apps that use less secure sign-in technology, + reducing the risk of credential compromise. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + less_secure_allowed = security_client.policies.less_secure_apps_allowed + + if less_secure_allowed is False: + report.status = "PASS" + report.status_extended = ( + f"Less secure app access is disabled " + f"in domain {security_client.provider.identity.domain}." + ) + elif less_secure_allowed is None: + report.status = "PASS" + report.status_extended = ( + f"Less secure app access uses Google's secure default " + f"configuration (disabled) " + f"in domain {security_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Less secure app access is enabled " + f"in domain {security_client.provider.identity.domain}. " + f"Less secure app access should be disabled to prevent " + f"credential compromise through apps that do not use modern " + f"security standards." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/__init__.py b/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.metadata.json b/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.metadata.json new file mode 100644 index 0000000000..0f4433ca78 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_login_challenges_configured", + "CheckTitle": "Login challenges are configured correctly", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level login challenges configuration has the **employee ID challenge disabled**. CIS 4.1.4.1 also requires Post-SSO verification to be enabled, but that setting is **not exposed by the Cloud Identity Policy API**. This check only covers the employee ID challenge portion of the control.", + "Risk": "When the employee ID login challenge is enabled without proper configuration, it may create a **false sense of security** or interfere with the login flow. The employee ID challenge is a supplementary verification method that should only be used when specifically required by the organization.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/security/protect-google-workspace-accounts-with-security-challenges", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Security** > **Login Challenges**\n3. Under **Login challenges**, uncheck **Use employee ID to keep my users more secure**\n4. Click **Save**\n5. Under **Post-SSO verification**, check **Logins using SSO are subject to additional verifications (if appropriate) and 2-Step Verification (if configured)** (this setting cannot be verified via the Policy API)\n6. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable the **employee ID login challenge** and manually verify that **Post-SSO verification** is enabled in the Admin Console, as the Post-SSO setting is not exposed by the Policy API.", + "Url": "https://hub.prowler.com/check/security_login_challenges_configured" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "security_2sv_enforced" + ], + "Notes": "This check is partial โ€” it only verifies the employee ID challenge setting. CIS 4.1.4.1 also requires Post-SSO verification to be enabled, which is not exposed by the Cloud Identity Policy API and must be verified manually." +} diff --git a/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.py b/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.py new file mode 100644 index 0000000000..e3732053b8 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.py @@ -0,0 +1,62 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_login_challenges_configured(Check): + """Check that login challenges are configured correctly. + + This check verifies that the employee ID login challenge is disabled, + as recommended by CIS. Note: CIS 4.1.4.1 also requires Post-SSO + verification to be enabled, but that setting is not exposed by the + Cloud Identity Policy API. This check only covers the employee ID + challenge portion of the control. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + employee_id_enabled = security_client.policies.login_challenge_employee_id + + if employee_id_enabled is False: + report.status = "PASS" + report.status_extended = ( + f"Employee ID login challenge is disabled " + f"in domain {security_client.provider.identity.domain}. " + f"Note: Post-SSO verification status cannot be verified " + f"via the Policy API." + ) + elif employee_id_enabled is None: + report.status = "PASS" + report.status_extended = ( + f"Employee ID login challenge uses Google's secure default " + f"configuration (disabled) " + f"in domain {security_client.provider.identity.domain}. " + f"Note: Post-SSO verification status cannot be verified " + f"via the Policy API." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Employee ID login challenge is enabled " + f"in domain {security_client.provider.identity.domain}. " + f"The employee ID challenge should be disabled per CIS " + f"recommendations. Note: Post-SSO verification status " + f"cannot be verified via the Policy API." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_password_policy_strong/__init__.py b/prowler/providers/googleworkspace/services/security/security_password_policy_strong/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong.metadata.json b/prowler/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong.metadata.json new file mode 100644 index 0000000000..2bd351e93b --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_password_policy_strong", + "CheckTitle": "Password policy is configured for enhanced security", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level password policy is configured with **enhanced security settings**: minimum length of 14 characters, strong passwords enforced, password reuse disallowed, enforcement at next sign-in enabled, and password expiration set to 365 days.", + "Risk": "Weak password policies allow users to set **short, simple, or previously compromised passwords** that are vulnerable to brute-force attacks, credential stuffing, and password spraying. Without enforcement at sign-in, users may continue using weak passwords indefinitely.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/users/enforce-and-monitor-password-requirements-for-users", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Security** > **Password management**\n3. Under **Strength**, check **Enforce strong passwords**\n4. Under **Length**, set **Minimum Length** to **14** or greater\n5. Under **Strength and Length enforcement**, check **Enforce password policy at next sign-in**\n6. Under **Reuse**, uncheck **Allow password reuse**\n7. Under **Expiration**, set **Password reset frequency** to **365 Days**\n8. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure a **strong password policy** with minimum 14-character length, strong password enforcement, reuse prevention, enforcement at next sign-in, and annual password expiration.", + "Url": "https://hub.prowler.com/check/security_password_policy_strong" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong.py b/prowler/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong.py new file mode 100644 index 0000000000..33448aa536 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong.py @@ -0,0 +1,76 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_password_policy_strong(Check): + """Check that password policy is configured for enhanced security. + + This check verifies that the domain-level password policy meets CIS + requirements: minimum length of 14 characters, strong passwords enforced, + password reuse disallowed, enforcement at next sign-in, and password + expiration configured. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + policies = security_client.policies + domain = security_client.provider.identity.domain + issues = [] + + min_length = policies.password_minimum_length + if min_length is None or min_length < 14: + issues.append( + "minimum length is not configured (requires 14+)" + if min_length is None + else f"minimum length is {min_length} (requires 14+)" + ) + + if policies.password_allowed_strength != "STRONG": + issues.append( + "password strength is not configured (requires STRONG)" + if policies.password_allowed_strength is None + else f"password strength is {policies.password_allowed_strength} (requires STRONG)" + ) + + if policies.password_allow_reuse is True: + issues.append("password reuse is allowed") + + if policies.password_enforce_at_login is not True: + issues.append("password policy is not enforced at next sign-in") + + expiration = policies.password_expiration_duration + if expiration is None or expiration == "0s": + issues.append("password expiration is not configured") + + if not issues: + report.status = "PASS" + report.status_extended = ( + f"Password policy meets CIS requirements " + f"in domain {domain}: minimum length {min_length}, " + f"strong passwords enforced, reuse disallowed, " + f"enforced at next sign-in, expiration configured." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Password policy does not meet CIS requirements " + f"in domain {domain}: {'; '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_service.py b/prowler/providers/googleworkspace/services/security/security_service.py new file mode 100644 index 0000000000..96f24061f8 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_service.py @@ -0,0 +1,281 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService + + +class Security(GoogleWorkspaceService): + """Google Workspace Security service for auditing domain-level security policies. + + Uses the Cloud Identity Policy API v1 to read authentication, password, + session, recovery, API control, and DLP settings configured in the + Admin Console. + """ + + def __init__(self, provider): + super().__init__(provider) + self.policies = SecurityPolicies() + self.policies_fetched = False + self._fetch_security_policies() + + def _fetch_security_policies(self): + """Fetch security policies from the Cloud Identity Policy API v1.""" + logger.info("Security - Fetching security policies...") + + try: + service = self._build_service("cloudidentity", "v1") + + if not service: + logger.error("Failed to build Cloud Identity service") + return + + fetch_succeeded = True + + # Fetch 1: security.* settings + fetch_succeeded = self._fetch_namespace( + service, 'setting.type.matches("security.*")', fetch_succeeded + ) + + # Fetch 2: api_controls.* settings + fetch_succeeded = self._fetch_namespace( + service, 'setting.type.matches("api_controls.*")', fetch_succeeded + ) + + # Fetch 3: rule.dlp for DLP existence check + fetch_succeeded = self._fetch_namespace( + service, 'setting.type.matches("rule.dlp")', fetch_succeeded + ) + + self.policies_fetched = fetch_succeeded + + if fetch_succeeded: + logger.info("Security policies fetched successfully.") + else: + logger.warning( + "Security policies fetched with partial failures; " + "some checks may be skipped." + ) + + except Exception as error: + self._handle_api_error( + error, + "fetching security policies", + self.provider.identity.customer_id, + ) + self.policies_fetched = False + + def _fetch_namespace(self, service, filter_str: str, fetch_succeeded: bool) -> bool: + """Fetch policies for a single namespace filter.""" + try: + request = service.policies().list( + pageSize=100, + filter=filter_str, + ) + + while request is not None: + try: + response = request.execute() + + for policy in response.get("policies", []): + if not self._is_customer_level_policy(policy): + continue + + setting = policy.get("setting", {}) + setting_type = setting.get("type", "").removeprefix("settings/") + value = setting.get("value", {}) + + self._process_setting(setting_type, value) + + request = service.policies().list_next(request, response) + + except Exception as error: + self._handle_api_error( + error, + f"fetching policies with filter {filter_str}", + self.provider.identity.customer_id, + ) + return False + + except Exception as error: + self._handle_api_error( + error, + f"listing policies with filter {filter_str}", + self.provider.identity.customer_id, + ) + return False + + return fetch_succeeded + + def _process_setting(self, setting_type: str, value: dict): + """Process a single policy setting and populate the model.""" + + # 2-Step Verification settings + if setting_type == "security.two_step_verification_enrollment": + self.policies.two_sv_allow_enrollment = value.get("allowEnrollment") + logger.debug(f"2SV enrollment: {self.policies.two_sv_allow_enrollment}") + + elif setting_type == "security.two_step_verification_enforcement": + self.policies.two_sv_enforced_from = value.get("enforcedFrom") + logger.debug(f"2SV enforcement: {self.policies.two_sv_enforced_from}") + + elif setting_type == "security.two_step_verification_enforcement_factor": + self.policies.two_sv_allowed_factor_set = value.get( + "allowedSignInFactorSet" + ) + logger.debug(f"2SV factor set: {self.policies.two_sv_allowed_factor_set}") + + elif setting_type == "security.two_step_verification_device_trust": + self.policies.two_sv_allow_trusting_device = value.get( + "allowTrustingDevice" + ) + logger.debug( + f"2SV device trust: {self.policies.two_sv_allow_trusting_device}" + ) + + elif setting_type == "security.two_step_verification_grace_period": + self.policies.two_sv_enrollment_grace_period = value.get( + "enrollmentGracePeriod" + ) + logger.debug( + f"2SV grace period: {self.policies.two_sv_enrollment_grace_period}" + ) + + elif setting_type == "security.two_step_verification_sign_in_code": + self.policies.two_sv_backup_code_exception_period = value.get( + "backupCodeExceptionPeriod" + ) + logger.debug( + f"2SV backup code period: {self.policies.two_sv_backup_code_exception_period}" + ) + + # Account recovery + elif setting_type == "security.super_admin_account_recovery": + self.policies.super_admin_recovery_enabled = value.get( + "enableAccountRecovery" + ) + logger.debug( + f"Super admin recovery: {self.policies.super_admin_recovery_enabled}" + ) + + elif setting_type == "security.user_account_recovery": + self.policies.user_recovery_enabled = value.get("enableAccountRecovery") + logger.debug(f"User recovery: {self.policies.user_recovery_enabled}") + + # Advanced Protection Program + elif setting_type == "security.advanced_protection_program": + self.policies.advanced_protection_enrollment = value.get( + "enableAdvancedProtectionSelfEnrollment" + ) + self.policies.advanced_protection_security_code_option = value.get( + "securityCodeOption" + ) + logger.debug("Advanced Protection Program settings fetched.") + + # Login challenges + elif setting_type == "security.login_challenges": + self.policies.login_challenge_employee_id = value.get( + "enableEmployeeIdChallenge" + ) + logger.debug("Login challenges settings fetched.") + + # Password policy + elif setting_type == "security.password": + self.policies.password_minimum_length = value.get("minimumLength") + self.policies.password_maximum_length = value.get("maximumLength") + self.policies.password_allowed_strength = value.get("allowedStrength") + self.policies.password_allow_reuse = value.get("allowReuse") + self.policies.password_enforce_at_login = value.get( + "enforceRequirementsAtLogin" + ) + self.policies.password_expiration_duration = value.get("expirationDuration") + logger.debug("Password policy settings fetched.") + + # Less secure apps + elif setting_type == "security.less_secure_apps": + self.policies.less_secure_apps_allowed = value.get("allowLessSecureApps") + logger.debug(f"Less secure apps: {self.policies.less_secure_apps_allowed}") + + # Session controls + elif setting_type == "security.session_controls": + self.policies.web_session_duration = value.get("webSessionDuration") + logger.debug(f"Web session duration: {self.policies.web_session_duration}") + + # Passkeys restriction + elif setting_type == "security.passkeys_restriction": + self.policies.passkeys_type = value.get("allowedPasskeysType") + logger.debug(f"Passkeys type: {self.policies.passkeys_type}") + + # API controls - internal apps + elif setting_type == "api_controls.internal_apps": + self.policies.trust_internal_apps = value.get("trustInternalApps") + logger.debug(f"Trust internal apps: {self.policies.trust_internal_apps}") + + # API controls - google services + elif setting_type == "api_controls.google_services": + services = value.get("services", []) + for svc in services: + if svc.get("isEnabled") is False: + self.policies.google_services_restricted = True + break + if self.policies.google_services_restricted is None: + self.policies.google_services_restricted = False + logger.debug( + f"Google services restricted: {self.policies.google_services_restricted}" + ) + + # DLP rules + elif setting_type == "rule.dlp": + state = value.get("state") + triggers = value.get("triggers", []) + if state == "ACTIVE" and any( + trigger.startswith("google.workspace.drive.") for trigger in triggers + ): + self.policies.dlp_drive_rules_exist = True + logger.debug(f"DLP rule: state={state}, triggers={triggers}") + + +class SecurityPolicies(BaseModel): + """Model for domain-level Security policy settings.""" + + # security.two_step_verification_enrollment + two_sv_allow_enrollment: Optional[bool] = None + # security.two_step_verification_enforcement + two_sv_enforced_from: Optional[str] = None + # security.two_step_verification_enforcement_factor + two_sv_allowed_factor_set: Optional[str] = None + # security.two_step_verification_device_trust + two_sv_allow_trusting_device: Optional[bool] = None + # security.two_step_verification_grace_period + two_sv_enrollment_grace_period: Optional[str] = None + # security.two_step_verification_sign_in_code + two_sv_backup_code_exception_period: Optional[str] = None + # security.super_admin_account_recovery + super_admin_recovery_enabled: Optional[bool] = None + # security.user_account_recovery + user_recovery_enabled: Optional[bool] = None + # security.advanced_protection_program + advanced_protection_enrollment: Optional[bool] = None + advanced_protection_security_code_option: Optional[str] = None + # security.login_challenges + login_challenge_employee_id: Optional[bool] = None + # security.password + password_minimum_length: Optional[int] = None + password_maximum_length: Optional[int] = None + password_allowed_strength: Optional[str] = None + password_allow_reuse: Optional[bool] = None + password_enforce_at_login: Optional[bool] = None + password_expiration_duration: Optional[str] = None + # security.less_secure_apps + less_secure_apps_allowed: Optional[bool] = None + # security.session_controls + web_session_duration: Optional[str] = None + # security.passkeys_restriction + passkeys_type: Optional[str] = None + # api_controls.internal_apps + trust_internal_apps: Optional[bool] = None + # api_controls.google_services + google_services_restricted: Optional[bool] = None + # rule.dlp + dlp_drive_rules_exist: Optional[bool] = None diff --git a/prowler/providers/googleworkspace/services/security/security_session_duration_limited/__init__.py b/prowler/providers/googleworkspace/services/security/security_session_duration_limited/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.metadata.json b/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.metadata.json new file mode 100644 index 0000000000..5cc4916945 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_session_duration_limited", + "CheckTitle": "Google session control is configured to 12 hours or less", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level Google session control limits web session duration to **12 hours or less**. When a session expires, users must re-authenticate, reducing the window of opportunity for session hijacking.", + "Risk": "The default 14-day session duration means that a compromised session token provides an attacker with **two weeks of uninterrupted access** without re-authentication. Shorter session durations limit the impact of **stolen cookies, session hijacking, and unauthorized access** from shared or untrusted devices.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/security/set-session-length-for-google-services", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Security** > **Access and Data Control** > **Google session control**\n3. Set **Web session duration** to **12 hours** or less\n4. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set **Google session control** web session duration to **12 hours or less** to limit the window of access from compromised sessions.", + "Url": "https://hub.prowler.com/check/security_session_duration_limited" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.py b/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.py new file mode 100644 index 0000000000..1b72310ee6 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited.py @@ -0,0 +1,75 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.lib.logger import logger +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + +MAX_SESSION_DURATION_SECONDS = 43200 # 12 hours + + +class security_session_duration_limited(Check): + """Check that Google session control is configured to 12 hours or less. + + This check verifies that the domain-level web session duration is set + to 12 hours or less, requiring users to re-authenticate more frequently + than the default 14-day session length. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + duration_str = security_client.policies.web_session_duration + domain = security_client.provider.identity.domain + + if duration_str is None: + report.status = "FAIL" + report.status_extended = ( + f"Google session control is not explicitly configured " + f"in domain {domain}. The default is 14 days. " + f"Web session duration should be 12 hours or less." + ) + findings.append(report) + return findings + + try: + duration_seconds = int(duration_str.removesuffix("s")) + except ValueError: + logger.error(f"Unparseable web session duration: {duration_str!r}") + report.status = "FAIL" + report.status_extended = ( + f"Web session duration value {duration_str!r} is not parseable " + f"in domain {domain}." + ) + findings.append(report) + return findings + + duration_hours = duration_seconds / 3600 + + if duration_seconds <= MAX_SESSION_DURATION_SECONDS: + report.status = "PASS" + report.status_extended = ( + f"Google session control is set to {duration_hours:.0f} hours " + f"in domain {domain}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Google session control is set to {duration_hours:.0f} hours " + f"in domain {domain}. " + f"Web session duration should be 12 hours or less." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/__init__.py b/prowler/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled.metadata.json b/prowler/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled.metadata.json new file mode 100644 index 0000000000..c39fb69d03 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_super_admin_recovery_disabled", + "CheckTitle": "Super Admin account recovery is disabled", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level policy **disables self-service account recovery for Super Admin accounts**. When disabled, Super Admins cannot use recovery options (phone, email) to regain access, reducing the risk of account takeover through compromised recovery channels.", + "Risk": "If Super Admin account recovery is enabled, an attacker who compromises a Super Admin's **recovery phone or email** could use the self-service recovery flow to take over the account, gaining **full administrative control** over the entire Google Workspace organization.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/users/allow-super-administrators-to-recover-their-password", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Security** > **Authentication** > **Account recovery**\n3. Select **Super admin account recovery**\n4. Uncheck **Allow super admins to recover their account**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable **Super Admin account recovery** to prevent attackers from exploiting the self-service recovery flow to take over privileged accounts.", + "Url": "https://hub.prowler.com/check/security_super_admin_recovery_disabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "security_user_recovery_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled.py b/prowler/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled.py new file mode 100644 index 0000000000..5c201e7b5d --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled.py @@ -0,0 +1,55 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_super_admin_recovery_disabled(Check): + """Check that Super Admin account recovery is disabled. + + This check verifies that the domain-level policy prevents Super Admin + users from recovering their account through self-service, reducing the + risk of account takeover through the recovery flow. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + recovery_enabled = security_client.policies.super_admin_recovery_enabled + + if recovery_enabled is False: + report.status = "PASS" + report.status_extended = ( + f"Super Admin account recovery is disabled " + f"in domain {security_client.provider.identity.domain}." + ) + elif recovery_enabled is None: + report.status = "PASS" + report.status_extended = ( + f"Super Admin account recovery uses Google's secure default " + f"configuration (disabled) " + f"in domain {security_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Super Admin account recovery is enabled " + f"in domain {security_client.provider.identity.domain}. " + f"Super Admin account recovery should be disabled to prevent " + f"account takeover through the recovery flow." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/security/security_user_recovery_enabled/__init__.py b/prowler/providers/googleworkspace/services/security/security_user_recovery_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled.metadata.json b/prowler/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled.metadata.json new file mode 100644 index 0000000000..4d3e7dcb64 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "googleworkspace", + "CheckID": "security_user_recovery_enabled", + "CheckTitle": "User account recovery is enabled", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The domain-level policy **enables self-service account recovery for non-Super Admin users**. When enabled, users can recover access to their accounts if their password is forgotten, reducing helpdesk burden and downtime.", + "Risk": "When user account recovery is disabled, users who lose access to their accounts must **contact an administrator** to regain access. This increases helpdesk burden, extends downtime, and may lead to users adopting **insecure workarounds** like sharing credentials.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://knowledge.workspace.google.com/admin/users/set-up-password-recovery-for-users", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Security** > **Authentication** > **Account recovery**\n3. Select **User account recovery**\n4. Check **Allow users and non-super admins to recover their account**\n5. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable **user account recovery** to allow non-admin users to regain access to their accounts through self-service, reducing helpdesk burden and user downtime.", + "Url": "https://hub.prowler.com/check/security_user_recovery_enabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "security_super_admin_recovery_disabled" + ], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled.py b/prowler/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled.py new file mode 100644 index 0000000000..99aabcb842 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled.py @@ -0,0 +1,56 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.security_client import ( + security_client, +) + + +class security_user_recovery_enabled(Check): + """Check that user account recovery is enabled. + + This check verifies that the domain-level policy allows non-Super Admin + users to recover their accounts through self-service, reducing helpdesk + burden while maintaining access to their accounts. + """ + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if security_client.policies_fetched: + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=security_client.policies, + resource_id="securityPolicies", + resource_name="Security Policies", + customer_id=security_client.provider.identity.customer_id, + ) + + recovery_enabled = security_client.policies.user_recovery_enabled + + if recovery_enabled is True: + report.status = "PASS" + report.status_extended = ( + f"User account recovery is enabled " + f"in domain {security_client.provider.identity.domain}." + ) + else: + report.status = "FAIL" + if recovery_enabled is None: + report.status_extended = ( + f"User account recovery is not explicitly configured " + f"in domain {security_client.provider.identity.domain}. " + f"The default is disabled. User account recovery should be " + f"enabled to reduce helpdesk burden." + ) + else: + report.status_extended = ( + f"User account recovery is disabled " + f"in domain {security_client.provider.identity.domain}. " + f"User account recovery should be enabled to reduce " + f"helpdesk burden." + ) + + findings.append(report) + + return findings diff --git a/tests/providers/googleworkspace/services/security/googleworkspace_security_service_test.py b/tests/providers/googleworkspace/services/security/googleworkspace_security_service_test.py new file mode 100644 index 0000000000..13335e22cb --- /dev/null +++ b/tests/providers/googleworkspace/services/security/googleworkspace_security_service_test.py @@ -0,0 +1,499 @@ +from unittest.mock import MagicMock, patch + +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + set_mocked_googleworkspace_provider, +) + + +class TestSecurityService: + def test_fetch_policies_all_security_settings(self): + """Test fetching security policies from Cloud Identity API""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_credentials = MagicMock() + mock_session = MagicMock() + mock_session.credentials = mock_credentials + mock_provider.session = mock_session + + mock_service = MagicMock() + + # First call: security.* settings + mock_security_list = MagicMock() + mock_security_list.execute.return_value = { + "policies": [ + { + "setting": { + "type": "settings/security.two_step_verification_enrollment", + "value": { + "allowEnrollment": True, + }, + } + }, + { + "setting": { + "type": "settings/security.two_step_verification_enforcement", + "value": { + "enforcedFrom": "2026-05-25T15:27:52.352Z", + }, + } + }, + { + "setting": { + "type": "settings/security.two_step_verification_enforcement_factor", + "value": { + "allowedSignInFactorSet": "ALL", + }, + } + }, + { + "setting": { + "type": "settings/security.two_step_verification_device_trust", + "value": { + "allowTrustingDevice": True, + }, + } + }, + { + "setting": { + "type": "settings/security.two_step_verification_grace_period", + "value": { + "enrollmentGracePeriod": "0s", + }, + } + }, + { + "setting": { + "type": "settings/security.two_step_verification_sign_in_code", + "value": { + "backupCodeExceptionPeriod": "86400s", + }, + } + }, + { + "setting": { + "type": "settings/security.super_admin_account_recovery", + "value": { + "enableAccountRecovery": True, + }, + } + }, + { + "setting": { + "type": "settings/security.user_account_recovery", + "value": { + "enableAccountRecovery": False, + }, + } + }, + { + "setting": { + "type": "settings/security.password", + "value": { + "allowedStrength": "STRONG", + "minimumLength": 8, + "maximumLength": 100, + "enforceRequirementsAtLogin": False, + "allowReuse": False, + "expirationDuration": "0s", + }, + } + }, + { + "setting": { + "type": "settings/security.session_controls", + "value": { + "webSessionDuration": "1209600s", + }, + } + }, + { + "setting": { + "type": "settings/security.less_secure_apps", + "value": { + "allowLessSecureApps": False, + }, + } + }, + { + "setting": { + "type": "settings/security.advanced_protection_program", + "value": { + "enableAdvancedProtectionSelfEnrollment": True, + "securityCodeOption": "CODES_NOT_ALLOWED", + }, + } + }, + { + "setting": { + "type": "settings/security.login_challenges", + "value": { + "enableEmployeeIdChallenge": False, + }, + } + }, + { + "setting": { + "type": "settings/security.passkeys_restriction", + "value": { + "allowedPasskeysType": "ANY_DEVICE_OR_PLATFORM", + }, + } + }, + ] + } + + # Second call: api_controls.* settings + mock_api_controls_list = MagicMock() + mock_api_controls_list.execute.return_value = { + "policies": [ + { + "setting": { + "type": "settings/api_controls.internal_apps", + "value": { + "trustInternalApps": True, + }, + } + }, + { + "setting": { + "type": "settings/api_controls.google_services", + "value": { + "services": [ + {"scopesGroup": "DRIVE_ALL", "isEnabled": True}, + {"scopesGroup": "GMAIL_HIGH_RISK", "isEnabled": False}, + ], + }, + } + }, + ] + } + + # Third call: rule.dlp + mock_dlp_list = MagicMock() + mock_dlp_list.execute.return_value = { + "policies": [ + { + "setting": { + "type": "settings/rule.dlp", + "value": { + "displayName": "PII Detection", + "triggers": ["google.workspace.drive.file.v1.share"], + "state": "ACTIVE", + }, + } + }, + ] + } + + mock_service.policies().list.side_effect = [ + mock_security_list, + mock_api_controls_list, + mock_dlp_list, + ] + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.security.security_service import ( + Security, + ) + + security = Security(mock_provider) + + assert security.policies_fetched is True + assert security.policies.two_sv_allow_enrollment is True + assert security.policies.two_sv_enforced_from == "2026-05-25T15:27:52.352Z" + assert security.policies.two_sv_allowed_factor_set == "ALL" + assert security.policies.two_sv_allow_trusting_device is True + assert security.policies.two_sv_enrollment_grace_period == "0s" + assert security.policies.two_sv_backup_code_exception_period == "86400s" + assert security.policies.super_admin_recovery_enabled is True + assert security.policies.user_recovery_enabled is False + assert security.policies.password_minimum_length == 8 + assert security.policies.password_allowed_strength == "STRONG" + assert security.policies.password_allow_reuse is False + assert security.policies.password_enforce_at_login is False + assert security.policies.password_expiration_duration == "0s" + assert security.policies.web_session_duration == "1209600s" + assert security.policies.less_secure_apps_allowed is False + assert security.policies.advanced_protection_enrollment is True + assert ( + security.policies.advanced_protection_security_code_option + == "CODES_NOT_ALLOWED" + ) + assert security.policies.login_challenge_employee_id is False + assert security.policies.passkeys_type == "ANY_DEVICE_OR_PLATFORM" + assert security.policies.trust_internal_apps is True + assert security.policies.google_services_restricted is True + assert security.policies.dlp_drive_rules_exist is True + + def test_fetch_policies_empty_response(self): + """Test handling empty policies response across all namespaces""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_empty = MagicMock() + mock_empty.execute.return_value = {"policies": []} + mock_service.policies().list.side_effect = [ + mock_empty, + mock_empty, + mock_empty, + ] + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.security.security_service import ( + Security, + ) + + security = Security(mock_provider) + + assert security.policies_fetched is True + assert security.policies.two_sv_enforced_from is None + assert security.policies.super_admin_recovery_enabled is None + assert security.policies.password_minimum_length is None + assert security.policies.web_session_duration is None + assert security.policies.less_secure_apps_allowed is None + assert security.policies.trust_internal_apps is None + assert security.policies.dlp_drive_rules_exist is None + + def test_fetch_policies_api_error(self): + """Test handling of API errors during policy fetch""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_service.policies().list.side_effect = Exception("API Error") + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.security.security_service import ( + Security, + ) + + security = Security(mock_provider) + + assert security.policies_fetched is False + + def test_fetch_policies_build_service_returns_none(self): + """Test early return when _build_service fails""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_service.GoogleWorkspaceService._build_service", + return_value=None, + ), + ): + from prowler.providers.googleworkspace.services.security.security_service import ( + Security, + ) + + security = Security(mock_provider) + + assert security.policies_fetched is False + + def test_fetch_policies_execute_raises(self): + """Test inner except handler when request.execute() raises during pagination""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_request = MagicMock() + mock_request.execute.side_effect = Exception("Execute failed") + mock_service.policies().list.return_value = mock_request + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.security.security_service import ( + Security, + ) + + security = Security(mock_provider) + + assert security.policies_fetched is False + + def test_fetch_policies_google_services_no_restricted(self): + """Test google_services with all services enabled sets restricted to False""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_empty = MagicMock() + mock_empty.execute.return_value = {"policies": []} + + mock_api_list = MagicMock() + mock_api_list.execute.return_value = { + "policies": [ + { + "setting": { + "type": "settings/api_controls.google_services", + "value": { + "services": [ + {"scopesGroup": "DRIVE_ALL", "isEnabled": True}, + {"scopesGroup": "GMAIL_ALL", "isEnabled": True}, + ], + }, + } + }, + ] + } + + mock_service.policies().list.side_effect = [ + mock_empty, + mock_api_list, + mock_empty, + ] + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.security.security_service import ( + Security, + ) + + security = Security(mock_provider) + + assert security.policies_fetched is True + assert security.policies.google_services_restricted is False + + def test_dlp_rule_without_drive_trigger_ignored(self): + """Test that DLP rules without Drive triggers don't set dlp_drive_rules_exist""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_empty = MagicMock() + mock_empty.execute.return_value = {"policies": []} + + mock_dlp_list = MagicMock() + mock_dlp_list.execute.return_value = { + "policies": [ + { + "setting": { + "type": "settings/rule.dlp", + "value": { + "displayName": "Gmail Only Rule", + "triggers": ["google.workspace.gmail.email.v1.send"], + "state": "ACTIVE", + }, + } + }, + ] + } + + mock_service.policies().list.side_effect = [ + mock_empty, + mock_empty, + mock_dlp_list, + ] + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.security.security_service import ( + Security, + ) + + security = Security(mock_provider) + + assert security.policies_fetched is True + assert security.policies.dlp_drive_rules_exist is None + + def test_security_policies_model(self): + """Test SecurityPolicies Pydantic model""" + from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, + ) + + policies = SecurityPolicies( + two_sv_enforced_from="2026-05-25T15:27:52.352Z", + super_admin_recovery_enabled=False, + password_minimum_length=14, + web_session_duration="43200s", + ) + + assert policies.two_sv_enforced_from == "2026-05-25T15:27:52.352Z" + assert policies.super_admin_recovery_enabled is False + assert policies.password_minimum_length == 14 + assert policies.web_session_duration == "43200s" diff --git a/tests/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced_test.py b/tests/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced_test.py new file mode 100644 index 0000000000..a6d6a549a9 --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced_test.py @@ -0,0 +1,156 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurity2svEnforced: + def test_pass_2sv_enforced(self): + """Test PASS when 2-Step Verification enforcement is active""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced import ( + security_2sv_enforced, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + two_sv_enforced_from="2026-05-25T15:27:52.352Z" + ) + + check = security_2sv_enforced() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "active" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_none_not_configured(self): + """Test FAIL when 2-Step Verification enforcement is not configured (None)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced import ( + security_2sv_enforced, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(two_sv_enforced_from=None) + + check = security_2sv_enforced() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not configured" in findings[0].status_extended + + def test_fail_empty_off(self): + """Test FAIL when 2-Step Verification enforcement is set to OFF (empty string)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced import ( + security_2sv_enforced, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(two_sv_enforced_from="") + + check = security_2sv_enforced() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "OFF" in findings[0].status_extended + + def test_fail_epoch_enforcement_off(self): + """Test FAIL when API returns epoch zero timestamp (enforcement OFF)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced import ( + security_2sv_enforced, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + two_sv_enforced_from="1970-01-01T00:00:00Z" + ) + + check = security_2sv_enforced() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "OFF" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced import ( + security_2sv_enforced, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_2sv_enforced() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins_test.py b/tests/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins_test.py new file mode 100644 index 0000000000..522164f341 --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins_test.py @@ -0,0 +1,126 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurity2svHardwareKeysAdmins: + def test_pass_passkey_only(self): + """Test PASS when 2SV enforcement requires security keys only""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins import ( + security_2sv_hardware_keys_admins, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + two_sv_allowed_factor_set="PASSKEY_ONLY" + ) + + check = security_2sv_hardware_keys_admins() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "security keys only" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_all_methods_allowed(self): + """Test FAIL when 2SV enforcement allows ALL methods""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins import ( + security_2sv_hardware_keys_admins, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(two_sv_allowed_factor_set="ALL") + + check = security_2sv_hardware_keys_admins() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "ALL" in findings[0].status_extended + + def test_fail_none_not_configured(self): + """Test FAIL when 2SV enforcement factor is not configured (None)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins import ( + security_2sv_hardware_keys_admins, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(two_sv_allowed_factor_set=None) + + check = security_2sv_hardware_keys_admins() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not configured" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins import ( + security_2sv_hardware_keys_admins, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_2sv_hardware_keys_admins() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured_test.py b/tests/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured_test.py new file mode 100644 index 0000000000..86f25229ae --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_advanced_protection_configured/security_advanced_protection_configured_test.py @@ -0,0 +1,163 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurityAdvancedProtectionConfigured: + def test_pass_properly_configured(self): + """Test PASS when Advanced Protection is configured with enrollment and codes blocked""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_advanced_protection_configured.security_advanced_protection_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_advanced_protection_configured.security_advanced_protection_configured import ( + security_advanced_protection_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + advanced_protection_enrollment=True, + advanced_protection_security_code_option="CODES_NOT_ALLOWED", + ) + + check = security_advanced_protection_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "configured" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_codes_allowed(self): + """Test FAIL when enrollment is enabled but security codes are allowed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_advanced_protection_configured.security_advanced_protection_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_advanced_protection_configured.security_advanced_protection_configured import ( + security_advanced_protection_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + advanced_protection_enrollment=True, + advanced_protection_security_code_option="ALLOWED_WITHOUT_REMOTE_ACCESS", + ) + + check = security_advanced_protection_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not properly configured" in findings[0].status_extended + + def test_fail_enrollment_disabled(self): + """Test FAIL when enrollment is disabled""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_advanced_protection_configured.security_advanced_protection_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_advanced_protection_configured.security_advanced_protection_configured import ( + security_advanced_protection_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + advanced_protection_enrollment=False, + ) + + check = security_advanced_protection_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not properly configured" in findings[0].status_extended + + def test_fail_enrollment_unset(self): + """Test FAIL when enrollment is None (not configured)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_advanced_protection_configured.security_advanced_protection_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_advanced_protection_configured.security_advanced_protection_configured import ( + security_advanced_protection_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + advanced_protection_enrollment=None, + advanced_protection_security_code_option="CODES_NOT_ALLOWED", + ) + + check = security_advanced_protection_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "enrollment is not configured" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_advanced_protection_configured.security_advanced_protection_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_advanced_protection_configured.security_advanced_protection_configured import ( + security_advanced_protection_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_advanced_protection_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted_test.py b/tests/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted_test.py new file mode 100644 index 0000000000..ad11b50a02 --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_app_access_restricted/security_app_access_restricted_test.py @@ -0,0 +1,124 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurityAppAccessRestricted: + def test_pass_access_restricted(self): + """Test PASS when application access to Google services is restricted""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_app_access_restricted.security_app_access_restricted.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_app_access_restricted.security_app_access_restricted import ( + security_app_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(google_services_restricted=True) + + check = security_app_access_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "restricted" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_access_unrestricted(self): + """Test FAIL when application access to Google services is unrestricted""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_app_access_restricted.security_app_access_restricted.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_app_access_restricted.security_app_access_restricted import ( + security_app_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(google_services_restricted=False) + + check = security_app_access_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "unrestricted" in findings[0].status_extended + + def test_fail_none_not_configured(self): + """Test FAIL when application access is not configured (None)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_app_access_restricted.security_app_access_restricted.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_app_access_restricted.security_app_access_restricted import ( + security_app_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(google_services_restricted=None) + + check = security_app_access_restricted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not configured" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_app_access_restricted.security_app_access_restricted.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_app_access_restricted.security_app_access_restricted import ( + security_app_access_restricted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_app_access_restricted() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured_test.py b/tests/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured_test.py new file mode 100644 index 0000000000..402c7b4f2d --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_dlp_drive_rules_configured/security_dlp_drive_rules_configured_test.py @@ -0,0 +1,124 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurityDlpDriveRulesConfigured: + def test_pass_dlp_rules_configured(self): + """Test PASS when DLP policies for Google Drive are configured""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured import ( + security_dlp_drive_rules_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(dlp_drive_rules_exist=True) + + check = security_dlp_drive_rules_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "configured" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_no_dlp_rules(self): + """Test FAIL when no DLP policies for Google Drive are configured""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured import ( + security_dlp_drive_rules_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(dlp_drive_rules_exist=False) + + check = security_dlp_drive_rules_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active" in findings[0].status_extended + + def test_fail_none_no_dlp_rules(self): + """Test FAIL when DLP rules existence is None (no active rules)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured import ( + security_dlp_drive_rules_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(dlp_drive_rules_exist=None) + + check = security_dlp_drive_rules_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured import ( + security_dlp_drive_rules_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_dlp_drive_rules_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted_test.py b/tests/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted_test.py new file mode 100644 index 0000000000..2e0aafc1fb --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_internal_apps_trusted/security_internal_apps_trusted_test.py @@ -0,0 +1,127 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurityInternalAppsTrusted: + def test_pass_internal_apps_trusted(self): + """Test PASS when internal domain-owned apps are trusted""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_internal_apps_trusted.security_internal_apps_trusted.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_internal_apps_trusted.security_internal_apps_trusted import ( + security_internal_apps_trusted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(trust_internal_apps=True) + + check = security_internal_apps_trusted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "trusted" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_pass_none_secure_default(self): + """Test PASS when internal apps trust is None (secure default)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_internal_apps_trusted.security_internal_apps_trusted.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_internal_apps_trusted.security_internal_apps_trusted import ( + security_internal_apps_trusted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(trust_internal_apps=None) + + check = security_internal_apps_trusted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_internal_apps_not_trusted(self): + """Test FAIL when internal domain-owned apps are not trusted""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_internal_apps_trusted.security_internal_apps_trusted.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_internal_apps_trusted.security_internal_apps_trusted import ( + security_internal_apps_trusted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(trust_internal_apps=False) + + check = security_internal_apps_trusted() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not trusted" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_internal_apps_trusted.security_internal_apps_trusted.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_internal_apps_trusted.security_internal_apps_trusted import ( + security_internal_apps_trusted, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_internal_apps_trusted() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled_test.py b/tests/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled_test.py new file mode 100644 index 0000000000..6dc9d53416 --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_less_secure_apps_disabled/security_less_secure_apps_disabled_test.py @@ -0,0 +1,127 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurityLessSecureAppsDisabled: + def test_pass_less_secure_apps_disabled(self): + """Test PASS when less secure app access is explicitly disabled""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_less_secure_apps_disabled.security_less_secure_apps_disabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_less_secure_apps_disabled.security_less_secure_apps_disabled import ( + security_less_secure_apps_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(less_secure_apps_allowed=False) + + check = security_less_secure_apps_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "disabled" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_pass_none_secure_default(self): + """Test PASS when less secure app access is None (secure default)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_less_secure_apps_disabled.security_less_secure_apps_disabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_less_secure_apps_disabled.security_less_secure_apps_disabled import ( + security_less_secure_apps_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(less_secure_apps_allowed=None) + + check = security_less_secure_apps_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_less_secure_apps_enabled(self): + """Test FAIL when less secure app access is enabled""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_less_secure_apps_disabled.security_less_secure_apps_disabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_less_secure_apps_disabled.security_less_secure_apps_disabled import ( + security_less_secure_apps_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(less_secure_apps_allowed=True) + + check = security_less_secure_apps_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "enabled" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_less_secure_apps_disabled.security_less_secure_apps_disabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_less_secure_apps_disabled.security_less_secure_apps_disabled import ( + security_less_secure_apps_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_less_secure_apps_disabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured_test.py b/tests/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured_test.py new file mode 100644 index 0000000000..8cc084ca84 --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured_test.py @@ -0,0 +1,127 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurityLoginChallengesConfigured: + def test_pass_employee_id_challenge_disabled(self): + """Test PASS when employee ID login challenge is disabled""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_login_challenges_configured.security_login_challenges_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_login_challenges_configured.security_login_challenges_configured import ( + security_login_challenges_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(login_challenge_employee_id=False) + + check = security_login_challenges_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "disabled" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_pass_none_secure_default(self): + """Test PASS when employee ID login challenge is None (secure default)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_login_challenges_configured.security_login_challenges_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_login_challenges_configured.security_login_challenges_configured import ( + security_login_challenges_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(login_challenge_employee_id=None) + + check = security_login_challenges_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_employee_id_challenge_enabled(self): + """Test FAIL when employee ID login challenge is enabled""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_login_challenges_configured.security_login_challenges_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_login_challenges_configured.security_login_challenges_configured import ( + security_login_challenges_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(login_challenge_employee_id=True) + + check = security_login_challenges_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "enabled" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_login_challenges_configured.security_login_challenges_configured.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_login_challenges_configured.security_login_challenges_configured import ( + security_login_challenges_configured, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_login_challenges_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong_test.py b/tests/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong_test.py new file mode 100644 index 0000000000..850bf243a9 --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong_test.py @@ -0,0 +1,210 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurityPasswordPolicyStrong: + def test_pass_strong_password_policy(self): + """Test PASS when password policy meets CIS requirements""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong import ( + security_password_policy_strong, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + password_minimum_length=14, + password_allowed_strength="STRONG", + password_allow_reuse=False, + password_enforce_at_login=True, + password_expiration_duration="31536000s", + ) + + check = security_password_policy_strong() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "meets CIS requirements" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_weak_password_policy(self): + """Test FAIL when password policy does not meet CIS requirements""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong import ( + security_password_policy_strong, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + password_minimum_length=8, + password_allowed_strength="STRONG", + password_allow_reuse=False, + password_enforce_at_login=False, + password_expiration_duration="0s", + ) + + check = security_password_policy_strong() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "does not meet" in findings[0].status_extended + + def test_fail_none_all_defaults(self): + """Test FAIL when all password policy fields are None (defaults)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong import ( + security_password_policy_strong, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + password_minimum_length=None, + password_allowed_strength=None, + password_allow_reuse=None, + password_enforce_at_login=None, + password_expiration_duration=None, + ) + + check = security_password_policy_strong() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "does not meet" in findings[0].status_extended + + def test_fail_strength_unset_treated_as_missing(self): + """Test FAIL when password_allowed_strength is None even with other fields strong""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong import ( + security_password_policy_strong, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + password_minimum_length=14, + password_allowed_strength=None, + password_allow_reuse=False, + password_enforce_at_login=True, + password_expiration_duration="31536000s", + ) + + check = security_password_policy_strong() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "password strength is not configured" in findings[0].status_extended + + def test_fail_min_length_unset_reports_not_configured(self): + """Test FAIL message uses 'not configured' when password_minimum_length is None""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong import ( + security_password_policy_strong, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + password_minimum_length=None, + password_allowed_strength="STRONG", + password_allow_reuse=False, + password_enforce_at_login=True, + password_expiration_duration="31536000s", + ) + + check = security_password_policy_strong() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "minimum length is not configured" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong import ( + security_password_policy_strong, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_password_policy_strong() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited_test.py b/tests/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited_test.py new file mode 100644 index 0000000000..668b6aea78 --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_session_duration_limited/security_session_duration_limited_test.py @@ -0,0 +1,152 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecuritySessionDurationLimited: + def test_pass_session_12_hours(self): + """Test PASS when session duration is set to 12 hours""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited import ( + security_session_duration_limited, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(web_session_duration="43200s") + + check = security_session_duration_limited() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "12 hours" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_long_session_duration(self): + """Test FAIL when session duration is too long (336 hours)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited import ( + security_session_duration_limited, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(web_session_duration="1209600s") + + check = security_session_duration_limited() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "336 hours" in findings[0].status_extended + + def test_fail_none_not_configured(self): + """Test FAIL when session duration is not explicitly configured (None)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited import ( + security_session_duration_limited, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(web_session_duration=None) + + check = security_session_duration_limited() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not explicitly configured" in findings[0].status_extended + + def test_fail_unparseable_duration(self): + """Test FAIL when session duration has an unexpected format instead of crashing""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited import ( + security_session_duration_limited, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(web_session_duration="invalid") + + check = security_session_duration_limited() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not parseable" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited import ( + security_session_duration_limited, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_session_duration_limited() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled_test.py b/tests/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled_test.py new file mode 100644 index 0000000000..d9a1f306b8 --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_super_admin_recovery_disabled/security_super_admin_recovery_disabled_test.py @@ -0,0 +1,127 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecuritySuperAdminRecoveryDisabled: + def test_pass_recovery_disabled(self): + """Test PASS when Super Admin account recovery is explicitly disabled""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_super_admin_recovery_disabled.security_super_admin_recovery_disabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_super_admin_recovery_disabled.security_super_admin_recovery_disabled import ( + security_super_admin_recovery_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(super_admin_recovery_enabled=False) + + check = security_super_admin_recovery_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "disabled" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_pass_none_secure_default(self): + """Test PASS when Super Admin account recovery is None (secure default)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_super_admin_recovery_disabled.security_super_admin_recovery_disabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_super_admin_recovery_disabled.security_super_admin_recovery_disabled import ( + security_super_admin_recovery_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(super_admin_recovery_enabled=None) + + check = security_super_admin_recovery_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "secure default" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_recovery_enabled(self): + """Test FAIL when Super Admin account recovery is enabled""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_super_admin_recovery_disabled.security_super_admin_recovery_disabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_super_admin_recovery_disabled.security_super_admin_recovery_disabled import ( + security_super_admin_recovery_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(super_admin_recovery_enabled=True) + + check = security_super_admin_recovery_disabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "enabled" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_super_admin_recovery_disabled.security_super_admin_recovery_disabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_super_admin_recovery_disabled.security_super_admin_recovery_disabled import ( + security_super_admin_recovery_disabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_super_admin_recovery_disabled() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled_test.py b/tests/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled_test.py new file mode 100644 index 0000000000..c8a519dbe0 --- /dev/null +++ b/tests/providers/googleworkspace/services/security/security_user_recovery_enabled/security_user_recovery_enabled_test.py @@ -0,0 +1,124 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.security.security_service import ( + SecurityPolicies, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + + +class TestSecurityUserRecoveryEnabled: + def test_pass_recovery_enabled(self): + """Test PASS when user account recovery is enabled""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_user_recovery_enabled.security_user_recovery_enabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_user_recovery_enabled.security_user_recovery_enabled import ( + security_user_recovery_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(user_recovery_enabled=True) + + check = security_user_recovery_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "enabled" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_recovery_disabled(self): + """Test FAIL when user account recovery is disabled""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_user_recovery_enabled.security_user_recovery_enabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_user_recovery_enabled.security_user_recovery_enabled import ( + security_user_recovery_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(user_recovery_enabled=False) + + check = security_user_recovery_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "disabled" in findings[0].status_extended + + def test_fail_none_not_configured(self): + """Test FAIL when user account recovery is None (not explicitly configured)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_user_recovery_enabled.security_user_recovery_enabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_user_recovery_enabled.security_user_recovery_enabled import ( + security_user_recovery_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies(user_recovery_enabled=None) + + check = security_user_recovery_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not explicitly configured" in findings[0].status_extended + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_user_recovery_enabled.security_user_recovery_enabled.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_user_recovery_enabled.security_user_recovery_enabled import ( + security_user_recovery_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = False + mock_client.policies = SecurityPolicies() + + check = security_user_recovery_enabled() + findings = check.execute() + + assert len(findings) == 0 From c58dad2ca4bb83f7a627b560ef805359c42e04e8 Mon Sep 17 00:00:00 2001 From: lydiavilchez <114735608+lydiavilchez@users.noreply.github.com> Date: Thu, 28 May 2026 11:17:33 +0200 Subject: [PATCH 04/14] feat(googleworkspace): add rules service checks (#11379) Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 1 + .../cis_1.3_googleworkspace.json | 32 +- .../cisa_scuba_0.6_googleworkspace.json | 11 +- .../services/rules/__init__.py | 0 .../__init__.py | 0 ...ege_granted_alert_configured.metadata.json | 38 +++ ...dmin_privilege_granted_alert_configured.py | 61 ++++ .../services/rules/rules_client.py | 6 + .../__init__.py | 0 ...ee_spoofing_alert_configured.metadata.json | 38 +++ ...mail_employee_spoofing_alert_configured.py | 61 ++++ .../__init__.py | 0 ...ked_attacks_alert_configured.metadata.json | 37 ++ ...ernment_backed_attacks_alert_configured.py | 61 ++++ .../__init__.py | 0 ...ed_password_alert_configured.metadata.json | 38 +++ .../rules_leaked_password_alert_configured.py | 61 ++++ .../__init__.py | 0 ...ord_changed_alert_configured.metadata.json | 38 +++ ...rules_password_changed_alert_configured.py | 61 ++++ .../services/rules/rules_service.py | 143 ++++++++ .../__init__.py | 0 ..._suspension_alert_configured.metadata.json | 38 +++ ...us_activity_suspension_alert_configured.py | 61 ++++ .../__init__.py | 0 ...cious_login_alert_configured.metadata.json | 38 +++ ...rules_suspicious_login_alert_configured.py | 61 ++++ .../__init__.py | 0 ...matic_login_alert_configured.metadata.json | 38 +++ ...ous_programmatic_login_alert_configured.py | 61 ++++ ...privilege_granted_alert_configured_test.py | 183 ++++++++++ ...employee_spoofing_alert_configured_test.py | 183 ++++++++++ ...nt_backed_attacks_alert_configured_test.py | 183 ++++++++++ ...s_leaked_password_alert_configured_test.py | 183 ++++++++++ ..._password_changed_alert_configured_test.py | 183 ++++++++++ .../services/rules/rules_service_test.py | 323 ++++++++++++++++++ ...tivity_suspension_alert_configured_test.py | 183 ++++++++++ ..._suspicious_login_alert_configured_test.py | 183 ++++++++++ ...rogrammatic_login_alert_configured_test.py | 183 ++++++++++ 39 files changed, 2762 insertions(+), 9 deletions(-) create mode 100644 prowler/providers/googleworkspace/services/rules/__init__.py create mode 100644 prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/__init__.py create mode 100644 prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.metadata.json create mode 100644 prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.py create mode 100644 prowler/providers/googleworkspace/services/rules/rules_client.py create mode 100644 prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/__init__.py create mode 100644 prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.metadata.json create mode 100644 prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.py create mode 100644 prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/__init__.py create mode 100644 prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.metadata.json create mode 100644 prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.py create mode 100644 prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/__init__.py create mode 100644 prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.metadata.json create mode 100644 prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.py create mode 100644 prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/__init__.py create mode 100644 prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.metadata.json create mode 100644 prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.py create mode 100644 prowler/providers/googleworkspace/services/rules/rules_service.py create mode 100644 prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/__init__.py create mode 100644 prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.metadata.json create mode 100644 prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.py create mode 100644 prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/__init__.py create mode 100644 prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.metadata.json create mode 100644 prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.py create mode 100644 prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/__init__.py create mode 100644 prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.metadata.json create mode 100644 prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.py create mode 100644 tests/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured_test.py create mode 100644 tests/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured_test.py create mode 100644 tests/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured_test.py create mode 100644 tests/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured_test.py create mode 100644 tests/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured_test.py create mode 100644 tests/providers/googleworkspace/services/rules/rules_service_test.py create mode 100644 tests/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured_test.py create mode 100644 tests/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured_test.py create mode 100644 tests/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index ef37ca39b5..f1e1008826 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `application` service for Okta provider with `application_admin_console_session_idle_timeout_15min`, `application_admin_console_mfa_required`, `application_admin_console_phishing_resistant_authentication`, `application_dashboard_mfa_required`, `application_dashboard_phishing_resistant_authentication`, and `application_authentication_policy_network_zone_enforced` checks [(#11358)](https://github.com/prowler-cloud/prowler/pull/11358) - AWS AI Security Framework compliance for AWS provider [(#11353)](https://github.com/prowler-cloud/prowler/pull/11353) - `storage_account_public_network_access_disabled` check for Azure provider and remapped the Azure CIS "Public Network Access is Disabled" requirements to it [(#11334)](https://github.com/prowler-cloud/prowler/pull/11334) +- 8 Rules service checks for Google Workspace provider using the Cloud Identity Policy API [(#11379)](https://github.com/prowler-cloud/prowler/pull/11379) - 12 Security service checks for Google Workspace provider using the Cloud Identity Policy API [(#11356)](https://github.com/prowler-cloud/prowler/pull/11356) ### ๐Ÿž Fixed diff --git a/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json b/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json index 972cdfe7a2..167842af4b 100644 --- a/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json +++ b/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json @@ -1827,7 +1827,9 @@ { "Id": "6.1", "Description": "Ensure User's password changed is configured", - "Checks": [], + "Checks": [ + "rules_password_changed_alert_configured" + ], "Attributes": [ { "Section": "6 Rules", @@ -1848,7 +1850,9 @@ { "Id": "6.2", "Description": "Ensure Government-backed attacks is configured", - "Checks": [], + "Checks": [ + "rules_government_backed_attacks_alert_configured" + ], "Attributes": [ { "Section": "6 Rules", @@ -1869,7 +1873,9 @@ { "Id": "6.3", "Description": "Ensure User suspended due to suspicious activity is configured", - "Checks": [], + "Checks": [ + "rules_suspicious_activity_suspension_alert_configured" + ], "Attributes": [ { "Section": "6 Rules", @@ -1890,7 +1896,9 @@ { "Id": "6.4", "Description": "Ensure User granted Admin privilege is configured", - "Checks": [], + "Checks": [ + "rules_admin_privilege_granted_alert_configured" + ], "Attributes": [ { "Section": "6 Rules", @@ -1911,7 +1919,9 @@ { "Id": "6.5", "Description": "Ensure Suspicious programmatic login is configured", - "Checks": [], + "Checks": [ + "rules_suspicious_programmatic_login_alert_configured" + ], "Attributes": [ { "Section": "6 Rules", @@ -1932,7 +1942,9 @@ { "Id": "6.6", "Description": "Ensure Suspicious login is configured", - "Checks": [], + "Checks": [ + "rules_suspicious_login_alert_configured" + ], "Attributes": [ { "Section": "6 Rules", @@ -1953,7 +1965,9 @@ { "Id": "6.7", "Description": "Ensure Leaked password is configured", - "Checks": [], + "Checks": [ + "rules_leaked_password_alert_configured" + ], "Attributes": [ { "Section": "6 Rules", @@ -1974,7 +1988,9 @@ { "Id": "6.8", "Description": "Ensure Gmail potential employee spoofing is configured", - "Checks": [], + "Checks": [ + "rules_gmail_employee_spoofing_alert_configured" + ], "Attributes": [ { "Section": "6 Rules", diff --git a/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json b/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json index 8fb840ae96..72ff97d97d 100644 --- a/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json +++ b/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json @@ -427,7 +427,16 @@ { "Id": "GWS.COMMONCONTROLS.13.1", "Description": "All system-defined alerting rules SHALL be enabled with alerts sent to admin email addresses", - "Checks": [], + "Checks": [ + "rules_password_changed_alert_configured", + "rules_government_backed_attacks_alert_configured", + "rules_suspicious_activity_suspension_alert_configured", + "rules_admin_privilege_granted_alert_configured", + "rules_suspicious_programmatic_login_alert_configured", + "rules_suspicious_login_alert_configured", + "rules_leaked_password_alert_configured", + "rules_gmail_employee_spoofing_alert_configured" + ], "Attributes": [ { "Section": "Common Controls", diff --git a/prowler/providers/googleworkspace/services/rules/__init__.py b/prowler/providers/googleworkspace/services/rules/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/__init__.py b/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.metadata.json new file mode 100644 index 0000000000..a93b4fb737 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "googleworkspace", + "CheckID": "rules_admin_privilege_granted_alert_configured", + "CheckTitle": "User granted Admin privilege alert rule is configured", + "CheckType": [], + "ServiceName": "rules", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "monitoring", + "Description": "The **User granted Admin privilege** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when a user is given elevated admin privileges.", + "Risk": "Without this alert enabled, administrators will not be notified when users receive **elevated admin privileges**. Unauthorized privilege escalation could indicate account compromise or insider threats and requires immediate verification.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/answer/3230421", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Select **Rules**\n3. Under **Google protects you by default** select **View list**\n4. Scroll to **User granted Admin privilege** and select it\n5. Within the Actions pane, click the edit pencil\n6. Select **Send to alert center** to set the alert to ON\n7. Set the alert severity to **Medium**\n8. Select **Send email notifications**\n9. Ensure **All super administrators** is selected as recipients\n10. Click **Review** to confirm the values\n11. Click **Update Rule**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure the **User granted Admin privilege** alert rule with alert center ON, email notifications ON, and recipients set to **all super administrators**.", + "Url": "https://hub.prowler.com/check/rules_admin_privilege_granted_alert_configured" + } + }, + "Categories": [ + "logging", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.py new file mode 100644 index 0000000000..55b9a685bf --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.py @@ -0,0 +1,61 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.rules_client import ( + rules_client, +) + +RULE_NAME = "User granted Admin privilege" + + +class rules_admin_privilege_granted_alert_configured(Check): + """Check that the User granted Admin privilege system-defined alert rule is fully configured.""" + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if rules_client.policies_fetched: + for alert in rules_client.system_defined_alerts: + if alert.display_name != RULE_NAME: + continue + + domain = rules_client.provider.identity.domain + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=alert, + resource_id=f"systemDefinedAlert/{RULE_NAME}", + resource_name=RULE_NAME, + customer_id=rules_client.provider.identity.customer_id, + ) + + is_active = alert.state == "ACTIVE" + has_recipients = alert.email_notifications_enabled + all_super_admins = alert.all_super_admins + + if is_active and has_recipients and all_super_admins: + report.status = "PASS" + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is properly " + f"configured in domain {domain}: alert is ON, email " + f"notifications are enabled, and recipients include " + f"all super administrators." + ) + else: + report.status = "FAIL" + issues = [] + if not is_active: + issues.append("alert is OFF") + if not has_recipients: + issues.append("email notifications are disabled") + elif not all_super_admins: + issues.append( + "email recipients do not include all super administrators" + ) + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is not properly " + f"configured in domain {domain}: {', '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/rules/rules_client.py b/prowler/providers/googleworkspace/services/rules/rules_client.py new file mode 100644 index 0000000000..8d1c8821a1 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.googleworkspace.services.rules.rules_service import ( + Rules, +) + +rules_client = Rules(Provider.get_global_provider()) diff --git a/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/__init__.py b/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.metadata.json new file mode 100644 index 0000000000..34885cb605 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "googleworkspace", + "CheckID": "rules_gmail_employee_spoofing_alert_configured", + "CheckTitle": "Gmail potential employee spoofing alert rule is configured", + "CheckType": [], + "ServiceName": "rules", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "monitoring", + "Description": "The **Gmail potential employee spoofing** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when incoming messages have a sender name matching the directory but from an external domain.", + "Risk": "Without this alert enabled, administrators will not be notified of potential **employee spoofing via email**. Attackers may impersonate internal employees using external email addresses to conduct phishing attacks against the organization.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/answer/3230421", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Select **Rules**\n3. Under **Google protects you by default** select **View list**\n4. Scroll to **Gmail potential employee spoofing** and select it\n5. Within the Actions pane, click the edit pencil\n6. Select **Send to alert center** to set the alert to ON\n7. Set the alert severity to **Medium**\n8. Select **Send email notifications**\n9. Ensure **All super administrators** is selected as recipients\n10. Click **Review** to confirm the values\n11. Click **Update Rule**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure the **Gmail potential employee spoofing** alert rule with alert center ON, email notifications ON, and recipients set to **all super administrators**.", + "Url": "https://hub.prowler.com/check/rules_gmail_employee_spoofing_alert_configured" + } + }, + "Categories": [ + "logging", + "email-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.py new file mode 100644 index 0000000000..0993f72d3d --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.py @@ -0,0 +1,61 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.rules_client import ( + rules_client, +) + +RULE_NAME = "Gmail potential employee spoofing" + + +class rules_gmail_employee_spoofing_alert_configured(Check): + """Check that the Gmail potential employee spoofing system-defined alert rule is fully configured.""" + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if rules_client.policies_fetched: + for alert in rules_client.system_defined_alerts: + if alert.display_name != RULE_NAME: + continue + + domain = rules_client.provider.identity.domain + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=alert, + resource_id=f"systemDefinedAlert/{RULE_NAME}", + resource_name=RULE_NAME, + customer_id=rules_client.provider.identity.customer_id, + ) + + is_active = alert.state == "ACTIVE" + has_recipients = alert.email_notifications_enabled + all_super_admins = alert.all_super_admins + + if is_active and has_recipients and all_super_admins: + report.status = "PASS" + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is properly " + f"configured in domain {domain}: alert is ON, email " + f"notifications are enabled, and recipients include " + f"all super administrators." + ) + else: + report.status = "FAIL" + issues = [] + if not is_active: + issues.append("alert is OFF") + if not has_recipients: + issues.append("email notifications are disabled") + elif not all_super_admins: + issues.append( + "email recipients do not include all super administrators" + ) + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is not properly " + f"configured in domain {domain}: {', '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/__init__.py b/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.metadata.json new file mode 100644 index 0000000000..35fa25f248 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "googleworkspace", + "CheckID": "rules_government_backed_attacks_alert_configured", + "CheckTitle": "Government-backed attacks alert rule is configured", + "CheckType": [], + "ServiceName": "rules", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "monitoring", + "Description": "The **Government-backed attacks** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when Google believes users are being targeted by a government-backed attacker.", + "Risk": "Without this alert enabled, administrators will not be notified of potential **government-backed attacks** targeting their users. These attacks are sophisticated and require immediate response to protect affected accounts and investigate the threat.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/answer/3230421", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Select **Rules**\n3. Under **Google protects you by default** select **View list**\n4. Scroll to **Government-backed attacks** and select it\n5. Within the Actions pane, click the edit pencil\n6. Select **Send to alert center** to set the alert to ON\n7. Set the alert severity to **High**\n8. Select **Send email notifications**\n9. Ensure **All super administrators** is selected as recipients\n10. Click **Review** to confirm the values\n11. Click **Update Rule**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure the **Government-backed attacks** alert rule with alert center ON, email notifications ON, and recipients set to **all super administrators**.", + "Url": "https://hub.prowler.com/check/rules_government_backed_attacks_alert_configured" + } + }, + "Categories": [ + "logging" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.py new file mode 100644 index 0000000000..b566d99e0c --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.py @@ -0,0 +1,61 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.rules_client import ( + rules_client, +) + +RULE_NAME = "Government-backed attacks" + + +class rules_government_backed_attacks_alert_configured(Check): + """Check that the Government-backed attacks system-defined alert rule is fully configured.""" + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if rules_client.policies_fetched: + for alert in rules_client.system_defined_alerts: + if alert.display_name != RULE_NAME: + continue + + domain = rules_client.provider.identity.domain + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=alert, + resource_id=f"systemDefinedAlert/{RULE_NAME}", + resource_name=RULE_NAME, + customer_id=rules_client.provider.identity.customer_id, + ) + + is_active = alert.state == "ACTIVE" + has_recipients = alert.email_notifications_enabled + all_super_admins = alert.all_super_admins + + if is_active and has_recipients and all_super_admins: + report.status = "PASS" + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is properly " + f"configured in domain {domain}: alert is ON, email " + f"notifications are enabled, and recipients include " + f"all super administrators." + ) + else: + report.status = "FAIL" + issues = [] + if not is_active: + issues.append("alert is OFF") + if not has_recipients: + issues.append("email notifications are disabled") + elif not all_super_admins: + issues.append( + "email recipients do not include all super administrators" + ) + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is not properly " + f"configured in domain {domain}: {', '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/__init__.py b/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.metadata.json new file mode 100644 index 0000000000..870144a873 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "googleworkspace", + "CheckID": "rules_leaked_password_alert_configured", + "CheckTitle": "Leaked password alert rule is configured", + "CheckType": [], + "ServiceName": "rules", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "monitoring", + "Description": "The **Leaked password** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when Google detects compromised credentials requiring a password reset.", + "Risk": "Without this alert enabled, administrators will not be notified when Google detects that a user's **credentials have been compromised** in a publicized breach. The user likely reused their password at another site that was breached, and their account requires an immediate password change.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/answer/3230421", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Select **Rules**\n3. Under **Google protects you by default** select **View list**\n4. Scroll to **Leaked password** and select it\n5. Within the Actions pane, click the edit pencil\n6. Select **Send to alert center** to set the alert to ON\n7. Set the alert severity to **Medium**\n8. Select **Send email notifications**\n9. Ensure **All super administrators** is selected as recipients\n10. Click **Review** to confirm the values\n11. Click **Update Rule**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure the **Leaked password** alert rule with alert center ON, email notifications ON, and recipients set to **all super administrators**.", + "Url": "https://hub.prowler.com/check/rules_leaked_password_alert_configured" + } + }, + "Categories": [ + "logging", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.py new file mode 100644 index 0000000000..797bed4f71 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.py @@ -0,0 +1,61 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.rules_client import ( + rules_client, +) + +RULE_NAME = "Leaked password" + + +class rules_leaked_password_alert_configured(Check): + """Check that the Leaked password system-defined alert rule is fully configured.""" + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if rules_client.policies_fetched: + for alert in rules_client.system_defined_alerts: + if alert.display_name != RULE_NAME: + continue + + domain = rules_client.provider.identity.domain + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=alert, + resource_id=f"systemDefinedAlert/{RULE_NAME}", + resource_name=RULE_NAME, + customer_id=rules_client.provider.identity.customer_id, + ) + + is_active = alert.state == "ACTIVE" + has_recipients = alert.email_notifications_enabled + all_super_admins = alert.all_super_admins + + if is_active and has_recipients and all_super_admins: + report.status = "PASS" + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is properly " + f"configured in domain {domain}: alert is ON, email " + f"notifications are enabled, and recipients include " + f"all super administrators." + ) + else: + report.status = "FAIL" + issues = [] + if not is_active: + issues.append("alert is OFF") + if not has_recipients: + issues.append("email notifications are disabled") + elif not all_super_admins: + issues.append( + "email recipients do not include all super administrators" + ) + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is not properly " + f"configured in domain {domain}: {', '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/__init__.py b/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.metadata.json new file mode 100644 index 0000000000..f1f4fbc622 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "googleworkspace", + "CheckID": "rules_password_changed_alert_configured", + "CheckTitle": "User's password changed alert rule is configured", + "CheckType": [], + "ServiceName": "rules", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "monitoring", + "Description": "The **User's password changed** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are promptly notified when user passwords are changed.", + "Risk": "Without this alert enabled, administrators will not be notified when user passwords are changed. This could allow **credential compromise and account takeover** to go undetected, giving attackers time to establish persistence.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/answer/3230421", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Select **Rules**\n3. Under **Google protects you by default** select **View list**\n4. Scroll to **User's password changed** and select it\n5. Within the Actions pane, click the edit pencil\n6. Select **Send to alert center** to set the alert to ON\n7. Set the alert severity to **Medium**\n8. Select **Send email notifications**\n9. Ensure **All super administrators** is selected as recipients\n10. Click **Review** to confirm the values\n11. Click **Update Rule**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure the **User's password changed** alert rule with alert center ON, email notifications ON, and recipients set to **all super administrators**.", + "Url": "https://hub.prowler.com/check/rules_password_changed_alert_configured" + } + }, + "Categories": [ + "logging", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.py new file mode 100644 index 0000000000..fb6382caf8 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.py @@ -0,0 +1,61 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.rules_client import ( + rules_client, +) + +RULE_NAME = "User's password changed" + + +class rules_password_changed_alert_configured(Check): + """Check that the User's password changed system-defined alert rule is fully configured.""" + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if rules_client.policies_fetched: + for alert in rules_client.system_defined_alerts: + if alert.display_name != RULE_NAME: + continue + + domain = rules_client.provider.identity.domain + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=alert, + resource_id=f"systemDefinedAlert/{RULE_NAME}", + resource_name=RULE_NAME, + customer_id=rules_client.provider.identity.customer_id, + ) + + is_active = alert.state == "ACTIVE" + has_recipients = alert.email_notifications_enabled + all_super_admins = alert.all_super_admins + + if is_active and has_recipients and all_super_admins: + report.status = "PASS" + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is properly " + f"configured in domain {domain}: alert is ON, email " + f"notifications are enabled, and recipients include " + f"all super administrators." + ) + else: + report.status = "FAIL" + issues = [] + if not is_active: + issues.append("alert is OFF") + if not has_recipients: + issues.append("email notifications are disabled") + elif not all_super_admins: + issues.append( + "email recipients do not include all super administrators" + ) + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is not properly " + f"configured in domain {domain}: {', '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/rules/rules_service.py b/prowler/providers/googleworkspace/services/rules/rules_service.py new file mode 100644 index 0000000000..76b0b0df7a --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_service.py @@ -0,0 +1,143 @@ +from typing import Dict, List, Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService + +SYSTEM_RULE_DEFAULTS: Dict[str, str] = { + "User's password changed": "INACTIVE", + "Government-backed attacks": "ACTIVE", + "User suspended due to suspicious activity": "ACTIVE", + "User granted Admin privilege": "INACTIVE", + "Suspicious programmatic login": "ACTIVE", + "Suspicious login": "ACTIVE", + "Leaked password": "ACTIVE", + "Gmail potential employee spoofing": "ACTIVE", +} + + +class Rules(GoogleWorkspaceService): + """Google Workspace Rules service for auditing system-defined alert rules. + + Uses the Cloud Identity Policy API v1 to read system-defined alert rule + configurations from the Admin Console "Rules" section. + """ + + def __init__(self, provider): + super().__init__(provider) + self.system_defined_alerts: List[SystemDefinedAlert] = [] + self.policies_fetched = False + self._fetch_system_defined_alerts() + + def _fetch_system_defined_alerts(self): + """Fetch system-defined alert rules from the Cloud Identity Policy API v1.""" + logger.info("Rules - Fetching system-defined alert rules...") + + try: + service = self._build_service("cloudidentity", "v1") + + if not service: + logger.error("Failed to build Cloud Identity service") + return + + request = service.policies().list( + pageSize=100, + filter='setting.type.matches("rule.system_defined_alerts")', + ) + fetch_succeeded = True + found_rules: Dict[str, SystemDefinedAlert] = {} + + while request is not None: + try: + response = request.execute() + + for policy in response.get("policies", []): + if not self._is_customer_level_policy(policy): + continue + + setting = policy.get("setting", {}) + value = setting.get("value", {}) + display_name = value.get("displayName", "") + + if display_name not in SYSTEM_RULE_DEFAULTS: + continue + + alert = self._parse_alert(value) + found_rules[display_name] = alert + logger.debug( + f"System-defined alert rule: {display_name} " + f"state={alert.state} " + f"has_recipients={alert.email_notifications_enabled}" + ) + + request = service.policies().list_next(request, response) + + except Exception as error: + self._handle_api_error( + error, + "fetching system-defined alert rules", + self.provider.identity.customer_id, + ) + fetch_succeeded = False + break + + for rule_name, default_state in SYSTEM_RULE_DEFAULTS.items(): + if rule_name not in found_rules: + is_active_default = default_state == "ACTIVE" + found_rules[rule_name] = SystemDefinedAlert( + display_name=rule_name, + state=default_state, + email_notifications_enabled=is_active_default, + all_super_admins=is_active_default, + ) + logger.debug( + f"System-defined alert rule (default): {rule_name} " + f"state={default_state}" + ) + + self.system_defined_alerts = list(found_rules.values()) + self.policies_fetched = fetch_succeeded + + logger.info( + f"Rules policies fetched - " + f"{len(self.system_defined_alerts)} system-defined alert rules" + ) + + except Exception as error: + self._handle_api_error( + error, + "fetching system-defined alert rules", + self.provider.identity.customer_id, + ) + self.policies_fetched = False + + @staticmethod + def _parse_alert(value: dict) -> "SystemDefinedAlert": + """Parse a single system-defined alert rule from the API response.""" + display_name = value.get("displayName", "") + state = value.get("state", "INACTIVE") + + alert_center_action = value.get("action", {}).get("alertCenterAction", {}) + severity = alert_center_action.get("alertCenterConfig", {}).get("severity") + recipients = alert_center_action.get("recipients", []) + + all_super_admins = any(r.get("allSuperAdmins") is True for r in recipients) + + return SystemDefinedAlert( + display_name=display_name, + state=state, + severity=severity, + email_notifications_enabled=len(recipients) > 0, + all_super_admins=all_super_admins, + ) + + +class SystemDefinedAlert(BaseModel): + """Model for a system-defined alert rule.""" + + display_name: str + state: str = "INACTIVE" + severity: Optional[str] = None + email_notifications_enabled: bool = False + all_super_admins: bool = False diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/__init__.py b/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.metadata.json new file mode 100644 index 0000000000..b79120abde --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "googleworkspace", + "CheckID": "rules_suspicious_activity_suspension_alert_configured", + "CheckTitle": "User suspended due to suspicious activity alert rule is configured", + "CheckType": [], + "ServiceName": "rules", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "monitoring", + "Description": "The **User suspended due to suspicious activity** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when Google suspends an account due to a potential compromise.", + "Risk": "Without this alert enabled, administrators will not be promptly notified when Google **suspends a user account** due to detected compromise. The suspended user cannot work, and the underlying security incident requires immediate investigation.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/answer/3230421", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Select **Rules**\n3. Under **Google protects you by default** select **View list**\n4. Scroll to **User suspended due to suspicious activity** and select it\n5. Within the Actions pane, click the edit pencil\n6. Select **Send to alert center** to set the alert to ON\n7. Set the alert severity to **High**\n8. Select **Send email notifications**\n9. Ensure **All super administrators** is selected as recipients\n10. Click **Review** to confirm the values\n11. Click **Update Rule**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure the **User suspended due to suspicious activity** alert rule with alert center ON, email notifications ON, and recipients set to **all super administrators**.", + "Url": "https://hub.prowler.com/check/rules_suspicious_activity_suspension_alert_configured" + } + }, + "Categories": [ + "logging", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.py new file mode 100644 index 0000000000..cd243be8e3 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.py @@ -0,0 +1,61 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.rules_client import ( + rules_client, +) + +RULE_NAME = "User suspended due to suspicious activity" + + +class rules_suspicious_activity_suspension_alert_configured(Check): + """Check that the User suspended due to suspicious activity system-defined alert rule is fully configured.""" + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if rules_client.policies_fetched: + for alert in rules_client.system_defined_alerts: + if alert.display_name != RULE_NAME: + continue + + domain = rules_client.provider.identity.domain + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=alert, + resource_id=f"systemDefinedAlert/{RULE_NAME}", + resource_name=RULE_NAME, + customer_id=rules_client.provider.identity.customer_id, + ) + + is_active = alert.state == "ACTIVE" + has_recipients = alert.email_notifications_enabled + all_super_admins = alert.all_super_admins + + if is_active and has_recipients and all_super_admins: + report.status = "PASS" + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is properly " + f"configured in domain {domain}: alert is ON, email " + f"notifications are enabled, and recipients include " + f"all super administrators." + ) + else: + report.status = "FAIL" + issues = [] + if not is_active: + issues.append("alert is OFF") + if not has_recipients: + issues.append("email notifications are disabled") + elif not all_super_admins: + issues.append( + "email recipients do not include all super administrators" + ) + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is not properly " + f"configured in domain {domain}: {', '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/__init__.py b/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.metadata.json new file mode 100644 index 0000000000..93af99b565 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "googleworkspace", + "CheckID": "rules_suspicious_login_alert_configured", + "CheckTitle": "Suspicious login alert rule is configured", + "CheckType": [], + "ServiceName": "rules", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "monitoring", + "Description": "The **Suspicious login** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when Google detects a sign-in attempt that does not match a user's normal behavior.", + "Risk": "Without this alert enabled, administrators will not be notified of **suspicious login attempts** such as sign-ins from unusual locations. This could indicate an active attack using previously obtained credentials.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/answer/3230421", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Select **Rules**\n3. Under **Google protects you by default** select **View list**\n4. Scroll to **Suspicious login** and select it\n5. Within the Actions pane, click the edit pencil\n6. Select **Send to alert center** to set the alert to ON\n7. Set the alert severity to **Low**\n8. Select **Send email notifications**\n9. Ensure **All super administrators** is selected as recipients\n10. Click **Review** to confirm the values\n11. Click **Update Rule**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure the **Suspicious login** alert rule with alert center ON, email notifications ON, and recipients set to **all super administrators**.", + "Url": "https://hub.prowler.com/check/rules_suspicious_login_alert_configured" + } + }, + "Categories": [ + "logging", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.py new file mode 100644 index 0000000000..eee7844c43 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.py @@ -0,0 +1,61 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.rules_client import ( + rules_client, +) + +RULE_NAME = "Suspicious login" + + +class rules_suspicious_login_alert_configured(Check): + """Check that the Suspicious login system-defined alert rule is fully configured.""" + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if rules_client.policies_fetched: + for alert in rules_client.system_defined_alerts: + if alert.display_name != RULE_NAME: + continue + + domain = rules_client.provider.identity.domain + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=alert, + resource_id=f"systemDefinedAlert/{RULE_NAME}", + resource_name=RULE_NAME, + customer_id=rules_client.provider.identity.customer_id, + ) + + is_active = alert.state == "ACTIVE" + has_recipients = alert.email_notifications_enabled + all_super_admins = alert.all_super_admins + + if is_active and has_recipients and all_super_admins: + report.status = "PASS" + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is properly " + f"configured in domain {domain}: alert is ON, email " + f"notifications are enabled, and recipients include " + f"all super administrators." + ) + else: + report.status = "FAIL" + issues = [] + if not is_active: + issues.append("alert is OFF") + if not has_recipients: + issues.append("email notifications are disabled") + elif not all_super_admins: + issues.append( + "email recipients do not include all super administrators" + ) + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is not properly " + f"configured in domain {domain}: {', '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/__init__.py b/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.metadata.json new file mode 100644 index 0000000000..202df2adad --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "googleworkspace", + "CheckID": "rules_suspicious_programmatic_login_alert_configured", + "CheckTitle": "Suspicious programmatic login alert rule is configured", + "CheckType": [], + "ServiceName": "rules", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "monitoring", + "Description": "The **Suspicious programmatic login** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when Google detects suspicious login attempts from applications or programs.", + "Risk": "Without this alert enabled, administrators will not be notified of **suspicious programmatic login attempts**. This could indicate automated credential stuffing or unauthorized API access using compromised credentials.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.google.com/a/answer/3230421", + "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Select **Rules**\n3. Under **Google protects you by default** select **View list**\n4. Scroll to **Suspicious programmatic login** and select it\n5. Within the Actions pane, click the edit pencil\n6. Select **Send to alert center** to set the alert to ON\n7. Set the alert severity to **Low**\n8. Select **Send email notifications**\n9. Ensure **All super administrators** is selected as recipients\n10. Click **Review** to confirm the values\n11. Click **Update Rule**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure the **Suspicious programmatic login** alert rule with alert center ON, email notifications ON, and recipients set to **all super administrators**.", + "Url": "https://hub.prowler.com/check/rules_suspicious_programmatic_login_alert_configured" + } + }, + "Categories": [ + "logging", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.py new file mode 100644 index 0000000000..0d609a99e1 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.py @@ -0,0 +1,61 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.rules_client import ( + rules_client, +) + +RULE_NAME = "Suspicious programmatic login" + + +class rules_suspicious_programmatic_login_alert_configured(Check): + """Check that the Suspicious programmatic login system-defined alert rule is fully configured.""" + + def execute(self) -> List[CheckReportGoogleWorkspace]: + findings = [] + + if rules_client.policies_fetched: + for alert in rules_client.system_defined_alerts: + if alert.display_name != RULE_NAME: + continue + + domain = rules_client.provider.identity.domain + report = CheckReportGoogleWorkspace( + metadata=self.metadata(), + resource=alert, + resource_id=f"systemDefinedAlert/{RULE_NAME}", + resource_name=RULE_NAME, + customer_id=rules_client.provider.identity.customer_id, + ) + + is_active = alert.state == "ACTIVE" + has_recipients = alert.email_notifications_enabled + all_super_admins = alert.all_super_admins + + if is_active and has_recipients and all_super_admins: + report.status = "PASS" + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is properly " + f"configured in domain {domain}: alert is ON, email " + f"notifications are enabled, and recipients include " + f"all super administrators." + ) + else: + report.status = "FAIL" + issues = [] + if not is_active: + issues.append("alert is OFF") + if not has_recipients: + issues.append("email notifications are disabled") + elif not all_super_admins: + issues.append( + "email recipients do not include all super administrators" + ) + report.status_extended = ( + f"System-defined alert rule '{RULE_NAME}' is not properly " + f"configured in domain {domain}: {', '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/tests/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured_test.py new file mode 100644 index 0000000000..cf1a6128c4 --- /dev/null +++ b/tests/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured_test.py @@ -0,0 +1,183 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.rules.rules_service import ( + SystemDefinedAlert, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + +RULE_NAME = "User granted Admin privilege" + + +class TestRulesAdminPrivilegeGrantedAlertConfigured: + def test_pass_fully_configured(self): + """Test PASS when alert is ON, email notifications ON, recipients = all super admins.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured import ( + rules_admin_privilege_granted_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_admin_privilege_granted_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_alert_off(self): + """Test FAIL when alert is OFF (INACTIVE state).""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured import ( + rules_admin_privilege_granted_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="INACTIVE", + ) + ] + + check = rules_admin_privilege_granted_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "alert is OFF" in findings[0].status_extended + + def test_fail_no_email_notifications(self): + """Test FAIL when alert is ON but email notifications are disabled.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured import ( + rules_admin_privilege_granted_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=False, + all_super_admins=False, + ) + ] + + check = rules_admin_privilege_granted_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "email notifications are disabled" in findings[0].status_extended + + def test_fail_recipients_not_all_super_admins(self): + """Test FAIL when email notifications ON but recipients do not include all super admins.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured import ( + rules_admin_privilege_granted_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=False, + ) + ] + + check = rules_admin_privilege_granted_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert ( + "do not include all super administrators" in findings[0].status_extended + ) + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured import ( + rules_admin_privilege_granted_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = False + mock_rules_client.system_defined_alerts = [] + + check = rules_admin_privilege_granted_alert_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured_test.py new file mode 100644 index 0000000000..f907b2f89e --- /dev/null +++ b/tests/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured_test.py @@ -0,0 +1,183 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.rules.rules_service import ( + SystemDefinedAlert, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + +RULE_NAME = "Gmail potential employee spoofing" + + +class TestRulesGmailEmployeeSpoofingAlertConfigured: + def test_pass_fully_configured(self): + """Test PASS when alert is ON, email notifications ON, recipients = all super admins.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured import ( + rules_gmail_employee_spoofing_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_gmail_employee_spoofing_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_alert_off(self): + """Test FAIL when alert is OFF (INACTIVE state).""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured import ( + rules_gmail_employee_spoofing_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="INACTIVE", + ) + ] + + check = rules_gmail_employee_spoofing_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "alert is OFF" in findings[0].status_extended + + def test_fail_no_email_notifications(self): + """Test FAIL when alert is ON but email notifications are disabled.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured import ( + rules_gmail_employee_spoofing_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=False, + all_super_admins=False, + ) + ] + + check = rules_gmail_employee_spoofing_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "email notifications are disabled" in findings[0].status_extended + + def test_fail_recipients_not_all_super_admins(self): + """Test FAIL when email notifications ON but recipients do not include all super admins.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured import ( + rules_gmail_employee_spoofing_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=False, + ) + ] + + check = rules_gmail_employee_spoofing_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert ( + "do not include all super administrators" in findings[0].status_extended + ) + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured import ( + rules_gmail_employee_spoofing_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = False + mock_rules_client.system_defined_alerts = [] + + check = rules_gmail_employee_spoofing_alert_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured_test.py new file mode 100644 index 0000000000..90ec845258 --- /dev/null +++ b/tests/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured_test.py @@ -0,0 +1,183 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.rules.rules_service import ( + SystemDefinedAlert, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + +RULE_NAME = "Government-backed attacks" + + +class TestRulesGovernmentBackedAttacksAlertConfigured: + def test_pass_fully_configured(self): + """Test PASS when alert is ON, email notifications ON, recipients = all super admins.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured import ( + rules_government_backed_attacks_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_government_backed_attacks_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_alert_off(self): + """Test FAIL when alert is OFF (INACTIVE state).""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured import ( + rules_government_backed_attacks_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="INACTIVE", + ) + ] + + check = rules_government_backed_attacks_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "alert is OFF" in findings[0].status_extended + + def test_fail_no_email_notifications(self): + """Test FAIL when alert is ON but email notifications are disabled.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured import ( + rules_government_backed_attacks_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=False, + all_super_admins=False, + ) + ] + + check = rules_government_backed_attacks_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "email notifications are disabled" in findings[0].status_extended + + def test_fail_recipients_not_all_super_admins(self): + """Test FAIL when email notifications ON but recipients do not include all super admins.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured import ( + rules_government_backed_attacks_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=False, + ) + ] + + check = rules_government_backed_attacks_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert ( + "do not include all super administrators" in findings[0].status_extended + ) + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured import ( + rules_government_backed_attacks_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = False + mock_rules_client.system_defined_alerts = [] + + check = rules_government_backed_attacks_alert_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured_test.py new file mode 100644 index 0000000000..ace6c29d13 --- /dev/null +++ b/tests/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured_test.py @@ -0,0 +1,183 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.rules.rules_service import ( + SystemDefinedAlert, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + +RULE_NAME = "Leaked password" + + +class TestRulesLeakedPasswordAlertConfigured: + def test_pass_fully_configured(self): + """Test PASS when alert is ON, email notifications ON, recipients = all super admins.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured import ( + rules_leaked_password_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_leaked_password_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_alert_off(self): + """Test FAIL when alert is OFF (INACTIVE state).""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured import ( + rules_leaked_password_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="INACTIVE", + ) + ] + + check = rules_leaked_password_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "alert is OFF" in findings[0].status_extended + + def test_fail_no_email_notifications(self): + """Test FAIL when alert is ON but email notifications are disabled.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured import ( + rules_leaked_password_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=False, + all_super_admins=False, + ) + ] + + check = rules_leaked_password_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "email notifications are disabled" in findings[0].status_extended + + def test_fail_recipients_not_all_super_admins(self): + """Test FAIL when email notifications ON but recipients do not include all super admins.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured import ( + rules_leaked_password_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=False, + ) + ] + + check = rules_leaked_password_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert ( + "do not include all super administrators" in findings[0].status_extended + ) + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured import ( + rules_leaked_password_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = False + mock_rules_client.system_defined_alerts = [] + + check = rules_leaked_password_alert_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured_test.py new file mode 100644 index 0000000000..db1b8056e7 --- /dev/null +++ b/tests/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured_test.py @@ -0,0 +1,183 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.rules.rules_service import ( + SystemDefinedAlert, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + +RULE_NAME = "User's password changed" + + +class TestRulesPasswordChangedAlertConfigured: + def test_pass_fully_configured(self): + """Test PASS when alert is ON, email notifications ON, recipients = all super admins.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured import ( + rules_password_changed_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_password_changed_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_alert_off(self): + """Test FAIL when alert is OFF (INACTIVE state).""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured import ( + rules_password_changed_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="INACTIVE", + ) + ] + + check = rules_password_changed_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "alert is OFF" in findings[0].status_extended + + def test_fail_no_email_notifications(self): + """Test FAIL when alert is ON but email notifications are disabled.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured import ( + rules_password_changed_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=False, + all_super_admins=False, + ) + ] + + check = rules_password_changed_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "email notifications are disabled" in findings[0].status_extended + + def test_fail_recipients_not_all_super_admins(self): + """Test FAIL when email notifications ON but recipients do not include all super admins.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured import ( + rules_password_changed_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=False, + ) + ] + + check = rules_password_changed_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert ( + "do not include all super administrators" in findings[0].status_extended + ) + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured import ( + rules_password_changed_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = False + mock_rules_client.system_defined_alerts = [] + + check = rules_password_changed_alert_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/rules/rules_service_test.py b/tests/providers/googleworkspace/services/rules/rules_service_test.py new file mode 100644 index 0000000000..6368df4bcb --- /dev/null +++ b/tests/providers/googleworkspace/services/rules/rules_service_test.py @@ -0,0 +1,323 @@ +from unittest.mock import MagicMock, patch + +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + set_mocked_googleworkspace_provider, +) + + +class TestRulesService: + def test_fetch_fully_configured_rule(self): + """Test fetching a system-defined alert rule with all 3 conditions met.""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = { + "policies": [ + { + "setting": { + "type": "settings/rule.system_defined_alerts", + "value": { + "displayName": "Suspicious login", + "description": "Google detected a suspicious sign-in.", + "action": { + "alertCenterAction": { + "recipients": [{"allSuperAdmins": True}], + "alertCenterConfig": {"severity": "LOW"}, + } + }, + "state": "ACTIVE", + }, + } + }, + ] + } + mock_service.policies().list.return_value = mock_policies_list + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.rules.rules_service import ( + Rules, + ) + + rules = Rules(mock_provider) + + assert rules.policies_fetched is True + assert len(rules.system_defined_alerts) == 8 + + suspicious_login = next( + a + for a in rules.system_defined_alerts + if a.display_name == "Suspicious login" + ) + assert suspicious_login.state == "ACTIVE" + assert suspicious_login.email_notifications_enabled is True + assert suspicious_login.all_super_admins is True + assert suspicious_login.severity == "LOW" + + def test_fetch_rule_without_email_notifications(self): + """Test a rule that is ACTIVE but has no email recipients configured.""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = { + "policies": [ + { + "setting": { + "type": "settings/rule.system_defined_alerts", + "value": { + "displayName": "Government-backed attacks", + "description": "Google believes a user is targeted.", + "action": { + "alertCenterAction": { + "alertCenterConfig": {"severity": "HIGH"} + } + }, + "state": "ACTIVE", + }, + } + }, + ] + } + mock_service.policies().list.return_value = mock_policies_list + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.rules.rules_service import ( + Rules, + ) + + rules = Rules(mock_provider) + + gov_attack = next( + a + for a in rules.system_defined_alerts + if a.display_name == "Government-backed attacks" + ) + assert gov_attack.state == "ACTIVE" + assert gov_attack.email_notifications_enabled is False + assert gov_attack.all_super_admins is False + assert gov_attack.severity == "HIGH" + + def test_empty_response_fills_defaults(self): + """Test that all 8 rules get default values when API returns nothing.""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = {"policies": []} + mock_service.policies().list.return_value = mock_policies_list + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.rules.rules_service import ( + Rules, + ) + + rules = Rules(mock_provider) + + assert rules.policies_fetched is True + assert len(rules.system_defined_alerts) == 8 + + # All defaults should have no severity (not returned by API) + for alert in rules.system_defined_alerts: + assert alert.severity is None + + # INACTIVE defaults: no email notifications + password_changed = next( + a + for a in rules.system_defined_alerts + if a.display_name == "User's password changed" + ) + assert password_changed.state == "INACTIVE" + assert password_changed.email_notifications_enabled is False + assert password_changed.all_super_admins is False + + admin_privilege = next( + a + for a in rules.system_defined_alerts + if a.display_name == "User granted Admin privilege" + ) + assert admin_privilege.state == "INACTIVE" + assert admin_privilege.email_notifications_enabled is False + assert admin_privilege.all_super_admins is False + + # ACTIVE defaults: email notifications ON with all super admins + suspicious_login = next( + a + for a in rules.system_defined_alerts + if a.display_name == "Suspicious login" + ) + assert suspicious_login.state == "ACTIVE" + assert suspicious_login.email_notifications_enabled is True + assert suspicious_login.all_super_admins is True + + gov_attacks = next( + a + for a in rules.system_defined_alerts + if a.display_name == "Government-backed attacks" + ) + assert gov_attacks.state == "ACTIVE" + assert gov_attacks.email_notifications_enabled is True + assert gov_attacks.all_super_admins is True + + def test_api_error_sets_policies_fetched_false(self): + """Test that API errors result in policies_fetched being False.""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_service.policies().list.side_effect = Exception("API Error") + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.rules.rules_service import ( + Rules, + ) + + rules = Rules(mock_provider) + + assert rules.policies_fetched is False + + def test_build_service_returns_none(self): + """Test early return when _build_service fails.""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_service.GoogleWorkspaceService._build_service", + return_value=None, + ), + ): + from prowler.providers.googleworkspace.services.rules.rules_service import ( + Rules, + ) + + rules = Rules(mock_provider) + + assert rules.policies_fetched is False + assert len(rules.system_defined_alerts) == 0 + + def test_non_cis_rules_are_ignored(self): + """Test that system-defined rules not in the 8 CIS rules are ignored.""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = { + "policies": [ + { + "setting": { + "type": "settings/rule.system_defined_alerts", + "value": { + "displayName": "Device compromised", + "description": "A device has been compromised.", + "state": "ACTIVE", + }, + } + }, + ] + } + mock_service.policies().list.return_value = mock_policies_list + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.rules.rules_service import ( + Rules, + ) + + rules = Rules(mock_provider) + + assert len(rules.system_defined_alerts) == 8 + names = {a.display_name for a in rules.system_defined_alerts} + assert "Device compromised" not in names + + def test_system_defined_alert_model(self): + """Test SystemDefinedAlert Pydantic model defaults.""" + from prowler.providers.googleworkspace.services.rules.rules_service import ( + SystemDefinedAlert, + ) + + alert = SystemDefinedAlert(display_name="Test rule") + assert alert.state == "INACTIVE" + assert alert.severity is None + assert alert.email_notifications_enabled is False + assert alert.all_super_admins is False diff --git a/tests/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured_test.py new file mode 100644 index 0000000000..acd60756f0 --- /dev/null +++ b/tests/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured_test.py @@ -0,0 +1,183 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.rules.rules_service import ( + SystemDefinedAlert, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + +RULE_NAME = "User suspended due to suspicious activity" + + +class TestRulesSuspiciousActivitySuspensionAlertConfigured: + def test_pass_fully_configured(self): + """Test PASS when alert is ON, email notifications ON, recipients = all super admins.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured import ( + rules_suspicious_activity_suspension_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_suspicious_activity_suspension_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_alert_off(self): + """Test FAIL when alert is OFF (INACTIVE state).""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured import ( + rules_suspicious_activity_suspension_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="INACTIVE", + ) + ] + + check = rules_suspicious_activity_suspension_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "alert is OFF" in findings[0].status_extended + + def test_fail_no_email_notifications(self): + """Test FAIL when alert is ON but email notifications are disabled.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured import ( + rules_suspicious_activity_suspension_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=False, + all_super_admins=False, + ) + ] + + check = rules_suspicious_activity_suspension_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "email notifications are disabled" in findings[0].status_extended + + def test_fail_recipients_not_all_super_admins(self): + """Test FAIL when email notifications ON but recipients do not include all super admins.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured import ( + rules_suspicious_activity_suspension_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=False, + ) + ] + + check = rules_suspicious_activity_suspension_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert ( + "do not include all super administrators" in findings[0].status_extended + ) + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured import ( + rules_suspicious_activity_suspension_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = False + mock_rules_client.system_defined_alerts = [] + + check = rules_suspicious_activity_suspension_alert_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured_test.py new file mode 100644 index 0000000000..aab70797dc --- /dev/null +++ b/tests/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured_test.py @@ -0,0 +1,183 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.rules.rules_service import ( + SystemDefinedAlert, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + +RULE_NAME = "Suspicious login" + + +class TestRulesSuspiciousLoginAlertConfigured: + def test_pass_fully_configured(self): + """Test PASS when alert is ON, email notifications ON, recipients = all super admins.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured import ( + rules_suspicious_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_suspicious_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_alert_off(self): + """Test FAIL when alert is OFF (INACTIVE state).""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured import ( + rules_suspicious_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="INACTIVE", + ) + ] + + check = rules_suspicious_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "alert is OFF" in findings[0].status_extended + + def test_fail_no_email_notifications(self): + """Test FAIL when alert is ON but email notifications are disabled.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured import ( + rules_suspicious_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=False, + all_super_admins=False, + ) + ] + + check = rules_suspicious_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "email notifications are disabled" in findings[0].status_extended + + def test_fail_recipients_not_all_super_admins(self): + """Test FAIL when email notifications ON but recipients do not include all super admins.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured import ( + rules_suspicious_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=False, + ) + ] + + check = rules_suspicious_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert ( + "do not include all super administrators" in findings[0].status_extended + ) + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured import ( + rules_suspicious_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = False + mock_rules_client.system_defined_alerts = [] + + check = rules_suspicious_login_alert_configured() + findings = check.execute() + + assert len(findings) == 0 diff --git a/tests/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured_test.py new file mode 100644 index 0000000000..f1596ace67 --- /dev/null +++ b/tests/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured_test.py @@ -0,0 +1,183 @@ +from unittest.mock import patch + +from prowler.providers.googleworkspace.services.rules.rules_service import ( + SystemDefinedAlert, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + CUSTOMER_ID, + set_mocked_googleworkspace_provider, +) + +RULE_NAME = "Suspicious programmatic login" + + +class TestRulesSuspiciousProgrammaticLoginAlertConfigured: + def test_pass_fully_configured(self): + """Test PASS when alert is ON, email notifications ON, recipients = all super admins.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured import ( + rules_suspicious_programmatic_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_suspicious_programmatic_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_alert_off(self): + """Test FAIL when alert is OFF (INACTIVE state).""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured import ( + rules_suspicious_programmatic_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="INACTIVE", + ) + ] + + check = rules_suspicious_programmatic_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "alert is OFF" in findings[0].status_extended + + def test_fail_no_email_notifications(self): + """Test FAIL when alert is ON but email notifications are disabled.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured import ( + rules_suspicious_programmatic_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=False, + all_super_admins=False, + ) + ] + + check = rules_suspicious_programmatic_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "email notifications are disabled" in findings[0].status_extended + + def test_fail_recipients_not_all_super_admins(self): + """Test FAIL when email notifications ON but recipients do not include all super admins.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured import ( + rules_suspicious_programmatic_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=False, + ) + ] + + check = rules_suspicious_programmatic_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert ( + "do not include all super administrators" in findings[0].status_extended + ) + + def test_no_findings_when_fetch_failed(self): + """Test no findings returned when the API fetch failed.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured import ( + rules_suspicious_programmatic_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = False + mock_rules_client.system_defined_alerts = [] + + check = rules_suspicious_programmatic_login_alert_configured() + findings = check.execute() + + assert len(findings) == 0 From edbbd8682855651b254ade3dc10e8d347e25b255 Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Thu, 28 May 2026 11:52:45 +0200 Subject: [PATCH 05/14] fix(openstack): move exception codes off the Alibaba Cloud range (#11382) --- prowler/CHANGELOG.md | 1 + .../openstack/exceptions/exceptions.py | 50 +++++++++---------- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index f1e1008826..dfc2e9ca2e 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - ENS RD 311/2022 (AWS) compliance mapping: `vpc_different_regions` was uncorrectly mapped under the `mp.com.4` family (Network segregation). That check is now mapped to a new `op.cont.2.aws.vpc.1` requirement under the Continuity of Service control [(#11372)](https://github.com/prowler-cloud/prowler/pull/11372) - Compliance CSV row count now matches the UI per requirement by sourcing rows from the framework JSON's `requirement.Checks` instead of the stale `finding.compliance` snapshot [(#11370)](https://github.com/prowler-cloud/prowler/pull/11370) +- OpenStack provider exception codes moved from the `10000-10999` range, shared with the AlibabaCloud provider, to the free `17000-17999` range to keep error codes unambiguous [(#11382)](https://github.com/prowler-cloud/prowler/pull/11382) --- diff --git a/prowler/providers/openstack/exceptions/exceptions.py b/prowler/providers/openstack/exceptions/exceptions.py index c78e35c475..f5b7dc9a7d 100644 --- a/prowler/providers/openstack/exceptions/exceptions.py +++ b/prowler/providers/openstack/exceptions/exceptions.py @@ -1,56 +1,56 @@ from prowler.exceptions.exceptions import ProwlerException -# Exceptions codes from 10000 to 10999 are reserved for OpenStack exceptions +# Exceptions codes from 17000 to 17999 are reserved for OpenStack exceptions class OpenStackBaseException(ProwlerException): """Base class for OpenStack Errors.""" OPENSTACK_ERROR_CODES = { - (10000, "OpenStackCredentialsError"): { + (17000, "OpenStackCredentialsError"): { "message": "OpenStack credentials not found or invalid", "remediation": "Check the OpenStack API credentials and ensure they are properly set.", }, - (10001, "OpenStackAuthenticationError"): { + (17001, "OpenStackAuthenticationError"): { "message": "OpenStack authentication failed", "remediation": "Check the OpenStack API credentials and ensure they are valid.", }, - (10002, "OpenStackSessionError"): { + (17002, "OpenStackSessionError"): { "message": "OpenStack session setup failed", "remediation": "Check the session setup and ensure it is properly configured.", }, - (10003, "OpenStackIdentityError"): { + (17003, "OpenStackIdentityError"): { "message": "OpenStack identity setup failed", "remediation": "Check credentials and ensure they are properly set up for OpenStack.", }, - (10004, "OpenStackAPIError"): { + (17004, "OpenStackAPIError"): { "message": "OpenStack API call failed", "remediation": "Check the API request and ensure it is properly formatted.", }, - (10005, "OpenStackRateLimitError"): { + (17005, "OpenStackRateLimitError"): { "message": "OpenStack API rate limit exceeded", "remediation": "Reduce the number of API requests or wait before making more requests.", }, - (10006, "OpenStackConfigFileNotFoundError"): { + (17006, "OpenStackConfigFileNotFoundError"): { "message": "OpenStack clouds.yaml configuration file not found", "remediation": "Check that the clouds.yaml file exists at the specified path or in standard locations (~/.config/openstack/clouds.yaml, /etc/openstack/clouds.yaml, ./clouds.yaml).", }, - (10007, "OpenStackCloudNotFoundError"): { + (17007, "OpenStackCloudNotFoundError"): { "message": "Specified cloud not found in clouds.yaml configuration", "remediation": "Check that the cloud name exists in your clouds.yaml file and is properly configured.", }, - (10008, "OpenStackInvalidConfigError"): { + (17008, "OpenStackInvalidConfigError"): { "message": "Invalid or malformed clouds.yaml configuration file", "remediation": "Check that the clouds.yaml file is valid YAML and follows the OpenStack configuration format.", }, - (10009, "OpenStackInvalidProviderIdError"): { + (17009, "OpenStackInvalidProviderIdError"): { "message": "Provider ID does not match the project_id in clouds.yaml", "remediation": "Ensure the provider_id matches the project_id configured in your clouds.yaml file.", }, - (10010, "OpenStackNoRegionError"): { + (17010, "OpenStackNoRegionError"): { "message": "No region configuration found in clouds.yaml", "remediation": "Add either 'region_name' (single region) or 'regions' (list of regions) to your cloud configuration in clouds.yaml.", }, - (10011, "OpenStackAmbiguousRegionError"): { + (17011, "OpenStackAmbiguousRegionError"): { "message": "Ambiguous region configuration in clouds.yaml", "remediation": "Use either 'region_name' or 'regions' in your cloud configuration, not both.", }, @@ -75,7 +75,7 @@ class OpenStackCredentialsError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10000, + code=17000, file=file, original_exception=original_exception, message=message, @@ -87,7 +87,7 @@ class OpenStackAuthenticationError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10001, + code=17001, file=file, original_exception=original_exception, message=message, @@ -99,7 +99,7 @@ class OpenStackSessionError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10002, + code=17002, file=file, original_exception=original_exception, message=message, @@ -111,7 +111,7 @@ class OpenStackIdentityError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10003, + code=17003, file=file, original_exception=original_exception, message=message, @@ -123,7 +123,7 @@ class OpenStackAPIError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10004, + code=17004, file=file, original_exception=original_exception, message=message, @@ -135,7 +135,7 @@ class OpenStackRateLimitError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10005, + code=17005, file=file, original_exception=original_exception, message=message, @@ -147,7 +147,7 @@ class OpenStackConfigFileNotFoundError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10006, + code=17006, file=file, original_exception=original_exception, message=message, @@ -159,7 +159,7 @@ class OpenStackCloudNotFoundError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10007, + code=17007, file=file, original_exception=original_exception, message=message, @@ -171,7 +171,7 @@ class OpenStackInvalidConfigError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10008, + code=17008, file=file, original_exception=original_exception, message=message, @@ -183,7 +183,7 @@ class OpenStackInvalidProviderIdError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10009, + code=17009, file=file, original_exception=original_exception, message=message, @@ -195,7 +195,7 @@ class OpenStackNoRegionError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10010, + code=17010, file=file, original_exception=original_exception, message=message, @@ -207,7 +207,7 @@ class OpenStackAmbiguousRegionError(OpenStackBaseException): def __init__(self, file=None, original_exception=None, message=None): super().__init__( - code=10011, + code=17011, file=file, original_exception=original_exception, message=message, From a2824f7166818e12672ef961f0bf403520c63164 Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Thu, 28 May 2026 13:16:38 +0200 Subject: [PATCH 06/14] feat(stackit): add new provider with 4 checks (#9237) Co-authored-by: Claude Co-authored-by: Sergio Garcia Co-authored-by: Hugo P.Brito Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> --- .github/workflows/sdk-tests.yml | 48 ++ README.md | 1 + docs/developer-guide/stackit-details.mdx | 730 ++++++++++++++++++ docs/docs.json | 10 +- docs/introduction.mdx | 1 + .../providers/stackit/authentication.mdx | 100 +++ .../stackit/getting-started-stackit.mdx | 141 ++++ prowler/CHANGELOG.md | 1 + prowler/__main__.py | 5 + prowler/compliance/stackit/__init__.py | 0 prowler/config/config.py | 1 + prowler/config/stackit_mutelist_example.yaml | 26 + prowler/lib/check/models.py | 25 + prowler/lib/cli/parser.py | 5 +- prowler/lib/outputs/finding.py | 16 + prowler/lib/outputs/html/html.py | 67 ++ prowler/lib/outputs/outputs.py | 2 + prowler/lib/outputs/summary_table.py | 7 + prowler/providers/common/provider.py | 19 + prowler/providers/stackit/__init__.py | 0 .../providers/stackit/exceptions/__init__.py | 0 .../stackit/exceptions/exceptions.py | 99 +++ prowler/providers/stackit/lib/__init__.py | 0 .../stackit/lib/arguments/__init__.py | 0 .../stackit/lib/arguments/arguments.py | 60 ++ .../stackit/lib/mutelist/__init__.py | 0 .../stackit/lib/mutelist/mutelist.py | 23 + .../providers/stackit/lib/service/__init__.py | 0 prowler/providers/stackit/models.py | 48 ++ .../providers/stackit/services/__init__.py | 0 .../stackit/services/iaas/__init__.py | 0 .../stackit/services/iaas/iaas_client.py | 4 + .../__init__.py | 0 ...oup_all_traffic_unrestricted.metadata.json | 37 + ...security_group_all_traffic_unrestricted.py | 67 ++ .../__init__.py | 0 ..._group_database_unrestricted.metadata.json | 37 + ...as_security_group_database_unrestricted.py | 74 ++ .../__init__.py | 0 ...urity_group_rdp_unrestricted.metadata.json | 37 + .../iaas_security_group_rdp_unrestricted.py | 51 ++ .../__init__.py | 0 ...urity_group_ssh_unrestricted.metadata.json | 37 + .../iaas_security_group_ssh_unrestricted.py | 51 ++ .../stackit/services/iaas/iaas_service.py | 477 ++++++++++++ prowler/providers/stackit/stackit_provider.py | 617 +++++++++++++++ .../stackit/stackit_regions_by_service.json | 10 + pyproject.toml | 3 + tests/config/config_test.py | 7 + tests/lib/cli/parser_test.py | 3 +- tests/lib/cli/redact_test.py | 2 + tests/lib/outputs/finding_test.py | 103 +++ tests/lib/outputs/html/html_test.py | 37 + tests/lib/outputs/outputs_test.py | 40 + tests/lib/outputs/summary_table_test.py | 62 ++ ...device_required_for_authentication_test.py | 12 +- .../lib/arguments/stackit_arguments_test.py | 68 ++ .../lib/mutelist/stackit_mutelist_test.py | 33 + ...ity_group_all_traffic_unrestricted_test.py | 504 ++++++++++++ ...curity_group_database_unrestricted_test.py | 503 ++++++++++++ ...as_security_group_rdp_unrestricted_test.py | 389 ++++++++++ ...as_security_group_ssh_unrestricted_test.py | 589 ++++++++++++++ .../services/iaas/iaas_service_test.py | 515 ++++++++++++ .../stackit/stackit_exceptions_test.py | 29 + tests/providers/stackit/stackit_fixtures.py | 54 ++ .../stackit/stackit_metadata_test.py | 15 + .../stackit/stackit_provider_test.py | 413 ++++++++++ uv.lock | 52 ++ 68 files changed, 6360 insertions(+), 7 deletions(-) create mode 100644 docs/developer-guide/stackit-details.mdx create mode 100644 docs/user-guide/providers/stackit/authentication.mdx create mode 100644 docs/user-guide/providers/stackit/getting-started-stackit.mdx create mode 100644 prowler/compliance/stackit/__init__.py create mode 100644 prowler/config/stackit_mutelist_example.yaml create mode 100644 prowler/providers/stackit/__init__.py create mode 100644 prowler/providers/stackit/exceptions/__init__.py create mode 100644 prowler/providers/stackit/exceptions/exceptions.py create mode 100644 prowler/providers/stackit/lib/__init__.py create mode 100644 prowler/providers/stackit/lib/arguments/__init__.py create mode 100644 prowler/providers/stackit/lib/arguments/arguments.py create mode 100644 prowler/providers/stackit/lib/mutelist/__init__.py create mode 100644 prowler/providers/stackit/lib/mutelist/mutelist.py create mode 100644 prowler/providers/stackit/lib/service/__init__.py create mode 100644 prowler/providers/stackit/models.py create mode 100644 prowler/providers/stackit/services/__init__.py create mode 100644 prowler/providers/stackit/services/iaas/__init__.py create mode 100644 prowler/providers/stackit/services/iaas/iaas_client.py create mode 100644 prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/__init__.py create mode 100644 prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.metadata.json create mode 100644 prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.py create mode 100644 prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/__init__.py create mode 100644 prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.metadata.json create mode 100644 prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.py create mode 100644 prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/__init__.py create mode 100644 prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.metadata.json create mode 100644 prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.py create mode 100644 prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/__init__.py create mode 100644 prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.metadata.json create mode 100644 prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.py create mode 100644 prowler/providers/stackit/services/iaas/iaas_service.py create mode 100644 prowler/providers/stackit/stackit_provider.py create mode 100644 prowler/providers/stackit/stackit_regions_by_service.json create mode 100644 tests/providers/stackit/lib/arguments/stackit_arguments_test.py create mode 100644 tests/providers/stackit/lib/mutelist/stackit_mutelist_test.py create mode 100644 tests/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted_test.py create mode 100644 tests/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted_test.py create mode 100644 tests/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted_test.py create mode 100644 tests/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted_test.py create mode 100644 tests/providers/stackit/services/iaas/iaas_service_test.py create mode 100644 tests/providers/stackit/stackit_exceptions_test.py create mode 100644 tests/providers/stackit/stackit_fixtures.py create mode 100644 tests/providers/stackit/stackit_metadata_test.py create mode 100644 tests/providers/stackit/stackit_provider_test.py diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index 6307d5d6df..d0ef83c772 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -541,6 +541,54 @@ jobs: flags: prowler-py${{ matrix.python-version }}-vercel files: ./vercel_coverage.xml + # Scaleway Provider + - name: Check if Scaleway files changed + if: steps.check-changes.outputs.any_changed == 'true' + id: changed-scaleway + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 + with: + files: | + ./prowler/**/scaleway/** + ./tests/**/scaleway/** + ./uv.lock + + - name: Run Scaleway tests + if: steps.changed-scaleway.outputs.any_changed == 'true' + run: uv run pytest -n auto --cov=./prowler/providers/scaleway --cov-report=xml:scaleway_coverage.xml tests/providers/scaleway + + - name: Upload Scaleway coverage to Codecov + if: steps.changed-scaleway.outputs.any_changed == 'true' + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + flags: prowler-py${{ matrix.python-version }}-scaleway + files: ./scaleway_coverage.xml + + # StackIT Provider + - name: Check if StackIT files changed + if: steps.check-changes.outputs.any_changed == 'true' + id: changed-stackit + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 + with: + files: | + ./prowler/**/stackit/** + ./tests/**/stackit/** + ./uv.lock + + - name: Run StackIT tests + if: steps.changed-stackit.outputs.any_changed == 'true' + run: uv run pytest -n auto --cov=./prowler/providers/stackit --cov-report=xml:stackit_coverage.xml tests/providers/stackit + + - name: Upload StackIT coverage to Codecov + if: steps.changed-stackit.outputs.any_changed == 'true' + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + flags: prowler-py${{ matrix.python-version }}-stackit + files: ./stackit_coverage.xml + # Lib - name: Check if Lib files changed if: steps.check-changes.outputs.any_changed == 'true' diff --git a/README.md b/README.md index 41ad716c2b..eff78169b0 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically | Vercel | 26 | 6 | 0 | 8 | Official | UI, API, CLI | | Okta | 1 | 1 | 0 | 1 | Official | CLI | | Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 0 | 1 | Unofficial | CLI | +| StackIT [Contact us](https://prowler.com/contact) | 4 | 1 | 0 | 1 | Unofficial | CLI | | NHN | 6 | 2 | 1 | 0 | Unofficial | CLI | > [!Note] diff --git a/docs/developer-guide/stackit-details.mdx b/docs/developer-guide/stackit-details.mdx new file mode 100644 index 0000000000..9d4ea5d458 --- /dev/null +++ b/docs/developer-guide/stackit-details.mdx @@ -0,0 +1,730 @@ +--- +title: 'StackIT Provider' +--- + +This page details the [StackIT Cloud](https://www.stackit.de/) provider implementation in Prowler. + +By default, Prowler audits a single StackIT project per scan. To configure it, provide the project ID and either a service account key file path or inline service account key JSON. + +## StackIT Provider Classes Architecture + +The StackIT provider implementation follows the general [Provider structure](/developer-guide/provider). This section focuses on the StackIT-specific implementation, highlighting how the generic provider concepts are realized for StackIT in Prowler. For a full overview of the provider pattern, base classes, and extension guidelines, see [Provider documentation](/developer-guide/provider). + +### `StackitProvider` (Main Class) + +- **Location:** [`prowler/providers/stackit/stackit_provider.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/stackit/stackit_provider.py) +- **Base Class:** Inherits from `Provider` (see [base class details](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/common/provider.py)). +- **Purpose:** Central orchestrator for StackIT-specific logic, API authentication, credential validation, and configuration. +- **Key StackIT Responsibilities:** + - Initializes StackIT SDK authentication via a service account key file or inline service account key JSON. The SDK mints and refreshes access tokens internally. + - Validates the service account credentials and project ID (UUID format validation). + - Loads and manages configuration, mutelist, and fixer settings. + - Provides properties and methods for downstream StackIT service classes to access credentials, identity, and configuration data. + +### Data Models + +- **Location:** [`prowler/providers/stackit/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/stackit/models.py) +- **Purpose:** Define structured data for StackIT identity and output configuration. +- **Key StackIT Models:** + - `StackITIdentityInfo`: Holds StackIT identity metadata, including project ID and project name (fetched automatically from Resource Manager API). + - `StackITOutputOptions`: Customizes default output filenames so StackIT reports include the audited project ID. + - IaaS resource models such as `SecurityGroup` and `SecurityGroupRule` are defined in the IaaS service module. + +### StackIT Services + +- **Location:** [`prowler/providers/stackit/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/services) +- **Purpose:** Implement StackIT service clients and resource collection logic following the generic [service pattern](/developer-guide/services#service-base-class). +- **Current Implementation:** The `IaaSService` collects security groups, rules, and network interface usage across supported StackIT regions. + +### Exception Handling + +- **Location:** [`prowler/providers/stackit/exceptions/exceptions.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/stackit/exceptions/exceptions.py) +- **Purpose:** Custom exception classes for StackIT-specific error handling, such as credential validation, API connection, and configuration errors. +- **Key Exception Classes:** + - `StackITBaseException`: Base exception for all StackIT provider errors. + - `StackITCredentialsError`: Raised when credentials are invalid or missing. + - `StackITInvalidProjectIdError`: Raised when project ID is invalid or not in UUID format. + - `StackITAPIError`: Raised when StackIT API calls fail. + +## Authentication + +### Service Account Creation and Key Generation + +StackIT uses service account keys for API authentication. Service account keys are RSA key-pair based and provide secure, short-lived access tokens. + +### Creating a Service Account Key + +#### Method 1: Via StackIT Portal + +1. **Navigate to Service Accounts** + - Go to the [StackIT Portal](https://portal.stackit.cloud/) + - Select your project + - Click on **Service Accounts** in the left sidebar + +2. **Create or Select Service Account** + - If you don't have a service account, click **Create Service Account** + - Provide a name and description + - Assign necessary permissions: + - For IaaS security checks: `iaas.viewer` or `project.owner` + - For comprehensive audits: `project.owner` + +3. **Generate Service Account Key** + - Select your service account + - Navigate to **Service Account Keys** + - Click **Create key** + - Choose one of the following options: + - **STACKIT-generated key pair** (Recommended): Let STACKIT automatically generate an RSA key-pair + - **User-provided key pair**: Upload your own RSA 2048 public key + +4. **Download and Save the Key** + - Download the generated service account key file (JSON format) + - **Important**: Save the key securely - it contains your private key and will only be available once + - Store the key file in a secure location (e.g., `~/.stackit/sa_key.json`) + +#### Method 2: Via StackIT CLI + +```bash +# Install STACKIT CLI (if not already installed) +# Follow instructions at: https://github.com/stackitcloud/stackit-cli + +# Create service account key (STACKIT-generated) +stackit service-account key create --email my-service-account@example.com + +# Or create with your own RSA 2048 public key +# First, generate your RSA key pair: +openssl genrsa -out private-key.pem 2048 +openssl rsa -in private-key.pem -pubout -out public-key.pem + +# Then create the key with your public key: +stackit service-account key create \ + --email my-service-account@example.com \ + --public-key "$(cat public-key.pem)" +``` + +### Finding Your Project ID + +Your StackIT project ID is a UUID that can be found: + +1. In the StackIT Portal URL when viewing your project: `https://portal.stackit.cloud/projects/{PROJECT_ID}/...` +2. In the project settings page +3. Using the StackIT CLI: `stackit project list` + +### Passing the Service Account Key to Prowler + +Prowler accepts the service account credentials in two equivalent forms; both go through the same StackIT SDK flow and refresh access tokens internally. + +#### Option 1: Key File Path (key persisted on disk) + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" +export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc" + +prowler stackit +``` + +Or as CLI flags: + +```bash +prowler stackit \ + --stackit-service-account-key-path ~/.stackit/sa-key.json \ + --stackit-project-id 12345678-1234-1234-1234-123456789abc +``` + +#### Option 2: Inline Key Content (CI/CD, secret managers) + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY="$(vault kv get -field=key stackit/sa)" +export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc" + +prowler stackit +``` + +Prefer the environment variable over the matching `--stackit-service-account-key` CLI flag; passing the secret on the command line leaks it through process listings and shell history. + +### Credential Lookup Order + +Prowler resolves credentials in this order: + +1. **Command-line arguments**: + - `--stackit-service-account-key` + - `--stackit-service-account-key-path` + - `--stackit-project-id` +2. **Environment variables**: + - `STACKIT_SERVICE_ACCOUNT_KEY` + - `STACKIT_SERVICE_ACCOUNT_KEY_PATH` + - `STACKIT_PROJECT_ID` + +When both the inline key and the key file path are set, the inline content takes precedence. + +## Configuration + +### Command-Line Arguments + +StackIT-specific command-line arguments: + +| Argument | Description | Required | Default | +|----------|-------------|----------|---------| +| `--stackit-service-account-key-path` | Path to a StackIT service account key JSON file | Yes* | `$STACKIT_SERVICE_ACCOUNT_KEY_PATH` | +| `--stackit-service-account-key` | Inline JSON content of a StackIT service account key (preferred env var: `STACKIT_SERVICE_ACCOUNT_KEY`) | Yes* | `$STACKIT_SERVICE_ACCOUNT_KEY` | +| `--stackit-project-id` | StackIT project ID (UUID format) | Yes* | `$STACKIT_PROJECT_ID` | +| `--stackit-region` | StackIT region(s) to scan | No | All available regions | + +\* Required unless provided via environment variables. + +### Input Validation + +The StackIT provider performs comprehensive input validation: + +- **Service Account Credentials**: + - At least one of `service_account_key_path` (file path) or `service_account_key` (inline JSON) must be supplied; both empty raises `StackITNonExistentTokenError` + - When both are provided the inline content takes precedence + - The key file path is logged as-is; the inline content is redacted in the credentials box + +- **Project ID**: + - Must not be empty + - Must be a valid UUID format (e.g., `12345678-1234-1234-1234-123456789abc`) + - Validated using Python's UUID constructor + +Invalid credentials will result in clear error messages before any API calls are made. + +## Available Services + +### IaaS (Infrastructure as a Service) + +- **Service Class:** `IaaSService` +- **Location:** [`prowler/providers/stackit/services/iaas/iaas_service.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/stackit/services/iaas/iaas_service.py) +- **SDK:** Uses the [stackit-iaas](https://pypi.org/project/stackit-iaas/) Python SDK +- **Purpose:** Manages IaaS resources including security groups, servers, and network interfaces. + +**Supported Resources:** +- Security Groups and Rules +- Servers (Virtual Machines) +- Network Interfaces (NICs) + +**Key Features:** +- Automatic discovery of all security groups in the project +- Security rule parsing with support for unrestricted access detection +- Network interface analysis to determine whether security groups are in use +- By default, reports only security groups attached to at least one NIC; `--scan-unused-services` includes unused security groups too + +## Available Checks + +The StackIT provider currently implements 4 security checks focused on network security: + +### 1. iaas_security_group_ssh_unrestricted + +- **Severity:** High +- **Description:** Detects security groups that allow unrestricted SSH access (port 22) from the internet. +- **Risk:** Unrestricted SSH access increases the attack surface and risk of brute-force attacks. +- **Detection Logic:** + - Checks for ingress rules allowing TCP port 22 + - Flags rules with `ip_range=None` or `ip_range="0.0.0.0/0"` or `ip_range="::/0"` + - Reports security groups attached to NICs by default, or all security groups when `--scan-unused-services` is enabled + +### 2. iaas_security_group_rdp_unrestricted + +- **Severity:** High +- **Description:** Detects security groups that allow unrestricted RDP access (port 3389) from the internet. +- **Risk:** Unrestricted RDP access enables potential unauthorized remote desktop access. +- **Detection Logic:** + - Checks for ingress rules allowing TCP port 3389 + - Flags unrestricted IP ranges (None, 0.0.0.0/0, ::/0) + - Reports security groups attached to NICs by default, or all security groups when `--scan-unused-services` is enabled + +### 3. iaas_security_group_database_unrestricted + +- **Severity:** High +- **Description:** Detects security groups that allow unrestricted access to common database ports. +- **Monitored Ports:** + - MySQL: 3306 + - PostgreSQL: 5432 + - MongoDB: 27017 + - Redis: 6379 + - SQL Server: 1433 + - CouchDB: 5984 +- **Risk:** Unrestricted database access can lead to data breaches and unauthorized data access. + +### 4. iaas_security_group_all_traffic_unrestricted + +- **Severity:** Critical +- **Description:** Detects security groups that allow all traffic from the internet. +- **Detection Logic:** + - Checks for rules with `port_range=None` (all ports) + - Checks for rules with port range covering 0-65535 or 1-65535 + - Flags unrestricted IP ranges + - Critical security misconfiguration requiring immediate remediation + +### Important Implementation Notes + +**Self-Referencing Security Group Rules:** + +Security group rules with `remoteSecurityGroupId` set are automatically filtered out from unrestricted access checks. These rules only allow traffic from instances within the same security group (self-referencing), not from the internet, and are therefore not flagged as security risks. + +**Rule Display Names:** + +All findings include user-friendly rule descriptions when available. If a security group rule has a description field set (the name shown in the StackIT UI), it will be displayed in the finding message along with the rule ID: +- With description: `'Allow SSH from office' (sgr-abc123)` +- Without description: `'sgr-abc123'` + +**Network Interface (NIC) Usage Filtering:** + +The IaaS service lists project NICs and records the security group IDs attached to them. Checks use that signal to decide whether a security group is in use: + +1. **Default behavior:** Report security groups attached to at least one NIC. +2. **`--scan-unused-services`:** Report every security group, including unused ones. +3. **FAIL logic:** Internet exposure is driven by security group rules that allow unrestricted source ranges, not by the presence of a public IP on the NIC. + +**Unrestricted IP Ranges:** + +The StackIT API represents "unrestricted" in two ways: +- **`ip_range=null`**: No IP restriction specified (implicit unrestricted) +- **`ip_range="0.0.0.0/0"` or `"::/0"`**: Explicitly configured to allow all IPs + +Both are flagged as unrestricted. A `null` value is **more permissive** than an explicit range and applies to all protocols/ports if other fields are also `null`. + +## Requirements + +### Python Version + +- **Minimum:** Python 3.10+ +- **Reason:** The StackIT SDK requires Python 3.10 or higher + +### Dependencies + +The StackIT provider requires the following Python packages (automatically installed with Prowler): + +- **stackit-core** (v0.2.0): Core SDK for StackIT API authentication and configuration +- **stackit-iaas** (v1.4.0): IaaS service SDK for managing compute resources +- **stackit-resourcemanager** (v0.8.0): Resource Manager SDK for fetching project metadata (e.g., project names) + +These dependencies are defined in `pyproject.toml` and installed automatically with: + +```bash +poetry install +``` + +**Note:** The `stackit-resourcemanager` package enables automatic retrieval of project names for display in reports. If this package is not available, Prowler will still function normally but project names will be empty in the output. + +## Region Support + +### Supported Regions + +- **Available Regions:** `eu01` (Germany South) and `eu02` (Austria West) +- **Default:** All scans use both `eu01` and `eu02` regions by default. + +### Multi-Region Scanning + +Prowler supports scanning multiple StackIT regions in a single execution. By default, it will scan all regions defined in the `stackit_regions_by_service.json` configuration file. + +### CLI Argument + +You can specify which regions to scan using the `--stackit-region` argument: + +```bash +# Scan only eu01 +prowler stackit --stackit-region eu01 + +# Scan both eu01 and eu02 +prowler stackit --stackit-region eu01 eu02 +``` + +### Implementation Details + +- **Regional Clients:** Prowler generates a separate API client for each audited region. +- **Service Iteration:** Each service (e.g., IaaS) iterates through the regional clients to fetch and audit resources. +- **Identity Tracking:** The `audited_regions` are stored in the identity model for reporting. + +### Future Enhancements + +As StackIT adds more regions, they can be easily added to Prowler by updating the `prowler/providers/stackit/stackit_regions_by_service.json` file without requiring code changes. + +## Command Examples + +### Scan Specific Regions + +Scan only the `eu01` region: + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" + +prowler stackit \ + --stackit-project-id "your-project-id" \ + --stackit-region eu01 +``` + +Scan multiple regions: + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" + +prowler stackit \ + --stackit-project-id "your-project-id" \ + --stackit-region eu01 eu02 +``` + +### Scan Specific Checks + +Run only SSH unrestricted check: + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" + +prowler stackit \ + --stackit-project-id "your-project-id" \ + --checks iaas_security_group_ssh_unrestricted +``` + +### Scan All Security Group Checks + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" + +prowler stackit \ + --stackit-project-id "your-project-id" \ + --services iaas +``` + +### Output Formats + +Generate JSON output: + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" + +prowler stackit \ + --stackit-project-id "your-project-id" \ + --output-formats json +``` + +Generate HTML report: + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" + +prowler stackit \ + --stackit-project-id "your-project-id" \ + --output-formats html +``` + +## Known Limitations + +### Current Limitations + +1. **Single Project Scope**: Only one project can be scanned at a time +2. **Service Coverage**: Only the IaaS service is currently implemented +3. **Check Coverage**: Limited to security group network security checks (4 checks total) +4. **No Compliance Frameworks**: Compliance framework mappings are not yet implemented + +### Planned Enhancements + +- Multi-project scanning capability +- Additional IaaS checks (volume encryption, server public IP exposure, backup status) +- Compliance framework mappings (CIS, custom StackIT best practices) +- StackIT CLI remediation examples in metadata + +## Troubleshooting + +### Authentication Errors + +**Error:** `StackIT service account key was rejected` + +**Solutions:** +1. Re-issue the service account key in the StackIT Portal +2. Verify the service account key file or inline JSON content is complete +3. Check that the service account has the necessary permissions (`iaas.viewer` or `project.owner`) +4. Ensure the service account key is provided through `STACKIT_SERVICE_ACCOUNT_KEY_PATH`, `STACKIT_SERVICE_ACCOUNT_KEY`, or the matching CLI arguments + +**Error:** `StackIT credentials not found or are invalid` + +**Solutions:** +1. Ensure the project ID and one service account credential source are provided +2. Check that credentials are set via environment variables or command-line arguments +3. Verify there are no extra spaces or newlines in the credentials + +**Error:** `Invalid StackIT project ID format` + +**Solutions:** +1. Verify the project ID is a valid UUID format: `12345678-1234-1234-1234-123456789abc` +2. Copy the project ID directly from the StackIT Portal +3. Ensure there are no extra spaces or quotes around the UUID + +### API Connection Errors + +**Error:** `Failed to connect to StackIT API` + +**Solutions:** +1. Check your internet connection +2. Verify the StackIT API endpoint is accessible from your network +3. Check if there are any firewall rules blocking HTTPS connections +4. Review the full error message for specific API error codes + +**Error:** `HTTP 403 Forbidden` + +**Solutions:** +1. Verify the service account has the correct permissions +2. Ensure the project ID is correct and you have access to it +3. Check that the service account is enabled (not disabled or expired) +4. Verify the service account key has not been revoked + +**Error:** `HTTP 404 Not Found` + +**Solutions:** +1. Verify the project ID exists and is correct +2. Check that the IaaS service is enabled in your project +3. Ensure you're using the correct region (eu01) + +### Empty Results + +**Issue:** No security groups or findings reported + +**Solutions:** +1. Verify that security groups exist in your project +2. Check that the IaaS service is properly configured +3. Ensure the service account has `iaas.viewer` permission +4. Check Prowler logs for any API errors (use `--log-level DEBUG`) + +### Debug Mode + +Enable debug logging for detailed troubleshooting: + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" + +prowler stackit \ + --stackit-project-id "your-project-id" \ + --log-level DEBUG +``` + +This will show: +- API authentication details (with inline service account keys redacted) +- Resource discovery progress +- Security rule parsing details +- Any API errors or warnings + +## Specific Patterns in StackIT Services + +The generic service pattern is described in [service page](/developer-guide/services#service-structure-and-initialisation). You can find all the currently implemented services in the following locations: + +- Directly in the code, in location [`prowler/providers/stackit/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/services) +- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view. + +The best reference to understand how to implement a new service is following the [service implementation documentation](/developer-guide/services#adding-a-new-service) and taking other StackIT services as reference. + +### StackIT Service Common Patterns + +- Services communicate with StackIT using the StackIT Python SDK, you can find the documentation [here](https://github.com/stackitcloud/stackit-sdk-python). +- Service constructors receive a `StackitProvider` instance and use it to access credentials, identity, and configuration. +- The provider builds StackIT SDK `Configuration` objects from the service account key path or inline key content. +- Resource containers **must** be initialized in the constructor, typically as lists or dictionaries. +- Do not manipulate `os.environ` for credentials inside services. Use the provider session and SDK configuration helpers. +- All StackIT resources are represented as Pydantic `BaseModel` classes, providing type safety and structured access to resource attributes. +- StackIT SDK calls are wrapped in try/except blocks, with specific handling for API errors, always logging errors. +- **Centralized Error Handling**: Use `provider.handle_api_error(exception)` for consistent authentication error detection across all services. +- **SDK Warning Suppression**: StackIT SDK prints deprecation warnings to stderr - use the `suppress_stderr()` context manager during SDK initialization and API calls. +- **Unrestricted Access Detection**: In StackIT API, `None` values mean "allow all" (more permissive than explicit 0.0.0.0/0). + - `protocol=None` โ†’ All protocols allowed + - `ip_range=None` โ†’ All source IPs allowed (unrestricted!) + - `port_range=None` โ†’ All ports allowed + - `remote_security_group_id` set โ†’ Only allows traffic from the same security group (not unrestricted!) + +### IaaS Service Specific Patterns + +**Security Group Discovery:** +```python +# List all security groups +security_groups = client.list_security_groups( + project_id=self.project_id, + region=region, +) + +# List network interfaces to determine security group usage +nics = client.list_project_nics( + project_id=self.project_id, + region=region, +) + +# Checks report in-use security groups by default. Use --scan-unused-services +# to include security groups that are not attached to any NIC. +``` + +**Centralized Authentication Error Handling:** +```python +def _handle_api_call(self, api_function, *args, **kwargs): + """Wrapper for API calls with centralized error handling.""" + try: + with suppress_stderr(): # Suppress SDK warnings + return api_function(*args, **kwargs) + except Exception as e: + # Use centralized error handler from provider + self.provider.handle_api_error(e) # Detects 401 and raises StackITInvalidTokenError +``` + +**Unrestricted Access Detection:** +```python +def is_unrestricted(rule): + """Check if a rule allows unrestricted access.""" + # Filter out self-referencing rules + if rule.remote_security_group_id is not None: + return False + # Check for unrestricted IP ranges + return rule.ip_range is None or rule.ip_range in ["0.0.0.0/0", "::/0"] + +def is_tcp(rule): + """Check if a rule applies to TCP protocol.""" + # None means all protocols (including TCP) + return rule.protocol is None or rule.protocol.lower() in ["tcp", "all"] + +def includes_port(rule, port): + """Check if a rule includes a specific port.""" + # None means all ports + if rule.port_range is None: + return True + return rule.port_range.min <= port <= rule.port_range.max +``` + +## Specific Patterns in StackIT Checks + +The StackIT checks pattern is described in [checks page](/developer-guide/checks). You can find all the currently implemented checks: + +- Directly in the code, within each service folder, each check has its own folder named after the name of the check. (e.g. [`prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted)) +- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view. + +The best reference to understand how to implement a new check is following the [check creation documentation](/developer-guide/checks#creating-a-check) and taking other similar StackIT checks as reference. + +### Check Report Class + +The `CheckReportStackIT` class models a single finding for a StackIT resource in a check report. It is defined in [`prowler/lib/check/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/lib/check/models.py) and inherits from the generic `Check_Report` base class. + +#### Purpose + +`CheckReportStackIT` extends the base report structure with StackIT-specific fields, enabling detailed tracking of the resource, project, and location associated with each finding. + +#### Constructor and Attribute Population + +When you instantiate `CheckReportStackIT`, you must provide the check metadata and a resource object. The class will attempt to automatically populate its StackIT-specific attributes from the resource, using the following logic: + +- **`resource_id`**: + - Uses `resource.id` if present. + - Otherwise, uses `resource.resource_id` if present. + - Defaults to an empty string if none are available. + +- **`resource_name`**: + - Uses `resource.name` if present. + - Defaults to an empty string if not available. + +- **`project_id`**: + - Uses `resource.project_id` if present. + - Defaults to an empty string if not available (should be set in check logic). + +- **`location`**: + - Uses `resource.region` if present. + - Otherwise, uses `resource.location` if present. + - Defaults to an empty string if not available. + +If the resource object does not contain the required attributes, you must set them manually in the check logic. + +Other attributes are inherited from the `Check_Report` class, from which you **always** have to set the `status` and `status_extended` attributes in the check logic. + +#### Example Usage + +```python +from prowler.lib.check.models import CheckReportStackIT + +report = CheckReportStackIT( + metadata=self.metadata(), + resource=security_group +) +report.status = "FAIL" +report.status_extended = f"Security group {security_group.name} allows unrestricted SSH access from the internet." +report.resource_id = security_group.id +report.resource_name = security_group.name +report.project_id = security_group.project_id +report.location = security_group.region +``` + +### Common Check Pattern + +```python +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.iaas.iaas_client import iaas_client + +class iaas_security_group_ssh_unrestricted(Check): + """Check if IaaS security groups allow unrestricted SSH access.""" + + def execute(self): + findings = [] + + for security_group in iaas_client.security_groups: + if not (iaas_client.scan_unused_services or security_group.in_use): + continue + + report = CheckReportStackIT( + metadata=self.metadata(), + resource=security_group + ) + report.status = "PASS" + report.status_extended = f"Security group {security_group.name} does not allow unrestricted SSH access." + + # Check each rule + for rule in security_group.rules: + if (rule.is_ingress() and + rule.is_tcp() and + rule.includes_port(22) and + rule.is_unrestricted()): + report.status = "FAIL" + report.status_extended = f"Security group {security_group.name} allows unrestricted SSH access from the internet." + break + + findings.append(report) + + return findings +``` + +## Resources + +### Official StackIT Documentation + +- **StackIT Portal**: [https://portal.stackit.cloud/](https://portal.stackit.cloud/) +- **StackIT Documentation**: [https://docs.stackit.cloud/](https://docs.stackit.cloud/) +- **StackIT API Documentation**: [https://docs.api.eu01.stackit.cloud/](https://docs.api.eu01.stackit.cloud/) + +### Python SDK + +- **StackIT Python SDK (GitHub)**: [https://github.com/stackitcloud/stackit-sdk-python](https://github.com/stackitcloud/stackit-sdk-python) +- **stackit-core (PyPI)**: [https://pypi.org/project/stackit-core/](https://pypi.org/project/stackit-core/) +- **stackit-iaas (PyPI)**: [https://pypi.org/project/stackit-iaas/](https://pypi.org/project/stackit-iaas/) +- **IaaS Models**: [https://github.com/stackitcloud/stackit-sdk-python/tree/main/services/iaas/src/stackit/iaas/models](https://github.com/stackitcloud/stackit-sdk-python/tree/main/services/iaas/src/stackit/iaas/models) + +### Prowler Resources + +- **Provider Implementation**: [`prowler/providers/stackit/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/) +- **IaaS Service**: [`prowler/providers/stackit/services/iaas/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/stackit/services/iaas/) +- **Prowler Hub**: [https://hub.prowler.com/](https://hub.prowler.com/) +- **GitHub Issues**: [https://github.com/prowler-cloud/prowler/issues](https://github.com/prowler-cloud/prowler/issues) + +## Contributing + +If you'd like to contribute to the StackIT provider: + +1. **Add New Checks**: Follow the [check creation guide](/developer-guide/checks#creating-a-check) and use existing StackIT checks as templates +2. **Enhance Services**: Implement additional IaaS resource discovery or add new services +3. **Improve Documentation**: Add metadata enhancements, CLI remediation examples, or Terraform code samples +4. **Report Issues**: Submit bug reports or feature requests on [GitHub](https://github.com/prowler-cloud/prowler/issues) + +### Quick Start for Contributors + +1. **Install dependencies**: `poetry install` (includes stackit-core and stackit-iaas) +2. **Set credentials**: Export `STACKIT_SERVICE_ACCOUNT_KEY_PATH` and `STACKIT_PROJECT_ID` +3. **Run checks**: `prowler stackit` +4. **View code**: Start in `prowler/providers/stackit/` +5. **Add checks**: Create new check directories under `services/iaas/` +6. **Run tests**: `poetry run pytest tests/providers/stackit/ -v` + +### Code Quality Standards + +The StackIT provider should follow the same quality expectations as the rest of the Prowler SDK: + +- Keep service and check logic covered by unit tests. +- Redact inline service account keys from generated output. +- Keep documentation aligned with the implemented services and checks. +- Follow existing provider, service, and check patterns before adding StackIT-specific abstractions. diff --git a/docs/docs.json b/docs/docs.json index 836ff50513..e31575878e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -339,6 +339,13 @@ "user-guide/providers/scaleway/authentication" ] }, + { + "group": "StackIT", + "pages": [ + "user-guide/providers/stackit/getting-started-stackit", + "user-guide/providers/stackit/authentication" + ] + }, { "group": "Vercel", "pages": [ @@ -401,7 +408,8 @@ "developer-guide/kubernetes-details", "developer-guide/m365-details", "developer-guide/github-details", - "developer-guide/llm-details" + "developer-guide/llm-details", + "developer-guide/stackit-details" ] }, { diff --git a/docs/introduction.mdx b/docs/introduction.mdx index dd05e3dbdb..fe530e4a4f 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -36,6 +36,7 @@ Prowler supports a wide range of providers organized by category: | [OpenStack](/user-guide/providers/openstack/getting-started-openstack) | Official | Projects | UI, API, CLI | | [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | Tenancies / Compartments | UI, API, CLI | | [Scaleway](/user-guide/providers/scaleway/getting-started-scaleway) | [Contact us](https://prowler.com/contact) | Organizations | CLI | +| [StackIT](/user-guide/providers/stackit/getting-started-stackit) | [Contact us](https://prowler.com/contact) | Projects | CLI | ### Infrastructure as Code Providers diff --git a/docs/user-guide/providers/stackit/authentication.mdx b/docs/user-guide/providers/stackit/authentication.mdx new file mode 100644 index 0000000000..dabc407c50 --- /dev/null +++ b/docs/user-guide/providers/stackit/authentication.mdx @@ -0,0 +1,100 @@ +--- +title: 'StackIT Authentication' +--- + +Prowler authenticates with StackIT using a **service account key file**. The StackIT SDK signs the RSA challenge in the key file and mints/refreshes access tokens internally for the life of the scan, so no manual token rotation is needed. + +## Service Account Key + +StackIT uses RSA key-pair based service account keys. They are issued once, must be stored securely, and are read by the SDK on every scan to mint short-lived access tokens transparently. + +### Option 1: Create the Key via the StackIT Portal + +1. Open the [StackIT Portal](https://portal.stackit.cloud/) and select your project. +2. In the left sidebar, click **Service Accounts**. +3. Create a service account if you do not have one already. Assign: + - `iaas.viewer` for the IaaS security group checks currently shipped, or + - `project.owner` if you want to cover any future service Prowler adds. +4. Open the service account and go to **Service Account Keys**. +5. Click **Create key** and choose **STACKIT-generated key pair** (recommended). Download the resulting JSON file and store it securely (for example, `~/.stackit/sa-key.json`). The private material is only shown once. + +### Option 2: Create the Key via the StackIT CLI + +```bash +# Install the StackIT CLI from https://github.com/stackitcloud/stackit-cli first +stackit service-account key create --email my-service-account@example.com +``` + +## Project ID + +Your StackIT project ID is a UUID. You can find it in: + +1. The portal URL when viewing the project: `https://portal.stackit.cloud/projects/{PROJECT_ID}/...` +2. The project settings page +3. `stackit project list` + +## Passing Credentials to Prowler + +You can give Prowler either the **path** to the key file on disk or the **inline JSON content** of the key. Both go through the same StackIT SDK flow and refresh access tokens internally. + +### Option A: Key File Path (workstation, persistent agents) + +Recommended when the key is stored on disk. + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" +export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc" + +prowler stackit +``` + +Or as CLI flags: + +```bash +prowler stackit \ + --stackit-service-account-key-path ~/.stackit/sa-key.json \ + --stackit-project-id 12345678-1234-1234-1234-123456789abc +``` + + +Keep the key file outside of source control and lock it down with `chmod 600 ~/.stackit/sa-key.json`. Anyone with the JSON can mint access tokens for the service account. + + +### Option B: Inline Key Content (CI/CD, secret managers) + +Recommended when the key is fetched at run time from a secret manager (GitHub Actions secret, AWS Secrets Manager, HashiCorp Vault, etc.) and you do not want to write it to disk. + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY="$(vault kv get -field=key stackit/sa)" +export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc" + +prowler stackit +``` + + +Prefer the `STACKIT_SERVICE_ACCOUNT_KEY` environment variable over the matching CLI flag (`--stackit-service-account-key`); passing the secret on the command line leaks it through process listings and shell history. + + +When both the inline content and a key path are set, the inline content wins. + +## Credential Lookup Order + +Prowler resolves credentials in this order: + +1. CLI arguments: `--stackit-service-account-key`, `--stackit-service-account-key-path`, `--stackit-project-id` +2. Environment variables: `STACKIT_SERVICE_ACCOUNT_KEY`, `STACKIT_SERVICE_ACCOUNT_KEY_PATH`, `STACKIT_PROJECT_ID` + +When both the inline key and the key file path are set, the inline content takes precedence. + +## Token Lifetime + +Access tokens are minted on demand by the SDK from the key file and refreshed before they expire. There is nothing to rotate while Prowler is running. + +## Troubleshooting + +| Symptom | Likely Cause | Fix | +|---------|--------------|-----| +| `401 Unauthorized` during scan | Key file is missing fields, the public key is no longer registered, or the key was revoked | Re-issue the service account key in the StackIT portal and update `STACKIT_SERVICE_ACCOUNT_KEY_PATH` | +| `403 Forbidden` during scan | Service account lacks role on the project | Re-check role assignment in the StackIT portal; `iaas.viewer` is the minimum for the shipped IaaS checks | +| `StackIT project ID must be a valid UUID` | The project ID is not in UUID format | Copy the UUID from the portal URL or `stackit project list` | +| `StackIT service account credentials are required` | None of the four credential inputs is set | Export `STACKIT_SERVICE_ACCOUNT_KEY_PATH` or `STACKIT_SERVICE_ACCOUNT_KEY` (or use their CLI counterparts) before running Prowler | diff --git a/docs/user-guide/providers/stackit/getting-started-stackit.mdx b/docs/user-guide/providers/stackit/getting-started-stackit.mdx new file mode 100644 index 0000000000..6c56008a89 --- /dev/null +++ b/docs/user-guide/providers/stackit/getting-started-stackit.mdx @@ -0,0 +1,141 @@ +--- +title: 'Getting Started With StackIT' +--- + +Prowler supports [StackIT](https://www.stackit.de/) from the CLI. This guide walks you through the requirements and how to run scans. + + +StackIT support in Prowler is community-maintained. For commercial support or to request additional service coverage, [contact us](https://prowler.com/contact). + + +## Prerequisites + +Before running Prowler with the StackIT provider, ensure you have: + +1. A StackIT account with at least one project +2. A StackIT service account key file with permissions on the project (`iaas.viewer` is enough for the currently shipped IaaS checks; `project.owner` works for any future service). See the [Authentication guide](/user-guide/providers/stackit/authentication) for the full setup. +3. Access to Prowler CLI (see [Installation](/getting-started/installation/prowler-cli)) + +## Prowler CLI + +### Step 1: Point Prowler at the Service Account Key + +Prowler authenticates with a StackIT service account key. The SDK signs the RSA challenge in the key and refreshes access tokens internally for the life of the scan, so there is no manual token rotation. + +**On a workstation or persistent agent** (key on disk): + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY_PATH="$HOME/.stackit/sa-key.json" +export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc" +``` + +**In CI/CD** (key in a secret manager, never written to disk): + +```bash +export STACKIT_SERVICE_ACCOUNT_KEY="$(vault kv get -field=key stackit/sa)" +export STACKIT_PROJECT_ID="12345678-1234-1234-1234-123456789abc" +``` + +CLI flags work too: + +```bash +prowler stackit \ + --stackit-service-account-key-path ~/.stackit/sa-key.json \ + --stackit-project-id 12345678-1234-1234-1234-123456789abc +``` + + +For the inline key, prefer the `STACKIT_SERVICE_ACCOUNT_KEY` env var over the matching CLI flag; passing the secret on the command line leaks it through process listings and shell history. + +Keep the key file outside of source control and lock it down with `chmod 600 ~/.stackit/sa-key.json`. Anyone with the JSON can mint access tokens for the service account. + + +### Step 2: Run Your First Scan + +```bash +prowler stackit +``` + +Prowler will discover and audit the project's IaaS security groups across the available StackIT regions. + +**Scan specific regions:** + +```bash +prowler stackit --stackit-region eu01 eu02 +``` + +**Run specific security checks:** + +```bash +prowler stackit --checks iaas_security_group_ssh_unrestricted + +# List all available checks +prowler stackit --list-checks +``` + +**Filter by check severity:** + +```bash +prowler stackit --severity critical high +``` + +**Generate specific output formats:** + +```bash +# JSON only +prowler stackit --output-modes json + +# CSV and HTML +prowler stackit --output-modes csv html + +# Custom output directory +prowler stackit --output-directory /path/to/reports/ +``` + +**Use a mutelist to suppress findings:** + +```yaml +# mutelist.yaml +Mutelist: + Accounts: + "12345678-1234-1234-1234-123456789abc": + Checks: + iaas_security_group_ssh_unrestricted: + Regions: + - "*" + Resources: + - "test-sg-id" + Tags: [] +``` + +```bash +prowler stackit --mutelist-file mutelist.yaml +``` + +### Step 3: Review the Results + +Prowler outputs findings to the console and writes reports to the `output/` directory by default: + +- CSV: `output/prowler-output-stackit-{project_id}-{timestamp}.csv` +- JSON: `output/prowler-output-stackit-{project_id}-{timestamp}.json` +- HTML: `output/prowler-output-stackit-{project_id}-{timestamp}.html` + +## Supported StackIT Services + +| Service | StackIT API | Description | Example Checks | +|---------|-------------|-------------|----------------| +| **IaaS** | `iaas` | Virtual machines, network interfaces, security groups | `iaas_security_group_ssh_unrestricted`, `iaas_security_group_rdp_unrestricted`, `iaas_security_group_database_unrestricted`, `iaas_security_group_all_traffic_unrestricted` | + +Additional services will be added in future releases. Track progress in the [Prowler release notes](https://github.com/prowler-cloud/prowler/releases). + +## Troubleshooting + +### Authentication Errors + +If the scan fails with a 401 error, the service account key is no longer valid (revoked, rotated or the key file is incomplete). Re-issue the key in the [StackIT portal](https://portal.stackit.cloud/) and update `STACKIT_SERVICE_ACCOUNT_KEY_PATH`. + +### Permission Errors + +If checks fail with a 403 error, the service account is missing the required role on the project. Re-check the role assignment in the StackIT portal (`iaas.viewer` is the minimum for the shipped IaaS checks). + +For detailed setup steps, see the [Authentication guide](/user-guide/providers/stackit/authentication). diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index dfc2e9ca2e..fedc6eecb8 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `application` service for Okta provider with `application_admin_console_session_idle_timeout_15min`, `application_admin_console_mfa_required`, `application_admin_console_phishing_resistant_authentication`, `application_dashboard_mfa_required`, `application_dashboard_phishing_resistant_authentication`, and `application_authentication_policy_network_zone_enforced` checks [(#11358)](https://github.com/prowler-cloud/prowler/pull/11358) - AWS AI Security Framework compliance for AWS provider [(#11353)](https://github.com/prowler-cloud/prowler/pull/11353) - `storage_account_public_network_access_disabled` check for Azure provider and remapped the Azure CIS "Public Network Access is Disabled" requirements to it [(#11334)](https://github.com/prowler-cloud/prowler/pull/11334) +- StackIT provider now authenticates with a service account key, either as a file path (`--stackit-service-account-key-path` / `STACKIT_SERVICE_ACCOUNT_KEY_PATH`) or as inline JSON content (`--stackit-service-account-key` / `STACKIT_SERVICE_ACCOUNT_KEY`, intended for CI/CD with a secret manager); the StackIT SDK refreshes access tokens internally, replacing the short-lived `STACKIT_API_TOKEN` flow [(#9237)](https://github.com/prowler-cloud/prowler/pull/9237) - 8 Rules service checks for Google Workspace provider using the Cloud Identity Policy API [(#11379)](https://github.com/prowler-cloud/prowler/pull/11379) - 12 Security service checks for Google Workspace provider using the Cloud Identity Policy API [(#11356)](https://github.com/prowler-cloud/prowler/pull/11356) diff --git a/prowler/__main__.py b/prowler/__main__.py index 3328905153..a9d794c2d6 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -158,6 +158,7 @@ from prowler.providers.okta.models import OktaOutputOptions from prowler.providers.openstack.models import OpenStackOutputOptions from prowler.providers.oraclecloud.models import OCIOutputOptions from prowler.providers.scaleway.models import ScalewayOutputOptions +from prowler.providers.stackit.models import StackITOutputOptions from prowler.providers.vercel.models import VercelOutputOptions @@ -416,6 +417,10 @@ def prowler(): output_options = OCIOutputOptions( args, bulk_checks_metadata, global_provider.identity ) + elif provider == "stackit": + output_options = StackITOutputOptions( + args, bulk_checks_metadata, global_provider.identity + ) elif provider == "alibabacloud": output_options = AlibabaCloudOutputOptions( args, bulk_checks_metadata, global_provider.identity diff --git a/prowler/compliance/stackit/__init__.py b/prowler/compliance/stackit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/config/config.py b/prowler/config/config.py index 2f4d18e32f..53eb5e3e37 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -78,6 +78,7 @@ class Provider(str, Enum): SCALEWAY = "scaleway" VERCEL = "vercel" OKTA = "okta" + STACKIT = "stackit" # Compliance diff --git a/prowler/config/stackit_mutelist_example.yaml b/prowler/config/stackit_mutelist_example.yaml new file mode 100644 index 0000000000..40b04a2731 --- /dev/null +++ b/prowler/config/stackit_mutelist_example.yaml @@ -0,0 +1,26 @@ +### Project, Check and/or Region can be * to apply for all the cases. +### Project == +### Resources and tags are lists that can have either Regex or Keywords. +### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together. +### Use an alternation Regex to match one of multiple tags with "ORed" logic. +### For each check you can except Projects, Regions, Resources and/or Tags. +########################### MUTELIST EXAMPLE ########################### +Mutelist: + Accounts: + "project_id_1": + Checks: + "iaas_security_group_ssh_unrestricted": + Regions: + - "*" + Resources: + - "sg-production-ssh" + - "sg-development-rdp" + Tags: + - "environment=dev" + "project_id_2": + Checks: + "*": + Regions: + - "eu01" + Resources: + - ".*-test$" diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index d034c5e550..d97be78cbe 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -1230,6 +1230,31 @@ class CheckReportNHN(Check_Report): self.location = getattr(resource, "location", "kr1") +@dataclass +class CheckReportStackIT(Check_Report): + """Contains the StackIT Check's finding information.""" + + resource_name: str + resource_id: str + project_id: str + location: str + + def __init__(self, metadata: Dict, resource: Any) -> None: + """Initialize the StackIT Check's finding information. + + Args: + metadata: The metadata of the check. + resource: Basic information about the resource. Defaults to None. + """ + super().__init__(metadata, resource) + self.resource_name = getattr( + resource, "name", getattr(resource, "resource_name", "") + ) + self.resource_id = getattr(resource, "id", getattr(resource, "resource_id", "")) + self.project_id = getattr(resource, "project_id", "") + self.location = getattr(resource, "region", getattr(resource, "location", "")) + + @dataclass class CheckReportOpenStack(Check_Report): """Contains the OpenStack Check's finding information.""" diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index 6045302a43..449c15a22c 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -29,10 +29,10 @@ class ProwlerArgumentParser: self.parser = argparse.ArgumentParser( prog="prowler", formatter_class=RawTextHelpFormatter, - usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,vercel,dashboard,iac,image,llm} ...", + usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm} ...", epilog=""" Available Cloud Providers: - {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,vercel} + {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel} aws AWS Provider azure Azure Provider gcp GCP Provider @@ -44,6 +44,7 @@ Available Cloud Providers: cloudflare Cloudflare Provider oraclecloud Oracle Cloud Infrastructure Provider openstack OpenStack Provider + stackit StackIT Provider alibabacloud Alibaba Cloud Provider iac IaC Provider llm LLM Provider (Beta) diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index 8ca01bc9c5..950460e4e3 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -342,6 +342,20 @@ class Finding(BaseModel): output_data["resource_uid"] = check_output.resource_id output_data["region"] = check_output.location + elif provider.type == "stackit": + output_data["auth_method"] = getattr( + provider, "auth_method", "api_token" + ) + output_data["account_uid"] = get_nested_attribute( + provider, "identity.project_id" + ) + output_data["account_name"] = get_nested_attribute( + provider, "identity.project_name" + ) + output_data["resource_name"] = check_output.resource_name + output_data["resource_uid"] = check_output.resource_id + output_data["region"] = check_output.location + elif provider.type == "iac": output_data["auth_method"] = provider.auth_method provider_uid = getattr(provider, "provider_uid", None) @@ -576,6 +590,8 @@ class Finding(BaseModel): finding.subscription = list(provider.identity.subscriptions.keys())[0] elif provider.type == "gcp": finding.project_id = list(provider.projects.keys())[0] + elif provider.type == "stackit": + finding.project_id = provider.identity.project_id elif provider.type == "iac": # For IaC, we don't have resource_line_range in the Finding model # It would need to be extracted from the resource metadata if needed diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index 8685d711ce..33f7ef4934 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -1076,6 +1076,73 @@ class HTML(Output): ) return "" + @staticmethod + def get_stackit_assessment_summary(provider: Provider) -> str: + """ + get_stackit_assessment_summary gets the HTML assessment summary for the StackIT provider + + Args: + provider (Provider): the StackIT provider object + + Returns: + str: HTML assessment summary for the StackIT provider + """ + try: + project_id = getattr(provider.identity, "project_id", "unknown") + project_name = getattr(provider.identity, "project_name", "") + audited_regions = getattr(provider.identity, "audited_regions", set()) + + project_name_item = ( + f""" +
  • + Project Name: {project_name} +
  • """ + if project_name + else "" + ) + + regions_item = ( + f""" +
  • + Regions: {", ".join(sorted(audited_regions))} +
  • """ + if audited_regions + else "" + ) + + return f""" +
    +
    +
    + StackIT Assessment Summary +
    +
      +
    • + Project ID: {project_id} +
    • + {project_name_item} + {regions_item} +
    +
    +
    +
    +
    +
    + StackIT Credentials +
    +
      +
    • + Authentication Type: Service Account Key +
    • +
    +
    +
    """ + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + return "" + @staticmethod def get_cloudflare_assessment_summary(provider: Provider) -> str: """ diff --git a/prowler/lib/outputs/outputs.py b/prowler/lib/outputs/outputs.py index d0ec605d4b..e4d935e3cf 100644 --- a/prowler/lib/outputs/outputs.py +++ b/prowler/lib/outputs/outputs.py @@ -24,6 +24,8 @@ def stdout_report(finding, color, verbose, status, fix): details = finding.location if finding.check_metadata.Provider == "nhn": details = finding.location + if finding.check_metadata.Provider == "stackit": + details = finding.location if finding.check_metadata.Provider == "llm": details = finding.check_metadata.CheckID if finding.check_metadata.Provider == "iac": diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py index 263c21b1e5..e76728ccc8 100644 --- a/prowler/lib/outputs/summary_table.py +++ b/prowler/lib/outputs/summary_table.py @@ -70,6 +70,13 @@ def display_summary_table( elif provider.type == "nhn": entity_type = "Tenant Domain" audited_entities = provider.identity.tenant_domain + elif provider.type == "stackit": + if provider.identity.project_name: + entity_type = "Project" + audited_entities = provider.identity.project_name + else: + entity_type = "Project ID" + audited_entities = provider.identity.project_id elif provider.type == "iac": if provider.scan_repository_url: entity_type = "Repository" diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index d76c1b6f2a..d11b015d54 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -256,6 +256,25 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) + elif "stackit" in provider_class_name.lower(): + provider_class( + project_id=arguments.stackit_project_id, + service_account_key_path=getattr( + arguments, "stackit_service_account_key_path", None + ), + service_account_key=getattr( + arguments, "stackit_service_account_key", None + ), + regions=( + set(arguments.stackit_region) + if arguments.stackit_region + else None + ), + scan_unused_services=arguments.scan_unused_services, + config_path=arguments.config_file, + mutelist_path=arguments.mutelist_file, + fixer_config=fixer_config, + ) elif "github" in provider_class_name.lower(): orgs = [] repos = [] diff --git a/prowler/providers/stackit/__init__.py b/prowler/providers/stackit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/exceptions/__init__.py b/prowler/providers/stackit/exceptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/exceptions/exceptions.py b/prowler/providers/stackit/exceptions/exceptions.py new file mode 100644 index 0000000000..246abb337c --- /dev/null +++ b/prowler/providers/stackit/exceptions/exceptions.py @@ -0,0 +1,99 @@ +from prowler.exceptions.exceptions import ProwlerException + + +# Exceptions codes from 16000 to 16999 are reserved for StackIT exceptions +class StackITBaseException(ProwlerException): + """Base class for StackIT Errors.""" + + STACKIT_ERROR_CODES = { + (16001, "StackITNonExistentTokenError"): { + "message": "A StackIT service account key file is required to authenticate against StackIT", + "remediation": "Set --stackit-service-account-key-path or the STACKIT_SERVICE_ACCOUNT_KEY_PATH environment variable to a valid service account key JSON file.", + }, + (16002, "StackITInvalidTokenError"): { + "message": "StackIT service account key was rejected or lacks permissions", + "remediation": "Verify the service account key file is current, has not been revoked, and that the service account has the required roles on the project.", + }, + (16003, "StackITSetUpSessionError"): { + "message": "Error setting up StackIT session", + "remediation": "Check the session setup and ensure the StackIT SDK is properly configured.", + }, + (16004, "StackITSetUpIdentityError"): { + "message": "StackIT identity setup error due to bad credentials", + "remediation": "Check credentials and ensure they are properly set up for StackIT.", + }, + (16005, "StackITInvalidProjectIdError"): { + "message": "The provided project ID is not valid or not accessible", + "remediation": "Check the project ID and ensure you have access to it with the provided credentials.", + }, + (16006, "StackITAPIError"): { + "message": "Error calling StackIT API", + "remediation": "Check the API endpoint and ensure the service is accessible. Verify network connectivity.", + }, + } + + def __init__(self, code, file=None, original_exception=None, message=None): + provider = "StackIT" + # Clone the catalog entry so per-instance message overrides do not + # mutate the class-level dict and bleed into later exceptions raised + # in the same process. + base_info = self.STACKIT_ERROR_CODES.get((code, self.__class__.__name__)) + error_info = dict(base_info) if base_info else None + if message and error_info is not None: + error_info["message"] = message + super().__init__( + code=code, + source=provider, + file=file, + original_exception=original_exception, + error_info=error_info, + ) + + +class StackITCredentialsError(StackITBaseException): + """Base class for StackIT credentials errors.""" + + def __init__(self, code, file=None, original_exception=None, message=None): + super().__init__(code, file, original_exception, message) + + +class StackITNonExistentTokenError(StackITCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 16001, file=file, original_exception=original_exception, message=message + ) + + +class StackITInvalidTokenError(StackITCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 16002, file=file, original_exception=original_exception, message=message + ) + + +class StackITSetUpSessionError(StackITCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 16003, file=file, original_exception=original_exception, message=message + ) + + +class StackITSetUpIdentityError(StackITCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 16004, file=file, original_exception=original_exception, message=message + ) + + +class StackITInvalidProjectIdError(StackITCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 16005, file=file, original_exception=original_exception, message=message + ) + + +class StackITAPIError(StackITBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 16006, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/stackit/lib/__init__.py b/prowler/providers/stackit/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/lib/arguments/__init__.py b/prowler/providers/stackit/lib/arguments/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/lib/arguments/arguments.py b/prowler/providers/stackit/lib/arguments/arguments.py new file mode 100644 index 0000000000..afabc41fe8 --- /dev/null +++ b/prowler/providers/stackit/lib/arguments/arguments.py @@ -0,0 +1,60 @@ +from prowler.providers.stackit.stackit_provider import StackitProvider + +SENSITIVE_ARGUMENTS = frozenset({"--stackit-service-account-key"}) + + +def init_parser(self): + """Init the StackIT Provider CLI parser""" + stackit_parser = self.subparsers.add_parser( + "stackit", parents=[self.common_providers_parser], help="StackIT Provider" + ) + + # Authentication + stackit_auth_subparser = stackit_parser.add_argument_group("Authentication") + stackit_auth_subparser.add_argument( + "--stackit-project-id", + nargs="?", + default=None, + help="StackIT Project ID to audit (alternatively set via STACKIT_PROJECT_ID environment variable)", + ) + stackit_auth_subparser.add_argument( + "--stackit-service-account-key-path", + nargs="?", + default=None, + help=( + "Path to a StackIT service account key JSON file. The SDK signs the RSA " + "challenge in the key and mints/refreshes access tokens internally for " + "the life of the scan. Alternatively set via the " + "STACKIT_SERVICE_ACCOUNT_KEY_PATH environment variable." + ), + ) + stackit_auth_subparser.add_argument( + "--stackit-service-account-key", + nargs="?", + default=None, + help=( + "Inline content of a StackIT service account key (JSON). Useful in " + "CI/CD where the secret comes from a secret manager and you do not " + "want to write it to disk. Prefer the STACKIT_SERVICE_ACCOUNT_KEY " + "environment variable over this flag to avoid leaking the key " + "through process listings or shell history." + ), + ) + + stackit_parser.add_argument( + "--stackit-region", + "-r", + nargs="+", + help="STACKIT region(s) to scan (default: all available regions)", + choices=StackitProvider.get_regions(), + default=None, + ) + + scan_unused_services_subparser = stackit_parser.add_argument_group( + "Scan Unused Services" + ) + scan_unused_services_subparser.add_argument( + "--scan-unused-services", + action="store_true", + help="Scan unused services", + ) diff --git a/prowler/providers/stackit/lib/mutelist/__init__.py b/prowler/providers/stackit/lib/mutelist/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/lib/mutelist/mutelist.py b/prowler/providers/stackit/lib/mutelist/mutelist.py new file mode 100644 index 0000000000..5711392849 --- /dev/null +++ b/prowler/providers/stackit/lib/mutelist/mutelist.py @@ -0,0 +1,23 @@ +from prowler.lib.check.models import CheckReportStackIT +from prowler.lib.mutelist.mutelist import Mutelist +from prowler.lib.outputs.utils import unroll_dict, unroll_tags + + +class StackITMutelist(Mutelist): + def is_finding_muted(self, finding: CheckReportStackIT) -> bool: + """ + Determines if a StackIT finding is muted based on mutelist rules. + + Args: + finding: A CheckReportStackIT finding object + + Returns: + bool: True if the finding is muted, False otherwise + """ + return self.is_muted( + finding.project_id, + finding.check_metadata.CheckID, + finding.location, + finding.resource_name, + unroll_dict(unroll_tags(finding.resource_tags)), + ) diff --git a/prowler/providers/stackit/lib/service/__init__.py b/prowler/providers/stackit/lib/service/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/models.py b/prowler/providers/stackit/models.py new file mode 100644 index 0000000000..d069011083 --- /dev/null +++ b/prowler/providers/stackit/models.py @@ -0,0 +1,48 @@ +from pydantic.v1 import BaseModel + +from prowler.config.config import output_file_timestamp +from prowler.providers.common.models import ProviderOutputOptions + + +class StackITIdentityInfo(BaseModel): + """ + StackITIdentityInfo holds basic identity fields for the StackIT provider. + + Attributes: + - project_id (str): The StackIT project ID being audited. + - project_name (str): The name of the StackIT project (fetched from Resource Manager API). + """ + + project_id: str + project_name: str = "" + audited_regions: set = set() + + +class StackITOutputOptions(ProviderOutputOptions): + """ + StackITOutputOptions overrides ProviderOutputOptions for StackIT-specific output logic. + Generates a filename that includes the StackIT project_id. + + Attributes inherited from ProviderOutputOptions: + - output_filename (str): The base filename used for generated reports. + - output_directory (str): The directory to store the output files. + - ... see ProviderOutputOptions for more details. + + Methods: + - __init__: Customizes the output filename logic for StackIT. + """ + + def __init__(self, arguments, bulk_checks_metadata, identity: StackITIdentityInfo): + super().__init__(arguments, bulk_checks_metadata) + + # If --output-filename is not specified, build a default name. + if not getattr(arguments, "output_filename", None): + # If project_id exists, include it in the filename (e.g., prowler-output-stackit--20230101) + if identity.project_id: + self.output_filename = f"prowler-output-stackit-{identity.project_id}-{output_file_timestamp}" + # Otherwise just 'prowler-output-stackit-' + else: + self.output_filename = f"prowler-output-stackit-{output_file_timestamp}" + # If --output-filename was explicitly given, respect that + else: + self.output_filename = arguments.output_filename diff --git a/prowler/providers/stackit/services/__init__.py b/prowler/providers/stackit/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/iaas/__init__.py b/prowler/providers/stackit/services/iaas/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/iaas/iaas_client.py b/prowler/providers/stackit/services/iaas/iaas_client.py new file mode 100644 index 0000000000..eca20c46b4 --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.stackit.services.iaas.iaas_service import IaaSService + +iaas_client = IaaSService(Provider.get_global_provider()) diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/__init__.py b/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.metadata.json b/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.metadata.json new file mode 100644 index 0000000000..a6bcd648bd --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "stackit", + "CheckID": "iaas_security_group_all_traffic_unrestricted", + "CheckTitle": "IaaS security groups do not allow unrestricted access to all ports", + "CheckType": [], + "ServiceName": "iaas", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "Security groups should not allow unrestricted access to all ports from the public internet (`0.0.0.0/0` or `::/0`). This includes rules with no port range specified or rules covering the full port range (`0-65535` or `1-65535`). Allowing all ports **exposes every service** running on the instances to potential attacks.", + "Risk": "Allowing unrestricted access to all ports from the internet exposes **all services and applications** to potential attacks, **unauthorized access**, and security breaches. This effectively **bypasses the security group's purpose** as a firewall.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/network/core-networking/security-groups/", + "https://docs.stackit.cloud/products/network/core-networking/security-groups/how-tos/create-and-manage-security-groups-and-rules/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the StackIT Portal open Networking > Security Groups and select the affected security group. 2. Locate the ingress rule that allows all ports and protocols from 0.0.0.0/0 or ::/0. 3. Delete the broad rule and replace it with granular rules that open only the specific ports each service needs. 4. Restrict the source of every rule to trusted IP ranges following least privilege. 5. Re-run Prowler to confirm the finding is resolved.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Follow the **principle of least privilege** by only allowing the specific ports that are required. Create **granular rules** for each service and restrict access to trusted IP addresses or ranges.", + "Url": "https://hub.prowler.com/check/iaas_security_group_all_traffic_unrestricted" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check identifies security groups that allow all ports from the public internet. It flags rules with no port range specified or rules covering the full port range (0-65535 or 1-65535), regardless of the specific protocol. Security groups should implement specific rules for each required service rather than allowing all ports." +} diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.py b/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.py new file mode 100644 index 0000000000..1b2a7d02f3 --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted.py @@ -0,0 +1,67 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.iaas.iaas_client import iaas_client + + +class iaas_security_group_all_traffic_unrestricted(Check): + """ + Check if IaaS security groups allow unrestricted access to all ports. + + This check verifies that security groups do not allow all ports + from the public internet (0.0.0.0/0 or ::/0). This includes rules + with no port range specified or rules covering the full port range + (0-65535 or 1-65535), regardless of the specific protocol. + """ + + def execute(self): + """ + Execute the check for all security groups in the StackIT project. + + Returns: + list: A list of CheckReportStackIT findings + """ + findings = [] + + for security_group in iaas_client.security_groups: + if not (iaas_client.scan_unused_services or security_group.in_use): + continue + unrestricted_rules = [] + + # Check each ingress rule + for rule in security_group.rules: + # Only check ingress rules that are unrestricted + if rule.is_ingress() and rule.is_unrestricted(): + # Check if rule allows all traffic (no port restrictions or all protocols) + if rule.port_range_min is None or rule.port_range_max is None: + # No port range specified - allows all ports + unrestricted_rules.append( + f"Rule {rule.get_rule_display_name()} allows all ports ({rule.protocol or 'all protocols'}) from {rule.get_ip_range_display()}" + ) + elif ( + rule.port_range_min == 0 or rule.port_range_min == 1 + ) and rule.port_range_max >= 65535: + # Port range covers all or nearly all ports + unrestricted_rules.append( + f"Rule {rule.get_rule_display_name()} allows all ports (1-65535) ({rule.protocol or 'all protocols'}) from {rule.get_ip_range_display()}" + ) + + # Create a finding report for this security group + report = CheckReportStackIT( + metadata=self.metadata(), + resource=security_group, + ) + + if unrestricted_rules: + report.status = "FAIL" + rules_list = "; ".join(unrestricted_rules) + report.status_extended = f"Security group {security_group.name} allows unrestricted access to all traffic: {rules_list}." + else: + report.status = "PASS" + report.status_extended = f"Security group {security_group.name} does not allow unrestricted access to all traffic." + + report.resource_id = security_group.id + report.resource_name = security_group.name + report.location = security_group.region + + findings.append(report) + + return findings diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/__init__.py b/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.metadata.json b/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.metadata.json new file mode 100644 index 0000000000..2337e2e01c --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "stackit", + "CheckID": "iaas_security_group_database_unrestricted", + "CheckTitle": "IaaS security groups do not allow unrestricted database access", + "CheckType": [], + "ServiceName": "iaas", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "Security groups should not allow unrestricted access to database ports from the public internet (`0.0.0.0/0` or `::/0`). This includes MySQL (`3306`), PostgreSQL (`5432`), MongoDB (`27017`), Redis (`6379`), SQL Server (`1433`), and CouchDB (`5984`). Unrestricted database access can lead to **data breaches** and **unauthorized access**.", + "Risk": "Allowing unrestricted database access from the internet exposes sensitive data to potential **breaches**, **unauthorized access**, **data exfiltration**, and malicious attacks. Databases often contain critical business data and should **never** be directly accessible from the public internet.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/network/core-networking/security-groups/", + "https://docs.stackit.cloud/products/network/core-networking/security-groups/how-tos/create-and-manage-security-groups-and-rules/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the StackIT Portal open Networking > Security Groups and select the affected security group. 2. Locate the ingress rules that allow database ports (3306, 5432, 27017, 6379, 1433, 5984) from 0.0.0.0/0 or ::/0. 3. Delete those rules or restrict their source to the specific application servers or trusted IP ranges. 4. Prefer private networking for database connectivity and do not expose database ports to the internet. 5. Re-run Prowler to confirm the finding is resolved.", + "Terraform": "" + }, + "Recommendation": { + "Text": "**Restrict database access** to specific application servers or trusted IP ranges. Use **private networks** for database connectivity and implement additional security layers such as VPNs, private endpoints, or application-level authentication.", + "Url": "https://hub.prowler.com/check/iaas_security_group_database_unrestricted" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check covers common database ports: MySQL (3306), PostgreSQL (5432), MongoDB (27017), Redis (6379), SQL Server (1433), and CouchDB (5984). Databases should always be placed in private networks and accessed through secure channels." +} diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.py b/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.py new file mode 100644 index 0000000000..e777cbb17f --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted.py @@ -0,0 +1,74 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.iaas.iaas_client import iaas_client + +# Database ports to check +DATABASE_PORTS = { + 3306: "MySQL", + 5432: "PostgreSQL", + 27017: "MongoDB", + 6379: "Redis", + 1433: "SQL Server", + 5984: "CouchDB", +} + + +class iaas_security_group_database_unrestricted(Check): + """ + Check if IaaS security groups allow unrestricted database access. + + This check verifies that security groups do not allow database ports + (MySQL, PostgreSQL, MongoDB, Redis, SQL Server, CouchDB) access + from the public internet (0.0.0.0/0 or ::/0). + """ + + def execute(self): + """ + Execute the check for all security groups in the StackIT project. + + Returns: + list: A list of CheckReportStackIT findings + """ + findings = [] + + for security_group in iaas_client.security_groups: + if not (iaas_client.scan_unused_services or security_group.in_use): + continue + exposed_databases = set() + exposing_rule = None + + # Check each ingress rule + for rule in security_group.rules: + # Only check ingress TCP rules that are unrestricted + if rule.is_ingress() and rule.is_tcp() and rule.is_unrestricted(): + # Check if rule allows any database ports + for port, db_name in DATABASE_PORTS.items(): + if rule.includes_port(port): + exposed_databases.add(f"{db_name} (port {port})") + # Track the first exposing rule for the message + if exposed_databases and not exposing_rule: + exposing_rule = rule + + # Create a finding report for this security group + report = CheckReportStackIT( + metadata=self.metadata(), + resource=security_group, + ) + + if exposed_databases: + report.status = "FAIL" + databases_list = ", ".join(sorted(exposed_databases)) + report.status_extended = ( + f"Security group {security_group.name} allows unrestricted database access " + f"to: {databases_list} from {exposing_rule.get_ip_range_display()} via rule {exposing_rule.get_rule_display_name()}." + ) + else: + report.status = "PASS" + report.status_extended = f"Security group {security_group.name} does not allow unrestricted database access." + + report.resource_id = security_group.id + report.resource_name = security_group.name + report.location = security_group.region + + findings.append(report) + + return findings diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/__init__.py b/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.metadata.json b/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.metadata.json new file mode 100644 index 0000000000..e2b7cc527c --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "stackit", + "CheckID": "iaas_security_group_rdp_unrestricted", + "CheckTitle": "IaaS security groups do not allow unrestricted RDP access", + "CheckType": [], + "ServiceName": "iaas", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "Security groups should not allow unrestricted RDP access (port `3389`) from the public internet (`0.0.0.0/0` or `::/0`). Unrestricted RDP access increases the attack surface and can lead to **unauthorized access**, **brute force attacks**, and potential system compromise.", + "Risk": "Allowing unrestricted RDP access from the internet exposes Windows servers to potential **unauthorized access attempts**, **brute force attacks**, **ransomware**, and security breaches. Attackers can exploit weak credentials or vulnerabilities.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/network/core-networking/security-groups/", + "https://docs.stackit.cloud/products/network/core-networking/security-groups/how-tos/create-and-manage-security-groups-and-rules/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the StackIT Portal open Networking > Security Groups and select the affected security group. 2. Locate the ingress rule that allows TCP port 3389 from 0.0.0.0/0 or ::/0. 3. Delete that rule or edit its source to the specific IP ranges that require RDP access. 4. If administration from anywhere is required, expose RDP only through a bastion host or VPN instead of the public internet. 5. Re-run Prowler to confirm the finding is resolved.", + "Terraform": "" + }, + "Recommendation": { + "Text": "**Restrict RDP access** to specific IP addresses or ranges that require administrative access. Use **bastion hosts** or **VPN connections** for secure remote access instead of exposing RDP directly to the internet.", + "Url": "https://hub.prowler.com/check/iaas_security_group_rdp_unrestricted" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check focuses on ingress rules that allow RDP (port 3389) from 0.0.0.0/0 or ::/0. Consider implementing network-level authentication and regular security audits." +} diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.py b/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.py new file mode 100644 index 0000000000..839fdfabe3 --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted.py @@ -0,0 +1,51 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.iaas.iaas_client import iaas_client + + +class iaas_security_group_rdp_unrestricted(Check): + """ + Check if IaaS security groups allow unrestricted RDP access. + + This check verifies that security groups do not allow RDP (port 3389) + access from the public internet (0.0.0.0/0 or ::/0). + """ + + def execute(self): + """ + Execute the check for all security groups in the StackIT project. + + Returns: + list: A list of CheckReportStackIT findings + """ + findings = [] + + for security_group in iaas_client.security_groups: + if not (iaas_client.scan_unused_services or security_group.in_use): + continue + report = CheckReportStackIT( + metadata=self.metadata(), + resource=security_group, + ) + report.status = "PASS" + report.status_extended = f"Security group {security_group.name} does not allow unrestricted RDP access." + report.resource_id = security_group.id + report.resource_name = security_group.name + report.location = security_group.region + + for rule in security_group.rules: + if ( + rule.is_ingress() + and rule.is_tcp() + and rule.is_unrestricted() + and rule.includes_port(3389) + ): + report.status = "FAIL" + report.status_extended = ( + f"Security group {security_group.name} allows unrestricted RDP access (port 3389) " + f"from {rule.get_ip_range_display()} via rule {rule.get_rule_display_name()}." + ) + break + + findings.append(report) + + return findings diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/__init__.py b/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.metadata.json b/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.metadata.json new file mode 100644 index 0000000000..df18970ddf --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "stackit", + "CheckID": "iaas_security_group_ssh_unrestricted", + "CheckTitle": "IaaS security groups do not allow unrestricted SSH access", + "CheckType": [], + "ServiceName": "iaas", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "Security groups should not allow unrestricted SSH access (port `22`) from the public internet (`0.0.0.0/0` or `::/0`). Unrestricted SSH access increases the attack surface and can lead to **unauthorized access**, **brute force attacks**, and potential system compromise.", + "Risk": "Allowing unrestricted SSH access from the internet exposes servers to potential **unauthorized access attempts**, **brute force attacks**, and security breaches. Attackers can scan for and exploit weak credentials or vulnerabilities.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/network/core-networking/security-groups/", + "https://docs.stackit.cloud/products/network/core-networking/security-groups/how-tos/create-and-manage-security-groups-and-rules/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the StackIT Portal open Networking > Security Groups and select the affected security group. 2. Locate the ingress rule that allows TCP port 22 from 0.0.0.0/0 or ::/0. 3. Delete that rule or edit its source to the specific IP ranges that require SSH access. 4. If administration from anywhere is required, expose SSH only through a bastion host or VPN instead of the public internet. 5. Re-run Prowler to confirm the finding is resolved.", + "Terraform": "" + }, + "Recommendation": { + "Text": "**Restrict SSH access** to specific IP addresses or ranges that require administrative access. Use **bastion hosts** or **VPN connections** for secure remote access instead of exposing SSH directly to the internet.", + "Url": "https://hub.prowler.com/check/iaas_security_group_ssh_unrestricted" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check focuses on ingress rules that allow SSH (port 22) from 0.0.0.0/0 or ::/0. Consider implementing additional controls such as multi-factor authentication and regular security audits." +} diff --git a/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.py b/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.py new file mode 100644 index 0000000000..27b9265c15 --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted.py @@ -0,0 +1,51 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.iaas.iaas_client import iaas_client + + +class iaas_security_group_ssh_unrestricted(Check): + """ + Check if IaaS security groups allow unrestricted SSH access. + + This check verifies that security groups do not allow SSH (port 22) + access from the public internet (0.0.0.0/0 or ::/0). + """ + + def execute(self): + """ + Execute the check for all security groups in the StackIT project. + + Returns: + list: A list of CheckReportStackIT findings + """ + findings = [] + + for security_group in iaas_client.security_groups: + if not (iaas_client.scan_unused_services or security_group.in_use): + continue + report = CheckReportStackIT( + metadata=self.metadata(), + resource=security_group, + ) + report.status = "PASS" + report.status_extended = f"Security group {security_group.name} does not allow unrestricted SSH access." + report.resource_id = security_group.id + report.resource_name = security_group.name + report.location = security_group.region + + for rule in security_group.rules: + if ( + rule.is_ingress() + and rule.is_tcp() + and rule.is_unrestricted() + and rule.includes_port(22) + ): + report.status = "FAIL" + report.status_extended = ( + f"Security group {security_group.name} allows unrestricted SSH access (port 22) " + f"from {rule.get_ip_range_display()} via rule {rule.get_rule_display_name()}." + ) + break + + findings.append(report) + + return findings diff --git a/prowler/providers/stackit/services/iaas/iaas_service.py b/prowler/providers/stackit/services/iaas/iaas_service.py new file mode 100644 index 0000000000..2cea664241 --- /dev/null +++ b/prowler/providers/stackit/services/iaas/iaas_service.py @@ -0,0 +1,477 @@ +from typing import Optional + +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.stackit.stackit_provider import StackitProvider, suppress_stderr + + +class IaaSService: + """ + StackIT IaaS Service class to handle security group operations. + + This service uses the StackIT Python SDK to access IaaS resources. + Authentication is delegated to the SDK, which signs the RSA challenge + in the configured service account key and refreshes access tokens + internally for the life of the scan. + """ + + def __init__(self, provider: StackitProvider): + """ + Initialize the IaaS service. + + Args: + provider: The StackIT provider instance + """ + self.provider = provider + self.project_id = provider.identity.project_id + self.service_account_key_path = provider.session.get("service_account_key_path") + self.scan_unused_services = provider.scan_unused_services + + # Generate regional clients (AWS pattern) + self.regional_clients = provider.generate_regional_clients("iaas") + self.audited_regions = provider.identity.audited_regions + + # Initialize security groups list + self.security_groups: list[SecurityGroup] = [] + + # Initialize server NICs list and used security group IDs + self.server_nics: list = [] + self.in_use_sg_ids: set[str] = set() + + # Fetch resources from all regions + self._fetch_all_regions() + self._log_skipped_security_groups() + + def _log_skipped_security_groups(self): + """Explain an empty report when every security group is skipped. + + Following the same convention as the rest of Prowler, security group + checks only evaluate groups that are in use (attached to a network + interface) unless ``--scan-unused-services`` is set. When a project + has security groups but none are attached, every check returns no + finding, which looks like "nothing was scanned". Emit an explicit + hint so the empty report is not mistaken for a failure. + """ + if ( + not self.scan_unused_services + and self.security_groups + and not any(sg.in_use for sg in self.security_groups) + ): + logger.info( + f"{len(self.security_groups)} StackIT security group(s) were " + f"found but none are attached to a network interface, so all " + f"of them are skipped and no finding is produced. Re-run with " + f"--scan-unused-services to audit security groups that are " + f"not currently in use." + ) + + def _fetch_all_regions(self): + """Fetch resources from all audited regions. + + A project is not necessarily provisioned in every StackIT region. A + region where the project does not exist answers the IaaS endpoints + with HTTP 404 (``resource not found: project``). That is expected, so + the region is skipped and the scan continues with the remaining + regions instead of aborting (which previously left every check + failing to load and produced an empty, misleading report). + + Credential and permission failures (401/403) still propagate via + ``handle_api_error`` so a misconfigured account fails loudly. + """ + for region, client in self.regional_clients.items(): + try: + self._list_server_nics(client, region) + self._list_security_groups(client, region) + except Exception as error: + if getattr(error, "status", None) == 404: + logger.info( + f"StackIT project {self.project_id} has no IaaS " + f"presence in region {region} (404 resource not " + f"found); skipping this region." + ) + continue + raise + + @staticmethod + def _extract_items(response, endpoint_name: str) -> list: + """Extract the items list from a StackIT SDK response. + + Handles three response shapes safely: + - SDK model exposing an ``items`` attribute (not the ``dict.items`` method) + - Raw ``dict`` with an ``"items"`` key + - Plain ``list`` + + ``isinstance(response, dict)`` is checked first because ``dict`` has an + ``items`` *method*; ``hasattr(response, "items")`` is otherwise True for + plain dicts and silently returns the bound method. + """ + if isinstance(response, dict): + return response.get("items", []) + if isinstance(response, list): + return response + items_attr = getattr(response, "items", None) + if items_attr is not None and not callable(items_attr): + return items_attr + logger.warning( + f"Unexpected response type from {endpoint_name}: {type(response)}" + ) + return [] + + def _handle_api_call(self, api_function, *args, **kwargs): + """ + Centralized API call handler with authentication error detection. + + Args: + api_function: The API function to call + *args: Positional arguments to pass to the API function + **kwargs: Keyword arguments to pass to the API function + + Returns: + The API response + + Raises: + StackITInvalidTokenError: If authentication fails (401) + """ + try: + # Suppress StackIT SDK stderr messages during API calls + with suppress_stderr(): + return api_function(*args, **kwargs) + except Exception as e: + # Use centralized error handler from provider + self.provider.handle_api_error(e) + raise + + def _list_security_groups(self, client, region: str): + """ + List all security groups in the StackIT project and fetch their rules. + + This method populates the self.security_groups list with SecurityGroup + objects containing information about each security group and its rules. + """ + if not client: + logger.warning( + f"Cannot list security groups in {region}: StackIT IaaS client not available" + ) + return + + # Call the list security groups API with centralized error handling + response = self._handle_api_call( + client.list_security_groups, project_id=self.project_id, region=region + ) + + # Extract security groups from response + security_groups_list = self._extract_items(response, "list_security_groups") + + # Process each security group + for sg_data in security_groups_list: + try: + # Extract security group information + if hasattr(sg_data, "id"): + sg_id = sg_data.id + sg_name = getattr(sg_data, "name", sg_id) + elif isinstance(sg_data, dict): + sg_id = sg_data.get("id", "") + sg_name = sg_data.get("name", sg_id) + else: + logger.warning( + f"Unexpected security group data type: {type(sg_data)}" + ) + continue + + except Exception as e: + logger.error(f"Error processing security group: {e}") + continue + + # Get security group rules after local parsing succeeds so API errors + # from the rules endpoint propagate instead of being downgraded. + rules = self._list_security_group_rules(client, region, sg_id) + + security_group = SecurityGroup( + id=sg_id, + name=sg_name, + project_id=self.project_id, + region=region, + rules=rules, + # in_use_sg_ids is normalized to str; the SDK returns the NIC + # security group references as uuid.UUID while the security + # group id is a str, so compare on the string form. + in_use=str(sg_id) in self.in_use_sg_ids, + ) + self.security_groups.append(security_group) + + logger.info( + f"Successfully listed {len(security_groups_list)} security groups in {region}" + ) + + def _list_security_group_rules( + self, client, region: str, security_group_id: str + ) -> list["SecurityGroupRule"]: + """ + List all rules for a specific security group. + + Args: + client: The StackIT IaaS client + region: The region of the security group + security_group_id: The ID of the security group + + Returns: + list: List of SecurityGroupRule objects + """ + rules = [] + # Get security group rules via SDK + response = self._handle_api_call( + client.list_security_group_rules, + project_id=self.project_id, + region=region, + security_group_id=security_group_id, + ) + + # Extract rules from response + rules_list = self._extract_items(response, "list_security_group_rules") + + # Process each rule + for rule_data in rules_list: + try: + if hasattr(rule_data, "id"): + # Extract protocol name from Protocol object + protocol_obj = getattr(rule_data, "protocol", None) + protocol_name = None + if protocol_obj and hasattr(protocol_obj, "name"): + protocol_name = protocol_obj.name + + # Extract port range from PortRange object + port_range_obj = getattr(rule_data, "port_range", None) + port_min = None + port_max = None + if port_range_obj: + if hasattr(port_range_obj, "min"): + port_min = port_range_obj.min + if hasattr(port_range_obj, "max"): + port_max = port_range_obj.max + + rule = SecurityGroupRule( + id=getattr(rule_data, "id", ""), + direction=getattr(rule_data, "direction", ""), + protocol=protocol_name, + ip_range=getattr(rule_data, "ip_range", None), + port_range_min=port_min, + port_range_max=port_max, + description=getattr(rule_data, "description", None), + remote_security_group_id=getattr( + rule_data, "remote_security_group_id", None + ), + ) + elif isinstance(rule_data, dict): + # Handle dict response (if API returns dict instead of objects) + protocol_data = rule_data.get("protocol") + protocol_name = None + if isinstance(protocol_data, dict): + protocol_name = protocol_data.get("name") + elif isinstance(protocol_data, str): + protocol_name = protocol_data + + port_range_data = rule_data.get("port_range") + port_min = None + port_max = None + if isinstance(port_range_data, dict): + port_min = port_range_data.get("min") + port_max = port_range_data.get("max") + + rule = SecurityGroupRule( + id=rule_data.get("id", ""), + direction=rule_data.get("direction", ""), + protocol=protocol_name, + ip_range=rule_data.get("ip_range"), + port_range_min=port_min, + port_range_max=port_max, + description=rule_data.get("description"), + remote_security_group_id=rule_data.get( + "remote_security_group_id" + ), + ) + else: + continue + + rules.append(rule) + logger.debug( + f"Parsed rule: id={rule.id}, direction={rule.direction}, " + f"protocol={rule.protocol}, ip_range={rule.ip_range}, " + f"ports={rule.port_range_min}-{rule.port_range_max}, " + f"remote_sg={rule.remote_security_group_id}" + ) + + except Exception as e: + logger.debug(f"Error processing rule: {e}") + continue + + return rules + + def _list_server_nics(self, client, region: str): + """ + List all server network interfaces (NICs) in the StackIT project. + + This method fetches all NICs and determines which security groups are + actively in use by checking which security groups are attached to any NIC. + """ + if not client: + logger.warning( + f"Cannot list server NICs in {region}: StackIT IaaS client not available" + ) + return + + # Call the list project NICs API with centralized error handling + response = self._handle_api_call( + client.list_project_nics, project_id=self.project_id, region=region + ) + + # Extract NICs from response + nics_list = self._extract_items(response, "list_project_nics") + + self.server_nics.extend(nics_list) + + # A security group is "in use" when attached to any NIC + used_sg_ids = self._get_used_security_group_ids(nics_list) + self.in_use_sg_ids.update(used_sg_ids) + + logger.info( + f"Successfully listed {len(nics_list)} NICs in {region}. " + f"Found {len(used_sg_ids)} security groups attached to NICs." + ) + + def _get_used_security_group_ids(self, nics_list) -> set[str]: + """ + Get the set of security group IDs that are actively attached to any NIC. + + Returns: + set[str]: Set of security group IDs that are attached to at least one NIC + """ + used_sg_ids = set() + + for nic in nics_list: + try: + # Extract security groups from NIC. The SDK model exposes them + # as ``security_groups``; a raw dict uses the camelCase + # ``securityGroups`` key (falling back to snake_case). + if hasattr(nic, "security_groups"): + sg_list = nic.security_groups + elif isinstance(nic, dict): + sg_list = nic.get("securityGroups", nic.get("security_groups", [])) + else: + continue + + if sg_list: + for sg_id in sg_list: + if sg_id: + # The SDK returns these references as uuid.UUID + # while the security group id is a str; normalize + # to str so the membership test in + # _list_security_groups matches. + used_sg_ids.add(str(sg_id)) + + except Exception as e: + logger.debug(f"Error extracting security groups from NIC: {e}") + continue + + return used_sg_ids + + +class SecurityGroupRule(BaseModel): + """ + Represents a Security Group Rule. + + Attributes: + id: The unique identifier of the rule + direction: The direction of the rule (ingress/egress) + protocol: The protocol (tcp/udp/icmp/all) - can be None for some rules + ip_range: The IP range (CIDR notation) - can be None for some rules + port_range_min: The minimum port number + port_range_max: The maximum port number + description: The user-defined description/name of the rule (optional) + remote_security_group_id: The ID of a security group to allow traffic from (optional) + """ + + id: str + direction: str + protocol: Optional[str] = None + ip_range: Optional[str] = None + port_range_min: Optional[int] = None + port_range_max: Optional[int] = None + description: Optional[str] = None + remote_security_group_id: Optional[str] = None + + def is_unrestricted(self) -> bool: + """Check if the rule allows access from anywhere (0.0.0.0/0, ::/0, or None for unrestricted).""" + # If remote_security_group_id is set, the rule only allows traffic from that security group + # This is NOT unrestricted access - it's restricted to instances in the same security group + if self.remote_security_group_id is not None: + return False + + # None means no IP restriction (allows all sources) - this is unrestricted! + if self.ip_range is None: + return True + # Explicit unrestricted ranges + return self.ip_range in ["0.0.0.0/0", "::/0"] + + def is_ingress(self) -> bool: + """Check if the rule is an ingress rule.""" + if not self.direction: + return False + return self.direction.lower() == "ingress" + + def is_tcp(self) -> bool: + """Check if the rule is TCP protocol.""" + # None means all protocols (including TCP) - treat as TCP-applicable + if self.protocol is None: + return True + return self.protocol.lower() in ["tcp", "all"] + + def includes_port(self, port: int) -> bool: + """Check if the rule includes a specific port.""" + if self.port_range_min is None or self.port_range_max is None: + # If no port range specified, rule applies to all ports + return True + return self.port_range_min <= port <= self.port_range_max + + def get_ip_range_display(self) -> str: + """ + Get a user-friendly display string for the IP range. + + Returns: + str: Human-readable IP range description + """ + if self.ip_range is None: + return "anywhere (0.0.0.0/0, ::/0)" + return self.ip_range + + def get_rule_display_name(self) -> str: + """ + Get a user-friendly display name for the rule. + + Returns: + str: Rule description if available, otherwise rule ID + """ + if self.description: + return f"{self.description} ({self.id})" + return f"{self.id}" + + +class SecurityGroup(BaseModel): + """ + Represents a StackIT IaaS Security Group. + + Attributes: + id: The unique identifier of the security group + name: The name of the security group + project_id: The StackIT project ID containing the security group + region: The region where the security group is located + rules: List of security group rules + in_use: Whether the security group is actively attached to any resources + """ + + id: str + name: str + project_id: str + region: str + rules: list[SecurityGroupRule] = [] + in_use: bool = False diff --git a/prowler/providers/stackit/stackit_provider.py b/prowler/providers/stackit/stackit_provider.py new file mode 100644 index 0000000000..417555ef1d --- /dev/null +++ b/prowler/providers/stackit/stackit_provider.py @@ -0,0 +1,617 @@ +import contextlib +import io +import os +import pathlib +from typing import Optional +from uuid import UUID + +from colorama import Style + +# The StackIT SDK is a hard dependency of the provider (declared in +# pyproject.toml). Import it at module level, like every other Prowler +# provider, so a missing SDK fails immediately when the provider module is +# imported and is reported by Provider.init_global_provider as a critical +# error and a non-zero exit, instead of being swallowed later by the check +# loader and surfacing as a misleading empty report. +from stackit.core.configuration import Configuration +from stackit.iaas import DefaultApi as IaasDefaultApi +from stackit.resourcemanager import DefaultApi as ResourceManagerDefaultApi + +from prowler.config.config import ( + default_config_file_path, + get_default_mute_file_path, + load_and_validate_config_file, +) +from prowler.lib.logger import logger +from prowler.lib.utils.utils import open_file, parse_json_file, print_boxes +from prowler.providers.common.models import Audit_Metadata, Connection +from prowler.providers.common.provider import Provider +from prowler.providers.stackit.exceptions.exceptions import ( + StackITAPIError, + StackITInvalidProjectIdError, + StackITInvalidTokenError, + StackITNonExistentTokenError, + StackITSetUpIdentityError, + StackITSetUpSessionError, +) +from prowler.providers.stackit.lib.mutelist.mutelist import StackITMutelist +from prowler.providers.stackit.models import StackITIdentityInfo + +STACKIT_REGIONS_JSON_FILE = "stackit_regions_by_service.json" + + +@contextlib.contextmanager +def suppress_stderr(): + with contextlib.redirect_stderr(io.StringIO()): + yield + + +class StackitProvider(Provider): + """ + StackIT Provider class to handle the StackIT provider + + Attributes: + - _type: str -> The type of the provider, which is set to "stackit". + - _project_id: str -> The StackIT project ID to audit. + - _service_account_key_path: str -> Path to a StackIT service account key + JSON file. The SDK mints and refreshes access tokens internally. + - _service_account_key: str -> Inline JSON content of a StackIT service + account key, for secret-manager driven deployments that do not write + the key to disk. Takes precedence over the key path when both are set. + - _identity: StackITIdentityInfo -> The identity information for the StackIT provider. + - _audit_config: dict -> The audit configuration for the StackIT provider. + - _mutelist: StackITMutelist -> The mutelist object associated with the StackIT provider. + - audit_metadata: Audit_Metadata -> The audit metadata for the StackIT provider. + + Methods: + - __init__: Initializes the StackIT provider. + - type: Returns the type of the StackIT provider. + - identity: Returns the identity of the StackIT provider (ex: project_id). + - session: Returns the session/configuration for API calls. + - audit_config: Returns the audit configuration for the StackIT provider. + - fixer_config: Returns the fixer configuration. + - mutelist: Returns the mutelist object associated with the StackIT provider. + - validate_arguments: Validates the StackIT provider arguments (key path, project_id). + - print_credentials: Prints the StackIT credentials information (ex: project_id). + - setup_session: Set up the StackIT session with the specified authentication method. + - test_connection: Tests the provider connection. + """ + + _type: str = "stackit" + _project_id: Optional[str] + _service_account_key_path: Optional[str] + _service_account_key: Optional[str] + _session: Optional[dict] + _identity: StackITIdentityInfo + _audit_config: dict + _mutelist: StackITMutelist + _scan_unused_services: bool = False + audit_metadata: Audit_Metadata + + def __init__( + self, + project_id: str = None, + service_account_key_path: str = None, + service_account_key: str = None, + regions: set = None, + scan_unused_services: bool = False, + config_path: str = None, + fixer_config: dict = None, + mutelist_path: str = None, + mutelist_content: dict = None, + ): + """ + Initializes the StackIT provider. + + Args: + - project_id: The StackIT project ID to audit. + - service_account_key_path: Path to a StackIT service account key + JSON file. The SDK mints and refreshes access tokens internally + from this key. Read from ``STACKIT_SERVICE_ACCOUNT_KEY_PATH`` + when not provided. + - service_account_key: Inline JSON content of a StackIT service + account key, intended for CI/CD where the secret is fetched + from a secret manager and not persisted to disk. Read from + ``STACKIT_SERVICE_ACCOUNT_KEY`` when not provided. Takes + precedence over ``service_account_key_path`` when both are set. + - regions: The list of regions to audit. + - config_path: The path to the configuration file. + - fixer_config: The fixer configuration. + - mutelist_path: The path to the mutelist file. + - mutelist_content: The mutelist content. + """ + logger.info("Initializing StackIT Provider...") + + # 1) Store argument values + self._project_id = project_id or os.getenv("STACKIT_PROJECT_ID") + self._service_account_key_path = service_account_key_path or os.getenv( + "STACKIT_SERVICE_ACCOUNT_KEY_PATH" + ) + self._service_account_key = service_account_key or os.getenv( + "STACKIT_SERVICE_ACCOUNT_KEY" + ) + self._audited_regions = regions if regions else self.get_regions() + self._scan_unused_services = scan_unused_services + + # 2) Validate credentials format (following Azure's validation pattern) + try: + self.validate_arguments( + self._project_id, + self._service_account_key_path, + self._service_account_key, + ) + except StackITNonExistentTokenError: + logger.critical( + "StackIT service account credentials are required. Provide the " + "key file path via --stackit-service-account-key-path / " + "STACKIT_SERVICE_ACCOUNT_KEY_PATH, or the key content via " + "--stackit-service-account-key / STACKIT_SERVICE_ACCOUNT_KEY." + ) + raise + except StackITInvalidProjectIdError: + logger.critical( + "StackIT project ID must be a valid UUID. Provide it via --stackit-project-id or STACKIT_PROJECT_ID environment variable." + ) + raise + + # 3) Load audit_config, fixer_config, mutelist + self._fixer_config = fixer_config if fixer_config else {} + if not config_path: + config_path = default_config_file_path + self._audit_config = load_and_validate_config_file(self._type, config_path) + + if mutelist_content: + self._mutelist = StackITMutelist(mutelist_content=mutelist_content) + else: + if not mutelist_path: + mutelist_path = get_default_mute_file_path(self._type) + self._mutelist = StackITMutelist(mutelist_path=mutelist_path) + + # 4) Initialize session configuration + self._session = None + try: + self.setup_session() + except Exception as e: + logger.critical(f"Error setting up StackIT session: {e}") + raise StackITSetUpSessionError( + original_exception=e, + message=f"Failed to set up StackIT session: {str(e)}", + ) + + # 5) Create StackITIdentityInfo object and fetch project name + try: + project_name = self._get_project_name() + self._identity = StackITIdentityInfo( + project_id=self._project_id, + project_name=project_name, + audited_regions=self._audited_regions, + ) + except StackITInvalidTokenError: + # Re-raise authentication errors without wrapping to avoid verbose output + raise + except Exception as e: + logger.critical(f"Error setting up StackIT identity: {e}") + raise StackITSetUpIdentityError( + original_exception=e, + message=f"Failed to set up StackIT identity: {str(e)}", + ) + + # 6) Register as global provider + Provider.set_global_provider(self) + + @staticmethod + def read_stackit_regions_file() -> dict: + """Read the STACKIT regions JSON file.""" + actual_directory = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) + with open_file(f"{actual_directory}/{STACKIT_REGIONS_JSON_FILE}") as f: + return parse_json_file(f) + + @staticmethod + def get_regions() -> set: + """Get all available STACKIT regions from the JSON file.""" + regions = set() + data = StackitProvider.read_stackit_regions_file() + for service in data["services"].values(): + regions.update(service["regions"]) + return regions + + @staticmethod + def get_available_service_regions(service: str, audited_regions: set = None) -> set: + """Get available regions for a specific service, filtered by audited_regions.""" + data = StackitProvider.read_stackit_regions_file() + json_regions = set(data["services"].get(service, {}).get("regions", [])) + if audited_regions: + return json_regions.intersection(audited_regions) + return json_regions + + def generate_regional_clients(self, service: str = "iaas") -> dict: + """Generate regional API clients for the given service. + + Returns dict: {"eu01": DefaultApi_client, "eu02": DefaultApi_client} + """ + regional_clients = {} + service_regions = self.get_available_service_regions( + service, self._audited_regions + ) + + for region in service_regions: + with suppress_stderr(): + config = self._build_sdk_configuration( + self._service_account_key_path, + self._service_account_key, + ) + client = IaasDefaultApi(config) + client.region = region # Attach region attribute + regional_clients[region] = client + + return regional_clients + + @staticmethod + def _build_sdk_configuration( + service_account_key_path: str, service_account_key: str = None + ): + """Build a ``stackit.core.configuration.Configuration`` from the + configured service account credentials. + + Prefer the inlined key content over the path so secret-manager + deployments (where the path may also be set as a default) work + without writing the secret to disk. In both cases the SDK signs + the RSA challenge and refreshes access tokens internally for the + life of the scan. + + Kept as a static helper so ``test_connection`` (which has no provider + instance) can reuse it. + """ + if service_account_key: + return Configuration(service_account_key=service_account_key) + return Configuration(service_account_key_path=service_account_key_path) + + @property + def type(self) -> str: + """ + Returns the type of the provider ("stackit"). + """ + return self._type + + @property + def identity(self) -> StackITIdentityInfo: + """ + Returns the StackITIdentityInfo object, which contains project_id, etc. + """ + return self._identity + + @property + def session(self) -> dict: + """ + Returns the session configuration for StackIT API calls. + This includes the API token and project ID needed for SDK initialization. + """ + return self._session + + @property + def audit_config(self) -> dict: + """ + Returns the audit configuration loaded from file or default settings. + """ + return self._audit_config + + @property + def fixer_config(self) -> dict: + """ + Returns any fixer configuration provided to the StackIT provider. + """ + return self._fixer_config + + @property + def scan_unused_services(self) -> bool: + return self._scan_unused_services + + @property + def mutelist(self) -> StackITMutelist: + """ + Returns the StackITMutelist object for handling any muted checks. + """ + return self._mutelist + + @staticmethod + def validate_arguments( + project_id: str, + service_account_key_path: str, + service_account_key: str = None, + ) -> None: + """ + Validates StackIT static arguments format before use. + + Either the service account key file path or the inline key content + must be supplied, and the project ID is always required and must be + a valid UUID. This mirrors Azure's pattern of failing fast on input + format issues before making any API calls. + + Args: + project_id: The StackIT project ID (must be valid UUID format) + service_account_key_path: Path to a service account key JSON file + (optional when ``service_account_key`` is provided) + service_account_key: Inline JSON content of a service account key + (optional when ``service_account_key_path`` is provided) + + Raises: + StackITNonExistentTokenError: If both ``service_account_key_path`` + and ``service_account_key`` are missing or empty + StackITInvalidProjectIdError: If ``project_id`` is missing or not a + valid UUID + """ + has_path = bool(service_account_key_path and service_account_key_path.strip()) + has_key = bool(service_account_key and service_account_key.strip()) + if not has_path and not has_key: + raise StackITNonExistentTokenError( + message=( + "StackIT service account credentials are required: provide " + "the key file path (--stackit-service-account-key-path or " + "STACKIT_SERVICE_ACCOUNT_KEY_PATH) or the inline key content " + "(--stackit-service-account-key or STACKIT_SERVICE_ACCOUNT_KEY)" + ) + ) + + # Validate project_id is not empty + if not project_id or not project_id.strip(): + raise StackITInvalidProjectIdError( + message="StackIT project ID is required for auditing" + ) + + # Validate project_id is a valid UUID format + # StackIT uses UUIDs for project IDs, similar to Azure subscription IDs + try: + UUID(project_id) + except ValueError as e: + raise StackITInvalidProjectIdError( + original_exception=e, + message=f"StackIT project ID must be a valid UUID format, got: {project_id}", + ) + + @property + def auth_method(self) -> str: + """Auth method label used for findings and credentials box. + + StackIT authenticates with a service account key; the SDK signs + the RSA challenge and refreshes access tokens internally. + """ + return "service_account_key" + + def print_credentials(self) -> None: + """ + Prints the StackIT credentials in a simple box format. + """ + # Build credential lines + lines = [] + if self._identity.project_name: + lines.append(f" Project Name: {self._identity.project_name}") + lines.append(f" Project ID: {self._project_id}") + if self._service_account_key: + lines.append(" Service Account Key: ***REDACTED*** (inline)") + else: + lines.append(f" Service Account Key: {self._service_account_key_path}") + lines.append(" Auth Method: service account key (auto-refresh)") + + report_lines = ["\n".join(lines)] + + report_title = ( + f"{Style.BRIGHT}Using the StackIT credentials below:{Style.RESET_ALL}" + ) + print_boxes(report_lines, report_title) + + def setup_session(self) -> None: + """ + Set up the StackIT session configuration. + + This creates a session dictionary containing the credentials + used by service clients to build SDK ``Configuration`` objects. + """ + try: + self._session = { + "project_id": self._project_id, + "service_account_key_path": self._service_account_key_path, + "service_account_key": self._service_account_key, + } + logger.info("StackIT session configuration set up successfully.") + except Exception as e: + logger.critical(f"Error in setup_session: {e}") + raise e + + def _get_project_name(self) -> str: + """ + Fetch the project name from the StackIT Resource Manager API. + + The project name is cosmetic (shown in the credentials box and in + the ``account_name`` field of findings); a missing or unauthorized + Resource Manager endpoint must not abort an otherwise valid IaaS + scan. A service account can legitimately hold IaaS roles on a + project without holding Resource Manager roles on it. + + Failure semantics: + - HTTP 401 -> hard failure via :class:`StackITInvalidTokenError` + (the credentials cannot mint a usable token). + - HTTP 403 (or any other non-401 error) -> warning, returns + an empty project name; the scan continues and per-service + ``handle_api_error`` will abort later if the IaaS endpoints + are also forbidden. + + Returns: + str: The project name, or empty string if unavailable. + + Raises: + StackITInvalidTokenError: If the credentials are rejected with a + 401 response when contacting Resource Manager. + """ + try: + with suppress_stderr(): + config = self._build_sdk_configuration( + self._service_account_key_path, + self._service_account_key, + ) + client = ResourceManagerDefaultApi(config) + + # Fetch project details - validates that the credentials + # can mint a token; permission to read this specific + # project is checked but optional. + response = client.get_project(id=self._project_id) + + # Extract project name from response + if hasattr(response, "name"): + project_name = response.name + elif isinstance(response, dict): + project_name = response.get("name", "") + else: + project_name = "" + + logger.info(f"Successfully retrieved project name: {project_name}") + return project_name + + except Exception as e: + status = getattr(e, "status", None) + if status == 401: + # Bad credentials are a hard failure even at the cosmetic + # Resource Manager lookup; keep the same behaviour as + # ``handle_api_error`` so the user sees the same message. + logger.critical( + "StackIT service account key was rejected by Resource " + "Manager (401). Verify the key file is current and has " + "not been revoked in the StackIT portal." + ) + raise StackITInvalidTokenError( + file="stackit_provider.py", + original_exception=None, + message="StackIT service account key was rejected (401)", + ) + if status == 403: + logger.warning( + f"StackIT service account lacks the Resource Manager " + f"role on project {self._project_id} (403). The project " + f"name will not be displayed in reports, but IaaS " + f"checks will still run if the service account has the " + f"relevant IaaS role." + ) + return "" + logger.warning( + f"Unable to fetch project name from StackIT API: {e}. " + f"Project name will not be displayed in reports." + ) + return "" + + @staticmethod + def handle_api_error(exception: Exception) -> None: + """ + Centralized handler for StackIT API errors across all services. + + Detects credential and permission errors (HTTP 401 and 403) and raises + ``StackITInvalidTokenError`` so the scan aborts instead of continuing + with partial data. All other exceptions are re-raised unchanged so + callers can decide how to handle them (e.g. per-resource ``continue``). + + Args: + exception: The exception caught from a StackIT API call + + Raises: + StackITInvalidTokenError: If the error is a 401 Unauthorized or + a 403 Forbidden response + Exception: Re-raises the original exception otherwise + """ + status = getattr(exception, "status", None) + if status == 401: + logger.critical( + "StackIT service account key was rejected. Verify the key " + "file referenced by STACKIT_SERVICE_ACCOUNT_KEY_PATH is the " + "current one and has not been revoked in the StackIT portal." + ) + raise StackITInvalidTokenError( + file="stackit_provider.py", + original_exception=None, # Don't include verbose HTTP details + message="StackIT service account key was rejected (401)", + ) + if status == 403: + logger.critical( + "StackIT service account lacks the required permissions on this project. " + "Ensure the service account has the necessary IAM roles." + ) + raise StackITInvalidTokenError( + file="stackit_provider.py", + original_exception=None, # Don't include verbose HTTP details + message="Service account lacks required permissions on this project", + ) + # Re-raise other exceptions unchanged + raise exception + + @staticmethod + def test_connection( + project_id: str = None, + service_account_key_path: str = None, + service_account_key: str = None, + raise_on_exception: bool = True, + ) -> Connection: + """ + Test connection to StackIT by validating credentials. + + This method validates the service account credentials and project ID + by making a Resource Manager ``get_project`` call. Pass either the + key file path or the inline key content; the SDK signs the RSA + challenge and mints a short-lived access token internally. + + Args: + project_id (str): StackIT project ID + service_account_key_path (str): Path to a StackIT service account + key JSON file (optional when ``service_account_key`` is given) + service_account_key (str): Inline JSON content of a StackIT + service account key (optional when + ``service_account_key_path`` is given) + raise_on_exception (bool): If True, raise the caught exception; + if False, return Connection(error=exception). + + Returns: + Connection: + Connection(is_connected=True) if success, + otherwise Connection(error=Exception or custom error). + """ + try: + StackitProvider.validate_arguments( + project_id, service_account_key_path, service_account_key + ) + + with suppress_stderr(): + config = StackitProvider._build_sdk_configuration( + service_account_key_path, + service_account_key, + ) + client = ResourceManagerDefaultApi(config) + client.get_project(id=project_id) + + logger.info( + "StackIT test_connection: Successfully connected using StackIT Resource Manager." + ) + return Connection(is_connected=True) + except (StackITNonExistentTokenError, StackITInvalidProjectIdError) as error: + logger.error(f"StackIT test_connection error: {error}") + if raise_on_exception: + raise error + return Connection(error=error) + except Exception as test_error: + try: + StackitProvider.handle_api_error(test_error) + except StackITInvalidTokenError as auth_error: + if raise_on_exception: + raise auth_error + return Connection(error=auth_error) + except Exception as api_error: + error_msg = ( + "Failed to connect to StackIT using Resource Manager: " + f"{str(api_error)}" + ) + logger.error(error_msg) + connection_error = StackITAPIError( + original_exception=api_error, message=error_msg + ) + if raise_on_exception: + raise connection_error + return Connection(error=connection_error) + + if raise_on_exception: + raise test_error + return Connection(error=test_error) diff --git a/prowler/providers/stackit/stackit_regions_by_service.json b/prowler/providers/stackit/stackit_regions_by_service.json new file mode 100644 index 0000000000..d9c8cdb9be --- /dev/null +++ b/prowler/providers/stackit/stackit_regions_by_service.json @@ -0,0 +1,10 @@ +{ + "services": { + "iaas": { + "regions": [ + "eu01", + "eu02" + ] + } + } +} diff --git a/pyproject.toml b/pyproject.toml index 9c41081213..61f0ee5fc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,6 +93,9 @@ dependencies = [ "schema==0.7.5", "shodan==1.31.0", "slack-sdk==3.39.0", + "stackit-core==0.2.0", + "stackit-iaas==1.4.0", + "stackit-resourcemanager==0.8.0", "tabulate==0.9.0", "tzlocal==5.3.1", "uuid6==2024.7.10", diff --git a/tests/config/config_test.py b/tests/config/config_test.py index 1540ef6d5b..1af6e32df2 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -6,6 +6,7 @@ from unittest import mock from requests import Response from prowler.config.config import ( + Provider, check_current_version, get_available_compliance_frameworks, load_and_validate_config_file, @@ -17,6 +18,12 @@ MOCK_OLD_PROWLER_VERSION = "0.0.0" MOCK_PROWLER_MASTER_VERSION = "3.4.0" +def test_provider_enum_includes_stackit_for_global_discovery(): + providers = [provider.value for provider in Provider] + + assert "stackit" in providers + + def mock_prowler_get_latest_release(_, **_kwargs): """Mock requests.get() to get the Prowler latest release""" response = Response() diff --git a/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py index d667be9e66..cd602a3a61 100644 --- a/tests/lib/cli/parser_test.py +++ b/tests/lib/cli/parser_test.py @@ -17,7 +17,7 @@ prowler_command = "prowler" # capsys # https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html -prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,vercel,dashboard,iac,image,llm} ..." +prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm} ..." def mock_get_available_providers(): @@ -38,6 +38,7 @@ def mock_get_available_providers(): "llm", "cloudflare", "openstack", + "stackit", ] diff --git a/tests/lib/cli/redact_test.py b/tests/lib/cli/redact_test.py index 5de4cc8fd7..c7ff65a765 100644 --- a/tests/lib/cli/redact_test.py +++ b/tests/lib/cli/redact_test.py @@ -160,6 +160,7 @@ class TestGetSensitiveArguments: assert "--atlas-private-key" in result assert "--nhn-password" in result assert "--os-password" in result + assert "--stackit-service-account-key" in result def test_does_not_include_non_sensitive_flags(self): """Verify non-sensitive flags are not in the set.""" @@ -168,3 +169,4 @@ class TestGetSensitiveArguments: assert "--region" not in result assert "--profile" not in result assert "--output-formats" not in result + assert "--stackit-service-account-key-path" not in result diff --git a/tests/lib/outputs/finding_test.py b/tests/lib/outputs/finding_test.py index fb75a1f705..0f1cb14e25 100644 --- a/tests/lib/outputs/finding_test.py +++ b/tests/lib/outputs/finding_test.py @@ -1507,3 +1507,106 @@ class TestFinding: with pytest.raises(KeyError): Finding.transform_api_finding(dummy_finding, provider) + + @patch( + "prowler.lib.outputs.finding.get_check_compliance", + new=mock_get_check_compliance, + ) + def test_generate_output_stackit(self): + provider = MagicMock() + provider.type = "stackit" + provider.auth_method = "service_account_key" + provider.identity.project_id = "test-project-id" + provider.identity.project_name = "test-project-name" + + check_output = MagicMock() + check_output.resource_id = "test_resource_id" + check_output.resource_name = "test_resource_name" + check_output.resource_details = "" + check_output.location = "eu01" + check_output.status = Status.PASS + check_output.status_extended = "mock_status_extended" + check_output.muted = False + check_output.check_metadata = mock_check_metadata(provider="stackit") + check_output.resource = {} + check_output.compliance = {} + + output_options = MagicMock() + output_options.unix_timestamp = True + + finding_output = Finding.generate_output(provider, check_output, output_options) + + assert isinstance(finding_output, Finding) + assert finding_output.auth_method == "service_account_key" + assert finding_output.account_uid == "test-project-id" + assert finding_output.account_name == "test-project-name" + assert finding_output.resource_name == "test_resource_name" + assert finding_output.resource_uid == "test_resource_id" + assert finding_output.region == "eu01" + assert finding_output.status == Status.PASS + assert finding_output.muted is False + + def test_transform_api_finding_stackit(self): + provider = MagicMock() + provider.type = "stackit" + provider.auth_method = "service_account_key" + provider.identity.project_id = "stackit-project-id" + provider.identity.project_name = "stackit-project-name" + dummy_finding = DummyAPIFinding() + dummy_finding.inserted_at = 1234567890 + dummy_finding.scan = DummyScan(provider=provider) + dummy_finding.uid = "finding-uid-stackit" + dummy_finding.status = "PASS" + dummy_finding.status_extended = "StackIT check extended" + check_metadata = { + "provider": "stackit", + "checkid": "service_stackit_check_001", + "checktitle": "Test StackIT Check", + "checktype": [], + "servicename": "service", + "subservicename": "", + "severity": "high", + "resourcetype": "StackITResourceType", + "description": "StackIT check description", + "risk": "High risk", + "relatedurl": "", + "remediation": { + "code": { + "nativeiac": "", + "terraform": "", + "cli": "", + "other": "", + }, + "recommendation": { + "text": "Fix it", + "url": "https://hub.prowler.com/check/service_stackit_check_001", + }, + }, + "resourceidtemplate": "template", + "categories": ["encryption"], + "dependson": [], + "relatedto": [], + "notes": "StackIT notes", + } + dummy_finding.check_metadata = check_metadata + dummy_finding.raw_result = {} + dummy_finding.resource_name = "stackit-resource-name" + dummy_finding.resource_id = "stackit-resource-uid" + dummy_finding.location = "eu01" + dummy_finding.project_id = "stackit-project-id" + resource = DummyResource( + uid="stackit-resource-uid", + name="stackit-resource-name", + resource_arn="", + region="eu01", + tags=[], + ) + dummy_finding.resources = DummyResources(resource) + dummy_finding.muted = False + finding_obj = Finding.transform_api_finding(dummy_finding, provider) + assert finding_obj.auth_method == "service_account_key" + assert finding_obj.account_uid == "stackit-project-id" + assert finding_obj.account_name == "stackit-project-name" + assert finding_obj.resource_name == "stackit-resource-name" + assert finding_obj.resource_uid == "stackit-resource-uid" + assert finding_obj.region == "eu01" diff --git a/tests/lib/outputs/html/html_test.py b/tests/lib/outputs/html/html_test.py index c8d4ac199d..536e40f808 100644 --- a/tests/lib/outputs/html/html_test.py +++ b/tests/lib/outputs/html/html_test.py @@ -962,6 +962,43 @@ class TestHTML: assert summary == image_list_html_assessment_summary + def test_stackit_get_assessment_summary(self): + """Test StackIT HTML assessment summary shows the project ID.""" + findings = [generate_finding_output()] + output = HTML(findings) + + provider = MagicMock() + provider.type = "stackit" + provider.identity.project_id = "f033ea6d-8697-40eb-a60e-acfa9128480d" + provider.identity.project_name = "ProwlerDev" + provider.identity.audited_regions = {"eu01", "eu02"} + + summary = output.get_assessment_summary(provider) + + assert "StackIT Assessment Summary" in summary + assert "StackIT Credentials" in summary + assert "Project ID: f033ea6d-8697-40eb-a60e-acfa9128480d" in summary + assert "Project Name: ProwlerDev" in summary + assert "Regions: eu01, eu02" in summary + assert "Authentication Type: Service Account Key" in summary + + def test_stackit_get_assessment_summary_without_project_name(self): + """Project ID is always shown; the Project Name line is omitted when + the service account cannot read it from Resource Manager.""" + findings = [generate_finding_output()] + output = HTML(findings) + + provider = MagicMock() + provider.type = "stackit" + provider.identity.project_id = "f033ea6d-8697-40eb-a60e-acfa9128480d" + provider.identity.project_name = "" + provider.identity.audited_regions = {"eu01"} + + summary = output.get_assessment_summary(provider) + + assert "Project ID: f033ea6d-8697-40eb-a60e-acfa9128480d" in summary + assert "Project Name:" not in summary + def test_process_markdown_bold_text(self): """Test that **text** is converted to text""" test_text = "This is **bold text** and this is **also bold**" diff --git a/tests/lib/outputs/outputs_test.py b/tests/lib/outputs/outputs_test.py index 4aeccf1104..87901bd290 100644 --- a/tests/lib/outputs/outputs_test.py +++ b/tests/lib/outputs/outputs_test.py @@ -1256,3 +1256,43 @@ class TestReport: f"\t{Fore.YELLOW}INFO{Style.RESET_ALL} There are no resources" ) mocked_print.assert_called() # Verifying that print was called + + def test_report_with_stackit_provider_pass(self): + finding = MagicMock() + finding.status = "PASS" + finding.muted = False + finding.location = "eu01" + finding.check_metadata.Provider = "stackit" + finding.status_extended = "Security group has no unrestricted SSH access" + + output_options = MagicMock() + output_options.verbose = True + output_options.status = ["PASS", "FAIL"] + output_options.fixer = False + + provider = MagicMock() + provider.type = "stackit" + + with mock.patch("builtins.print") as mocked_print: + report([finding], provider, output_options) + mocked_print.assert_called() + + def test_report_with_stackit_provider_fail(self): + finding = MagicMock() + finding.status = "FAIL" + finding.muted = False + finding.location = "eu01" + finding.check_metadata.Provider = "stackit" + finding.status_extended = "Security group allows unrestricted SSH access" + + output_options = MagicMock() + output_options.verbose = True + output_options.status = ["PASS", "FAIL"] + output_options.fixer = False + + provider = MagicMock() + provider.type = "stackit" + + with mock.patch("builtins.print") as mocked_print: + report([finding], provider, output_options) + mocked_print.assert_called() diff --git a/tests/lib/outputs/summary_table_test.py b/tests/lib/outputs/summary_table_test.py index c1e5322c33..0c842225cc 100644 --- a/tests/lib/outputs/summary_table_test.py +++ b/tests/lib/outputs/summary_table_test.py @@ -40,3 +40,65 @@ class TestDisplaySummaryTable: assert "Subscriptions scanned:" in captured.out assert "Duplicate Subscription (subscription-id-1)" in captured.out assert "Duplicate Subscription (subscription-id-2)" in captured.out + + def test_stackit_summary_with_project_name(self, capsys): + provider = SimpleNamespace( + type="stackit", + identity=SimpleNamespace( + project_id="test-project-id", + project_name="my-prod-env", + ), + ) + output_options = SimpleNamespace( + output_directory="out", + output_filename="report", + output_modes=[], + ) + findings = [ + SimpleNamespace( + status="PASS", + muted=False, + check_metadata=SimpleNamespace( + ServiceName="iaas", + Provider="stackit", + Severity="high", + ), + ) + ] + + display_summary_table(findings, provider, output_options) + + captured = capsys.readouterr() + assert "Project" in captured.out + assert "my-prod-env" in captured.out + + def test_stackit_summary_with_project_id_only(self, capsys): + provider = SimpleNamespace( + type="stackit", + identity=SimpleNamespace( + project_id="test-project-id", + project_name=None, + ), + ) + output_options = SimpleNamespace( + output_directory="out", + output_filename="report", + output_modes=[], + ) + findings = [ + SimpleNamespace( + status="PASS", + muted=False, + check_metadata=SimpleNamespace( + ServiceName="iaas", + Provider="stackit", + Severity="high", + ), + ) + ] + + display_summary_table(findings, provider, output_options) + + captured = capsys.readouterr() + assert "Project ID" in captured.out + assert "test-project-id" in captured.out diff --git a/tests/providers/m365/services/entra/entra_managed_device_required_for_authentication/entra_managed_device_required_for_authentication_test.py b/tests/providers/m365/services/entra/entra_managed_device_required_for_authentication/entra_managed_device_required_for_authentication_test.py index bfd898cb26..5661ca1724 100644 --- a/tests/providers/m365/services/entra/entra_managed_device_required_for_authentication/entra_managed_device_required_for_authentication_test.py +++ b/tests/providers/m365/services/entra/entra_managed_device_required_for_authentication/entra_managed_device_required_for_authentication_test.py @@ -107,7 +107,9 @@ class Test_entra_managed_device_required_for_authentication: type=None, interval=SignInFrequencyInterval.TIME_BASED, ), - application_enforced_restrictions=ApplicationEnforcedRestrictions(is_enabled=False), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), ), state=ConditionalAccessPolicyState.DISABLED, ) @@ -186,7 +188,9 @@ class Test_entra_managed_device_required_for_authentication: type=None, interval=SignInFrequencyInterval.TIME_BASED, ), - application_enforced_restrictions=ApplicationEnforcedRestrictions(is_enabled=False), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), ), state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, ) @@ -269,7 +273,9 @@ class Test_entra_managed_device_required_for_authentication: type=None, interval=SignInFrequencyInterval.TIME_BASED, ), - application_enforced_restrictions=ApplicationEnforcedRestrictions(is_enabled=False), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), ), state=ConditionalAccessPolicyState.ENABLED, ) diff --git a/tests/providers/stackit/lib/arguments/stackit_arguments_test.py b/tests/providers/stackit/lib/arguments/stackit_arguments_test.py new file mode 100644 index 0000000000..36635e14d2 --- /dev/null +++ b/tests/providers/stackit/lib/arguments/stackit_arguments_test.py @@ -0,0 +1,68 @@ +import argparse + +import pytest + +from prowler.providers.stackit.lib.arguments.arguments import init_parser + + +@pytest.fixture +def parser(): + parser = argparse.ArgumentParser() + parser.common_providers_parser = argparse.ArgumentParser(add_help=False) + parser.subparsers = parser.add_subparsers(dest="provider") + init_parser(parser) + return parser + + +class TestStackITArguments: + def test_project_id_argument_is_registered(self, parser): + args = parser.parse_args( + [ + "stackit", + "--stackit-project-id", + "12345678-1234-1234-1234-123456789abc", + ] + ) + + assert args.stackit_project_id == "12345678-1234-1234-1234-123456789abc" + + def test_api_token_argument_is_not_registered(self, parser): + """Tokens were removed in favour of the service account key file.""" + with pytest.raises(SystemExit): + parser.parse_args(["stackit", "--stackit-api-token", "secret-token"]) + + def test_service_account_key_path_argument_is_registered(self, parser): + args = parser.parse_args( + [ + "stackit", + "--stackit-service-account-key-path", + "/tmp/sa-key.json", + ] + ) + assert args.stackit_service_account_key_path == "/tmp/sa-key.json" + + def test_service_account_key_path_defaults_to_none(self, parser): + args = parser.parse_args(["stackit"]) + assert args.stackit_service_account_key_path is None + + def test_service_account_key_argument_is_registered(self, parser): + args = parser.parse_args( + [ + "stackit", + "--stackit-service-account-key", + '{"keyId": "abc"}', + ] + ) + assert args.stackit_service_account_key == '{"keyId": "abc"}' + + def test_service_account_key_defaults_to_none(self, parser): + args = parser.parse_args(["stackit"]) + assert args.stackit_service_account_key is None + + def test_scan_unused_services_defaults_to_false(self, parser): + args = parser.parse_args(["stackit"]) + assert args.scan_unused_services is False + + def test_scan_unused_services_flag_sets_true(self, parser): + args = parser.parse_args(["stackit", "--scan-unused-services"]) + assert args.scan_unused_services is True diff --git a/tests/providers/stackit/lib/mutelist/stackit_mutelist_test.py b/tests/providers/stackit/lib/mutelist/stackit_mutelist_test.py new file mode 100644 index 0000000000..72e7770943 --- /dev/null +++ b/tests/providers/stackit/lib/mutelist/stackit_mutelist_test.py @@ -0,0 +1,33 @@ +from unittest.mock import MagicMock + +from prowler.providers.stackit.lib.mutelist.mutelist import StackITMutelist + + +class TestStackITMutelist: + def test_is_finding_muted_uses_project_id_as_account(self): + mutelist_content = { + "Accounts": { + "project_1": { + "Checks": { + "check_test": { + "Regions": ["*"], + "Resources": ["test_resource"], + } + } + } + } + } + + mutelist = StackITMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.project_id = "project_1" + finding.resource_id = "resource_1" + finding.check_metadata = MagicMock() + finding.check_metadata.CheckID = "check_test" + finding.status = "FAIL" + finding.resource_name = "test_resource" + finding.location = "eu01" + finding.resource_tags = {} + + assert mutelist.is_finding_muted(finding=finding) diff --git a/tests/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted_test.py b/tests/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted_test.py new file mode 100644 index 0000000000..0db0c9d6d7 --- /dev/null +++ b/tests/providers/stackit/services/iaas/iaas_security_group_all_traffic_unrestricted/iaas_security_group_all_traffic_unrestricted_test.py @@ -0,0 +1,504 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.stackit.services.iaas.iaas_service import ( + SecurityGroup, + SecurityGroupRule, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_iaas_security_group_all_traffic_unrestricted: + def setup_method(self): + mock.MagicMock.scan_unused_services = False + + def test_no_security_groups(self): + """Test with no security groups - should return empty results.""" + iaas_client = mock.MagicMock + iaas_client.security_groups = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 0 + + def test_security_group_not_in_use(self): + """Test security group not in use - should be skipped.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=None, + port_range_max=None, + ) + ], + in_use=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 0 + + def test_security_group_no_rules(self): + """Test security group with no rules - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Security group {security_group_name} does not allow unrestricted access to all traffic." + ) + assert result[0].resource_id == security_group_id + assert result[0].resource_name == security_group_name + assert result[0].location == "eu01" + + def test_security_group_all_ports_none_range(self): + """Test security group with None port range (all ports) - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=None, + port_range_max=None, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "allows unrestricted access to all traffic" in result[0].status_extended + ) + assert result[0].resource_id == security_group_id + assert result[0].resource_name == security_group_name + assert result[0].location == "eu01" + + def test_security_group_all_ports_full_range(self): + """Test security group with full port range 1-65535 - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=1, + port_range_max=65535, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "allows unrestricted access to all traffic" in result[0].status_extended + ) + + def test_security_group_all_ports_zero_range(self): + """Test security group with port range 0-65535 - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=0, + port_range_max=65535, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_security_group_limited_port_range(self): + """Test security group with limited port range - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=80, + port_range_max=443, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "does not allow unrestricted access to all traffic" + in result[0].status_extended + ) + + def test_security_group_restricted_ip_all_ports(self): + """Test security group with all ports but restricted IP - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="10.0.0.0/8", # Restricted IP + port_range_min=None, + port_range_max=None, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_security_group_egress_all_ports(self): + """Test security group with egress rule for all ports - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="egress", # Egress, not ingress + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=None, + port_range_max=None, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_security_group_multiple_unrestricted_rules(self): + """Test security group with multiple unrestricted rules - should FAIL with all listed.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=None, + port_range_max=None, + ), + SecurityGroupRule( + id="rule-2", + direction="ingress", + protocol="udp", + ip_range="0.0.0.0/0", + port_range_min=1, + port_range_max=65535, + ), + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_all_traffic_unrestricted.iaas_security_group_all_traffic_unrestricted import ( + iaas_security_group_all_traffic_unrestricted, + ) + + check = iaas_security_group_all_traffic_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "rule-1" in result[0].status_extended + assert "rule-2" in result[0].status_extended diff --git a/tests/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted_test.py b/tests/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted_test.py new file mode 100644 index 0000000000..83a279efac --- /dev/null +++ b/tests/providers/stackit/services/iaas/iaas_security_group_database_unrestricted/iaas_security_group_database_unrestricted_test.py @@ -0,0 +1,503 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.stackit.services.iaas.iaas_service import ( + SecurityGroup, + SecurityGroupRule, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_iaas_security_group_database_unrestricted: + def setup_method(self): + mock.MagicMock.scan_unused_services = False + + def test_no_security_groups(self): + """Test with no security groups - should return empty results.""" + iaas_client = mock.MagicMock + iaas_client.security_groups = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 0 + + def test_security_group_not_in_use(self): + """Test security group not in use - should be skipped.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=3306, + port_range_max=3306, + ) + ], + in_use=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 0 + + def test_security_group_no_rules(self): + """Test security group with no rules - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Security group {security_group_name} does not allow unrestricted database access." + ) + assert result[0].resource_id == security_group_id + assert result[0].resource_name == security_group_name + assert result[0].location == "eu01" + + def test_security_group_mysql_unrestricted(self): + """Test security group with MySQL port 3306 unrestricted - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=3306, + port_range_max=3306, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "MySQL (port 3306)" in result[0].status_extended + assert "allows unrestricted database access" in result[0].status_extended + assert result[0].resource_id == security_group_id + assert result[0].resource_name == security_group_name + assert result[0].location == "eu01" + + def test_security_group_postgresql_unrestricted(self): + """Test security group with PostgreSQL port 5432 unrestricted - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=5432, + port_range_max=5432, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "PostgreSQL (port 5432)" in result[0].status_extended + + def test_security_group_mongodb_unrestricted(self): + """Test security group with MongoDB port 27017 unrestricted - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=27017, + port_range_max=27017, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "MongoDB (port 27017)" in result[0].status_extended + + def test_security_group_multiple_databases_unrestricted(self): + """Test security group with multiple database ports unrestricted - should FAIL with all listed.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=3306, + port_range_max=3306, + ), + SecurityGroupRule( + id="rule-2", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=5432, + port_range_max=5432, + ), + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "MySQL (port 3306)" in result[0].status_extended + assert "PostgreSQL (port 5432)" in result[0].status_extended + + def test_security_group_database_port_range(self): + """Test security group with port range including database port - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=3000, + port_range_max=4000, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "MySQL (port 3306)" in result[0].status_extended + + def test_security_group_database_restricted_ip(self): + """Test security group with database port restricted to specific IP - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="10.0.0.0/8", + port_range_min=3306, + port_range_max=3306, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "does not allow unrestricted database access" + in result[0].status_extended + ) + + def test_security_group_non_database_port(self): + """Test security group with unrestricted access but non-database port - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=80, + port_range_max=80, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_database_unrestricted.iaas_security_group_database_unrestricted import ( + iaas_security_group_database_unrestricted, + ) + + check = iaas_security_group_database_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted_test.py b/tests/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted_test.py new file mode 100644 index 0000000000..65e21d4883 --- /dev/null +++ b/tests/providers/stackit/services/iaas/iaas_security_group_rdp_unrestricted/iaas_security_group_rdp_unrestricted_test.py @@ -0,0 +1,389 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.stackit.services.iaas.iaas_service import ( + SecurityGroup, + SecurityGroupRule, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_iaas_security_group_rdp_unrestricted: + def setup_method(self): + mock.MagicMock.scan_unused_services = False + + def test_no_security_groups(self): + """Test with no security groups - should return empty results.""" + iaas_client = mock.MagicMock + iaas_client.security_groups = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import ( + iaas_security_group_rdp_unrestricted, + ) + + check = iaas_security_group_rdp_unrestricted() + result = check.execute() + assert len(result) == 0 + + def test_security_group_not_in_use(self): + """Test security group not in use - should be skipped.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=3389, + port_range_max=3389, + ) + ], + in_use=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import ( + iaas_security_group_rdp_unrestricted, + ) + + check = iaas_security_group_rdp_unrestricted() + result = check.execute() + assert len(result) == 0 + + def test_security_group_no_rules(self): + """Test security group with no rules - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import ( + iaas_security_group_rdp_unrestricted, + ) + + check = iaas_security_group_rdp_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Security group {security_group_name} does not allow unrestricted RDP access." + ) + assert result[0].resource_id == security_group_id + assert result[0].resource_name == security_group_name + assert result[0].location == "eu01" + + def test_security_group_rdp_unrestricted_exact_port(self): + """Test security group with RDP port 3389 unrestricted - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=3389, + port_range_max=3389, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import ( + iaas_security_group_rdp_unrestricted, + ) + + check = iaas_security_group_rdp_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "allows unrestricted RDP access" in result[0].status_extended + assert result[0].resource_id == security_group_id + assert result[0].resource_name == security_group_name + assert result[0].location == "eu01" + + def test_security_group_rdp_unrestricted_port_range(self): + """Test security group with port range including RDP - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=3380, + port_range_max=3400, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import ( + iaas_security_group_rdp_unrestricted, + ) + + check = iaas_security_group_rdp_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "allows unrestricted RDP access" in result[0].status_extended + + def test_security_group_rdp_restricted_ip(self): + """Test security group with RDP restricted to specific IP - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="10.0.0.0/8", + port_range_min=3389, + port_range_max=3389, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import ( + iaas_security_group_rdp_unrestricted, + ) + + check = iaas_security_group_rdp_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "does not allow unrestricted RDP access" in result[0].status_extended + + def test_security_group_different_port(self): + """Test security group with unrestricted access but different port - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=80, + port_range_max=80, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import ( + iaas_security_group_rdp_unrestricted, + ) + + check = iaas_security_group_rdp_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_security_group_none_ip_range(self): + """Test security group with None ip_range (unrestricted) - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range=None, + port_range_min=3389, + port_range_max=3389, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_rdp_unrestricted.iaas_security_group_rdp_unrestricted import ( + iaas_security_group_rdp_unrestricted, + ) + + check = iaas_security_group_rdp_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted_test.py b/tests/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted_test.py new file mode 100644 index 0000000000..cabb167e78 --- /dev/null +++ b/tests/providers/stackit/services/iaas/iaas_security_group_ssh_unrestricted/iaas_security_group_ssh_unrestricted_test.py @@ -0,0 +1,589 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.stackit.services.iaas.iaas_service import ( + SecurityGroup, + SecurityGroupRule, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_iaas_security_group_ssh_unrestricted: + def setup_method(self): + mock.MagicMock.scan_unused_services = False + + def test_no_security_groups(self): + """Test with no security groups - should return empty results.""" + iaas_client = mock.MagicMock + iaas_client.security_groups = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 0 + + def test_security_group_not_in_use(self): + """Test security group not in use - should be skipped.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + ], + in_use=False, # Not in use - should be skipped + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 0 + + def test_security_group_no_rules(self): + """Test security group with no rules - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Security group {security_group_name} does not allow unrestricted SSH access." + ) + assert result[0].resource_id == security_group_id + assert result[0].resource_name == security_group_name + assert result[0].location == "eu01" + + def test_security_group_ssh_unrestricted_exact_port(self): + """Test security group with SSH port 22 unrestricted - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "allows unrestricted SSH access" in result[0].status_extended + assert result[0].resource_id == security_group_id + assert result[0].resource_name == security_group_name + assert result[0].location == "eu01" + + def test_security_group_ssh_unrestricted_port_range(self): + """Test security group with port range including SSH - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=20, + port_range_max=25, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "allows unrestricted SSH access" in result[0].status_extended + assert result[0].resource_id == security_group_id + assert result[0].resource_name == security_group_name + + def test_security_group_ssh_restricted_ip(self): + """Test security group with SSH restricted to specific IP - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="10.0.0.0/8", + port_range_min=22, + port_range_max=22, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "does not allow unrestricted SSH access" in result[0].status_extended + + def test_security_group_different_port(self): + """Test security group with unrestricted access but different port - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=80, + port_range_max=80, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_security_group_egress_rule(self): + """Test security group with egress rule - should PASS.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="egress", # Egress, not ingress + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_security_group_none_protocol(self): + """Test security group with None protocol (all protocols) - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol=None, # None means all protocols (includes TCP) + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_security_group_none_ip_range(self): + """Test security group with None ip_range (unrestricted) - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range=None, # None means unrestricted access + port_range_min=22, + port_range_max=22, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_security_group_none_port_range(self): + """Test security group with None port range (all ports) - should FAIL.""" + iaas_client = mock.MagicMock + security_group_name = "test-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=None, # None means all ports + port_range_max=None, + ) + ], + in_use=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_security_group_not_in_use_with_scan_unused_services(self): + """Unused SG with unrestricted SSH must FAIL when scan_unused_services=True.""" + iaas_client = mock.MagicMock + iaas_client.scan_unused_services = True + security_group_name = "unused-security-group" + security_group_id = str(uuid4()) + + iaas_client.security_groups = [ + SecurityGroup( + id=security_group_id, + name=security_group_name, + project_id=STACKIT_PROJECT_ID, + region="eu01", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + ], + in_use=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(scan_unused_services=True), + ), + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService", + new=iaas_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.iaas.iaas_client.iaas_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.iaas.iaas_security_group_ssh_unrestricted.iaas_security_group_ssh_unrestricted import ( + iaas_security_group_ssh_unrestricted, + ) + + check = iaas_security_group_ssh_unrestricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "allows unrestricted SSH access" in result[0].status_extended diff --git a/tests/providers/stackit/services/iaas/iaas_service_test.py b/tests/providers/stackit/services/iaas/iaas_service_test.py new file mode 100644 index 0000000000..79ea0c33de --- /dev/null +++ b/tests/providers/stackit/services/iaas/iaas_service_test.py @@ -0,0 +1,515 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from prowler.providers.stackit.exceptions.exceptions import StackITInvalidTokenError +from prowler.providers.stackit.services.iaas.iaas_service import ( + IaaSService, + SecurityGroupRule, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +def mock_iaas_fetch_all_regions(_): + """Mock the _fetch_all_regions method to avoid real API calls.""" + + +@patch( + "prowler.providers.stackit.services.iaas.iaas_service.IaaSService._fetch_all_regions", + new=mock_iaas_fetch_all_regions, +) +class Test_IaaS_Service: + def test_service_initialization(self): + """Test that the IaaS service initializes correctly.""" + iaas_service = IaaSService(set_mocked_stackit_provider()) + + assert iaas_service.project_id == STACKIT_PROJECT_ID + assert iaas_service.service_account_key_path is not None + assert isinstance(iaas_service.security_groups, list) + assert isinstance(iaas_service.server_nics, list) + assert isinstance(iaas_service.in_use_sg_ids, set) + assert iaas_service.scan_unused_services is False + + def test_service_project_id(self): + """Test that the service correctly extracts project_id from provider.""" + iaas_service = IaaSService(set_mocked_stackit_provider()) + assert iaas_service.project_id == STACKIT_PROJECT_ID + + def test_service_service_account_key_path(self): + """Test that the service correctly extracts the SA key path from provider.""" + custom_path = "/tmp/custom-sa.json" + provider = set_mocked_stackit_provider(service_account_key_path=custom_path) + iaas_service = IaaSService(provider) + assert iaas_service.service_account_key_path == custom_path + + def test_security_groups_list_structure(self): + """Test that security_groups is properly initialized as a list.""" + iaas_service = IaaSService(set_mocked_stackit_provider()) + assert hasattr(iaas_service, "security_groups") + assert isinstance(iaas_service.security_groups, list) + + def test_in_use_sg_ids_set_structure(self): + """Test that in_use_sg_ids is properly initialized as a set.""" + iaas_service = IaaSService(set_mocked_stackit_provider()) + assert hasattr(iaas_service, "in_use_sg_ids") + assert isinstance(iaas_service.in_use_sg_ids, set) + + @pytest.mark.parametrize( + "method_name,client_method_name", + [ + ("_list_server_nics", "list_project_nics"), + ("_list_security_groups", "list_security_groups"), + ("_list_security_group_rules", "list_security_group_rules"), + ], + ) + def test_list_methods_propagate_api_errors(self, method_name, client_method_name): + """API/auth failures must fail the scan instead of returning empty data.""" + iaas_service = IaaSService(set_mocked_stackit_provider()) + client = MagicMock() + getattr(client, client_method_name).side_effect = StackITInvalidTokenError( + message="Invalid token" + ) + + with pytest.raises(StackITInvalidTokenError): + if method_name == "_list_security_group_rules": + getattr(iaas_service, method_name)(client, "eu01", "sg-1") + else: + getattr(iaas_service, method_name)(client, "eu01") + + def test_security_group_parsing_errors_are_skipped_locally(self): + """Malformed resources are skipped while valid resources are retained.""" + + class MalformedSecurityGroup: + @property + def id(self): + raise ValueError("malformed security group") + + iaas_service = IaaSService(set_mocked_stackit_provider()) + client = MagicMock() + client.list_security_groups.return_value = [ + MalformedSecurityGroup(), + {"id": "sg-1", "name": "valid-sg"}, + ] + client.list_security_group_rules.return_value = [] + + iaas_service._list_security_groups(client, "eu01") + + assert len(iaas_service.security_groups) == 1 + assert iaas_service.security_groups[0].id == "sg-1" + + def test_in_use_considers_all_nics_not_only_public(self): + """A SG attached to any NIC (public or private) counts as in_use.""" + iaas_service = IaaSService(set_mocked_stackit_provider()) + + # NIC without a public IP, but has a security group attached + private_nic = {"id": "nic-private", "security_groups": ["sg-private"]} + used = iaas_service._get_used_security_group_ids([private_nic]) + + assert "sg-private" in used + + def test_in_use_sg_ids_populated_via_list_server_nics(self): + """_list_server_nics marks SGs on any NIC as in_use.""" + iaas_service = IaaSService(set_mocked_stackit_provider()) + client = MagicMock() + client.list_project_nics.return_value = [ + {"id": "nic-1", "security_groups": ["sg-1"]}, + {"id": "nic-2", "security_groups": ["sg-2"]}, + ] + + iaas_service._list_server_nics(client, "eu01") + + assert "sg-1" in iaas_service.in_use_sg_ids + assert "sg-2" in iaas_service.in_use_sg_ids + + +# Test SecurityGroupRule helper methods +class Test_SecurityGroupRule: + def test_is_unrestricted_with_none(self): + """Test that None ip_range is considered unrestricted.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range=None, + port_range_min=22, + port_range_max=22, + ) + assert rule.is_unrestricted() is True + + def test_is_unrestricted_with_cidr(self): + """Test that 0.0.0.0/0 is considered unrestricted.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + assert rule.is_unrestricted() is True + + def test_is_unrestricted_with_ipv6(self): + """Test that ::/0 is considered unrestricted.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="::/0", + port_range_min=22, + port_range_max=22, + ) + assert rule.is_unrestricted() is True + + def test_is_restricted_with_specific_ip(self): + """Test that specific IP range is considered restricted.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="10.0.0.0/8", + port_range_min=22, + port_range_max=22, + ) + assert rule.is_unrestricted() is False + + def test_is_ingress_true(self): + """Test that ingress direction is properly detected.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + assert rule.is_ingress() is True + + def test_is_ingress_false(self): + """Test that egress direction returns false for is_ingress.""" + rule = SecurityGroupRule( + id="rule-1", + direction="egress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + assert rule.is_ingress() is False + + def test_is_tcp_with_tcp_protocol(self): + """Test that TCP protocol is detected correctly.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + assert rule.is_tcp() is True + + def test_is_tcp_with_none_protocol(self): + """Test that None protocol is treated as TCP (all protocols).""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol=None, + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + assert rule.is_tcp() is True + + def test_is_tcp_with_all_protocol(self): + """Test that 'all' protocol is treated as TCP.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="all", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + assert rule.is_tcp() is True + + def test_is_tcp_with_udp_protocol(self): + """Test that UDP protocol returns false for is_tcp.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="udp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + assert rule.is_tcp() is False + + def test_includes_port_exact_match(self): + """Test that exact port match is detected.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=22, + port_range_max=22, + ) + assert rule.includes_port(22) is True + + def test_includes_port_in_range(self): + """Test that port within range is detected.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=20, + port_range_max=25, + ) + assert rule.includes_port(22) is True + assert rule.includes_port(20) is True + assert rule.includes_port(25) is True + + def test_includes_port_outside_range(self): + """Test that port outside range is not detected.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=80, + port_range_max=443, + ) + assert rule.includes_port(22) is False + + def test_includes_port_with_none_range(self): + """Test that None port range means all ports.""" + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ip_range="0.0.0.0/0", + port_range_min=None, + port_range_max=None, + ) + assert rule.includes_port(22) is True + assert rule.includes_port(443) is True + assert rule.includes_port(65535) is True + + +class Test_IaaS_Service_Extract_Items: + """Cover ``IaaSService._extract_items`` against the three response shapes the + StackIT SDK can return. The previous implementation matched ``dict`` via + ``hasattr(response, "items")`` and returned the bound method instead of the + items field. + """ + + def test_extract_items_from_sdk_model(self): + """SDK models expose ``items`` as a non-callable attribute.""" + response = MagicMock(spec=["items"]) + sentinel = [{"id": "sg-1"}, {"id": "sg-2"}] + response.items = sentinel + assert IaaSService._extract_items(response, "endpoint") is sentinel + + def test_extract_items_from_dict_with_items_key(self): + """Dict responses must use the ``items`` key, not ``dict.items()``.""" + response = {"items": [{"id": "sg-1"}]} + assert IaaSService._extract_items(response, "endpoint") == [{"id": "sg-1"}] + + def test_extract_items_from_empty_dict(self): + """An empty dict yields an empty list, not the ``dict.items`` method.""" + assert IaaSService._extract_items({}, "endpoint") == [] + + def test_extract_items_from_list(self): + """A plain list response is returned as-is.""" + response = [{"id": "sg-1"}] + assert IaaSService._extract_items(response, "endpoint") is response + + def test_extract_items_unknown_shape_returns_empty(self): + """Unknown shapes fall back to an empty list and log a warning.""" + assert IaaSService._extract_items(42, "endpoint") == [] + + def test_extract_items_ignores_dict_items_method(self): + """Regression: ``dict`` exposes ``items`` as a method; ensure the + ``isinstance(dict)`` branch wins and we do not return the bound method. + """ + result = IaaSService._extract_items({"items": ["ok"]}, "endpoint") + assert result == ["ok"] + assert not callable(result) + + +class Test_IaaS_Service_Used_Security_Group_Ids: + """Cover ``_get_used_security_group_ids``. The SDK returns NIC security + group references as ``uuid.UUID`` while a security group id is a ``str``; + they must be normalized to ``str`` so the in-use membership test matches. + """ + + def _service(self): + return object.__new__(IaaSService) + + def test_uuid_references_are_normalized_to_str(self): + from uuid import UUID + + sg_uuid = UUID("2040c1fa-72a6-47bc-a53b-f62075ae6d35") + nic = MagicMock(spec=["security_groups"]) + nic.security_groups = [sg_uuid] + + used = self._service()._get_used_security_group_ids([nic]) + + # Stored as the string form so `str(sg.id) in used` matches. + assert used == {"2040c1fa-72a6-47bc-a53b-f62075ae6d35"} + assert all(isinstance(x, str) for x in used) + + def test_dict_nic_camelcase_security_groups_key(self): + nic = {"securityGroups": ["sg-aaaa", "sg-bbbb"]} + used = self._service()._get_used_security_group_ids([nic]) + assert used == {"sg-aaaa", "sg-bbbb"} + + def test_empty_nic_security_groups(self): + nic = MagicMock(spec=["security_groups"]) + nic.security_groups = [] + assert self._service()._get_used_security_group_ids([nic]) == set() + + def test_in_use_matches_uuid_reference_end_to_end(self): + """A security group whose id (str) matches a NIC reference (UUID) must + be flagged in_use=True after a full region fetch. + """ + from uuid import UUID + + sg_id = "2040c1fa-72a6-47bc-a53b-f62075ae6d35" + client = MagicMock() + nic = MagicMock(spec=["security_groups"]) + nic.security_groups = [UUID(sg_id)] + client.list_project_nics.return_value = {"items": [nic]} + client.list_security_groups.return_value = {"items": [{"id": sg_id}]} + client.list_security_group_rules.return_value = {"items": []} + + from prowler.providers.stackit.stackit_provider import StackitProvider + + service = object.__new__(IaaSService) + service.provider = MagicMock() + service.provider.handle_api_error = StackitProvider.handle_api_error + service.project_id = STACKIT_PROJECT_ID + service.scan_unused_services = False + service.regional_clients = {"eu01": client} + service.security_groups = [] + service.server_nics = [] + service.in_use_sg_ids = set() + + service._fetch_all_regions() + + assert len(service.security_groups) == 1 + assert service.security_groups[0].in_use is True + + +class Test_IaaS_Service_Fetch_All_Regions: + """Cover ``_fetch_all_regions`` multi-region behaviour. A project is not + provisioned in every StackIT region; the region where it is absent answers + with HTTP 404. That must be skipped, not abort the whole scan (which + previously left every check failing to load with an empty report). + """ + + class _NotFound(Exception): + status = 404 + + class _Forbidden(Exception): + status = 403 + + def _service(self, regional_clients): + from prowler.providers.stackit.stackit_provider import StackitProvider + + service = object.__new__(IaaSService) + service.provider = MagicMock() + # Reuse the real centralized error handler so 401/403/404 semantics + # match production. + service.provider.handle_api_error = StackitProvider.handle_api_error + service.project_id = STACKIT_PROJECT_ID + service.scan_unused_services = True + service.regional_clients = regional_clients + service.security_groups = [] + service.server_nics = [] + service.in_use_sg_ids = set() + return service + + def _good_client(self, sg_id="sg-eu01"): + client = MagicMock() + client.list_project_nics.return_value = {"items": []} + client.list_security_groups.return_value = {"items": [{"id": sg_id}]} + client.list_security_group_rules.return_value = {"items": []} + return client + + def _missing_region_client(self): + client = MagicMock() + client.list_project_nics.side_effect = self._NotFound() + client.list_security_groups.side_effect = self._NotFound() + return client + + def test_skips_region_where_project_is_absent(self): + service = self._service( + {"eu01": self._good_client(), "eu02": self._missing_region_client()} + ) + + service._fetch_all_regions() + + # eu01 security group is collected; the eu02 404 is skipped silently. + assert [sg.id for sg in service.security_groups] == ["sg-eu01"] + + def test_403_still_aborts(self): + bad = MagicMock() + bad.list_project_nics.side_effect = self._Forbidden() + service = self._service({"eu01": bad}) + + with pytest.raises(StackITInvalidTokenError): + service._fetch_all_regions() + + +class Test_IaaS_Service_Log_Skipped_Security_Groups: + """``_log_skipped_security_groups`` should emit a hint only when groups + exist, none are in use, and ``scan_unused_services`` is off. + """ + + def _service(self, security_groups, scan_unused_services): + service = object.__new__(IaaSService) + service.scan_unused_services = scan_unused_services + service.security_groups = security_groups + return service + + def _sg(self, in_use): + sg = MagicMock() + sg.in_use = in_use + return sg + + def test_logs_when_all_skipped(self, caplog): + import logging + + service = self._service([self._sg(False), self._sg(False)], False) + with caplog.at_level(logging.INFO): + service._log_skipped_security_groups() + assert "scan-unused-services" in caplog.text + + def test_no_log_when_scan_unused_services_enabled(self, caplog): + import logging + + service = self._service([self._sg(False)], True) + with caplog.at_level(logging.INFO): + service._log_skipped_security_groups() + assert "scan-unused-services" not in caplog.text + + def test_no_log_when_a_group_is_in_use(self, caplog): + import logging + + service = self._service([self._sg(False), self._sg(True)], False) + with caplog.at_level(logging.INFO): + service._log_skipped_security_groups() + assert "scan-unused-services" not in caplog.text + + def test_no_log_when_no_security_groups(self, caplog): + import logging + + service = self._service([], False) + with caplog.at_level(logging.INFO): + service._log_skipped_security_groups() + assert "scan-unused-services" not in caplog.text diff --git a/tests/providers/stackit/stackit_exceptions_test.py b/tests/providers/stackit/stackit_exceptions_test.py new file mode 100644 index 0000000000..b6577305b0 --- /dev/null +++ b/tests/providers/stackit/stackit_exceptions_test.py @@ -0,0 +1,29 @@ +from prowler.providers.stackit.exceptions.exceptions import ( + StackITBaseException, + StackITInvalidTokenError, +) + + +class Test_StackIT_Exception_Catalog_Immutability: + """Regression: ``StackITBaseException.__init__`` previously assigned the + per-instance ``message`` override straight onto the class-level + ``STACKIT_ERROR_CODES`` dict, leaking the override into every later + exception of the same code raised in the same process. + """ + + def _default_message(self, code: int, class_name: str) -> str: + """Read the default message directly from the unmodified catalog.""" + return StackITBaseException.STACKIT_ERROR_CODES[(code, class_name)]["message"] + + def test_message_override_does_not_mutate_class_catalog(self): + default = self._default_message(16002, "StackITInvalidTokenError") + StackITInvalidTokenError(message="instance-specific message") + assert self._default_message(16002, "StackITInvalidTokenError") == default + + def test_sequential_overrides_do_not_leak(self): + """An override on instance A must not affect instance B.""" + default = self._default_message(16002, "StackITInvalidTokenError") + StackITInvalidTokenError(message="A") + StackITInvalidTokenError(message="B") + second_default = self._default_message(16002, "StackITInvalidTokenError") + assert second_default == default diff --git a/tests/providers/stackit/stackit_fixtures.py b/tests/providers/stackit/stackit_fixtures.py new file mode 100644 index 0000000000..7312bbc542 --- /dev/null +++ b/tests/providers/stackit/stackit_fixtures.py @@ -0,0 +1,54 @@ +from uuid import uuid4 + +from mock import MagicMock + +from prowler.providers.stackit.models import StackITIdentityInfo +from prowler.providers.stackit.stackit_provider import StackitProvider + +# StackIT Test Constants +STACKIT_PROJECT_ID = str(uuid4()) +STACKIT_SERVICE_ACCOUNT_KEY_PATH = "/tmp/stackit-sa-key.json" +STACKIT_PROJECT_NAME = "Test Project" + + +def set_mocked_stackit_provider( + service_account_key_path: str = STACKIT_SERVICE_ACCOUNT_KEY_PATH, + project_id: str = STACKIT_PROJECT_ID, + identity: StackITIdentityInfo = None, + audit_config: dict = None, + fixer_config: dict = None, + scan_unused_services: bool = False, +) -> StackitProvider: + """ + Create a mocked StackIT provider for testing. + + Args: + service_account_key_path: Path to the service account key file + (default: ``STACKIT_SERVICE_ACCOUNT_KEY_PATH`` constant) + project_id: The project ID to use (default: STACKIT_PROJECT_ID) + identity: Custom identity info (default: creates new StackITIdentityInfo) + audit_config: Audit configuration dict (default: None) + fixer_config: Fixer configuration dict (default: None) + + Returns: + MagicMock: A mocked StackitProvider instance + """ + if identity is None: + identity = StackITIdentityInfo( + project_id=project_id, + project_name=STACKIT_PROJECT_NAME, + ) + + provider = MagicMock() + provider.type = "stackit" + provider.identity = identity + provider.session = { + "project_id": project_id, + "service_account_key_path": service_account_key_path, + } + provider.audit_config = audit_config if audit_config else {} + provider.fixer_config = fixer_config if fixer_config else {} + provider.scan_unused_services = scan_unused_services + provider.auth_method = "service_account_key" + + return provider diff --git a/tests/providers/stackit/stackit_metadata_test.py b/tests/providers/stackit/stackit_metadata_test.py new file mode 100644 index 0000000000..7becf6bea5 --- /dev/null +++ b/tests/providers/stackit/stackit_metadata_test.py @@ -0,0 +1,15 @@ +from pathlib import Path + +import pytest + +from prowler.lib.check.models import CheckMetadata + + +@pytest.mark.parametrize( + "metadata_file", + sorted(Path("prowler/providers/stackit").glob("services/**/*.metadata.json")), +) +def test_stackit_check_metadata_is_valid(metadata_file): + metadata = CheckMetadata.parse_file(metadata_file) + assert metadata.Provider == "stackit" + assert metadata.CheckID == metadata_file.stem.replace(".metadata", "") diff --git a/tests/providers/stackit/stackit_provider_test.py b/tests/providers/stackit/stackit_provider_test.py new file mode 100644 index 0000000000..8bcbe17f84 --- /dev/null +++ b/tests/providers/stackit/stackit_provider_test.py @@ -0,0 +1,413 @@ +"""Tests for StackIT Provider input validation.""" + +import types +from argparse import Namespace +from unittest.mock import patch + +import pytest + +import prowler.providers.common.provider as common_provider +import prowler.providers.stackit.stackit_provider as stackit_provider_module +from prowler.providers.common.models import Connection +from prowler.providers.stackit.exceptions.exceptions import ( + StackITAPIError, + StackITInvalidProjectIdError, + StackITInvalidTokenError, + StackITNonExistentTokenError, +) +from prowler.providers.stackit.stackit_provider import StackitProvider + + +class TestStackITProviderValidation: + """Test suite for StackIT Provider input validation.""" + + KEY_PATH = "/tmp/sa-key.json" + VALID_PROJECT_ID = "12345678-1234-1234-1234-123456789abc" + + def test_validate_arguments_valid(self): + """Test validation passes with a key path and a valid UUID.""" + StackitProvider.validate_arguments(self.VALID_PROJECT_ID, self.KEY_PATH) + + def test_validate_arguments_empty_key_path(self): + with pytest.raises(StackITNonExistentTokenError) as exc_info: + StackitProvider.validate_arguments(self.VALID_PROJECT_ID, "") + assert "service account credentials are required" in str(exc_info.value) + + def test_validate_arguments_none_key_path(self): + with pytest.raises(StackITNonExistentTokenError) as exc_info: + StackitProvider.validate_arguments(self.VALID_PROJECT_ID, None) + assert "service account credentials are required" in str(exc_info.value) + + def test_validate_arguments_whitespace_only_key_path(self): + with pytest.raises(StackITNonExistentTokenError) as exc_info: + StackitProvider.validate_arguments(self.VALID_PROJECT_ID, " ") + assert "service account credentials are required" in str(exc_info.value) + + def test_validate_arguments_empty_project_id(self): + with pytest.raises(StackITInvalidProjectIdError) as exc_info: + StackitProvider.validate_arguments("", self.KEY_PATH) + assert "project ID is required" in str(exc_info.value) + + def test_validate_arguments_none_project_id(self): + with pytest.raises(StackITInvalidProjectIdError) as exc_info: + StackitProvider.validate_arguments(None, self.KEY_PATH) + assert "project ID is required" in str(exc_info.value) + + def test_validate_arguments_whitespace_only_project_id(self): + with pytest.raises(StackITInvalidProjectIdError) as exc_info: + StackitProvider.validate_arguments(" ", self.KEY_PATH) + assert "project ID is required" in str(exc_info.value) + + def test_validate_arguments_invalid_uuid_format(self): + with pytest.raises(StackITInvalidProjectIdError) as exc_info: + StackitProvider.validate_arguments("not-a-valid-uuid", self.KEY_PATH) + assert "must be a valid UUID format" in str(exc_info.value) + assert "not-a-valid-uuid" in str(exc_info.value) + + def test_validate_arguments_invalid_uuid_too_short(self): + with pytest.raises(StackITInvalidProjectIdError) as exc_info: + StackitProvider.validate_arguments("1234-5678", self.KEY_PATH) + assert "must be a valid UUID format" in str(exc_info.value) + + def test_validate_arguments_uuid_without_hyphens(self): + """Python's UUID() accepts compact form.""" + StackitProvider.validate_arguments( + "12345678123412341234123456789abc", self.KEY_PATH + ) + + def test_validate_arguments_numeric_only_project_id(self): + with pytest.raises(StackITInvalidProjectIdError) as exc_info: + StackitProvider.validate_arguments("123456789012", self.KEY_PATH) + assert "must be a valid UUID format" in str(exc_info.value) + + def test_validate_arguments_uuid_with_uppercase(self): + StackitProvider.validate_arguments( + "12345678-1234-1234-1234-123456789ABC", self.KEY_PATH + ) + + def test_validate_arguments_uuid_with_braces(self): + StackitProvider.validate_arguments( + "{12345678-1234-1234-1234-123456789abc}", self.KEY_PATH + ) + + def test_validate_arguments_uuid_v4_format(self): + StackitProvider.validate_arguments( + "550e8400-e29b-41d4-a716-446655440000", self.KEY_PATH + ) + + def test_validate_arguments_both_invalid(self): + """Key path is checked first.""" + with pytest.raises(StackITNonExistentTokenError): + StackitProvider.validate_arguments("not-a-uuid", "") + + def test_validate_arguments_both_none(self): + """Key path is checked first.""" + with pytest.raises(StackITNonExistentTokenError): + StackitProvider.validate_arguments(None, None) + + def test_validate_arguments_accepts_inline_key_without_path(self): + """Inline key content alone is enough; the path can be omitted.""" + StackitProvider.validate_arguments( + self.VALID_PROJECT_ID, None, '{"keyId": "abc"}' + ) + + def test_validate_arguments_accepts_inline_key_when_path_is_empty(self): + StackitProvider.validate_arguments( + self.VALID_PROJECT_ID, "", '{"keyId": "abc"}' + ) + + def test_validate_arguments_rejects_when_inline_key_is_whitespace(self): + with pytest.raises(StackITNonExistentTokenError): + StackitProvider.validate_arguments(self.VALID_PROJECT_ID, None, " ") + + +class TestStackITProviderInitialization: + def test_init_global_provider_passes_key_path_from_cli(self, monkeypatch): + """The service account key path and inline key content from CLI args + are forwarded to the provider constructor; tokens are not part of + the API anymore.""" + captured_kwargs = {} + + class FakeStackitProvider: + def __init__(self, **kwargs): + captured_kwargs.update(kwargs) + + fake_module = types.SimpleNamespace(StackitProvider=FakeStackitProvider) + arguments = Namespace( + provider="stackit", + stackit_project_id="12345678-1234-1234-1234-123456789abc", + stackit_service_account_key_path="/tmp/sa-key.json", + stackit_service_account_key='{"keyId": "abc"}', + stackit_region=None, + scan_unused_services=False, + config_file="config.yaml", + mutelist_file=None, + fixer_config="fixer_config.yaml", + ) + + monkeypatch.setattr(common_provider.Provider, "_global", None) + + with ( + patch.object(common_provider, "import_module", return_value=fake_module), + patch.object( + common_provider, "load_and_validate_config_file", return_value={} + ), + ): + common_provider.Provider.init_global_provider(arguments) + + assert "api_token" not in captured_kwargs + assert captured_kwargs["project_id"] == arguments.stackit_project_id + assert ( + captured_kwargs["service_account_key_path"] + == arguments.stackit_service_account_key_path + ) + assert ( + captured_kwargs["service_account_key"] + == arguments.stackit_service_account_key + ) + assert captured_kwargs["scan_unused_services"] is False + + def test_init_global_provider_passes_scan_unused_services_true(self, monkeypatch): + """scan_unused_services=True is forwarded to the provider constructor.""" + captured_kwargs = {} + + class FakeStackitProvider: + def __init__(self, **kwargs): + captured_kwargs.update(kwargs) + + fake_module = types.SimpleNamespace(StackitProvider=FakeStackitProvider) + arguments = Namespace( + provider="stackit", + stackit_project_id="12345678-1234-1234-1234-123456789abc", + stackit_service_account_key_path="/tmp/sa-key.json", + stackit_region=None, + scan_unused_services=True, + config_file="config.yaml", + mutelist_file=None, + fixer_config="fixer_config.yaml", + ) + + monkeypatch.setattr(common_provider.Provider, "_global", None) + + with ( + patch.object(common_provider, "import_module", return_value=fake_module), + patch.object( + common_provider, "load_and_validate_config_file", return_value={} + ), + ): + common_provider.Provider.init_global_provider(arguments) + + assert captured_kwargs["scan_unused_services"] is True + + +class TestStackITProviderTestConnection: + KEY_PATH = "/tmp/sa-key.json" + KEY_CONTENT = '{"keyId": "abc", "publicKey": "..."}' + PROJECT_ID = "12345678-1234-1234-1234-123456789abc" + + @pytest.fixture + def fake_stackit_resourcemanager(self, monkeypatch): + class FakeConfiguration: + def __init__(self, service_account_key_path=None, service_account_key=None): + self.service_account_key_path = service_account_key_path + self.service_account_key = service_account_key + + class FakeDefaultApi: + error = None + calls = [] + + def __init__(self, config): + self.config = config + + def get_project(self, id): + self.__class__.calls.append( + ( + self.config.service_account_key_path, + self.config.service_account_key, + id, + ) + ) + if self.__class__.error: + raise self.__class__.error + return {"name": "Test Project"} + + # The SDK is imported at module level, so patch the names bound in the + # provider module rather than sys.modules. + monkeypatch.setattr(stackit_provider_module, "Configuration", FakeConfiguration) + monkeypatch.setattr( + stackit_provider_module, "ResourceManagerDefaultApi", FakeDefaultApi + ) + return FakeDefaultApi + + def test_connection_success_with_key_path(self, fake_stackit_resourcemanager): + connection = StackitProvider.test_connection( + project_id=self.PROJECT_ID, + service_account_key_path=self.KEY_PATH, + ) + + assert connection == Connection(is_connected=True) + assert fake_stackit_resourcemanager.calls == [ + (self.KEY_PATH, None, self.PROJECT_ID) + ] + + def test_connection_success_with_inline_key(self, fake_stackit_resourcemanager): + """The inline key path takes precedence over the file path.""" + connection = StackitProvider.test_connection( + project_id=self.PROJECT_ID, + service_account_key=self.KEY_CONTENT, + ) + + assert connection == Connection(is_connected=True) + assert fake_stackit_resourcemanager.calls == [ + (None, self.KEY_CONTENT, self.PROJECT_ID) + ] + + def test_connection_inline_key_overrides_path(self, fake_stackit_resourcemanager): + connection = StackitProvider.test_connection( + project_id=self.PROJECT_ID, + service_account_key_path=self.KEY_PATH, + service_account_key=self.KEY_CONTENT, + ) + + assert connection == Connection(is_connected=True) + # When the inline key is present the SDK is configured with it and + # the key path is not passed on at all. + assert fake_stackit_resourcemanager.calls == [ + (None, self.KEY_CONTENT, self.PROJECT_ID) + ] + + def test_connection_returns_error_when_raise_on_exception_is_false( + self, fake_stackit_resourcemanager + ): + fake_stackit_resourcemanager.error = RuntimeError("denied") + + connection = StackitProvider.test_connection( + project_id=self.PROJECT_ID, + service_account_key_path=self.KEY_PATH, + raise_on_exception=False, + ) + + assert isinstance(connection.error, StackITAPIError) + assert "Failed to connect to StackIT using Resource Manager" in str( + connection.error + ) + + def test_connection_raises_when_raise_on_exception_is_true( + self, fake_stackit_resourcemanager + ): + fake_stackit_resourcemanager.error = RuntimeError("denied") + + with pytest.raises(StackITAPIError): + StackitProvider.test_connection( + project_id=self.PROJECT_ID, + service_account_key_path=self.KEY_PATH, + raise_on_exception=True, + ) + + +class TestStackITProviderGetProjectName: + @pytest.fixture + def fake_resourcemanager(self, monkeypatch): + class FakeConfiguration: + def __init__(self, service_account_key_path=None, service_account_key=None): + self.service_account_key_path = service_account_key_path + self.service_account_key = service_account_key + + class FakeDefaultApi: + error = None + + def __init__(self, config): + pass + + def get_project(self, id): + if self.__class__.error: + raise self.__class__.error + return {"name": "My Project"} + + # The SDK is imported at module level, so patch the names bound in the + # provider module rather than sys.modules. + monkeypatch.setattr(stackit_provider_module, "Configuration", FakeConfiguration) + monkeypatch.setattr( + stackit_provider_module, "ResourceManagerDefaultApi", FakeDefaultApi + ) + return FakeDefaultApi + + def _make_provider(self): + provider = object.__new__(StackitProvider) + provider._service_account_key_path = "/tmp/sa-key.json" + provider._service_account_key = None + provider._project_id = "12345678-1234-1234-1234-123456789abc" + return provider + + def test_get_project_name_403_returns_empty_string_and_does_not_abort( + self, fake_resourcemanager + ): + """The Resource Manager lookup is cosmetic; a 403 there must NOT + abort the scan because the service account can legitimately hold + IaaS roles on the project without holding Resource Manager roles. + """ + + class Http403Error(Exception): + status = 403 + + fake_resourcemanager.error = Http403Error() + provider = self._make_provider() + + # Must not raise; returns an empty project name so the scan can + # continue and the IaaS service can decide whether *its* endpoints + # are forbidden too. + assert provider._get_project_name() == "" + + def test_get_project_name_401_raises_invalid_token_error( + self, fake_resourcemanager + ): + class Http401Error(Exception): + status = 401 + + fake_resourcemanager.error = Http401Error() + provider = self._make_provider() + + with pytest.raises(StackITInvalidTokenError): + provider._get_project_name() + + def test_get_project_name_other_error_returns_empty_string( + self, fake_resourcemanager + ): + fake_resourcemanager.error = RuntimeError("network error") + provider = self._make_provider() + + result = provider._get_project_name() + + assert result == "" + + +class Test_StackitProvider_Handle_API_Error: + """Direct coverage for ``StackitProvider.handle_api_error`` so 401 and 403 + are treated as credential failures by every service call site, not just + ``_get_project_name``. + """ + + def _http(self, status_code: int) -> Exception: + err = Exception(f"http {status_code}") + err.status = status_code + return err + + def test_401_raises_invalid_token_error(self): + with pytest.raises(StackITInvalidTokenError): + StackitProvider.handle_api_error(self._http(401)) + + def test_403_raises_invalid_token_error(self): + with pytest.raises(StackITInvalidTokenError): + StackitProvider.handle_api_error(self._http(403)) + + def test_other_http_status_is_reraised_unchanged(self): + original = self._http(500) + with pytest.raises(Exception) as excinfo: + StackitProvider.handle_api_error(original) + assert excinfo.value is original + + def test_exception_without_status_is_reraised_unchanged(self): + original = RuntimeError("network error") + with pytest.raises(RuntimeError) as excinfo: + StackitProvider.handle_api_error(original) + assert excinfo.value is original diff --git a/uv.lock b/uv.lock index 80c42e7823..1341671060 100644 --- a/uv.lock +++ b/uv.lock @@ -3319,6 +3319,9 @@ dependencies = [ { name = "schema" }, { name = "shodan" }, { name = "slack-sdk" }, + { name = "stackit-core" }, + { name = "stackit-iaas" }, + { name = "stackit-resourcemanager" }, { name = "tabulate" }, { name = "tzlocal" }, { name = "uuid6" }, @@ -3424,6 +3427,9 @@ requires-dist = [ { name = "schema", specifier = "==0.7.5" }, { name = "shodan", specifier = "==1.31.0" }, { name = "slack-sdk", specifier = "==3.39.0" }, + { name = "stackit-core", specifier = "==0.2.0" }, + { name = "stackit-iaas", specifier = "==1.4.0" }, + { name = "stackit-resourcemanager", specifier = "==0.8.0" }, { name = "tabulate", specifier = "==0.9.0" }, { name = "tzlocal", specifier = "==5.3.1" }, { name = "uuid6", specifier = "==2024.7.10" }, @@ -4257,6 +4263,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "stackit-core" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/90/20f9ec7387eec4067cfd3d29055d0e2b5e1e0322c601a7f48125fd8ea35f/stackit_core-0.2.0.tar.gz", hash = "sha256:b8af91877cdb060d6969a303d8cf20bc0b33b345afd91f679c44a987381e2d47", size = 8987, upload-time = "2025-06-12T08:24:45.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b4/7b53187ce68956870d864ccb9ccfb68066c9df9de1c9568fd2feb03c4504/stackit_core-0.2.0-py3-none-any.whl", hash = "sha256:04632fc6742790d08ddfcb7f2313e04d1254827397a80250f838a2f81b92645b", size = 10240, upload-time = "2025-06-12T08:24:44.214Z" }, +] + +[[package]] +name = "stackit-iaas" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "stackit-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/07/24e65278300d5c3cb19cb1660bff924c80812cf8aad3e715f826bae5aa80/stackit_iaas-1.4.0.tar.gz", hash = "sha256:93523b23442350c7ebefd9129485c4c2a539f694a9c36a0f8edfaba9862057ea", size = 116236, upload-time = "2026-05-13T09:43:15.996Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/51/2201164d7bfacf47539888c735f10f6320c188252384957aa1b23121a210/stackit_iaas-1.4.0-py3-none-any.whl", hash = "sha256:3f4a32321b57ac238f73e5d660c6428186b92cc0425c1f0783ba801e377149d9", size = 316588, upload-time = "2026-05-13T09:43:14.943Z" }, +] + +[[package]] +name = "stackit-resourcemanager" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "stackit-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/2d/f458f18e48ed2b1c83df52cff7dbdfd5dd904fb2980ffd9385876e47bbd9/stackit_resourcemanager-0.8.0.tar.gz", hash = "sha256:f44542beab4130857f5a7f465cf02defeef657bdf63c1beeb3102f0ba3c003fe", size = 33943, upload-time = "2026-05-13T09:43:08.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9c/38a74d0f7a89b4320f6d2366fb660638bda8860daa08748b12c713d84381/stackit_resourcemanager-0.8.0-py3-none-any.whl", hash = "sha256:dd04bb8353d041a137c4dcba190beabded7acfaff1bc98b218fce20a99389ebc", size = 81288, upload-time = "2026-05-13T09:43:07.81Z" }, +] + [[package]] name = "std-uritemplate" version = "2.0.8" From 81226cd8373f92d5a4c5065a4f2d57643e2ae632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pe=C3=B1a?= Date: Thu, 28 May 2026 13:46:15 +0200 Subject: [PATCH 07/14] perf(api): use literal scan_ids in finding-groups /latest aggregation (#11380) --- api/src/backend/api/v1/views.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index a611d873da..067a0403a3 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -7484,14 +7484,17 @@ class FindingGroupViewSet(BaseRLSViewSet): def _get_latest_findings_per_provider(self, filtered_queryset): """Keep only findings from each provider's most recent completed scan.""" - latest_scan_ids = ( + # Materialize to a literal IN list. Left as a subquery, Postgres can't + # estimate the match count and picks a serial nested loop on + # resource_finding_mappings when one scan dominates findings + latest_scan_ids = list( Scan.objects.filter( tenant_id=self.request.tenant_id, state=StateChoices.COMPLETED, ) .order_by("provider_id", "-completed_at", "-inserted_at") .distinct("provider_id") - .values("id") + .values_list("id", flat=True) ) return filtered_queryset.filter(scan_id__in=latest_scan_ids) From 8f745cdbe62d12f496946099162cbaef4f4b2b18 Mon Sep 17 00:00:00 2001 From: "Pablo Fernandez Guerra (PFE)" <148432447+pfe-nazaries@users.noreply.github.com> Date: Thu, 28 May 2026 14:39:57 +0200 Subject: [PATCH 08/14] chore(ui): upgrade pnpm to 11 and harden supply-chain defaults (#11225) Co-authored-by: Pablo F.G --- .config/wt.toml | 9 +- .github/workflows/ui-e2e-tests-v2.yml | 2 +- .github/workflows/ui-tests.yml | 5 +- ui/.npmrc | 3 - ui/AGENTS.md | 6 +- ui/CHANGELOG.md | 6 + ui/Dockerfile | 3 +- ui/package.json | 25 +- ui/pnpm-lock.yaml | 370 ++++++++++++-------------- ui/pnpm-workspace.yaml | 52 +++- 10 files changed, 246 insertions(+), 235 deletions(-) delete mode 100644 ui/.npmrc diff --git a/.config/wt.toml b/.config/wt.toml index c217b4b6e7..fa6883b014 100644 --- a/.config/wt.toml +++ b/.config/wt.toml @@ -11,7 +11,14 @@ envs = "wt step copy-ignored" [[pre-start]] deps = "uv sync" -# Block 3: reminder - last visible output before `wt switch` returns. +# Block 3: prepare pnpm via corepack. +[[pre-start]] +corepack-enable = "corepack enable" + +[[pre-start]] +corepack-install = "cd ui && corepack install" + +# Block 4: reminder - last visible output before `wt switch` returns. # Hooks can't mutate the parent shell, so venv activation is manual. [[pre-start]] reminder = "echo '>> Reminder: activate the venv in this shell with: source .venv/bin/activate'" diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml index 67b4a701da..5e50adb19c 100644 --- a/.github/workflows/ui-e2e-tests-v2.yml +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -172,7 +172,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: '24.13.0' + node-version-file: 'ui/.nvmrc' - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 91e2324c07..918755a629 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -16,7 +16,6 @@ concurrency: env: UI_WORKING_DIR: ./ui - NODE_VERSION: "24.13.0" permissions: {} @@ -93,11 +92,11 @@ jobs: ui/vitest.config.ts ui/vitest.setup.ts - - name: Setup Node.js ${{ env.NODE_VERSION }} + - name: Setup Node.js if: steps.check-changes.outputs.any_changed == 'true' uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: ${{ env.NODE_VERSION }} + node-version-file: 'ui/.nvmrc' - name: Setup pnpm if: steps.check-changes.outputs.any_changed == 'true' diff --git a/ui/.npmrc b/ui/.npmrc deleted file mode 100644 index deb20d2dc7..0000000000 --- a/ui/.npmrc +++ /dev/null @@ -1,3 +0,0 @@ -public-hoist-pattern[]=*@nextui-org/* -public-hoist-pattern[]=*@heroui/* -save-exact=true diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 55b62045ac..38af45860f 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -225,9 +225,9 @@ pnpm run test:e2e:ui ## QA CHECKLIST BEFORE COMMIT -- [ ] `npm run typecheck` passes -- [ ] `npm run lint:fix` passes -- [ ] `npm run format:write` passes +- [ ] `pnpm run typecheck` passes +- [ ] `pnpm run lint:fix` passes +- [ ] `pnpm run format:write` passes - [ ] Relevant E2E tests pass - [ ] All UI states handled (loading, error, empty) - [ ] No secrets in code (use `.env.local`) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 064d723a9b..e0f2dc21d7 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -7,12 +7,18 @@ All notable changes to the **Prowler UI** are documented in this file. ### ๐Ÿ”„ Changed - Dark mode: pure-black canvas, pure-white primary text, and brighter border / input tokens for clearer separation between cards, tables, and inputs [(#11073)](https://github.com/prowler-cloud/prowler/pull/11073) +- CI workflows (`ui-tests.yml`, `ui-e2e-tests-v2.yml`) now read the Node version from `ui/.nvmrc` and the pnpm version from `package.json#packageManager` instead of hardcoded values [(#11225)](https://github.com/prowler-cloud/prowler/pull/11225) ### ๐Ÿž Fixed - Compliance page now loads the most recent scan when opened from the sidebar instead of showing the "no compliance data available" alert [(#11374)](https://github.com/prowler-cloud/prowler/pull/11374) - Invitation links now show specific expired, no-longer-valid, and invalid-token messages based on API error responses [(#11376)](https://github.com/prowler-cloud/prowler/pull/11376) +### ๐Ÿ” Security + +- `pnpm` upgraded to 11 with supply-chain defaults consolidated in `pnpm-workspace.yaml` and `trustPolicyExclude` entries pinned to exact versions [(#11225)](https://github.com/prowler-cloud/prowler/pull/11225) +- `uuid` pinned to `11.1.1` via `pnpm-workspace.yaml#overrides` to clear `GHSA-w5hq-g745-h8pq` (missing bounds check in `v3`/`v5`/`v6` name-based generators with `buf`) in the transitive tree [(#11225)](https://github.com/prowler-cloud/prowler/pull/11225) + --- ## [1.28.1] (Prowler v5.28.1) diff --git a/ui/Dockerfile b/ui/Dockerfile index 9163be77cc..047df1171b 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -1,3 +1,4 @@ +# Keep in sync with ui/.nvmrc. FROM node:24.13.0-alpine@sha256:cd6fb7efa6490f039f3471a189214d5f548c11df1ff9e5b181aa49e22c14383e AS base LABEL maintainer="https://github.com/prowler-cloud" @@ -13,7 +14,7 @@ RUN apk add --no-cache libc6-compat WORKDIR /app # Install dependencies based on the preferred package manager -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY scripts ./scripts RUN corepack install && pnpm install --frozen-lockfile diff --git a/ui/package.json b/ui/package.json index b03d7a55b5..8466539961 100644 --- a/ui/package.json +++ b/ui/package.json @@ -161,27 +161,10 @@ "vitest": "4.0.18", "vitest-browser-react": "2.0.4" }, - "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", - "pnpm": { - "overrides": { - "@hono/node-server": "1.19.14", - "@internationalized/date": "3.10.0", - "@isaacs/brace-expansion": "5.0.1", - "@react-aria/interactions>react": "19.2.6", - "@react-aria/ssr>react": "19.2.6", - "@react-aria/ssr>react-dom": "19.2.6", - "@react-aria/visually-hidden>react": "19.2.6", - "fast-xml-parser": "5.8.0", - "hono": "4.12.18", - "lodash": "4.18.1", - "lodash-es": "4.18.1", - "minimatch@>=9 <10": "9.0.7", - "minimatch@>=10": "10.2.3", - "minimatch@<4": "3.1.4", - "qs": "6.14.2", - "rollup@>=4": "4.59.0", - "serialize-javascript": "7.0.5" - } + "packageManager": "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d", + "engines": { + "node": ">=22.13", + "pnpm": ">=11.1.3" }, "msw": { "workerDirectory": [ diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index a673d6e428..3996bbb8a8 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -5,23 +5,28 @@ settings: excludeLinksFromLockfile: false overrides: - '@hono/node-server': 1.19.14 + '@react-types/shared': 3.26.0 '@internationalized/date': 3.10.0 - '@isaacs/brace-expansion': 5.0.1 - '@react-aria/interactions>react': 19.2.6 '@react-aria/ssr>react': 19.2.6 '@react-aria/ssr>react-dom': 19.2.6 '@react-aria/visually-hidden>react': 19.2.6 - fast-xml-parser: 5.8.0 - hono: 4.12.18 + '@react-aria/interactions>react': 19.2.6 lodash: 4.18.1 lodash-es: 4.18.1 + hono: 4.12.18 + '@hono/node-server': 1.19.14 + '@isaacs/brace-expansion': 5.0.1 + fast-xml-parser: 5.8.0 + serialize-javascript: 7.0.5 + rollup@>=4: 4.59.0 + minimatch@<4: 3.1.4 minimatch@>=9 <10: 9.0.7 minimatch@>=10: 10.2.3 - minimatch@<4: 3.1.4 + ajv@<7: 6.14.0 + ajv@>=8: 8.18.0 qs: 6.14.2 - rollup@>=4: 4.59.0 - serialize-javascript: 7.0.5 + express-rate-limit: 8.5.1 + uuid: 11.1.1 importers: @@ -369,7 +374,7 @@ importers: version: 17.0.0 jsdom: specifier: 27.4.0 - version: 27.4.0(@noble/hashes@1.8.0) + version: 27.4.0 knip: specifier: 6.3.1 version: 6.3.1(@emnapi/core@1.8.1)(@emnapi/runtime@1.10.0) @@ -396,7 +401,7 @@ importers: version: 5.5.4 vitest: specifier: 4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) vitest-browser-react: specifier: 2.0.4 version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.0.18) @@ -2150,10 +2155,6 @@ packages: next: ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0-beta.0 react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} - '@nodable/entities@2.1.0': resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} @@ -3824,16 +3825,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/shared@3.32.0': - resolution: {integrity: sha512-t+cligIJsZYFMSPFMvsJMjzlzde06tZMOIOFa1OV5Z0BcMowrb2g4mB57j/9nP28iJIRYn10xCniQts+qadrqQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - - '@react-types/shared@3.34.0': - resolution: {integrity: sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/slider@3.8.2': resolution: {integrity: sha512-MQYZP76OEOYe7/yA2To+Dl0LNb0cKKnvh5JtvNvDnAvEprn1RuLiay8Oi/rTtXmc2KmBa4VdTcsXsmkbbkeN2Q==} peerDependencies: @@ -4188,6 +4179,7 @@ packages: '@smithy/core@3.24.1': resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==} engines: {node: '>=18.0.0'} + deprecated: Deprecated due to bug in browser bundling instructions https://github.com/smithy-lang/smithy-typescript/issues/2025 '@smithy/credential-provider-imds@4.3.1': resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==} @@ -4788,6 +4780,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} @@ -4958,7 +4951,7 @@ packages: ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: - ajv: ^8.0.0 + ajv: 8.18.0 peerDependenciesMeta: ajv: optional: true @@ -4966,7 +4959,7 @@ packages: ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: - ajv: ^8.0.0 + ajv: 8.18.0 peerDependenciesMeta: ajv: optional: true @@ -4974,7 +4967,7 @@ packages: ajv-keywords@5.1.0: resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} peerDependencies: - ajv: ^8.8.2 + ajv: 8.18.0 ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} @@ -6053,6 +6046,7 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true globals@14.0.0: @@ -8273,17 +8267,8 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - uuid@10.0.0: - resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). - hasBin: true - - uuid@13.0.2: - resolution: {integrity: sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==} - hasBin: true - - uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} hasBin: true vary@1.1.2: @@ -9568,9 +9553,7 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@exodus/bytes@1.14.1(@noble/hashes@1.8.0)': - optionalDependencies: - '@noble/hashes': 1.8.0 + '@exodus/bytes@1.14.1': {} '@extractus/feed-extractor@7.1.7': dependencies: @@ -9640,7 +9623,7 @@ snapshots: '@react-aria/interactions': 3.25.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/tree': 3.9.2(react@19.2.6) '@react-types/accordion': 3.0.0-alpha.26(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) framer-motion: 11.18.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -9665,7 +9648,7 @@ snapshots: '@react-aria/utils': 3.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/collections': 3.12.7(react@19.2.6) '@react-types/overlays': 3.9.1(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: @@ -9691,7 +9674,7 @@ snapshots: '@react-aria/i18n': 3.12.12(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/combobox': 3.11.1(react@19.2.6) '@react-types/combobox': 3.13.8(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) framer-motion: 11.18.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -9743,7 +9726,7 @@ snapshots: '@heroui/use-aria-button': 2.2.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/focus': 3.21.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/interactions': 3.25.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) framer-motion: 11.18.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -9769,7 +9752,7 @@ snapshots: '@react-stately/utils': 3.10.8(react@19.2.6) '@react-types/button': 3.14.0(react@19.2.6) '@react-types/calendar': 3.7.4(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) framer-motion: 11.18.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -9785,7 +9768,7 @@ snapshots: '@heroui/use-aria-button': 2.2.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/focus': 3.21.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/interactions': 3.25.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) framer-motion: 11.18.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -9805,7 +9788,7 @@ snapshots: '@react-stately/checkbox': 3.7.1(react@19.2.6) '@react-stately/toggle': 3.9.1(react@19.2.6) '@react-types/checkbox': 3.10.1(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -9842,7 +9825,7 @@ snapshots: '@react-aria/i18n': 3.12.12(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/datepicker': 3.15.1(react@19.2.6) '@react-types/datepicker': 3.13.1(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -9865,7 +9848,7 @@ snapshots: '@react-stately/datepicker': 3.15.1(react@19.2.6) '@react-stately/utils': 3.10.8(react@19.2.6) '@react-types/datepicker': 3.13.1(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) framer-motion: 11.18.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -9875,7 +9858,7 @@ snapshots: '@heroui/react-rsc-utils': 2.1.9(react@19.2.6) '@heroui/system-rsc': 2.3.19(@heroui/theme@2.4.22(tailwindcss@4.1.18))(react@19.2.6) '@heroui/theme': 2.4.22(tailwindcss@4.1.18) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -9920,7 +9903,7 @@ snapshots: '@heroui/theme': 2.4.22(tailwindcss@4.1.18) '@react-stately/form': 3.2.1(react@19.2.6) '@react-types/form': 3.7.15(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -9974,7 +9957,7 @@ snapshots: '@react-aria/interactions': 3.25.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/textfield': 3.18.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/utils': 3.10.8(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@react-types/textfield': 3.12.5(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -10017,7 +10000,7 @@ snapshots: '@react-aria/interactions': 3.25.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/listbox': 3.14.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/list': 3.13.0(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@tanstack/react-virtual': 3.11.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -10038,7 +10021,7 @@ snapshots: '@react-aria/menu': 3.19.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/tree': 3.9.2(react@19.2.6) '@react-types/menu': 3.10.4(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: @@ -10103,7 +10086,7 @@ snapshots: '@react-stately/numberfield': 3.10.1(react@19.2.6) '@react-types/button': 3.14.0(react@19.2.6) '@react-types/numberfield': 3.8.14(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: @@ -10173,7 +10156,7 @@ snapshots: '@react-aria/visually-hidden': 3.8.27(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/radio': 3.11.1(react@19.2.6) '@react-types/radio': 3.9.1(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -10288,7 +10271,7 @@ snapshots: '@react-aria/interactions': 3.25.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/overlays': 3.29.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/visually-hidden': 3.8.27(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) framer-motion: 11.18.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -10378,7 +10361,7 @@ snapshots: '@heroui/system-rsc@2.3.19(@heroui/theme@2.4.22(tailwindcss@4.1.18))(react@19.2.6)': dependencies: '@heroui/theme': 2.4.22(tailwindcss@4.1.18) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) clsx: 1.2.1 react: 19.2.6 @@ -10428,7 +10411,7 @@ snapshots: '@react-aria/interactions': 3.25.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/tabs': 3.10.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/tabs': 3.8.5(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) framer-motion: 11.18.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -10489,7 +10472,7 @@ snapshots: '@react-aria/selection': 3.25.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/tree': 3.9.2(react@19.2.6) '@react-types/accordion': 3.0.0-alpha.26(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 transitivePeerDependencies: - react-dom @@ -10500,7 +10483,7 @@ snapshots: '@react-aria/interactions': 3.25.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/utils': 3.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-types/button': 3.14.0(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 transitivePeerDependencies: - react-dom @@ -10511,7 +10494,7 @@ snapshots: '@react-aria/interactions': 3.25.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/utils': 3.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-types/link': 3.6.4(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 transitivePeerDependencies: - react-dom @@ -10539,7 +10522,7 @@ snapshots: '@react-stately/menu': 3.9.7(react@19.2.6) '@react-types/button': 3.14.0(react@19.2.6) '@react-types/overlays': 3.9.1(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -10548,7 +10531,7 @@ snapshots: '@react-aria/focus': 3.21.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/interactions': 3.25.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/overlays': 3.29.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -10958,7 +10941,7 @@ snapshots: '@langchain/langgraph-checkpoint@1.0.2(@langchain/core@1.1.45(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(openai@6.37.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1))': dependencies: '@langchain/core': 1.1.45(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(openai@6.37.0(ws@8.20.1)(zod@4.4.3))(ws@8.20.1) - uuid: 10.0.0 + uuid: 11.1.1 '@langchain/langgraph-sdk@1.9.2(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(openai@6.37.0(ws@8.20.1)(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(ws@8.20.1)': dependencies: @@ -10967,7 +10950,7 @@ snapshots: '@types/json-schema': 7.0.15 p-queue: 9.2.0 p-retry: 7.1.1 - uuid: 13.0.2 + uuid: 11.1.1 optionalDependencies: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -10985,7 +10968,7 @@ snapshots: '@langchain/langgraph-sdk': 1.9.2(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(openai@6.37.0(ws@8.20.1)(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(ws@8.20.1) '@langchain/protocol': 0.0.15 '@standard-schema/spec': 1.1.0 - uuid: 10.0.0 + uuid: 11.1.1 zod: 4.4.3 optionalDependencies: zod-to-json-schema: 3.25.1(zod@4.4.3) @@ -11120,9 +11103,6 @@ snapshots: react: 19.2.6 third-party-capital: 1.0.20 - '@noble/hashes@1.8.0': - optional: true - '@nodable/entities@2.1.0': {} '@nodelib/fs.scandir@2.1.5': @@ -12150,7 +12130,7 @@ snapshots: '@react-aria/link': 3.8.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-types/breadcrumbs': 3.7.16(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12162,7 +12142,7 @@ snapshots: '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/toggle': 3.9.1(react@19.2.6) '@react-types/button': 3.14.1(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12177,7 +12157,7 @@ snapshots: '@react-stately/calendar': 3.8.4(react@19.2.6) '@react-types/button': 3.14.0(react@19.2.6) '@react-types/calendar': 3.7.4(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12193,7 +12173,7 @@ snapshots: '@react-stately/form': 3.2.2(react@19.2.6) '@react-stately/toggle': 3.9.1(react@19.2.6) '@react-types/checkbox': 3.10.1(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12214,7 +12194,7 @@ snapshots: '@react-stately/form': 3.2.2(react@19.2.6) '@react-types/button': 3.14.1(react@19.2.6) '@react-types/combobox': 3.13.8(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12237,7 +12217,7 @@ snapshots: '@react-types/calendar': 3.8.1(react@19.2.6) '@react-types/datepicker': 3.13.2(react@19.2.6) '@react-types/dialog': 3.5.22(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12248,7 +12228,7 @@ snapshots: '@react-aria/overlays': 3.29.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-types/dialog': 3.5.22(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12257,7 +12237,7 @@ snapshots: dependencies: '@react-aria/interactions': 3.25.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 clsx: 2.1.1 react: 19.2.6 @@ -12267,7 +12247,7 @@ snapshots: dependencies: '@react-aria/interactions': 3.26.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 clsx: 2.1.1 react: 19.2.6 @@ -12278,7 +12258,7 @@ snapshots: '@react-aria/interactions': 3.25.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/form': 3.2.2(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12288,7 +12268,7 @@ snapshots: '@react-aria/interactions': 3.26.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/form': 3.2.2(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12306,7 +12286,7 @@ snapshots: '@react-stately/selection': 3.20.7(react@19.2.6) '@react-types/checkbox': 3.10.2(react@19.2.6) '@react-types/grid': 3.3.6(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12319,7 +12299,7 @@ snapshots: '@internationalized/string': 3.2.7 '@react-aria/ssr': 3.9.10(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12332,7 +12312,7 @@ snapshots: '@internationalized/string': 3.2.7 '@react-aria/ssr': 3.9.10(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12345,7 +12325,7 @@ snapshots: '@internationalized/string': 3.2.7 '@react-aria/ssr': 3.9.10(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12355,7 +12335,7 @@ snapshots: '@react-aria/ssr': 3.9.10(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/flags': 3.1.2 - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12365,7 +12345,7 @@ snapshots: '@react-aria/ssr': 3.9.10(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/flags': 3.1.2 - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.18 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12373,7 +12353,7 @@ snapshots: '@react-aria/label@3.7.21(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@react-aria/utils': 3.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12381,7 +12361,7 @@ snapshots: '@react-aria/label@3.7.23(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12389,7 +12369,7 @@ snapshots: '@react-aria/landmark@3.0.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12400,7 +12380,7 @@ snapshots: '@react-aria/interactions': 3.26.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-types/link': 3.6.5(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12414,7 +12394,7 @@ snapshots: '@react-stately/collections': 3.12.8(react@19.2.6) '@react-stately/list': 3.13.0(react@19.2.6) '@react-types/listbox': 3.7.4(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12428,7 +12408,7 @@ snapshots: '@react-stately/collections': 3.12.8(react@19.2.6) '@react-stately/list': 3.13.2(react@19.2.6) '@react-types/listbox': 3.7.4(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12451,7 +12431,7 @@ snapshots: '@react-stately/tree': 3.9.2(react@19.2.6) '@react-types/button': 3.14.1(react@19.2.6) '@react-types/menu': 3.10.4(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12470,7 +12450,7 @@ snapshots: '@react-stately/tree': 3.9.4(react@19.2.6) '@react-types/button': 3.14.1(react@19.2.6) '@react-types/menu': 3.10.5(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12486,7 +12466,7 @@ snapshots: '@react-stately/numberfield': 3.10.1(react@19.2.6) '@react-types/button': 3.14.0(react@19.2.6) '@react-types/numberfield': 3.8.14(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12502,7 +12482,7 @@ snapshots: '@react-stately/overlays': 3.6.19(react@19.2.6) '@react-types/button': 3.14.1(react@19.2.6) '@react-types/overlays': 3.9.2(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12518,7 +12498,7 @@ snapshots: '@react-stately/overlays': 3.6.21(react@19.2.6) '@react-types/button': 3.14.1(react@19.2.6) '@react-types/overlays': 3.9.2(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12529,7 +12509,7 @@ snapshots: '@react-aria/label': 3.7.23(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-types/progress': 3.5.15(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12544,7 +12524,7 @@ snapshots: '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/radio': 3.11.1(react@19.2.6) '@react-types/radio': 3.9.1(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12556,7 +12536,7 @@ snapshots: '@react-aria/interactions': 3.25.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/utils': 3.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/selection': 3.20.7(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12568,7 +12548,7 @@ snapshots: '@react-aria/interactions': 3.26.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/selection': 3.20.7(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12580,7 +12560,7 @@ snapshots: '@react-aria/label': 3.7.23(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/slider': 3.7.1(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@react-types/slider': 3.8.2(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12592,7 +12572,7 @@ snapshots: '@react-aria/live-announcer': 3.4.4 '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-types/button': 3.14.1(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12611,7 +12591,7 @@ snapshots: dependencies: '@react-aria/toggle': 3.12.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/toggle': 3.9.1(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@react-types/switch': 3.5.15(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12631,7 +12611,7 @@ snapshots: '@react-stately/table': 3.15.0(react@19.2.6) '@react-types/checkbox': 3.10.2(react@19.2.6) '@react-types/grid': 3.3.5(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@react-types/table': 3.13.3(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12644,7 +12624,7 @@ snapshots: '@react-aria/selection': 3.27.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/tabs': 3.8.5(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@react-types/tabs': 3.3.20(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12658,7 +12638,7 @@ snapshots: '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/form': 3.2.2(react@19.2.6) '@react-stately/utils': 3.10.8(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@react-types/textfield': 3.12.5(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12672,7 +12652,7 @@ snapshots: '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/form': 3.2.2(react@19.2.6) '@react-stately/utils': 3.11.0(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@react-types/textfield': 3.12.6(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12686,7 +12666,7 @@ snapshots: '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/toast': 3.1.2(react@19.2.6) '@react-types/button': 3.14.1(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12697,7 +12677,7 @@ snapshots: '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/toggle': 3.9.3(react@19.2.6) '@react-types/checkbox': 3.10.2(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12707,7 +12687,7 @@ snapshots: '@react-aria/focus': 3.21.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/i18n': 3.12.13(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12717,7 +12697,7 @@ snapshots: '@react-aria/interactions': 3.26.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-stately/tooltip': 3.5.7(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@react-types/tooltip': 3.4.20(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12728,7 +12708,7 @@ snapshots: '@react-aria/ssr': 3.9.10(react@19.2.6) '@react-stately/flags': 3.1.2 '@react-stately/utils': 3.10.8(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 clsx: 2.1.1 react: 19.2.6 @@ -12739,7 +12719,7 @@ snapshots: '@react-aria/ssr': 3.9.10(react@19.2.6) '@react-stately/flags': 3.1.2 '@react-stately/utils': 3.11.0(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.18 clsx: 2.1.1 react: 19.2.6 @@ -12759,7 +12739,7 @@ snapshots: dependencies: '@react-aria/interactions': 3.26.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.18 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12768,7 +12748,7 @@ snapshots: dependencies: '@react-aria/interactions': 3.26.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12778,7 +12758,7 @@ snapshots: '@internationalized/date': 3.10.0 '@react-stately/utils': 3.10.8(react@19.2.6) '@react-types/calendar': 3.7.4(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12787,19 +12767,19 @@ snapshots: '@react-stately/form': 3.2.2(react@19.2.6) '@react-stately/utils': 3.10.8(react@19.2.6) '@react-types/checkbox': 3.10.1(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 '@react-stately/collections@3.12.7(react@19.2.6)': dependencies: - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 '@react-stately/collections@3.12.8(react@19.2.6)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12812,7 +12792,7 @@ snapshots: '@react-stately/select': 3.9.0(react@19.2.6) '@react-stately/utils': 3.10.8(react@19.2.6) '@react-types/combobox': 3.13.8(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12824,7 +12804,7 @@ snapshots: '@react-stately/overlays': 3.6.21(react@19.2.6) '@react-stately/utils': 3.10.8(react@19.2.6) '@react-types/datepicker': 3.13.2(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12834,13 +12814,13 @@ snapshots: '@react-stately/form@3.2.1(react@19.2.6)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 '@react-stately/form@3.2.2(react@19.2.6)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12849,7 +12829,7 @@ snapshots: '@react-stately/collections': 3.12.8(react@19.2.6) '@react-stately/selection': 3.20.7(react@19.2.6) '@react-types/grid': 3.3.6(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12858,7 +12838,7 @@ snapshots: '@react-stately/collections': 3.12.8(react@19.2.6) '@react-stately/selection': 3.20.7(react@19.2.6) '@react-stately/utils': 3.10.8(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12867,7 +12847,7 @@ snapshots: '@react-stately/collections': 3.12.8(react@19.2.6) '@react-stately/selection': 3.20.7(react@19.2.6) '@react-stately/utils': 3.11.0(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12875,7 +12855,7 @@ snapshots: dependencies: '@react-stately/overlays': 3.6.21(react@19.2.6) '@react-types/menu': 3.10.4(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12883,7 +12863,7 @@ snapshots: dependencies: '@react-stately/overlays': 3.6.21(react@19.2.6) '@react-types/menu': 3.10.5(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12915,7 +12895,7 @@ snapshots: '@react-stately/form': 3.2.2(react@19.2.6) '@react-stately/utils': 3.10.8(react@19.2.6) '@react-types/radio': 3.9.1(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12926,7 +12906,7 @@ snapshots: '@react-stately/overlays': 3.6.21(react@19.2.6) '@react-stately/utils': 3.11.0(react@19.2.6) '@react-types/select': 3.12.0(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12934,14 +12914,14 @@ snapshots: dependencies: '@react-stately/collections': 3.12.8(react@19.2.6) '@react-stately/utils': 3.11.0(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 '@react-stately/slider@3.7.1(react@19.2.6)': dependencies: '@react-stately/utils': 3.10.8(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@react-types/slider': 3.8.2(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12954,7 +12934,7 @@ snapshots: '@react-stately/selection': 3.20.7(react@19.2.6) '@react-stately/utils': 3.10.8(react@19.2.6) '@react-types/grid': 3.3.5(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@react-types/table': 3.13.3(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12962,7 +12942,7 @@ snapshots: '@react-stately/tabs@3.8.5(react@19.2.6)': dependencies: '@react-stately/list': 3.13.2(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@react-types/tabs': 3.3.20(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12977,7 +12957,7 @@ snapshots: dependencies: '@react-stately/utils': 3.10.8(react@19.2.6) '@react-types/checkbox': 3.10.2(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -12985,7 +12965,7 @@ snapshots: dependencies: '@react-stately/utils': 3.11.0(react@19.2.6) '@react-types/checkbox': 3.10.2(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -13001,7 +12981,7 @@ snapshots: '@react-stately/collections': 3.12.8(react@19.2.6) '@react-stately/selection': 3.20.7(react@19.2.6) '@react-stately/utils': 3.10.8(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -13010,7 +12990,7 @@ snapshots: '@react-stately/collections': 3.12.8(react@19.2.6) '@react-stately/selection': 3.20.7(react@19.2.6) '@react-stately/utils': 3.11.0(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 @@ -13027,57 +13007,57 @@ snapshots: '@react-stately/virtualizer@4.4.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@react-aria/utils': 3.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) '@swc/helpers': 0.5.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) '@react-types/accordion@3.0.0-alpha.26(react@19.2.6)': dependencies: - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/breadcrumbs@3.7.16(react@19.2.6)': dependencies: '@react-types/link': 3.6.5(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/button@3.14.0(react@19.2.6)': dependencies: - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/button@3.14.1(react@19.2.6)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/calendar@3.7.4(react@19.2.6)': dependencies: '@internationalized/date': 3.10.0 - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/calendar@3.8.1(react@19.2.6)': dependencies: '@internationalized/date': 3.10.0 - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/checkbox@3.10.1(react@19.2.6)': dependencies: - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/checkbox@3.10.2(react@19.2.6)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/combobox@3.13.8(react@19.2.6)': dependencies: - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/datepicker@3.13.1(react@19.2.6)': @@ -13085,7 +13065,7 @@ snapshots: '@internationalized/date': 3.10.0 '@react-types/calendar': 3.8.1(react@19.2.6) '@react-types/overlays': 3.9.2(react@19.2.6) - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/datepicker@3.13.2(react@19.2.6)': @@ -13093,134 +13073,126 @@ snapshots: '@internationalized/date': 3.10.0 '@react-types/calendar': 3.8.1(react@19.2.6) '@react-types/overlays': 3.9.2(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/dialog@3.5.22(react@19.2.6)': dependencies: '@react-types/overlays': 3.9.2(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/form@3.7.15(react@19.2.6)': dependencies: - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/grid@3.3.5(react@19.2.6)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/grid@3.3.6(react@19.2.6)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/link@3.6.4(react@19.2.6)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/link@3.6.5(react@19.2.6)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/listbox@3.7.4(react@19.2.6)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/menu@3.10.4(react@19.2.6)': dependencies: '@react-types/overlays': 3.9.2(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/menu@3.10.5(react@19.2.6)': dependencies: '@react-types/overlays': 3.9.2(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/numberfield@3.8.14(react@19.2.6)': dependencies: - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/overlays@3.9.1(react@19.2.6)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/overlays@3.9.2(react@19.2.6)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/progress@3.5.15(react@19.2.6)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/radio@3.9.1(react@19.2.6)': dependencies: - '@react-types/shared': 3.32.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/select@3.12.0(react@19.2.6)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/shared@3.26.0(react@19.2.6)': dependencies: react: 19.2.6 - '@react-types/shared@3.32.0(react@19.2.6)': - dependencies: - react: 19.2.6 - - '@react-types/shared@3.34.0(react@19.2.6)': - dependencies: - react: 19.2.6 - '@react-types/slider@3.8.2(react@19.2.6)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/switch@3.5.15(react@19.2.6)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/table@3.13.3(react@19.2.6)': dependencies: '@react-types/grid': 3.3.5(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/tabs@3.3.20(react@19.2.6)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/textfield@3.12.5(react@19.2.6)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/textfield@3.12.6(react@19.2.6)': dependencies: - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@react-types/tooltip@3.4.20(react@19.2.6)': dependencies: '@react-types/overlays': 3.9.1(react@19.2.6) - '@react-types/shared': 3.34.0(react@19.2.6) + '@react-types/shared': 3.26.0(react@19.2.6) react: 19.2.6 '@rolldown/pluginutils@1.0.0-beta.53': {} @@ -13516,7 +13488,7 @@ snapshots: dependencies: '@sentry/bundler-plugin-core': 4.7.0 unplugin: 1.0.1 - uuid: 9.0.1 + uuid: 11.1.1 webpack: 5.104.1(lightningcss@1.30.2)(postcss@8.5.14) transitivePeerDependencies: - encoding @@ -14295,7 +14267,7 @@ snapshots: '@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) playwright: 1.56.1 tinyrainbow: 3.1.0 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) transitivePeerDependencies: - bufferutil - msw @@ -14311,7 +14283,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) ws: 8.20.1 transitivePeerDependencies: - bufferutil @@ -14331,7 +14303,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.1.0 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) optionalDependencies: '@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18) @@ -15967,9 +15939,9 @@ snapshots: hono@4.12.18: {} - html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + html-encoding-sniffer@6.0.0: dependencies: - '@exodus/bytes': 1.14.1(@noble/hashes@1.8.0) + '@exodus/bytes': 1.14.1 transitivePeerDependencies: - '@noble/hashes' @@ -16263,15 +16235,15 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@27.4.0(@noble/hashes@1.8.0): + jsdom@27.4.0: dependencies: '@acemir/cssom': 0.9.31 '@asamuzakjp/dom-selector': 6.8.1 - '@exodus/bytes': 1.14.1(@noble/hashes@1.8.0) + '@exodus/bytes': 1.14.1 cssstyle: 5.3.7 data-urls: 6.0.1 decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + html-encoding-sniffer: 6.0.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 @@ -16704,7 +16676,7 @@ snapshots: roughjs: 4.6.6 stylis: 4.3.6 ts-dedent: 2.2.0 - uuid: 13.0.2 + uuid: 11.1.1 micromark-core-commonmark@2.0.3: dependencies: @@ -18454,11 +18426,7 @@ snapshots: util-deprecate@1.0.2: {} - uuid@10.0.0: {} - - uuid@13.0.2: {} - - uuid@9.0.1: {} + uuid@11.1.1: {} vary@1.1.2: {} @@ -18523,12 +18491,12 @@ snapshots: dependencies: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) optionalDependencies: '@types/react': 19.2.8 '@types/react-dom': 19.2.3(@types/react@19.2.8) - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0): dependencies: '@vitest/expect': 4.0.18 '@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) @@ -18554,7 +18522,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@types/node': 24.10.8 '@vitest/browser-playwright': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18) - jsdom: 27.4.0(@noble/hashes@1.8.0) + jsdom: 27.4.0 transitivePeerDependencies: - jiti - less diff --git a/ui/pnpm-workspace.yaml b/ui/pnpm-workspace.yaml index 26ebd51565..e4ce0ceaf9 100644 --- a/ui/pnpm-workspace.yaml +++ b/ui/pnpm-workspace.yaml @@ -1,7 +1,49 @@ +# pnpm 11+ workspace config. .npmrc is auth/registry only; everything else lives here. # Reference: https://pnpm.io/supply-chain-security packages: [] +# Refuse to install on Node/pnpm outside the `engines` block in package.json. +engineStrict: true + +# Hoist the HeroUI family so its legacy peer-dep pattern resolves. +publicHoistPattern: + - "*@heroui/*" + +# Default `pnpm add` to exact versions โ€” matches package.json convention. +saveExact: true + +# --- Dependency overrides --- +overrides: + "@react-types/shared": "3.26.0" + "@internationalized/date": "3.10.0" + "@react-aria/ssr>react": "19.2.6" + "@react-aria/ssr>react-dom": "19.2.6" + "@react-aria/visually-hidden>react": "19.2.6" + "@react-aria/interactions>react": "19.2.6" + "lodash": "4.18.1" + "lodash-es": "4.18.1" + "hono": "4.12.18" + "@hono/node-server": "1.19.14" + "@isaacs/brace-expansion": "5.0.1" + "fast-xml-parser": "5.8.0" + "serialize-javascript": "7.0.5" + "rollup@>=4": "4.59.0" + "minimatch@<4": "3.1.4" + "minimatch@>=9 <10": "9.0.7" + "minimatch@>=10": "10.2.3" + "ajv@<7": "6.14.0" + "ajv@>=8": "8.18.0" + "qs": "6.14.2" + # 8.2.2 dropped provenance attestation; 8.3.1+ restored it. Pinned to skip 8.2.2 + # under `trustPolicy: no-downgrade`. + "express-rate-limit": "8.5.1" + # GHSA-w5hq-g745-h8pq: missing bounds check in v3/v5/v6 with buf, fixed in + # 11.1.1. Transitive consumers (@sentry/webpack-plugin@9, @langchain/langgraph@10) + # use the random v4 generator only, so the bug isn't reachable in practice, + # but the override unifies the tree on a patched version. + "uuid": "11.1.1" + # --- Level 1: Minimum Release Age --- # Packages must be published for at least 1 day before they can be installed. # Prevents installing compromised packages during the detection window. @@ -33,7 +75,15 @@ allowBuilds: # --- Level 3: Trust Policy + Exotic Subdeps --- # Fail when a package's trust evidence is downgraded (e.g., new publisher). trustPolicy: no-downgrade -trustPolicyExclude: [] +# False positives โ€” packages that don't publish provenance for real releases. +# Pin to the version range that lacks provenance so a bump fails until reviewed. +trustPolicyExclude: + # next-auth: only one one-off manual test release (`0.0.0-manual.2824fa11`) has + # provenance; real beta/stable releases don't. Scoped to current beta line. + - "next-auth@5.0.0-beta.30" + # semver: legacy major 6.x never had provenance (added in 7.5.1+). Pinned + # to the exact 6.x version pulled transitively (via @babel/helper-compilation-targets). + - "semver@6.3.1" # Block transitive dependencies from using exotic specifiers (git URLs, tarballs). blockExoticSubdeps: true From 74251350bcfddf2475185306771e2dcf4c4fe51f Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Thu, 28 May 2026 19:20:39 +0200 Subject: [PATCH 09/14] feat(ui): add new scan jobs view (#11258) --- ui/CHANGELOG.md | 4 + ui/actions/scans/scans.ts | 2 + ui/actions/task/index.ts | 1 + ui/actions/task/task.adapter.test.ts | 91 +++++ ui/actions/task/task.adapter.ts | 55 +++ .../_components/accounts-selector.test.tsx | 88 ++++- .../_components/accounts-selector.tsx | 120 ++++--- .../provider-type-selector.test.tsx | 6 +- .../_components/provider-type-selector.tsx | 19 +- .../risk-pipeline-view.ssr.tsx | 4 +- .../graphs-tabs/risk-plot/risk-plot.ssr.tsx | 4 +- .../__tests__/alert-form-modal.test.tsx | 10 +- ui/app/(prowler)/alerts/page.tsx | 4 +- ui/app/(prowler)/findings/page.tsx | 4 +- .../(prowler)/integrations/amazon-s3/page.tsx | 4 +- .../integrations/aws-security-hub/page.tsx | 4 +- ui/app/(prowler)/page.tsx | 10 +- .../providers/provider-groups-content.tsx | 4 +- .../providers/providers-page.utils.test.ts | 4 + .../providers/providers-page.utils.ts | 4 +- ui/app/(prowler)/resources/page.tsx | 4 +- ui/app/(prowler)/scans/page.tsx | 198 +++++------ .../compliance-scan-info.tsx | 2 +- .../provider-account-selectors.test.tsx | 247 +++++++++++++ .../filters/provider-account-selectors.tsx | 200 +++++++++++ ui/components/findings/findings-filters.tsx | 46 +-- ui/components/resources/resources-filters.tsx | 29 +- ui/components/scans/edit-alias-modal.test.tsx | 203 +++++++++++ ui/components/scans/edit-alias-modal.tsx | 127 +++++++ ui/components/scans/forms/edit-scan-form.tsx | 92 ----- ui/components/scans/forms/index.ts | 2 - ui/components/scans/forms/schedule-form.tsx | 86 ----- ui/components/scans/index.ts | 3 - .../scans/launch-scan-modal.test.tsx | 306 ++++++++++++++++ ui/components/scans/launch-scan-modal.tsx | 163 +++++++++ ui/components/scans/launch-workflow/index.ts | 2 - .../launch-scan-workflow-form.tsx | 154 -------- .../launch-workflow/select-scan-provider.tsx | 95 ----- ui/components/scans/no-scans-empty-state.tsx | 55 +++ ui/components/scans/scan-alias-validation.ts | 16 + .../scans/scan-error-details-modal.test.tsx | 102 ++++++ .../scans/scan-error-details-modal.tsx | 67 ++++ .../scans/scan-error-details-view.test.tsx | 69 ++++ .../scans/scan-error-details-view.tsx | 35 ++ ui/components/scans/scans-filter-bar.tsx | 84 +++++ ui/components/scans/scans-filters.tsx | 103 ------ .../scans/scans-launch-section.test.tsx | 62 ---- ui/components/scans/scans-launch-section.tsx | 47 --- ui/components/scans/scans-page-shell.test.tsx | 335 ++++++++++++++++++ ui/components/scans/scans-page-shell.tsx | 140 ++++++++ .../scans-providers-empty-state.test.tsx | 37 ++ .../scans/scans-providers-empty-state.tsx | 32 ++ ui/components/scans/scans.utils.test.ts | 151 ++++++++ ui/components/scans/scans.utils.ts | 219 ++++++++++++ .../scans/table/cells/account-cell.tsx | 22 ++ ui/components/scans/table/cells/index.ts | 5 + .../scans/table/cells/progress-cell.tsx | 22 ++ .../scans/table/cells/resource-count-cell.tsx | 11 + .../scans/table/cells/scan-info-cell.tsx | 17 + .../scans/table/cells/schedule-cell.tsx | 18 + ui/components/scans/table/index.ts | 11 +- ui/components/scans/table/scan-detail.tsx | 107 ------ .../scans/table/scan-jobs-columns.test.tsx | 166 +++++++++ .../scans/table/scan-jobs-columns.tsx | 179 ++++++++++ .../table/scan-jobs-row-actions.test.tsx | 307 ++++++++++++++++ .../scans/table/scan-jobs-row-actions.tsx | 152 ++++++++ .../scans/table/scan-jobs-table.test.tsx | 99 ++++++ ui/components/scans/table/scan-jobs-table.tsx | 48 +++ .../table/scans/column-get-scans.test.ts | 22 -- .../scans/table/scans/column-get-scans.tsx | 280 --------------- .../table/scans/data-table-row-actions.tsx | 62 ---- .../table/scans/data-table-row-details.tsx | 70 ---- ui/components/scans/table/scans/index.ts | 4 - .../table/scans/scans-table-with-polling.tsx | 134 ------- .../table/scans/skeleton-scan-detail.tsx | 64 ---- .../scans/table/skeleton-table-scans.tsx | 268 ++++++++++++-- ui/components/scans/trigger-icon.tsx | 25 -- ui/components/scans/use-scans-filters.ts | 72 ++++ ui/components/shadcn/badge/badge.tsx | 6 + ui/components/shadcn/button/button.test.tsx | 19 +- ui/components/shadcn/button/button.tsx | 10 +- ui/components/shadcn/field/field.tsx | 26 +- .../file-upload/file-upload-dropzone.tsx | 77 ++++ ui/components/shadcn/file-upload/index.ts | 1 + ui/components/shadcn/index.ts | 1 + ui/components/shadcn/modal/modal.tsx | 2 +- ui/components/shadcn/progress.tsx | 2 +- .../shadcn/select/multiselect.test.tsx | 32 ++ ui/components/shadcn/select/multiselect.tsx | 6 + ui/components/shadcn/select/select.tsx | 4 +- ui/components/ui/button/button.test.tsx | 17 + ui/components/ui/button/button.tsx | 2 +- .../ui/entities/date-with-time.test.tsx | 13 + ui/components/ui/entities/date-with-time.tsx | 2 +- ui/components/ui/form/form-buttons.tsx | 11 +- ui/components/ui/main-layout/main-layout.tsx | 3 +- ui/components/ui/sidebar/menu.test.tsx | 77 ++++ ui/components/ui/sidebar/menu.tsx | 52 ++- ui/components/ui/sidebar/sidebar.tsx | 2 +- .../ui/table/data-table-expand-all-toggle.tsx | 2 +- ui/components/ui/table/status-badge.test.tsx | 49 +++ ui/components/ui/table/status-badge.tsx | 74 ++-- ui/hooks/use-related-filters.ts | 9 +- ui/lib/scans-navigation.ts | 3 + ui/store/index.ts | 1 + ui/store/scans/store.ts | 16 + ui/tests/scans/scans-page.ts | 109 ++++-- ui/tests/scans/scans.md | 32 +- ui/tests/scans/scans.spec.ts | 10 +- ui/types/components.ts | 16 - ui/types/formSchemas.ts | 17 - ui/types/index.ts | 1 + ui/types/scans.ts | 36 +- ui/types/tasks.ts | 17 + 114 files changed, 5028 insertions(+), 1849 deletions(-) create mode 100644 ui/actions/task/task.adapter.test.ts create mode 100644 ui/actions/task/task.adapter.ts create mode 100644 ui/components/filters/provider-account-selectors.test.tsx create mode 100644 ui/components/filters/provider-account-selectors.tsx create mode 100644 ui/components/scans/edit-alias-modal.test.tsx create mode 100644 ui/components/scans/edit-alias-modal.tsx delete mode 100644 ui/components/scans/forms/edit-scan-form.tsx delete mode 100644 ui/components/scans/forms/index.ts delete mode 100644 ui/components/scans/forms/schedule-form.tsx create mode 100644 ui/components/scans/launch-scan-modal.test.tsx create mode 100644 ui/components/scans/launch-scan-modal.tsx delete mode 100644 ui/components/scans/launch-workflow/index.ts delete mode 100644 ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx delete mode 100644 ui/components/scans/launch-workflow/select-scan-provider.tsx create mode 100644 ui/components/scans/no-scans-empty-state.tsx create mode 100644 ui/components/scans/scan-alias-validation.ts create mode 100644 ui/components/scans/scan-error-details-modal.test.tsx create mode 100644 ui/components/scans/scan-error-details-modal.tsx create mode 100644 ui/components/scans/scan-error-details-view.test.tsx create mode 100644 ui/components/scans/scan-error-details-view.tsx create mode 100644 ui/components/scans/scans-filter-bar.tsx delete mode 100644 ui/components/scans/scans-filters.tsx delete mode 100644 ui/components/scans/scans-launch-section.test.tsx delete mode 100644 ui/components/scans/scans-launch-section.tsx create mode 100644 ui/components/scans/scans-page-shell.test.tsx create mode 100644 ui/components/scans/scans-page-shell.tsx create mode 100644 ui/components/scans/scans-providers-empty-state.test.tsx create mode 100644 ui/components/scans/scans-providers-empty-state.tsx create mode 100644 ui/components/scans/scans.utils.test.ts create mode 100644 ui/components/scans/scans.utils.ts create mode 100644 ui/components/scans/table/cells/account-cell.tsx create mode 100644 ui/components/scans/table/cells/index.ts create mode 100644 ui/components/scans/table/cells/progress-cell.tsx create mode 100644 ui/components/scans/table/cells/resource-count-cell.tsx create mode 100644 ui/components/scans/table/cells/scan-info-cell.tsx create mode 100644 ui/components/scans/table/cells/schedule-cell.tsx delete mode 100644 ui/components/scans/table/scan-detail.tsx create mode 100644 ui/components/scans/table/scan-jobs-columns.test.tsx create mode 100644 ui/components/scans/table/scan-jobs-columns.tsx create mode 100644 ui/components/scans/table/scan-jobs-row-actions.test.tsx create mode 100644 ui/components/scans/table/scan-jobs-row-actions.tsx create mode 100644 ui/components/scans/table/scan-jobs-table.test.tsx create mode 100644 ui/components/scans/table/scan-jobs-table.tsx delete mode 100644 ui/components/scans/table/scans/column-get-scans.test.ts delete mode 100644 ui/components/scans/table/scans/column-get-scans.tsx delete mode 100644 ui/components/scans/table/scans/data-table-row-actions.tsx delete mode 100644 ui/components/scans/table/scans/data-table-row-details.tsx delete mode 100644 ui/components/scans/table/scans/index.ts delete mode 100644 ui/components/scans/table/scans/scans-table-with-polling.tsx delete mode 100644 ui/components/scans/table/scans/skeleton-scan-detail.tsx delete mode 100644 ui/components/scans/trigger-icon.tsx create mode 100644 ui/components/scans/use-scans-filters.ts create mode 100644 ui/components/shadcn/file-upload/file-upload-dropzone.tsx create mode 100644 ui/components/shadcn/file-upload/index.ts create mode 100644 ui/components/ui/entities/date-with-time.test.tsx create mode 100644 ui/components/ui/sidebar/menu.test.tsx create mode 100644 ui/components/ui/table/status-badge.test.tsx create mode 100644 ui/lib/scans-navigation.ts create mode 100644 ui/store/scans/store.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index e0f2dc21d7..fee50f603b 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file. ## [1.29.0] (Prowler UNRELEASED) +### ๐Ÿš€ Added + +- New Scan Jobs view with specific In Progress, Completed, Scheduled tabs [(#11258)](https://github.com/prowler-cloud/prowler/pull/11258) + ### ๐Ÿ”„ Changed - Dark mode: pure-black canvas, pure-white primary text, and brighter border / input tokens for clearer separation between cards, tables, and inputs [(#11073)](https://github.com/prowler-cloud/prowler/pull/11073) diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index 08b0e8b451..a121da3e50 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -1,5 +1,6 @@ "use server"; +import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { apiBaseUrl, getAuthHeaders, getErrorMessage } from "@/lib"; @@ -140,6 +141,7 @@ export const scanOnDemand = async (formData: FormData) => { const result = await handleApiResponse(response, "/scans"); if (result?.data?.id) { addScanOperation("start", result.data.id); + revalidatePath("/scans"); } return result; } catch (error) { diff --git a/ui/actions/task/index.ts b/ui/actions/task/index.ts index f5752264ce..00f29a1b0e 100644 --- a/ui/actions/task/index.ts +++ b/ui/actions/task/index.ts @@ -1,2 +1,3 @@ export * from "./poll"; +export * from "./task.adapter"; export * from "./tasks"; diff --git a/ui/actions/task/task.adapter.test.ts b/ui/actions/task/task.adapter.test.ts new file mode 100644 index 0000000000..82f4919972 --- /dev/null +++ b/ui/actions/task/task.adapter.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { getScanErrorDetails } from "./task.adapter"; + +describe("getScanErrorDetails", () => { + it("returns null when response is not a record", () => { + expect(getScanErrorDetails(null)).toBeNull(); + expect(getScanErrorDetails("oops")).toBeNull(); + expect(getScanErrorDetails(undefined)).toBeNull(); + }); + + it("returns null when data is missing", () => { + expect(getScanErrorDetails({})).toBeNull(); + }); + + it("returns null when attributes.result is missing", () => { + expect(getScanErrorDetails({ data: { attributes: {} } })).toBeNull(); + }); + + it("returns null when result has no recognizable fields", () => { + expect( + getScanErrorDetails({ data: { attributes: { result: {} } } }), + ).toBeNull(); + }); + + it("parses an error with only an exc_type", () => { + const details = getScanErrorDetails({ + data: { attributes: { result: { exc_type: "BotoCoreError" } } }, + }); + + expect(details).toEqual({ + type: "BotoCoreError", + messages: ["-"], + module: undefined, + copyValue: "ErrorType: BotoCoreError\nError: -", + }); + }); + + it("joins multiple exc_message entries in copyValue", () => { + const details = getScanErrorDetails({ + data: { + attributes: { + result: { + exc_type: "ScanError", + exc_message: ["Failed to connect", "Retry exhausted"], + exc_module: "scan.runner", + }, + }, + }, + }); + + expect(details).toEqual({ + type: "ScanError", + messages: ["Failed to connect", "Retry exhausted"], + module: "scan.runner", + copyValue: + "ErrorType: ScanError\nError: Failed to connect\nRetry exhausted", + }); + }); + + it("filters non-string entries out of exc_message", () => { + const details = getScanErrorDetails({ + data: { + attributes: { + result: { + exc_type: "ScanError", + exc_message: ["valid", 42, null, " ", " trimmed "], + }, + }, + }, + }); + + expect(details?.messages).toEqual(["valid", "trimmed"]); + }); + + it("returns null when only whitespace fields are present", () => { + const details = getScanErrorDetails({ + data: { + attributes: { + result: { + exc_type: " ", + exc_message: [""], + exc_module: "", + }, + }, + }, + }); + + expect(details).toBeNull(); + }); +}); diff --git a/ui/actions/task/task.adapter.ts b/ui/actions/task/task.adapter.ts new file mode 100644 index 0000000000..673c22219a --- /dev/null +++ b/ui/actions/task/task.adapter.ts @@ -0,0 +1,55 @@ +export interface ScanErrorDetails { + type: string; + messages: string[]; + module?: string; + copyValue: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() !== "" + ? value.trim() + : undefined; +} + +function getStringList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + + return value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter((item) => item !== ""); +} + +export function buildScanErrorDetails( + result: unknown, +): ScanErrorDetails | null { + if (!isRecord(result)) return null; + + const type = getString(result.exc_type) ?? "-"; + const messages = getStringList(result.exc_message); + const module = getString(result.exc_module); + + if (type === "-" && messages.length === 0 && !module) return null; + + const errorText = messages.length > 0 ? messages.join("\n") : "-"; + + return { + type, + messages: messages.length > 0 ? messages : ["-"], + module, + copyValue: `ErrorType: ${type}\nError: ${errorText}`, + }; +} + +export function getScanErrorDetails( + taskResponse: unknown, +): ScanErrorDetails | null { + if (!isRecord(taskResponse) || !isRecord(taskResponse.data)) return null; + if (!isRecord(taskResponse.data.attributes)) return null; + + return buildScanErrorDetails(taskResponse.data.attributes.result); +} diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx index 62154f1042..626e0985da 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx @@ -1,9 +1,11 @@ import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { AccountsSelector } from "./accounts-selector"; const multiSelectContentSpy = vi.fn(); +const multiSelectSpy = vi.fn(); vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(), @@ -35,9 +37,25 @@ vi.mock("@/components/icons/providers-badge", () => ({ })); vi.mock("@/components/shadcn/select/multiselect", () => ({ - MultiSelect: ({ children }: { children: React.ReactNode }) => ( -
    {children}
    - ), + MultiSelect: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + }) => { + multiSelectSpy({ open }); + return ( +
    + + {children} +
    + ); + }, MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
    {children}
    ), @@ -56,16 +74,26 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({ }, MultiSelectItem: ({ children, + disabled, value, keywords, + onSelect, }: { children: React.ReactNode; + disabled?: boolean; value: string; keywords?: string[]; + onSelect?: (value: string) => void; }) => ( -
    +
    + ), })); @@ -114,8 +142,8 @@ describe("AccountsSelector", () => { render(); expect(multiSelectContentSpy).toHaveBeenCalledWith({ - placeholder: "Search accounts...", - emptyMessage: "No accounts found.", + placeholder: "Search Providers...", + emptyMessage: "No Providers found.", }); expect(screen.getByText("Production AWS")).toBeInTheDocument(); }); @@ -140,12 +168,56 @@ describe("AccountsSelector", () => { ).toHaveAttribute("data-keywords", expect.stringContaining("123456789012")); }); + it("can use provider UID values for pages whose API filters by provider_uid__in", () => { + render( + , + ); + + expect( + screen.getByText("Production AWS").closest("[data-value]"), + ).toHaveAttribute("data-value", "123456789012"); + }); + it("disables select all when every account is already shown", () => { render(); expect( - screen.getByRole("option", { name: /select all accounts/i }), + screen.getByRole("option", { name: /select all Providers/i }), ).toHaveAttribute("aria-disabled", "true"); expect(screen.getByText("All selected")).toBeInTheDocument(); }); + + it("marks configured account values as disabled", () => { + render( + , + ); + + expect( + screen.getByText("Production AWS").closest("[data-value]"), + ).toHaveAttribute("data-disabled", "true"); + expect(screen.getByText("Disconnected")).toBeInTheDocument(); + }); + + it("can close the dropdown after selecting a launch-scan provider", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: /open selector/i })); + expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: true }); + + await user.click(screen.getByRole("button", { name: /production aws/i })); + + expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: false }); + }); }); diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index 4e7ca5bf79..a37807e793 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -1,7 +1,7 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { ReactNode } from "react"; +import { ReactNode, useState } from "react"; import { AlibabaCloudProviderBadge, @@ -21,6 +21,7 @@ import { OracleCloudProviderBadge, VercelProviderBadge, } from "@/components/icons/providers-badge"; +import { Badge } from "@/components/shadcn"; import { MultiSelect, MultiSelectContent, @@ -36,6 +37,14 @@ import { type ProviderType, } from "@/types/providers"; +const ACCOUNT_SELECTOR_FILTER = { + PROVIDER_ID: "provider_id__in", + PROVIDER_UID: "provider_uid__in", +} as const; + +type AccountSelectorFilter = + (typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER]; + const PROVIDER_ICON: Record = { aws: , azure: , @@ -59,12 +68,10 @@ const PROVIDER_ICON: Record = { interface AccountsSelectorBaseProps { providers: ProviderProps[]; search?: MultiSelectSearchProp; - /** - * Currently selected provider types (from the pending ProviderTypeSelector state). - * Used only for contextual description/empty-state messaging โ€” does NOT narrow - * the list of available accounts, which remains independent of provider selection. - */ - selectedProviderTypes?: string[]; + filterKey?: AccountSelectorFilter; + id?: string; + disabledValues?: string[]; + closeOnSelect?: boolean; } /** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */ @@ -98,74 +105,79 @@ export function AccountsSelector({ providers, onBatchChange, selectedValues, - selectedProviderTypes, + filterKey = ACCOUNT_SELECTOR_FILTER.PROVIDER_ID, + id = "accounts-selector", + disabledValues = [], search = { - placeholder: "Search accounts...", - emptyMessage: "No accounts found.", + placeholder: "Search Providers...", + emptyMessage: "No Providers found.", }, + closeOnSelect = false, }: AccountsSelectorProps) { const searchParams = useSearchParams(); const { navigateWithParams } = useUrlFilters(); + const [selectorOpen, setSelectorOpen] = useState(false); - const filterKey = "filter[provider_id__in]"; - const current = searchParams.get(filterKey) || ""; + const labelId = `${id}-label`; + const urlFilterKey = `filter[${filterKey}]`; + const current = searchParams.get(urlFilterKey) || ""; const urlSelectedIds = current ? current.split(",").filter(Boolean) : []; - // In batch mode, use the parent-controlled pending values; otherwise, use URL state. - const selectedIds = onBatchChange ? selectedValues : urlSelectedIds; const visibleProviders = providers; - // .filter((p) => p.attributes.connection?.connected) + const getProviderValue = (provider: ProviderProps) => + filterKey === ACCOUNT_SELECTOR_FILTER.PROVIDER_UID + ? provider.attributes.uid + : provider.id; + const disabledValuesSet = new Set(disabledValues); + + // In batch mode, use the parent-controlled pending values; otherwise, use URL state. + const selectedIds = (onBatchChange ? selectedValues : urlSelectedIds).filter( + (id) => !disabledValuesSet.has(id), + ); const handleMultiValueChange = (ids: string[]) => { + const enabledIds = ids.filter((id) => !disabledValuesSet.has(id)); + if (onBatchChange) { - onBatchChange("provider_id__in", ids); + onBatchChange(filterKey, enabledIds); + if (closeOnSelect) setSelectorOpen(false); return; } navigateWithParams((params) => { - params.delete(filterKey); + params.delete(urlFilterKey); - if (ids.length > 0) { - params.set(filterKey, ids.join(",")); + if (enabledIds.length > 0) { + params.set(urlFilterKey, enabledIds.join(",")); } }); + if (closeOnSelect) setSelectorOpen(false); }; const selectedLabel = () => { if (selectedIds.length === 0) return null; if (selectedIds.length === 1) { - const p = providers.find((pr) => pr.id === selectedIds[0]); + const p = providers.find((pr) => getProviderValue(pr) === selectedIds[0]); const name = p ? p.attributes.alias || p.attributes.uid : selectedIds[0]; return {name}; } return ( - {selectedIds.length} accounts selected + {selectedIds.length} Providers selected ); }; - // Build a contextual description based on currently selected provider types. - // This is purely for user guidance (aria label + empty state) and does NOT - // narrow the list of available accounts โ€” all providers remain selectable. - const filterDescription = - selectedProviderTypes && selectedProviderTypes.length > 0 - ? `Accounts for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}` - : "All connected provider accounts"; - return (
    -
    {visibleProviders.map((p) => { - const id = p.id; + const value = getProviderValue(p); + const isDisabled = disabledValuesSet.has(value); const displayName = p.attributes.alias || p.attributes.uid; const providerType = p.attributes.provider as ProviderType; const icon = PROVIDER_ICON[providerType]; @@ -205,23 +218,28 @@ export function AccountsSelector({ ].filter(Boolean); return ( { + if (closeOnSelect) setSelectorOpen(false); + }} > - {displayName} + + {displayName} + {isDisabled && Disconnected} + ); })} ) : (
    - {selectedProviderTypes && selectedProviderTypes.length > 0 - ? `No accounts available for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}` - : "No connected accounts available"} + No connected Providers available
    )} diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx index d7e027cae2..5cd94a3087 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx @@ -114,8 +114,8 @@ describe("ProviderTypeSelector", () => { render(); expect(multiSelectContentSpy).toHaveBeenCalledWith({ - placeholder: "Search providers...", - emptyMessage: "No providers found.", + placeholder: "Search Provider Types...", + emptyMessage: "No Provider Types found.", }); expect(screen.getByText("Amazon Web Services")).toBeInTheDocument(); }); @@ -141,7 +141,7 @@ describe("ProviderTypeSelector", () => { render(); expect( - screen.getByRole("option", { name: /select all providers/i }), + screen.getByRole("option", { name: /select all Provider Types/i }), ).toHaveAttribute("aria-disabled", "true"); expect(screen.getByText("All selected")).toBeInTheDocument(); }); diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx index 9d0b0de3b5..15d069b49d 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx @@ -210,8 +210,8 @@ export const ProviderTypeSelector = ({ onBatchChange, selectedValues, search = { - placeholder: "Search providers...", - emptyMessage: "No providers found.", + placeholder: "Search Provider Types...", + emptyMessage: "No Provider Types found.", }, }: ProviderTypeSelectorProps) => { const searchParams = useSearchParams(); @@ -274,7 +274,7 @@ export const ProviderTypeSelector = ({ } return ( - {selectedTypes.length} providers selected + {selectedTypes.length} Provider Types selected ); }; @@ -286,7 +286,8 @@ export const ProviderTypeSelector = ({ className="sr-only" id="provider-type-label" > - Filter by provider type. Select one or more providers to view findings. + Filter by Provider Type. Select one or more Provider Types to view + findings. - {selectedLabel() || } + {selectedLabel() || ( + + )} {availableTypes.length > 0 ? ( @@ -305,7 +308,7 @@ export const ProviderTypeSelector = ({ role="option" aria-selected={selectedTypes.length === 0} aria-disabled={selectedTypes.length === 0} - aria-label="Select all providers (clears current selection to show all)" + aria-label="Select all Provider Types (clears current selection to show all)" tabIndex={0} className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50" onClick={() => { @@ -328,7 +331,7 @@ export const ProviderTypeSelector = ({ value={providerType} badgeLabel={PROVIDER_DATA[providerType].label} keywords={[providerType, PROVIDER_DATA[providerType].label]} - aria-label={`${PROVIDER_DATA[providerType].label} provider`} + aria-label={`${PROVIDER_DATA[providerType].label} Provider Type`} > {PROVIDER_DATA[providerType].label} @@ -337,7 +340,7 @@ export const ProviderTypeSelector = ({ ) : (
    - No connected providers available + No connected Provider Types available
    )}
    diff --git a/ui/app/(prowler)/_overview/graphs-tabs/risk-pipeline-view/risk-pipeline-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/risk-pipeline-view/risk-pipeline-view.ssr.tsx index 509c92fa2c..b8479432e6 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/risk-pipeline-view/risk-pipeline-view.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/risk-pipeline-view/risk-pipeline-view.ssr.tsx @@ -3,7 +3,7 @@ import { getFindingsBySeverity, SeverityByProviderType, } from "@/actions/overview"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders } from "@/actions/providers"; import { SankeyChart } from "@/components/graphs/sankey-chart"; import { SearchParamsProps } from "@/types"; @@ -20,7 +20,7 @@ export async function RiskPipelineViewSSR({ const providerIdFilter = filters["filter[provider_id__in]"]; // Fetch providers list to know account types - const providersListResponse = await getProviders({ pageSize: 200 }); + const providersListResponse = await getAllProviders(); const allProviders = providersListResponse?.data || []; // Build severityByProviderType based on filters diff --git a/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx index ee69f0fea4..887eb7a5d5 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx @@ -4,7 +4,7 @@ import { adaptToRiskPlotData, getProvidersRiskData, } from "@/actions/overview/risk-plot"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders } from "@/actions/providers"; import { SearchParamsProps } from "@/types"; import { pickFilterParams } from "../../_lib/filter-params"; @@ -21,7 +21,7 @@ export async function RiskPlotSSR({ const providerIdFilter = filters["filter[provider_id__in]"]; // Fetch all providers - const providersListResponse = await getProviders({ pageSize: 200 }); + const providersListResponse = await getAllProviders(); const allProviders = providersListResponse?.data || []; // Filter providers based on search params diff --git a/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx index c3f37cfd45..be336be2a7 100644 --- a/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx +++ b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx @@ -518,7 +518,7 @@ describe("AlertFormModal", () => { expect(alertEditGrid).toHaveClass("xl:grid-cols-3", "2xl:grid-cols-3"); expect(alertEditGrid).not.toHaveClass("xl:grid-cols-4", "2xl:grid-cols-5"); expect(screen.getAllByText("Amazon Web Services")[0]).toBeVisible(); - expect(screen.getByText("All accounts")).toBeVisible(); + expect(screen.getByText("All Providers")).toBeVisible(); expect(within(filterControls).getByText("All Delta")).toBeVisible(); expect(within(filterControls).getByText("All Resource Type")).toBeVisible(); expect( @@ -547,7 +547,9 @@ describe("AlertFormModal", () => { }); // When - await user.click(screen.getByLabelText(/provider type/i)); + await user.click( + screen.getByRole("combobox", { name: /filter by Provider Type/i }), + ); const providerOptions = await screen.findAllByText("Google Cloud Platform"); const visibleProviderOption = providerOptions.at(-1); expect(visibleProviderOption).toBeDefined(); @@ -597,7 +599,9 @@ describe("AlertFormModal", () => { }); // When - await user.click(screen.getByLabelText(/provider type/i)); + await user.click( + screen.getByRole("combobox", { name: /filter by Provider Type/i }), + ); const providerOptions = await screen.findAllByText("Google Cloud Platform"); const visibleProviderOption = providerOptions.at(-1); expect(visibleProviderOption).toBeDefined(); diff --git a/ui/app/(prowler)/alerts/page.tsx b/ui/app/(prowler)/alerts/page.tsx index f9c5ea5f69..28f238ef01 100644 --- a/ui/app/(prowler)/alerts/page.tsx +++ b/ui/app/(prowler)/alerts/page.tsx @@ -1,7 +1,7 @@ import { redirect } from "next/navigation"; import { getLatestMetadataInfo } from "@/actions/findings"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; import { getAlert, listAlerts } from "@/app/(prowler)/alerts/_actions"; import { AlertsManager } from "@/app/(prowler)/alerts/_components/alerts-manager"; @@ -58,7 +58,7 @@ export default async function AlertsPage({ searchParams }: AlertsPageProps) { const [result, providersData, scansData, metadataInfoData, editResult] = await Promise.all([ listAlerts(toAlertsSearchParams(resolvedSearchParams)), - getProviders({ pageSize: 50 }), + getAllProviders(), getScans({ pageSize: 50 }), getLatestMetadataInfo({}), editAlertId ? getAlert(editAlertId) : Promise.resolve(null), diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 57ff5a3e8c..7ff9780292 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -6,7 +6,7 @@ import { getLatestFindingGroups, } from "@/actions/finding-groups"; import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders } from "@/actions/providers"; import { getScan, getScans } from "@/actions/scans"; import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components"; import { FindingsFilters } from "@/components/findings/findings-filters"; @@ -37,7 +37,7 @@ export default async function Findings({ const { filters, query } = extractFiltersAndQuery(resolvedSearchParams); const [providersData, scansData] = await Promise.all([ - getProviders({ pageSize: 50 }), + getAllProviders(), getScans({ pageSize: 50 }), ]); diff --git a/ui/app/(prowler)/integrations/amazon-s3/page.tsx b/ui/app/(prowler)/integrations/amazon-s3/page.tsx index 48e74f8baf..9db94b034f 100644 --- a/ui/app/(prowler)/integrations/amazon-s3/page.tsx +++ b/ui/app/(prowler)/integrations/amazon-s3/page.tsx @@ -1,7 +1,7 @@ import React from "react"; import { getIntegrations } from "@/actions/integrations"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders } from "@/actions/providers"; import { S3IntegrationsManager } from "@/components/integrations/s3/s3-integrations-manager"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn"; import { ContentLayout } from "@/components/ui"; @@ -47,7 +47,7 @@ export default async function S3Integrations({ const [integrations, providers] = await Promise.all([ getIntegrations(urlSearchParams), - getProviders({ pageSize: 100 }), + getAllProviders(), ]); const s3Integrations = integrations?.data || []; diff --git a/ui/app/(prowler)/integrations/aws-security-hub/page.tsx b/ui/app/(prowler)/integrations/aws-security-hub/page.tsx index 4adb23181f..12050a8eaa 100644 --- a/ui/app/(prowler)/integrations/aws-security-hub/page.tsx +++ b/ui/app/(prowler)/integrations/aws-security-hub/page.tsx @@ -1,7 +1,7 @@ import React from "react"; import { getIntegrations } from "@/actions/integrations"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders } from "@/actions/providers"; import { SecurityHubIntegrationsManager } from "@/components/integrations/security-hub/security-hub-integrations-manager"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn"; import { ContentLayout } from "@/components/ui"; @@ -45,7 +45,7 @@ export default async function SecurityHubIntegrations({ const [integrations, providers] = await Promise.all([ getIntegrations(urlSearchParams), - getProviders({ pageSize: 100 }), + getAllProviders(), ]); const securityHubIntegrations = integrations?.data || []; diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx index 7f475687e3..99f152b4e4 100644 --- a/ui/app/(prowler)/page.tsx +++ b/ui/app/(prowler)/page.tsx @@ -1,11 +1,10 @@ import { Suspense } from "react"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders } from "@/actions/providers"; +import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors"; import { ContentLayout } from "@/components/ui"; import { SearchParamsProps } from "@/types"; -import { AccountsSelector } from "./_overview/_components/accounts-selector"; -import { ProviderTypeSelector } from "./_overview/_components/provider-type-selector"; import { AttackSurfaceSkeleton, AttackSurfaceSSR, @@ -39,13 +38,12 @@ export default async function Home({ searchParams: Promise; }) { const resolvedSearchParams = await searchParams; - const providersData = await getProviders({ page: 1, pageSize: 200 }); + const providersData = await getAllProviders(); return (
    - - +
    diff --git a/ui/app/(prowler)/providers/provider-groups-content.tsx b/ui/app/(prowler)/providers/provider-groups-content.tsx index 2bb4cd9ae8..f732f131e5 100644 --- a/ui/app/(prowler)/providers/provider-groups-content.tsx +++ b/ui/app/(prowler)/providers/provider-groups-content.tsx @@ -2,7 +2,7 @@ import { getProviderGroupInfoById, getProviderGroups, } from "@/actions/manage-groups/manage-groups"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders } from "@/actions/providers"; import { getRoles } from "@/actions/roles"; import { AddGroupForm, EditGroupForm } from "@/components/manage-groups/forms"; import { ColumnGroups } from "@/components/manage-groups/table"; @@ -19,7 +19,7 @@ export const ProviderGroupsContent = async ({ // Fetch all data in parallel const [providersResponse, rolesResponse, providerGroupsData, editGroupData] = await Promise.all([ - getProviders({ pageSize: 50 }), + getAllProviders(), getRoles({}), fetchGroupsTableData(searchParams), providerGroupId && !Array.isArray(providerGroupId) diff --git a/ui/app/(prowler)/providers/providers-page.utils.test.ts b/ui/app/(prowler)/providers/providers-page.utils.test.ts index 2bf4b4edd1..335b672af2 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.test.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; const providersActionsMock = vi.hoisted(() => ({ getProviders: vi.fn(), + getAllProviders: vi.fn(), })); const organizationsActionsMock = vi.hoisted(() => ({ @@ -619,6 +620,7 @@ describe("loadProvidersAccountsViewData", () => { it("does not call organizations endpoints in OSS", async () => { // Given providersActionsMock.getProviders.mockResolvedValue(providersResponse); + providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); scansActionsMock.getScans.mockResolvedValue({ data: [] }); // When @@ -662,6 +664,7 @@ describe("loadProvidersAccountsViewData", () => { }, })), }); + providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); organizationsActionsMock.listOrganizationsSafe.mockResolvedValue({ data: [ { @@ -724,6 +727,7 @@ describe("loadProvidersAccountsViewData", () => { it("falls back to empty cloud grouping data when organizations endpoints fail", async () => { // Given providersActionsMock.getProviders.mockResolvedValue(providersResponse); + providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); organizationsActionsMock.listOrganizationsSafe.mockResolvedValue({ data: [], }); diff --git a/ui/app/(prowler)/providers/providers-page.utils.ts b/ui/app/(prowler)/providers/providers-page.utils.ts index 781fb1fc6f..db79452709 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.ts @@ -2,7 +2,7 @@ import { listOrganizationsSafe, listOrganizationUnitsSafe, } from "@/actions/organizations/organizations"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders, getProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; import { extractFiltersAndQuery, @@ -467,7 +467,7 @@ export async function loadProvidersAccountsViewData({ ), // Unfiltered fetch for ProviderTypeSelector โ€” only needs distinct types; // TODO: Replace with a dedicated lightweight endpoint when available. - resolveActionResult(getProviders({ pageSize: 500 })), + resolveActionResult(getAllProviders()), // Fetch active scheduled scans to determine daily schedule per provider resolveActionResult( getScans({ diff --git a/ui/app/(prowler)/resources/page.tsx b/ui/app/(prowler)/resources/page.tsx index 426778ddb5..fb7e5ab63d 100644 --- a/ui/app/(prowler)/resources/page.tsx +++ b/ui/app/(prowler)/resources/page.tsx @@ -1,6 +1,6 @@ import { Suspense } from "react"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders } from "@/actions/providers"; import { getLatestMetadataInfo, getLatestResources, @@ -44,7 +44,7 @@ export default async function Resources({ filters: outputFilters, sort: encodedSort, }), - getProviders({ pageSize: 50 }), + getAllProviders(), initialResourceId ? getResourceById(initialResourceId, { include: ["provider"] }) : Promise.resolve(undefined), diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index 1b045721ef..62aba551ae 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -3,23 +3,54 @@ import { Suspense } from "react"; import { getAllProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; import { auth } from "@/auth.config"; -import { MutedFindingsConfigButton } from "@/components/providers"; -import { ScansFilters } from "@/components/scans"; -import { ScansLaunchSection } from "@/components/scans/scans-launch-section"; +import { + getScanJobsTab, + getScanJobsTabFilters, + getScanJobsUserFilters, +} from "@/components/scans/scans.utils"; +import { ScansPageShell } from "@/components/scans/scans-page-shell"; +import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state"; import { SkeletonTableScans } from "@/components/scans/table"; -import { ScansTableWithPolling } from "@/components/scans/table/scans"; +import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table"; import { ContentLayout } from "@/components/ui"; import { - createProviderDetailsMapping, - extractProviderUIDs, -} from "@/lib/provider-helpers"; -import { - ExpandedScanData, ProviderProps, + SCAN_JOBS_TAB, ScanProps, SearchParamsProps, } from "@/types"; +const ACTIVE_SCAN_COUNT_PAGE_SIZE = 1; + +const getFilterSearchQuery = ( + filters: Record, +): string => { + const value = filters["filter[search]"]; + if (Array.isArray(value)) return value[0] ?? ""; + + return value ?? ""; +}; + +const getActiveScanCount = async ( + searchParams: SearchParamsProps, +): Promise => { + const userFilters = getScanJobsUserFilters(searchParams); + const filters = { + ...userFilters, + ...getScanJobsTabFilters(SCAN_JOBS_TAB.ACTIVE), + }; + + const scansData = await getScans({ + query: getFilterSearchQuery(filters), + page: 1, + pageSize: ACTIVE_SCAN_COUNT_PAGE_SIZE, + filters, + fields: { scans: "state" }, + }); + + return scansData && "meta" in scansData ? scansData.meta.pagination.count : 0; +}; + export default async function Scans({ searchParams, }: { @@ -27,96 +58,47 @@ export default async function Scans({ }) { const session = await auth(); const resolvedSearchParams = await searchParams; - const filteredParams = { ...resolvedSearchParams }; - delete filteredParams.scanId; - const [providersData, completedScansData] = await Promise.all([ - getAllProviders(), - getScans({ - filters: { "filter[state]": "completed" }, - pageSize: 50, - fields: { scans: "name,completed_at,provider" }, - include: "provider", - }), - ]); + const providersData = await getAllProviders(); + const providers = providersData?.data ?? []; - const completedScans: ExpandedScanData[] = (completedScansData?.data ?? []) - .map((scan: ScanProps) => { - const providerId = scan.relationships?.provider?.data?.id; - const providerData = completedScansData?.included?.find( - (item: { type: string; id: string }) => - item.type === "providers" && item.id === providerId, - ); - if (!providerData) return null; - return { - ...scan, - providerInfo: { - provider: providerData.attributes.provider, - uid: providerData.attributes.uid, - alias: providerData.attributes.alias, - }, - }; - }) - .filter(Boolean) as ExpandedScanData[]; - - const providerInfo = - providersData?.data - ?.filter( - (provider: ProviderProps) => - provider.attributes.connection.connected === true, - ) - .map((provider: ProviderProps) => ({ - providerId: provider.id, - alias: provider.attributes.alias, - providerType: provider.attributes.provider, - uid: provider.attributes.uid, - connected: provider.attributes.connection.connected, - })) || []; - - const thereIsNoProviders = - !providersData?.data || providersData.data.length === 0; - - const thereIsNoProvidersConnected = Boolean( - providersData?.data?.every( - (provider: ProviderProps) => !provider.attributes.connection.connected, - ), + const connectedProviders = providers.filter( + (provider: ProviderProps) => + provider.attributes.connection.connected === true, ); + const thereIsNoProviders = providers.length === 0; + const thereIsNoProvidersConnected = + !thereIsNoProviders && connectedProviders.length === 0; const hasManageScansPermission = Boolean( session?.user?.permissions?.manage_scans, ); - - // Extract provider UIDs and create provider details mapping for filtering - const providerUIDs = providersData ? extractProviderUIDs(providersData) : []; - const providerDetails = providersData - ? createProviderDetailsMapping(providerUIDs, providersData) - : []; + const activeScanCount = + thereIsNoProviders || thereIsNoProvidersConnected + ? 0 + : await getActiveScanCount(resolvedSearchParams); return ( - - <> - + {thereIsNoProviders || thereIsNoProvidersConnected ? ( + + ) : ( + - {!thereIsNoProviders && ( -
    - -
    - -
    - }> - - -
    - )} - + activeScanCount={activeScanCount} + > + + } + > + + +
    + )}
    ); } @@ -126,21 +108,27 @@ const SSRDataTableScans = async ({ }: { searchParams: SearchParamsProps; }) => { + const tab = getScanJobsTab(searchParams.tab); + const page = parseInt(searchParams.page?.toString() || "1", 10); const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); const sort = searchParams.sort?.toString(); - // Extract all filter parameters, excluding scanId - const filters = Object.fromEntries( - Object.entries(searchParams).filter( - ([key]) => key.startsWith("filter[") && key !== "scanId", - ), + const userFilters = Object.entries(searchParams).filter(([key]) => + key.startsWith("filter["), ); + const hasUserFilters = userFilters.length > 0; + + const filters = { + ...getScanJobsUserFilters(searchParams), + ...getScanJobsTabFilters( + tab, + searchParams["filter[state__in]"] ?? searchParams["filter[state]"], + ), + }; - // Extract query from filters const query = (filters["filter[search]"] as string) || ""; - // Fetch scans data with provider information included const scansData = await getScans({ query, page, @@ -158,19 +146,12 @@ const SSRDataTableScans = async ({ scans?.map((scan: ScanProps) => { const providerId = scan.relationships?.provider?.data?.id; - if (!providerId) { - return { ...scan, providerInfo: null }; - } - - // Find the provider data in the included array const providerData = included?.find( (item: { type: string; id: string }) => item.type === "providers" && item.id === providerId, ); - if (!providerData) { - return { ...scan, providerInfo: null }; - } + if (!providerData) return scan; return { ...scan, @@ -183,10 +164,11 @@ const SSRDataTableScans = async ({ }) || []; return ( - ); }; diff --git a/ui/components/compliance/compliance-header/compliance-scan-info.tsx b/ui/components/compliance/compliance-header/compliance-scan-info.tsx index 76bf8e46be..da1caa5ee9 100644 --- a/ui/components/compliance/compliance-header/compliance-scan-info.tsx +++ b/ui/components/compliance/compliance-header/compliance-scan-info.tsx @@ -13,7 +13,7 @@ interface ComplianceScanInfoProps { }; attributes: { name?: string; - completed_at: string; + completed_at: string | null; }; }; } diff --git a/ui/components/filters/provider-account-selectors.test.tsx b/ui/components/filters/provider-account-selectors.test.tsx new file mode 100644 index 0000000000..7d576251ac --- /dev/null +++ b/ui/components/filters/provider-account-selectors.test.tsx @@ -0,0 +1,247 @@ +import { render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ProviderProps } from "@/types/providers"; + +import { ProviderAccountSelectors } from "./provider-account-selectors"; + +const { selectorProps, navigateWithParamsMock, currentSearchParams } = + vi.hoisted(() => ({ + selectorProps: { + providerType: undefined as + | { + providers: ProviderProps[]; + onBatchChange: (filterKey: string, values: string[]) => void; + selectedValues: string[]; + } + | undefined, + accounts: undefined as + | { + providers: ProviderProps[]; + filterKey?: string; + onBatchChange: (filterKey: string, values: string[]) => void; + selectedValues: string[]; + } + | undefined, + }, + navigateWithParamsMock: vi.fn(), + currentSearchParams: { value: "" }, + })); + +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(currentSearchParams.value), +})); + +vi.mock("@/hooks/use-url-filters", () => ({ + useUrlFilters: () => ({ + navigateWithParams: navigateWithParamsMock, + }), +})); + +vi.mock("@/app/(prowler)/_overview/_components/provider-type-selector", () => ({ + ProviderTypeSelector: (props: { + providers: ProviderProps[]; + onBatchChange: (filterKey: string, values: string[]) => void; + selectedValues: string[]; + }) => { + selectorProps.providerType = props; + return
    Provider type selector
    ; + }, +})); + +vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({ + AccountsSelector: (props: { + providers: ProviderProps[]; + filterKey?: string; + onBatchChange: (filterKey: string, values: string[]) => void; + selectedValues: string[]; + }) => { + selectorProps.accounts = props; + return
    Accounts selector
    ; + }, +})); + +const makeProvider = ({ + id, + provider, + uid, + alias, +}: { + id: string; + provider: ProviderProps["attributes"]["provider"]; + uid: string; + alias: string; +}): ProviderProps => ({ + id, + type: "providers", + attributes: { + provider, + uid, + alias, + status: "completed", + resources: 0, + connection: { + connected: true, + last_checked_at: "2026-04-13T00:00:00Z", + }, + scanner_args: { + only_logs: false, + excluded_checks: [], + aws_retries_max_attempts: 3, + }, + inserted_at: "2026-04-13T00:00:00Z", + updated_at: "2026-04-13T00:00:00Z", + created_by: { + object: "user", + id: "user-1", + }, + }, + relationships: { + secret: { data: null }, + provider_groups: { + meta: { count: 0 }, + data: [], + }, + }, +}); + +const providers = [ + makeProvider({ + id: "aws-provider", + provider: "aws", + uid: "123456789012", + alias: "Production AWS", + }), + makeProvider({ + id: "gcp-provider", + provider: "gcp", + uid: "prowler-project", + alias: "Production GCP", + }), +]; + +const applyLastNavigation = () => { + const modifier = navigateWithParamsMock.mock.calls.at(-1)?.[0] as + | ((params: URLSearchParams) => void) + | undefined; + const params = new URLSearchParams(currentSearchParams.value); + + if (!modifier) throw new Error("Expected navigateWithParams to be called"); + + modifier(params); + + return params; +}; + +describe("ProviderAccountSelectors", () => { + beforeEach(() => { + currentSearchParams.value = ""; + selectorProps.providerType = undefined; + selectorProps.accounts = undefined; + navigateWithParamsMock.mockClear(); + }); + + it("filters account options by selected provider types in instant mode", () => { + currentSearchParams.value = "filter%5Bprovider_type__in%5D=aws"; + + render(); + + expect(selectorProps.accounts?.providers).toEqual([providers[0]]); + }); + + it("cleans incompatible selected accounts in the same instant navigation", () => { + currentSearchParams.value = + "filter%5Bprovider_type__in%5D=aws&filter%5Bprovider_id__in%5D=aws-provider"; + + render(); + + selectorProps.providerType?.onBatchChange("provider_type__in", ["gcp"]); + + const params = applyLastNavigation(); + + expect(params.get("filter[provider_type__in]")).toBe("gcp"); + expect(params.get("filter[provider_id__in]")).toBeNull(); + }); + + it("cleans incompatible UID accounts in the same instant navigation", () => { + currentSearchParams.value = + "filter%5Bprovider_type__in%5D=aws&filter%5Bprovider_uid__in%5D=123456789012&page=2&scanId=scan-1"; + + render( + , + ); + + selectorProps.providerType?.onBatchChange("provider_type__in", ["gcp"]); + + const params = applyLastNavigation(); + + expect(selectorProps.accounts?.filterKey).toBe("provider_uid__in"); + expect(params.get("filter[provider_type__in]")).toBe("gcp"); + expect(params.get("filter[provider_uid__in]")).toBeNull(); + expect(params.get("page")).toBeNull(); + expect(params.get("scanId")).toBeNull(); + }); + + it("filters account options by selected provider types in batch mode", () => { + render( + , + ); + + expect(selectorProps.accounts?.providers).toEqual([providers[0]]); + }); + + it("cleans incompatible selected accounts in batch mode", () => { + const onBatchChange = vi.fn(); + + render( + , + ); + + selectorProps.providerType?.onBatchChange("provider_type__in", ["gcp"]); + + expect(onBatchChange).toHaveBeenCalledWith("provider_type__in", ["gcp"]); + expect(onBatchChange).toHaveBeenCalledWith("provider_id__in", [ + "gcp-provider", + ]); + }); + + it("uses provider UID values when accountValue is uid", () => { + const onBatchChange = vi.fn(); + + render( + , + ); + + selectorProps.providerType?.onBatchChange("provider_type__in", ["gcp"]); + + expect(selectorProps.accounts?.filterKey).toBe("provider_uid__in"); + expect(onBatchChange).toHaveBeenCalledWith("provider_uid__in", [ + "prowler-project", + ]); + }); +}); diff --git a/ui/components/filters/provider-account-selectors.tsx b/ui/components/filters/provider-account-selectors.tsx new file mode 100644 index 0000000000..060413b19c --- /dev/null +++ b/ui/components/filters/provider-account-selectors.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; + +import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; +import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector"; +import { useUrlFilters } from "@/hooks/use-url-filters"; +import type { ProviderProps } from "@/types/providers"; + +const ACCOUNT_FILTER_KEY = { + PROVIDER_ID: "provider_id__in", + PROVIDER_UID: "provider_uid__in", +} as const; + +const ACCOUNT_VALUE = { + ID: "id", + UID: "uid", +} as const; + +type AccountFilterKey = + (typeof ACCOUNT_FILTER_KEY)[keyof typeof ACCOUNT_FILTER_KEY]; +type AccountValue = (typeof ACCOUNT_VALUE)[keyof typeof ACCOUNT_VALUE]; + +interface ProviderAccountSelectorsBaseProps { + providers: ProviderProps[]; + accountFilterKey?: AccountFilterKey; + accountValue?: AccountValue; + providerSelectorClassName?: string; + accountSelectorClassName?: string; + paramsToDeleteOnChange?: string[]; +} + +interface ProviderAccountSelectorsInstantProps + extends ProviderAccountSelectorsBaseProps { + mode?: "instant"; + selectedProviderTypes?: never; + selectedAccounts?: never; + onBatchChange?: never; +} + +interface ProviderAccountSelectorsBatchProps + extends ProviderAccountSelectorsBaseProps { + mode: "batch"; + selectedProviderTypes: string[]; + selectedAccounts: string[]; + onBatchChange: (filterKey: string, values: string[]) => void; +} + +type ProviderAccountSelectorsProps = + | ProviderAccountSelectorsInstantProps + | ProviderAccountSelectorsBatchProps; + +const toFilterKey = (filterKey: string) => `filter[${filterKey}]`; + +const getAccountValue = ( + provider: ProviderProps, + accountValue: AccountValue, +): string => + accountValue === ACCOUNT_VALUE.UID ? provider.attributes.uid : provider.id; + +const getCsvValues = (value: string | null): string[] => + value ? value.split(",").filter(Boolean) : []; + +const getFilteredProviders = ( + providers: ProviderProps[], + selectedProviderTypes: string[], +): ProviderProps[] => { + if (selectedProviderTypes.length === 0) return providers; + + return providers.filter((provider) => + selectedProviderTypes.includes(provider.attributes.provider), + ); +}; + +const getCompatibleAccounts = ({ + providers, + selectedAccounts, + selectedProviderTypes, + accountValue, +}: { + providers: ProviderProps[]; + selectedAccounts: string[]; + selectedProviderTypes: string[]; + accountValue: AccountValue; +}): string[] => { + if (selectedAccounts.length === 0) return []; + if (selectedProviderTypes.length === 0) return selectedAccounts; + + const compatibleValues = new Set( + getFilteredProviders(providers, selectedProviderTypes).map((provider) => + getAccountValue(provider, accountValue), + ), + ); + + return selectedAccounts.filter((account) => compatibleValues.has(account)); +}; + +export function ProviderAccountSelectors({ + providers, + accountFilterKey = ACCOUNT_FILTER_KEY.PROVIDER_ID, + accountValue = ACCOUNT_VALUE.ID, + providerSelectorClassName, + accountSelectorClassName, + paramsToDeleteOnChange = [], + ...props +}: ProviderAccountSelectorsProps) { + const searchParams = useSearchParams(); + const { navigateWithParams } = useUrlFilters(); + const isBatchMode = props.mode === "batch"; + const selectedProviderTypes = isBatchMode + ? props.selectedProviderTypes + : getCsvValues(searchParams.get(toFilterKey("provider_type__in"))); + const selectedAccounts = isBatchMode + ? props.selectedAccounts + : getCsvValues(searchParams.get(toFilterKey(accountFilterKey))); + const filteredProviders = getFilteredProviders( + providers, + selectedProviderTypes, + ); + + const handleProviderTypeChange = ( + filterKey: string, + values: string[], + ): void => { + const compatibleAccounts = getCompatibleAccounts({ + providers, + selectedAccounts, + selectedProviderTypes: values, + accountValue, + }); + + if (isBatchMode) { + props.onBatchChange(filterKey, values); + + if (compatibleAccounts.length !== selectedAccounts.length) { + props.onBatchChange(accountFilterKey, compatibleAccounts); + } + + return; + } + + navigateWithParams((params) => { + const providerFilterKey = toFilterKey(filterKey); + const accountUrlFilterKey = toFilterKey(accountFilterKey); + + if (values.length > 0) { + params.set(providerFilterKey, values.join(",")); + } else { + params.delete(providerFilterKey); + } + + if (compatibleAccounts.length > 0) { + params.set(accountUrlFilterKey, compatibleAccounts.join(",")); + } else { + params.delete(accountUrlFilterKey); + } + + paramsToDeleteOnChange.forEach((key) => params.delete(key)); + }); + }; + + const handleAccountChange = (filterKey: string, values: string[]): void => { + if (isBatchMode) { + props.onBatchChange(filterKey, values); + return; + } + + navigateWithParams((params) => { + const accountUrlFilterKey = toFilterKey(filterKey); + + if (values.length > 0) { + params.set(accountUrlFilterKey, values.join(",")); + } else { + params.delete(accountUrlFilterKey); + } + + paramsToDeleteOnChange.forEach((key) => params.delete(key)); + }); + }; + + return ( + <> +
    + +
    +
    + +
    + + ); +} diff --git a/ui/components/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx index e498fb9eed..02a3aec62d 100644 --- a/ui/components/findings/findings-filters.tsx +++ b/ui/components/findings/findings-filters.tsx @@ -4,8 +4,6 @@ import { ChevronDown } from "lucide-react"; import type { ReactNode } from "react"; import { useState } from "react"; -import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; -import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector"; import { ApplyFiltersButton } from "@/components/filters/apply-filters-button"; import { BatchFiltersLayout } from "@/components/filters/batch-filters-layout"; import { ClearFiltersButton } from "@/components/filters/clear-filters-button"; @@ -15,6 +13,7 @@ import { FilterChip, FilterSummaryStrip, } from "@/components/filters/filter-summary-strip"; +import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors"; import { Button } from "@/components/shadcn"; import { ExpandableSection } from "@/components/ui/expandable-section"; import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom"; @@ -30,7 +29,7 @@ import { } from "./findings-filters.utils"; interface FindingsFiltersProps { - /** Provider data for ProviderTypeSelector and AccountsSelector */ + /** Provider data for provider/account filter controls. */ providers: ProviderProps[]; completedScanIds: string[]; scanDetails: { [key: string]: ScanEntity }[]; @@ -96,7 +95,7 @@ export const FindingsFilterBatchControls = ({ const [isExpanded, setIsExpanded] = useState(false); const isAlertsEdit = variant === "alerts-edit"; - // Custom filters for the expandable section (removed Provider - now using AccountsSelector) + // Custom filters for the expandable section. const customFilters = [ ...filterFindings .filter((filter) => !isAlertsEdit || filter.key !== FilterType.STATUS) @@ -203,37 +202,23 @@ export const FindingsFilterBatchControls = ({ ? pendingDateValues[0] : undefined; - const providerTypeControl = (className: string) => ( -
    - -
    - ); - - const accountsControl = (className: string) => ( -
    - -
    + const providerAccountControls = (className: string) => ( + ); const alertEditFilterGrid = hasCustomFilters ? ( - {providerTypeControl(FILTER_GRID_ITEM_CLASS)} - {accountsControl(FILTER_GRID_ITEM_CLASS)} - - } + prependElement={providerAccountControls(FILTER_GRID_ITEM_CLASS)} hideClearButton mode={DATA_TABLE_FILTER_MODE.BATCH} onBatchChange={setPending} @@ -303,8 +288,7 @@ export const FindingsFilterBatchControls = ({ alertEditFilterGrid ) : ( <> - {providerTypeControl(FILTER_CONTROL_COLUMN_CLASS)} - {accountsControl(FILTER_CONTROL_COLUMN_CLASS)} + {providerAccountControls(FILTER_CONTROL_COLUMN_CLASS)} {hasCustomFilters && ( + ), + toast: toastMock, +})); + +vi.mock("@/components/shadcn/modal", () => ({ + Modal: ({ + children, + open, + title, + }: { + children: React.ReactNode; + open: boolean; + title: string; + }) => + open ? ( +
    + {children} +
    + ) : null, +})); + +vi.mock("@/components/ui/entities", () => ({ + EntityInfo: ({ + entityAlias, + entityId, + }: { + entityAlias?: string; + entityId?: string; + }) => <>{entityAlias || entityId}, +})); + +vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({ + AccountsSelector: ({ + disabledValues = [], + providers, + onBatchChange, + selectedValues, + id, + }: { + disabledValues?: string[]; + providers: { id: string; attributes: { alias: string; uid: string } }[]; + onBatchChange: (filterKey: string, values: string[]) => void; + selectedValues: string[]; + id?: string; + }) => ( +
    + + +
    + ), +})); + +import { LaunchScanModal } from "./launch-scan-modal"; + +const provider = { + id: "provider-1", + type: "providers" as const, + attributes: { + provider: "aws" as const, + uid: "123456789012", + alias: "Production", + status: "completed" as const, + resources: 0, + connection: { + connected: true, + last_checked_at: "2026-04-13T00:00:00Z", + }, + scanner_args: { + only_logs: false, + excluded_checks: [], + aws_retries_max_attempts: 3, + }, + inserted_at: "2026-04-13T00:00:00Z", + updated_at: "2026-04-13T00:00:00Z", + created_by: { + object: "user", + id: "user-1", + }, + }, + relationships: { + secret: { + data: null, + }, + provider_groups: { + meta: { + count: 0, + }, + data: [], + }, + }, +}; + +const disconnectedProvider = { + ...provider, + id: "provider-2", + attributes: { + ...provider.attributes, + alias: "Disconnected", + uid: "210987654321", + connection: { + connected: false, + last_checked_at: "2026-05-20T11:46:38.834045Z", + }, + }, +}; + +describe("LaunchScanModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + searchParamsValue.current = ""; + scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); + }); + + it("shows a searchable provider selector", () => { + render( + , + ); + + expect(screen.getByPlaceholderText("Search Providers...")).toBeVisible(); + }); + + it("disables disconnected providers in the launch selector", () => { + render( + , + ); + + expect(screen.getByRole("option", { name: "Disconnected" })).toBeDisabled(); + }); + + it("submits alias as scanName so the API stores it as the scan alias", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.selectOptions(screen.getByLabelText("Providers"), provider.id); + await user.type(screen.getByLabelText("Alias"), "Production audit"); + await user.click(screen.getByRole("button", { name: /launch scan/i })); + + await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalled()); + + const formData = scanOnDemandMock.mock.calls[0][0] as FormData; + expect(formData.get("providerId")).toBe(provider.id); + expect(formData.get("scanName")).toBe("Production audit"); + expect(formData.get("scanNote")).toBeNull(); + }); + + it("accepts scan aliases up to the API limit of 100 characters", async () => { + const user = userEvent.setup(); + const alias = "a".repeat(100); + + render( + , + ); + + await user.selectOptions(screen.getByLabelText("Providers"), provider.id); + await user.type(screen.getByLabelText("Alias"), alias); + await user.click(screen.getByRole("button", { name: /launch scan/i })); + + await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalled()); + + const formData = scanOnDemandMock.mock.calls[0][0] as FormData; + expect(formData.get("scanName")).toBe(alias); + }); + + it("adds a toast action to view the scan in progress when another tab is active", async () => { + const user = userEvent.setup(); + searchParamsValue.current = + "tab=completed&filter%5Bstate__in%5D=failed&page=3"; + + render( + , + ); + + await user.selectOptions(screen.getByLabelText("Providers"), provider.id); + await user.click(screen.getByRole("button", { name: /launch scan/i })); + + await waitFor(() => expect(toastMock).toHaveBeenCalled()); + + const toastPayload = toastMock.mock.calls[0]?.[0]; + expect(toastPayload.action).toBeDefined(); + expect(toastPayload.action.props.children.props.href).toBe( + "/scans?tab=active", + ); + expect(toastPayload.action.props.children.props.children).toBe("View scan"); + }); + + it("does not add a toast action when the in progress tab is active", async () => { + const user = userEvent.setup(); + searchParamsValue.current = "tab=active"; + + render( + , + ); + + await user.selectOptions(screen.getByLabelText("Providers"), provider.id); + await user.click(screen.getByRole("button", { name: /launch scan/i })); + + await waitFor(() => expect(toastMock).toHaveBeenCalled()); + + const toastPayload = toastMock.mock.calls[0]?.[0]; + expect(toastPayload.action).toBeUndefined(); + }); + + it("rejects scan aliases over the API limit of 100 characters", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.selectOptions(screen.getByLabelText("Providers"), provider.id); + await user.type(screen.getByLabelText("Alias"), "a".repeat(101)); + await user.click(screen.getByRole("button", { name: /launch scan/i })); + + expect( + await screen.findByText(/alias must not exceed 100 characters/i), + ).toBeInTheDocument(); + expect(scanOnDemandMock).not.toHaveBeenCalled(); + }); + + it("does not show the old scan note label", () => { + render( + , + ); + + expect(screen.queryByLabelText("Scan Note")).not.toBeInTheDocument(); + expect(screen.queryByText("Scan Note (optional)")).not.toBeInTheDocument(); + }); + + it("surfaces JSON:API errors from scanOnDemand and skips the success toast", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + scanOnDemandMock.mockResolvedValueOnce({ + errors: [{ detail: "Provider already has a scan in progress" }], + }); + + render( + , + ); + + await user.selectOptions(screen.getByLabelText("Providers"), provider.id); + await user.click(screen.getByRole("button", { name: /launch scan/i })); + + expect( + await screen.findByText("Provider already has a scan in progress"), + ).toBeInTheDocument(); + expect(toastMock).not.toHaveBeenCalled(); + expect(refreshMock).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); +}); diff --git a/ui/components/scans/launch-scan-modal.tsx b/ui/components/scans/launch-scan-modal.tsx new file mode 100644 index 0000000000..dd4b049c87 --- /dev/null +++ b/ui/components/scans/launch-scan-modal.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { CloudCog, Rocket } from "lucide-react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { scanOnDemand } from "@/actions/scans"; +import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; +import { Field, FieldError, FieldLabel, Input } from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; +import { FormButtons } from "@/components/ui/form"; +import { toast, ToastAction } from "@/components/ui/toast"; +import { SCAN_JOBS_TAB } from "@/types"; +import type { ProviderProps } from "@/types/providers"; + +import { scanAliasSchema } from "./scan-alias-validation"; +import { getScanJobsTab } from "./scans.utils"; + +const launchScanSchema = z.object({ + providerId: z.string().min(1, "Select a provider to launch a scan."), + scanAlias: scanAliasSchema.optional(), +}); + +type LaunchScanFormValues = z.infer; + +interface LaunchScanModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + providers: ProviderProps[]; +} + +interface LaunchScanFormProps { + providers: ProviderProps[]; + onClose: () => void; +} + +function LaunchScanForm({ providers, onClose }: LaunchScanFormProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const form = useForm({ + resolver: zodResolver(launchScanSchema), + defaultValues: { providerId: "", scanAlias: "" }, + }); + + const providerId = form.watch("providerId"); + const activeTab = getScanJobsTab(searchParams.get("tab") ?? undefined); + const shouldShowActiveTabAction = activeTab !== SCAN_JOBS_TAB.ACTIVE; + const disconnectedProviderIds = providers + .filter((provider) => provider.attributes.connection.connected !== true) + .map((provider) => provider.id); + + const onSubmit = form.handleSubmit(async ({ providerId, scanAlias }) => { + const formData = new FormData(); + formData.set("providerId", providerId); + const trimmedAlias = scanAlias?.trim(); + if (trimmedAlias) { + formData.set("scanName", trimmedAlias); + } + + const result = await scanOnDemand(formData); + + if (result?.error) { + form.setError("root", { message: String(result.error) }); + return; + } + + if (result?.errors && result.errors.length > 0) { + form.setError("root", { + message: String(result.errors[0]?.detail ?? "Failed to launch scan."), + }); + return; + } + + toast({ + title: "Scan launched", + description: "The scan was launched successfully.", + action: shouldShowActiveTabAction ? ( + + View scan + + ) : undefined, + }); + onClose(); + router.refresh(); + }); + + const providerError = form.formState.errors.providerId?.message; + const aliasError = form.formState.errors.scanAlias?.message; + const rootError = form.formState.errors.root?.message; + const isSubmitting = form.formState.isSubmitting; + + return ( +
    +
    + + + Select the provider you would like to scan + +
    + + + Providers + + form.setValue("providerId", values.at(-1) ?? "", { + shouldValidate: true, + }) + } + selectedValues={providerId ? [providerId] : []} + closeOnSelect + /> + {providerError && {providerError}} + + + + Alias (optional) + + {aliasError && {aliasError}} + + + {rootError && {rootError}} + + } + /> + + ); +} + +export function LaunchScanModal({ + open, + onOpenChange, + providers, +}: LaunchScanModalProps) { + return ( + + onOpenChange(false)} + /> + + ); +} diff --git a/ui/components/scans/launch-workflow/index.ts b/ui/components/scans/launch-workflow/index.ts deleted file mode 100644 index 4f8a794d33..0000000000 --- a/ui/components/scans/launch-workflow/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./launch-scan-workflow-form"; -export * from "./select-scan-provider"; diff --git a/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx b/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx deleted file mode 100644 index 6ee64724ba..0000000000 --- a/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx +++ /dev/null @@ -1,154 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { AnimatePresence, motion } from "framer-motion"; -import { useForm, useWatch } from "react-hook-form"; -import * as z from "zod"; - -import { scanOnDemand } from "@/actions/scans"; -import { RocketIcon } from "@/components/icons"; -import { Button } from "@/components/shadcn"; -import { CustomInput } from "@/components/ui/custom"; -import { Form } from "@/components/ui/form"; -import { toast } from "@/components/ui/toast"; -import { onDemandScanFormSchema, ScanProviderInfo } from "@/types"; - -import { SCAN_LAUNCHED_EVENT } from "../table/scans/scans-table-with-polling"; -import { SelectScanProvider } from "./select-scan-provider"; - -export const LaunchScanWorkflow = ({ - providers, -}: { - providers: ScanProviderInfo[]; -}) => { - const formSchema = z.object({ - ...onDemandScanFormSchema().shape, - scanName: z - .union([ - z - .string() - .min(3, "Must be at least 3 characters") - .max(32, "Must not exceed 32 characters"), - z.literal(""), - ]) - .optional(), - }); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - providerId: "", - scanName: "", - scannerArgs: undefined, - }, - }); - - const providerId = useWatch({ control: form.control, name: "providerId" }); - const hasProviderSelected = Boolean(providerId); - - const isLoading = form.formState.isSubmitting; - - const onSubmitClient = async (values: z.infer) => { - const formValues = { ...values }; - - const formData = new FormData(); - - // Loop through form values and add to formData - Object.entries(formValues).forEach( - ([key, value]) => - value !== undefined && - formData.append( - key, - typeof value === "object" ? JSON.stringify(value) : value, - ), - ); - - const data = await scanOnDemand(formData); - - if (data?.error) { - toast({ - variant: "destructive", - title: "Oops! Something went wrong", - description: data.error, - }); - } else { - toast({ - title: "Success!", - description: "The scan was launched successfully.", - }); - // Reset form after successful submission - form.reset(); - // Notify the scans table to refresh and pick up the new scan - window.dispatchEvent(new Event(SCAN_LAUNCHED_EVENT)); - } - }; - - return ( -
    - -
    - -
    - - {hasProviderSelected && ( - <> -
    - - - - - - - -
    - - )} -
    -
    - - ); -}; diff --git a/ui/components/scans/launch-workflow/select-scan-provider.tsx b/ui/components/scans/launch-workflow/select-scan-provider.tsx deleted file mode 100644 index 9d8be12e3e..0000000000 --- a/ui/components/scans/launch-workflow/select-scan-provider.tsx +++ /dev/null @@ -1,95 +0,0 @@ -"use client"; - -import { Control, FieldPath, FieldValues } from "react-hook-form"; - -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/shadcn"; -import { EntityInfo } from "@/components/ui/entities"; -import { FormControl, FormField, FormMessage } from "@/components/ui/form"; -import { ScanProviderInfo } from "@/types"; - -interface SelectScanProviderProps< - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, -> { - providers: ScanProviderInfo[]; - control: Control; - name: TName; -} - -export const SelectScanProvider = < - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, ->({ - providers, - control, - name, -}: SelectScanProviderProps) => { - return ( - { - const selectedItem = providers.find( - (item) => item.providerId === field.value, - ); - - return ( -
    - - Select a provider to launch a scan - - - - - -
    - ); - }} - /> - ); -}; diff --git a/ui/components/scans/no-scans-empty-state.tsx b/ui/components/scans/no-scans-empty-state.tsx new file mode 100644 index 0000000000..b03c1544f6 --- /dev/null +++ b/ui/components/scans/no-scans-empty-state.tsx @@ -0,0 +1,55 @@ +import { Card, CardContent } from "@/components/shadcn"; +import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types"; + +import { InfoIcon } from "../icons/Icons"; + +interface EmptyStateCopy { + title: string; + description: string; + hint: string; +} + +const EMPTY_STATE_COPY: Record = { + [SCAN_JOBS_TAB.ACTIVE]: { + title: "No scans in progress", + description: + "Scans currently running or queued will appear here when available.", + hint: `Switch to ${SCAN_TAB_LABELS[SCAN_JOBS_TAB.COMPLETED]} to review past results, or to ${SCAN_TAB_LABELS[SCAN_JOBS_TAB.SCHEDULED]}.`, + }, + [SCAN_JOBS_TAB.COMPLETED]: { + title: "No completed scans yet", + description: + "Finished, failed, or cancelled scans will appear here once they wrap up.", + hint: `Switch to ${SCAN_TAB_LABELS[SCAN_JOBS_TAB.ACTIVE]} to monitor ongoing scans, or to ${SCAN_TAB_LABELS[SCAN_JOBS_TAB.SCHEDULED]} to plan future runs.`, + }, + [SCAN_JOBS_TAB.SCHEDULED]: { + title: "No scheduled scans", + description: "Scans scheduled to run later will appear here.", + hint: `Switch to ${SCAN_TAB_LABELS[SCAN_JOBS_TAB.ACTIVE]} to monitor ongoing scans, or to ${SCAN_TAB_LABELS[SCAN_JOBS_TAB.COMPLETED]} to review past results.`, + }, +}; + +interface NoScansEmptyStateProps { + tab: ScanJobsTab; +} + +export function NoScansEmptyState({ tab }: NoScansEmptyStateProps) { + const copy = EMPTY_STATE_COPY[tab]; + + return ( + + + +

    + {copy.title} +

    +

    + {copy.description} +

    +

    + {copy.hint} +

    +
    +
    + ); +} diff --git a/ui/components/scans/scan-alias-validation.ts b/ui/components/scans/scan-alias-validation.ts new file mode 100644 index 0000000000..99d73decb2 --- /dev/null +++ b/ui/components/scans/scan-alias-validation.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const SCAN_ALIAS_MIN_LENGTH = 3; +export const SCAN_ALIAS_MAX_LENGTH = 100; + +export const scanAliasSchema = z + .string() + .max( + SCAN_ALIAS_MAX_LENGTH, + `Alias must not exceed ${SCAN_ALIAS_MAX_LENGTH} characters.`, + ) + .refine( + (value) => + value.trim().length === 0 || value.trim().length >= SCAN_ALIAS_MIN_LENGTH, + `Alias must be empty or have at least ${SCAN_ALIAS_MIN_LENGTH} characters.`, + ); diff --git a/ui/components/scans/scan-error-details-modal.test.tsx b/ui/components/scans/scan-error-details-modal.test.tsx new file mode 100644 index 0000000000..1474e7eea1 --- /dev/null +++ b/ui/components/scans/scan-error-details-modal.test.tsx @@ -0,0 +1,102 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + ScanErrorDetailsModal, + type ScanErrorDetailsState, +} from "./scan-error-details-modal"; + +vi.mock("@/components/ui/code-snippet/code-snippet", () => ({ + CodeSnippet: ({ + value, + formatter, + ariaLabel, + }: { + value: string; + formatter?: (value: string) => string; + ariaLabel?: string; + }) => ( + <> + {formatter ? formatter(value) : value} + + + ), +})); + +const loadedState: ScanErrorDetailsState = { + kind: "loaded", + details: { + type: "ValidationError", + messages: ["Missing cloud credentials", "Retry scan setup"], + module: "scan.runner", + copyValue: + "ErrorType: ValidationError\nError: Missing cloud credentials\nRetry scan setup", + }, +}; + +describe("ScanErrorDetailsModal", () => { + it("renders nothing visible when closed", () => { + render( + , + ); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("shows the loading placeholder while state is loading", () => { + render( + , + ); + expect(screen.getByText(/loading error details/i)).toBeInTheDocument(); + }); + + it("renders the error message when state is error", () => { + render( + , + ); + expect(screen.getByText("Task not found")).toBeInTheDocument(); + }); + + it("renders error type, module and messages when loaded", () => { + render( + , + ); + expect(screen.getByText("ValidationError")).toBeInTheDocument(); + expect(screen.getByText("scan.runner")).toBeInTheDocument(); + expect(screen.getByText(/Missing cloud credentials/)).toBeInTheDocument(); + expect(screen.getByText(/Retry scan setup/)).toBeInTheDocument(); + }); + + it("shows the copy action only when state is loaded", () => { + const { rerender } = render( + , + ); + expect( + screen.queryByRole("button", { name: /copy error details/i }), + ).not.toBeInTheDocument(); + + rerender( + , + ); + expect( + screen.getByRole("button", { name: /copy error details/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/ui/components/scans/scan-error-details-modal.tsx b/ui/components/scans/scan-error-details-modal.tsx new file mode 100644 index 0000000000..d510b9ae31 --- /dev/null +++ b/ui/components/scans/scan-error-details-modal.tsx @@ -0,0 +1,67 @@ +"use client"; + +import type { ScanErrorDetails } from "@/actions/task/task.adapter"; +import { Button, Card, CardContent } from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; +import { LoadingState } from "@/components/shadcn/spinner/loading-state"; + +import { ScanErrorDetailsView } from "./scan-error-details-view"; + +export type ScanErrorDetailsState = + | { kind: "idle" } + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "loaded"; details: ScanErrorDetails }; + +interface ScanErrorDetailsModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + state: ScanErrorDetailsState; +} + +function LoadingView() { + return ; +} + +function ErrorView({ message }: { message: string }) { + return ( + + +

    {message}

    +
    +
    + ); +} + +export function ScanErrorDetailsModal({ + open, + onOpenChange, + state, +}: ScanErrorDetailsModalProps) { + return ( + + {state.kind === "loading" && } + {state.kind === "error" && } + {state.kind === "loaded" && ( + + )} + +
    + +
    +
    + ); +} diff --git a/ui/components/scans/scan-error-details-view.test.tsx b/ui/components/scans/scan-error-details-view.test.tsx new file mode 100644 index 0000000000..145f84d529 --- /dev/null +++ b/ui/components/scans/scan-error-details-view.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ScanErrorDetails } from "@/actions/task/task.adapter"; + +import { ScanErrorDetailsView } from "./scan-error-details-view"; + +vi.mock("@/components/ui/code-snippet/code-snippet", () => ({ + CodeSnippet: ({ + value, + formatter, + ariaLabel, + }: { + value: string; + formatter?: (value: string) => string; + ariaLabel?: string; + }) => ( + <> + {formatter ? formatter(value) : value} + + + ), +})); + +const details: ScanErrorDetails = { + type: "ValidationError", + messages: ["Missing cloud credentials", "Retry scan setup"], + module: "scan.runner", + copyValue: + "ErrorType: ValidationError\nError: Missing cloud credentials\nRetry scan setup", +}; + +describe("ScanErrorDetailsView", () => { + it("renders error type, module and joined messages", () => { + render(); + + expect(screen.getByText("Error Type")).toBeInTheDocument(); + expect(screen.getByText("ValidationError")).toBeInTheDocument(); + expect(screen.getByText("Module")).toBeInTheDocument(); + expect(screen.getByText("scan.runner")).toBeInTheDocument(); + expect(screen.getByText(/Missing cloud credentials/)).toBeInTheDocument(); + expect(screen.getByText(/Retry scan setup/)).toBeInTheDocument(); + }); + + it("omits the module field when not provided", () => { + render( + , + ); + expect(screen.queryByText("Module")).not.toBeInTheDocument(); + }); + + it("uses the provided copy aria label", () => { + render( + , + ); + expect( + screen.getByRole("button", { name: /copy custom/i }), + ).toBeInTheDocument(); + }); + + it("defaults the copy aria label to 'Copy error details'", () => { + render(); + expect( + screen.getByRole("button", { name: /copy error details/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/ui/components/scans/scan-error-details-view.tsx b/ui/components/scans/scan-error-details-view.tsx new file mode 100644 index 0000000000..8670e20ba4 --- /dev/null +++ b/ui/components/scans/scan-error-details-view.tsx @@ -0,0 +1,35 @@ +"use client"; + +import type { ScanErrorDetails } from "@/actions/task/task.adapter"; +import { Field, FieldLabel, LabeledField } from "@/components/shadcn"; +import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; + +interface ScanErrorDetailsViewProps { + details: ScanErrorDetails; + copyAriaLabel?: string; +} + +export function ScanErrorDetailsView({ + details, + copyAriaLabel = "Copy error details", +}: ScanErrorDetailsViewProps) { + return ( +
    +
    + {details.type} + {details.module && ( + {details.module} + )} +
    + + Error + details.messages.join("\n")} + multiline + ariaLabel={copyAriaLabel} + /> + +
    + ); +} diff --git a/ui/components/scans/scans-filter-bar.tsx b/ui/components/scans/scans-filter-bar.tsx new file mode 100644 index 0000000000..89ac753345 --- /dev/null +++ b/ui/components/scans/scans-filter-bar.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/shadcn"; +import type { ScanJobsTab } from "@/types"; +import type { ProviderProps } from "@/types/providers"; + +import { + getScanStatusFilterOptions, + getScanTriggerFilterOptions, +} from "./scans.utils"; + +interface ScansFilterBarProps { + providers: ProviderProps[]; + activeTab: ScanJobsTab; + scheduleType: string; + scanStatus: string; + showStatusFilter: boolean; + onScheduleTypeChange: (value: string) => void; + onScanStatusChange: (value: string) => void; +} + +const filterItemClass = "w-full md:w-[calc(50%-0.375rem)] xl:w-60"; + +export function ScansFilterBar({ + providers, + activeTab, + scheduleType, + scanStatus, + showStatusFilter, + onScheduleTypeChange, + onScanStatusChange, +}: ScansFilterBarProps) { + const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const triggerFilterOptions = getScanTriggerFilterOptions(isCloudEnvironment); + const statusFilterOptions = getScanStatusFilterOptions(activeTab); + + return ( + <> + + + + + {showStatusFilter && ( + + )} + + ); +} diff --git a/ui/components/scans/scans-filters.tsx b/ui/components/scans/scans-filters.tsx deleted file mode 100644 index 60dbca0bfc..0000000000 --- a/ui/components/scans/scans-filters.tsx +++ /dev/null @@ -1,103 +0,0 @@ -"use client"; - -import { X } from "lucide-react"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; - -import { ScanSelector } from "@/components/compliance/compliance-header"; -import { filterScans } from "@/components/filters/data-filters"; -import { FilterControls } from "@/components/filters/filter-controls"; -import { Badge } from "@/components/shadcn/badge/badge"; -import { useRelatedFilters } from "@/hooks"; -import { ExpandedScanData, FilterEntity, FilterType } from "@/types"; - -interface ScansFiltersProps { - providerUIDs: string[]; - providerDetails: { [uid: string]: FilterEntity }[]; - completedScans?: ExpandedScanData[]; -} - -export const ScansFilters = ({ - providerUIDs, - providerDetails, - completedScans = [], -}: ScansFiltersProps) => { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - const idFilter = searchParams.get("filter[id__in]"); - - const { availableProviderUIDs } = useRelatedFilters({ - providerUIDs, - providerDetails, - enableScanRelation: false, - providerFilterType: FilterType.PROVIDER_UID, - }); - - const handleDismissIdFilter = () => { - const params = new URLSearchParams(searchParams.toString()); - params.delete("filter[id__in]"); - router.push(`${pathname}?${params.toString()}`); - }; - - const handleScanChange = (selectedScanId: string) => { - const params = new URLSearchParams(searchParams.toString()); - params.set("filter[id__in]", selectedScanId); - router.push(`${pathname}?${params.toString()}`); - }; - - const scanIdElement = idFilter ? ( - completedScans.length > 0 ? ( -
    - - -
    - ) : ( -
    - - - Scan: - - {idFilter} - - -
    - ) - ) : null; - - return ( - - ); -}; diff --git a/ui/components/scans/scans-launch-section.test.tsx b/ui/components/scans/scans-launch-section.test.tsx deleted file mode 100644 index 7abee44117..0000000000 --- a/ui/components/scans/scans-launch-section.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; - -import { ScansLaunchSection } from "./scans-launch-section"; - -vi.mock("@/components/providers/wizard", () => ({ - ProviderWizardModal: ({ open }: { open: boolean }) => - open ?
    Provider wizard
    : null, -})); - -vi.mock("@/components/scans/launch-workflow", () => ({ - LaunchScanWorkflow: () =>
    Launch scan workflow
    , -})); - -vi.mock("@/components/scans/no-providers-connected", () => ({ - NoProvidersConnected: () =>
    No providers connected
    , -})); - -vi.mock("@/components/ui/custom/custom-banner", () => ({ - CustomBanner: ({ title }: { title: string }) =>
    {title}
    , -})); - -const connectedProvider = { - providerId: "provider-1", - alias: "Production", - providerType: "aws", - uid: "123456789012", - connected: true, -}; - -describe("ScansLaunchSection", () => { - it("should keep the provider wizard open when providers data refreshes after adding the first provider", async () => { - // Given - const user = userEvent.setup(); - const { rerender } = render( - , - ); - - // When - await user.click( - screen.getByRole("button", { name: /open add provider modal/i }), - ); - rerender( - , - ); - - // Then - expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard"); - expect(screen.getByText("Launch scan workflow")).toBeInTheDocument(); - }); -}); diff --git a/ui/components/scans/scans-launch-section.tsx b/ui/components/scans/scans-launch-section.tsx deleted file mode 100644 index f683155a21..0000000000 --- a/ui/components/scans/scans-launch-section.tsx +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; - -import { useState } from "react"; - -import { ProviderWizardModal } from "@/components/providers/wizard"; -import { LaunchScanWorkflow } from "@/components/scans/launch-workflow"; -import { NoProvidersAdded } from "@/components/scans/no-providers-added"; -import { NoProvidersConnected } from "@/components/scans/no-providers-connected"; -import { CustomBanner } from "@/components/ui/custom/custom-banner"; -import { ScanProviderInfo } from "@/types"; - -interface ScansLaunchSectionProps { - providers: ScanProviderInfo[]; - hasManageScansPermission: boolean; - thereIsNoProviders: boolean; - thereIsNoProvidersConnected: boolean; -} - -export function ScansLaunchSection({ - providers, - hasManageScansPermission, - thereIsNoProviders, - thereIsNoProvidersConnected, -}: ScansLaunchSectionProps) { - const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false); - - return ( - <> - {thereIsNoProviders ? ( - setIsProviderWizardOpen(true)} /> - ) : !hasManageScansPermission ? ( - - ) : thereIsNoProvidersConnected ? ( - - ) : ( - - )} - - - ); -} diff --git a/ui/components/scans/scans-page-shell.test.tsx b/ui/components/scans/scans-page-shell.test.tsx new file mode 100644 index 0000000000..e1ada277d6 --- /dev/null +++ b/ui/components/scans/scans-page-shell.test.tsx @@ -0,0 +1,335 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useScansStore } from "@/store"; + +import { ScansPageShell } from "./scans-page-shell"; + +const { pushMock, replaceMock, searchParamsValue } = vi.hoisted(() => ({ + pushMock: vi.fn(), + replaceMock: vi.fn(), + searchParamsValue: { current: "" }, +})); + +const { scansFilterBarSpy } = vi.hoisted(() => ({ + scansFilterBarSpy: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/scans", + useRouter: () => ({ + push: pushMock, + replace: replaceMock, + }), + useSearchParams: () => new URLSearchParams(searchParamsValue.current), +})); + +vi.mock("./scans-filter-bar", () => ({ + ScansFilterBar: (props: { + showStatusFilter: boolean; + onScheduleTypeChange: (value: string) => void; + onScanStatusChange: (value: string) => void; + }) => { + scansFilterBarSpy(props); + return ( + <> +
    Shared scan filters
    + + {props.showStatusFilter && ( + + )} + + ); + }, +})); + +vi.mock("./launch-scan-modal", () => ({ + LaunchScanModal: ({ + open, + onOpenChange, + }: { + open: boolean; + onOpenChange: (open: boolean) => void; + }) => + open ? ( +
    + Launch scan + +
    + ) : null, +})); + +vi.mock("@/components/providers/muted-findings-config-button", () => ({ + MutedFindingsConfigButton: () => Configure Mutelist, +})); + +const providers = [ + { + id: "provider-1", + type: "providers" as const, + attributes: { + provider: "aws" as const, + uid: "123456789012", + alias: "Production", + status: "completed" as const, + resources: 0, + connection: { + connected: true, + last_checked_at: "2026-04-13T00:00:00Z", + }, + scanner_args: { + only_logs: false, + excluded_checks: [], + aws_retries_max_attempts: 3, + }, + inserted_at: "2026-04-13T00:00:00Z", + updated_at: "2026-04-13T00:00:00Z", + created_by: { + object: "user", + id: "user-1", + }, + }, + relationships: { + secret: { + data: null, + }, + provider_groups: { + meta: { + count: 0, + }, + data: [], + }, + }, + }, +]; + +describe("ScansPageShell", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + searchParamsValue.current = ""; + useScansStore.getState().closeLaunchScanModal(); + }); + + it("does not render an imported findings tab", () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + + render( + +
    Scans table
    +
    , + ); + + expect( + screen.queryByRole("tab", { name: /imported findings/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /import findings/i }), + ).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("uses the shared scan filter bar for scan filters", () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + + render( + +
    Scans table
    +
    , + ); + + expect(screen.getByText("Shared scan filters")).toBeInTheDocument(); + expect(scansFilterBarSpy).toHaveBeenCalledWith( + expect.objectContaining({ + providers, + scheduleType: "all", + }), + ); + }); + + it("clears the active sort when switching tabs", async () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + searchParamsValue.current = "tab=active&sort=trigger"; + const user = userEvent.setup(); + + render( + +
    Scans table
    +
    , + ); + + await user.click(screen.getByRole("tab", { name: /completed/i })); + + expect(pushMock).toHaveBeenCalled(); + const calledUrl = pushMock.mock.calls.at(-1)?.[0] as string; + expect(calledUrl).toContain("tab=completed"); + expect(calledUrl).not.toContain("sort="); + }); + + it("uses a generic type filter label in Cloud", () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + + render( + +
    Scans table
    +
    , + ); + + expect(screen.getByRole("combobox", { name: /all types/i })).toBeVisible(); + }); + + it("keeps launch scan with filters and mutelist with tabs", () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + + render( + +
    Scans table
    +
    , + ); + + expect( + screen.getByRole("group", { name: /scan filters and actions/i }), + ).toContainElement(screen.getByRole("button", { name: /launch scan/i })); + expect( + screen.getByRole("group", { name: /scan filters and actions/i }), + ).not.toContainElement( + screen.getByRole("link", { name: /configure mutelist/i }), + ); + expect(screen.getByRole("group", { name: /scan tabs/i })).toContainElement( + screen.getByRole("link", { name: /configure mutelist/i }), + ); + }); + + it("shows the active scans count in the in progress tab", () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + + render( + +
    Scans table
    +
    , + ); + + expect( + screen.getByRole("tab", { name: /in progress \(3\)/i }), + ).toBeVisible(); + expect(screen.getByRole("tab", { name: /^completed$/i })).toBeVisible(); + }); + + it("opens the launch scan modal from the URL", () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + searchParamsValue.current = "launchScan=true"; + + render( + +
    Scans table
    +
    , + ); + + expect(screen.getByRole("dialog")).toHaveTextContent(/launch scan/i); + }); + + it("strips the launchScan URL param when closing the URL-opened modal", async () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + searchParamsValue.current = "tab=completed&launchScan=true"; + const user = userEvent.setup(); + + render( + +
    Scans table
    +
    , + ); + + await user.click(screen.getByRole("button", { name: /close/i })); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(replaceMock).toHaveBeenCalledWith( + "/scans?tab=completed", + expect.objectContaining({ scroll: false }), + ); + expect(pushMock).not.toHaveBeenCalled(); + }); + + it("opens and closes the launch scan modal from client state without navigation", async () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + const user = userEvent.setup(); + useScansStore.getState().openLaunchScanModal(); + + render( + +
    Scans table
    +
    , + ); + + expect(screen.getByRole("dialog")).toHaveTextContent(/launch scan/i); + + await user.click(screen.getByRole("button", { name: /close/i })); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(pushMock).not.toHaveBeenCalled(); + }); + + it("shows the status filter only on the completed tab", () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + searchParamsValue.current = "tab=completed"; + + render( + +
    Scans table
    +
    , + ); + + expect( + screen.getByRole("combobox", { name: /all statuses/i }), + ).toBeVisible(); + }); + + it("hides the status filter outside of the completed tab", () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + searchParamsValue.current = "tab=active"; + + render( + +
    Scans table
    +
    , + ); + + expect( + screen.queryByRole("combobox", { name: /all statuses/i }), + ).not.toBeInTheDocument(); + }); + + it("clears status filter when switching scan tabs", async () => { + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + searchParamsValue.current = "tab=completed&filter%5Bstate__in%5D=failed"; + const user = userEvent.setup(); + + render( + +
    Scans table
    +
    , + ); + + await user.click(screen.getByRole("tab", { name: /in progress/i })); + + const calledUrl = pushMock.mock.calls.at(-1)?.[0] as string; + expect(calledUrl).toContain("tab=active"); + expect(calledUrl).not.toContain("filter%5Bstate__in%5D"); + }); +}); diff --git a/ui/components/scans/scans-page-shell.tsx b/ui/components/scans/scans-page-shell.tsx new file mode 100644 index 0000000000..79579f59df --- /dev/null +++ b/ui/components/scans/scans-page-shell.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { type ReactNode, useState } from "react"; + +import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button"; +import { + Button, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/shadcn"; +import { + LAUNCH_SCAN_SEARCH_PARAM, + LAUNCH_SCAN_SEARCH_VALUE, +} from "@/lib/scans-navigation"; +import { useScansStore } from "@/store"; +import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types"; +import type { ProviderProps } from "@/types/providers"; + +import { LaunchScanModal } from "./launch-scan-modal"; +import { ScansFilterBar } from "./scans-filter-bar"; +import { useScansFilters } from "./use-scans-filters"; + +interface ScansPageShellProps { + providers: ProviderProps[]; + hasManageScansPermission: boolean; + activeScanCount?: number; + children: ReactNode; +} + +export function ScansPageShell({ + providers, + hasManageScansPermission, + activeScanCount = 0, + children, +}: ScansPageShellProps) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [urlLaunchOpen, setUrlLaunchOpen] = useState( + () => + searchParams.get(LAUNCH_SCAN_SEARCH_PARAM) === LAUNCH_SCAN_SEARCH_VALUE, + ); + const isLaunchScanModalOpen = useScansStore( + (state) => state.isLaunchScanModalOpen, + ); + const setLaunchScanModalOpen = useScansStore( + (state) => state.setLaunchScanModalOpen, + ); + const filters = useScansFilters(); + const hasConnectedProviders = providers.some( + (provider) => provider.attributes.connection.connected === true, + ); + const launchDisabled = !hasManageScansPermission || !hasConnectedProviders; + const launchOpen = isLaunchScanModalOpen || urlLaunchOpen; + + const getTabLabel = (tab: ScanJobsTab) => { + const label = SCAN_TAB_LABELS[tab]; + if (tab !== SCAN_JOBS_TAB.ACTIVE) return label; + + return `${label} (${activeScanCount})`; + }; + + const handleLaunchOpenChange = (open: boolean) => { + setLaunchScanModalOpen(open); + if (open) return; + setUrlLaunchOpen(false); + if (!searchParams.has(LAUNCH_SCAN_SEARCH_PARAM)) return; + const params = new URLSearchParams(searchParams.toString()); + params.delete(LAUNCH_SCAN_SEARCH_PARAM); + const query = params.toString(); + router.replace(query ? `${pathname}?${query}` : pathname, { + scroll: false, + }); + }; + + return ( +
    +
    + + + +
    + + +
    + + {Object.values(SCAN_JOBS_TAB).map((tab) => ( + + {getTabLabel(tab as ScanJobsTab)} + + ))} + +
    + +
    +
    + + {children} + +
    + + +
    + ); +} diff --git a/ui/components/scans/scans-providers-empty-state.test.tsx b/ui/components/scans/scans-providers-empty-state.test.tsx new file mode 100644 index 0000000000..8a488309b3 --- /dev/null +++ b/ui/components/scans/scans-providers-empty-state.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { ScansProvidersEmptyState } from "./scans-providers-empty-state"; + +vi.mock("@/components/providers/wizard", () => ({ + ProviderWizardModal: ({ open }: { open: boolean }) => + open ?
    Provider wizard
    : null, +})); + +vi.mock("./no-providers-connected", () => ({ + NoProvidersConnected: () =>
    No Connected Providers
    , +})); + +describe("ScansProvidersEmptyState", () => { + it("shows the add provider message and opens the provider wizard", async () => { + const user = userEvent.setup(); + + render(); + + expect(screen.getByText("No Providers Configured")).toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { name: /open add provider modal/i }), + ); + + expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard"); + }); + + it("shows the no connected providers message", () => { + render(); + + expect(screen.getByText("No Connected Providers")).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/scans/scans-providers-empty-state.tsx b/ui/components/scans/scans-providers-empty-state.tsx new file mode 100644 index 0000000000..153d8071c3 --- /dev/null +++ b/ui/components/scans/scans-providers-empty-state.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useState } from "react"; + +import { ProviderWizardModal } from "@/components/providers/wizard"; + +import { NoProvidersAdded } from "./no-providers-added"; +import { NoProvidersConnected } from "./no-providers-connected"; + +interface ScansProvidersEmptyStateProps { + thereIsNoProviders: boolean; +} + +export function ScansProvidersEmptyState({ + thereIsNoProviders, +}: ScansProvidersEmptyStateProps) { + const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false); + + return ( + <> + {thereIsNoProviders ? ( + setIsProviderWizardOpen(true)} /> + ) : ( + + )} + + + ); +} diff --git a/ui/components/scans/scans.utils.test.ts b/ui/components/scans/scans.utils.test.ts new file mode 100644 index 0000000000..a798080a08 --- /dev/null +++ b/ui/components/scans/scans.utils.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; + +import { + SCAN_JOBS_TAB, + type ScanAttributes, + type ScanProps, + type ScanTrigger, +} from "@/types"; + +import { + formatScanDuration, + getScanAlias, + getScanFindingsSummary, + getScanJobsTab, + getScanJobsTabFilters, + getScanJobsUserFilters, + getScanScheduleLabel, + getScanStatusLabel, + getScanTriggerFilterOptions, +} from "./scans.utils"; + +const makeScan = ( + name: string | null, + trigger: ScanTrigger = "manual", +): ScanProps => ({ + type: "scans", + id: "scan-1", + attributes: { + name: name ?? "", + trigger, + state: "completed", + unique_resource_count: 0, + progress: 100, + scanner_args: null, + duration: 0, + started_at: "", + inserted_at: "", + completed_at: "", + scheduled_at: "", + next_scan_at: "", + }, + relationships: { + provider: { data: { type: "providers", id: "provider-1" } }, + task: { data: { type: "tasks", id: "task-1" } }, + }, +}); + +describe("scans.utils", () => { + it("falls back to completed tab for unknown tab values", () => { + expect(getScanJobsTab("unknown")).toBe(SCAN_JOBS_TAB.COMPLETED); + expect(getScanJobsTab(SCAN_JOBS_TAB.COMPLETED)).toBe( + SCAN_JOBS_TAB.COMPLETED, + ); + }); + + it("maps scan job tabs to the state filters expected by the API", () => { + expect(getScanJobsTabFilters(SCAN_JOBS_TAB.ACTIVE)).toEqual({ + "filter[state__in]": "available,executing", + }); + expect(getScanJobsTabFilters(SCAN_JOBS_TAB.COMPLETED)).toEqual({ + "filter[state__in]": "completed,failed,cancelled", + }); + expect(getScanJobsTabFilters(SCAN_JOBS_TAB.SCHEDULED)).toEqual({ + "filter[state__in]": "scheduled", + }); + }); + + it("narrows tab state filters when a matching status is selected", () => { + expect(getScanJobsTabFilters(SCAN_JOBS_TAB.COMPLETED, "failed")).toEqual({ + "filter[state__in]": "failed", + }); + expect( + getScanJobsTabFilters(SCAN_JOBS_TAB.COMPLETED, "failed,cancelled"), + ).toEqual({ + "filter[state__in]": "failed,cancelled", + }); + expect(getScanJobsTabFilters(SCAN_JOBS_TAB.ACTIVE, "failed")).toEqual({ + "filter[state__in]": "available,executing", + }); + }); + + it("keeps user filters while excluding scan state filters", () => { + expect( + getScanJobsUserFilters({ + tab: "completed", + page: "2", + "filter[provider_uid]": "123456789012", + "filter[state__in]": "failed,cancelled", + "filter[search]": "production", + }), + ).toEqual({ + "filter[provider_uid]": "123456789012", + "filter[search]": "production", + }); + }); + + it("formats scan labels and durations for table display", () => { + expect(getScanAlias(makeScan(""))).toBe("-"); + expect(getScanAlias(makeScan("Daily scheduled scan", "scheduled"))).toBe( + "scheduled scan", + ); + expect(getScanAlias(makeScan("", "scheduled"))).toBe("scheduled scan"); + expect(getScanAlias(makeScan("Production scan"))).toBe("Production scan"); + expect(formatScanDuration(73)).toBe("1 min 13 sec"); + expect(formatScanDuration(null)).toBe("-"); + }); + + it("maps trigger and state values to product labels", () => { + expect(getScanScheduleLabel("manual")).toBe("Manual"); + expect(getScanScheduleLabel("scheduled")).toBe("Scheduled"); + expect(getScanScheduleLabel("imported")).toBe("Imported"); + expect(getScanStatusLabel("available")).toBe("Queued"); + expect(getScanStatusLabel("completed")).toBe("Completed"); + }); + + it("includes imported in the trigger filter only for Cloud", () => { + expect(getScanTriggerFilterOptions(false)).toEqual([ + { value: "all", label: "All Types" }, + { value: "manual", label: "Manual" }, + { value: "scheduled", label: "Scheduled" }, + ]); + expect(getScanTriggerFilterOptions(true)).toEqual([ + { value: "all", label: "All Types" }, + { value: "manual", label: "Manual" }, + { value: "scheduled", label: "Scheduled" }, + { value: "imported", label: "Imported" }, + ]); + }); + + it("reads findings summary from root or nested API fields", () => { + expect( + getScanFindingsSummary({ + fail: 2, + pass: 3, + fail_new: 1, + } as unknown as ScanAttributes), + ).toEqual({ fail: 2, pass: 3, failNew: 1 }); + + expect( + getScanFindingsSummary({ + findings: { + failed_findings: 4, + passed_findings: 8, + new_passed_findings: 2, + }, + } as unknown as ScanAttributes), + ).toEqual({ fail: 4, pass: 8, passNew: 2 }); + + expect(getScanFindingsSummary(makeScan("x").attributes)).toBeNull(); + }); +}); diff --git a/ui/components/scans/scans.utils.ts b/ui/components/scans/scans.utils.ts new file mode 100644 index 0000000000..d4c47d9392 --- /dev/null +++ b/ui/components/scans/scans.utils.ts @@ -0,0 +1,219 @@ +import { + DEFAULT_SCAN_JOBS_TAB, + SCAN_JOBS_TAB, + SCAN_STATE, + SCAN_TRIGGER, + type ScanAttributes, + type ScanFindingsSummary, + type ScanJobsTab, + type ScanProps, + type ScanState, + type ScanTrigger, + type SearchParamsProps, +} from "@/types"; + +export const SCAN_STATE_FILTER_KEYS = [ + "filter[state]", + "filter[state__in]", +] as const; + +const ALL_VALUE = "all"; + +const SCAN_JOBS_TAB_STATES: Record = { + [SCAN_JOBS_TAB.ACTIVE]: [SCAN_STATE.AVAILABLE, SCAN_STATE.EXECUTING], + [SCAN_JOBS_TAB.COMPLETED]: [ + SCAN_STATE.COMPLETED, + SCAN_STATE.FAILED, + SCAN_STATE.CANCELLED, + ], + [SCAN_JOBS_TAB.SCHEDULED]: [SCAN_STATE.SCHEDULED], +}; + +const toStateFilter = (states: ScanState[]): Record => ({ + "filter[state__in]": states.join(","), +}); + +const SCAN_JOBS_TAB_FILTERS = Object.fromEntries( + Object.entries(SCAN_JOBS_TAB_STATES).map(([tab, states]) => [ + tab, + toStateFilter(states), + ]), +) as Record>; + +export interface ScanTriggerFilterOption { + value: typeof ALL_VALUE | ScanTrigger; + label: string; +} + +export interface ScanStatusFilterOption { + value: typeof ALL_VALUE | ScanState; + label: string; +} + +export function getScanTriggerFilterOptions( + isCloudEnvironment: boolean, +): ScanTriggerFilterOption[] { + const options: ScanTriggerFilterOption[] = [ + { value: ALL_VALUE, label: "All Types" }, + { value: SCAN_TRIGGER.MANUAL, label: "Manual" }, + { value: SCAN_TRIGGER.SCHEDULED, label: "Scheduled" }, + ]; + + if (isCloudEnvironment) { + options.push({ value: SCAN_TRIGGER.IMPORTED, label: "Imported" }); + } + + return options; +} + +export function isScanStateFilterKey(key: string): boolean { + return SCAN_STATE_FILTER_KEYS.some((filterKey) => filterKey === key); +} + +function isSearchParamValue(value: unknown): value is string | string[] { + return typeof value === "string" || Array.isArray(value); +} + +export function getScanJobsUserFilters( + searchParams: SearchParamsProps, +): Record { + return Object.entries(searchParams).reduce>( + (filters, [key, value]) => { + if ( + key.startsWith("filter[") && + !isScanStateFilterKey(key) && + isSearchParamValue(value) + ) { + filters[key] = value; + } + + return filters; + }, + {}, + ); +} + +function parseStateFilter(value?: string | string[]): ScanState[] { + const rawValue = Array.isArray(value) ? value.join(",") : value; + if (!rawValue || rawValue === ALL_VALUE) return []; + + return rawValue + .split(",") + .filter((item): item is ScanState => + Object.values(SCAN_STATE).includes(item as ScanState), + ); +} + +export function getScanJobsTab(value?: string | string[]): ScanJobsTab { + const rawValue = Array.isArray(value) ? value[0] : value; + const tabs = Object.values(SCAN_JOBS_TAB); + + return tabs.includes(rawValue as ScanJobsTab) + ? (rawValue as ScanJobsTab) + : DEFAULT_SCAN_JOBS_TAB; +} + +export function getScanJobsTabFilters( + tab: ScanJobsTab, + stateFilter?: string | string[], +): Record { + const selectedStates = parseStateFilter(stateFilter); + const allowedStates = SCAN_JOBS_TAB_STATES[tab]; + const matchingStates = selectedStates.filter((state) => + allowedStates.includes(state), + ); + + if (matchingStates.length === 0) return { ...SCAN_JOBS_TAB_FILTERS[tab] }; + + return { "filter[state__in]": matchingStates.join(",") }; +} + +export function getScanAlias(scan: ScanProps): string { + if (scan.attributes.trigger === SCAN_TRIGGER.SCHEDULED) + return "scheduled scan"; + return scan.attributes.name?.trim() || "-"; +} + +export function formatScanDuration(duration?: number | null): string { + if (duration === null || duration === undefined || duration < 0) return "-"; + + const totalSeconds = Math.round(duration); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + + if (hours > 0) return `${hours}h ${remainingMinutes}m ${seconds}s`; + if (minutes > 0) return `${minutes} min ${seconds} sec`; + return `${seconds} sec`; +} + +export function getScanScheduleLabel(trigger?: ScanTrigger | string): string { + if (trigger === "scheduled") return "Scheduled"; + if (trigger === "manual") return "Manual"; + if (trigger === "imported") return "Imported"; + return "-"; +} + +export function getScanStatusLabel(state?: ScanState | string): string { + if (state === "available") return "Queued"; + if (!state) return "-"; + return state.charAt(0).toUpperCase() + state.slice(1); +} + +export function getScanStatusFilterOptions( + tab: ScanJobsTab, +): ScanStatusFilterOption[] { + return [ + { value: ALL_VALUE, label: "All Statuses" }, + ...SCAN_JOBS_TAB_STATES[tab].map((state) => ({ + value: state, + label: getScanStatusLabel(state), + })), + ]; +} + +function getNumericValue( + source: Record, + keys: string[], +): number | undefined { + for (const key of keys) { + const value = source[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + } + + return undefined; +} + +export function getScanFindingsSummary( + attributes: ScanAttributes, +): ScanFindingsSummary | null { + const root = attributes as unknown as Record; + const nested = + typeof root.findings === "object" && root.findings !== null + ? (root.findings as Record) + : {}; + const source = { ...root, ...nested }; + + const fail = getNumericValue(source, [ + "fail", + "failed", + "failed_findings", + "fail_findings", + ]); + const pass = getNumericValue(source, [ + "pass", + "passed", + "passed_findings", + "pass_findings", + ]); + + if (fail === undefined || pass === undefined) return null; + + return { + fail, + pass, + failNew: getNumericValue(source, ["fail_new", "new_failed_findings"]), + passNew: getNumericValue(source, ["pass_new", "new_passed_findings"]), + }; +} diff --git a/ui/components/scans/table/cells/account-cell.tsx b/ui/components/scans/table/cells/account-cell.tsx new file mode 100644 index 0000000000..ecc7bcc987 --- /dev/null +++ b/ui/components/scans/table/cells/account-cell.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { EntityInfo } from "@/components/ui/entities"; +import type { ProviderType, ScanProps } from "@/types"; + +export function AccountCell({ scan }: { scan: ScanProps }) { + const providerInfo = scan.providerInfo; + + if (!providerInfo) { + return -; + } + + return ( +
    + +
    + ); +} diff --git a/ui/components/scans/table/cells/index.ts b/ui/components/scans/table/cells/index.ts new file mode 100644 index 0000000000..3a3639d6dc --- /dev/null +++ b/ui/components/scans/table/cells/index.ts @@ -0,0 +1,5 @@ +export { AccountCell } from "./account-cell"; +export { ProgressCell } from "./progress-cell"; +export { ResourceCountCell } from "./resource-count-cell"; +export { ScanInfoCell } from "./scan-info-cell"; +export { ScheduleCell } from "./schedule-cell"; diff --git a/ui/components/scans/table/cells/progress-cell.tsx b/ui/components/scans/table/cells/progress-cell.tsx new file mode 100644 index 0000000000..7c018f5ebf --- /dev/null +++ b/ui/components/scans/table/cells/progress-cell.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Badge, Progress } from "@/components/shadcn"; +import type { ScanProps } from "@/types"; + +export function ProgressCell({ scan }: { scan: ScanProps }) { + const progress = scan.attributes.progress ?? 0; + const isQueued = scan.attributes.state === "available"; + + if (isQueued) { + return Queued for scan; + } + + return ( +
    + + + {progress}% + +
    + ); +} diff --git a/ui/components/scans/table/cells/resource-count-cell.tsx b/ui/components/scans/table/cells/resource-count-cell.tsx new file mode 100644 index 0000000000..3fd9e2fb7e --- /dev/null +++ b/ui/components/scans/table/cells/resource-count-cell.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { Badge } from "@/components/shadcn"; + +export function ResourceCountCell({ count }: { count?: number }) { + return ( + + {(count ?? 0).toLocaleString()} + + ); +} diff --git a/ui/components/scans/table/cells/scan-info-cell.tsx b/ui/components/scans/table/cells/scan-info-cell.tsx new file mode 100644 index 0000000000..aa61a118d0 --- /dev/null +++ b/ui/components/scans/table/cells/scan-info-cell.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { getScanAlias } from "@/components/scans/scans.utils"; +import { EntityInfo } from "@/components/ui/entities"; +import type { ScanProps } from "@/types"; + +export function ScanInfoCell({ scan }: { scan: ScanProps }) { + return ( +
    + +
    + ); +} diff --git a/ui/components/scans/table/cells/schedule-cell.tsx b/ui/components/scans/table/cells/schedule-cell.tsx new file mode 100644 index 0000000000..7e70dde52d --- /dev/null +++ b/ui/components/scans/table/cells/schedule-cell.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { getScanScheduleLabel } from "@/components/scans/scans.utils"; +import { DateWithTime } from "@/components/ui/entities"; +import type { ScanProps } from "@/types"; + +export function ScheduleCell({ scan }: { scan: ScanProps }) { + return ( +
    + + {getScanScheduleLabel(scan.attributes.trigger)} + + {scan.attributes.scheduled_at && ( + + )} +
    + ); +} diff --git a/ui/components/scans/table/index.ts b/ui/components/scans/table/index.ts index 0a8aee6de4..b5b15bbefb 100644 --- a/ui/components/scans/table/index.ts +++ b/ui/components/scans/table/index.ts @@ -1,2 +1,11 @@ -export * from "./scan-detail"; +export { + AccountCell, + ProgressCell, + ResourceCountCell, + ScanInfoCell, + ScheduleCell, +} from "./cells"; +export * from "./scan-jobs-columns"; +export * from "./scan-jobs-row-actions"; +export * from "./scan-jobs-table"; export * from "./skeleton-table-scans"; diff --git a/ui/components/scans/table/scan-detail.tsx b/ui/components/scans/table/scan-detail.tsx deleted file mode 100644 index f1fc6e1b5f..0000000000 --- a/ui/components/scans/table/scan-detail.tsx +++ /dev/null @@ -1,107 +0,0 @@ -"use client"; - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn"; -import { InfoField } from "@/components/shadcn/info-field/info-field"; -import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; -import { DateWithTime, EntityInfo } from "@/components/ui/entities"; -import { StatusBadge } from "@/components/ui/table/status-badge"; -import { formatDuration } from "@/lib/date-utils"; -import { ProviderProps, ProviderType, ScanProps, TaskDetails } from "@/types"; - -const renderValue = (value: string | null | undefined) => { - return value && value.trim() !== "" ? value : "-"; -}; - -export const ScanDetail = ({ - scanDetails, -}: { - scanDetails: ScanProps & { - taskDetails?: TaskDetails; - // TODO: Remove the "?" once we have a proper provider details type - providerDetails?: ProviderProps; - }; -}) => { - const scan = scanDetails.attributes; - const taskDetails = scanDetails.taskDetails; - const providerDetails = scanDetails.providerDetails?.attributes; - - return ( -
    - {/* Header */} -
    -
    - -
    - -
    - - {/* Scan Details */} - - - Scan Details - - -
    - {renderValue(scan.name)} - - {scan.unique_resource_count} - - {scan.progress}% -
    - -
    - {renderValue(scan.trigger)} - {renderValue(scan.state)} - - {formatDuration(scan.duration)} - -
    - - - - - - {scan.state === "failed" && taskDetails?.attributes.result && ( - <> - {taskDetails.attributes.result.exc_message && ( - - - - )} -
    - - {renderValue(taskDetails.attributes.result.exc_type)} - -
    - - )} - -
    - - - - - - - - - -
    -
    -
    -
    - ); -}; diff --git a/ui/components/scans/table/scan-jobs-columns.test.tsx b/ui/components/scans/table/scan-jobs-columns.test.tsx new file mode 100644 index 0000000000..d339e68cba --- /dev/null +++ b/ui/components/scans/table/scan-jobs-columns.test.tsx @@ -0,0 +1,166 @@ +import type { CellContext, HeaderContext } from "@tanstack/react-table"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ScanProps } from "@/types"; + +vi.mock("@/components/shadcn", () => ({ + Badge: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + Progress: () =>
    , +})); + +vi.mock("@/components/ui/entities", () => ({ + DateWithTime: () =>