diff --git a/ui/actions/finding-groups/finding-groups.adapter.ts b/ui/actions/finding-groups/finding-groups.adapter.ts index e8d42beaa5..c9ee7167c6 100644 --- a/ui/actions/finding-groups/finding-groups.adapter.ts +++ b/ui/actions/finding-groups/finding-groups.adapter.ts @@ -210,7 +210,7 @@ export function adaptFindingGroupResourcesResponse( rowType: FINDINGS_ROW_TYPE.RESOURCE, findingId: item.attributes.finding_id || item.id, checkId, - providerType: (item.attributes.provider?.type || "aws") as ProviderType, + providerType: (item.attributes.provider?.type ?? "") as ProviderType, providerAlias: item.attributes.provider?.alias || "", providerUid: item.attributes.provider?.uid || "", resourceName: item.attributes.resource?.name || "-", diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index 5a6ddb7a29..e192bb5b49 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -14,10 +14,7 @@ import { type ComplianceReportType, } from "@/lib/compliance/compliance-report-types"; import { runWithConcurrencyLimit } from "@/lib/concurrency"; -import { - appendSanitizedProviderTypeFilters, - sanitizeProviderTypesCsv, -} from "@/lib/provider-filters"; +import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { addScanOperation } from "@/lib/sentry-breadcrumbs"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; import { SCAN_STATES } from "@/types/attack-paths"; @@ -66,10 +63,6 @@ export const getScansByState = async () => { const url = new URL(`${apiBaseUrl}/scans`); // Request only the necessary fields to optimize the response url.searchParams.append("fields[scans]", "state"); - url.searchParams.append( - "filter[provider_type__in]", - sanitizeProviderTypesCsv(), - ); // Only need to know whether at least one completed scan exists; filter server-side // and cap to a single row so the answer is correct regardless of total scan count. url.searchParams.append("filter[state]", SCAN_STATES.COMPLETED); diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx index e69d0427ee..0b6e90a697 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx @@ -2,6 +2,7 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; +import { ProviderProps } from "@/types"; import { FILTER_FIELD } from "@/types/filters"; import { AccountsSelector } from "./accounts-selector"; @@ -99,12 +100,13 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({ ), })); -const providers = [ +const providers: ProviderProps[] = [ { id: "provider-1", type: "providers" as const, attributes: { provider: "aws" as const, + is_dynamic: false, uid: "123456789012", alias: "Production AWS", status: "completed" as const, 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 b2c05b336d..23c7d4ace3 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx @@ -1,6 +1,8 @@ import { render, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { ProviderProps } from "@/types"; + import { ProviderTypeSelector } from "./provider-type-selector"; const multiSelectContentSpy = vi.fn(); @@ -69,12 +71,13 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({ ), })); -const providers = [ +const providers: ProviderProps[] = [ { id: "provider-1", type: "providers" as const, attributes: { provider: "aws" as const, + is_dynamic: false, uid: "123456789012", alias: "Production AWS", status: "completed" as const, diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx index e78412eeeb..fdda0f8e49 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx @@ -16,7 +16,22 @@ import { MultiSelectValue, } from "@/components/shadcn/select/multiselect"; import { useUrlFilters } from "@/hooks/use-url-filters"; -import { type ProviderProps, ProviderType } from "@/types/providers"; +import { + humanizeProviderId, + isKnownProviderType, + type ProviderProps, + ProviderType, +} from "@/types/providers"; + +/** + * Label for a provider type: the rich configured label for known types, a + * humanized id for anything the API returns outside the known set (dynamic + * plug-ins). Icons fall back to the generic glyph via `ProviderTypeIcon`. + */ +const providerTypeLabel = (type: ProviderType): string => + isKnownProviderType(type) + ? PROVIDER_TYPE_DATA[type].label + : humanizeProviderId(type); /** Common props shared by both batch and instant modes. */ interface ProviderTypeSelectorBaseProps { @@ -88,16 +103,8 @@ export const ProviderTypeSelector = ({ }; const availableTypes = Array.from( - new Set( - providers - // .filter((p) => p.attributes.connection?.connected) - .map((p) => p.attributes.provider), - ), - ) - .filter((type): type is ProviderType => type in PROVIDER_TYPE_DATA) - .sort((a, b) => - PROVIDER_TYPE_DATA[a].label.localeCompare(PROVIDER_TYPE_DATA[b].label), - ); + new Set(providers.map((p) => p.attributes.provider)), + ).sort((a, b) => providerTypeLabel(a).localeCompare(providerTypeLabel(b))); const selectedLabel = () => { if (selectedTypes.length === 0) return null; @@ -108,9 +115,7 @@ export const ProviderTypeSelector = ({ - - {PROVIDER_TYPE_DATA[providerType].label} - + {providerTypeLabel(providerType)} ); } @@ -120,7 +125,7 @@ export const ProviderTypeSelector = ({ items={(selectedTypes as ProviderType[]).map((type) => ({ key: type, type, - tooltip: PROVIDER_TYPE_DATA[type].label, + tooltip: providerTypeLabel(type), }))} /> @@ -176,23 +181,24 @@ export const ProviderTypeSelector = ({ > {selectedTypes.length === 0 ? "All selected" : "Select All"} - {availableTypes.map((providerType) => ( - - - {PROVIDER_TYPE_DATA[providerType].label} - - ))} + {availableTypes.map((providerType) => { + const label = providerTypeLabel(providerType); + + return ( + + + {label} + + ); + })} ) : (
diff --git a/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx index 6d5ebc945d..28518fd576 100644 --- a/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx +++ b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx @@ -104,6 +104,7 @@ const mockProviders: ProviderProps[] = [ type: "providers", attributes: { provider: "aws", + is_dynamic: false, uid: "123456789012", alias: "Production AWS", status: "completed", @@ -131,6 +132,7 @@ const mockProviders: ProviderProps[] = [ type: "providers", attributes: { provider: "gcp", + is_dynamic: false, uid: "prowler-prod-project", alias: "Production GCP", status: "completed", diff --git a/ui/app/(prowler)/providers/providers-page.utils.test.ts b/ui/app/(prowler)/providers/providers-page.utils.test.ts index 4c712ff081..fff1cc863c 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.test.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.test.ts @@ -62,6 +62,7 @@ const providersResponse: ProvidersApiResponse = { type: "providers", attributes: { provider: "aws", + is_dynamic: false, uid: "111111111111", alias: "AWS App Account", status: "completed", @@ -107,6 +108,7 @@ const providersResponse: ProvidersApiResponse = { type: "providers", attributes: { provider: "aws", + is_dynamic: false, uid: "222222222222", alias: "Standalone Account", status: "completed", diff --git a/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx b/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx index 1583ccc527..e9086cd366 100644 --- a/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx +++ b/ui/app/(prowler)/scans/config/_components/scan-configuration-editor.tsx @@ -31,6 +31,8 @@ import { scanConfigurationFormSchema } from "@/types/formSchemas"; import { ProviderProps } from "@/types/providers"; import { ScanConfigurationData } from "@/types/scan-configurations"; +import { getSelectableProviders } from "./scan-configuration-providers"; + interface ScanConfigurationEditorProps { open: boolean; onClose: (saved: boolean) => void; @@ -90,21 +92,12 @@ function ScanConfigurationForm({ ? validateYaml(configText) : { isValid: true as const }; - // A provider can only be attached to one config at a time. We exclude - // providers that are owned by *other* configs from the selector so the user - // can't double-attach them. (AccountsSelector doesn't expose a per-option - // disabled state, so filtering out is the cleanest contract here.) - const ownerByProvider = new Map(); - for (const c of existingConfigs) { - if (config && c.id === config.id) continue; - for (const pid of c.attributes.providers || []) { - ownerByProvider.set(pid, c.attributes.name); - } - } - const selectableProviders = richProviders.filter( - (p) => !ownerByProvider.has(p.id), - ); - const lockedCount = richProviders.length - selectableProviders.length; + // Dynamic providers can't use a Scan Configuration, and a provider can only be + // attached to one config at a time, so both are excluded from the selector. + // (AccountsSelector doesn't expose a per-option disabled state, so filtering + // out is the cleanest contract here.) + const { selectableProviders, configurableCount, lockedCount } = + getSelectableProviders(richProviders, existingConfigs, config?.id ?? null); const onSubmit = form.handleSubmit(async (values) => { // Block on a YAML syntax error (the inline message already explains it); the @@ -252,7 +245,9 @@ function ScanConfigurationForm({

{richProviders.length === 0 ? "No providers available in this tenant." - : "All providers are already attached to other Scan Configurations."} + : configurableCount === 0 + ? "Dynamic providers can't use custom Scan Configurations, so there are none to attach." + : "All providers are already attached to other Scan Configurations."}

) : ( + ({ + id, + type: "providers", + attributes: { + provider: isDynamic ? "template" : "aws", + is_dynamic: isDynamic, + }, + }) as unknown as ProviderProps; + +const config = (id: string, providers: string[]): ScanConfigurationData => ({ + type: "scan-configurations", + id, + attributes: { + inserted_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + name: id, + configuration: {}, + providers, + }, +}); + +describe("getSelectableProviders", () => { + it("excludes dynamic providers (they have no config baseline to override)", () => { + const result = getSelectableProviders( + [provider("aws-1"), provider("dyn-1", true)], + [], + null, + ); + + expect(result.selectableProviders.map((p) => p.id)).toEqual(["aws-1"]); + expect(result.configurableCount).toBe(1); + expect(result.lockedCount).toBe(0); + }); + + it("excludes providers already attached to another config", () => { + const result = getSelectableProviders( + [provider("aws-1"), provider("aws-2")], + [config("other", ["aws-2"])], + null, + ); + + expect(result.selectableProviders.map((p) => p.id)).toEqual(["aws-1"]); + expect(result.lockedCount).toBe(1); + }); + + it("keeps providers attached to the config being edited selectable", () => { + const result = getSelectableProviders( + [provider("aws-1"), provider("aws-2")], + [config("current", ["aws-1"])], + "current", + ); + + expect(result.selectableProviders.map((p) => p.id)).toEqual([ + "aws-1", + "aws-2", + ]); + expect(result.lockedCount).toBe(0); + }); + + it("lockedCount counts only configurable providers attached elsewhere, not dynamic ones", () => { + const result = getSelectableProviders( + [provider("aws-1"), provider("aws-2"), provider("dyn-1", true)], + [config("other", ["aws-2"])], + null, + ); + + expect(result.selectableProviders.map((p) => p.id)).toEqual(["aws-1"]); + expect(result.configurableCount).toBe(2); + expect(result.lockedCount).toBe(1); + }); + + it("reports zero configurable providers when every provider is dynamic", () => { + const result = getSelectableProviders( + [provider("dyn-1", true), provider("dyn-2", true)], + [], + null, + ); + + expect(result.selectableProviders).toEqual([]); + expect(result.configurableCount).toBe(0); + expect(result.lockedCount).toBe(0); + }); +}); diff --git a/ui/app/(prowler)/scans/config/_components/scan-configuration-providers.ts b/ui/app/(prowler)/scans/config/_components/scan-configuration-providers.ts new file mode 100644 index 0000000000..4e392e249e --- /dev/null +++ b/ui/app/(prowler)/scans/config/_components/scan-configuration-providers.ts @@ -0,0 +1,46 @@ +import { ProviderProps } from "@/types/providers"; +import { ScanConfigurationData } from "@/types/scan-configurations"; + +export interface SelectableProvidersResult { + selectableProviders: ProviderProps[]; + /** Providers eligible for a Scan Configuration at all (i.e. non-dynamic). */ + configurableCount: number; + /** Configurable providers hidden because another config already owns them. */ + lockedCount: number; +} + +/** + * Providers that can be attached to a Scan Configuration. + * + * Dynamic providers are SDK-defined and have no `config.yaml` baseline to + * override, so a Scan Configuration can't apply to them — they are never + * selectable. A provider can also belong to only one config at a time, so those + * already attached to *another* config are excluded (the one being edited is + * kept selectable). + */ +export function getSelectableProviders( + richProviders: ProviderProps[], + existingConfigs: ScanConfigurationData[], + currentConfigId: string | null, +): SelectableProvidersResult { + const attachedElsewhere = new Set(); + for (const c of existingConfigs) { + if (currentConfigId && c.id === currentConfigId) continue; + for (const pid of c.attributes.providers || []) { + attachedElsewhere.add(pid); + } + } + + const configurableProviders = richProviders.filter( + (p) => !p.attributes.is_dynamic, + ); + const selectableProviders = configurableProviders.filter( + (p) => !attachedElsewhere.has(p.id), + ); + + return { + selectableProviders, + configurableCount: configurableProviders.length, + lockedCount: configurableProviders.length - selectableProviders.length, + }; +} diff --git a/ui/changelog.d/dynamic-providers.added.md b/ui/changelog.d/dynamic-providers.added.md new file mode 100644 index 0000000000..e2d213dca7 --- /dev/null +++ b/ui/changelog.d/dynamic-providers.added.md @@ -0,0 +1 @@ +Dynamically registered providers are now listed, filtered, and rendered across the UI, using a generic icon and humanized label when no bespoke assets exist, a "Custom" badge, and read-only handling for non-configurable providers diff --git a/ui/components/filters/provider-account-selectors.test.tsx b/ui/components/filters/provider-account-selectors.test.tsx index 01868d0e8c..c30397eed4 100644 --- a/ui/components/filters/provider-account-selectors.test.tsx +++ b/ui/components/filters/provider-account-selectors.test.tsx @@ -77,6 +77,7 @@ const makeProvider = ({ type: "providers", attributes: { provider, + is_dynamic: false, uid, alias, status: "completed", diff --git a/ui/components/findings/table/provider-icon-cell.test.tsx b/ui/components/findings/table/provider-icon-cell.test.tsx index 593122d33a..2d7082b120 100644 --- a/ui/components/findings/table/provider-icon-cell.test.tsx +++ b/ui/components/findings/table/provider-icon-cell.test.tsx @@ -33,13 +33,15 @@ describe("ProviderIconCell", () => { expect(await screen.findByText("AWS")).toBeInTheDocument(); }); - it("renders a '?' placeholder for a provider type missing from the map", () => { - render( + it("renders the neutral generic glyph for a provider missing from the map", () => { + // Dynamic/unknown providers render the shared generic glyph, not a "?". + const { container } = render( , ); - expect(screen.getByText("?")).toBeInTheDocument(); + expect(screen.queryByText("?")).not.toBeInTheDocument(); + expect(container.querySelector("svg")).toBeInTheDocument(); }); }); diff --git a/ui/components/findings/table/provider-icon-cell.tsx b/ui/components/findings/table/provider-icon-cell.tsx index 350e730e78..5a996415e6 100644 --- a/ui/components/findings/table/provider-icon-cell.tsx +++ b/ui/components/findings/table/provider-icon-cell.tsx @@ -1,7 +1,4 @@ -import { - PROVIDER_TYPE_DATA, - ProviderTypeIcon, -} from "@/components/icons/providers-badge/provider-type-icon"; +import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon"; import { cn } from "@/lib/utils"; import { ProviderType } from "@/types"; @@ -16,16 +13,6 @@ export const ProviderIconCell = ({ size = 26, className = "size-8 rounded-md bg-white", }: ProviderIconCellProps) => { - // 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 ( -
- ? -
- ); - } - return (
= ({ + size, + width, + height, + ...props +}) => ( +