From e60a4462e5417cea26ad3eebdd0a7f4030b3f5d7 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:08:06 +0200 Subject: [PATCH 01/15] fix(ui): refine add-provider wizard flow between scans and providers (#11424) --- ui/CHANGELOG.md | 16 ++ .../_components/accounts-selector.test.tsx | 45 ++- .../_components/accounts-selector.tsx | 76 +++-- .../provider-type-selector.test.tsx | 26 +- .../_components/provider-type-selector.tsx | 212 +++----------- .../table/provider-icon-cell.test.tsx | 45 +++ .../findings/table/provider-icon-cell.tsx | 47 +--- .../provider-type-icon.test.tsx | 126 +++++++++ .../providers-badge/provider-type-icon.tsx | 250 +++++++++++++++++ .../integrations/s3/s3-integration-form.tsx | 12 +- .../security-hub-integration-form.tsx | 12 +- .../providers/no-providers-added.tsx | 92 +++++++ .../providers-accounts-view.test.tsx | 260 +++++++++++++++++- .../providers/providers-accounts-view.tsx | 83 ++++-- .../hooks/use-provider-wizard-controller.ts | 9 +- .../wizard/provider-wizard-modal.tsx | 3 + ui/components/scans/no-providers-added.tsx | 38 --- .../scans-providers-empty-state.test.tsx | 74 ++--- .../scans/scans-providers-empty-state.tsx | 34 +-- ui/dependency-log.json | 24 +- ui/lib/providers-navigation.ts | 3 + ui/package.json | 8 +- ui/pnpm-lock.yaml | 213 +++++++------- ui/tests/helpers.ts | 31 ++- ui/tests/providers/providers-page.ts | 9 +- 25 files changed, 1198 insertions(+), 550 deletions(-) create mode 100644 ui/components/findings/table/provider-icon-cell.test.tsx create mode 100644 ui/components/icons/providers-badge/provider-type-icon.test.tsx create mode 100644 ui/components/icons/providers-badge/provider-type-icon.tsx create mode 100644 ui/components/providers/no-providers-added.tsx delete mode 100644 ui/components/scans/no-providers-added.tsx create mode 100644 ui/lib/providers-navigation.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 8ed4a4418c..db38de5fd3 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -10,6 +10,22 @@ All notable changes to the **Prowler UI** are documented in this file. --- +## [1.29.2] (Prowler v5.29.2) + +### 🔄 Changed + +- Account and provider-type selector triggers now show the provider icon, with a non-deduped icon stack [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424) + +### 🐞 Fixed + +- Add Provider modal now closes without reloading the providers page [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424) + +### 🔐 Security + +- Vitest toolchain upgraded `4.0.18` → `4.1.8` to clear two critical `pnpm audit` advisories [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424) + +--- + ## [1.29.0] (Prowler v5.29.0) ### 🚀 Added diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx index 626e0985da..19c63cf3d2 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; @@ -57,7 +57,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({ ); }, MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => ( -
{children}
+
{children}
), MultiSelectValue: ({ placeholder }: { placeholder: string }) => ( {placeholder} @@ -220,4 +220,45 @@ describe("AccountsSelector", () => { expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: false }); }); + + it("shows the provider icon next to the name in the trigger for a single selection", async () => { + render( + , + ); + + const trigger = screen.getByTestId("trigger"); + expect(await within(trigger).findByText("AWS")).toBeInTheDocument(); + expect(within(trigger).getByText("Production AWS")).toBeInTheDocument(); + }); + + it("renders one icon per selected account without deduping by provider type", async () => { + const secondAws = { + ...providers[0], + id: "provider-2", + attributes: { + ...providers[0].attributes, + uid: "999999999999", + alias: "Staging AWS", + }, + }; + + render( + , + ); + + const trigger = screen.getByTestId("trigger"); + // Two AWS accounts -> two AWS icons in the trigger (no dedupe). + expect(await within(trigger).findAllByText("AWS")).toHaveLength(2); + expect( + within(trigger).getByText("2 Providers selected"), + ).toBeInTheDocument(); + }); }); diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index a37807e793..0c389febb7 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -1,26 +1,12 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { ReactNode, useState } from "react"; +import { useState } from "react"; import { - AlibabaCloudProviderBadge, - AWSProviderBadge, - AzureProviderBadge, - CloudflareProviderBadge, - GCPProviderBadge, - GitHubProviderBadge, - GoogleWorkspaceProviderBadge, - IacProviderBadge, - ImageProviderBadge, - KS8ProviderBadge, - M365ProviderBadge, - MongoDBAtlasProviderBadge, - OktaProviderBadge, - OpenStackProviderBadge, - OracleCloudProviderBadge, - VercelProviderBadge, -} from "@/components/icons/providers-badge"; + ProviderTypeIcon, + ProviderTypeIconStack, +} from "@/components/icons/providers-badge/provider-type-icon"; import { Badge } from "@/components/shadcn"; import { MultiSelect, @@ -45,25 +31,6 @@ const ACCOUNT_SELECTOR_FILTER = { type AccountSelectorFilter = (typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER]; -const PROVIDER_ICON: Record = { - aws: , - azure: , - gcp: , - kubernetes: , - m365: , - github: , - googleworkspace: , - iac: , - image: , - oraclecloud: , - mongodbatlas: , - alibabacloud: , - cloudflare: , - openstack: , - vercel: , - okta: , -}; - /** Common props shared by both batch and instant modes. */ interface AccountsSelectorBaseProps { providers: ProviderProps[]; @@ -158,10 +125,36 @@ export function AccountsSelector({ if (selectedIds.length === 1) { const p = providers.find((pr) => getProviderValue(pr) === selectedIds[0]); const name = p ? p.attributes.alias || p.attributes.uid : selectedIds[0]; - return {name}; + return ( + + {p && ( + + )} + {name} + + ); } + // One icon per selected account (no dedupe): two accounts of the same + // provider show two icons, disambiguated by the UID tooltip on hover. + const items = selectedIds + .map((selectedId) => + providers.find((pr) => getProviderValue(pr) === selectedId), + ) + .filter((p): p is ProviderProps => Boolean(p)) + .map((p) => ({ + key: p.id, + type: p.attributes.provider as ProviderType, + tooltip: p.attributes.uid, + })); return ( - {selectedIds.length} Providers selected + + + + {selectedIds.length} Providers selected + + ); }; @@ -208,7 +201,6 @@ export function AccountsSelector({ 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]; const searchKeywords = [ displayName, p.attributes.alias, @@ -228,7 +220,9 @@ export function AccountsSelector({ if (closeOnSelect) setSelectorOpen(false); }} > - + {displayName} {isDisabled && Disconnected} 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 5cd94a3087..b2c05b336d 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { ProviderTypeSelector } from "./provider-type-selector"; @@ -39,7 +39,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
{children}
), MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => ( -
{children}
+
{children}
), MultiSelectValue: ({ placeholder }: { placeholder: string }) => ( {placeholder} @@ -145,4 +145,26 @@ describe("ProviderTypeSelector", () => { ).toHaveAttribute("aria-disabled", "true"); expect(screen.getByText("All selected")).toBeInTheDocument(); }); + + it("shows one icon per selected type and a count in the trigger", async () => { + const azure = { + ...providers[0], + id: "provider-2", + attributes: { ...providers[0].attributes, provider: "azure" as const }, + }; + + render( + , + ); + + const trigger = screen.getByTestId("trigger"); + expect(await within(trigger).findByText("AWS")).toBeInTheDocument(); + expect( + within(trigger).getByText("2 Provider Types 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 15d069b49d..e78412eeeb 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx @@ -1,8 +1,12 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { type ComponentType, lazy, Suspense } from "react"; +import { + PROVIDER_TYPE_DATA, + ProviderTypeIcon, + ProviderTypeIconStack, +} from "@/components/icons/providers-badge/provider-type-icon"; import { MultiSelect, MultiSelectContent, @@ -14,163 +18,6 @@ import { import { useUrlFilters } from "@/hooks/use-url-filters"; import { type ProviderProps, ProviderType } from "@/types/providers"; -const AWSProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.AWSProviderBadge, - })), -); -const AzureProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.AzureProviderBadge, - })), -); -const GCPProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.GCPProviderBadge, - })), -); -const KS8ProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.KS8ProviderBadge, - })), -); -const M365ProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.M365ProviderBadge, - })), -); -const GitHubProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.GitHubProviderBadge, - })), -); -const IacProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.IacProviderBadge, - })), -); -const ImageProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.ImageProviderBadge, - })), -); -const OracleCloudProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.OracleCloudProviderBadge, - })), -); -const MongoDBAtlasProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.MongoDBAtlasProviderBadge, - })), -); -const AlibabaCloudProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.AlibabaCloudProviderBadge, - })), -); -const CloudflareProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.CloudflareProviderBadge, - })), -); -const OpenStackProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.OpenStackProviderBadge, - })), -); -const GoogleWorkspaceProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.GoogleWorkspaceProviderBadge, - })), -); -const VercelProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.VercelProviderBadge, - })), -); -const OktaProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.OktaProviderBadge, - })), -); - -type IconProps = { width: number; height: number }; - -const IconPlaceholder = ({ width, height }: IconProps) => ( -
-); - -const PROVIDER_DATA: Record< - ProviderType, - { label: string; icon: ComponentType } -> = { - aws: { - label: "Amazon Web Services", - icon: AWSProviderBadge, - }, - azure: { - label: "Microsoft Azure", - icon: AzureProviderBadge, - }, - gcp: { - label: "Google Cloud Platform", - icon: GCPProviderBadge, - }, - kubernetes: { - label: "Kubernetes", - icon: KS8ProviderBadge, - }, - m365: { - label: "Microsoft 365", - icon: M365ProviderBadge, - }, - github: { - label: "GitHub", - icon: GitHubProviderBadge, - }, - googleworkspace: { - label: "Google Workspace", - icon: GoogleWorkspaceProviderBadge, - }, - iac: { - label: "Infrastructure as Code", - icon: IacProviderBadge, - }, - image: { - label: "Container Registry", - icon: ImageProviderBadge, - }, - oraclecloud: { - label: "Oracle Cloud Infrastructure", - icon: OracleCloudProviderBadge, - }, - mongodbatlas: { - label: "MongoDB Atlas", - icon: MongoDBAtlasProviderBadge, - }, - alibabacloud: { - label: "Alibaba Cloud", - icon: AlibabaCloudProviderBadge, - }, - cloudflare: { - label: "Cloudflare", - icon: CloudflareProviderBadge, - }, - openstack: { - label: "OpenStack", - icon: OpenStackProviderBadge, - }, - vercel: { - label: "Vercel", - icon: VercelProviderBadge, - }, - okta: { - label: "Okta", - icon: OktaProviderBadge, - }, -}; - /** Common props shared by both batch and instant modes. */ interface ProviderTypeSelectorBaseProps { providers: ProviderProps[]; @@ -247,34 +94,38 @@ export const ProviderTypeSelector = ({ .map((p) => p.attributes.provider), ), ) - .filter((type): type is ProviderType => type in PROVIDER_DATA) + .filter((type): type is ProviderType => type in PROVIDER_TYPE_DATA) .sort((a, b) => - PROVIDER_DATA[a].label.localeCompare(PROVIDER_DATA[b].label), + PROVIDER_TYPE_DATA[a].label.localeCompare(PROVIDER_TYPE_DATA[b].label), ); - const renderIcon = (providerType: ProviderType) => { - const IconComponent = PROVIDER_DATA[providerType].icon; - return ( - }> - - - ); - }; - const selectedLabel = () => { if (selectedTypes.length === 0) return null; if (selectedTypes.length === 1) { const providerType = selectedTypes[0] as ProviderType; return ( - {renderIcon(providerType)} - {PROVIDER_DATA[providerType].label} + + + {PROVIDER_TYPE_DATA[providerType].label} + ); } return ( - - {selectedTypes.length} Provider Types selected + + ({ + key: type, + type, + tooltip: PROVIDER_TYPE_DATA[type].label, + }))} + /> + + {selectedTypes.length} Provider Types selected + ); }; @@ -329,12 +180,17 @@ export const ProviderTypeSelector = ({ - - {PROVIDER_DATA[providerType].label} + + {PROVIDER_TYPE_DATA[providerType].label} ))} diff --git a/ui/components/findings/table/provider-icon-cell.test.tsx b/ui/components/findings/table/provider-icon-cell.test.tsx new file mode 100644 index 0000000000..593122d33a --- /dev/null +++ b/ui/components/findings/table/provider-icon-cell.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ProviderType } from "@/types/providers"; + +import { ProviderIconCell } from "./provider-icon-cell"; + +// Render the lazy provider badges as plain text so we can assert on them. The +// real PROVIDER_TYPE_DATA map (and its `in` guard) is exercised on purpose. +vi.mock("@/components/icons/providers-badge", () => ({ + AWSProviderBadge: () => AWS, + AzureProviderBadge: () => Azure, + GCPProviderBadge: () => GCP, + KS8ProviderBadge: () => Kubernetes, + M365ProviderBadge: () => M365, + GitHubProviderBadge: () => GitHub, + GoogleWorkspaceProviderBadge: () => Google Workspace, + IacProviderBadge: () => IaC, + ImageProviderBadge: () => Image, + OracleCloudProviderBadge: () => Oracle Cloud, + MongoDBAtlasProviderBadge: () => MongoDB Atlas, + AlibabaCloudProviderBadge: () => Alibaba Cloud, + CloudflareProviderBadge: () => Cloudflare, + OpenStackProviderBadge: () => OpenStack, + VercelProviderBadge: () => Vercel, + OktaProviderBadge: () => Okta, +})); + +describe("ProviderIconCell", () => { + it("renders the shared provider-type icon for a known provider", async () => { + render(); + + expect(await screen.findByText("AWS")).toBeInTheDocument(); + }); + + it("renders a '?' placeholder for a provider type missing from the map", () => { + render( + , + ); + + expect(screen.getByText("?")).toBeInTheDocument(); + }); +}); diff --git a/ui/components/findings/table/provider-icon-cell.tsx b/ui/components/findings/table/provider-icon-cell.tsx index 05a35b3dc2..350e730e78 100644 --- a/ui/components/findings/table/provider-icon-cell.tsx +++ b/ui/components/findings/table/provider-icon-cell.tsx @@ -1,43 +1,10 @@ import { - AlibabaCloudProviderBadge, - AWSProviderBadge, - AzureProviderBadge, - CloudflareProviderBadge, - GCPProviderBadge, - GitHubProviderBadge, - GoogleWorkspaceProviderBadge, - IacProviderBadge, - ImageProviderBadge, - KS8ProviderBadge, - M365ProviderBadge, - MongoDBAtlasProviderBadge, - OktaProviderBadge, - OpenStackProviderBadge, - OracleCloudProviderBadge, - VercelProviderBadge, -} from "@/components/icons/providers-badge"; + PROVIDER_TYPE_DATA, + ProviderTypeIcon, +} from "@/components/icons/providers-badge/provider-type-icon"; import { cn } from "@/lib/utils"; import { ProviderType } from "@/types"; -export const PROVIDER_ICONS = { - aws: AWSProviderBadge, - azure: AzureProviderBadge, - gcp: GCPProviderBadge, - kubernetes: KS8ProviderBadge, - m365: M365ProviderBadge, - github: GitHubProviderBadge, - googleworkspace: GoogleWorkspaceProviderBadge, - iac: IacProviderBadge, - image: ImageProviderBadge, - oraclecloud: OracleCloudProviderBadge, - mongodbatlas: MongoDBAtlasProviderBadge, - alibabacloud: AlibabaCloudProviderBadge, - cloudflare: CloudflareProviderBadge, - openstack: OpenStackProviderBadge, - vercel: VercelProviderBadge, - okta: OktaProviderBadge, -} as const; - interface ProviderIconCellProps { provider: ProviderType; size?: number; @@ -49,9 +16,9 @@ export const ProviderIconCell = ({ size = 26, className = "size-8 rounded-md bg-white", }: ProviderIconCellProps) => { - const IconComponent = PROVIDER_ICONS[provider]; - - if (!IconComponent) { + // Unknown provider types (present in the data but missing from the shared + // PROVIDER_TYPE_DATA map) render an explicit "?" rather than an empty icon. + if (!(provider in PROVIDER_TYPE_DATA)) { return (
? @@ -66,7 +33,7 @@ export const ProviderIconCell = ({ className, )} > - +
); }; diff --git a/ui/components/icons/providers-badge/provider-type-icon.test.tsx b/ui/components/icons/providers-badge/provider-type-icon.test.tsx new file mode 100644 index 0000000000..96b4f5f4d4 --- /dev/null +++ b/ui/components/icons/providers-badge/provider-type-icon.test.tsx @@ -0,0 +1,126 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ProviderType } from "@/types/providers"; + +import { ProviderTypeIcon, ProviderTypeIconStack } from "./provider-type-icon"; + +// A provider type the API may return but this UI build does not know about. +const UNKNOWN_TYPE = "future-provider" as unknown as ProviderType; + +// Render the lazy provider badges as plain text so we can assert on them. +vi.mock("@/components/icons/providers-badge", () => ({ + AWSProviderBadge: () => AWS, + AzureProviderBadge: () => Azure, + GCPProviderBadge: () => GCP, + KS8ProviderBadge: () => Kubernetes, + M365ProviderBadge: () => M365, + GitHubProviderBadge: () => GitHub, + GoogleWorkspaceProviderBadge: () => Google Workspace, + IacProviderBadge: () => IaC, + ImageProviderBadge: () => Image, + OracleCloudProviderBadge: () => Oracle Cloud, + MongoDBAtlasProviderBadge: () => MongoDB Atlas, + AlibabaCloudProviderBadge: () => Alibaba Cloud, + CloudflareProviderBadge: () => Cloudflare, + OpenStackProviderBadge: () => OpenStack, + VercelProviderBadge: () => Vercel, + OktaProviderBadge: () => Okta, +})); + +// Render the tooltip pieces inline so the hover content is queryable in jsdom. +vi.mock("@/components/shadcn", () => ({ + Badge: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + TooltipContent: ({ children }: { children: React.ReactNode }) => ( + {children} + ), +})); + +describe("ProviderTypeIcon", () => { + it("renders the badge for the given provider type", async () => { + render(); + + expect(await screen.findByText("AWS")).toBeInTheDocument(); + }); + + it("renders a sized placeholder instead of crashing for an unknown type", () => { + // Regression guard for #9991: an unknown provider type must not throw. + const { container } = render( + , + ); + + expect(screen.queryByText("AWS")).not.toBeInTheDocument(); + expect(container.querySelector("div")).toHaveStyle({ + width: "24px", + height: "24px", + }); + }); +}); + +describe("ProviderTypeIconStack", () => { + it("renders one icon per item without deduping by type", async () => { + render( + , + ); + + // Two AWS accounts -> two AWS icons (no dedupe). + expect(await screen.findAllByText("AWS")).toHaveLength(2); + }); + + it("shows each item's tooltip text on the icon", async () => { + render( + , + ); + + expect(await screen.findByTestId("tooltip")).toHaveTextContent( + "account-uid-123", + ); + }); + + it("collapses items beyond `max` into a +N badge", async () => { + render( + , + ); + + expect(await screen.findByTestId("badge")).toHaveTextContent("+2"); + // First icon is shown; items sliced beyond `max` never reach the DOM. + expect(await screen.findByText("AWS")).toBeInTheDocument(); + expect(screen.queryByText("Okta")).not.toBeInTheDocument(); + }); + + it("renders known icons and skips unknown types without crashing", async () => { + // Regression guard for #9991: an unknown type in the stack must not throw. + render( + , + ); + + expect(await screen.findByText("AWS")).toBeInTheDocument(); + }); +}); diff --git a/ui/components/icons/providers-badge/provider-type-icon.tsx b/ui/components/icons/providers-badge/provider-type-icon.tsx new file mode 100644 index 0000000000..c3915f7d40 --- /dev/null +++ b/ui/components/icons/providers-badge/provider-type-icon.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { type ComponentType, lazy, Suspense } from "react"; + +import { + Badge, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn"; +import { cn } from "@/lib/utils"; +import type { ProviderType } from "@/types/providers"; + +type IconProps = { width: number; height: number }; + +const IconPlaceholder = ({ width, height }: IconProps) => ( +
+); + +// Lazy-load every provider badge so the ~16 SVGs ship in a single deferred +// chunk instead of being eagerly bundled wherever a selector is imported. +const AWSProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.AWSProviderBadge, + })), +); +const AzureProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.AzureProviderBadge, + })), +); +const GCPProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.GCPProviderBadge, + })), +); +const KS8ProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.KS8ProviderBadge, + })), +); +const M365ProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.M365ProviderBadge, + })), +); +const GitHubProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.GitHubProviderBadge, + })), +); +const GoogleWorkspaceProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.GoogleWorkspaceProviderBadge, + })), +); +const IacProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.IacProviderBadge, + })), +); +const ImageProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.ImageProviderBadge, + })), +); +const OracleCloudProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.OracleCloudProviderBadge, + })), +); +const MongoDBAtlasProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.MongoDBAtlasProviderBadge, + })), +); +const AlibabaCloudProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.AlibabaCloudProviderBadge, + })), +); +const CloudflareProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.CloudflareProviderBadge, + })), +); +const OpenStackProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.OpenStackProviderBadge, + })), +); +const VercelProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.VercelProviderBadge, + })), +); +const OktaProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.OktaProviderBadge, + })), +); + +/** + * Single source of truth mapping each provider type to its human-readable + * label and (lazy) badge component. Shared by the account and provider-type + * selectors so both stay in sync on labels, icons, and sizing. + */ +export const PROVIDER_TYPE_DATA: Record< + ProviderType, + { label: string; icon: ComponentType } +> = { + aws: { label: "Amazon Web Services", icon: AWSProviderBadge }, + azure: { label: "Microsoft Azure", icon: AzureProviderBadge }, + gcp: { label: "Google Cloud Platform", icon: GCPProviderBadge }, + kubernetes: { label: "Kubernetes", icon: KS8ProviderBadge }, + m365: { label: "Microsoft 365", icon: M365ProviderBadge }, + github: { label: "GitHub", icon: GitHubProviderBadge }, + googleworkspace: { + label: "Google Workspace", + icon: GoogleWorkspaceProviderBadge, + }, + iac: { label: "Infrastructure as Code", icon: IacProviderBadge }, + image: { label: "Container Registry", icon: ImageProviderBadge }, + oraclecloud: { + label: "Oracle Cloud Infrastructure", + icon: OracleCloudProviderBadge, + }, + mongodbatlas: { label: "MongoDB Atlas", icon: MongoDBAtlasProviderBadge }, + alibabacloud: { label: "Alibaba Cloud", icon: AlibabaCloudProviderBadge }, + cloudflare: { label: "Cloudflare", icon: CloudflareProviderBadge }, + openstack: { label: "OpenStack", icon: OpenStackProviderBadge }, + vercel: { label: "Vercel", icon: VercelProviderBadge }, + okta: { label: "Okta", icon: OktaProviderBadge }, +}; + +interface ProviderTypeIconProps { + type: ProviderType; + size?: number; +} + +/** + * Renders a single provider-type badge with a sized placeholder fallback. + * + * Falls back to the placeholder for provider types missing from + * `PROVIDER_TYPE_DATA` (e.g. a brand-new provider the API knows but this UI + * build does not). The `type` is statically typed as `ProviderType`, so this + * only guards the runtime case — see #9991, which fixed the same crash class. + */ +export const ProviderTypeIcon = ({ + type, + size = 18, +}: ProviderTypeIconProps) => { + const data = PROVIDER_TYPE_DATA[type]; + if (!data) return ; + + const Icon = data.icon; + return ( + }> + + + ); +}; + +export interface ProviderTypeIconStackItem { + /** Stable React key (account id for accounts, provider type for types). */ + key: string; + type: ProviderType; + /** Text shown on hover to disambiguate the icon (e.g. an account UID). */ + tooltip?: string; +} + +interface ProviderTypeIconStackProps { + items: ProviderTypeIconStackItem[]; + max?: number; + size?: number; + className?: string; +} + +/** + * Icon with a hover tooltip. `TooltipContent` (shadcn) already renders inside a + * Radix portal, so the tooltip is not clipped by the selector trigger and we do + * not need to portal it ourselves. `delayDuration` is set on the tooltip itself + * because shadcn's `Tooltip` wraps each instance in its own `TooltipProvider` + * (delay 0), which would otherwise override an ancestor provider's delay. + */ +const IconWithTooltip = ({ + item, + size, +}: { + item: ProviderTypeIconStackItem; + size: number; +}) => { + const icon = ( + + + + ); + + if (!item.tooltip) return icon; + + return ( + + {icon} + {item.tooltip} + + ); +}; + +/** + * Renders up to `max` provider-type icons followed by a `+N` badge for the + * remainder. Each icon shows its `tooltip` on hover. Items are rendered as + * passed (one per selection) — callers decide whether to dedupe. + */ +export const ProviderTypeIconStack = ({ + items, + max = 3, + size = 18, + className, +}: ProviderTypeIconStackProps) => { + const visible = items.slice(0, max); + const overflow = items.slice(max); + const overflowLabel = overflow + .map((item) => item.tooltip) + .filter(Boolean) + .join(", "); + + return ( + + + {visible.map((item) => ( + + ))} + + {overflow.length > 0 && ( + + + + +{overflow.length} + + + {overflowLabel && ( + + {overflowLabel} + + )} + + )} + + ); +}; diff --git a/ui/components/integrations/s3/s3-integration-form.tsx b/ui/components/integrations/s3/s3-integration-form.tsx index 7b3af4b018..23be644570 100644 --- a/ui/components/integrations/s3/s3-integration-form.tsx +++ b/ui/components/integrations/s3/s3-integration-form.tsx @@ -8,7 +8,10 @@ import { useState } from "react"; import { Control, useForm } from "react-hook-form"; import { createIntegration, updateIntegration } from "@/actions/integrations"; -import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell"; +import { + PROVIDER_TYPE_DATA, + ProviderTypeIcon, +} from "@/components/icons/providers-badge/provider-type-icon"; import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form"; import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; import { useToast } from "@/components/ui"; @@ -279,11 +282,14 @@ export const S3IntegrationForm = ({ // Show configuration step (step 0 or editing configuration) if (isEditingConfig || currentStep === 0) { const providerOptions = providers.map((provider) => { - const Icon = PROVIDER_ICONS[provider.attributes.provider]; + const providerType = provider.attributes.provider; return { value: provider.id, label: provider.attributes.alias || provider.attributes.uid, - icon: Icon ? : undefined, + icon: + providerType in PROVIDER_TYPE_DATA ? ( + + ) : undefined, description: provider.attributes.connection.connected ? "Connected" : "Disconnected", diff --git a/ui/components/integrations/security-hub/security-hub-integration-form.tsx b/ui/components/integrations/security-hub/security-hub-integration-form.tsx index 8a62f62457..1c3b436832 100644 --- a/ui/components/integrations/security-hub/security-hub-integration-form.tsx +++ b/ui/components/integrations/security-hub/security-hub-integration-form.tsx @@ -10,7 +10,10 @@ import { useEffect, useState } from "react"; import { Control, useForm } from "react-hook-form"; import { createIntegration, updateIntegration } from "@/actions/integrations"; -import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell"; +import { + PROVIDER_TYPE_DATA, + ProviderTypeIcon, +} from "@/components/icons/providers-badge/provider-type-icon"; import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form"; import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; import { useToast } from "@/components/ui"; @@ -121,11 +124,14 @@ export const SecurityHubIntegrationForm = ({ ? "Connected" : "Disconnected"; - const Icon = PROVIDER_ICONS[provider.attributes.provider]; + const providerType = provider.attributes.provider; return { value: provider.id, label: provider.attributes.alias || provider.attributes.uid, - icon: Icon ? : undefined, + icon: + providerType in PROVIDER_TYPE_DATA ? ( + + ) : undefined, description: isDisabled ? `${connectionLabel} (Already in use)` : connectionLabel, diff --git a/ui/components/providers/no-providers-added.tsx b/ui/components/providers/no-providers-added.tsx new file mode 100644 index 0000000000..0860f92d80 --- /dev/null +++ b/ui/components/providers/no-providers-added.tsx @@ -0,0 +1,92 @@ +"use client"; + +import Link from "next/link"; + +import { InfoIcon } from "@/components/icons/Icons"; +import { Button, Card, CardContent } from "@/components/shadcn"; +import { cn } from "@/lib/utils"; + +const NO_PROVIDERS_ADDED_ACTION = { + BUTTON: "button", + LINK: "link", +} as const; + +interface NoProvidersAddedBaseProps { + containerClassName?: string; +} + +interface NoProvidersAddedButtonProps extends NoProvidersAddedBaseProps { + action: typeof NO_PROVIDERS_ADDED_ACTION.BUTTON; + onOpenWizard: () => void; + href?: never; +} + +interface NoProvidersAddedLinkProps extends NoProvidersAddedBaseProps { + action: typeof NO_PROVIDERS_ADDED_ACTION.LINK; + href: string; + onOpenWizard?: never; +} + +type NoProvidersAddedProps = + | NoProvidersAddedButtonProps + | NoProvidersAddedLinkProps; + +const renderCta = (props: NoProvidersAddedProps) => { + if (props.action === NO_PROVIDERS_ADDED_ACTION.LINK) { + return ( + + ); + } + + return ( + + ); +}; + +export const NoProvidersAdded = (props: NoProvidersAddedProps) => { + return ( +
+ + +
+ +

+ No Providers Configured +

+
+
+

+ No providers have been configured. Start by setting up a provider. +

+
+ + {renderCta(props)} +
+
+
+ ); +}; diff --git a/ui/components/providers/providers-accounts-view.test.tsx b/ui/components/providers/providers-accounts-view.test.tsx index 9263277ea9..c8b3baf596 100644 --- a/ui/components/providers/providers-accounts-view.test.tsx +++ b/ui/components/providers/providers-accounts-view.test.tsx @@ -1,11 +1,36 @@ import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { FilterOption, MetaDataProps, ProviderProps } from "@/types"; import type { ProvidersTableRow } from "@/types/providers-table"; +const { refreshMock, replaceMock, searchParamsValue } = vi.hoisted(() => ({ + refreshMock: vi.fn(), + replaceMock: vi.fn(), + searchParamsValue: { current: "" }, +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/providers", + useRouter: () => ({ + refresh: refreshMock, + replace: replaceMock, + }), + useSearchParams: () => new URLSearchParams(searchParamsValue.current), +})); + +vi.mock("@/components/providers/table", () => ({ + SkeletonTableProviders: () =>
, +})); + vi.mock("@/components/providers/add-provider-button", () => ({ - AddProviderButton: () => , + AddProviderButton: ({ onOpenWizard }: { onOpenWizard: () => void }) => ( + + ), })); vi.mock("@/components/providers/muted-findings-config-button", () => ({ @@ -15,7 +40,12 @@ vi.mock("@/components/providers/muted-findings-config-button", () => ({ })); vi.mock("@/components/providers/providers-filters", () => ({ - ProvidersFilters: () =>
Filters
, + ProvidersFilters: ({ actions }: { actions: ReactNode }) => ( +
+ Filters + {actions} +
+ ), })); vi.mock("@/components/providers/providers-accounts-table", () => ({ @@ -23,7 +53,21 @@ vi.mock("@/components/providers/providers-accounts-table", () => ({ })); vi.mock("@/components/providers/wizard", () => ({ - ProviderWizardModal: () =>
, + ProviderWizardModal: ({ + open, + onOpenChange, + }: { + open: boolean; + onOpenChange: (open: boolean) => void; + }) => + open ? ( +
+ Provider wizard + +
+ ) : null, })); import { ProvidersAccountsView } from "./providers-accounts-view"; @@ -36,8 +80,55 @@ const metadata: MetaDataProps = { version: "latest", }; +const disconnectedProviders: ProviderProps[] = [ + { + id: "provider-1", + type: "providers", + attributes: { + provider: "aws", + uid: "123456789012", + alias: "Production", + status: "completed", + resources: 0, + connection: { + connected: false, + 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("ProvidersAccountsView", () => { - it("keeps the same vertical spacing between filters and table as other views", () => { + afterEach(() => { + vi.restoreAllMocks(); + searchParamsValue.current = ""; + window.history.replaceState({}, "", "/"); + }); + + it("shows a full page empty state without filters or table when there are no providers", () => { + // Given/When render( { />, ); + // Then + expect(screen.getByText("No Providers Configured")).toBeInTheDocument(); + expect( + screen.getByRole("region", { name: /no providers configured/i }), + ).toHaveClass("min-h-[calc(100dvh-28rem)]"); + expect(screen.queryByTestId("providers-filters")).not.toBeInTheDocument(); + expect(screen.queryByTestId("providers-table")).not.toBeInTheDocument(); + }); + + it("opens the provider wizard from the no providers CTA", async () => { + // Given + const user = userEvent.setup(); + + render( + , + ); + + // When + await user.click( + screen.getByRole("button", { name: /open add provider modal/i }), + ); + + // Then + expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard"); + }); + + it("opens the provider wizard from the URL without immediately clearing the one-shot intent", () => { + // Given + searchParamsValue.current = "tab=connected&addProvider=true"; + window.history.replaceState( + {}, + "", + "/providers?tab=connected&addProvider=true", + ); + // Spy only after the URL setup so we measure what the component does on mount. + const replaceStateSpy = vi.spyOn(window.history, "replaceState"); + + render( + , + ); + + // Then + expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard"); + expect(replaceStateSpy).not.toHaveBeenCalled(); + }); + + it("cleans the one-shot intent from the URL without refetching when the URL-opened wizard closes", async () => { + // Given + searchParamsValue.current = "tab=connected&addProvider=true"; + const replaceStateSpy = vi.spyOn(window.history, "replaceState"); + const user = userEvent.setup(); + + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: /close/i })); + + // Then + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + // The URL is cleaned via the History API (no RSC refetch). We must NOT + // refresh/replace here: re-running the /providers Server Component on close + // read as a full page reload. The provider-creation actions already + // revalidatePath("/providers"), so the table is fresh behind the modal. + expect(replaceStateSpy).toHaveBeenCalledWith( + null, + "", + "/providers?tab=connected", + ); + expect(refreshMock).not.toHaveBeenCalled(); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it("does not touch the URL or refetch when a manually opened wizard closes", async () => { + // Given: no addProvider param in the URL, wizard opened via the CTA. + searchParamsValue.current = ""; + const replaceStateSpy = vi.spyOn(window.history, "replaceState"); + const user = userEvent.setup(); + + render( + , + ); + + // When: open the wizard from the empty-state CTA, then close it. + await user.click( + screen.getByRole("button", { name: /open add provider modal/i }), + ); + await user.click(screen.getByRole("button", { name: /close/i })); + + // Then: nothing to clean and no refresh — the creation actions own the + // data refresh via revalidatePath. + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(replaceStateSpy).not.toHaveBeenCalled(); + expect(refreshMock).not.toHaveBeenCalled(); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it("keeps filters and table visible when providers are disconnected", () => { + // Given/When + render( + , + ); + + // Then expect(screen.getByTestId("providers-filters").parentElement).toHaveClass( "flex", "flex-col", "gap-6", ); expect(screen.getByTestId("providers-table")).toBeInTheDocument(); + expect( + screen.queryByText("No Providers Configured"), + ).not.toBeInTheDocument(); + }); + + it("opens the provider wizard from the normal Add Provider button", async () => { + // Given + const user = userEvent.setup(); + + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: /add provider/i })); + + // Then + expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard"); }); }); diff --git a/ui/components/providers/providers-accounts-view.tsx b/ui/components/providers/providers-accounts-view.tsx index b53155d8c0..62322e2ccd 100644 --- a/ui/components/providers/providers-accounts-view.tsx +++ b/ui/components/providers/providers-accounts-view.tsx @@ -1,9 +1,11 @@ "use client"; +import { usePathname, useSearchParams } from "next/navigation"; import { useState } from "react"; import { AddProviderButton } from "@/components/providers/add-provider-button"; import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button"; +import { NoProvidersAdded } from "@/components/providers/no-providers-added"; import { ProvidersAccountsTable } from "@/components/providers/providers-accounts-table"; import { ProvidersFilters } from "@/components/providers/providers-filters"; import { ProviderWizardModal } from "@/components/providers/wizard"; @@ -11,6 +13,10 @@ import type { OrgWizardInitialData, ProviderWizardInitialData, } from "@/components/providers/wizard/types"; +import { + ADD_PROVIDER_SEARCH_PARAM, + ADD_PROVIDER_SEARCH_VALUE, +} from "@/lib/providers-navigation"; import type { FilterOption, MetaDataProps, ProviderProps } from "@/types"; import type { ProvidersTableRow } from "@/types/providers-table"; @@ -29,7 +35,14 @@ export function ProvidersAccountsView({ providers, rows, }: ProvidersAccountsViewProps) { - const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const hasNoProviders = providers.length === 0; + const shouldOpenProviderWizardFromUrl = + searchParams.get(ADD_PROVIDER_SEARCH_PARAM) === ADD_PROVIDER_SEARCH_VALUE; + const [isProviderWizardOpen, setIsProviderWizardOpen] = useState( + () => shouldOpenProviderWizardFromUrl, + ); const [providerWizardInitialData, setProviderWizardInitialData] = useState< ProviderWizardInitialData | undefined >(undefined); @@ -52,38 +65,64 @@ export function ProvidersAccountsView({ const handleWizardOpenChange = (open: boolean) => { setIsProviderWizardOpen(open); - if (!open) { - setProviderWizardInitialData(undefined); - setOrgWizardInitialData(undefined); + if (open) return; + + setProviderWizardInitialData(undefined); + setOrgWizardInitialData(undefined); + + // Only clean the one-shot ?addProvider intent from the URL bar, via the + // History API so it does NOT trigger an RSC refetch. We must not refresh + // here: the provider-creation actions (addProvider / addCredentialsProvider + // / checkConnectionProvider) already revalidatePath("/providers"), so the + // table updates behind the modal. A router.refresh()/replace() on close + // re-ran the whole /providers Server Component, which read as a full reload. + if (searchParams.has(ADD_PROVIDER_SEARCH_PARAM)) { + const params = new URLSearchParams(searchParams.toString()); + params.delete(ADD_PROVIDER_SEARCH_PARAM); + const query = params.toString(); + window.history.replaceState( + null, + "", + query ? `${pathname}?${query}` : pathname, + ); } }; return ( <> -
- - - openProviderWizard()} /> - - } + {hasNoProviders ? ( + openProviderWizard()} /> - -
+ ) : ( +
+ + + openProviderWizard()} /> + + } + /> + +
+ )} ); diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts index 40644450c2..dea38e95a9 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts @@ -50,6 +50,10 @@ interface UseProviderWizardControllerProps { onOpenChange: (open: boolean) => void; initialData?: ProviderWizardInitialData; orgInitialData?: OrgWizardInitialData; + // When false, the caller skips the post-close router.refresh() and relies on + // the provider-creation actions' revalidatePath("/providers") to refresh the + // data. Defaults to true so standalone callers keep refreshing. + refreshOnClose?: boolean; } export function useProviderWizardController({ @@ -57,6 +61,7 @@ export function useProviderWizardController({ onOpenChange, initialData, orgInitialData, + refreshOnClose = true, }: UseProviderWizardControllerProps) { const router = useRouter(); const initialProviderId = initialData?.providerId ?? null; @@ -185,7 +190,9 @@ export function useProviderWizardController({ setProviderTypeHint(null); setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); onOpenChange(false); - router.refresh(); + if (refreshOnClose) { + router.refresh(); + } }; const handleDialogOpenChange = (nextOpen: boolean) => { diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index d731ca5786..2d0646d957 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -38,6 +38,7 @@ interface ProviderWizardModalProps { onOpenChange: (open: boolean) => void; initialData?: ProviderWizardInitialData; orgInitialData?: OrgWizardInitialData; + refreshOnClose?: boolean; } export function ProviderWizardModal({ @@ -45,6 +46,7 @@ export function ProviderWizardModal({ onOpenChange, initialData, orgInitialData, + refreshOnClose, }: ProviderWizardModalProps) { const { backToProviderFlow, @@ -72,6 +74,7 @@ export function ProviderWizardModal({ onOpenChange, initialData, orgInitialData, + refreshOnClose, }); const scrollHintRefreshToken = `${wizardVariant}-${currentStep}-${orgCurrentStep}-${orgSetupPhase}`; const { containerRef, sentinelRef, showScrollHint } = useScrollHint({ diff --git a/ui/components/scans/no-providers-added.tsx b/ui/components/scans/no-providers-added.tsx deleted file mode 100644 index a108009c2d..0000000000 --- a/ui/components/scans/no-providers-added.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import { Button, Card, CardContent } from "@/components/shadcn"; - -import { InfoIcon } from "../icons/Icons"; - -interface NoProvidersAddedProps { - onOpenWizard: () => void; -} - -export const NoProvidersAdded = ({ onOpenWizard }: NoProvidersAddedProps) => ( -
- - -
- -

- No Providers Configured -

-
-
-

- No providers have been configured. Start by setting up a provider. -

-
- - -
-
-
-); diff --git a/ui/components/scans/scans-providers-empty-state.test.tsx b/ui/components/scans/scans-providers-empty-state.test.tsx index dc6748a785..deee8318e3 100644 --- a/ui/components/scans/scans-providers-empty-state.test.tsx +++ b/ui/components/scans/scans-providers-empty-state.test.tsx @@ -1,73 +1,43 @@ import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + +import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation"; import { ScansProvidersEmptyState } from "./scans-providers-empty-state"; -const { replaceMock, searchParamsValue } = vi.hoisted(() => ({ - replaceMock: vi.fn(), - searchParamsValue: { current: "" }, -})); - -vi.mock("next/navigation", () => ({ - usePathname: () => "/scans", - useRouter: () => ({ - replace: replaceMock, - }), - useSearchParams: () => new URLSearchParams(searchParamsValue.current), -})); - -vi.mock("@/components/providers/wizard", () => ({ - ProviderWizardModal: ({ open }: { open: boolean }) => - open ?
Provider wizard
: null, -})); - vi.mock("./no-providers-connected", () => ({ NoProvidersConnected: () =>
No Connected Providers
, })); describe("ScansProvidersEmptyState", () => { - afterEach(() => { - vi.clearAllMocks(); - searchParamsValue.current = ""; - }); - - it("shows the add provider message and opens the provider wizard", async () => { - const user = userEvent.setup(); - + it("shows the add provider message with a providers page CTA", () => { + // Given/When 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("clears the launch scan URL intent before opening the provider wizard", async () => { - // Given - searchParamsValue.current = "tab=completed&launchScan=true"; - const user = userEvent.setup(); - - render(); - - // When - await user.click( - screen.getByRole("button", { name: /open add provider modal/i }), - ); - // Then - expect(replaceMock).toHaveBeenCalledWith("/scans?tab=completed", { - scroll: false, + expect(screen.getByText("No Providers Configured")).toBeInTheDocument(); + const cta = screen.getByRole("link", { + name: /open add provider modal/i, }); - expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard"); + + expect(cta).toHaveAttribute("href", ADD_PROVIDER_HREF); + expect(cta.tagName).toBe("A"); + }); + + it("does not render the provider wizard in Scans", () => { + // Given/When + render(); + + // Then + expect(screen.getByText("No Providers Configured")).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); it("shows the no connected providers message", () => { + // Given/When render(); + // Then 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 index 7837dcea2c..09258320f9 100644 --- a/ui/components/scans/scans-providers-empty-state.tsx +++ b/ui/components/scans/scans-providers-empty-state.tsx @@ -1,12 +1,6 @@ -"use client"; +import { NoProvidersAdded } from "@/components/providers/no-providers-added"; +import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useState } from "react"; - -import { ProviderWizardModal } from "@/components/providers/wizard"; -import { LAUNCH_SCAN_SEARCH_PARAM } from "@/lib/scans-navigation"; - -import { NoProvidersAdded } from "./no-providers-added"; import { NoProvidersConnected } from "./no-providers-connected"; interface ScansProvidersEmptyStateProps { @@ -16,35 +10,13 @@ interface ScansProvidersEmptyStateProps { export function ScansProvidersEmptyState({ thereIsNoProviders, }: ScansProvidersEmptyStateProps) { - const pathname = usePathname(); - const router = useRouter(); - const searchParams = useSearchParams(); - const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false); - - const openProviderWizard = () => { - if (searchParams.has(LAUNCH_SCAN_SEARCH_PARAM)) { - const params = new URLSearchParams(searchParams.toString()); - params.delete(LAUNCH_SCAN_SEARCH_PARAM); - const query = params.toString(); - router.replace(query ? `${pathname}?${query}` : pathname, { - scroll: false, - }); - } - - setIsProviderWizardOpen(true); - }; - return ( <> {thereIsNoProviders ? ( - + ) : ( )} - ); } diff --git a/ui/dependency-log.json b/ui/dependency-log.json index e52d786972..76af7f296a 100644 --- a/ui/dependency-log.json +++ b/ui/dependency-log.json @@ -778,26 +778,26 @@ { "section": "devDependencies", "name": "@vitest/browser", - "from": "4.1.6", - "to": "4.0.18", + "from": "4.0.18", + "to": "4.1.8", "strategy": "installed", - "generatedAt": "2026-05-14T10:22:47.378Z" + "generatedAt": "2026-06-02T11:34:46.264Z" }, { "section": "devDependencies", "name": "@vitest/browser-playwright", - "from": "4.1.6", - "to": "4.0.18", + "from": "4.0.18", + "to": "4.1.8", "strategy": "installed", - "generatedAt": "2026-05-14T10:22:47.378Z" + "generatedAt": "2026-06-02T11:34:46.264Z" }, { "section": "devDependencies", "name": "@vitest/coverage-v8", - "from": "4.1.6", - "to": "4.0.18", + "from": "4.0.18", + "to": "4.1.8", "strategy": "installed", - "generatedAt": "2026-05-14T10:22:47.378Z" + "generatedAt": "2026-06-02T11:34:46.264Z" }, { "section": "devDependencies", @@ -978,10 +978,10 @@ { "section": "devDependencies", "name": "vitest", - "from": "4.1.6", - "to": "4.0.18", + "from": "4.0.18", + "to": "4.1.8", "strategy": "installed", - "generatedAt": "2026-05-14T10:22:47.378Z" + "generatedAt": "2026-06-02T11:34:46.264Z" }, { "section": "devDependencies", diff --git a/ui/lib/providers-navigation.ts b/ui/lib/providers-navigation.ts new file mode 100644 index 0000000000..7428eed540 --- /dev/null +++ b/ui/lib/providers-navigation.ts @@ -0,0 +1,3 @@ +export const ADD_PROVIDER_SEARCH_PARAM = "addProvider"; +export const ADD_PROVIDER_SEARCH_VALUE = "true"; +export const ADD_PROVIDER_HREF = `/providers?${ADD_PROVIDER_SEARCH_PARAM}=${ADD_PROVIDER_SEARCH_VALUE}`; diff --git a/ui/package.json b/ui/package.json index 8466539961..a5c34094b0 100644 --- a/ui/package.json +++ b/ui/package.json @@ -133,9 +133,9 @@ "@typescript-eslint/eslint-plugin": "8.53.0", "@typescript-eslint/parser": "8.53.0", "@vitejs/plugin-react": "5.1.2", - "@vitest/browser": "4.0.18", - "@vitest/browser-playwright": "4.0.18", - "@vitest/coverage-v8": "4.0.18", + "@vitest/browser": "4.1.8", + "@vitest/browser-playwright": "4.1.8", + "@vitest/coverage-v8": "4.1.8", "babel-plugin-react-compiler": "1.0.0", "dotenv": "16.6.1", "dotenv-expand": "12.0.3", @@ -158,7 +158,7 @@ "prettier-plugin-tailwindcss": "0.6.14", "tailwindcss": "4.1.18", "typescript": "5.5.4", - "vitest": "4.0.18", + "vitest": "4.1.8", "vitest-browser-react": "2.0.4" }, "packageManager": "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 3996bbb8a8..6d44c180a1 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -325,14 +325,14 @@ importers: specifier: 5.1.2 version: 5.1.2(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/browser': - specifier: 4.0.18 - version: 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) + specifier: 4.1.8 + version: 4.1.8(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.1.8) '@vitest/browser-playwright': - specifier: 4.0.18 - version: 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) + specifier: 4.1.8 + version: 4.1.8(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.1.8) '@vitest/coverage-v8': - specifier: 4.0.18 - version: 4.0.18(@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))(vitest@4.0.18) + specifier: 4.1.8 + version: 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8) babel-plugin-react-compiler: specifier: 1.0.0 version: 1.0.0 @@ -400,11 +400,11 @@ importers: specifier: 5.5.4 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)(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) + specifier: 4.1.8 + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(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-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) + 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.1.8) packages: @@ -737,6 +737,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} @@ -4795,54 +4798,54 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitest/browser-playwright@4.0.18': - resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==} + '@vitest/browser-playwright@4.1.8': + resolution: {integrity: sha512-SR7FqgegaexEg73xvf3ArtygXegagMdXnL0EZMpxrWvvhQxvicD/E8p0ib0J91riPRtQUViyh67Xjw3NqvyhVg==} peerDependencies: playwright: '*' - vitest: 4.0.18 + vitest: 4.1.8 - '@vitest/browser@4.0.18': - resolution: {integrity: sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==} + '@vitest/browser@4.1.8': + resolution: {integrity: sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==} peerDependencies: - vitest: 4.0.18 + vitest: 4.1.8 - '@vitest/coverage-v8@4.0.18': - resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + '@vitest/coverage-v8@4.1.8': + resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} peerDependencies: - '@vitest/browser': 4.0.18 - vitest: 4.0.18 + '@vitest/browser': 4.1.8 + vitest: 4.1.8 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@4.0.18': - resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} - '@vitest/mocker@4.0.18': - resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.0.18': - resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} - '@vitest/runner@4.0.18': - resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} - '@vitest/snapshot@4.0.18': - resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} - '@vitest/spy@4.0.18': - resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} - '@vitest/utils@4.0.18': - resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -5048,8 +5051,8 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - ast-v8-to-istanbul@0.3.12: - resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + ast-v8-to-istanbul@1.0.3: + resolution: {integrity: sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==} async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} @@ -5650,9 +5653,6 @@ packages: resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} @@ -7246,10 +7246,6 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - pixelmatch@7.1.0: - resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} - hasBin: true - pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -7537,6 +7533,7 @@ packages: recharts@2.15.4: resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} engines: {node: '>=14'} + deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide peerDependencies: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -7829,8 +7826,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} @@ -8347,20 +8344,23 @@ packages: '@types/react-dom': optional: true - vitest@4.0.18: - resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.18 - '@vitest/browser-preview': 4.0.18 - '@vitest/browser-webdriverio': 4.0.18 - '@vitest/ui': 4.0.18 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -8374,6 +8374,10 @@ packages: optional: true '@vitest/browser-webdriverio': optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true '@vitest/ui': optional: true happy-dom: @@ -9319,6 +9323,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@blazediff/core@1.9.1': {} + '@braintree/sanitize-url@7.1.1': {} '@cfworker/json-schema@4.1.1': {} @@ -14261,29 +14267,29 @@ snapshots: transitivePeerDependencies: - supports-color - '@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)': + '@vitest/browser-playwright@4.1.8(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.1.8)': dependencies: - '@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) - '@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)) + '@vitest/browser': 4.1.8(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.1.8) + '@vitest/mocker': 4.1.8(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)(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.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(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)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@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)': + '@vitest/browser@4.1.8(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.1.8)': dependencies: - '@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)) - '@vitest/utils': 4.0.18 + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.8(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/utils': 4.1.8 magic-string: 0.30.21 - pixelmatch: 7.1.0 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)(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.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(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)) ws: 8.20.1 transitivePeerDependencies: - bufferutil @@ -14291,60 +14297,62 @@ snapshots: - utf-8-validate - vite - '@vitest/coverage-v8@4.0.18(@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))(vitest@4.0.18)': + '@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.18 - ast-v8-to-istanbul: 0.3.12 + '@vitest/utils': 4.1.8 + ast-v8-to-istanbul: 1.0.3 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 magicast: 0.5.2 obug: 2.1.1 - std-env: 3.10.0 + std-env: 4.1.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)(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.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(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)) 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) + '@vitest/browser': 4.1.8(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.1.8) - '@vitest/expect@4.0.18': + '@vitest/expect@4.1.8': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 chai: 6.2.2 tinyrainbow: 3.1.0 - '@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))': + '@vitest/mocker@4.1.8(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))': dependencies: - '@vitest/spy': 4.0.18 + '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: 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/pretty-format@4.0.18': + '@vitest/pretty-format@4.1.8': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.0.18': + '@vitest/runner@4.1.8': dependencies: - '@vitest/utils': 4.0.18 + '@vitest/utils': 4.1.8 pathe: 2.0.3 - '@vitest/snapshot@4.0.18': + '@vitest/snapshot@4.1.8': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.18': {} + '@vitest/spy@4.1.8': {} - '@vitest/utils@4.0.18': + '@vitest/utils@4.1.8': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 tinyrainbow: 3.1.0 '@webassemblyjs/ast@1.14.1': @@ -14604,7 +14612,7 @@ snapshots: ast-types-flow@0.0.8: {} - ast-v8-to-istanbul@0.3.12: + ast-v8-to-istanbul@1.0.3: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -15273,8 +15281,6 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 - es-module-lexer@1.7.0: {} - es-module-lexer@2.1.0: {} es-object-atoms@1.1.1: @@ -17280,10 +17286,6 @@ snapshots: picomatch@4.0.4: {} - pixelmatch@7.1.0: - dependencies: - pngjs: 7.0.0 - pkce-challenge@5.0.1: {} pkg-types@1.3.1: @@ -17980,7 +17982,7 @@ snapshots: statuses@2.0.2: {} - std-env@3.10.0: {} + std-env@4.1.0: {} stop-iteration-iterator@1.1.0: dependencies: @@ -18487,31 +18489,31 @@ snapshots: terser: 5.47.1 yaml: 2.9.0 - vitest-browser-react@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): + vitest-browser-react@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.1.8): 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)(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.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(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)) 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)(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.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(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)): 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)) - '@vitest/pretty-format': 4.0.18 - '@vitest/runner': 4.0.18 - '@vitest/snapshot': 4.0.18 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 - es-module-lexer: 1.7.0 + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(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/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.4 - std-env: 3.10.0 + std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.1.2 tinyglobby: 0.2.16 @@ -18521,20 +18523,11 @@ snapshots: optionalDependencies: '@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) + '@vitest/browser-playwright': 4.1.8(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.1.8) + '@vitest/coverage-v8': 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8) jsdom: 27.4.0 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml w3c-keyname@2.2.8: {} diff --git a/ui/tests/helpers.ts b/ui/tests/helpers.ts index 2bbbdeaeec..dd4692ed57 100644 --- a/ui/tests/helpers.ts +++ b/ui/tests/helpers.ts @@ -132,6 +132,24 @@ export async function addAWSProvider( await scansPage.verifyPageLoaded(); } +/** + * Waits for the providers page to settle and reports whether the data table is + * present. With zero providers the page renders a full-page empty state + * ("No Providers Configured") instead of the table, so callers must not assume + * the table is always there. + */ +async function providersTableVisibleOrEmptyState( + page: ProvidersPage, +): Promise { + const emptyState = page.page.getByRole("region", { + name: /no providers configured/i, + }); + await expect(page.providersTable.or(emptyState)).toBeVisible({ + timeout: 10000, + }); + return page.providersTable.isVisible().catch(() => false); +} + export async function deleteProviderIfExists( page: ProvidersPage, providerUID: string, @@ -140,7 +158,11 @@ export async function deleteProviderIfExists( // Navigate to providers page await page.goto(); - await expect(page.providersTable).toBeVisible({ timeout: 10000 }); + // With zero providers the page shows the empty state, not the table, so there + // is nothing to delete. + if (!(await providersTableVisibleOrEmptyState(page))) { + return; + } const allRows = page.providersTable.locator("tbody tr"); @@ -180,7 +202,7 @@ export async function deleteProviderIfExists( // Provider not found, nothing to delete // Navigate back to providers page to ensure clean state await page.goto(); - await expect(page.providersTable).toBeVisible({ timeout: 10000 }); + await providersTableVisibleOrEmptyState(page); return; } @@ -217,7 +239,8 @@ export async function deleteProviderIfExists( // Wait for modal to close (this indicates deletion was initiated) await expect(modal).not.toBeVisible({ timeout: 10000 }); - // Navigate back to providers page to ensure clean state + // Navigate back to providers page to ensure clean state. Deleting the last + // provider reveals the empty state instead of an empty table. await page.goto(); - await expect(page.providersTable).toBeVisible({ timeout: 10000 }); + await providersTableVisibleOrEmptyState(page); } diff --git a/ui/tests/providers/providers-page.ts b/ui/tests/providers/providers-page.ts index 5b73269062..54545f965d 100644 --- a/ui/tests/providers/providers-page.ts +++ b/ui/tests/providers/providers-page.ts @@ -341,7 +341,10 @@ export class ProvidersPage extends BasePage { name: /Adding A Provider|Update Provider Credentials/i, }); - // Button to add a new provider + // Button to add a new provider. When providers exist this is the filter-bar + // "Add Provider" control; with zero providers the page renders the empty + // state whose CTA is labelled "Open Add Provider modal" (button on + // /providers, link on /scans). Only one of these is ever in the DOM at once. this.addProviderButton = page .getByRole("button", { name: "Add Provider", @@ -352,7 +355,9 @@ export class ProvidersPage extends BasePage { name: "Add Provider", exact: true, }), - ); + ) + .or(page.getByRole("button", { name: "Open Add Provider modal" })) + .or(page.getByRole("link", { name: "Open Add Provider modal" })); // Table displaying existing providers this.providersTable = page.getByRole("table"); From eb7949c884f66cb46892eedeb7bd6012bd4d4123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Wed, 3 Jun 2026 17:03:12 +0200 Subject: [PATCH 02/15] fix(ui): show delete user action only for the current user (#11447) Co-authored-by: Pepe Fagoaga --- .../user-guide/tutorials/prowler-app-rbac.mdx | 6 +- ui/CHANGELOG.md | 1 + ui/app/(prowler)/users/page.tsx | 3 + .../table/data-table-row-actions.test.tsx | 159 ++++++++++++++++++ .../users/table/data-table-row-actions.tsx | 59 ++++--- 5 files changed, 203 insertions(+), 25 deletions(-) create mode 100644 ui/components/users/table/data-table-row-actions.test.tsx diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx index c6319591cf..cabea5bd35 100644 --- a/docs/user-guide/tutorials/prowler-app-rbac.mdx +++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx @@ -47,7 +47,11 @@ Follow these steps to remove a user of your account: 1. Navigate to **Users** from the side menu. 2. Click the delete button of your current user. -> **Note: Each user will be able to delete himself and not others, regardless of his permissions.** +> **Note: Each user can only delete their own account, regardless of their permissions. For this reason, the delete button is only shown on your own row and not on other users' rows.** + +Deleting a user removes the **entire user account** from Prowler, not just its membership in your organization. Because a single account can belong to more than one tenant, allowing one administrator to delete it outright could affect organizations they don't manage and irreversibly remove another person's identity. To keep this destructive action under the control of the account owner, the API only permits a user to delete themselves (it rejects any other target with a `400` response), and the UI mirrors this by showing the delete button exclusively on your own row. + +To remove **another** user from your organization, use the [_Expel from organization_](/user-guide/tutorials/prowler-app-multi-tenant#expelling-a-user-from-an-organization) action instead. Expelling removes the user's membership, role grants, and active sessions for your tenant only, and deletes the underlying account just for that user if your organization was their last remaining membership. This action is reserved for tenant **owners**. Remove User diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index db38de5fd3..8b2ba973aa 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🐞 Fixed - Add Provider modal now closes without reloading the providers page [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424) +- Users page now shows the "Delete User" action only on the current user's row, matching the backend rule that a user can only delete their own account [(#11447)](https://github.com/prowler-cloud/prowler/pull/11447) ### 🔐 Security diff --git a/ui/app/(prowler)/users/page.tsx b/ui/app/(prowler)/users/page.tsx index 4b26c0baf2..fa1e838872 100644 --- a/ui/app/(prowler)/users/page.tsx +++ b/ui/app/(prowler)/users/page.tsx @@ -109,6 +109,9 @@ const SSRDataTable = async ({ roles, canBeExpelled, currentTenantId: canBeExpelled ? currentTenantId : undefined, + // Users may only delete their own account; gate the delete action so the + // UI matches the backend rule and never offers an action that would fail. + isCurrentUser: user.id === currentUserId, }; }); diff --git a/ui/components/users/table/data-table-row-actions.test.tsx b/ui/components/users/table/data-table-row-actions.test.tsx new file mode 100644 index 0000000000..39a6c95f8b --- /dev/null +++ b/ui/components/users/table/data-table-row-actions.test.tsx @@ -0,0 +1,159 @@ +import { Row } from "@tanstack/react-table"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +// The forms pull in server actions (`@/actions/users/users`) that can't run in +// jsdom, so stub them with identifiable markers to assert which modal opens. +vi.mock("../forms", () => ({ + DeleteForm: ({ userId }: { userId: string }) => ( +
delete-form:{userId}
+ ), + EditForm: ({ userId }: { userId: string }) => ( +
edit-form:{userId}
+ ), + ExpelUserForm: ({ userId }: { userId: string }) => ( +
expel-form:{userId}
+ ), +})); + +import { DataTableRowActions } from "./data-table-row-actions"; + +interface RowOptions { + id?: string; + isCurrentUser?: boolean; + canBeExpelled?: boolean; + currentTenantId?: string; +} + +const createRow = ({ + id = "user-1", + isCurrentUser, + canBeExpelled, + currentTenantId, +}: RowOptions = {}) => + ({ + original: { + id, + attributes: { + name: "Jane Doe", + email: "jane@example.com", + company_name: "Acme", + role: { name: "admin" }, + }, + isCurrentUser, + canBeExpelled, + currentTenantId, + }, + }) as unknown as Row<{ id: string }>; + +const openMenu = async (user: ReturnType) => { + await user.click(screen.getByRole("button", { name: "Open actions menu" })); +}; + +describe("DataTableRowActions (users)", () => { + it("always renders the Edit User action", async () => { + const user = userEvent.setup(); + render(); + + await openMenu(user); + + expect(screen.getByText("Edit User")).toBeInTheDocument(); + }); + + it("shows Delete User only for the current user's row", async () => { + const user = userEvent.setup(); + render(); + + await openMenu(user); + + expect(screen.getByText("Delete User")).toBeInTheDocument(); + expect(screen.getByText("Danger zone")).toBeInTheDocument(); + }); + + it("does NOT show Delete User for another user's row", async () => { + const user = userEvent.setup(); + render(); + + await openMenu(user); + + expect(screen.queryByText("Delete User")).not.toBeInTheDocument(); + }); + + it("does NOT show Delete User when isCurrentUser is undefined", async () => { + const user = userEvent.setup(); + render(); + + await openMenu(user); + + expect(screen.queryByText("Delete User")).not.toBeInTheDocument(); + }); + + it("hides the Danger zone entirely when the user can neither be deleted nor expelled", async () => { + const user = userEvent.setup(); + render( + , + ); + + await openMenu(user); + + // Only the non-destructive Edit action remains. + expect(screen.getByText("Edit User")).toBeInTheDocument(); + expect(screen.queryByText("Danger zone")).not.toBeInTheDocument(); + expect(screen.queryByText("Delete User")).not.toBeInTheDocument(); + expect( + screen.queryByText("Expel from organization"), + ).not.toBeInTheDocument(); + }); + + it("shows Expel but not Delete User for an expellable, non-current user", async () => { + const user = userEvent.setup(); + render( + , + ); + + await openMenu(user); + + expect(screen.getByText("Danger zone")).toBeInTheDocument(); + expect(screen.getByText("Expel from organization")).toBeInTheDocument(); + expect(screen.queryByText("Delete User")).not.toBeInTheDocument(); + }); + + it("renders Delete User with destructive styling", async () => { + const user = userEvent.setup(); + render(); + + await openMenu(user); + + const menuItem = screen + .getByText("Delete User") + .closest("[role='menuitem']"); + expect(menuItem).toBeInTheDocument(); + expect(menuItem).toHaveClass("text-text-error-primary"); + }); + + it("opens the delete confirmation modal when Delete User is selected", async () => { + const user = userEvent.setup(); + render( + , + ); + + await openMenu(user); + await user.click(screen.getByText("Delete User")); + + expect(screen.getByText("Are you absolutely sure?")).toBeInTheDocument(); + expect(screen.getByTestId("delete-form")).toHaveTextContent( + "delete-form:user-42", + ); + }); +}); diff --git a/ui/components/users/table/data-table-row-actions.tsx b/ui/components/users/table/data-table-row-actions.tsx index 3bc8e6a0c8..29c59566da 100644 --- a/ui/components/users/table/data-table-row-actions.tsx +++ b/ui/components/users/table/data-table-row-actions.tsx @@ -29,6 +29,7 @@ interface UserRowData { attributes?: UserRowAttributes; canBeExpelled?: boolean; currentTenantId?: string; + isCurrentUser?: boolean; } interface DataTableRowActionsProps { @@ -57,6 +58,10 @@ export function DataTableRowActions({ row.original.canBeExpelled === true && !!row.original.currentTenantId; const currentTenantId = row.original.currentTenantId; + // A user can only delete their own account (enforced by the backend), so the + // delete action is shown exclusively for the current user's row. + const canDeleteUser = row.original.isCurrentUser === true; + return ( <> ({ setIsOpen={setIsEditOpen} /> - - - + {canDeleteUser && ( + + + + )} {canExpelUser && currentTenantId && ( ({ label="Edit User" onSelect={() => setIsEditOpen(true)} /> - - {canExpelUser && ( - + {(canExpelUser || canDeleteUser) && ( + + {canExpelUser && ( + + )}
From bcd282d3d0d17655ea0d799605268d362e10f1dc Mon Sep 17 00:00:00 2001 From: Oleksandr_Sanin Date: Thu, 4 Jun 2026 12:07:01 +0200 Subject: [PATCH 03/15] fix(gcp): honour org-level aggregated sinks in logging_sink_created check (#11355) Signed-off-by: Oleksandr Sanin Co-authored-by: Hugo P.Brito --- prowler/CHANGELOG.md | 8 + .../gcp/services/logging/logging_service.py | 34 ++++ .../logging_sink_created.py | 61 ++++-- .../services/logging/logging_service_test.py | 73 ++++++- .../logging_sink_created_test.py | 178 +++++++++++++++++- 5 files changed, 336 insertions(+), 18 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index d16f24ca99..db7c87f4c7 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -11,6 +11,14 @@ All notable changes to the **Prowler SDK** are documented in this file. --- +## [5.29.3] (Prowler UNRELEASED) + +### 🐞 Fixed + +- GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355) + +--- + ## [5.29.1] (Prowler v5.29.1) ### 🐞 Fixed diff --git a/prowler/providers/gcp/services/logging/logging_service.py b/prowler/providers/gcp/services/logging/logging_service.py index 637c8782b2..2459895c4c 100644 --- a/prowler/providers/gcp/services/logging/logging_service.py +++ b/prowler/providers/gcp/services/logging/logging_service.py @@ -12,6 +12,7 @@ class Logging(GCPService): self.sinks = [] self.metrics = [] self._get_sinks() + self._get_org_sinks() self._get_metrics() def _get_sinks(self): @@ -39,6 +40,38 @@ class Logging(GCPService): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_org_sinks(self): + """Fetch org-level sinks with includeChildren so child projects are not falsely failed.""" + org_ids = set() + for project in self.projects.values(): + if project.organization: + org_ids.add(project.organization.id) + + for org_id in org_ids: + try: + request = self.client.sinks().list(parent=f"organizations/{org_id}") + while request is not None: + response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS) + + for sink in response.get("sinks", []): + self.sinks.append( + Sink( + name=sink["name"], + destination=sink["destination"], + filter=sink.get("filter", "all"), + project_id=f"organizations/{org_id}", + include_children=sink.get("includeChildren", False), + ) + ) + + request = self.client.sinks().list_next( + previous_request=request, previous_response=response + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _get_metrics(self): for project_id in self.project_ids: try: @@ -76,6 +109,7 @@ class Sink(BaseModel): destination: str filter: str project_id: str + include_children: bool = False class Metric(BaseModel): diff --git a/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py b/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py index 30104a050d..a7846e3dd8 100644 --- a/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py +++ b/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py @@ -5,26 +5,30 @@ from prowler.providers.gcp.services.logging.logging_client import logging_client class logging_sink_created(Check): def execute(self) -> Check_Report_GCP: findings = [] + + # Map project_id -> sink for direct project-level sinks projects_with_logging_sink = {} for sink in logging_client.sinks: - if sink.filter == "all": + if sink.filter == "all" and not sink.include_children: projects_with_logging_sink[sink.project_id] = sink + # Collect org resource names that have a covering sink (includeChildren=True) + covering_org_sinks = {} + for sink in logging_client.sinks: + if sink.filter == "all" and sink.include_children: + covering_org_sinks[sink.project_id] = sink + for project in logging_client.project_ids: - if project not in projects_with_logging_sink.keys(): - project_obj = logging_client.projects.get(project) - report = Check_Report_GCP( - metadata=self.metadata(), - resource=project_obj, - resource_id=project, - project_id=project, - location=logging_client.region, - resource_name=(getattr(project_obj, "name", None) or "GCP Project"), - ) - report.status = "FAIL" - report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}." - findings.append(report) - else: + project_obj = logging_client.projects.get(project) + + # Determine whether this project is covered by an org-level sink + org = getattr(project_obj, "organization", None) if project_obj else None + org_resource = f"organizations/{org.id}" if org else None + covering_sink = ( + covering_org_sinks.get(org_resource) if org_resource else None + ) + + if project in projects_with_logging_sink: sink = projects_with_logging_sink[project] sink_name = getattr(sink, "name", None) or "unknown" report = Check_Report_GCP( @@ -40,4 +44,31 @@ class logging_sink_created(Check): report.status = "PASS" report.status_extended = f"Sink {sink_name} is enabled exporting copies of all the log entries in project {project}." findings.append(report) + elif covering_sink: + sink_name = getattr(covering_sink, "name", None) or "unknown" + report = Check_Report_GCP( + metadata=self.metadata(), + resource=covering_sink, + resource_id=sink_name, + project_id=project, + location=logging_client.region, + resource_name=( + sink_name if sink_name != "unknown" else "Logging Sink" + ), + ) + report.status = "PASS" + report.status_extended = f"Sink {sink_name} at organization level is exporting copies of all the log entries in project {project}." + findings.append(report) + else: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=project_obj, + resource_id=project, + project_id=project, + location=logging_client.region, + resource_name=(getattr(project_obj, "name", None) or "GCP Project"), + ) + report.status = "FAIL" + report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}." + findings.append(report) return findings diff --git a/tests/providers/gcp/services/logging/logging_service_test.py b/tests/providers/gcp/services/logging/logging_service_test.py index 0396130c2f..49368d0289 100644 --- a/tests/providers/gcp/services/logging/logging_service_test.py +++ b/tests/providers/gcp/services/logging/logging_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.gcp.services.logging.logging_service import Logging from tests.providers.gcp.gcp_fixtures import ( @@ -66,3 +66,74 @@ class TestLoggingService: == "resource.type=gae_app AND severity>=ERROR" ) assert logging_client.metrics[1].project_id == GCP_PROJECT_ID + + def test_org_sinks_fetched_when_project_has_organization(self): + """_get_org_sinks() appends org-level sinks when projects have an org.""" + from prowler.providers.gcp.models import GCPOrganization, GCPProject + + org_id = "999888777" + provider = set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + provider.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization(id=org_id, name=f"organizations/{org_id}"), + ) + } + + mock_client = MagicMock() + mock_client.sinks().list().execute.return_value = { + "sinks": [ + { + "name": "org-sink", + "destination": "storage.googleapis.com/org-bucket", + "filter": "all", + "includeChildren": True, + } + ] + } + mock_client.sinks().list_next.return_value = None + mock_client.projects().metrics().list().execute.return_value = {"metrics": []} + mock_client.projects().metrics().list_next.return_value = None + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + return_value=mock_client, + ), + ): + logging_svc = Logging(provider) + + org_sinks = [ + s for s in logging_svc.sinks if s.project_id == f"organizations/{org_id}" + ] + assert len(org_sinks) == 1 + assert org_sinks[0].name == "org-sink" + assert org_sinks[0].include_children is True + assert org_sinks[0].filter == "all" + + def test_org_sinks_skipped_when_no_organization(self): + """_get_org_sinks() adds nothing when projects have no organization.""" + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + logging_svc = Logging(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])) + + org_sinks = [ + s for s in logging_svc.sinks if s.project_id.startswith("organizations/") + ] + assert org_sinks == [] diff --git a/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py b/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py index b9c6481d22..6ced615f65 100644 --- a/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py +++ b/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py @@ -1,6 +1,6 @@ from unittest.mock import MagicMock, patch -from prowler.providers.gcp.models import GCPProject +from prowler.providers.gcp.models import GCPOrganization, GCPProject from tests.providers.gcp.gcp_fixtures import ( GCP_EU1_LOCATION, GCP_PROJECT_ID, @@ -268,6 +268,7 @@ class Test_logging_sink_created: sink.name = None sink.filter = "all" sink.project_id = GCP_PROJECT_ID + sink.include_children = False logging_client.project_ids = [GCP_PROJECT_ID] logging_client.region = GCP_EU1_LOCATION @@ -311,9 +312,10 @@ class Test_logging_sink_created: ) # Create a MagicMock sink object without name attribute - sink = MagicMock(spec=["filter", "project_id"]) + sink = MagicMock(spec=["filter", "project_id", "include_children"]) sink.filter = "all" sink.project_id = GCP_PROJECT_ID + sink.include_children = False logging_client.project_ids = [GCP_PROJECT_ID] logging_client.region = GCP_EU1_LOCATION @@ -336,3 +338,175 @@ class Test_logging_sink_created: assert result[0].resource_id == "unknown" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_org_level_sink_with_include_children_passes(self): + """Projects covered by an org-level sink with includeChildren=True should PASS.""" + logging_client = MagicMock() + org_id = "111222333" + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client", + new=logging_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_service import Sink + from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import ( + logging_sink_created, + ) + + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.sinks = [ + Sink( + name="org-sink", + destination="storage.googleapis.com/org-bucket", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + + check = logging_sink_created() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Sink org-sink at organization level is exporting copies of all the log entries in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == "org-sink" + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == GCP_EU1_LOCATION + + def test_org_level_sink_without_include_children_fails(self): + """Projects NOT covered by includeChildren should still FAIL if no direct project sink.""" + logging_client = MagicMock() + org_id = "111222333" + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client", + new=logging_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_service import Sink + from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import ( + logging_sink_created, + ) + + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.sinks = [ + Sink( + name="org-sink-no-children", + destination="storage.googleapis.com/org-bucket", + filter="all", + project_id=f"organizations/{org_id}", + include_children=False, + ) + ] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + + check = logging_sink_created() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"There are no logging sinks to export copies of all the log entries in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == GCP_PROJECT_ID + assert result[0].project_id == GCP_PROJECT_ID + + def test_project_sink_takes_precedence_over_org_sink(self): + """A direct project sink should be reported even when an org-level sink also covers the project.""" + logging_client = MagicMock() + org_id = "111222333" + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client", + new=logging_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_service import Sink + from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import ( + logging_sink_created, + ) + + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.sinks = [ + Sink( + name="project-sink", + destination="storage.googleapis.com/project-bucket", + filter="all", + project_id=GCP_PROJECT_ID, + ), + Sink( + name="org-sink", + destination="storage.googleapis.com/org-bucket", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ), + ] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + + check = logging_sink_created() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Sink project-sink is enabled exporting copies of all the log entries in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == "project-sink" + assert result[0].project_id == GCP_PROJECT_ID From 3a3d9d61462983f5856e4b4bf418d898141b69ef Mon Sep 17 00:00:00 2001 From: "Pablo Fernandez Guerra (PFE)" <148432447+pfe-nazaries@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:31:16 +0200 Subject: [PATCH 04/15] chore(ui): type process.env via ambient NodeJS.ProcessEnv (#11328) Co-authored-by: Pablo F.G --- ui/__tests__/msw/handlers/attack-paths.ts | 2 +- ui/types/env.d.ts | 125 ++++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 ui/types/env.d.ts diff --git a/ui/__tests__/msw/handlers/attack-paths.ts b/ui/__tests__/msw/handlers/attack-paths.ts index b17c6c0596..39076666dd 100644 --- a/ui/__tests__/msw/handlers/attack-paths.ts +++ b/ui/__tests__/msw/handlers/attack-paths.ts @@ -10,7 +10,7 @@ import type { QueryResultAttributes, } from "@/types/attack-paths"; -const API = process.env.NEXT_PUBLIC_API_BASE_URL!; +const API = process.env.NEXT_PUBLIC_API_BASE_URL; type JsonApiErrorBody = { errors: Array<{ detail: string; status: string }>; diff --git a/ui/types/env.d.ts b/ui/types/env.d.ts new file mode 100644 index 0000000000..c0b5b10a66 --- /dev/null +++ b/ui/types/env.d.ts @@ -0,0 +1,125 @@ +declare global { + namespace NodeJS { + interface ProcessEnv { + // Runtime (Node / Next.js) + NODE_ENV: "development" | "production" | "test"; + NEXT_RUNTIME?: "nodejs" | "edge"; + + // Public client config + NEXT_PUBLIC_API_BASE_URL: string; + NEXT_PUBLIC_API_DOCS_URL?: string; + NEXT_PUBLIC_IS_CLOUD_ENV?: "true" | "false"; + NEXT_PUBLIC_PROWLER_RELEASE_VERSION?: string; + NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID?: string; + NEXT_PUBLIC_SENTRY_DSN?: string; + NEXT_PUBLIC_SENTRY_ENVIRONMENT?: string; + + // Auth (NextAuth) + AUTH_URL: string; + AUTH_SECRET: string; + AUTH_TRUST_HOST?: "true" | "false"; + NEXTAUTH_URL?: string; + + // Sentry (server / build) + SENTRY_DSN?: string; + SENTRY_ENVIRONMENT?: string; + SENTRY_RELEASE?: string; + SENTRY_ORG?: string; + SENTRY_PROJECT?: string; + SENTRY_AUTH_TOKEN?: string; + + // Social OAuth + SOCIAL_GOOGLE_OAUTH_CLIENT_ID?: string; + SOCIAL_GOOGLE_OAUTH_CLIENT_SECRET?: string; + SOCIAL_GOOGLE_OAUTH_CALLBACK_URL?: string; + SOCIAL_GITHUB_OAUTH_CLIENT_ID?: string; + SOCIAL_GITHUB_OAUTH_CLIENT_SECRET?: string; + SOCIAL_GITHUB_OAUTH_CALLBACK_URL?: string; + + // Feature integrations + PROWLER_MCP_SERVER_URL?: string; + // JSON-encoded array, parsed in actions/feeds + RSS_FEED_SOURCES?: string; + + // Environment detection + CI?: string; + DOCKER?: string; + KUBERNETES_SERVICE_HOST?: string; + + // E2E test credentials (Playwright only) + E2E_ADMIN_USER?: string; + E2E_ADMIN_PASSWORD?: string; + E2E_NEW_USER_PASSWORD?: string; + E2E_MANAGE_CLOUD_PROVIDERS_USER?: string; + E2E_MANAGE_CLOUD_PROVIDERS_PASSWORD?: string; + E2E_INVITE_AND_MANAGE_USERS_USER?: string; + E2E_INVITE_AND_MANAGE_USERS_PASSWORD?: string; + E2E_UNLIMITED_VISIBILITY_USER?: string; + E2E_UNLIMITED_VISIBILITY_PASSWORD?: string; + E2E_MANAGE_INTEGRATIONS_USER?: string; + E2E_MANAGE_INTEGRATIONS_PASSWORD?: string; + E2E_MANAGE_ACCOUNT_USER?: string; + E2E_MANAGE_ACCOUNT_PASSWORD?: string; + E2E_MANAGE_SCANS_USER?: string; + E2E_MANAGE_SCANS_PASSWORD?: string; + E2E_ORGANIZATION_ID?: string; + + // E2E AWS + E2E_AWS_PROVIDER_ACCOUNT_ID?: string; + E2E_AWS_PROVIDER_ACCESS_KEY?: string; + E2E_AWS_PROVIDER_SECRET_KEY?: string; + E2E_AWS_PROVIDER_ROLE_ARN?: string; + E2E_AWS_ORGANIZATION_ID?: string; + E2E_AWS_ORGANIZATION_ROLE_ARN?: string; + + // E2E Azure + E2E_AZURE_SUBSCRIPTION_ID?: string; + E2E_AZURE_CLIENT_ID?: string; + E2E_AZURE_SECRET_ID?: string; + E2E_AZURE_TENANT_ID?: string; + + // E2E Microsoft 365 + E2E_M365_DOMAIN_ID?: string; + E2E_M365_CLIENT_ID?: string; + E2E_M365_TENANT_ID?: string; + E2E_M365_SECRET_ID?: string; + E2E_M365_CERTIFICATE_CONTENT?: string; + + // E2E GCP + E2E_GCP_PROJECT_ID?: string; + E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY?: string; + + // E2E Kubernetes + E2E_KUBERNETES_CONTEXT?: string; + E2E_KUBERNETES_KUBECONFIG_PATH?: string; + + // E2E GitHub + E2E_GITHUB_USERNAME?: string; + E2E_GITHUB_PERSONAL_ACCESS_TOKEN?: string; + E2E_GITHUB_APP_ID?: string; + E2E_GITHUB_BASE64_APP_PRIVATE_KEY?: string; + E2E_GITHUB_ORGANIZATION?: string; + E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN?: string; + + // E2E Oracle Cloud + E2E_OCI_TENANCY_ID?: string; + E2E_OCI_USER_ID?: string; + E2E_OCI_FINGERPRINT?: string; + E2E_OCI_KEY_CONTENT?: string; + E2E_OCI_REGION?: string; + + // E2E Alibaba Cloud + E2E_ALIBABACLOUD_ACCOUNT_ID?: string; + E2E_ALIBABACLOUD_ACCESS_KEY_ID?: string; + E2E_ALIBABACLOUD_ACCESS_KEY_SECRET?: string; + E2E_ALIBABACLOUD_ROLE_ARN?: string; + + // E2E Google Workspace + E2E_GOOGLEWORKSPACE_CUSTOMER_ID?: string; + E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON?: string; + E2E_GOOGLEWORKSPACE_DELEGATED_USER?: string; + } + } +} + +export {}; From a5bc226f1141436e71a5c7e150df448fe772969d Mon Sep 17 00:00:00 2001 From: Aline Almeida Date: Fri, 5 Jun 2026 12:07:30 +0200 Subject: [PATCH 05/15] fix(gcp): pass iam_service_account_unused for disabled service accounts (#11467) --- prowler/CHANGELOG.md | 1 + .../providers/gcp/services/iam/iam_service.py | 2 + .../iam_service_account_unused.py | 7 ++- .../iam_service_account_unused_test.py | 57 +++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index db7c87f4c7..ec2e68c30e 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. ### 🐞 Fixed - GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355) +- GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467) --- diff --git a/prowler/providers/gcp/services/iam/iam_service.py b/prowler/providers/gcp/services/iam/iam_service.py index 13e96276e7..987a86068c 100644 --- a/prowler/providers/gcp/services/iam/iam_service.py +++ b/prowler/providers/gcp/services/iam/iam_service.py @@ -37,6 +37,7 @@ class IAM(GCPService): display_name=account.get("displayName", ""), project_id=project_id, uniqueId=account.get("uniqueId", ""), + disabled=account.get("disabled", False), ) ) @@ -102,6 +103,7 @@ class ServiceAccount(BaseModel): keys: list[Key] = [] project_id: str uniqueId: str + disabled: bool = False class AccessApproval(GCPService): diff --git a/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py b/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py index 12440aff25..912237b9e5 100644 --- a/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py +++ b/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py @@ -19,7 +19,12 @@ class iam_service_account_unused(Check): resource_id=account.email, location=iam_client.region, ) - if account.uniqueId in sa_ids_used: + if account.disabled: + report.status = "PASS" + report.status_extended = ( + f"Service Account {account.email} is disabled and cannot be used." + ) + elif account.uniqueId in sa_ids_used: report.status = "PASS" report.status_extended = f"Service Account {account.email} was used over the last {max_unused_days} days." else: diff --git a/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py b/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py index d76200734d..79c0eb23c3 100644 --- a/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py +++ b/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py @@ -179,3 +179,60 @@ class Test_iam_service_account_unused: assert result[1].project_id == GCP_PROJECT_ID assert result[1].location == GCP_US_CENTER1_LOCATION assert result[1].resource == iam_client.service_accounts[1] + + def test_iam_service_account_disabled(self): + iam_client = mock.MagicMock() + monitoring_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused.iam_client", + new=iam_client, + ), + mock.patch( + "prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.services.iam.iam_service import ServiceAccount + from prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused import ( + iam_service_account_unused, + ) + + iam_client.project_ids = [GCP_PROJECT_ID] + iam_client.region = GCP_US_CENTER1_LOCATION + + iam_client.service_accounts = [ + ServiceAccount( + name="projects/my-project/serviceAccounts/disabled-sa@my-project.iam.gserviceaccount.com", + email="disabled-sa@my-project.iam.gserviceaccount.com", + display_name="Disabled service account", + keys=[], + project_id=GCP_PROJECT_ID, + uniqueId="999888877776666", + disabled=True, + ) + ] + + # The account is absent from the usage metrics, so a non-disabled + # account here would FAIL. Being disabled must take precedence and + # PASS, since a disabled account cannot authenticate or be used. + monitoring_client.sa_api_metrics = set() + monitoring_client.audit_config = {"max_unused_account_days": 30} + + check = iam_service_account_unused() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Service Account {iam_client.service_accounts[0].email} is disabled and cannot be used." + ) + assert result[0].resource_id == iam_client.service_accounts[0].email + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].resource == iam_client.service_accounts[0] From d4bbc8b5adbfa1139b152aad731597bf540feace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Fri, 5 Jun 2026 13:26:28 +0200 Subject: [PATCH 06/15] fix(jira): avoid 400 INVALID_INPUT on findings with empty field (#11474) --- prowler/CHANGELOG.md | 1 + prowler/lib/outputs/jira/jira.py | 16 +++++- tests/lib/outputs/jira/jira_test.py | 83 +++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index ec2e68c30e..f98b551bce 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. ### 🐞 Fixed - GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355) +- Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474) - GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467) --- diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index ed8f7faab0..f7f31666e1 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -229,7 +229,9 @@ class MarkdownToADFConverter: return node def _paragraph_with_text(self, text: str) -> Dict: - return {"type": "paragraph", "content": [self._create_text_node(text, None)]} + # ADF forbids empty text nodes; emit an empty paragraph instead. + content = [self._create_text_node(text, None)] if text else [] + return {"type": "paragraph", "content": content} @staticmethod def _pop_mark(marks_stack: List[Dict], mark_type: str) -> None: @@ -1118,6 +1120,18 @@ class Jira: tenant_info: str = "", ) -> dict: + # ADF forbids empty text nodes, so Jira rejects them with 400 INVALID_INPUT. + def _safe(value: str) -> str: + return value if (value and value.strip()) else "-" + + check_id = _safe(check_id) + check_title = _safe(check_title) + status_extended = _safe(status_extended) + provider = _safe(provider) + region = _safe(region) + resource_uid = _safe(resource_uid) + resource_name = _safe(resource_name) + table_rows = [ { "type": "tableRow", diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index 03656891c8..788d6ba7c9 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -1004,6 +1004,89 @@ class TestJiraIntegration: for mark in node.get("marks", []) ) + @staticmethod + def _find_empty_text_nodes(node) -> List[str]: + # ADF forbids empty text nodes; collect any to assert the document is valid. + empties: List[str] = [] + + def walk(current) -> None: + if isinstance(current, dict): + if current.get("type") == "text" and current.get("text", "") == "": + empties.append(current.get("text", "")) + for value in current.values(): + walk(value) + elif isinstance(current, list): + for item in current: + walk(item) + + walk(node) + return empties + + def test_get_adf_description_empty_resource_name_has_no_empty_text_nodes(self): + # A resource without a name (e.g. an AWS-managed IAM policy) used to emit an + # empty ADF text node, making Jira reject the issue with 400 INVALID_INPUT. + adf_description = self.jira_integration.get_adf_description( + check_id="CHECK-1", + check_title="Sample check", + severity="CRITICAL", + severity_color="#FF0000", + status="FAIL", + status_color="#FF0000", + status_extended="Some status", + provider="aws", + region="eu-west-1", + resource_uid="arn:aws:iam::aws:policy/AdministratorAccess", + resource_name="", + recommendation_text="", + ) + + assert self._find_empty_text_nodes(adf_description) == [] + + table = adf_description["content"][1] + resource_name_row = self._find_table_row(table["content"], "Resource Name") + value_cell = resource_name_row["content"][1] + assert self._collect_text_from_cell(value_cell) == "-" + + @pytest.mark.parametrize( + "field, header", + [ + ("check_id", "Check Id"), + ("check_title", "Check Title"), + ("status_extended", "Status Extended"), + ("provider", "Provider"), + ("region", "Region"), + ("resource_uid", "Resource UID"), + ("resource_name", "Resource Name"), + ], + ) + def test_get_adf_description_empty_plain_text_fields_render_placeholder( + self, field, header + ): + base_kwargs = dict( + check_id="CHECK-1", + check_title="Sample check", + severity="HIGH", + severity_color="#FF0000", + status="FAIL", + status_color="#00FF00", + status_extended="Some status", + provider="aws", + region="us-east-1", + resource_uid="resource-1", + resource_name="resource-name", + recommendation_text="", + ) + base_kwargs[field] = "" + + adf_description = self.jira_integration.get_adf_description(**base_kwargs) + + assert self._find_empty_text_nodes(adf_description) == [] + + table = adf_description["content"][1] + row = self._find_table_row(table["content"], header) + value_cell = row["content"][1] + assert self._collect_text_from_cell(value_cell) == "-" + @patch.object(Jira, "get_access_token", return_value="valid_access_token") @patch.object( Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"] From a7d180ea5bdaba6e1899265fdfd4ff4b76b490b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Fri, 5 Jun 2026 13:28:31 +0200 Subject: [PATCH 07/15] feat(dashboard): add AWS AI Security Framework compliance view (#11475) --- .../aws_ai_security_framework_aws.py | 27 +++++++++++++++++++ prowler/CHANGELOG.md | 1 + 2 files changed, 28 insertions(+) create mode 100644 dashboard/compliance/aws_ai_security_framework_aws.py diff --git a/dashboard/compliance/aws_ai_security_framework_aws.py b/dashboard/compliance/aws_ai_security_framework_aws.py new file mode 100644 index 0000000000..ece9bdf9cb --- /dev/null +++ b/dashboard/compliance/aws_ai_security_framework_aws.py @@ -0,0 +1,27 @@ +import warnings + +from dashboard.common_methods import get_section_containers_3_levels + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "NAME", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ] + + return get_section_containers_3_levels( + aux, + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "NAME", + ) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index f98b551bce..5d180cb4e3 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355) - Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474) - GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467) +- AWS AI Security Framework now renders in the dashboard instead of showing "No data found for this compliance", by adding the missing compliance view module [(#11470)](https://github.com/prowler-cloud/prowler/pull/11470) --- From efa3283a25dfdcdcd8382e2aaea8ea638133659a Mon Sep 17 00:00:00 2001 From: StylusFrost Date: Fri, 5 Jun 2026 13:42:29 +0200 Subject: [PATCH 08/15] fix(provider): return generic OutputOptions default instead of raising External providers that do not override get_output_options no longer abort the run with NotImplementedError. The base contract returns a generic ProviderOutputOptions, honoring arguments.output_filename and otherwise falling back to a provider-typed filename. Built-ins are unaffected. --- prowler/providers/common/provider.py | 15 ++++-- .../external/test_dynamic_provider_loading.py | 49 +++++++++++++++++-- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 5e1ca42f14..c8561cd4bd 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -153,11 +153,16 @@ class Provider(ABC): """ raise NotImplementedError(f"{cls.__name__} has not implemented from_cli_args()") - def get_output_options(self, arguments, _bulk_checks_metadata): - """Create the provider-specific OutputOptions.""" - raise NotImplementedError( - f"{self.__class__.__name__} has not implemented get_output_options()" - ) + def get_output_options(self, arguments, bulk_checks_metadata): + """Return a generic OutputOptions default; override for provider-specific output.""" + from prowler.config.config import output_file_timestamp + from prowler.providers.common.models import ProviderOutputOptions + + output_options = ProviderOutputOptions(arguments, bulk_checks_metadata) + output_options.output_filename = getattr( + arguments, "output_filename", None + ) or (f"prowler-output-{self.type}-{output_file_timestamp}") + return output_options def get_stdout_detail(self, _finding) -> str: """Return the detail string for stdout reporting (region, location, etc.).""" diff --git a/tests/providers/external/test_dynamic_provider_loading.py b/tests/providers/external/test_dynamic_provider_loading.py index 749b7aff95..65087315e1 100644 --- a/tests/providers/external/test_dynamic_provider_loading.py +++ b/tests/providers/external/test_dynamic_provider_loading.py @@ -1798,11 +1798,52 @@ class TestBaseContractDefaults: with pytest.raises(NotImplementedError): FakeProviderNoHelpText.from_cli_args(MagicMock(), {}) - def test_get_output_options_raises_not_implemented(self): - """Base Provider.get_output_options raises NotImplementedError.""" + def test_get_output_options_returns_generic_default(self): + """Base Provider.get_output_options returns a generic ProviderOutputOptions + so an external provider that does not override it still produces output + instead of aborting the run with NotImplementedError.""" + from prowler.config.config import output_file_timestamp + from prowler.providers.common.models import ProviderOutputOptions + provider = FakeProviderNoHelpText() - with pytest.raises(NotImplementedError): - provider.get_output_options(MagicMock(), {}) + arguments = Namespace( + status=None, + output_formats=None, + output_directory=None, + output_filename=None, + verbose=None, + only_logs=None, + unix_timestamp=None, + shodan=None, + fixer=None, + ) + + output_options = provider.get_output_options(arguments, {}) + + assert isinstance(output_options, ProviderOutputOptions) + assert ( + output_options.output_filename + == f"prowler-output-{provider.type}-{output_file_timestamp}" + ) + + def test_get_output_options_honors_explicit_filename(self): + """A user-supplied output_filename is preserved by the default.""" + provider = FakeProviderNoHelpText() + arguments = Namespace( + status=None, + output_formats=None, + output_directory=None, + output_filename="custom-name", + verbose=None, + only_logs=None, + unix_timestamp=None, + shodan=None, + fixer=None, + ) + + output_options = provider.get_output_options(arguments, {}) + + assert output_options.output_filename == "custom-name" def test_get_stdout_detail_raises_not_implemented(self): """Base Provider.get_stdout_detail raises NotImplementedError.""" From 8bc8b16a77da12bf033a60c3b211474e6e9ca948 Mon Sep 17 00:00:00 2001 From: StylusFrost Date: Fri, 5 Jun 2026 13:57:36 +0200 Subject: [PATCH 09/15] fix(provider): avoid import cycle in get_output_options default Move the models.py Provider import (used only in the shodan path) to a local import so models no longer depends on provider at module level. This breaks the provider <-> models import cycle CodeQL flagged after the generic OutputOptions default was added, and lets provider.py import ProviderOutputOptions without a cycle. The output_file_timestamp import is consolidated into the existing top-level config import. --- prowler/providers/common/models.py | 5 +++-- prowler/providers/common/provider.py | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/prowler/providers/common/models.py b/prowler/providers/common/models.py index ea70252f0a..b440718aff 100644 --- a/prowler/providers/common/models.py +++ b/prowler/providers/common/models.py @@ -4,8 +4,6 @@ from os.path import isdir from pydantic.v1 import BaseModel -from prowler.providers.common.provider import Provider - # TODO: include this for all the providers class Audit_Metadata(BaseModel): @@ -41,6 +39,9 @@ class ProviderOutputOptions: # Shodan API Key if self.shodan_api_key: # TODO: revisit this logic + # Local import to avoid a module-level import cycle with provider.py + from prowler.providers.common.provider import Provider + provider = Provider.get_global_provider() updated_audit_config = Provider.update_provider_config( provider.audit_config, "shodan_api_key", self.shodan_api_key diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index c8561cd4bd..5627839288 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -9,7 +9,7 @@ from argparse import Namespace from importlib import import_module from typing import Any, Optional -from prowler.config.config import load_and_validate_config_file +from prowler.config.config import load_and_validate_config_file, output_file_timestamp from prowler.lib.logger import logger from prowler.lib.mutelist.mutelist import Mutelist @@ -155,7 +155,6 @@ class Provider(ABC): def get_output_options(self, arguments, bulk_checks_metadata): """Return a generic OutputOptions default; override for provider-specific output.""" - from prowler.config.config import output_file_timestamp from prowler.providers.common.models import ProviderOutputOptions output_options = ProviderOutputOptions(arguments, bulk_checks_metadata) From 356e6e2bb4105bad203d6c2780ea628c24205b59 Mon Sep 17 00:00:00 2001 From: StylusFrost Date: Fri, 5 Jun 2026 14:05:56 +0200 Subject: [PATCH 10/15] fix(provider): default get_summary_entity instead of raising External providers that do not override get_summary_entity no longer cause the summary table to be silently dropped. The base contract returns (self.type, account_id), mirroring the get_output_options default. --- prowler/providers/common/provider.py | 4 +- .../external/test_dynamic_provider_loading.py | 38 +++++++++++++++++-- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 5627839288..ff25c4a0bc 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -175,9 +175,7 @@ class Provider(ABC): def get_summary_entity(self) -> tuple: """Return (entity_type, audited_entities) for the summary table.""" - raise NotImplementedError( - f"{self.__class__.__name__} has not implemented get_summary_entity()" - ) + return (self.type, getattr(self.identity, "account_id", "")) def get_finding_output_data(self, _check_output) -> dict: """Return provider-specific fields for Finding.generate_output().""" diff --git a/tests/providers/external/test_dynamic_provider_loading.py b/tests/providers/external/test_dynamic_provider_loading.py index 65087315e1..cc9c3f14c8 100644 --- a/tests/providers/external/test_dynamic_provider_loading.py +++ b/tests/providers/external/test_dynamic_provider_loading.py @@ -1856,11 +1856,41 @@ class TestBaseContractDefaults: provider = FakeProviderNoHelpText() assert provider.get_finding_sort_key() is None - def test_get_summary_entity_raises_not_implemented(self): - """Base Provider.get_summary_entity raises NotImplementedError.""" + def test_get_summary_entity_returns_type_and_account_default(self): + """Base Provider.get_summary_entity returns (type, account_id) so the + summary table is not silently dropped for providers that don't override + it.""" + from types import SimpleNamespace + from unittest.mock import PropertyMock + provider = FakeProviderNoHelpText() - with pytest.raises(NotImplementedError): - provider.get_summary_entity() + with patch.object( + type(provider), + "identity", + new_callable=PropertyMock, + return_value=SimpleNamespace(account_id="acc-123"), + ): + entity_type, audited_entities = provider.get_summary_entity() + + assert entity_type == provider.type + assert audited_entities == "acc-123" + + def test_get_summary_entity_defaults_account_to_empty_string(self): + """When the identity has no account_id, audited_entities falls back to ''.""" + from types import SimpleNamespace + from unittest.mock import PropertyMock + + provider = FakeProviderNoHelpText() + with patch.object( + type(provider), + "identity", + new_callable=PropertyMock, + return_value=SimpleNamespace(), + ): + entity_type, audited_entities = provider.get_summary_entity() + + assert entity_type == provider.type + assert audited_entities == "" def test_get_finding_output_data_raises_not_implemented(self): """Base Provider.get_finding_output_data raises NotImplementedError.""" From 29825f9a2f1a7e6ac7e010f67491ccd1eb505b14 Mon Sep 17 00:00:00 2001 From: StylusFrost Date: Fri, 5 Jun 2026 14:21:49 +0200 Subject: [PATCH 11/15] fix(provider): move get_output_options default to call site Resolve the provider<->models import cycle CodeQL flagged (py/cyclic-import #7267, #7268). provider.py no longer imports models: get_output_options stays override-only and __main__ falls back to a new default_output_options helper in models.py when a provider does not implement it. models.py keeps its original module-level Provider import (one-way, no cycle). --- prowler/__main__.py | 13 +++++++- prowler/providers/common/models.py | 18 +++++++++-- prowler/providers/common/provider.py | 16 ++++------ .../external/test_dynamic_provider_loading.py | 30 +++++++++++++------ 4 files changed, 54 insertions(+), 23 deletions(-) diff --git a/prowler/__main__.py b/prowler/__main__.py index f2637395c3..6cbaf575d9 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -438,7 +438,18 @@ def prowler(): ) else: # Dynamic fallback: any external/custom provider - output_options = global_provider.get_output_options(args, bulk_checks_metadata) + try: + output_options = global_provider.get_output_options( + args, bulk_checks_metadata + ) + except NotImplementedError: + # No provider-specific OutputOptions: use the generic default so the + # run still produces output instead of aborting. + from prowler.providers.common.models import default_output_options + + output_options = default_output_options( + global_provider, args, bulk_checks_metadata + ) # Run the quick inventory for the provider if available if hasattr(args, "quick_inventory") and args.quick_inventory: diff --git a/prowler/providers/common/models.py b/prowler/providers/common/models.py index b440718aff..120cc1a374 100644 --- a/prowler/providers/common/models.py +++ b/prowler/providers/common/models.py @@ -4,6 +4,9 @@ from os.path import isdir from pydantic.v1 import BaseModel +from prowler.config.config import output_file_timestamp +from prowler.providers.common.provider import Provider + # TODO: include this for all the providers class Audit_Metadata(BaseModel): @@ -39,9 +42,6 @@ class ProviderOutputOptions: # Shodan API Key if self.shodan_api_key: # TODO: revisit this logic - # Local import to avoid a module-level import cycle with provider.py - from prowler.providers.common.provider import Provider - provider = Provider.get_global_provider() updated_audit_config = Provider.update_provider_config( provider.audit_config, "shodan_api_key", self.shodan_api_key @@ -70,3 +70,15 @@ class Connection: is_connected: bool = False error: Exception = None + + +def default_output_options(provider, arguments, bulk_checks_metadata): + """Generic OutputOptions fallback for external providers that do not + implement get_output_options, so the run still produces output instead of + aborting. Honors arguments.output_filename and otherwise derives a name + from the provider type.""" + output_options = ProviderOutputOptions(arguments, bulk_checks_metadata) + output_options.output_filename = getattr(arguments, "output_filename", None) or ( + f"prowler-output-{provider.type}-{output_file_timestamp}" + ) + return output_options diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index ff25c4a0bc..8bc7567795 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -9,7 +9,7 @@ from argparse import Namespace from importlib import import_module from typing import Any, Optional -from prowler.config.config import load_and_validate_config_file, output_file_timestamp +from prowler.config.config import load_and_validate_config_file from prowler.lib.logger import logger from prowler.lib.mutelist.mutelist import Mutelist @@ -153,15 +153,11 @@ class Provider(ABC): """ raise NotImplementedError(f"{cls.__name__} has not implemented from_cli_args()") - def get_output_options(self, arguments, bulk_checks_metadata): - """Return a generic OutputOptions default; override for provider-specific output.""" - from prowler.providers.common.models import ProviderOutputOptions - - output_options = ProviderOutputOptions(arguments, bulk_checks_metadata) - output_options.output_filename = getattr( - arguments, "output_filename", None - ) or (f"prowler-output-{self.type}-{output_file_timestamp}") - return output_options + def get_output_options(self, arguments, _bulk_checks_metadata): + """Create the provider-specific OutputOptions.""" + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented get_output_options()" + ) def get_stdout_detail(self, _finding) -> str: """Return the detail string for stdout reporting (region, location, etc.).""" diff --git a/tests/providers/external/test_dynamic_provider_loading.py b/tests/providers/external/test_dynamic_provider_loading.py index cc9c3f14c8..e60720e32e 100644 --- a/tests/providers/external/test_dynamic_provider_loading.py +++ b/tests/providers/external/test_dynamic_provider_loading.py @@ -1798,12 +1798,22 @@ class TestBaseContractDefaults: with pytest.raises(NotImplementedError): FakeProviderNoHelpText.from_cli_args(MagicMock(), {}) - def test_get_output_options_returns_generic_default(self): - """Base Provider.get_output_options returns a generic ProviderOutputOptions - so an external provider that does not override it still produces output - instead of aborting the run with NotImplementedError.""" + def test_get_output_options_raises_not_implemented(self): + """Base Provider.get_output_options raises NotImplementedError; the + generic default is applied at the call site via default_output_options.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.get_output_options(MagicMock(), {}) + + def test_default_output_options_builds_generic_default(self): + """default_output_options returns a generic ProviderOutputOptions so an + external provider without get_output_options still produces output + instead of aborting the run.""" from prowler.config.config import output_file_timestamp - from prowler.providers.common.models import ProviderOutputOptions + from prowler.providers.common.models import ( + ProviderOutputOptions, + default_output_options, + ) provider = FakeProviderNoHelpText() arguments = Namespace( @@ -1818,7 +1828,7 @@ class TestBaseContractDefaults: fixer=None, ) - output_options = provider.get_output_options(arguments, {}) + output_options = default_output_options(provider, arguments, {}) assert isinstance(output_options, ProviderOutputOptions) assert ( @@ -1826,8 +1836,10 @@ class TestBaseContractDefaults: == f"prowler-output-{provider.type}-{output_file_timestamp}" ) - def test_get_output_options_honors_explicit_filename(self): - """A user-supplied output_filename is preserved by the default.""" + def test_default_output_options_honors_explicit_filename(self): + """A user-supplied output_filename is preserved by default_output_options.""" + from prowler.providers.common.models import default_output_options + provider = FakeProviderNoHelpText() arguments = Namespace( status=None, @@ -1841,7 +1853,7 @@ class TestBaseContractDefaults: fixer=None, ) - output_options = provider.get_output_options(arguments, {}) + output_options = default_output_options(provider, arguments, {}) assert output_options.output_filename == "custom-name" From 6f172a5c19886ee43e9ba1b5c19d7ce11ff6aaa1 Mon Sep 17 00:00:00 2001 From: potato-20 <164017049+potato-20@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:56:07 +0530 Subject: [PATCH 12/15] feat(elbv2): add elbv2_alb_drop_invalid_header_fields_enabled check (FSBP ELB.4) (#11471) Co-authored-by: Hugo P.Brito --- prowler/CHANGELOG.md | 1 + ...ndational_security_best_practices_aws.json | 4 +- .../__init__.py | 0 ...nvalid_header_fields_enabled.metadata.json | 40 +++ ..._alb_drop_invalid_header_fields_enabled.py | 27 ++ ...drop_invalid_header_fields_enabled_test.py | 254 ++++++++++++++++++ 6 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/__init__.py create mode 100644 prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json create mode 100644 prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.py create mode 100644 tests/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 5d180cb4e3..6922846b0d 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278) - DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) +- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471) --- diff --git a/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json b/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json index a64a421c8a..cea7ad1655 100644 --- a/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json +++ b/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json @@ -1863,7 +1863,9 @@ "Id": "ELB.4", "Name": "Application load balancers should be configured to drop HTTP headers", "Description": "This control evaluates AWS Application Load Balancers (ALB) to ensure they are configured to drop invalid HTTP headers. The control fails if the value of routing.http.drop_invalid_header_fields.enabled is set to false. By default, ALBs are not configured to drop invalid HTTP header values. Removing these header values prevents HTTP desync attacks.", - "Checks": [], + "Checks": [ + "elbv2_alb_drop_invalid_header_fields_enabled" + ], "Attributes": [ { "ItemId": "ELB.4", diff --git a/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/__init__.py b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json new file mode 100644 index 0000000000..0293501578 --- /dev/null +++ b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "aws", + "CheckID": "elbv2_alb_drop_invalid_header_fields_enabled", + "CheckTitle": "Application Load Balancer should be configured to drop invalid HTTP header fields", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "TTPs/Initial Access", + "Effects/Data Exposure" + ], + "ServiceName": "elbv2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsElbv2LoadBalancer", + "ResourceGroup": "network", + "Description": "Ensure that Application Load Balancers (ALB) are configured to drop invalid HTTP header fields. The check fails when `routing.http.drop_invalid_header_fields.enabled` is not set to `true`. By default, ALBs do not remove HTTP headers that do not conform to RFC 7230.", + "Risk": "Forwarding non-RFC-compliant HTTP headers to backend targets enables HTTP desync (request smuggling):\n- **Confidentiality**: session/token theft, data exfiltration\n- **Integrity**: cache poisoning, request routing bypass, unauthorized actions\n- **Availability**: backend exhaustion.\nDropping invalid header fields removes a primary smuggling vector.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#drop-invalid-header-fields", + "https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-4" + ], + "Remediation": { + "Code": { + "CLI": "aws elbv2 modify-load-balancer-attributes --load-balancer-arn --attributes Key=routing.http.drop_invalid_header_fields.enabled,Value=true", + "NativeIaC": "```yaml\n# CloudFormation: enable drop invalid header fields on an ALB\nResources:\n :\n Type: AWS::ElasticLoadBalancingV2::LoadBalancer\n Properties:\n Type: application\n Subnets:\n - \n - \n LoadBalancerAttributes:\n - Key: routing.http.drop_invalid_header_fields.enabled # Critical: drop non-RFC-compliant headers\n Value: true\n```", + "Other": "1. Open the Amazon EC2 console and choose Load Balancers.\n2. Select the Application Load Balancer.\n3. On the Attributes tab, choose Edit.\n4. Set 'Drop invalid header fields' to Enabled.\n5. Save changes.", + "Terraform": "```hcl\n# Terraform: enable drop invalid header fields on an ALB\nresource \"aws_lb\" \"\" {\n name = \"\"\n load_balancer_type = \"application\"\n subnets = [\"\", \"\"]\n drop_invalid_header_fields = true # Critical: drop non-RFC-compliant headers\n}\n```" + }, + "Recommendation": { + "Text": "Enable 'drop invalid header fields' on Application Load Balancers so non-RFC-compliant HTTP headers are removed before requests reach backend targets, reducing exposure to HTTP desync and request smuggling. Apply defense in depth and validate requests at the application layer as well.", + "Url": "https://hub.prowler.com/check/elbv2_alb_drop_invalid_header_fields_enabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.py b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.py new file mode 100644 index 0000000000..86740a253a --- /dev/null +++ b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.py @@ -0,0 +1,27 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.elbv2.elbv2_client import elbv2_client + + +class elbv2_alb_drop_invalid_header_fields_enabled(Check): + def execute(self): + findings = [] + for lb in elbv2_client.loadbalancersv2.values(): + if lb.type == "application": + report = Check_Report_AWS( + metadata=self.metadata(), + resource=lb, + ) + report.status = "PASS" + report.status_extended = ( + f"ELBv2 ALB {lb.name} is configured to drop invalid " + "header fields." + ) + if lb.drop_invalid_header_fields != "true": + report.status = "FAIL" + report.status_extended = ( + f"ELBv2 ALB {lb.name} is not configured to drop " + "invalid header fields." + ) + findings.append(report) + + return findings diff --git a/tests/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled_test.py b/tests/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled_test.py new file mode 100644 index 0000000000..40d8d91f0d --- /dev/null +++ b/tests/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled_test.py @@ -0,0 +1,254 @@ +from importlib import import_module +from unittest import mock + +from boto3 import client, resource +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_REGION_EU_WEST_1, + AWS_REGION_EU_WEST_1_AZA, + AWS_REGION_EU_WEST_1_AZB, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CHECK_MODULE = ( + "prowler.providers.aws.services.elbv2." + "elbv2_alb_drop_invalid_header_fields_enabled." + "elbv2_alb_drop_invalid_header_fields_enabled" +) +ELBV2_CLIENT_PATCH = f"{CHECK_MODULE}.elbv2_client" +GLOBAL_PROVIDER_PATCH = ".".join( + [ + "prowler.providers.common.provider.Provider", + "get_global_provider", + ] +) +PASS_STATUS_EXTENDED = " ".join( + [ + "ELBv2 ALB my-lb is configured to drop invalid", + "header fields.", + ] +) +FAIL_STATUS_EXTENDED = ( + "ELBv2 ALB my-lb is not configured to drop invalid header fields." +) + + +def get_check_class(): + return getattr( + import_module(CHECK_MODULE), + "elbv2_alb_drop_invalid_header_fields_enabled", + ) + + +class Test_elbv2_alb_drop_invalid_header_fields_enabled: + @mock_aws + def test_elb_no_balancers(self): + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + with ( + mock.patch( + GLOBAL_PROVIDER_PATCH, + return_value=set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ), + ), + mock.patch( + ELBV2_CLIENT_PATCH, + new=ELBv2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + ), + ), + ): + check = get_check_class()() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_elbv2_dropping_invalid_header_fields(self): + conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1) + ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1) + + security_group = ec2.create_security_group( + GroupName="a-security-group", Description="First One" + ) + vpc = ec2.create_vpc( + CidrBlock="172.28.7.0/24", + InstanceTenancy="default", + ) + subnet1 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.192/26", + AvailabilityZone=AWS_REGION_EU_WEST_1_AZA, + ) + subnet2 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.0/26", + AvailabilityZone=AWS_REGION_EU_WEST_1_AZB, + ) + + lb = conn.create_load_balancer( + Name="my-lb", + Subnets=[subnet1.id, subnet2.id], + SecurityGroups=[security_group.id], + Scheme="internal", + Type="application", + )["LoadBalancers"][0] + + conn.modify_load_balancer_attributes( + LoadBalancerArn=lb["LoadBalancerArn"], + Attributes=[ + { + "Key": "routing.http.drop_invalid_header_fields.enabled", + "Value": "true", + }, + ], + ) + + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + with ( + mock.patch( + GLOBAL_PROVIDER_PATCH, + return_value=set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ), + ), + mock.patch( + ELBV2_CLIENT_PATCH, + new=ELBv2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + ), + ), + ): + check = get_check_class()() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == PASS_STATUS_EXTENDED + assert result[0].resource_id == "my-lb" + assert result[0].resource_arn == lb["LoadBalancerArn"] + + @mock_aws + def test_elbv2_not_dropping_invalid_header_fields(self): + conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1) + ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1) + + security_group = ec2.create_security_group( + GroupName="a-security-group", Description="First One" + ) + vpc = ec2.create_vpc( + CidrBlock="172.28.7.0/24", + InstanceTenancy="default", + ) + subnet1 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.192/26", + AvailabilityZone=AWS_REGION_EU_WEST_1_AZA, + ) + subnet2 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.0/26", + AvailabilityZone=AWS_REGION_EU_WEST_1_AZB, + ) + + lb = conn.create_load_balancer( + Name="my-lb", + Subnets=[subnet1.id, subnet2.id], + SecurityGroups=[security_group.id], + Scheme="internal", + Type="application", + )["LoadBalancers"][0] + + conn.modify_load_balancer_attributes( + LoadBalancerArn=lb["LoadBalancerArn"], + Attributes=[ + { + "Key": "routing.http.drop_invalid_header_fields.enabled", + "Value": "false", + }, + ], + ) + + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + with ( + mock.patch( + GLOBAL_PROVIDER_PATCH, + return_value=set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ), + ), + mock.patch( + ELBV2_CLIENT_PATCH, + new=ELBv2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + ), + ), + ): + check = get_check_class()() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == FAIL_STATUS_EXTENDED + assert result[0].resource_id == "my-lb" + assert result[0].resource_arn == lb["LoadBalancerArn"] + + @mock_aws + def test_elbv2_network_load_balancer_ignored(self): + conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1) + ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1) + + vpc = ec2.create_vpc( + CidrBlock="172.28.7.0/24", + InstanceTenancy="default", + ) + subnet1 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.192/26", + AvailabilityZone=AWS_REGION_EU_WEST_1_AZA, + ) + + conn.create_load_balancer( + Name="my-nlb", + Subnets=[subnet1.id], + Scheme="internal", + Type="network", + ) + + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + with ( + mock.patch( + GLOBAL_PROVIDER_PATCH, + return_value=set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ), + ), + mock.patch( + ELBV2_CLIENT_PATCH, + new=ELBv2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + ), + ), + ): + check = get_check_class()() + result = check.execute() + + assert len(result) == 0 From f9682c13547ba09eee9941829237948ffb387faf Mon Sep 17 00:00:00 2001 From: StylusFrost Date: Fri, 5 Jun 2026 14:38:42 +0200 Subject: [PATCH 13/15] fix(compliance): make GenericCompliance tolerant of provider-specific schemas GenericCompliance is the documented last-resort renderer, but it read the universal attribute fields (Section, SubSection, SubGroup, Service, Type, Comment) directly and raised AttributeError on frameworks whose schema does not declare them (CIS, ENS, ISO27001), dropping the whole compliance CSV. Read all six fields with getattr defaulting to None, and dedupe the finding and manual rows into a single helper. --- .../lib/outputs/compliance/generic/generic.py | 82 ++++++++----------- .../compliance/generic/generic_aws_test.py | 45 ++++++++++ 2 files changed, 80 insertions(+), 47 deletions(-) diff --git a/prowler/lib/outputs/compliance/generic/generic.py b/prowler/lib/outputs/compliance/generic/generic.py index c7217db5b3..b774f09577 100644 --- a/prowler/lib/outputs/compliance/generic/generic.py +++ b/prowler/lib/outputs/compliance/generic/generic.py @@ -34,60 +34,48 @@ class GenericCompliance(ComplianceOutput): Returns: - None """ + + def compliance_row(requirement, attribute, finding=None): + # Read attribute fields defensively: GenericCompliance is the + # last-resort renderer for any framework, and provider-specific + # schemas (e.g. CIS, ENS, ISO27001) do not declare the universal + # Section/SubSection/SubGroup/Service/Type/Comment fields. + return GenericComplianceModel( + Provider=(finding.provider if finding else compliance.Provider.lower()), + Description=compliance.Description, + AccountId=finding.account_uid if finding else "", + Region=finding.region if finding else "", + AssessmentDate=str(timestamp), + Requirements_Id=requirement.Id, + Requirements_Description=requirement.Description, + Requirements_Attributes_Section=getattr(attribute, "Section", None), + Requirements_Attributes_SubSection=getattr( + attribute, "SubSection", None + ), + Requirements_Attributes_SubGroup=getattr(attribute, "SubGroup", None), + Requirements_Attributes_Service=getattr(attribute, "Service", None), + Requirements_Attributes_Type=getattr(attribute, "Type", None), + Requirements_Attributes_Comment=getattr(attribute, "Comment", None), + Status=finding.status if finding else "MANUAL", + StatusExtended=(finding.status_extended if finding else "Manual check"), + ResourceId=finding.resource_uid if finding else "manual_check", + ResourceName=finding.resource_name if finding else "Manual check", + CheckId=finding.check_id if finding else "manual", + Muted=finding.muted if finding else False, + Framework=compliance.Framework, + Name=compliance.Name, + ) + for finding in findings: for requirement in compliance.Requirements: # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). if finding.check_id in requirement.Checks: for attribute in requirement.Attributes: - compliance_row = GenericComplianceModel( - Provider=finding.provider, - Description=compliance.Description, - AccountId=finding.account_uid, - Region=finding.region, - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_SubSection=attribute.SubSection, - Requirements_Attributes_SubGroup=attribute.SubGroup, - Requirements_Attributes_Service=attribute.Service, - Requirements_Attributes_Type=attribute.Type, - Requirements_Attributes_Comment=attribute.Comment, - Status=finding.status, - StatusExtended=finding.status_extended, - ResourceId=finding.resource_uid, - ResourceName=finding.resource_name, - CheckId=finding.check_id, - Muted=finding.muted, - Framework=compliance.Framework, - Name=compliance.Name, + self._data.append( + compliance_row(requirement, attribute, finding) ) - self._data.append(compliance_row) # Add manual requirements to the compliance output for requirement in compliance.Requirements: if not requirement.Checks: for attribute in requirement.Attributes: - compliance_row = GenericComplianceModel( - Provider=compliance.Provider.lower(), - Description=compliance.Description, - AccountId="", - Region="", - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_SubSection=attribute.SubSection, - Requirements_Attributes_SubGroup=attribute.SubGroup, - Requirements_Attributes_Service=attribute.Service, - Requirements_Attributes_Type=attribute.Type, - Requirements_Attributes_Comment=attribute.Comment, - Status="MANUAL", - StatusExtended="Manual check", - ResourceId="manual_check", - ResourceName="Manual check", - CheckId="manual", - Muted=False, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) + self._data.append(compliance_row(requirement, attribute)) diff --git a/tests/lib/outputs/compliance/generic/generic_aws_test.py b/tests/lib/outputs/compliance/generic/generic_aws_test.py index 335d1860ea..335a9ad17d 100644 --- a/tests/lib/outputs/compliance/generic/generic_aws_test.py +++ b/tests/lib/outputs/compliance/generic/generic_aws_test.py @@ -9,6 +9,7 @@ from prowler.lib.check.compliance_models import ( Compliance, Compliance_Requirement, Generic_Compliance_Requirement_Attribute, + ISO27001_2013_Requirement_Attribute, ) from prowler.lib.outputs.compliance.generic.generic import GenericCompliance from prowler.lib.outputs.compliance.generic.models import GenericComplianceModel @@ -198,3 +199,47 @@ class TestAWSGenericCompliance: ), f"Expected 1 row driven by framework JSON, got {len(rows)}" assert rows[0].Requirements_Id == "req_in_framework" assert rows[0].CheckId == "service_check_in_framework" + + def test_transform_tolerates_framework_specific_attribute_schema(self): + """GenericCompliance is the documented last-resort renderer, so it must not + crash on a framework whose attribute schema lacks the universal fields + (Section, SubSection, SubGroup, Service, Type, Comment). ISO27001 declares + none of them; missing fields must render as None instead of raising + AttributeError and dropping the whole CSV.""" + framework_name = "ISO27001-2013-External" + compliance = Compliance( + Framework=framework_name, + Name=framework_name, + Provider="external", + Version="", + Description="Framework shipping a provider-specific attribute schema", + Requirements=[ + Compliance_Requirement( + Id="A.5.1.1", + Description="Policies for information security", + Attributes=[ + ISO27001_2013_Requirement_Attribute( + Category="Information security policies", + Objetive_ID="A.5.1", + Objetive_Name="Management direction", + Check_Summary="Policy is defined", + ) + ], + Checks=["service_test_check_id"], + ) + ], + ) + + findings = [generate_finding_output(check_id="service_test_check_id")] + + output = GenericCompliance(findings, compliance) + + rows = [row for row in output.data if row.Status != "MANUAL"] + assert len(rows) == 1 + assert rows[0].Requirements_Id == "A.5.1.1" + assert rows[0].Requirements_Attributes_Section is None + assert rows[0].Requirements_Attributes_SubSection is None + assert rows[0].Requirements_Attributes_SubGroup is None + assert rows[0].Requirements_Attributes_Service is None + assert rows[0].Requirements_Attributes_Type is None + assert rows[0].Requirements_Attributes_Comment is None From 8a0d56786dad4fe1912524712f9c621193fe9662 Mon Sep 17 00:00:00 2001 From: StylusFrost Date: Fri, 5 Jun 2026 15:01:11 +0200 Subject: [PATCH 14/15] fix(changelog): resolve leftover merge conflict marker Remove a stray '=======' conflict marker left in prowler/CHANGELOG.md after the master merge, and move the elbv2_alb_drop_invalid_header_fields_enabled entry from Fixed to Added where it belongs. --- prowler/CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 1756ff0854..b9d1169a5e 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -9,12 +9,11 @@ All notable changes to the **Prowler SDK** are documented in this file. - `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278) - DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) - Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700) +- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471) ### 🐞 Fixed - `load_and_validate_config_file` now unwraps namespaced config for every built-in and external provider, and no longer leaks the full file as the provider's config when the file is namespaced [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700) -======= -- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471) - GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355) - Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474) - GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467) From 40da3598045844f976c671ed27ca1bf04a381308 Mon Sep 17 00:00:00 2001 From: StylusFrost Date: Sun, 7 Jun 2026 13:29:40 +0200 Subject: [PATCH 15/15] feat(compliance): discover external universal frameworks via entry points External plug-ins ship multi-provider (universal-schema) frameworks through a dedicated prowler.compliance.universal entry point group, separate from the per-provider prowler.compliance group. Both get_bulk_compliance_frameworks_universal (loading) and get_available_compliance_frameworks (listing / --compliance choices) scan the new group. Built-ins load first and win on a name collision; multiple packages under the same provider are merged. load_compliance_framework gains fatal=False so the legacy external path skips a non-legacy JSON with a warning instead of aborting the run. --- prowler/config/config.py | 29 ++++- prowler/lib/check/compliance_models.py | 57 +++++++-- .../check/universal_compliance_models_test.py | 121 ++++++++++++++++++ .../external/test_dynamic_provider_loading.py | 85 ++++++++++++ 4 files changed, 278 insertions(+), 14 deletions(-) diff --git a/prowler/config/config.py b/prowler/config/config.py index 10e63c42df..71c59d82a3 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -144,8 +144,7 @@ def get_available_compliance_frameworks(provider=None): continue if name not in available_compliance_frameworks: available_compliance_frameworks.append(name) - # External compliance via entry points. - # Multi-provider support for external plug-ins is tracked in PROWLER-1444. + # External per-provider compliance via entry points. ep_dirs = _get_ep_compliance_dirs() for prov, path in ep_dirs.items(): if provider and prov != provider: @@ -156,6 +155,32 @@ def get_available_compliance_frameworks(provider=None): name = file.name.removesuffix(".json") if name not in available_compliance_frameworks: available_compliance_frameworks.append(name) + # External multi-provider frameworks via the dedicated universal group; + # filtered by supports_provider when a provider is given. + for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"): + try: + module = ep.load() + path = ( + module.__path__[0] + if hasattr(module, "__path__") + else os.path.dirname(module.__file__) + ) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + continue + if not os.path.isdir(path): + continue + for file in os.scandir(path): + if file.is_file() and file.name.endswith(".json"): + name = file.name.removesuffix(".json") + if provider: + framework = load_compliance_framework_universal(file.path) + if framework is None or not framework.supports_provider(provider): + continue + if name not in available_compliance_frameworks: + available_compliance_frameworks.append(name) return available_compliance_frameworks diff --git a/prowler/lib/check/compliance_models.py b/prowler/lib/check/compliance_models.py index 8cc588cc4c..3610893008 100644 --- a/prowler/lib/check/compliance_models.py +++ b/prowler/lib/check/compliance_models.py @@ -478,9 +478,15 @@ class Compliance(BaseModel): compliance_framework_name not in bulk_compliance_frameworks ): - bulk_compliance_frameworks[ - compliance_framework_name - ] = load_compliance_framework(file_path) + # External JSON: tolerate non-legacy + # schemas (skip + warn) instead of aborting. + framework = load_compliance_framework( + file_path, fatal=False + ) + if framework is not None: + bulk_compliance_frameworks[ + compliance_framework_name + ] = framework except Exception as error: logger.warning( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -494,18 +500,26 @@ class Compliance(BaseModel): # Testing Pending def load_compliance_framework( - compliance_specification_file: str, -) -> Compliance: - """load_compliance_framework loads and parse a Compliance Framework Specification""" + compliance_specification_file: str, fatal: bool = True +) -> Optional[Compliance]: + """load_compliance_framework loads and parse a Compliance Framework Specification. + + With ``fatal=True`` (built-in JSONs) an invalid file aborts the run; with + ``fatal=False`` (external JSONs) it is skipped with a warning and ``None`` + is returned. + """ try: - compliance_framework = Compliance.parse_file(compliance_specification_file) + return Compliance.parse_file(compliance_specification_file) except ValidationError as error: - logger.critical( - f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}" + if fatal: + logger.critical( + f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}" + ) + sys.exit(1) + logger.warning( + f"Skipping invalid compliance framework {compliance_specification_file}: {error}" ) - sys.exit(1) - else: - return compliance_framework + return None # ─── Universal Compliance Schema Models (Phase 1-3) ───────────────────────── @@ -982,6 +996,25 @@ def get_bulk_compliance_frameworks_universal(provider: str) -> dict: if compliance_root and os.path.isdir(compliance_root): _load_jsons_from_dir(compliance_root, provider, bulk) + # External multi-provider frameworks via the dedicated universal entry + # point group, kept separate from the per-provider `prowler.compliance` + # group so the legacy loader never parses a universal JSON. Built-ins + # (already in bulk) win on a name collision. + for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"): + try: + module = ep.load() + ep_dir = ( + module.__path__[0] + if hasattr(module, "__path__") + else os.path.dirname(module.__file__) + ) + if os.path.isdir(ep_dir): + _load_jsons_from_dir(ep_dir, provider, bulk) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as e: logger.error(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}") return bulk diff --git a/tests/lib/check/universal_compliance_models_test.py b/tests/lib/check/universal_compliance_models_test.py index 5a3e4aae56..c8f614488c 100644 --- a/tests/lib/check/universal_compliance_models_test.py +++ b/tests/lib/check/universal_compliance_models_test.py @@ -1,5 +1,7 @@ import json import os +import tempfile +from unittest.mock import MagicMock, patch import pytest from pydantic.v1 import ValidationError @@ -23,6 +25,7 @@ from prowler.lib.check.compliance_models import ( TableLabels, UniversalComplianceRequirement, adapt_legacy_to_universal, + get_bulk_compliance_frameworks_universal, load_compliance_framework_universal, ) from tests.lib.outputs.compliance.fixtures import ( @@ -1116,3 +1119,121 @@ class TestAttributesMetadataValidation: ], attributes_metadata=self._metadata(enum=["high", "low"]), ) + + +class TestGetBulkUniversalEntryPoints: + """Entry-point discovery for universal (multi-provider) compliance frameworks.""" + + @staticmethod + def _write_universal_json(directory, filename, framework, display_name): + data = { + "framework": framework, + "name": display_name, + "version": "1.0", + "description": "External multi-provider framework", + "requirements": [ + { + "id": "1", + "name": "Requirement 1", + "description": "desc", + "checks": {"fakeexternal": ["check_a"]}, + } + ], + } + with open(os.path.join(directory, filename), "w") as f: + json.dump(data, f) + + @staticmethod + def _entry_point(path): + module = MagicMock() + module.__path__ = [path] + ep = MagicMock() + ep.name = "fakeexternal" + ep.group = "prowler.compliance.universal" + ep.load.return_value = module + return ep + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_includes_external_universal_framework(self, mock_list_modules, mock_ep): + mock_list_modules.return_value = [] + with tempfile.TemporaryDirectory() as ep_dir: + self._write_universal_json( + ep_dir, "customuniversal_1.0.json", "CustomUniversal", "Custom" + ) + mock_ep.return_value = [self._entry_point(ep_dir)] + + bulk = get_bulk_compliance_frameworks_universal("fakeexternal") + + mock_ep.assert_called_with(group="prowler.compliance.universal") + assert "customuniversal_1.0" in bulk + assert bulk["customuniversal_1.0"].framework == "CustomUniversal" + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_builtin_wins_over_external_on_name_collision( + self, mock_list_modules, mock_ep + ): + with ( + tempfile.TemporaryDirectory() as root, + tempfile.TemporaryDirectory() as ep_dir, + ): + builtin_sub = os.path.join(root, "builtinprov") + os.makedirs(builtin_sub) + self._write_universal_json( + builtin_sub, "shared_1.0.json", "SharedFramework", "Built-in" + ) + builtin_module = MagicMock() + builtin_module.module_finder.path = root + builtin_module.name = "prowler.compliance.builtinprov" + mock_list_modules.return_value = [builtin_module] + + self._write_universal_json( + ep_dir, "shared_1.0.json", "SharedFramework", "External" + ) + mock_ep.return_value = [self._entry_point(ep_dir)] + + bulk = get_bulk_compliance_frameworks_universal("fakeexternal") + + assert "shared_1.0" in bulk + assert bulk["shared_1.0"].name == "Built-in" + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_loads_all_frameworks_in_a_single_entry_point_path( + self, mock_list_modules, mock_ep + ): + """All JSONs in one entry-point directory are added, not collapsed to one.""" + mock_list_modules.return_value = [] + with tempfile.TemporaryDirectory() as ep_dir: + self._write_universal_json(ep_dir, "fw_a_1.0.json", "FwA", "Framework A") + self._write_universal_json(ep_dir, "fw_b_1.0.json", "FwB", "Framework B") + mock_ep.return_value = [self._entry_point(ep_dir)] + + bulk = get_bulk_compliance_frameworks_universal("fakeexternal") + + assert "fw_a_1.0" in bulk + assert "fw_b_1.0" in bulk + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_merges_frameworks_from_multiple_packages_same_provider( + self, mock_list_modules, mock_ep + ): + """Two packages under the same provider name are both discovered.""" + mock_list_modules.return_value = [] + with ( + tempfile.TemporaryDirectory() as dir_a, + tempfile.TemporaryDirectory() as dir_b, + ): + self._write_universal_json(dir_a, "pkg_a_1.0.json", "PkgA", "Package A") + self._write_universal_json(dir_b, "pkg_b_1.0.json", "PkgB", "Package B") + mock_ep.return_value = [ + self._entry_point(dir_a), + self._entry_point(dir_b), + ] + + bulk = get_bulk_compliance_frameworks_universal("fakeexternal") + + assert "pkg_a_1.0" in bulk + assert "pkg_b_1.0" in bulk diff --git a/tests/providers/external/test_dynamic_provider_loading.py b/tests/providers/external/test_dynamic_provider_loading.py index e60720e32e..eefde19545 100644 --- a/tests/providers/external/test_dynamic_provider_loading.py +++ b/tests/providers/external/test_dynamic_provider_loading.py @@ -1218,6 +1218,48 @@ class TestCompliance: assert "custom_1.0_ext" in frameworks + @patch("prowler.config.config.importlib.metadata.entry_points") + def test_get_available_compliance_includes_external_universal(self, mock_ep): + """External universal frameworks under prowler.compliance.universal are + listed, for a provider and for the provider=None case that feeds + --compliance choices.""" + import json + import os + import tempfile + + from prowler.config.config import get_available_compliance_frameworks + + with tempfile.TemporaryDirectory() as tmpdir: + framework = { + "framework": "CustomUniversal", + "name": "Custom Universal", + "version": "1.0", + "description": "Multi-provider", + "requirements": [ + { + "id": "1", + "name": "r", + "description": "d", + "checks": {"aws": ["c"]}, + } + ], + } + with open(os.path.join(tmpdir, "customuniversal_1.0.json"), "w") as f: + json.dump(framework, f) + + module = MagicMock() + module.__path__ = [tmpdir] + ep = _make_entry_point( + "anyname", "pkg.compliance", "prowler.compliance.universal" + ) + ep.load.return_value = module + mock_ep.side_effect = lambda group: ( + [ep] if group == "prowler.compliance.universal" else [] + ) + + assert "customuniversal_1.0" in get_available_compliance_frameworks("aws") + assert "customuniversal_1.0" in get_available_compliance_frameworks(None) + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") @patch("prowler.lib.check.compliance_models.list_compliance_modules") def test_compliance_get_bulk_loads_external(self, mock_list_modules, mock_ep): @@ -1257,6 +1299,49 @@ class TestCompliance: assert "custom_1.0_fakeexternal" in bulk assert bulk["custom_1.0_fakeexternal"].Framework == "Custom" + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_compliance_get_bulk_skips_non_legacy_external_json( + self, mock_list_modules, mock_ep + ): + """A universal-schema JSON registered under prowler.compliance is skipped, + not aborting the run via sys.exit.""" + import json + import os + import tempfile + + from prowler.lib.check.compliance_models import Compliance + + mock_list_modules.return_value = [] + + with tempfile.TemporaryDirectory() as tmpdir: + json_data = { + "framework": "Universal", + "name": "Universal Framework", + "version": "1.0", + "description": "Multi-provider", + "requirements": [ + { + "id": "1", + "name": "r", + "description": "d", + "checks": {"aws": ["c"]}, + } + ], + } + with open(os.path.join(tmpdir, "universal_1.0.json"), "w") as f: + json.dump(json_data, f) + + mock_module = MagicMock() + mock_module.__path__ = [tmpdir] + ep = _make_entry_point("aws", "pkg.compliance", "prowler.compliance") + ep.load.return_value = mock_module + mock_ep.return_value = [ep] + + bulk = Compliance.get_bulk("aws") + + assert "universal_1.0" not in bulk + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") @patch("prowler.lib.check.compliance_models.list_compliance_modules") def test_compliance_get_bulk_file_fallback(self, mock_list_modules, mock_ep):