From f2d35f58850c82634e46f4ce74b7af3ec22ba275 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:07:22 +0200 Subject: [PATCH 01/26] fix(ui): exclude muted findings and polish filter selectors (#10734) --- ui/CHANGELOG.md | 4 ++ .../findings/findings-by-resource.test.ts | 39 +++++++++++++++++++ ui/actions/findings/findings-by-resource.ts | 4 +- ui/components/findings/findings-filters.tsx | 1 + .../use-resource-detail-drawer.test.ts | 25 ++++++++++++ .../use-resource-detail-drawer.ts | 7 +++- ui/components/providers/providers-filters.tsx | 5 ++- .../shadcn/select/multiselect.test.tsx | 27 +++++++++++++ ui/components/shadcn/select/multiselect.tsx | 11 +++++- .../data-table-filter-custom-batch.test.tsx | 30 +++++++++++++- .../ui/table/data-table-filter-custom.tsx | 16 +++++++- .../use-finding-group-resource-state.test.ts | 15 +++++++ ui/hooks/use-finding-group-resource-state.ts | 1 + ui/types/filters.ts | 1 + 14 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 ui/hooks/use-finding-group-resource-state.test.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 304d934af1..38a74d7dde 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to the **Prowler UI** are documented in this file. - Upgrade React to 19.2.5 and Next.js to 16.2.3 to mitigate CVE-2026-23869 (React2DoS), a high-severity unauthenticated remote DoS vulnerability in the React Flight Protocol's Server Function deserialization [(#10754)](https://github.com/prowler-cloud/prowler/pull/10754) +### 🐞 Fixed + +- Findings and filter UX fixes: exclude muted findings by default in the resource detail drawer and finding group resource views, show category context label (for example `Status: FAIL`) on MultiSelect triggers instead of hiding the placeholder, and add a `wide` width option for filter dropdowns applied to the findings Scan filter to prevent label truncation [(#10734)](https://github.com/prowler-cloud/prowler/pull/10734) + --- ## [1.24.0] (Prowler v5.24.0) diff --git a/ui/actions/findings/findings-by-resource.test.ts b/ui/actions/findings/findings-by-resource.test.ts index 4aff44a2cf..3a045d2e75 100644 --- a/ui/actions/findings/findings-by-resource.test.ts +++ b/ui/actions/findings/findings-by-resource.test.ts @@ -43,6 +43,7 @@ vi.mock("@/actions/finding-groups", () => ({ })); import { + getLatestFindingsByResourceUid, resolveFindingIdsByCheckIds, resolveFindingIdsByVisibleGroupResources, } from "./findings-by-resource"; @@ -262,3 +263,41 @@ describe("resolveFindingIdsByVisibleGroupResources", () => { expect(fetchMock).not.toHaveBeenCalled(); }); }); + +describe("getLatestFindingsByResourceUid", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + handleApiResponseMock.mockResolvedValue({ data: [] }); + }); + + it("should exclude muted findings by default and always apply severity/time sorting", async () => { + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + + await getLatestFindingsByResourceUid({ + resourceUid: "resource-1", + }); + + const calledUrl = new URL(fetchMock.mock.calls[0][0]); + expect(calledUrl.pathname).toBe("/api/v1/findings/latest"); + expect(calledUrl.searchParams.get("filter[resource_uid]")).toBe( + "resource-1", + ); + expect(calledUrl.searchParams.get("filter[muted]")).toBe("false"); + expect(calledUrl.searchParams.get("sort")).toBe("-severity,-updated_at"); + }); + + it("should include muted findings only when explicitly requested", async () => { + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + + await getLatestFindingsByResourceUid({ + resourceUid: "resource-1", + includeMuted: true, + }); + + const calledUrl = new URL(fetchMock.mock.calls[0][0]); + expect(calledUrl.searchParams.get("filter[muted]")).toBe("include"); + expect(calledUrl.searchParams.get("sort")).toBe("-severity,-updated_at"); + }); +}); diff --git a/ui/actions/findings/findings-by-resource.ts b/ui/actions/findings/findings-by-resource.ts index 5a848fff4b..ee6d952cf0 100644 --- a/ui/actions/findings/findings-by-resource.ts +++ b/ui/actions/findings/findings-by-resource.ts @@ -250,10 +250,12 @@ export const getLatestFindingsByResourceUid = async ({ resourceUid, page = 1, pageSize = 50, + includeMuted = false, }: { resourceUid: string; page?: number; pageSize?: number; + includeMuted?: boolean; }) => { const headers = await getAuthHeaders({ contentType: false }); @@ -262,7 +264,7 @@ export const getLatestFindingsByResourceUid = async ({ ); url.searchParams.append("filter[resource_uid]", resourceUid); - url.searchParams.append("filter[muted]", "include"); + url.searchParams.append("filter[muted]", includeMuted ? "include" : "false"); url.searchParams.append("sort", "-severity,-updated_at"); if (page) url.searchParams.append("page[number]", page.toString()); if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); diff --git a/ui/components/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx index 6316e49203..61008d311a 100644 --- a/ui/components/findings/findings-filters.tsx +++ b/ui/components/findings/findings-filters.tsx @@ -132,6 +132,7 @@ export const FindingsFilters = ({ key: FilterType.SCAN, labelCheckboxGroup: "Scan ID", values: completedScanIds, + width: "wide" as const, valueLabelMapping: scanDetails, labelFormatter: (value: string) => getFindingsFilterDisplayValue(`filter[${FilterType.SCAN}]`, value, { diff --git a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts index 797676562f..b3729fd2cc 100644 --- a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts +++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts @@ -270,6 +270,31 @@ describe("useResourceDetailDrawer β€” other findings filtering", () => { ]); }); + it("should request muted findings only when explicitly enabled", async () => { + const resources = [makeResource()]; + + getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] }); + adaptFindingsByResourceResponseMock.mockReturnValue([makeDrawerFinding()]); + + const { result } = renderHook(() => + useResourceDetailDrawer({ + resources, + checkId: "s3_check", + includeMutedInOtherFindings: true, + }), + ); + + await act(async () => { + result.current.openDrawer(0); + await Promise.resolve(); + }); + + expect(getLatestFindingsByResourceUidMock).toHaveBeenCalledWith({ + resourceUid: "arn:aws:s3:::my-bucket", + includeMuted: true, + }); + }); + it("should keep isNavigating true for a cached resource long enough to render skeletons", async () => { vi.useFakeTimers(); diff --git a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts index 687d9ac739..3affd7d38a 100644 --- a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts +++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts @@ -47,6 +47,7 @@ interface UseResourceDetailDrawerOptions { totalResourceCount?: number; onRequestMoreResources?: () => void; initialIndex?: number | null; + includeMutedInOtherFindings?: boolean; } interface UseResourceDetailDrawerReturn { @@ -79,6 +80,7 @@ export function useResourceDetailDrawer({ totalResourceCount, onRequestMoreResources, initialIndex = null, + includeMutedInOtherFindings = false, }: UseResourceDetailDrawerOptions): UseResourceDetailDrawerReturn { const [isOpen, setIsOpen] = useState(initialIndex !== null); const [isLoading, setIsLoading] = useState(false); @@ -165,7 +167,10 @@ export function useResourceDetailDrawer({ setIsLoading(true); try { - const response = await getLatestFindingsByResourceUid({ resourceUid }); + const response = await getLatestFindingsByResourceUid({ + resourceUid, + includeMuted: includeMutedInOtherFindings, + }); // Discard stale response if a newer request was started if (controller.signal.aborted) return; diff --git a/ui/components/providers/providers-filters.tsx b/ui/components/providers/providers-filters.tsx index c5eebad74e..d50c90f291 100644 --- a/ui/components/providers/providers-filters.tsx +++ b/ui/components/providers/providers-filters.tsx @@ -125,7 +125,10 @@ export const ProvidersFilters = ({ placeholder={`All ${filter.labelCheckboxGroup}`} /> - + Select All {filter.values.map((value) => { diff --git a/ui/components/shadcn/select/multiselect.test.tsx b/ui/components/shadcn/select/multiselect.test.tsx index e87804ab4f..d5e497e8ce 100644 --- a/ui/components/shadcn/select/multiselect.test.tsx +++ b/ui/components/shadcn/select/multiselect.test.tsx @@ -47,6 +47,33 @@ describe("MultiSelect", () => { expect( within(screen.getByRole("combobox")).getByText("Production AWS"), ).toBeInTheDocument(); + expect( + within(screen.getByRole("combobox")).queryByText("Select accounts"), + ).not.toBeInTheDocument(); + }); + + it("keeps the filter label context when a value is selected", () => { + render( + {}}> + + + + + FAIL + PASS + + , + ); + + expect( + within(screen.getByRole("combobox")).getByText("Status"), + ).toBeInTheDocument(); + expect( + within(screen.getByRole("combobox")).getByText("FAIL"), + ).toBeInTheDocument(); + expect( + within(screen.getByRole("combobox")).queryByText("All Status"), + ).not.toBeInTheDocument(); }); it("filters items without crashing when search is enabled", async () => { diff --git a/ui/components/shadcn/select/multiselect.tsx b/ui/components/shadcn/select/multiselect.tsx index 1658a3c20e..e7dca260db 100644 --- a/ui/components/shadcn/select/multiselect.tsx +++ b/ui/components/shadcn/select/multiselect.tsx @@ -163,6 +163,10 @@ export function MultiSelectValue({ const shouldWrap = overflowBehavior === "wrap" || (overflowBehavior === "wrap-when-open" && open); + const selectedContextLabel = + placeholder && /^All\s+/i.test(placeholder) && selectedValues.size > 0 + ? placeholder.replace(/^All\s+/i, "").trim() + : ""; const checkOverflow = useCallback(() => { if (valueRef.current === null) return; @@ -222,11 +226,16 @@ export function MultiSelectValue({ className, )} > - {placeholder && ( + {placeholder && selectedValues.size === 0 && ( {placeholder} )} + {selectedContextLabel && ( + + {selectedContextLabel} + + )} {Array.from(selectedValues) .filter((value) => items.has(value)) .map((value) => ( diff --git a/ui/components/ui/table/data-table-filter-custom-batch.test.tsx b/ui/components/ui/table/data-table-filter-custom-batch.test.tsx index f108c9d70b..a8a629d24c 100644 --- a/ui/components/ui/table/data-table-filter-custom-batch.test.tsx +++ b/ui/components/ui/table/data-table-filter-custom-batch.test.tsx @@ -62,8 +62,16 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({ MultiSelectValue: ({ placeholder }: { placeholder: string }) => ( {placeholder} ), - MultiSelectContent: ({ children }: { children: React.ReactNode }) => ( - <>{children} + MultiSelectContent: ({ + children, + width, + }: { + children: React.ReactNode; + width?: string; + }) => ( +
+ {children} +
), MultiSelectSelectAll: ({ children }: { children: React.ReactNode }) => ( @@ -114,6 +122,13 @@ const severityFilter: FilterOption = { values: ["critical", "high"], }; +const scanFilter: FilterOption = { + key: "filter[scan__in]", + labelCheckboxGroup: "Scan ID", + values: ["scan-1"], + width: "wide", +}; + describe("DataTableFilterCustom β€” batch vs instant mode", () => { beforeEach(() => { vi.clearAllMocks(); @@ -275,4 +290,15 @@ describe("DataTableFilterCustom β€” batch vs instant mode", () => { expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument(); }); }); + + describe("dropdown width", () => { + it("should propagate the filter width to the dropdown content", () => { + render(); + + expect(screen.getByTestId("multiselect-content")).toHaveAttribute( + "data-width", + "wide", + ); + }); + }); }); diff --git a/ui/components/ui/table/data-table-filter-custom.tsx b/ui/components/ui/table/data-table-filter-custom.tsx index af302592da..da95dd7aff 100644 --- a/ui/components/ui/table/data-table-filter-custom.tsx +++ b/ui/components/ui/table/data-table-filter-custom.tsx @@ -16,6 +16,7 @@ import { import { EntityInfo } from "@/components/ui/entities/entity-info"; import { useUrlFilters } from "@/hooks/use-url-filters"; import { isConnectionStatus, isScanEntity } from "@/lib/helper-filters"; +import { cn } from "@/lib/utils"; import { FilterEntity, FilterOption, @@ -29,6 +30,8 @@ export interface DataTableFilterCustomProps { filters: FilterOption[]; /** Optional element to render at the start of the filters grid */ prependElement?: React.ReactNode; + /** Optional className override for the filters grid layout */ + gridClassName?: string; /** Hide the clear filters button and active badges (useful when parent manages this) */ hideClearButton?: boolean; /** @@ -54,6 +57,7 @@ export interface DataTableFilterCustomProps { export const DataTableFilterCustom = ({ filters, prependElement, + gridClassName, hideClearButton = false, mode = DATA_TABLE_FILTER_MODE.INSTANT, onBatchChange, @@ -173,7 +177,12 @@ export const DataTableFilterCustom = ({ }; return ( -
+
{prependElement} {sortedFilters().map((filter) => { const selectedValues = getSelectedValues(filter); @@ -189,7 +198,10 @@ export const DataTableFilterCustom = ({ placeholder={`All ${filter.labelCheckboxGroup}`} /> - + Select All {filter.values.map((value) => { diff --git a/ui/hooks/use-finding-group-resource-state.test.ts b/ui/hooks/use-finding-group-resource-state.test.ts new file mode 100644 index 0000000000..789ea592c2 --- /dev/null +++ b/ui/hooks/use-finding-group-resource-state.test.ts @@ -0,0 +1,15 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("useFindingGroupResourceState", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "use-finding-group-resource-state.ts"); + const source = readFileSync(filePath, "utf8"); + + it("enables muted findings only for the finding-group resource drawer", () => { + expect(source).toContain("includeMutedInOtherFindings: true"); + }); +}); diff --git a/ui/hooks/use-finding-group-resource-state.ts b/ui/hooks/use-finding-group-resource-state.ts index 6374a5319b..f313bb77b0 100644 --- a/ui/hooks/use-finding-group-resource-state.ts +++ b/ui/hooks/use-finding-group-resource-state.ts @@ -83,6 +83,7 @@ export function useFindingGroupResourceState({ checkId: group.checkId, totalResourceCount: totalCount ?? group.resourcesTotal, onRequestMoreResources: loadMore, + includeMutedInOtherFindings: true, }); const handleDrawerMuteComplete = () => { diff --git a/ui/types/filters.ts b/ui/types/filters.ts index 40e1c7b7a0..ccaf7f2c53 100644 --- a/ui/types/filters.ts +++ b/ui/types/filters.ts @@ -15,6 +15,7 @@ export interface FilterOption { key: string; labelCheckboxGroup: string; values: string[]; + width?: "default" | "wide"; valueLabelMapping?: Array<{ [uid: string]: FilterEntity }>; labelFormatter?: (value: string) => string; index?: number; From 19c752c1273065c6ae3ca6d93047308f630cbca6 Mon Sep 17 00:00:00 2001 From: Andoni Alonso <14891798+andoniaf@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:23:31 +0200 Subject: [PATCH 02/26] fix(cloudflare): guard validate_credentials against paginator infinite loops (#10771) --- prowler/CHANGELOG.md | 1 + .../cloudflare/cloudflare_provider.py | 21 +++++++++++++++-- .../cloudflare/cloudflare_provider_test.py | 23 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index a4b292b9dd..9a3369b8fb 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Cloudflare account-scoped API tokens failing connection test in the App with `CloudflareUserTokenRequiredError` [(#10723)](https://github.com/prowler-cloud/prowler/pull/10723) - `prowler image --registry` failing with `ImageNoImagesProvidedError` due to registry arguments not being forwarded to `ImageProvider` in `init_global_provider` [(#10470)](https://github.com/prowler-cloud/prowler/pull/10470) - Google Workspace Calendar checks false FAIL on unconfigured settings with secure Google defaults [(#10726)](https://github.com/prowler-cloud/prowler/pull/10726) +- Cloudflare `validate_credentials` can hang in an infinite pagination loop when the SDK repeats accounts, blocking connection tests [(#10771)](https://github.com/prowler-cloud/prowler/pull/10771) --- diff --git a/prowler/providers/cloudflare/cloudflare_provider.py b/prowler/providers/cloudflare/cloudflare_provider.py index 35067c30d8..48763df395 100644 --- a/prowler/providers/cloudflare/cloudflare_provider.py +++ b/prowler/providers/cloudflare/cloudflare_provider.py @@ -274,8 +274,12 @@ class CloudflareProvider(Provider): for account in client.accounts.list(): account_id = getattr(account, "id", None) - # Prevent infinite loop - skip if we've seen this account + # Prevent infinite loop on repeated pages from the SDK paginator if account_id in seen_account_ids: + logger.warning( + "Detected repeated Cloudflare account ID while listing accounts. " + "Stopping pagination to avoid an infinite loop." + ) break seen_account_ids.add(account_id) @@ -395,7 +399,20 @@ class CloudflareProvider(Provider): # Fallback: try accounts.list() try: - accounts = list(client.accounts.list()) + accounts: list = [] + seen_account_ids: set = set() + for account in client.accounts.list(): + account_id = getattr(account, "id", None) + # Prevent infinite loop on repeated pages from the SDK paginator + if account_id in seen_account_ids: + logger.warning( + "Detected repeated Cloudflare account ID while validating credentials. " + "Stopping pagination to avoid an infinite loop." + ) + break + seen_account_ids.add(account_id) + accounts.append(account) + if not accounts: logger.error("CloudflareNoAccountsError: No accounts found") raise CloudflareNoAccountsError( diff --git a/tests/providers/cloudflare/cloudflare_provider_test.py b/tests/providers/cloudflare/cloudflare_provider_test.py index 58b7eb8086..c3ba6d75e2 100644 --- a/tests/providers/cloudflare/cloudflare_provider_test.py +++ b/tests/providers/cloudflare/cloudflare_provider_test.py @@ -433,6 +433,29 @@ class TestCloudflareValidateCredentials: with pytest.raises(CloudflareNoAccountsError): CloudflareProvider.validate_credentials(session) + def test_validate_credentials_breaks_on_repeated_account_ids(self): + """Pagination must stop when the SDK repeats account IDs to avoid infinite loops.""" + + def repeating_accounts(): + account = MagicMock() + account.id = ACCOUNT_ID + while True: + yield account + + mock_client = MagicMock() + mock_client.user.get.side_effect = Exception("Some other error") + mock_client.accounts.list.return_value = repeating_accounts() + + session = CloudflareSession( + client=mock_client, + api_token=API_TOKEN, + api_key=None, + api_email=None, + ) + + # Must return without hanging; repeated IDs break the loop. + CloudflareProvider.validate_credentials(session) + class TestCloudflareTestConnection: """Tests for test_connection method.""" From 577aa14acc5ea30506da77900c2b42b0aab8edd8 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 17 Apr 2026 12:48:57 +0200 Subject: [PATCH 03/26] fix(ui): correct IaC findings counters (#10736) Co-authored-by: alejandrobailo --- ui/CHANGELOG.md | 1 + .../findings-by-resource.adapter.test.ts | 31 + .../findings/findings-by-resource.adapter.ts | 11 +- ui/actions/findings/findings.ts | 10 +- .../table/column-finding-groups.test.tsx | 154 ++- .../findings/table/column-finding-groups.tsx | 77 +- .../findings/table/finding-detail-drawer.tsx | 3 +- .../table/findings-group-drill-down.tsx | 24 +- .../findings/table/findings-group-table.tsx | 3 +- .../table/inline-resource-container.tsx | 7 +- .../inline-resource-container.utils.test.ts | 45 + .../table/inline-resource-container.utils.ts | 33 + .../findings/table/notification-indicator.tsx | 13 +- .../resource-detail-drawer-content.test.tsx | 413 ++++++++- .../resource-detail-drawer-content.tsx | 873 +++++++++++------- .../resource-detail-drawer.tsx | 7 + .../use-resource-detail-drawer.test.ts | 491 ++++++++-- .../use-resource-detail-drawer.ts | 150 +-- ui/hooks/use-finding-group-resource-state.ts | 2 +- ui/lib/findings-groups.test.ts | 115 +++ ui/lib/findings-groups.ts | 105 ++- ui/types/components.ts | 1 + 22 files changed, 2012 insertions(+), 557 deletions(-) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 38a74d7dde..be12653904 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🐞 Fixed - Findings and filter UX fixes: exclude muted findings by default in the resource detail drawer and finding group resource views, show category context label (for example `Status: FAIL`) on MultiSelect triggers instead of hiding the placeholder, and add a `wide` width option for filter dropdowns applied to the findings Scan filter to prevent label truncation [(#10734)](https://github.com/prowler-cloud/prowler/pull/10734) +- Findings grouped view now handles zero-resource IaC counters, refines drawer loading states, and adds provider indicators to finding groups [(#10736)](https://github.com/prowler-cloud/prowler/pull/10736) --- diff --git a/ui/actions/findings/findings-by-resource.adapter.test.ts b/ui/actions/findings/findings-by-resource.adapter.test.ts index b8781f0036..87d37c192c 100644 --- a/ui/actions/findings/findings-by-resource.adapter.test.ts +++ b/ui/actions/findings/findings-by-resource.adapter.test.ts @@ -115,4 +115,35 @@ describe("adaptFindingsByResourceResponse β€” malformed input", () => { expect(result[0].id).toBe("finding-1"); expect(result[0].checkId).toBe("s3_check"); }); + + it("should normalize a single finding response into a one-item drawer array", () => { + // Given β€” getFindingById returns a single JSON:API resource object + const input = { + data: { + id: "finding-1", + attributes: { + uid: "uid-1", + check_id: "s3_check", + status: "FAIL", + severity: "critical", + check_metadata: { + checktitle: "S3 Check", + }, + }, + relationships: { + resources: { data: [] }, + scan: { data: null }, + }, + }, + included: [], + }; + + // When + const result = adaptFindingsByResourceResponse(input); + + // Then + expect(result).toHaveLength(1); + expect(result[0].id).toBe("finding-1"); + expect(result[0].checkTitle).toBe("S3 Check"); + }); }); diff --git a/ui/actions/findings/findings-by-resource.adapter.ts b/ui/actions/findings/findings-by-resource.adapter.ts index 75e8f5a81b..d1685eaa9f 100644 --- a/ui/actions/findings/findings-by-resource.adapter.ts +++ b/ui/actions/findings/findings-by-resource.adapter.ts @@ -165,16 +165,18 @@ type IncludedDict = Record; * then resolves each finding's resource and provider relationships. */ interface JsonApiResponse { - data: FindingApiItem[]; + data: FindingApiItem | FindingApiItem[]; included?: Record[]; } function isJsonApiResponse(value: unknown): value is JsonApiResponse { + const data = (value as { data?: unknown })?.data; + return ( value !== null && typeof value === "object" && "data" in value && - Array.isArray((value as { data: unknown }).data) + (Array.isArray(data) || (data !== null && typeof data === "object")) ); } @@ -188,8 +190,11 @@ export function adaptFindingsByResourceResponse( const resourcesDict = createDict("resources", apiResponse) as IncludedDict; const scansDict = createDict("scans", apiResponse) as IncludedDict; const providersDict = createDict("providers", apiResponse) as IncludedDict; + const findings = Array.isArray(apiResponse.data) + ? apiResponse.data + : [apiResponse.data]; - return apiResponse.data.map((item) => { + return findings.map((item) => { const attrs = item.attributes; const meta = (attrs.check_metadata || {}) as Record; const remediationRaw = meta.remediation as diff --git a/ui/actions/findings/findings.ts b/ui/actions/findings/findings.ts index 7ce7f931ad..242bd007a8 100644 --- a/ui/actions/findings/findings.ts +++ b/ui/actions/findings/findings.ts @@ -141,7 +141,15 @@ export const getLatestMetadataInfo = async ({ } }; -export const getFindingById = async (findingId: string, include = "") => { +interface GetFindingByIdOptions { + source?: "resource-detail-drawer"; +} + +export const getFindingById = async ( + findingId: string, + include = "", + _options?: GetFindingByIdOptions, +) => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/findings/${findingId}`); diff --git a/ui/components/findings/table/column-finding-groups.test.tsx b/ui/components/findings/table/column-finding-groups.test.tsx index 3f2568db51..eefbcb5a45 100644 --- a/ui/components/findings/table/column-finding-groups.test.tsx +++ b/ui/components/findings/table/column-finding-groups.test.tsx @@ -78,6 +78,18 @@ vi.mock("./notification-indicator", () => ({ }, })); +vi.mock("@/components/shadcn/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("./provider-icon-cell", () => ({ + ProviderIconCell: ({ provider }: { provider: string }) => ( + {provider} + ), +})); + // --------------------------------------------------------------------------- // Import after mocks // --------------------------------------------------------------------------- @@ -148,6 +160,26 @@ function renderFindingCell( render(
{CellComponent({ row: { original: group } })}
); } +function renderFindingGroupTitleCell(overrides?: Partial) { + const columns = getColumnFindingGroups({ + rowSelection: {}, + selectableRowCount: 1, + onDrillDown: vi.fn(), + }); + + const findingColumn = columns.find( + (col) => (col as { accessorKey?: string }).accessorKey === "finding", + ); + if (!findingColumn?.cell) throw new Error("finding column not found"); + + const group = makeGroup(overrides); + const CellComponent = findingColumn.cell as (props: { + row: { original: FindingGroupRow }; + }) => ReactNode; + + render(
{CellComponent({ row: { original: group } })}
); +} + function renderImpactedResourcesCell(overrides?: Partial) { const columns = getColumnFindingGroups({ rowSelection: {}, @@ -171,11 +203,13 @@ function renderImpactedResourcesCell(overrides?: Partial) { } function renderSelectCell(overrides?: Partial) { + const onDrillDown = + vi.fn<(checkId: string, group: FindingGroupRow) => void>(); const toggleSelected = vi.fn(); const columns = getColumnFindingGroups({ rowSelection: {}, selectableRowCount: 1, - onDrillDown: vi.fn(), + onDrillDown, }); const selectColumn = columns.find( @@ -206,7 +240,7 @@ function renderSelectCell(overrides?: Partial) {
, ); - return { toggleSelected }; + return { onDrillDown, toggleSelected }; } // --------------------------------------------------------------------------- @@ -231,6 +265,15 @@ describe("column-finding-groups β€” accessibility of check title cell", () => { expect(impactedProvidersColumn).toBeUndefined(); }); + it("should render the first provider icon with its provider name", () => { + // Given + renderFindingGroupTitleCell({ providers: ["iac"] }); + + // Then + expect(screen.getByTestId("provider-icon-iac")).toBeInTheDocument(); + expect(screen.getByText("Infrastructure as Code")).toBeInTheDocument(); + }); + it("should render the check title as a button element (not a

)", () => { // Given const onDrillDown = @@ -332,6 +375,47 @@ describe("column-finding-groups β€” accessibility of check title cell", () => { }), ); }); + + it("should keep zero-resource fallback groups non-clickable even when fallback counts are present", () => { + // Given + const onDrillDown = + vi.fn<(checkId: string, group: FindingGroupRow) => void>(); + + renderFindingCell("Fallback IaC Check", onDrillDown, { + resourcesTotal: 0, + resourcesFail: 0, + failCount: 0, + passCount: 2, + manualCount: 1, + }); + + // Then + expect( + screen.queryByRole("button", { name: "Fallback IaC Check" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("Fallback IaC Check")).toBeInTheDocument(); + expect(onDrillDown).not.toHaveBeenCalled(); + }); + + it("should keep fallback groups non-clickable when the displayed total is zero", () => { + // Given + const onDrillDown = + vi.fn<(checkId: string, group: FindingGroupRow) => void>(); + + // When + renderFindingCell("No failing findings", onDrillDown, { + resourcesTotal: 0, + resourcesFail: 0, + failCount: 0, + passCount: 0, + }); + + // Then + expect( + screen.queryByRole("button", { name: "No failing findings" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("No failing findings")).toBeInTheDocument(); + }); }); describe("column-finding-groups β€” impacted resources count", () => { @@ -345,6 +429,36 @@ describe("column-finding-groups β€” impacted resources count", () => { // Then expect(screen.getByText("3/5")).toBeInTheDocument(); }); + + it("should fall back to finding counts when resources total is zero", () => { + // Given/When + renderImpactedResourcesCell({ + resourcesTotal: 0, + resourcesFail: 0, + failCount: 3, + passCount: 2, + muted: false, + }); + + // Then + expect(screen.getByText("3/5")).toBeInTheDocument(); + }); + + it("should include muted findings in the denominator when the row is muted", () => { + // Given/When + renderImpactedResourcesCell({ + resourcesTotal: 0, + resourcesFail: 0, + failCount: 3, + passCount: 2, + failMutedCount: 4, + passMutedCount: 1, + muted: true, + }); + + // Then + expect(screen.getByText("3/10")).toBeInTheDocument(); + }); }); describe("column-finding-groups β€” group selection", () => { @@ -357,6 +471,42 @@ describe("column-finding-groups β€” group selection", () => { expect(screen.getByRole("checkbox", { name: "Select row" })).toBeDisabled(); }); + + it("should hide the chevron for zero-resource fallback groups even when fallback counts are present", () => { + // Given + const { onDrillDown } = renderSelectCell({ + resourcesTotal: 0, + resourcesFail: 0, + failCount: 0, + passCount: 2, + manualCount: 1, + }); + + // Then + expect( + screen.queryByRole("button", { + name: "Expand S3 Bucket Public Access", + }), + ).not.toBeInTheDocument(); + expect(onDrillDown).not.toHaveBeenCalled(); + }); + + it("should hide the chevron for zero-resource groups when the displayed total is zero", () => { + // Given/When + renderSelectCell({ + resourcesTotal: 0, + resourcesFail: 0, + failCount: 0, + passCount: 0, + }); + + // Then + expect( + screen.queryByRole("button", { + name: "Expand S3 Bucket Public Access", + }), + ).not.toBeInTheDocument(); + }); }); describe("column-finding-groups β€” indicators", () => { diff --git a/ui/components/findings/table/column-finding-groups.tsx b/ui/components/findings/table/column-finding-groups.tsx index ffd6f18845..79b584edcc 100644 --- a/ui/components/findings/table/column-finding-groups.tsx +++ b/ui/components/findings/table/column-finding-groups.tsx @@ -4,6 +4,11 @@ import { ColumnDef, RowSelectionState } from "@tanstack/react-table"; import { ChevronRight } from "lucide-react"; import { Checkbox } from "@/components/shadcn"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; import { DataTableColumnHeader, SeverityBadge, @@ -11,15 +16,19 @@ import { } from "@/components/ui/table"; import { cn } from "@/lib"; import { + canDrillDownFindingGroup, getFilteredFindingGroupDelta, + getFindingGroupImpactedCounts, isFindingGroupMuted, } from "@/lib/findings-groups"; import { FindingGroupRow } from "@/types"; +import { getProviderDisplayName } from "@/types/providers"; import { DataTableRowActions } from "./data-table-row-actions"; import { canMuteFindingGroup } from "./finding-group-selection"; import { ImpactedResourcesCell } from "./impacted-resources-cell"; -import { DeltaValues, NotificationIndicator } from "./notification-indicator"; +import { NotificationIndicator } from "./notification-indicator"; +import { ProviderIconCell } from "./provider-icon-cell"; interface GetColumnFindingGroupsOptions { rowSelection: RowSelectionState; @@ -83,14 +92,7 @@ export function getColumnFindingGroups({ const allMuted = isFindingGroupMuted(group); const isExpanded = expandedCheckId === group.checkId; const deltaKey = getFilteredFindingGroupDelta(group, filters); - const delta = - deltaKey === "new" - ? DeltaValues.NEW - : deltaKey === "changed" - ? DeltaValues.CHANGED - : DeltaValues.NONE; - - const canExpand = group.resourcesTotal > 0; + const canExpand = canDrillDownFindingGroup(group); const canSelect = canMuteFindingGroup({ resourcesFail: group.resourcesFail, resourcesTotal: group.resourcesTotal, @@ -101,7 +103,7 @@ export function getColumnFindingGroups({ return (

@@ -175,23 +177,43 @@ export function getColumnFindingGroups({ ), cell: ({ row }) => { const group = row.original; - const canExpand = group.resourcesTotal > 0; + const canExpand = canDrillDownFindingGroup(group); + const provider = group.providers[0]; + const providerName = provider + ? getProviderDisplayName(provider) + : undefined; return ( -
- {canExpand ? ( - - ) : ( - - {group.checkTitle} - - )} +
+ {provider && providerName ? ( + + +
+ +
+
+ {providerName} +
+ ) : null} +
+ {canExpand ? ( + + ) : ( + + {group.checkTitle} + + )} +
); }, @@ -216,10 +238,11 @@ export function getColumnFindingGroups({ ), cell: ({ row }) => { const group = row.original; + const counts = getFindingGroupImpactedCounts(group); return ( ); }, diff --git a/ui/components/findings/table/finding-detail-drawer.tsx b/ui/components/findings/table/finding-detail-drawer.tsx index 5281686d20..93f8dd0438 100644 --- a/ui/components/findings/table/finding-detail-drawer.tsx +++ b/ui/components/findings/table/finding-detail-drawer.tsx @@ -30,7 +30,6 @@ export function FindingDetailDrawer({ }: FindingDetailDrawerProps) { const drawer = useResourceDetailDrawer({ resources: [findingToFindingResourceRow(finding)], - checkId: finding.attributes.check_id, totalResourceCount: 1, initialIndex: defaultOpen || inline ? 0 : null, }); @@ -63,6 +62,7 @@ export function FindingDetailDrawer({ checkMeta={drawer.checkMeta} currentIndex={drawer.currentIndex} totalResources={drawer.totalResources} + currentResource={drawer.currentResource} currentFinding={drawer.currentFinding} otherFindings={drawer.otherFindings} onNavigatePrev={drawer.navigatePrev} @@ -87,6 +87,7 @@ export function FindingDetailDrawer({ checkMeta={drawer.checkMeta} currentIndex={drawer.currentIndex} totalResources={drawer.totalResources} + currentResource={drawer.currentResource} currentFinding={drawer.currentFinding} otherFindings={drawer.otherFindings} onNavigatePrev={drawer.navigatePrev} diff --git a/ui/components/findings/table/findings-group-drill-down.tsx b/ui/components/findings/table/findings-group-drill-down.tsx index 445adb6ec7..2bee7d5667 100644 --- a/ui/components/findings/table/findings-group-drill-down.tsx +++ b/ui/components/findings/table/findings-group-drill-down.tsx @@ -22,6 +22,7 @@ import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource import { cn, hasHistoricalFindingFilter } from "@/lib"; import { getFilteredFindingGroupDelta, + getFindingGroupImpactedCounts, isFindingGroupMuted, } from "@/lib/findings-groups"; import { FindingGroupRow } from "@/types"; @@ -30,7 +31,8 @@ import { FloatingMuteButton } from "../floating-mute-button"; import { getColumnFindingResources } from "./column-finding-resources"; import { FindingsSelectionContext } from "./findings-selection-context"; import { ImpactedResourcesCell } from "./impacted-resources-cell"; -import { DeltaValues, NotificationIndicator } from "./notification-indicator"; +import { getFindingGroupEmptyStateMessage } from "./inline-resource-container.utils"; +import { NotificationIndicator } from "./notification-indicator"; import { ResourceDetailDrawer } from "./resource-detail-drawer"; interface FindingsGroupDrillDownProps { @@ -96,14 +98,8 @@ export function FindingsGroupDrillDown({ // Delta for the sticky header const deltaKey = getFilteredFindingGroupDelta(group, filters); - const delta = - deltaKey === "new" - ? DeltaValues.NEW - : deltaKey === "changed" - ? DeltaValues.CHANGED - : DeltaValues.NONE; - const allMuted = isFindingGroupMuted(group); + const impactedCounts = getFindingGroupImpactedCounts(group); const rows = table.getRowModel().rows; @@ -139,7 +135,7 @@ export function FindingsGroupDrillDown({ {/* Notification indicator */} @@ -159,8 +155,8 @@ export function FindingsGroupDrillDown({ {/* Impacted resources count */}
@@ -209,9 +205,7 @@ export function FindingsGroupDrillDown({ colSpan={columns.length} className="h-24 text-center" > - {Object.keys(filters).length > 0 - ? "No resources found for the selected filters." - : "No resources found."} + {getFindingGroupEmptyStateMessage(group, filters)} ) : null} @@ -248,8 +242,10 @@ export function FindingsGroupDrillDown({ checkMeta={drawer.checkMeta} currentIndex={drawer.currentIndex} totalResources={drawer.totalResources} + currentResource={drawer.currentResource} currentFinding={drawer.currentFinding} otherFindings={drawer.otherFindings} + showSyntheticResourceHint={group.resourcesTotal === 0} onNavigatePrev={drawer.navigatePrev} onNavigateNext={drawer.navigateNext} onMuteComplete={handleDrawerMuteComplete} diff --git a/ui/components/findings/table/findings-group-table.tsx b/ui/components/findings/table/findings-group-table.tsx index 2f3a515415..1459972e22 100644 --- a/ui/components/findings/table/findings-group-table.tsx +++ b/ui/components/findings/table/findings-group-table.tsx @@ -6,6 +6,7 @@ import { useRef, useState } from "react"; import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource"; import { DataTable } from "@/components/ui/table"; +import { canDrillDownFindingGroup } from "@/lib/findings-groups"; import { FindingGroupRow, MetaDataProps } from "@/types"; import { FloatingMuteButton } from "../floating-mute-button"; @@ -140,7 +141,7 @@ export function FindingsGroupTable({ const handleDrillDown = (checkId: string, group: FindingGroupRow) => { // No resources in the group β†’ nothing to show, skip drill-down - if (group.resourcesTotal === 0) return; + if (!canDrillDownFindingGroup(group)) return; // Toggle: same group = collapse, different = switch if (expandedCheckId === checkId) { diff --git a/ui/components/findings/table/inline-resource-container.tsx b/ui/components/findings/table/inline-resource-container.tsx index 47e6d0eb06..2aa13056d9 100644 --- a/ui/components/findings/table/inline-resource-container.tsx +++ b/ui/components/findings/table/inline-resource-container.tsx @@ -20,6 +20,7 @@ import { getColumnFindingResources } from "./column-finding-resources"; import { FindingsSelectionContext } from "./findings-selection-context"; import { getFilteredFindingGroupResourceCount, + getFindingGroupEmptyStateMessage, getFindingGroupSkeletonCount, } from "./inline-resource-container.utils"; import { ResourceDetailDrawer } from "./resource-detail-drawer"; @@ -278,9 +279,7 @@ export function InlineResourceContainer({ colSpan={columns.length} className="h-24 text-center" > - {Object.keys(filters).length > 0 - ? "No resources found for the selected filters." - : "No resources found."} + {getFindingGroupEmptyStateMessage(group, filters)} )} @@ -334,8 +333,10 @@ export function InlineResourceContainer({ checkMeta={drawer.checkMeta} currentIndex={drawer.currentIndex} totalResources={drawer.totalResources} + currentResource={drawer.currentResource} currentFinding={drawer.currentFinding} otherFindings={drawer.otherFindings} + showSyntheticResourceHint={group.resourcesTotal === 0} onNavigatePrev={drawer.navigatePrev} onNavigateNext={drawer.navigateNext} onMuteComplete={handleDrawerMuteComplete} diff --git a/ui/components/findings/table/inline-resource-container.utils.test.ts b/ui/components/findings/table/inline-resource-container.utils.test.ts index 8fac280669..ac1f968521 100644 --- a/ui/components/findings/table/inline-resource-container.utils.test.ts +++ b/ui/components/findings/table/inline-resource-container.utils.test.ts @@ -4,6 +4,7 @@ import type { FindingGroupRow } from "@/types"; import { getFilteredFindingGroupResourceCount, + getFindingGroupEmptyStateMessage, getFindingGroupSkeletonCount, isFailOnlyStatusFilter, } from "./inline-resource-container.utils"; @@ -99,3 +100,47 @@ describe("getFindingGroupSkeletonCount", () => { ).toBe(1); }); }); + +describe("getFindingGroupEmptyStateMessage", () => { + it("returns the muted hint when muted findings are excluded and no visible resources remain", () => { + expect( + getFindingGroupEmptyStateMessage( + makeGroup({ + resourcesTotal: 0, + resourcesFail: 0, + mutedCount: 1, + failCount: 0, + passCount: 0, + }), + { + "filter[status]": "FAIL", + "filter[muted]": "false", + }, + ), + ).toBe( + "No resources match the current filters. Try enabling Include muted to view muted findings.", + ); + }); + + it("keeps the generic filtered empty state when muted findings are already included", () => { + expect( + getFindingGroupEmptyStateMessage( + makeGroup({ + resourcesTotal: 0, + resourcesFail: 0, + mutedCount: 1, + }), + { + "filter[status]": "FAIL", + "filter[muted]": "include", + }, + ), + ).toBe("No resources found for the selected filters."); + }); + + it("keeps the generic empty state when no filters are active", () => { + expect(getFindingGroupEmptyStateMessage(makeGroup(), {})).toBe( + "No resources found.", + ); + }); +}); diff --git a/ui/components/findings/table/inline-resource-container.utils.ts b/ui/components/findings/table/inline-resource-container.utils.ts index 9967ea7da3..274304e03f 100644 --- a/ui/components/findings/table/inline-resource-container.utils.ts +++ b/ui/components/findings/table/inline-resource-container.utils.ts @@ -33,6 +33,18 @@ export function isFailOnlyStatusFilter( return multiStatusValues.length === 1 && multiStatusValues[0] === "FAIL"; } +function includesMutedFindings( + filters: Record, +): boolean { + const mutedFilter = filters["filter[muted]"]; + + if (Array.isArray(mutedFilter)) { + return mutedFilter.includes("include"); + } + + return mutedFilter === "include"; +} + export function getFilteredFindingGroupResourceCount( group: FindingGroupRow, filters: Record, @@ -53,3 +65,24 @@ export function getFindingGroupSkeletonCount( // empty state ("No resources found") replaces the skeleton. return Math.max(1, Math.min(filteredTotal, maxSkeletonRows)); } + +export function getFindingGroupEmptyStateMessage( + group: FindingGroupRow, + filters: Record, +): string { + const hasFilters = Object.keys(filters).length > 0; + + if (!hasFilters) { + return "No resources found."; + } + + const mutedExcluded = !includesMutedFindings(filters); + const hasMutedFindings = (group.mutedCount ?? 0) > 0; + const visibleCount = getFilteredFindingGroupResourceCount(group, filters); + + if (mutedExcluded && hasMutedFindings && visibleCount === 0) { + return "No resources match the current filters. Try enabling Include muted to view muted findings."; + } + + return "No resources found for the selected filters."; +} diff --git a/ui/components/findings/table/notification-indicator.tsx b/ui/components/findings/table/notification-indicator.tsx index 324e216c57..be50ee1515 100644 --- a/ui/components/findings/table/notification-indicator.tsx +++ b/ui/components/findings/table/notification-indicator.tsx @@ -17,14 +17,11 @@ import { } from "@/components/shadcn/tooltip"; import { DOCS_URLS } from "@/lib/external-urls"; import { cn } from "@/lib/utils"; +import { FINDING_DELTA, type FindingDelta } from "@/types"; -export const DeltaValues = { - NEW: "new", - CHANGED: "changed", - NONE: "none", -} as const; +export const DeltaValues = FINDING_DELTA; -export type DeltaType = (typeof DeltaValues)[keyof typeof DeltaValues]; +export type DeltaType = Exclude; interface NotificationIndicatorProps { delta?: DeltaType; @@ -124,12 +121,12 @@ function MutedIndicator({ mutedReason }: { mutedReason?: string }) { ({ })); vi.mock("@/components/shadcn/skeleton/skeleton", () => ({ - Skeleton: () =>
, + Skeleton: ({ + className, + ...props + }: HTMLAttributes & { className?: string }) => ( +
+ ), })); vi.mock("@/components/shadcn/spinner/spinner", () => ({ @@ -309,6 +314,7 @@ vi.mock("../../muted", () => ({ // --------------------------------------------------------------------------- import type { ResourceDrawerFinding } from "@/actions/findings"; +import type { FindingResourceRow } from "@/types"; import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content"; import type { CheckMeta } from "./use-resource-detail-drawer"; @@ -374,6 +380,29 @@ const mockFinding: ResourceDrawerFinding = { scan: null, }; +const mockResourceRow: FindingResourceRow = { + id: "row-1", + rowType: "resource", + findingId: "finding-1", + checkId: "s3_check", + providerType: "aws", + providerAlias: "prod", + providerUid: "123456789", + resourceName: "my-bucket", + resourceType: "Bucket", + resourceGroup: "default", + resourceUid: "arn:aws:s3:::bucket", + service: "s3", + region: "us-east-1", + severity: "critical", + status: "FAIL", + delta: null, + isMuted: false, + mutedReason: undefined, + firstSeenAt: null, + lastSeenAt: null, +}; + // --------------------------------------------------------------------------- // Fix 1: Lighthouse AI button text change // --------------------------------------------------------------------------- @@ -937,3 +966,385 @@ describe("ResourceDetailDrawerContent β€” other findings mute refresh", () => { expect(onMuteComplete).not.toHaveBeenCalled(); }); }); + +describe("ResourceDetailDrawerContent β€” synthetic resource empty state", () => { + it("should explain that simulated IaC resources never have other findings", () => { + // Given/When + render( + , + ); + + // Then + expect( + screen.getByText( + "No other findings are available for this IaC resource.", + ), + ).toBeInTheDocument(); + }); +}); + +describe("ResourceDetailDrawerContent β€” current resource row display", () => { + it("should render resource card fields from the current resource row instead of the fetched finding", () => { + // Given + const currentResource: FindingResourceRow = { + ...mockResourceRow, + providerAlias: "row-account", + providerUid: "row-provider-uid", + resourceName: "row-resource-name", + resourceUid: "row-resource-uid", + service: "row-service", + region: "eu-west-1", + resourceType: "row-type", + resourceGroup: "row-group", + severity: "low", + status: "PASS", + }; + const fetchedFinding: ResourceDrawerFinding = { + ...mockFinding, + providerAlias: "finding-account", + providerUid: "finding-provider-uid", + resourceName: "finding-resource-name", + resourceUid: "finding-resource-uid", + resourceService: "finding-service", + resourceRegion: "ap-south-1", + resourceType: "finding-type", + resourceGroup: "finding-group", + severity: "critical", + status: "FAIL", + }; + + // When + render( + , + ); + + // Then + expect(screen.getByText("row-service")).toBeInTheDocument(); + expect(screen.getByText("eu-west-1")).toBeInTheDocument(); + expect(screen.getByText("row-group")).toBeInTheDocument(); + expect(screen.getByText("row-type")).toBeInTheDocument(); + expect(screen.getByText("FAIL")).toBeInTheDocument(); + expect(screen.getByText("critical")).toBeInTheDocument(); + expect(screen.queryByText("finding-service")).not.toBeInTheDocument(); + expect(screen.queryByText("ap-south-1")).not.toBeInTheDocument(); + expect(screen.queryByText("finding-group")).not.toBeInTheDocument(); + expect(screen.queryByText("finding-type")).not.toBeInTheDocument(); + }); + + it("should prefer the fetched finding status and severity in the header when the current row is stale", () => { + // Given + const currentResource: FindingResourceRow = { + ...mockResourceRow, + severity: "critical", + status: "FAIL", + isMuted: false, + }; + const fetchedFinding: ResourceDrawerFinding = { + ...mockFinding, + severity: "low", + status: "PASS", + isMuted: true, + mutedReason: "Muted after refresh", + }; + + // When + render( + , + ); + + // Then + expect(screen.getByText("PASS")).toBeInTheDocument(); + expect(screen.getByText("low")).toBeInTheDocument(); + expect(screen.queryByText("FAIL")).not.toBeInTheDocument(); + expect(screen.queryByText("critical")).not.toBeInTheDocument(); + }); +}); + +describe("ResourceDetailDrawerContent β€” header skeleton while navigating", () => { + it("should keep row-backed navigation chrome visible while hiding stale finding details during carousel navigation", () => { + // Given + const currentResource: FindingResourceRow = { + ...mockResourceRow, + checkId: mockCheckMeta.checkId, + resourceName: "next-bucket", + resourceUid: "next-resource-uid", + service: "ec2", + region: "eu-west-1", + resourceType: "Instance", + resourceGroup: "row-group", + severity: "low", + status: "PASS", + findingId: "finding-2", + }; + + // When + render( + , + ); + + // Then + expect(screen.getByText("PASS")).toBeInTheDocument(); + expect(screen.getByText("low")).toBeInTheDocument(); + expect(screen.getByText("ec2")).toBeInTheDocument(); + expect(screen.getByText("eu-west-1")).toBeInTheDocument(); + expect(screen.getByText("row-group")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Finding Overview" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Other Findings For This Resource" }), + ).toBeInTheDocument(); + expect(screen.queryByText("uid-1")).not.toBeInTheDocument(); + expect(screen.queryByText("Status extended")).not.toBeInTheDocument(); + expect(screen.queryByText("FAIL")).not.toBeInTheDocument(); + expect(screen.queryByText("critical")).not.toBeInTheDocument(); + }); + + it("should skeletonize stale check-level header content when navigating to a different check", () => { + // Given + const currentResource: FindingResourceRow = { + ...mockResourceRow, + checkId: "ec2_check", + findingId: "finding-2", + severity: "low", + status: "PASS", + }; + + // When + render( + , + ); + + // Then + expect(screen.getByTestId("drawer-header-skeleton")).toBeInTheDocument(); + expect(screen.queryByText("S3 Check")).not.toBeInTheDocument(); + expect(screen.queryByText("PCI-DSS")).not.toBeInTheDocument(); + expect(screen.getByText("PASS")).toBeInTheDocument(); + expect(screen.getByText("low")).toBeInTheDocument(); + }); + + it("should keep same-check overview sections visible while hiding stale finding-specific details during navigation", () => { + // Given/When + render( + , + ); + + // Then + expect(screen.getByText("Risk:")).toBeInTheDocument(); + expect(screen.getByText("Description:")).toBeInTheDocument(); + expect(screen.getByText("Remediation:")).toBeInTheDocument(); + expect(screen.getByText("security")).toBeInTheDocument(); + expect(screen.queryByText("Status Extended:")).not.toBeInTheDocument(); + expect(screen.queryByText("uid-1")).not.toBeInTheDocument(); + expect( + screen.queryByRole("link", { + name: "Analyze This Finding With Lighthouse AI", + }), + ).not.toBeInTheDocument(); + }); + + it("should keep the overview tab shell visible with section skeletons when navigating to a different check", () => { + // Given + const currentResource: FindingResourceRow = { + ...mockResourceRow, + checkId: "ec2_check", + findingId: "finding-2", + severity: "low", + status: "PASS", + }; + + // When + render( + , + ); + + // Then + expect( + screen.getByTestId("overview-navigation-skeleton"), + ).toBeInTheDocument(); + expect(screen.queryByText("Risk:")).not.toBeInTheDocument(); + expect(screen.queryByText("Description:")).not.toBeInTheDocument(); + expect(screen.queryByText("Remediation:")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Finding Overview" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Other Findings For This Resource" }), + ).toBeInTheDocument(); + }); + + it("should keep other findings table headers visible while skeletonizing only the rows during navigation", () => { + // Given/When + render( + , + ); + + // Then + expect(screen.getByText("Status")).toBeInTheDocument(); + expect(screen.getByText("Finding")).toBeInTheDocument(); + expect(screen.getByText("Severity")).toBeInTheDocument(); + expect(screen.getByText("Time")).toBeInTheDocument(); + expect( + screen.getByTestId("other-findings-total-entries-skeleton"), + ).toBeInTheDocument(); + expect( + screen.getByTestId("other-findings-navigation-skeleton"), + ).toBeInTheDocument(); + }); + + it("should keep scans labels visible while skeletonizing only the scan values during navigation", () => { + // Given/When + render( + , + ); + + // Then + expect( + screen.getByText("Showing the latest scan that evaluated this finding"), + ).toBeInTheDocument(); + expect(screen.getByText("Scan Name")).toBeInTheDocument(); + expect(screen.getByText("Resources Scanned")).toBeInTheDocument(); + expect(screen.getByText("Progress")).toBeInTheDocument(); + expect(screen.getByText("Trigger")).toBeInTheDocument(); + expect(screen.getByText("State")).toBeInTheDocument(); + expect(screen.getByText("Duration")).toBeInTheDocument(); + expect(screen.getByText("Started At")).toBeInTheDocument(); + expect(screen.getByText("Completed At")).toBeInTheDocument(); + expect(screen.getByText("Launched At")).toBeInTheDocument(); + expect(screen.getByText("Scheduled At")).toBeInTheDocument(); + expect(screen.getByTestId("scans-navigation-skeleton")).toBeInTheDocument(); + }); + + it("should keep the events tab shell visible while showing timeline row skeletons during navigation", () => { + // Given/When + render( + , + ); + + // Then + expect(screen.getByRole("button", { name: "Events" })).toBeInTheDocument(); + expect( + screen.getByTestId("events-navigation-skeleton"), + ).toBeInTheDocument(); + }); +}); diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index d488462b14..626f33bfbd 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -70,6 +70,7 @@ import { getFailingForLabel } from "@/lib/date-utils"; import { formatDuration } from "@/lib/date-utils"; import { getRegionFlag } from "@/lib/region-flags"; import type { ComplianceOverviewData } from "@/types/compliance"; +import type { FindingResourceRow } from "@/types/findings-table"; import { Muted } from "../../muted"; import { DeltaIndicator } from "../delta-indicator"; @@ -303,8 +304,10 @@ interface ResourceDetailDrawerContentProps { checkMeta: CheckMeta | null; currentIndex: number; totalResources: number; + currentResource?: FindingResourceRow | null; currentFinding: ResourceDrawerFinding | null; otherFindings: ResourceDrawerFinding[]; + showSyntheticResourceHint?: boolean; onNavigatePrev: () => void; onNavigateNext: () => void; onMuteComplete: () => void; @@ -316,8 +319,10 @@ export function ResourceDetailDrawerContent({ checkMeta, currentIndex, totalResources, + currentResource = null, currentFinding, otherFindings, + showSyntheticResourceHint = false, onNavigatePrev, onNavigateNext, onMuteComplete, @@ -371,8 +376,30 @@ export function ResourceDetailDrawerContent({ } // checkMeta is always available from here. - // currentFinding may be null during resource loading (e.g. drawer reopen). - const f = currentFinding; + // During carousel navigation we only trust row-backed data until the next + // finding payload is fully ready, otherwise stale details flash briefly. + const f = isNavigating ? null : currentFinding; + const isCheckMetaFresh = + !currentResource?.checkId || currentResource.checkId === checkMeta.checkId; + const showCheckMetaContent = !isNavigating || isCheckMetaFresh; + const findingStatus = f?.status ?? currentResource?.status; + const findingSeverity = f?.severity ?? currentResource?.severity; + const findingDelta = f?.delta ?? currentResource?.delta; + const findingIsMuted = f?.isMuted ?? currentResource?.isMuted; + const findingMutedReason = + f?.mutedReason ?? currentResource?.mutedReason ?? "This finding is muted"; + const providerType = currentResource?.providerType ?? f?.providerType; + const providerAlias = currentResource?.providerAlias ?? f?.providerAlias; + const providerUid = currentResource?.providerUid ?? f?.providerUid; + const resourceName = currentResource?.resourceName ?? f?.resourceName; + const resourceUid = currentResource?.resourceUid ?? f?.resourceUid; + const resourceService = currentResource?.service ?? f?.resourceService; + const resourceRegion = currentResource?.region ?? f?.resourceRegion; + const resourceGroup = currentResource?.resourceGroup ?? f?.resourceGroup; + const resourceType = currentResource?.resourceType ?? f?.resourceType; + const resourceRegionLabel = resourceRegion || "-"; + const firstSeenAt = currentResource?.firstSeenAt ?? f?.firstSeenAt ?? null; + const lastSeenAt = currentResource?.lastSeenAt ?? f?.updatedAt ?? null; const hasPrev = currentIndex > 0; const hasNext = currentIndex < totalResources - 1; const selectedScanIds = parseSelectedScanIds( @@ -385,7 +412,9 @@ export function ResourceDetailDrawerContent({ ? (f?.scan?.id ?? null) : null; const regionFilter = searchParams.get("filter[region__in]"); - const nativeIacConfig = resolveNativeIacConfig(f?.providerType); + const nativeIacConfig = resolveNativeIacConfig(providerType); + const showOverviewCheckMetaContent = showCheckMetaContent; + const showOverviewFindingContent = Boolean(f); const handleOpenCompliance = async (framework: string) => { if (!complianceScanId || resolvingFramework) { @@ -448,104 +477,130 @@ export function ResourceDetailDrawerContent({ /> )} - {/* Header: status badges + title (check-level from checkMeta) */} + {/* Header: keep row-backed badges visible; only hide stale check metadata */}
- {f && } - {f && } - {f?.delta && ( + {findingStatus && ( + + )} + {findingSeverity && } + {findingDelta && (
- + - {f.delta} + {findingDelta}
)} - {f && ( - + {findingIsMuted !== undefined && ( + )}
-

- {checkMeta.checkTitle} -

+ {showCheckMetaContent ? ( + <> +

+ {checkMeta.checkTitle} +

- {checkMeta.complianceFrameworks.length > 0 && ( -
- - Compliance Frameworks: - -
- {checkMeta.complianceFrameworks.map((framework) => { - const icon = getComplianceIcon(framework); - const isNavigable = Boolean(complianceScanId); - const isResolving = resolvingFramework === framework; + {checkMeta.complianceFrameworks.length > 0 && ( +
+ + Compliance Frameworks: + +
+ {checkMeta.complianceFrameworks.map((framework) => { + const icon = getComplianceIcon(framework); + const isNavigable = Boolean(complianceScanId); + const isResolving = resolvingFramework === framework; - return icon ? ( - - - {isNavigable ? ( - + ) : ( +
+ {framework} +
)} - - ) : ( -
- {framework} -
- )} -
- {framework} -
- ) : ( - - - {isNavigable ? ( - + ) : ( + + {framework} + )} - - ) : ( - - {framework} - - )} - - {framework} - - ); - })} + + {framework} + + ); + })} +
+
+ )} + + ) : ( + )} @@ -584,7 +639,7 @@ export function ResourceDetailDrawerContent({ {/* Resource card */}
{/* Resource info β€” shows loading when currentFinding is not yet available */} - {!f || isNavigating ? ( + {!currentResource && !f ? ( ) : ( <> @@ -593,95 +648,110 @@ export function ResourceDetailDrawerContent({
{/* Row 1: Account, Resource, Service, Region */} } - entityAlias={f.providerAlias} - entityId={f.providerUid} + entityAlias={providerAlias} + entityId={providerUid} /> } - entityAlias={f.resourceName} - entityId={f.resourceUid} + entityAlias={resourceName} + entityId={resourceUid} idLabel="UID" /> - {f.resourceService} + {resourceService} - {getRegionFlag(f.resourceRegion) && ( + {getRegionFlag(resourceRegionLabel) && ( - {getRegionFlag(f.resourceRegion)} + {getRegionFlag(resourceRegionLabel)} )} - {f.resourceRegion} + {resourceRegionLabel} {/* Row 2: Dates */} - + - + - {getFailingForLabel(f.firstSeenAt) || "-"} + {getFailingForLabel(firstSeenAt) || "-"} - {f.resourceGroup || "-"} + {resourceGroup || "-"} {/* Row 3: IDs */} - + {currentResource?.findingId || f?.id ? ( + + ) : ( + + )} - + {f?.uid ? ( + + ) : ( + + )} {/* Row 4: Resource metadata */} - {f.resourceType || "-"} + {resourceType || "-"}
{/* Actions button β€” fixed size, aligned with row 1 */}
- - - ) : ( - - ) - } - label={f.isMuted ? "Muted" : "Mute"} - disabled={f.isMuted} - onSelect={() => setIsMuteModalOpen(true)} - /> - } - label="Send to Jira" - onSelect={() => setIsJiraModalOpen(true)} - /> - + {f ? ( + + + ) : ( + + ) + } + label={f.isMuted ? "Muted" : "Mute"} + disabled={f.isMuted} + onSelect={() => setIsMuteModalOpen(true)} + /> + } + label="Send to Jira" + onSelect={() => setIsJiraModalOpen(true)} + /> + + ) : ( + + )}
@@ -708,162 +778,170 @@ export function ResourceDetailDrawerContent({ value="overview" className="minimal-scrollbar flex flex-col gap-4 overflow-y-auto" > - {/* Card 1: Risk + Description + Status Extended */} - {(checkMeta.risk || checkMeta.description || f?.statusExtended) && ( - - {checkMeta.risk && ( - - - Risk: - - {checkMeta.risk} - - )} - {checkMeta.description && ( -
- - Description: - - - {checkMeta.description} - -
- )} - {f?.statusExtended && ( -
- - Status Extended: - -

- {f.statusExtended} -

-
- )} -
- )} - - {/* Card 2: Remediation + Commands */} - {(checkMeta.remediation.recommendation.text || - checkMeta.remediation.code.cli || - checkMeta.remediation.code.terraform || - checkMeta.remediation.code.nativeiac) && ( - - {checkMeta.remediation.recommendation.text && ( -
- - Remediation: - -
-
+ {showOverviewCheckMetaContent ? ( + <> + {/* Card 1: Risk + Description + Status Extended */} + {(checkMeta.risk || + checkMeta.description || + showOverviewFindingContent) && ( + + {checkMeta.risk && ( + + + Risk: + + {checkMeta.risk} + + )} + {checkMeta.description && ( +
+ + Description: + - {checkMeta.remediation.recommendation.text} + {checkMeta.description}
- {checkMeta.remediation.recommendation.url && ( - - View in Prowler Hub - - )} + )} + {showOverviewFindingContent && f?.statusExtended && ( +
+ + Status Extended: + +

+ {f.statusExtended} +

+
+ )} +
+ )} + + {/* Card 2: Remediation + Commands */} + {(checkMeta.remediation.recommendation.text || + checkMeta.remediation.code.cli || + checkMeta.remediation.code.terraform || + checkMeta.remediation.code.nativeiac) && ( + + {checkMeta.remediation.recommendation.text && ( +
+ + Remediation: + +
+
+ + {checkMeta.remediation.recommendation.text} + +
+ {checkMeta.remediation.recommendation.url && ( + + View in Prowler Hub + + )} +
+
+ )} + + {checkMeta.remediation.code.cli && ( +
+ {renderRemediationCodeBlock({ + label: "CLI Command", + language: QUERY_EDITOR_LANGUAGE.SHELL, + value: `$ ${stripCodeFences(checkMeta.remediation.code.cli)}`, + copyValue: stripCodeFences( + checkMeta.remediation.code.cli, + ), + })} +
+ )} + + {checkMeta.remediation.code.terraform && ( +
+ {renderRemediationCodeBlock({ + label: "Terraform", + language: QUERY_EDITOR_LANGUAGE.HCL, + value: stripCodeFences( + checkMeta.remediation.code.terraform, + ), + })} +
+ )} + + {checkMeta.remediation.code.nativeiac && providerType && ( +
+ {renderRemediationCodeBlock({ + label: nativeIacConfig.label, + language: nativeIacConfig.language, + value: stripCodeFences( + checkMeta.remediation.code.nativeiac, + ), + })} +
+ )} + + {checkMeta.remediation.code.other && ( +
+ + Remediation Steps: + + + {checkMeta.remediation.code.other} + +
+ )} +
+ )} + + {checkMeta.additionalUrls.length > 0 && ( + +
+ + References: + +
    + {checkMeta.additionalUrls.map((link, idx) => ( +
  • + + {link} + +
  • + ))} +
-
+ )} - {checkMeta.remediation.code.cli && ( -
- {renderRemediationCodeBlock({ - label: "CLI Command", - language: QUERY_EDITOR_LANGUAGE.SHELL, - value: `$ ${stripCodeFences(checkMeta.remediation.code.cli)}`, - copyValue: stripCodeFences( - checkMeta.remediation.code.cli, - ), - })} -
+ {checkMeta.categories.length > 0 && ( + +
+ + Categories: + +
+ {checkMeta.categories.map((category) => ( + + {category} + + ))} +
+
+
)} - - {checkMeta.remediation.code.terraform && ( -
- {renderRemediationCodeBlock({ - label: "Terraform", - language: QUERY_EDITOR_LANGUAGE.HCL, - value: stripCodeFences( - checkMeta.remediation.code.terraform, - ), - })} -
- )} - - {checkMeta.remediation.code.nativeiac && f && ( -
- {renderRemediationCodeBlock({ - label: nativeIacConfig.label, - language: nativeIacConfig.language, - value: stripCodeFences( - checkMeta.remediation.code.nativeiac, - ), - })} -
- )} - - {checkMeta.remediation.code.other && ( -
- - Remediation Steps: - - - {checkMeta.remediation.code.other} - -
- )} - - )} - - {checkMeta.additionalUrls.length > 0 && ( - -
- - References: - -
    - {checkMeta.additionalUrls.map((link, idx) => ( -
  • - - {link} - -
  • - ))} -
-
-
- )} - - {checkMeta.categories.length > 0 && ( - -
- - Categories: - -
- {checkMeta.categories.map((category) => ( - - {category} - - ))} -
-
-
+ + ) : ( + )} @@ -872,14 +950,21 @@ export function ResourceDetailDrawerContent({ value="other-findings" className="minimal-scrollbar flex flex-col gap-2 overflow-y-auto" > - {!f || isNavigating ? ( + {!f && !isNavigating ? ( ) : ( <>
- - {otherFindings.length} Total Entries - + {isNavigating ? ( + + ) : ( + + {otherFindings.length} Total Entries + + )}
@@ -910,7 +995,9 @@ export function ResourceDetailDrawerContent({ - {otherFindings.length > 0 ? ( + {isNavigating ? ( + + ) : otherFindings.length > 0 ? ( otherFindings.map((finding) => ( - No other findings for this resource. + {showSyntheticResourceHint + ? "No other findings are available for this IaC resource." + : "No other findings for this resource."} @@ -942,7 +1031,13 @@ export function ResourceDetailDrawerContent({ {/* Scans Tab */} - {f?.scan ? ( + {!f && !isNavigating ? ( +

+ Scan information is not available. +

+ ) : isNavigating ? ( + + ) : ( <>

@@ -950,7 +1045,7 @@ export function ResourceDetailDrawerContent({

- {f.scan.name || "N/A"} + {f?.scan?.name || "N/A"} - {f.scan.uniqueResourceCount} + {f?.scan?.uniqueResourceCount} - {f.scan.progress}% + {f?.scan?.progress}%
- {f.scan.trigger} + {f?.scan?.trigger} - {f.scan.state} + {f?.scan?.state} - {formatDuration(f.scan.duration)} + {f?.scan?.duration !== undefined + ? formatDuration(f.scan.duration) + : "-"}
- + - +
- + - {f.scan.scheduledAt && ( + {f?.scan?.scheduledAt && ( )}
- ) : ( -

- Scan information is not available. -

)}
@@ -1012,25 +1111,165 @@ export function ResourceDetailDrawerContent({ value="events" className="flex min-h-0 flex-1 flex-col gap-4" > - + {isNavigating ? ( + + ) : ( + <> + + + )} {/* Lighthouse AI button */} - - - Analyze This Finding With Lighthouse AI - + {!isNavigating && ( + + + Analyze This Finding With Lighthouse AI + + )} + + ); +} + +function OverviewNavigationSkeleton() { + return ( +
+ + + + + + + + + +
+ ); +} + +function OverviewCardSkeleton({ lineWidths }: { lineWidths: string[] }) { + return ( + + ); +} + +function OtherFindingsNavigationSkeletonRows() { + return ( + <> + {Array.from({ length: 3 }).map((_, index) => ( + + ))} + + ); +} + +function ScansNavigationSkeleton() { + return ( + + ); +} + +function ScansInfoGridSkeleton({ labels }: { labels: string[] }) { + const columnCount = labels.length; + + return ( +
+ {labels.map((label, index) => ( +
+ {label} + +
+ ))} +
+ ); +} + +function EventsNavigationSkeleton() { + return ( + ); } diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx index b8837ea596..65ad4e734c 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.tsx @@ -11,6 +11,7 @@ import { DrawerHeader, DrawerTitle, } from "@/components/shadcn"; +import type { FindingResourceRow } from "@/types"; import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content"; import type { CheckMeta } from "./use-resource-detail-drawer"; @@ -23,8 +24,10 @@ interface ResourceDetailDrawerProps { checkMeta: CheckMeta | null; currentIndex: number; totalResources: number; + currentResource: FindingResourceRow | null; currentFinding: ResourceDrawerFinding | null; otherFindings: ResourceDrawerFinding[]; + showSyntheticResourceHint?: boolean; onNavigatePrev: () => void; onNavigateNext: () => void; onMuteComplete: () => void; @@ -38,8 +41,10 @@ export function ResourceDetailDrawer({ checkMeta, currentIndex, totalResources, + currentResource, currentFinding, otherFindings, + showSyntheticResourceHint = false, onNavigatePrev, onNavigateNext, onMuteComplete, @@ -64,8 +69,10 @@ export function ResourceDetailDrawer({ checkMeta={checkMeta} currentIndex={currentIndex} totalResources={totalResources} + currentResource={currentResource} currentFinding={currentFinding} otherFindings={otherFindings} + showSyntheticResourceHint={showSyntheticResourceHint} onNavigatePrev={onNavigatePrev} onNavigateNext={onNavigateNext} onMuteComplete={onMuteComplete} diff --git a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts index b3729fd2cc..f767184598 100644 --- a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts +++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts @@ -6,14 +6,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; // --------------------------------------------------------------------------- const { + getFindingByIdMock, getLatestFindingsByResourceUidMock, adaptFindingsByResourceResponseMock, } = vi.hoisted(() => ({ + getFindingByIdMock: vi.fn(), getLatestFindingsByResourceUidMock: vi.fn(), adaptFindingsByResourceResponseMock: vi.fn(), })); vi.mock("@/actions/findings", () => ({ + getFindingById: getFindingByIdMock, getLatestFindingsByResourceUid: getLatestFindingsByResourceUidMock, adaptFindingsByResourceResponse: adaptFindingsByResourceResponseMock, })); @@ -109,6 +112,7 @@ describe("useResourceDetailDrawer β€” unmount cleanup", () => { beforeEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); + getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] }); }); it("should abort the in-flight fetch controller when the hook unmounts", async () => { @@ -116,9 +120,7 @@ describe("useResourceDetailDrawer β€” unmount cleanup", () => { const abortSpy = vi.spyOn(AbortController.prototype, "abort"); // never-resolving fetch to simulate in-flight request - getLatestFindingsByResourceUidMock.mockImplementation( - () => new Promise(() => {}), - ); + getFindingByIdMock.mockImplementation(() => new Promise(() => {})); adaptFindingsByResourceResponseMock.mockReturnValue([]); const resources = [makeResource()]; @@ -126,7 +128,6 @@ describe("useResourceDetailDrawer β€” unmount cleanup", () => { const { result, unmount } = renderHook(() => useResourceDetailDrawer({ resources, - checkId: "s3_check", }), ); @@ -136,7 +137,7 @@ describe("useResourceDetailDrawer β€” unmount cleanup", () => { }); // Verify a fetch was started - expect(getLatestFindingsByResourceUidMock).toHaveBeenCalledTimes(1); + expect(getFindingByIdMock).toHaveBeenCalledTimes(1); // Reset spy count to detect only the unmount abort abortSpy.mockClear(); @@ -158,7 +159,6 @@ describe("useResourceDetailDrawer β€” unmount cleanup", () => { const { unmount } = renderHook(() => useResourceDetailDrawer({ resources, - checkId: "s3_check", }), ); @@ -173,40 +173,63 @@ describe("useResourceDetailDrawer β€” unmount cleanup", () => { describe("useResourceDetailDrawer β€” other findings filtering", () => { beforeEach(() => { vi.clearAllMocks(); + getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] }); }); - it("should exclude the current finding from otherFindings and preserve API order", async () => { + it("should load other findings from the current resource uid and exclude the current finding", async () => { const resources = [makeResource()]; - getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] }); - adaptFindingsByResourceResponseMock.mockReturnValue([ - makeDrawerFinding({ - id: "current", - checkId: "s3_check", - checkTitle: "Current", - status: "FAIL", - severity: "critical", - }), - makeDrawerFinding({ - id: "other-1", - checkId: "check-other-1", - checkTitle: "Other 1", - status: "FAIL", - severity: "critical", - }), - makeDrawerFinding({ - id: "other-2", - checkId: "check-other-2", - checkTitle: "Other 2", - status: "FAIL", - severity: "medium", - }), - ]); + // Given + getFindingByIdMock.mockResolvedValue({ data: ["detail"] }); + getLatestFindingsByResourceUidMock.mockResolvedValue({ + data: ["resource"], + }); + adaptFindingsByResourceResponseMock.mockImplementation( + (response: { data: string[] }) => { + if (response.data[0] === "detail") { + return [ + makeDrawerFinding({ + id: "finding-1", + checkId: "s3_check", + checkTitle: "Current", + status: "MANUAL", + severity: "informational", + }), + ]; + } + + return [ + makeDrawerFinding({ + id: "finding-3", + checkTitle: "First other finding", + status: "FAIL", + severity: "high", + }), + makeDrawerFinding({ + id: "finding-1", + checkTitle: "Current finding duplicate from resource fetch", + status: "FAIL", + severity: "critical", + }), + makeDrawerFinding({ + id: "finding-4", + checkTitle: "Manual finding should be filtered out", + status: "MANUAL", + severity: "low", + }), + makeDrawerFinding({ + id: "finding-5", + checkTitle: "Second other finding", + status: "FAIL", + severity: "medium", + }), + ]; + }, + ); const { result } = renderHook(() => useResourceDetailDrawer({ resources, - checkId: "s3_check", }), ); @@ -215,59 +238,65 @@ describe("useResourceDetailDrawer β€” other findings filtering", () => { await Promise.resolve(); }); + // Then + expect(getFindingByIdMock).toHaveBeenCalledWith( + "finding-1", + "resources,scan.provider", + { source: "resource-detail-drawer" }, + ); + expect(getLatestFindingsByResourceUidMock).toHaveBeenCalledWith({ + resourceUid: "arn:aws:s3:::my-bucket", + pageSize: 50, + includeMuted: false, + }); + expect(result.current.currentFinding?.id).toBe("finding-1"); expect(result.current.otherFindings.map((finding) => finding.id)).toEqual([ - "other-1", - "other-2", + "finding-3", + "finding-5", ]); }); - it("should exclude non-FAIL findings from otherFindings", async () => { - const resources = [makeResource()]; + it("should skip loading other findings for synthetic IaC resources and keep the current detail on findingId", async () => { + const resources = [ + makeResource({ + findingId: "synthetic-finding", + resourceUid: "synthetic://iac-resource", + }), + ]; - getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] }); + // Given + getFindingByIdMock.mockResolvedValue({ data: ["detail"] }); adaptFindingsByResourceResponseMock.mockReturnValue([ makeDrawerFinding({ - id: "current", + id: "synthetic-finding", checkId: "s3_check", status: "MANUAL", severity: "informational", }), - makeDrawerFinding({ - id: "other-pass", - checkId: "check-pass", - status: "PASS", - severity: "low", - }), - makeDrawerFinding({ - id: "other-manual", - checkId: "check-manual", - status: "MANUAL", - severity: "low", - }), - makeDrawerFinding({ - id: "other-fail", - checkId: "check-fail", - status: "FAIL", - severity: "high", - }), ]); const { result } = renderHook(() => useResourceDetailDrawer({ resources, - checkId: "s3_check", + canLoadOtherFindings: false, }), ); await act(async () => { + // When result.current.openDrawer(0); await Promise.resolve(); }); - expect(result.current.currentFinding?.id).toBe("current"); - expect(result.current.otherFindings.map((f) => f.id)).toEqual([ - "other-fail", - ]); + // Then + expect(getFindingByIdMock).toHaveBeenCalledWith( + "synthetic-finding", + "resources,scan.provider", + { source: "resource-detail-drawer" }, + ); + expect(getLatestFindingsByResourceUidMock).not.toHaveBeenCalled(); + expect(result.current.currentFinding?.id).toBe("synthetic-finding"); + expect(result.current.otherFindings).toEqual([]); }); it("should request muted findings only when explicitly enabled", async () => { @@ -279,7 +308,6 @@ describe("useResourceDetailDrawer β€” other findings filtering", () => { const { result } = renderHook(() => useResourceDetailDrawer({ resources, - checkId: "s3_check", includeMutedInOtherFindings: true, }), ); @@ -291,6 +319,7 @@ describe("useResourceDetailDrawer β€” other findings filtering", () => { expect(getLatestFindingsByResourceUidMock).toHaveBeenCalledWith({ resourceUid: "arn:aws:s3:::my-bucket", + pageSize: 50, includeMuted: true, }); }); @@ -313,19 +342,19 @@ describe("useResourceDetailDrawer β€” other findings filtering", () => { }), ]; - getLatestFindingsByResourceUidMock.mockImplementation( - async ({ resourceUid }: { resourceUid: string }) => ({ - data: [resourceUid], - }), - ); + getFindingByIdMock.mockImplementation(async (findingId: string) => ({ + data: [findingId], + })); adaptFindingsByResourceResponseMock.mockImplementation( (response: { data: string[] }) => [ makeDrawerFinding({ - id: response.data[0].includes("first") ? "finding-1" : "finding-2", - resourceUid: response.data[0], - resourceName: response.data[0].includes("first") - ? "first-bucket" - : "second-bucket", + id: response.data[0], + resourceUid: + response.data[0] === "finding-1" + ? "arn:aws:s3:::first-bucket" + : "arn:aws:s3:::second-bucket", + resourceName: + response.data[0] === "finding-1" ? "first-bucket" : "second-bucket", }), ], ); @@ -333,7 +362,6 @@ describe("useResourceDetailDrawer β€” other findings filtering", () => { const { result } = renderHook(() => useResourceDetailDrawer({ resources, - checkId: "s3_check", }), ); @@ -358,6 +386,8 @@ describe("useResourceDetailDrawer β€” other findings filtering", () => { expect(result.current.isNavigating).toBe(true); await act(async () => { + await Promise.resolve(); + await Promise.resolve(); vi.runAllTimers(); await Promise.resolve(); }); @@ -387,19 +417,19 @@ describe("useResourceDetailDrawer β€” other findings filtering", () => { }), ]; - getLatestFindingsByResourceUidMock.mockImplementation( - async ({ resourceUid }: { resourceUid: string }) => ({ - data: [resourceUid], - }), - ); + getFindingByIdMock.mockImplementation(async (findingId: string) => ({ + data: [findingId], + })); adaptFindingsByResourceResponseMock.mockImplementation( (response: { data: string[] }) => [ makeDrawerFinding({ - id: response.data[0].includes("first") ? "finding-1" : "finding-2", - resourceUid: response.data[0], - resourceName: response.data[0].includes("first") - ? "first-bucket" - : "second-bucket", + id: response.data[0], + resourceUid: + response.data[0] === "finding-1" + ? "arn:aws:s3:::first-bucket" + : "arn:aws:s3:::second-bucket", + resourceName: + response.data[0] === "finding-1" ? "first-bucket" : "second-bucket", }), ], ); @@ -407,7 +437,6 @@ describe("useResourceDetailDrawer β€” other findings filtering", () => { const { result } = renderHook(() => useResourceDetailDrawer({ resources, - checkId: "s3_check", }), ); @@ -452,6 +481,154 @@ describe("useResourceDetailDrawer β€” other findings filtering", () => { vi.useRealTimers(); }); + it("should update checkMeta when navigating to a resource with a different check", async () => { + // Given + const resources = [ + makeResource({ + id: "row-1", + findingId: "finding-1", + checkId: "s3_check", + }), + makeResource({ + id: "row-2", + findingId: "finding-2", + checkId: "ec2_check", + resourceUid: "arn:aws:ec2:::instance/i-123", + resourceName: "instance-1", + service: "ec2", + }), + ]; + + getFindingByIdMock.mockImplementation(async (findingId: string) => ({ + data: [findingId], + })); + getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] }); + adaptFindingsByResourceResponseMock.mockImplementation( + (response: { data: string[] }) => [ + response.data[0] === "finding-1" + ? makeDrawerFinding({ + id: "finding-1", + checkId: "s3_check", + checkTitle: "S3 Check", + description: "s3 description", + }) + : makeDrawerFinding({ + id: "finding-2", + checkId: "ec2_check", + checkTitle: "EC2 Check", + description: "ec2 description", + }), + ], + ); + + const { result } = renderHook(() => + useResourceDetailDrawer({ + resources, + }), + ); + + // When + await act(async () => { + result.current.openDrawer(0); + await Promise.resolve(); + }); + + expect(result.current.checkMeta?.checkTitle).toBe("S3 Check"); + + await act(async () => { + result.current.navigateNext(); + await Promise.resolve(); + }); + + // Then + expect(result.current.checkMeta?.checkTitle).toBe("EC2 Check"); + expect(result.current.checkMeta?.description).toBe("ec2 description"); + }); + + it("should keep the previous check metadata cached while reopening until the new finding arrives", async () => { + // Given + const resources = [ + makeResource({ + id: "row-1", + findingId: "finding-1", + checkId: "s3_check", + }), + makeResource({ + id: "row-2", + findingId: "finding-2", + checkId: "ec2_check", + resourceUid: "arn:aws:ec2:::instance/i-123", + resourceName: "instance-1", + service: "ec2", + }), + ]; + + let resolveSecondFinding: ((value: { data: string[] }) => void) | null = + null; + + getFindingByIdMock.mockImplementation((findingId: string) => { + if (findingId === "finding-2") { + return new Promise((resolve) => { + resolveSecondFinding = resolve; + }); + } + + return Promise.resolve({ data: [findingId] }); + }); + getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] }); + adaptFindingsByResourceResponseMock.mockImplementation( + (response: { data: string[] }) => [ + response.data[0] === "finding-1" + ? makeDrawerFinding({ + id: "finding-1", + checkId: "s3_check", + checkTitle: "S3 Check", + description: "s3 description", + }) + : makeDrawerFinding({ + id: "finding-2", + checkId: "ec2_check", + checkTitle: "EC2 Check", + description: "ec2 description", + }), + ], + ); + + const { result } = renderHook(() => + useResourceDetailDrawer({ + resources, + }), + ); + + await act(async () => { + result.current.openDrawer(0); + await Promise.resolve(); + }); + + expect(result.current.checkMeta?.checkTitle).toBe("S3 Check"); + + // When + act(() => { + result.current.closeDrawer(); + result.current.openDrawer(1); + }); + + // Then + expect(result.current.isOpen).toBe(true); + expect(result.current.currentIndex).toBe(1); + expect(result.current.currentFinding).toBeNull(); + expect(result.current.checkMeta?.checkTitle).toBe("S3 Check"); + + await act(async () => { + resolveSecondFinding?.({ data: ["finding-2"] }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(result.current.checkMeta?.checkTitle).toBe("EC2 Check"); + expect(result.current.checkMeta?.description).toBe("ec2 description"); + }); + it("should clear the previous resource findings when navigation to the next resource fails", async () => { // Given const resources = [ @@ -469,24 +646,24 @@ describe("useResourceDetailDrawer β€” other findings filtering", () => { }), ]; - getLatestFindingsByResourceUidMock.mockImplementation( - async ({ resourceUid }: { resourceUid: string }) => { - if (resourceUid.includes("second")) { - throw new Error("Fetch failed"); - } + getFindingByIdMock.mockImplementation(async (findingId: string) => { + if (findingId === "finding-2") { + throw new Error("Fetch failed"); + } - return { data: [resourceUid] }; - }, - ); + return { data: [findingId] }; + }); adaptFindingsByResourceResponseMock.mockImplementation( (response: { data: string[] }) => [ makeDrawerFinding({ - id: response.data[0].includes("first") ? "finding-1" : "finding-2", - resourceUid: response.data[0], - resourceName: response.data[0].includes("first") - ? "first-bucket" - : "second-bucket", + id: response.data[0], + resourceUid: + response.data[0] === "finding-1" + ? "arn:aws:s3:::first-bucket" + : "arn:aws:s3:::second-bucket", + resourceName: + response.data[0] === "finding-1" ? "first-bucket" : "second-bucket", }), ], ); @@ -494,7 +671,6 @@ describe("useResourceDetailDrawer β€” other findings filtering", () => { const { result } = renderHook(() => useResourceDetailDrawer({ resources, - checkId: "s3_check", }), ); @@ -506,6 +682,7 @@ describe("useResourceDetailDrawer β€” other findings filtering", () => { expect(result.current.currentFinding?.resourceUid).toBe( "arn:aws:s3:::first-bucket", ); + expect(result.current.checkMeta?.checkTitle).toBe("S3 Check"); // When await act(async () => { @@ -517,5 +694,123 @@ describe("useResourceDetailDrawer β€” other findings filtering", () => { expect(result.current.currentIndex).toBe(1); expect(result.current.currentFinding).toBeNull(); expect(result.current.otherFindings).toEqual([]); + expect(result.current.checkMeta).toBeNull(); + }); + + it("should clear other findings immediately while the next resource is loading", async () => { + // Given + const resources = [ + makeResource({ + id: "row-1", + findingId: "finding-1", + resourceUid: "arn:aws:s3:::first-bucket", + resourceName: "first-bucket", + }), + makeResource({ + id: "row-2", + findingId: "finding-2", + resourceUid: "arn:aws:s3:::second-bucket", + resourceName: "second-bucket", + }), + ]; + + let resolveSecondFinding: ((value: { data: string[] }) => void) | null = + null; + let resolveSecondResource: ((value: { data: string[] }) => void) | null = + null; + + getFindingByIdMock.mockImplementation((findingId: string) => { + if (findingId === "finding-2") { + return new Promise((resolve) => { + resolveSecondFinding = resolve; + }); + } + + return Promise.resolve({ data: [findingId] }); + }); + + getLatestFindingsByResourceUidMock.mockImplementation( + ({ resourceUid }: { resourceUid: string }) => { + if (resourceUid === "arn:aws:s3:::second-bucket") { + return new Promise((resolve) => { + resolveSecondResource = resolve; + }); + } + + return Promise.resolve({ data: ["resource-1"] }); + }, + ); + + adaptFindingsByResourceResponseMock.mockImplementation( + (response: { data: string[] }) => { + if (response.data[0] === "finding-1") { + return [makeDrawerFinding({ id: "finding-1" })]; + } + + if (response.data[0] === "finding-2") { + return [ + makeDrawerFinding({ + id: "finding-2", + resourceUid: "arn:aws:s3:::second-bucket", + resourceName: "second-bucket", + }), + ]; + } + + if (response.data[0] === "resource-1") { + return [ + makeDrawerFinding({ + id: "finding-3", + checkTitle: "First bucket other finding", + resourceUid: "arn:aws:s3:::first-bucket", + }), + ]; + } + + return [ + makeDrawerFinding({ + id: "finding-4", + checkTitle: "Second bucket other finding", + resourceUid: "arn:aws:s3:::second-bucket", + }), + ]; + }, + ); + + const { result } = renderHook(() => + useResourceDetailDrawer({ + resources, + }), + ); + + await act(async () => { + result.current.openDrawer(0); + await Promise.resolve(); + }); + + expect(result.current.otherFindings.map((finding) => finding.id)).toEqual([ + "finding-3", + ]); + + // When + act(() => { + result.current.navigateNext(); + }); + + // Then + expect(result.current.currentIndex).toBe(1); + expect(result.current.currentFinding).toBeNull(); + expect(result.current.otherFindings).toEqual([]); + + await act(async () => { + resolveSecondFinding?.({ data: ["finding-2"] }); + resolveSecondResource?.({ data: ["resource-2"] }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(result.current.otherFindings.map((finding) => finding.id)).toEqual([ + "finding-4", + ]); }); }); diff --git a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts index 3affd7d38a..7a85b3d994 100644 --- a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts +++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { adaptFindingsByResourceResponse, + getFindingById, getLatestFindingsByResourceUid, type ResourceDrawerFinding, } from "@/actions/findings"; @@ -43,10 +44,10 @@ function extractCheckMeta(finding: ResourceDrawerFinding): CheckMeta { interface UseResourceDetailDrawerOptions { resources: FindingResourceRow[]; - checkId: string; totalResourceCount?: number; onRequestMoreResources?: () => void; initialIndex?: number | null; + canLoadOtherFindings?: boolean; includeMutedInOtherFindings?: boolean; } @@ -57,9 +58,9 @@ interface UseResourceDetailDrawerReturn { checkMeta: CheckMeta | null; currentIndex: number; totalResources: number; + currentResource: FindingResourceRow | null; currentFinding: ResourceDrawerFinding | null; otherFindings: ResourceDrawerFinding[]; - allFindings: ResourceDrawerFinding[]; openDrawer: (index: number) => void; closeDrawer: () => void; navigatePrev: () => void; @@ -71,24 +72,33 @@ interface UseResourceDetailDrawerReturn { /** * Manages the resource detail drawer state, fetching, and navigation. * - * Caches findings per resourceUid in a Map ref so navigating prev/next + * Caches findings per findingId in a Map ref so navigating prev/next * doesn't re-fetch already-visited resources. */ export function useResourceDetailDrawer({ resources, - checkId, totalResourceCount, onRequestMoreResources, initialIndex = null, + canLoadOtherFindings = true, includeMutedInOtherFindings = false, }: UseResourceDetailDrawerOptions): UseResourceDetailDrawerReturn { const [isOpen, setIsOpen] = useState(initialIndex !== null); const [isLoading, setIsLoading] = useState(false); const [currentIndex, setCurrentIndex] = useState(initialIndex ?? 0); - const [findings, setFindings] = useState([]); + const [currentFinding, setCurrentFinding] = + useState(null); + const [otherFindings, setOtherFindings] = useState( + [], + ); const [isNavigating, setIsNavigating] = useState(false); - const cacheRef = useRef>(new Map()); + const currentFindingCacheRef = useRef< + Map + >(new Map()); + const otherFindingsCacheRef = useRef>( + new Map(), + ); const checkMetaRef = useRef(null); const fetchControllerRef = useRef(null); const navigationTimeoutRef = useRef | null>( @@ -136,6 +146,11 @@ export function useResourceDetailDrawer({ setIsNavigating(true); }; + const resetCurrentResourceState = () => { + setCurrentFinding(null); + setOtherFindings([]); + }; + // Abort any in-flight request on unmount to prevent state updates // on an already-unmounted component. useEffect(() => { @@ -146,49 +161,83 @@ export function useResourceDetailDrawer({ }; }, []); - const fetchFindings = async (resourceUid: string) => { + const fetchFindings = async (resource: FindingResourceRow) => { // Abort any in-flight request to prevent stale data from out-of-order responses fetchControllerRef.current?.abort(); clearNavigationTimeout(); const controller = new AbortController(); fetchControllerRef.current = controller; - // Check cache first - const cached = cacheRef.current.get(resourceUid); - if (cached) { - if (!checkMetaRef.current) { - const main = cached.find((f) => f.checkId === checkId) ?? cached[0]; - if (main) checkMetaRef.current = extractCheckMeta(main); + const { findingId, resourceUid } = resource; + + const fetchCurrentFinding = async () => { + const cached = currentFindingCacheRef.current.get(findingId); + if (cached !== undefined) { + return cached; } - setFindings(cached); - finishNavigation(); - return; - } + + const response = await getFindingById( + findingId, + "resources,scan.provider", + { source: "resource-detail-drawer" }, + ); + + const adapted = adaptFindingsByResourceResponse(response); + const finding = + adapted.find((item) => item.id === findingId) ?? adapted[0] ?? null; + + currentFindingCacheRef.current.set(findingId, finding); + + return finding; + }; + + const fetchOtherFindings = async () => { + if (!canLoadOtherFindings || !resourceUid) { + return []; + } + + const cached = otherFindingsCacheRef.current.get(resourceUid); + if (cached) { + return cached; + } + + const response = await getLatestFindingsByResourceUid({ + resourceUid, + pageSize: 50, + includeMuted: includeMutedInOtherFindings, + }); + const adapted = adaptFindingsByResourceResponse(response); + + otherFindingsCacheRef.current.set(resourceUid, adapted); + + return adapted; + }; setIsLoading(true); try { - const response = await getLatestFindingsByResourceUid({ - resourceUid, - includeMuted: includeMutedInOtherFindings, - }); + const [nextCurrentFinding, nextOtherFindings] = await Promise.all([ + fetchCurrentFinding(), + fetchOtherFindings(), + ]); // Discard stale response if a newer request was started if (controller.signal.aborted) return; - const adapted = adaptFindingsByResourceResponse(response); - cacheRef.current.set(resourceUid, adapted); + checkMetaRef.current = nextCurrentFinding + ? extractCheckMeta(nextCurrentFinding) + : null; - // Extract check-level metadata once (stable across all resources) - if (!checkMetaRef.current) { - const main = adapted.find((f) => f.checkId === checkId) ?? adapted[0]; - if (main) checkMetaRef.current = extractCheckMeta(main); - } - - setFindings(adapted); - } catch (error) { + setCurrentFinding(nextCurrentFinding); + setOtherFindings( + nextOtherFindings.filter( + (finding) => finding.id !== findingId && finding.status === "FAIL", + ), + ); + } catch (_error) { if (!controller.signal.aborted) { - console.error("Error fetching findings for resource:", error); - setFindings([]); + checkMetaRef.current = null; + setCurrentFinding(null); + setOtherFindings([]); } } finally { if (!controller.signal.aborted) { @@ -207,7 +256,7 @@ export function useResourceDetailDrawer({ return; } - fetchFindings(resource.resourceUid); + fetchFindings(resource); // Only initialize once on mount for deep-link/inline entry points. // User-driven navigations use openDrawer/navigateTo afterwards. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -217,13 +266,11 @@ export function useResourceDetailDrawer({ const resource = resources[index]; if (!resource) return; - clearNavigationTimeout(); - navigationStartedAtRef.current = null; setCurrentIndex(index); setIsOpen(true); - setIsNavigating(false); - setFindings([]); - fetchFindings(resource.resourceUid); + startNavigation(); + resetCurrentResourceState(); + fetchFindings(resource); }; const closeDrawer = () => { @@ -233,10 +280,11 @@ export function useResourceDetailDrawer({ const refetchCurrent = () => { const resource = resources[currentIndex]; if (!resource) return; - cacheRef.current.delete(resource.resourceUid); + currentFindingCacheRef.current.delete(resource.findingId); + otherFindingsCacheRef.current.delete(resource.resourceUid); startNavigation(); - setFindings([]); - fetchFindings(resource.resourceUid); + resetCurrentResourceState(); + fetchFindings(resource); }; const navigateTo = (index: number) => { @@ -245,8 +293,8 @@ export function useResourceDetailDrawer({ setCurrentIndex(index); startNavigation(); - setFindings([]); - fetchFindings(resource.resourceUid); + resetCurrentResourceState(); + fetchFindings(resource); }; const navigatePrev = () => { @@ -270,17 +318,7 @@ export function useResourceDetailDrawer({ } }; - // The finding whose checkId matches the drill-down's checkId - const currentFinding = - findings.find((f) => f.checkId === checkId) ?? findings[0] ?? null; - - // "Other Findings For This Resource" intentionally shows only FAIL entries, - // while currentFinding (the drilled-down one) can be any status (FAIL, MANUAL, PASS…). - const otherFindings = ( - currentFinding - ? findings.filter((f) => f.id !== currentFinding.id) - : findings - ).filter((f) => f.status === "FAIL"); + const currentResource = resources[currentIndex]; return { isOpen, @@ -289,9 +327,9 @@ export function useResourceDetailDrawer({ checkMeta: checkMetaRef.current, currentIndex, totalResources: totalResourceCount ?? resources.length, + currentResource: currentResource ?? null, currentFinding, otherFindings, - allFindings: findings, openDrawer, closeDrawer, navigatePrev, diff --git a/ui/hooks/use-finding-group-resource-state.ts b/ui/hooks/use-finding-group-resource-state.ts index f313bb77b0..309a69a6dc 100644 --- a/ui/hooks/use-finding-group-resource-state.ts +++ b/ui/hooks/use-finding-group-resource-state.ts @@ -80,9 +80,9 @@ export function useFindingGroupResourceState({ const drawer = useResourceDetailDrawer({ resources, - checkId: group.checkId, totalResourceCount: totalCount ?? group.resourcesTotal, onRequestMoreResources: loadMore, + canLoadOtherFindings: group.resourcesTotal !== 0, includeMutedInOtherFindings: true, }); diff --git a/ui/lib/findings-groups.test.ts b/ui/lib/findings-groups.test.ts index 9fa73c5b1c..1d319371de 100644 --- a/ui/lib/findings-groups.test.ts +++ b/ui/lib/findings-groups.test.ts @@ -3,9 +3,11 @@ import { describe, expect, it } from "vitest"; import type { FindingGroupRow } from "@/types"; import { + canDrillDownFindingGroup, getActiveStatusFilter, getFilteredFindingGroupDelta, getFindingGroupDelta, + getFindingGroupImpactedCounts, isFindingGroupMuted, } from "./findings-groups"; @@ -138,6 +140,119 @@ describe("getActiveStatusFilter", () => { }); }); +describe("getFindingGroupImpactedCounts", () => { + it("should fall back to pass and fail counts when resources total is zero", () => { + // Given + const group = makeGroup({ + resourcesTotal: 0, + resourcesFail: 0, + failCount: 3, + passCount: 2, + muted: false, + }); + + // When + const result = getFindingGroupImpactedCounts(group); + + // Then + expect(result).toEqual({ impacted: 3, total: 5 }); + }); + + it("should include manual findings in fallback counts when resources total is zero", () => { + // Given + const group = makeGroup({ + resourcesTotal: 0, + resourcesFail: 0, + failCount: 3, + passCount: 2, + manualCount: 4, + muted: false, + }); + + // When + const result = getFindingGroupImpactedCounts(group); + + // Then + expect(result).toEqual({ impacted: 3, total: 9 }); + }); + + it("should include muted pass and fail counts in the denominator when the result is muted", () => { + // Given + const group = makeGroup({ + resourcesTotal: 0, + resourcesFail: 0, + failCount: 3, + passCount: 2, + failMutedCount: 4, + passMutedCount: 1, + muted: true, + }); + + // When + const result = getFindingGroupImpactedCounts(group); + + // Then + expect(result).toEqual({ impacted: 3, total: 10 }); + }); + + it("should keep resource-based counts when resources total is available", () => { + // Given + const group = makeGroup({ + resourcesTotal: 6, + resourcesFail: 4, + failCount: 2, + passCount: 1, + failMutedCount: 5, + passMutedCount: 3, + muted: true, + }); + + // When + const result = getFindingGroupImpactedCounts(group); + + // Then + expect(result).toEqual({ impacted: 4, total: 6 }); + }); +}); + +describe("canDrillDownFindingGroup", () => { + it("should allow drill-down when resources exist", () => { + expect( + canDrillDownFindingGroup( + makeGroup({ + resourcesTotal: 2, + failCount: 0, + }), + ), + ).toBe(true); + }); + + it("should keep zero-resource fallback groups non-expandable even when fallback counts are present", () => { + expect( + canDrillDownFindingGroup( + makeGroup({ + resourcesTotal: 0, + failCount: 0, + passCount: 2, + manualCount: 1, + }), + ), + ).toBe(false); + }); + + it("should keep drill-down disabled for zero-resource groups when the displayed total is zero", () => { + expect( + canDrillDownFindingGroup( + makeGroup({ + resourcesTotal: 0, + failCount: 0, + passCount: 0, + }), + ), + ).toBe(false); + }); +}); + describe("getFilteredFindingGroupDelta", () => { it("falls back to the aggregate delta when no status filter is active", () => { expect( diff --git a/ui/lib/findings-groups.ts b/ui/lib/findings-groups.ts index 7f15e9ad2c..60f1911c05 100644 --- a/ui/lib/findings-groups.ts +++ b/ui/lib/findings-groups.ts @@ -1,9 +1,10 @@ -import type { FindingGroupRow } from "@/types"; - -type FindingGroupMutedState = Pick< - FindingGroupRow, - "muted" | "mutedCount" | "resourcesFail" | "resourcesTotal" ->; +import { + FINDING_DELTA, + FINDING_STATUS, + type FindingDelta, + type FindingGroupRow, + type FindingStatus, +} from "@/types"; type FindingGroupDeltaState = Pick< FindingGroupRow, @@ -23,7 +24,18 @@ type FindingGroupDeltaState = Pick< | "changedManualMutedCount" >; -export function isFindingGroupMuted(group: FindingGroupMutedState): boolean { +type FindingGroupDelta = Exclude; + +type FindingGroupStatus = FindingStatus; + +const FINDING_GROUP_STATUSES = Object.values(FINDING_STATUS); + +export function isFindingGroupMuted( + group: Pick< + FindingGroupRow, + "muted" | "mutedCount" | "resourcesFail" | "resourcesTotal" + >, +): boolean { if (typeof group.muted === "boolean") { return group.muted; } @@ -38,6 +50,54 @@ export function isFindingGroupMuted(group: FindingGroupMutedState): boolean { ); } +export function getFindingGroupImpactedCounts( + group: Pick< + FindingGroupRow, + | "resourcesTotal" + | "resourcesFail" + | "passCount" + | "failCount" + | "manualCount" + | "passMutedCount" + | "failMutedCount" + | "manualMutedCount" + | "muted" + | "mutedCount" + >, +): { impacted: number; total: number } { + if (group.resourcesTotal > 0) { + return { + impacted: group.resourcesFail, + total: group.resourcesTotal, + }; + } + + const total = + (group.passCount ?? 0) + (group.failCount ?? 0) + (group.manualCount ?? 0); + + if (!isFindingGroupMuted(group)) { + return { + impacted: group.failCount ?? 0, + total, + }; + } + + return { + impacted: group.failCount ?? 0, + total: + total + + (group.passMutedCount ?? 0) + + (group.failMutedCount ?? 0) + + (group.manualMutedCount ?? 0), + }; +} + +export function canDrillDownFindingGroup( + group: Pick, +): boolean { + return group.resourcesTotal > 0; +} + function getNewDeltaTotal(group: FindingGroupDeltaState): number { const breakdownTotal = (group.newFailCount ?? 0) + @@ -64,21 +124,18 @@ function getChangedDeltaTotal(group: FindingGroupDeltaState): number { export function getFindingGroupDelta( group: FindingGroupDeltaState, -): "new" | "changed" | "none" { +): FindingGroupDelta { if (getNewDeltaTotal(group) > 0) { - return "new"; + return FINDING_DELTA.NEW; } if (getChangedDeltaTotal(group) > 0) { - return "changed"; + return FINDING_DELTA.CHANGED; } - return "none"; + return FINDING_DELTA.NONE; } -const FINDING_GROUP_STATUSES = ["FAIL", "PASS", "MANUAL"] as const; -type FindingGroupStatus = (typeof FINDING_GROUP_STATUSES)[number]; - type FindingGroupFiltersRecord = Record; function parseStatusFilterValue( @@ -142,13 +199,13 @@ function getNewDeltaForStatuses( statuses: Set, ): number { let total = 0; - if (statuses.has("FAIL")) { + if (statuses.has(FINDING_STATUS.FAIL)) { total += (group.newFailCount ?? 0) + (group.newFailMutedCount ?? 0); } - if (statuses.has("PASS")) { + if (statuses.has(FINDING_STATUS.PASS)) { total += (group.newPassCount ?? 0) + (group.newPassMutedCount ?? 0); } - if (statuses.has("MANUAL")) { + if (statuses.has(FINDING_STATUS.MANUAL)) { total += (group.newManualCount ?? 0) + (group.newManualMutedCount ?? 0); } return total; @@ -159,13 +216,13 @@ function getChangedDeltaForStatuses( statuses: Set, ): number { let total = 0; - if (statuses.has("FAIL")) { + if (statuses.has(FINDING_STATUS.FAIL)) { total += (group.changedFailCount ?? 0) + (group.changedFailMutedCount ?? 0); } - if (statuses.has("PASS")) { + if (statuses.has(FINDING_STATUS.PASS)) { total += (group.changedPassCount ?? 0) + (group.changedPassMutedCount ?? 0); } - if (statuses.has("MANUAL")) { + if (statuses.has(FINDING_STATUS.MANUAL)) { total += (group.changedManualCount ?? 0) + (group.changedManualMutedCount ?? 0); } @@ -182,7 +239,7 @@ function getChangedDeltaForStatuses( export function getFilteredFindingGroupDelta( group: FindingGroupDeltaState, filters: FindingGroupFiltersRecord, -): "new" | "changed" | "none" { +): FindingGroupDelta { const activeStatuses = getActiveStatusFilter(filters); if (!activeStatuses || !hasAnyDeltaBreakdown(group)) { @@ -190,12 +247,12 @@ export function getFilteredFindingGroupDelta( } if (getNewDeltaForStatuses(group, activeStatuses) > 0) { - return "new"; + return FINDING_DELTA.NEW; } if (getChangedDeltaForStatuses(group, activeStatuses) > 0) { - return "changed"; + return FINDING_DELTA.CHANGED; } - return "none"; + return FINDING_DELTA.NONE; } diff --git a/ui/types/components.ts b/ui/types/components.ts index 0f294ceb7d..62642db4ea 100644 --- a/ui/types/components.ts +++ b/ui/types/components.ts @@ -82,6 +82,7 @@ export type PermissionState = export const FINDING_DELTA = { NEW: "new", CHANGED: "changed", + NONE: "none", } as const; export type FindingDelta = | (typeof FINDING_DELTA)[keyof typeof FINDING_DELTA] From 6ffe4e95bfe91ac7b03e4be12dd19f800b82e9c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Mon, 20 Apr 2026 09:00:43 +0200 Subject: [PATCH 04/26] fix(api): detect silent failures in ResourceFindingMapping (#10724) Co-authored-by: Pepe Fagoaga --- api/CHANGELOG.md | 1 + api/src/backend/tasks/jobs/scan.py | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 0d798fb29f..f3dab7cf9e 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to the **Prowler API** are documented in this file. - Attack Paths: Missing `tenant_id` filter while getting related findings after scan completes [(#10722)](https://github.com/prowler-cloud/prowler/pull/10722) - Finding group counters `pass_count`, `fail_count` and `manual_count` now exclude muted findings [(#10753)](https://github.com/prowler-cloud/prowler/pull/10753) +- Silent data loss in `ResourceFindingMapping` bulk insert that left findings orphaned when `INSERT ... ON CONFLICT DO NOTHING` dropped rows without raising; added explicit `unique_fields` [(#10724)](https://github.com/prowler-cloud/prowler/pull/10724) --- diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 97eba7cd4f..456fd786cb 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -752,11 +752,19 @@ def _process_finding_micro_batch( ) if mappings_to_create: - ResourceFindingMapping.objects.bulk_create( + created_mappings = ResourceFindingMapping.objects.bulk_create( mappings_to_create, batch_size=SCAN_DB_BATCH_SIZE, ignore_conflicts=True, + unique_fields=["tenant_id", "resource_id", "finding_id"], ) + inserted = sum(1 for m in created_mappings if m.pk) + if inserted != len(mappings_to_create): + logger.error( + f"scan {scan_instance.id}: expected " + f"{len(mappings_to_create)} ResourceFindingMapping rows, " + f"inserted {inserted}. Rolling back micro-batch." + ) # Update finding denormalized arrays findings_to_update = [] From f7194b32def4c64679daf859d24dd31864ad6843 Mon Sep 17 00:00:00 2001 From: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com> Date: Mon, 20 Apr 2026 09:37:30 +0200 Subject: [PATCH 05/26] docs: remove prowler ctf page (#10782) --- .../prowler-ctf-breach-investigation.mdx | 59 ------------------- 1 file changed, 59 deletions(-) delete mode 100644 docs/user-guide/tutorials/prowler-ctf-breach-investigation.mdx diff --git a/docs/user-guide/tutorials/prowler-ctf-breach-investigation.mdx b/docs/user-guide/tutorials/prowler-ctf-breach-investigation.mdx deleted file mode 100644 index b4124f5325..0000000000 --- a/docs/user-guide/tutorials/prowler-ctf-breach-investigation.mdx +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: 'Prowler CTF - Breach Investigation' -description: 'Investigate a simulated AWS data exfiltration case with Prowler Cloud and recover three flags.' ---- - -## Background - -Astoneta Corp's security team received an alert: someone has been exfiltrating data from an AWS account. Initial triage suggests that a threat actor found an entry point through a misconfigured storage resource, used leaked credentials to escalate privileges, and launched compute infrastructure to extract data. - -Use **Prowler** to scan the AWS account, investigate the findings, and uncover three flags hidden across the attack chain. - -## Getting Started - -You have been given a set of AWS credentials with read-only access. Use **Prowler Cloud** to connect the target account and run a scan: - -1. Log in to [Prowler Cloud](https://cloud.prowler.com). -2. Add a new **AWS provider** using **Static Access Keys**. Follow the [setup guide](https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#credentials-static-access-keys). -3. Enter the provided `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. -4. Launch a scan and wait for it to complete. - -Once the scan finishes, examine the findings to trace the threat actor's steps. - -## Challenge 1 - The Entry Point - -> "Every breach starts somewhere. The attacker walked in through a door that should never have been open." - -An S3 bucket has been left publicly accessible. Anyone on the internet can read its contents, and the threat actor did exactly that. - -Find the misconfigured bucket in the Prowler findings and inspect its tags. The flag is the **base64 encoding of the value of the `CaseId` tag** on the vulnerable bucket. - -**Hint:** Look for findings related to S3 public access. Every resource in this environment carries a `CaseId` tag, but only the vulnerable bucket's value is the correct flag. Do not take the first `CaseId` you find. Use Prowler's findings to identify which bucket is misconfigured, then review its tags. In Prowler Cloud, resource tags are visible in the metadata view. - -## Challenge 2 - Compromised Identity - -> "Inside the bucket, the attacker found what every intruder dreams of: admin credentials with no second factor." - -The leaked credentials belong to an IAM user with console access and **no MFA enabled**, holding administrator-level permissions. - -Find the user flagged by Prowler for having console access without MFA. The flag is the **MD5 hash of the username**. - -**Hint:** Not every IAM user in the account is vulnerable. Some exist only for programmatic access and will not trigger this finding. - -## Challenge 3 - The Exfiltration Node - -> "With admin access secured, the attacker launched an EC2 instance wide open to the internet - their staging ground for data exfiltration." - -A security group allows inbound traffic from `0.0.0.0/0` on multiple sensitive ports. An EC2 instance uses this security group to run an exfiltration node. - -The flag is hidden in one of the instance's **tags**, but the instance has many tags and only one holds the flag. The `CaseId` tag is **not** the answer this time. - -To determine which tag matters, think through the exposed services. The flag is the **base64 encoding of the value of the correct tag**. - -**Hint:** The security group allows HTTP traffic. Try accessing the instance. It may point to the tag that matters. The page itself is not the flag. In Prowler Cloud, resource tags are visible in the metadata view. - -## Submission - -Submit all three flags. - -Good luck, investigator. From 94a2ea1e8fb119dab0b3315b318a7f51723468db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:39:00 +0200 Subject: [PATCH 06/26] chore: update CODEOWNERS for new team hierarchy (#10706) --- .github/CODEOWNERS | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b953610fa1..3300394d83 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,14 +1,15 @@ # SDK -/* @prowler-cloud/sdk -/prowler/ @prowler-cloud/sdk @prowler-cloud/detection-and-remediation -/tests/ @prowler-cloud/sdk @prowler-cloud/detection-and-remediation -/dashboard/ @prowler-cloud/sdk -/docs/ @prowler-cloud/sdk -/examples/ @prowler-cloud/sdk -/util/ @prowler-cloud/sdk -/contrib/ @prowler-cloud/sdk -/permissions/ @prowler-cloud/sdk -/codecov.yml @prowler-cloud/sdk @prowler-cloud/api +/* @prowler-cloud/detection-remediation +/prowler/ @prowler-cloud/detection-remediation +/prowler/compliance/ @prowler-cloud/compliance +/tests/ @prowler-cloud/detection-remediation +/dashboard/ @prowler-cloud/detection-remediation +/docs/ @prowler-cloud/detection-remediation +/examples/ @prowler-cloud/detection-remediation +/util/ @prowler-cloud/detection-remediation +/contrib/ @prowler-cloud/detection-remediation +/permissions/ @prowler-cloud/detection-remediation +/codecov.yml @prowler-cloud/detection-remediation @prowler-cloud/api # API /api/ @prowler-cloud/api @@ -17,7 +18,7 @@ /ui/ @prowler-cloud/ui # AI -/mcp_server/ @prowler-cloud/ai +/mcp_server/ @prowler-cloud/detection-remediation # Platform /.github/ @prowler-cloud/platform From bf1b53bbd297e0e7d95d9a565d0399a972a593df Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Mon, 20 Apr 2026 13:34:31 +0200 Subject: [PATCH 07/26] fix(ui): sorting and filtering for findings (#10778) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: alejandrobailo --- ui/CHANGELOG.md | 12 +- .../findings/findings-by-resource.test.ts | 11 +- ui/actions/findings/findings-by-resource.ts | 3 +- .../findings-view/findings-view.ssr.tsx | 26 ++-- .../graphs-tabs/graphs-tabs-wrapper.tsx | 8 +- ui/app/(prowler)/findings/page.test.ts | 6 +- ui/app/(prowler)/findings/page.tsx | 14 +- .../client-accordion-content.tsx | 1 + ui/components/filters/custom-date-picker.tsx | 8 +- ui/components/findings/findings-filters.tsx | 53 +------ .../findings/findings-filters.utils.test.ts | 109 ++++++++++++- .../findings/findings-filters.utils.ts | 78 +++++++++- .../table/column-standalone-findings.tsx | 55 ++++--- .../resource-detail-drawer-content.test.tsx | 84 +++++++++- .../resource-detail-drawer-content.tsx | 14 +- .../use-resource-detail-drawer.test.ts | 14 +- .../use-resource-detail-drawer.ts | 6 +- .../link-to-findings.test.tsx | 48 ++++++ .../link-to-findings/link-to-findings.tsx | 21 +-- .../table/skeleton-table-new-findings.tsx | 143 +++++++++++++----- .../table/scans/column-get-scans.test.ts | 22 +++ .../scans/table/scans/column-get-scans.tsx | 70 ++++----- .../scans/data-table-download-details.tsx | 34 ----- .../ui/table/data-table-filter-custom.tsx | 15 +- ui/components/ui/table/data-table.tsx | 4 + ui/hooks/use-filter-batch.test.ts | 37 +++++ ui/lib/date-utils.test.ts | 35 +++++ ui/lib/date-utils.ts | 24 ++- ui/lib/findings-scan-filters.test.ts | 6 +- ui/lib/findings-scan-filters.ts | 18 ++- ui/lib/helper-filters.test.ts | 64 ++++++++ ui/lib/helper-filters.ts | 27 +++- ui/types/filters.ts | 6 +- 33 files changed, 808 insertions(+), 268 deletions(-) create mode 100644 ui/components/overview/new-findings-table/link-to-findings/link-to-findings.test.tsx create mode 100644 ui/components/scans/table/scans/column-get-scans.test.ts delete mode 100644 ui/components/scans/table/scans/data-table-download-details.tsx create mode 100644 ui/lib/date-utils.test.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index be12653904..9a83d91fea 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -4,14 +4,18 @@ All notable changes to the **Prowler UI** are documented in this file. ## [1.24.1] (Prowler v5.24.1) -### πŸ”’ Security - -- Upgrade React to 19.2.5 and Next.js to 16.2.3 to mitigate CVE-2026-23869 (React2DoS), a high-severity unauthenticated remote DoS vulnerability in the React Flight Protocol's Server Function deserialization [(#10754)](https://github.com/prowler-cloud/prowler/pull/10754) - ### 🐞 Fixed - Findings and filter UX fixes: exclude muted findings by default in the resource detail drawer and finding group resource views, show category context label (for example `Status: FAIL`) on MultiSelect triggers instead of hiding the placeholder, and add a `wide` width option for filter dropdowns applied to the findings Scan filter to prevent label truncation [(#10734)](https://github.com/prowler-cloud/prowler/pull/10734) - Findings grouped view now handles zero-resource IaC counters, refines drawer loading states, and adds provider indicators to finding groups [(#10736)](https://github.com/prowler-cloud/prowler/pull/10736) +- Other Findings for this resource: ordering by `severity` [(#10778)](https://github.com/prowler-cloud/prowler/pull/10778) +- Other Findings for this resource: show `delta` indicator [(#10778)](https://github.com/prowler-cloud/prowler/pull/10778) +- Compliance: requirement findings do not show muted findings [(#10778)](https://github.com/prowler-cloud/prowler/pull/10778) +- Latest new findings: link to finding groups order by `-severity,-last_seen_at` [(#10778)](https://github.com/prowler-cloud/prowler/pull/10778) + +### πŸ”’ Security + +- Upgrade React to 19.2.5 and Next.js to 16.2.3 to mitigate CVE-2026-23869 (React2DoS), a high-severity unauthenticated remote DoS vulnerability in the React Flight Protocol's Server Function deserialization [(#10754)](https://github.com/prowler-cloud/prowler/pull/10754) --- diff --git a/ui/actions/findings/findings-by-resource.test.ts b/ui/actions/findings/findings-by-resource.test.ts index 3a045d2e75..7bc2793195 100644 --- a/ui/actions/findings/findings-by-resource.test.ts +++ b/ui/actions/findings/findings-by-resource.test.ts @@ -272,7 +272,7 @@ describe("getLatestFindingsByResourceUid", () => { handleApiResponseMock.mockResolvedValue({ data: [] }); }); - it("should exclude muted findings by default and always apply severity/time sorting", async () => { + it("should restrict to FAIL, exclude muted findings, and apply severity/time sorting by default", async () => { fetchMock.mockResolvedValue(new Response("", { status: 200 })); await getLatestFindingsByResourceUid({ @@ -284,8 +284,12 @@ describe("getLatestFindingsByResourceUid", () => { expect(calledUrl.searchParams.get("filter[resource_uid]")).toBe( "resource-1", ); + // Status filter is applied server-side so the page[size]=50 window + // always holds FAIL rows β€” guards against PASS-heavy resources + // starving FAILs out of the result. + expect(calledUrl.searchParams.get("filter[status]")).toBe("FAIL"); expect(calledUrl.searchParams.get("filter[muted]")).toBe("false"); - expect(calledUrl.searchParams.get("sort")).toBe("-severity,-updated_at"); + expect(calledUrl.searchParams.get("sort")).toBe("severity,-updated_at"); }); it("should include muted findings only when explicitly requested", async () => { @@ -297,7 +301,8 @@ describe("getLatestFindingsByResourceUid", () => { }); const calledUrl = new URL(fetchMock.mock.calls[0][0]); + expect(calledUrl.searchParams.get("filter[status]")).toBe("FAIL"); expect(calledUrl.searchParams.get("filter[muted]")).toBe("include"); - expect(calledUrl.searchParams.get("sort")).toBe("-severity,-updated_at"); + expect(calledUrl.searchParams.get("sort")).toBe("severity,-updated_at"); }); }); diff --git a/ui/actions/findings/findings-by-resource.ts b/ui/actions/findings/findings-by-resource.ts index ee6d952cf0..74a0bcb6de 100644 --- a/ui/actions/findings/findings-by-resource.ts +++ b/ui/actions/findings/findings-by-resource.ts @@ -264,8 +264,9 @@ export const getLatestFindingsByResourceUid = async ({ ); url.searchParams.append("filter[resource_uid]", resourceUid); + url.searchParams.append("filter[status]", "FAIL"); url.searchParams.append("filter[muted]", includeMuted ? "include" : "false"); - url.searchParams.append("sort", "-severity,-updated_at"); + url.searchParams.append("sort", "severity,-updated_at"); if (page) url.searchParams.append("page[number]", page.toString()); if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); diff --git a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx index 9554869dce..cca971578c 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx @@ -4,6 +4,7 @@ import { getLatestFindings } from "@/actions/findings/findings"; import { LighthouseBanner } from "@/components/lighthouse/banner"; import { LinkToFindings } from "@/components/overview"; import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table"; +import { CardTitle } from "@/components/shadcn"; import { DataTable } from "@/components/ui/table"; import { createDict } from "@/lib/helper"; import { FindingProps, SearchParamsProps } from "@/types"; @@ -57,24 +58,23 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) { }; return ( -
+
-
-
-

- Latest new failing findings -

-

- Showing the latest 10 new failing findings by severity. -

- -
-
- +
+ Latest New Failed Findings +

+ Showing the latest 10 sorted by severity +

+
+ +
+ } />
); diff --git a/ui/app/(prowler)/_overview/graphs-tabs/graphs-tabs-wrapper.tsx b/ui/app/(prowler)/_overview/graphs-tabs/graphs-tabs-wrapper.tsx index c21491e37f..f9741dc75e 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/graphs-tabs-wrapper.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/graphs-tabs-wrapper.tsx @@ -1,6 +1,7 @@ import { Skeleton } from "@heroui/skeleton"; import { Suspense } from "react"; +import { SkeletonTableNewFindings } from "@/components/overview/new-findings-table/table"; import { SearchParamsProps } from "@/types"; import { GraphsTabsClient } from "./_components/graphs-tabs-client"; @@ -18,6 +19,10 @@ const LoadingFallback = () => ( ); +const TAB_FALLBACKS: Partial> = { + findings: , +}; + type GraphComponent = React.ComponentType<{ searchParams: SearchParamsProps }>; const GRAPH_COMPONENTS: Record = { @@ -38,9 +43,10 @@ export const GraphsTabsWrapper = async ({ const tabsContent = Object.fromEntries( GRAPH_TABS.map((tab) => { const Component = GRAPH_COMPONENTS[tab.id]; + const fallback = TAB_FALLBACKS[tab.id] ?? ; return [ tab.id, - }> + , ]; diff --git a/ui/app/(prowler)/findings/page.test.ts b/ui/app/(prowler)/findings/page.test.ts index 76462dff99..444ed47f9e 100644 --- a/ui/app/(prowler)/findings/page.test.ts +++ b/ui/app/(prowler)/findings/page.test.ts @@ -25,8 +25,10 @@ describe("findings page", () => { expect(source).toContain("resolveFindingScanDateFilters"); }); - it("uses getLatestFindingGroups for non-date/scan queries and getFindingGroups for historical", () => { - expect(source).toContain("hasDateOrScan"); + it("uses resolved filters to choose getFindingGroups for historical queries and getLatestFindingGroups otherwise", () => { + expect(source).toContain("hasHistoricalData"); + expect(source).toContain("hasDateOrScanFilter(filtersWithScanDates)"); + expect(source).toContain("hasDateOrScanFilter(filters)"); expect(source).toContain("getFindingGroups"); expect(source).toContain("getLatestFindingGroups"); }); diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 948d264602..22c90330bb 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -34,9 +34,6 @@ export default async function Findings({ const { encodedSort } = extractSortAndKey(resolvedSearchParams); const { filters, query } = extractFiltersAndQuery(resolvedSearchParams); - // Check if the searchParams contain any date or scan filter - const hasDateOrScan = hasDateOrScanFilter(resolvedSearchParams); - const [providersData, scansData] = await Promise.all([ getProviders({ pageSize: 50 }), getScans({ pageSize: 50 }), @@ -51,8 +48,10 @@ export default async function Findings({ }, }); + const hasHistoricalData = hasDateOrScanFilter(filtersWithScanDates); + const metadataInfoData = await ( - hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo + hasHistoricalData ? getMetadataInfo : getLatestMetadataInfo )({ query, sort: encodedSort, @@ -119,10 +118,9 @@ const SSRDataTable = async ({ const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); const { encodedSort } = extractSortAndKey(searchParams); - // Check if the searchParams contain any date or scan filter - const hasDateOrScan = hasDateOrScanFilter(searchParams); + const hasHistoricalData = hasDateOrScanFilter(filters); - const fetchFindingGroups = hasDateOrScan + const fetchFindingGroups = hasHistoricalData ? getFindingGroups : getLatestFindingGroups; @@ -151,7 +149,7 @@ const SSRDataTable = async ({ data={groups} metadata={findingGroupsData?.meta} resolvedFilters={filters} - hasHistoricalData={hasDateOrScan} + hasHistoricalData={hasHistoricalData} /> ); diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx index f582f2b3c2..659d3a83fa 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx @@ -62,6 +62,7 @@ export const ClientAccordionContent = ({ filters: { "filter[check_id__in]": checkIds.join(","), "filter[scan]": scanId, + "filter[muted]": "false", ...(region && { "filter[region__in]": region }), }, page: parseInt(pageNumber, 10), diff --git a/ui/components/filters/custom-date-picker.tsx b/ui/components/filters/custom-date-picker.tsx index 3e92615b5c..effe2ef144 100644 --- a/ui/components/filters/custom-date-picker.tsx +++ b/ui/components/filters/custom-date-picker.tsx @@ -12,6 +12,7 @@ import { PopoverTrigger, } from "@/components/shadcn/popover"; import { useUrlFilters } from "@/hooks/use-url-filters"; +import { toLocalDateString } from "@/lib/date-utils"; import { cn } from "@/lib/utils"; /** Batch mode: caller controls both the pending date value and the notification callback (all-or-nothing). */ @@ -67,17 +68,14 @@ export const CustomDatePicker = ({ const applyDateFilter = (selectedDate: Date | undefined) => { if (onBatchChange) { // Batch mode: notify caller instead of updating URL - onBatchChange( - "inserted_at", - selectedDate ? format(selectedDate, "yyyy-MM-dd") : "", - ); + onBatchChange("inserted_at", toLocalDateString(selectedDate) ?? ""); return; } // Instant mode (default): push to URL immediately if (selectedDate) { // Format as YYYY-MM-DD for the API - updateFilter("inserted_at", format(selectedDate, "yyyy-MM-dd")); + updateFilter("inserted_at", toLocalDateString(selectedDate) ?? ""); } else { updateFilter("inserted_at", null); } diff --git a/ui/components/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx index 61008d311a..57d2711166 100644 --- a/ui/components/findings/findings-filters.tsx +++ b/ui/components/findings/findings-filters.tsx @@ -20,10 +20,13 @@ import { DataTableFilterCustom } from "@/components/ui/table"; import { useFilterBatch } from "@/hooks/use-filter-batch"; import { getCategoryLabel, getGroupLabel } from "@/lib/categories"; import { FilterType, ScanEntity } from "@/types"; -import { DATA_TABLE_FILTER_MODE, FilterParam } from "@/types/filters"; +import { DATA_TABLE_FILTER_MODE } from "@/types/filters"; import { ProviderProps } from "@/types/providers"; -import { getFindingsFilterDisplayValue } from "./findings-filters.utils"; +import { + buildFindingsFilterChips, + getFindingsFilterDisplayValue, +} from "./findings-filters.utils"; interface FindingsFiltersProps { /** Provider data for ProviderTypeSelector and AccountsSelector */ @@ -37,30 +40,6 @@ interface FindingsFiltersProps { uniqueGroups: string[]; } -/** - * Maps raw filter param keys (e.g. "filter[severity__in]") to human-readable labels. - * Used to render chips in the FilterSummaryStrip. - * Typed as Record so TypeScript enforces exhaustiveness β€” any - * addition to FilterParam will cause a compile error here if the label is missing. - */ -const FILTER_KEY_LABELS: Record = { - "filter[provider_type__in]": "Provider", - "filter[provider_id__in]": "Account", - "filter[severity__in]": "Severity", - "filter[status__in]": "Status", - "filter[delta__in]": "Delta", - "filter[region__in]": "Region", - "filter[service__in]": "Service", - "filter[resource_type__in]": "Resource Type", - "filter[category__in]": "Category", - "filter[resource_groups__in]": "Resource Group", - "filter[scan__in]": "Scan", - "filter[scan_id]": "Scan", - "filter[scan_id__in]": "Scan", - "filter[inserted_at]": "Date", - "filter[muted]": "Muted", -}; - export const FindingsFilters = ({ providers, completedScanIds, @@ -145,25 +124,9 @@ export const FindingsFilters = ({ const hasCustomFilters = customFilters.length > 0; - // Build FilterChip[] from pendingFilters β€” one chip per individual value, not per key. - // Skip filter[muted]="false" β€” it is the silent default and should not appear as a chip. - const filterChips: FilterChip[] = []; - Object.entries(pendingFilters).forEach(([key, values]) => { - if (!values || values.length === 0) return; - const label = FILTER_KEY_LABELS[key as FilterParam] ?? key; - values.forEach((value) => { - // Do not show a chip for the default muted=false state - if (key === "filter[muted]" && value === "false") return; - filterChips.push({ - key, - label, - value, - displayValue: getFindingsFilterDisplayValue(key, value, { - providers, - scans: scanDetails, - }), - }); - }); + const filterChips: FilterChip[] = buildFindingsFilterChips(pendingFilters, { + providers, + scans: scanDetails, }); // Handler for removing a single chip: update the pending filter to remove that value. diff --git a/ui/components/findings/findings-filters.utils.test.ts b/ui/components/findings/findings-filters.utils.test.ts index b4dc63c85b..9fbd56bf63 100644 --- a/ui/components/findings/findings-filters.utils.test.ts +++ b/ui/components/findings/findings-filters.utils.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it } from "vitest"; import { ProviderProps } from "@/types/providers"; import { ScanEntity } from "@/types/scans"; -import { getFindingsFilterDisplayValue } from "./findings-filters.utils"; +import { + buildFindingsFilterChips, + getFindingsFilterDisplayValue, +} from "./findings-filters.utils"; function makeProvider( overrides: Partial & { id: string }, @@ -98,7 +101,7 @@ describe("getFindingsFilterDisplayValue", () => { it("shows the resolved scan badge label for scan filters instead of formatting the raw scan id", () => { expect( getFindingsFilterDisplayValue("filter[scan__in]", "scan-1", { scans }), - ).toBe("Nightly scan"); + ).toBe("AWS - Nightly scan"); }); it("normalizes finding statuses for display", () => { @@ -119,7 +122,17 @@ describe("getFindingsFilterDisplayValue", () => { ); }); - it("falls back to the scan provider uid when the alias is missing", () => { + it("formats the singular delta filter the same as delta__in", () => { + // The API registers the filter as `filter[delta]` (exact), not `delta__in`. + // Both shapes must resolve to the same human label so chips don't show + // the raw "new" going through formatLabel ("NEW" via the 3-letter acronym heuristic). + expect(getFindingsFilterDisplayValue("filter[delta]", "new")).toBe("New"); + expect(getFindingsFilterDisplayValue("filter[delta]", "changed")).toBe( + "Changed", + ); + }); + + it("uses the provider display name regardless of account alias/uid", () => { expect( getFindingsFilterDisplayValue("filter[scan__in]", "scan-2", { scans: [ @@ -133,17 +146,17 @@ describe("getFindingsFilterDisplayValue", () => { }), ], }), - ).toBe("Weekly scan"); + ).toBe("AWS - Weekly scan"); }); - it("falls back to the provider alias when the scan name is missing", () => { + it("returns only the provider name when the scan name is missing", () => { expect( getFindingsFilterDisplayValue("filter[scan__in]", "scan-3", { scans: [ ...scans, makeScanMap("scan-3", { providerInfo: { - provider: "aws", + provider: "gcp", alias: "Fallback Account", uid: "333333333333", }, @@ -154,7 +167,7 @@ describe("getFindingsFilterDisplayValue", () => { }), ], }), - ).toBe("Fallback Account"); + ).toBe("Google Cloud"); }); it("keeps the raw scan value when the scan cannot be resolved", () => { @@ -185,3 +198,85 @@ describe("getFindingsFilterDisplayValue", () => { ).toBe("2026-04-07"); }); }); + +describe("buildFindingsFilterChips", () => { + it("creates one chip per value with normalized labels", () => { + // Given β€” this is the exact pending state derived from the LinkToFindings URL: + // /findings?sort=...&filter[status__in]=FAIL&filter[delta]=new + const pendingFilters = { + "filter[status__in]": ["FAIL"], + "filter[delta]": ["new"], + }; + + // When + const chips = buildFindingsFilterChips(pendingFilters); + + // Then β€” both chips must appear; the delta chip must use "Delta" as label + // (not the raw "filter[delta]") and "New" as displayValue (not "NEW" via + // the short-word acronym heuristic in formatLabel). + expect(chips).toEqual([ + { + key: "filter[status__in]", + label: "Status", + value: "FAIL", + displayValue: "Fail", + }, + { + key: "filter[delta]", + label: "Delta", + value: "new", + displayValue: "New", + }, + ]); + }); + + it("treats filter[delta] and filter[delta__in] identically", () => { + // Given + const chipsSingular = buildFindingsFilterChips({ + "filter[delta]": ["new", "changed"], + }); + const chipsPlural = buildFindingsFilterChips({ + "filter[delta__in]": ["new", "changed"], + }); + + // Then β€” both shapes produce the same human labels and display values + expect( + chipsSingular.map((c) => ({ label: c.label, v: c.displayValue })), + ).toEqual([ + { label: "Delta", v: "New" }, + { label: "Delta", v: "Changed" }, + ]); + expect( + chipsPlural.map((c) => ({ label: c.label, v: c.displayValue })), + ).toEqual([ + { label: "Delta", v: "New" }, + { label: "Delta", v: "Changed" }, + ]); + }); + + it("skips the silent default filter[muted]=false", () => { + const chips = buildFindingsFilterChips({ + "filter[muted]": ["false"], + "filter[delta]": ["new"], + }); + + // Only the delta chip β€” the default muted=false should not surface + expect(chips).toHaveLength(1); + expect(chips[0].key).toBe("filter[delta]"); + }); + + it("surfaces unmapped keys using the raw key as label (fallback)", () => { + const chips = buildFindingsFilterChips({ + "filter[unknown_future_key]": ["value"], + }); + + expect(chips).toEqual([ + { + key: "filter[unknown_future_key]", + label: "filter[unknown_future_key]", + value: "value", + displayValue: "Value", + }, + ]); + }); +}); diff --git a/ui/components/findings/findings-filters.utils.ts b/ui/components/findings/findings-filters.utils.ts index 2599650c1d..0b8f752f69 100644 --- a/ui/components/findings/findings-filters.utils.ts +++ b/ui/components/findings/findings-filters.utils.ts @@ -1,5 +1,8 @@ +import type { FilterChip } from "@/components/filters/filter-summary-strip"; import { formatLabel, getCategoryLabel, getGroupLabel } from "@/lib/categories"; +import { getScanEntityLabel } from "@/lib/helper-filters"; import { FINDING_STATUS_DISPLAY_NAMES } from "@/types"; +import { FilterParam } from "@/types/filters"; import { getProviderDisplayName, ProviderProps } from "@/types/providers"; import { ScanEntity } from "@/types/scans"; import { SEVERITY_DISPLAY_NAMES } from "@/types/severities"; @@ -35,12 +38,7 @@ function getScanDisplayValue( return scanId; } - return ( - scan.attributes.name || - scan.providerInfo.alias || - scan.providerInfo.uid || - scanId - ); + return getScanEntityLabel(scan) || scanId; } export function getFindingsFilterDisplayValue( @@ -55,7 +53,7 @@ export function getFindingsFilterDisplayValue( if (filterKey === "filter[provider_id__in]") { return getProviderAccountDisplayValue(value, options.providers || []); } - if (filterKey === "filter[scan__in]") { + if (filterKey === "filter[scan__in]" || filterKey === "filter[scan]") { return getScanDisplayValue(value, options.scans || []); } if (filterKey === "filter[severity__in]") { @@ -72,7 +70,7 @@ export function getFindingsFilterDisplayValue( ] ?? formatLabel(value) ); } - if (filterKey === "filter[delta__in]") { + if (filterKey === "filter[delta__in]" || filterKey === "filter[delta]") { return ( FINDING_DELTA_DISPLAY_NAMES[value.toLowerCase()] ?? formatLabel(value) ); @@ -93,3 +91,67 @@ export function getFindingsFilterDisplayValue( return formatLabel(value); } + +/** + * Maps raw filter param keys (e.g. "filter[severity__in]") to human-readable labels. + * Used to render chips in the FilterSummaryStrip. + * Typed as Record so TypeScript enforces exhaustiveness β€” any + * addition to FilterParam will cause a compile error here if the label is missing. + */ +export const FILTER_KEY_LABELS: Record = { + "filter[provider_type__in]": "Provider", + "filter[provider_id__in]": "Account", + "filter[severity__in]": "Severity", + "filter[status__in]": "Status", + "filter[delta__in]": "Delta", + "filter[delta]": "Delta", + "filter[region__in]": "Region", + "filter[service__in]": "Service", + "filter[resource_type__in]": "Resource Type", + "filter[category__in]": "Category", + "filter[resource_groups__in]": "Resource Group", + "filter[scan]": "Scan", + "filter[scan__in]": "Scan", + "filter[scan_id]": "Scan", + "filter[scan_id__in]": "Scan", + "filter[inserted_at]": "Date", + "filter[muted]": "Muted", +}; + +interface BuildFindingsFilterChipsOptions { + providers?: ProviderProps[]; + scans?: Array<{ [scanId: string]: ScanEntity }>; +} + +/** + * Builds the chips displayed in the FilterSummaryStrip from a pendingFilters map. + * + * - One chip per individual value (not one per key), so a multi-select filter + * produces multiple chips. + * - Silently skips the default `filter[muted]=false` so it doesn't appear as a + * user-applied filter. + * - Falls back to the raw key as label for unmapped keys, so an unexpected + * param still surfaces instead of disappearing. + */ +export function buildFindingsFilterChips( + pendingFilters: Record, + options: BuildFindingsFilterChipsOptions = {}, +): FilterChip[] { + const chips: FilterChip[] = []; + + Object.entries(pendingFilters).forEach(([key, values]) => { + if (!values || values.length === 0) return; + const label = FILTER_KEY_LABELS[key as FilterParam] ?? key; + values.forEach((value) => { + if (key === "filter[muted]" && value === "false") return; + chips.push({ + key, + label, + value, + displayValue: getFindingsFilterDisplayValue(key, value, options), + }); + }); + }); + + return chips; +} diff --git a/ui/components/findings/table/column-standalone-findings.tsx b/ui/components/findings/table/column-standalone-findings.tsx index 9854f05d9d..cdd924a0b0 100644 --- a/ui/components/findings/table/column-standalone-findings.tsx +++ b/ui/components/findings/table/column-standalone-findings.tsx @@ -1,15 +1,15 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { Database } from "lucide-react"; +import { Container } from "lucide-react"; -import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; -import { DateWithTime } from "@/components/ui/entities"; +import { DateWithTime, EntityInfo } from "@/components/ui/entities"; import { DataTableColumnHeader, SeverityBadge, StatusFindingBadge, } from "@/components/ui/table"; +import { getRegionFlag } from "@/lib/region-flags"; import { FindingProps, ProviderType } from "@/types"; import { FindingDetailDrawer } from "./finding-detail-drawer"; @@ -126,18 +126,25 @@ export function getStandaloneFindingColumns({ ), cell: ({ row }) => { - const resourceName = getResourceData(row, "name"); - - if (resourceName === "-") { - return

-

; - } + const name = getResourceData(row, "name"); + const uid = getResourceData(row, "uid"); + const entityAlias = + typeof name === "string" && name.trim().length > 0 && name !== "-" + ? name + : undefined; + const entityId = + typeof uid === "string" && uid.trim().length > 0 && uid !== "-" + ? uid + : undefined; return ( - `...${value.slice(-10)}`} - icon={} - /> +
+ } + entityAlias={entityAlias} + entityId={entityId} + /> +
); }, enableSorting: false, @@ -161,12 +168,17 @@ export function getStandaloneFindingColumns({ { accessorKey: "provider", header: ({ column }) => ( - + ), cell: ({ row }) => { const provider = getProviderData(row, "provider"); - return ; + return ( + + ); }, enableSorting: false, }, @@ -193,10 +205,17 @@ export function getStandaloneFindingColumns({ cell: ({ row }) => { const region = getResourceData(row, "region"); const regionText = typeof region === "string" ? region : "-"; + const regionFlag = + typeof region === "string" ? getRegionFlag(region) : ""; return ( -

- {regionText} -

+ + {regionFlag && ( + + {regionFlag} + + )} + {regionText} + ); }, enableSorting: false, diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx index bdc104efbe..bebc9b0c4a 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx @@ -14,12 +14,14 @@ const { mockWindowOpen, mockClipboardWriteText, mockSearchParamsState, + mockNotificationIndicator, } = vi.hoisted(() => ({ mockGetComplianceIcon: vi.fn((_: string) => null as string | null), mockGetCompliancesOverview: vi.fn(), mockWindowOpen: vi.fn(), mockClipboardWriteText: vi.fn(), mockSearchParamsState: { value: "" }, + mockNotificationIndicator: vi.fn(), })); vi.mock("next/navigation", () => ({ @@ -298,7 +300,11 @@ vi.mock("../delta-indicator", () => ({ })); vi.mock("../notification-indicator", () => ({ - NotificationIndicator: () => null, + NotificationIndicator: (props: Record) => { + mockNotificationIndicator(props); + return null; + }, + DeltaValues: { NEW: "new", CHANGED: "changed", NONE: "none" } as const, })); vi.mock("./resource-detail-skeleton", () => ({ @@ -1348,3 +1354,79 @@ describe("ResourceDetailDrawerContent β€” header skeleton while navigating", () ).toBeInTheDocument(); }); }); + +describe("ResourceDetailDrawerContent β€” other findings delta/muted indicator", () => { + const renderWithOtherFinding = ( + overrides: Partial, + ) => { + const otherFinding: ResourceDrawerFinding = { + ...mockFinding, + id: "finding-2", + uid: "uid-2", + checkId: "ec2_check", + checkTitle: "EC2 Check", + ...overrides, + }; + render( + , + ); + }; + + const lastNotificationIndicatorPropsForOtherRow = () => { + const calls = mockNotificationIndicator.mock.calls; + // Last call corresponds to the other-finding row (current finding row renders first). + return calls[calls.length - 1][0]; + }; + + it("should forward delta='new' to the NotificationIndicator for a new other finding", () => { + renderWithOtherFinding({ delta: "new" }); + + expect(lastNotificationIndicatorPropsForOtherRow()).toMatchObject({ + delta: "new", + isMuted: false, + showDeltaWhenMuted: true, + }); + }); + + it("should forward delta='changed' to the NotificationIndicator for a changed other finding", () => { + renderWithOtherFinding({ delta: "changed" }); + + expect(lastNotificationIndicatorPropsForOtherRow()).toMatchObject({ + delta: "changed", + }); + }); + + it("should pass delta=undefined when the finding has delta='none'", () => { + renderWithOtherFinding({ delta: "none" }); + + expect(lastNotificationIndicatorPropsForOtherRow()).toMatchObject({ + delta: undefined, + }); + }); + + it("should forward mutedReason and keep delta when a muted other finding is also new", () => { + renderWithOtherFinding({ + delta: "new", + isMuted: true, + mutedReason: "False positive", + }); + + expect(lastNotificationIndicatorPropsForOtherRow()).toMatchObject({ + delta: "new", + isMuted: true, + mutedReason: "False positive", + showDeltaWhenMuted: true, + }); + }); +}); diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index 626f33bfbd..6042d5a39f 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -74,7 +74,7 @@ import type { FindingResourceRow } from "@/types/findings-table"; import { Muted } from "../../muted"; import { DeltaIndicator } from "../delta-indicator"; -import { NotificationIndicator } from "../notification-indicator"; +import { DeltaValues, NotificationIndicator } from "../notification-indicator"; import { ResourceDetailSkeleton } from "./resource-detail-skeleton"; import type { CheckMeta } from "./use-resource-detail-drawer"; @@ -1313,7 +1313,17 @@ function OtherFindingRow({ onClick={() => window.open(findingUrl, "_blank", "noopener,noreferrer")} > - + diff --git a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts index f767184598..9c5a731a51 100644 --- a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts +++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts @@ -176,10 +176,12 @@ describe("useResourceDetailDrawer β€” other findings filtering", () => { getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] }); }); - it("should load other findings from the current resource uid and exclude the current finding", async () => { + it("should load other findings from the current resource uid and exclude only the current finding (status is filtered server-side)", async () => { const resources = [makeResource()]; - // Given + // Given β€” the API call applies filter[status]=FAIL server-side, so the + // mock returns only FAIL rows. The hook's only client-side job is to + // drop the row already shown above the table. getFindingByIdMock.mockResolvedValue({ data: ["detail"] }); getLatestFindingsByResourceUidMock.mockResolvedValue({ data: ["resource"], @@ -192,7 +194,7 @@ describe("useResourceDetailDrawer β€” other findings filtering", () => { id: "finding-1", checkId: "s3_check", checkTitle: "Current", - status: "MANUAL", + status: "FAIL", severity: "informational", }), ]; @@ -211,12 +213,6 @@ describe("useResourceDetailDrawer β€” other findings filtering", () => { status: "FAIL", severity: "critical", }), - makeDrawerFinding({ - id: "finding-4", - checkTitle: "Manual finding should be filtered out", - status: "MANUAL", - severity: "low", - }), makeDrawerFinding({ id: "finding-5", checkTitle: "Second other finding", diff --git a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts index 7a85b3d994..1a4a9c37f9 100644 --- a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts +++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts @@ -228,10 +228,10 @@ export function useResourceDetailDrawer({ : null; setCurrentFinding(nextCurrentFinding); + // The API already filters to status=FAIL (see getLatestFindingsByResourceUid). + // Only need to drop the current finding from the list. setOtherFindings( - nextOtherFindings.filter( - (finding) => finding.id !== findingId && finding.status === "FAIL", - ), + nextOtherFindings.filter((finding) => finding.id !== findingId), ); } catch (_error) { if (!controller.signal.aborted) { diff --git a/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.test.tsx b/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.test.tsx new file mode 100644 index 0000000000..fff67495d4 --- /dev/null +++ b/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + ...rest + }: { + children: ReactNode; + href: string; + "aria-label"?: string; + className?: string; + }) => ( + + {children} + + ), +})); + +import { LinkToFindings } from "./link-to-findings"; + +describe("LinkToFindings", () => { + it("should link to findings sorted by severity (desc) then last_seen_at (desc), filtered to FAIL + new delta", () => { + render(); + + const link = screen.getByRole("link", { name: "Go to Findings page" }); + const href = link.getAttribute("href") ?? ""; + const [, query = ""] = href.split("?"); + const params = new URLSearchParams(query); + + expect(params.get("sort")).toBe("-severity,-last_seen_at"); + expect(params.get("filter[status__in]")).toBe("FAIL"); + // filter[delta] must be singular β€” the finding-groups filter does not + // register `delta__in`, so the plural form is silently dropped by the API. + expect(params.get("filter[delta]")).toBe("new"); + expect(params.has("filter[delta__in]")).toBe(false); + }); + + it("should render as a tertiary text link (not a solid button) to match the overview Card pattern", () => { + render(); + + const link = screen.getByRole("link", { name: "Go to Findings page" }); + expect(link.className).toContain("text-button-tertiary"); + expect(link.className).toContain("hover:text-button-tertiary-hover"); + }); +}); diff --git a/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.tsx b/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.tsx index 74272133ce..41b9f69e75 100644 --- a/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.tsx +++ b/ui/components/overview/new-findings-table/link-to-findings/link-to-findings.tsx @@ -1,20 +1,13 @@ -"use client"; - import Link from "next/link"; -import { Button } from "@/components/shadcn/button/button"; - export const LinkToFindings = () => { return ( -
- -
+ + Check out on Findings + ); }; diff --git a/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx b/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx index 7783e7ab2d..df5017712e 100644 --- a/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx +++ b/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx @@ -1,39 +1,114 @@ -import React from "react"; - -import { Card } from "@/components/shadcn/card/card"; import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; -export const SkeletonTableNewFindings = () => { - const columns = 7; - const rows = 3; - +const SkeletonTableRow = () => { return ( - - {/* Table headers */} -
- {Array.from({ length: columns }).map((_, index) => ( - - ))} -
- - {/* Table body */} -
- {Array.from({ length: rows }).map((_, rowIndex) => ( -
- {Array.from({ length: columns }).map((_, colIndex) => ( - - ))} -
- ))} -
-
+
+ {/* Notification dot */} + + {/* Status badge */} + + {/* Finding title */} + + {/* Resource name */} + + {/* Severity badge */} + + {/* Provider icon */} + + {/* Service */} + + {/* Region β€” flag + name */} + + {/* Time */} + + + ); +}; + +export const SkeletonTableNewFindings = () => { + const rows = 10; + + return ( +
+ {/* Header: title + description on the left, link on the right */} +
+
+ + +
+ +
+ + {/* Table */} +
+ + + + + + +
+ + +
+
+ + + + + + +
+ + +
+
+ +
+ + + {/* Notification header (no text) */} + + {/* Finding */} + + {/* Resource name */} + + {/* Severity */} + + {/* Cloud Provider */} + + {/* Service */} + + {/* Region */} + + {/* Time */} + + + + + {Array.from({ length: rows }).map((_, i) => ( + + ))} + +
+ {/* Status */} + + + + + + + + + + + + + + + + +
+
); }; diff --git a/ui/components/scans/table/scans/column-get-scans.test.ts b/ui/components/scans/table/scans/column-get-scans.test.ts new file mode 100644 index 0000000000..88757c5d98 --- /dev/null +++ b/ui/components/scans/table/scans/column-get-scans.test.ts @@ -0,0 +1,22 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("column-get-scans", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "column-get-scans.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("links scan findings to the historical finding-groups filters", () => { + expect(source).toContain("filter[scan]="); + expect(source).toContain("filter[inserted_at]="); + expect(source).not.toContain("filter[scan__in]"); + }); + + it("links the findings filter against the scan's completed_at (what the backend expects)", () => { + expect(source).toMatch(/attributes:\s*{\s*completed_at\s*}/); + expect(source).toMatch(/toLocalDateString\(completed_at\)/); + }); +}); diff --git a/ui/components/scans/table/scans/column-get-scans.tsx b/ui/components/scans/table/scans/column-get-scans.tsx index 5f55628b29..e9a073ac1c 100644 --- a/ui/components/scans/table/scans/column-get-scans.tsx +++ b/ui/components/scans/table/scans/column-get-scans.tsx @@ -8,10 +8,10 @@ import { TableLink } from "@/components/ui/custom"; import { DateWithTime, EntityInfo } from "@/components/ui/entities"; import { TriggerSheet } from "@/components/ui/sheet"; import { DataTableColumnHeader, StatusBadge } from "@/components/ui/table"; +import { toLocalDateString } from "@/lib/date-utils"; import { ProviderType, ScanProps } from "@/types"; import { TriggerIcon } from "../../trigger-icon"; -import { DataTableDownloadDetails } from "./data-table-download-details"; import { DataTableRowActions } from "./data-table-row-actions"; import { DataTableRowDetails } from "./data-table-row-details"; @@ -97,24 +97,6 @@ export const ColumnGetScans: ColumnDef[] = [ enableSorting: false, }, - { - accessorKey: "started_at", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const { - attributes: { started_at }, - } = getScanData(row); - - return ( -
- -
- ); - }, - enableSorting: false, - }, { accessorKey: "status", header: ({ column }) => ( @@ -141,12 +123,22 @@ export const ColumnGetScans: ColumnDef[] = [ ), cell: ({ row }) => { - const { id } = getScanData(row); + const { + id, + attributes: { completed_at }, + } = getScanData(row); const scanState = row.original.attributes?.state; + // Source is `completed_at` (scan finish time) because findings are + // persisted when the scan ends β€” that's when their `inserted_at` is + // written. The URL key stays `filter[inserted_at]` because the findings + // table is partitioned by the finding's `inserted_at` date; this filter + // is the partition hint the backend uses to avoid scanning every + // partition. Names differ by design: scan.completed_at β‰ˆ finding.inserted_at. + const scanDate = toLocalDateString(completed_at); return ( ); @@ -171,24 +163,10 @@ export const ColumnGetScans: ColumnDef[] = [ }, enableSorting: false, }, - { - id: "download", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - return ( -
- -
- ); - }, - enableSorting: false, - }, { accessorKey: "resources", header: ({ column }) => ( - + ), cell: ({ row }) => { const { @@ -202,6 +180,24 @@ export const ColumnGetScans: ColumnDef[] = [ }, enableSorting: false, }, + { + accessorKey: "started_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { + attributes: { started_at }, + } = getScanData(row); + + return ( +
+ +
+ ); + }, + enableSorting: false, + }, { accessorKey: "scheduled_at", header: ({ column }) => ( diff --git a/ui/components/scans/table/scans/data-table-download-details.tsx b/ui/components/scans/table/scans/data-table-download-details.tsx deleted file mode 100644 index 4579ef3c56..0000000000 --- a/ui/components/scans/table/scans/data-table-download-details.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Row } from "@tanstack/react-table"; -import { useState } from "react"; - -import { DownloadIconButton, useToast } from "@/components/ui"; -import { downloadScanZip } from "@/lib"; - -interface DataTableDownloadDetailsProps { - row: Row; -} - -export function DataTableDownloadDetails({ - row, -}: DataTableDownloadDetailsProps) { - const { toast } = useToast(); - const [isDownloading, setIsDownloading] = useState(false); - - const scanId = (row.original as { id: string }).id; - const scanState = (row.original as any).attributes?.state; - - const handleDownload = async () => { - setIsDownloading(true); - await downloadScanZip(scanId, toast); - setIsDownloading(false); - }; - - return ( - - ); -} diff --git a/ui/components/ui/table/data-table-filter-custom.tsx b/ui/components/ui/table/data-table-filter-custom.tsx index da95dd7aff..28988b34d8 100644 --- a/ui/components/ui/table/data-table-filter-custom.tsx +++ b/ui/components/ui/table/data-table-filter-custom.tsx @@ -15,7 +15,11 @@ import { } from "@/components/shadcn/select/multiselect"; import { EntityInfo } from "@/components/ui/entities/entity-info"; import { useUrlFilters } from "@/hooks/use-url-filters"; -import { isConnectionStatus, isScanEntity } from "@/lib/helper-filters"; +import { + getScanEntityLabel, + isConnectionStatus, + isScanEntity, +} from "@/lib/helper-filters"; import { cn } from "@/lib/utils"; import { FilterEntity, @@ -84,10 +88,11 @@ export const DataTableFilterCustom = ({ if (!entity) return value; if (isScanEntity(entity as ScanEntity)) { - const scanEntity = entity as ScanEntity; - return ( - scanEntity.providerInfo?.alias || scanEntity.providerInfo?.uid || value - ); + // Match the summary-strip chip: "Scan: {provider} - {name}". Without the + // "Scan:" prefix, the trigger badge would just say "AWS Prod - Nightly", + // which reads as a generic account tag and hides that it's a scan filter. + const label = getScanEntityLabel(entity as ScanEntity); + return label ? `Scan: ${label}` : value; } if (isConnectionStatus(entity)) { const connectionStatus = entity as ProviderConnectionStatus; diff --git a/ui/components/ui/table/data-table.tsx b/ui/components/ui/table/data-table.tsx index 339a5ab6d0..7cf78271a0 100644 --- a/ui/components/ui/table/data-table.tsx +++ b/ui/components/ui/table/data-table.tsx @@ -110,6 +110,8 @@ interface DataTableProviderProps { searchBadge?: { label: string; onDismiss: () => void }; /** Optional click handler for top-level rows. */ onRowClick?: (row: Row) => void; + /** Optional header rendered inside the table container, above the toolbar. */ + header?: ReactNode; } export function DataTable({ @@ -140,6 +142,7 @@ export function DataTable({ renderAfterRow, searchBadge, onRowClick, + header, }: DataTableProviderProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); @@ -235,6 +238,7 @@ export function DataTable({ isPending && "pointer-events-none opacity-60", )} > + {header &&
{header}
} {/* Table Toolbar */} {showToolbar && (
diff --git a/ui/hooks/use-filter-batch.test.ts b/ui/hooks/use-filter-batch.test.ts index 675316402e..8107b7d357 100644 --- a/ui/hooks/use-filter-batch.test.ts +++ b/ui/hooks/use-filter-batch.test.ts @@ -61,6 +61,43 @@ describe("useFilterBatch", () => { }); expect(result.current.hasChanges).toBe(false); }); + + it("should expose filter[delta]=new under the FilterType.DELTA key so the dropdown shows it selected", async () => { + // Given β€” URL from LinkToFindings uses `filter[delta]` (singular), matching the API. + setSearchParams({ + "filter[status__in]": "FAIL", + "filter[delta]": "new", + }); + + const { FilterType } = await import("@/types/filters"); + + // When + const { result } = renderHook(() => useFilterBatch()); + + // Then β€” the Delta dropdown reads via getFilterValue(`filter[${FilterType.DELTA}]`). + // For the checkbox of "new" to appear checked, that lookup must return ["new"]. + expect( + result.current.getFilterValue(`filter[${FilterType.DELTA}]`), + ).toEqual(["new"]); + }); + + it("should include both filter[status__in] and filter[delta] from the overview deep link", () => { + // Given β€” URL produced by LinkToFindings: /findings?...&filter[status__in]=FAIL&filter[delta]=new + setSearchParams({ + "filter[status__in]": "FAIL", + "filter[delta]": "new", + }); + + // When + const { result } = renderHook(() => useFilterBatch()); + + // Then β€” the singular `filter[delta]` key must be captured in pendingFilters + // so FindingsFilters can render a chip for it (same as filter[status__in]). + expect(result.current.pendingFilters).toEqual({ + "filter[status__in]": ["FAIL"], + "filter[delta]": ["new"], + }); + }); }); // ── Excluded keys ────────────────────────────────────────────────────────── diff --git a/ui/lib/date-utils.test.ts b/ui/lib/date-utils.test.ts new file mode 100644 index 0000000000..894506ffd1 --- /dev/null +++ b/ui/lib/date-utils.test.ts @@ -0,0 +1,35 @@ +import { format, parseISO } from "date-fns"; +import { describe, expect, it } from "vitest"; + +import { toLocalDateString } from "./date-utils"; + +describe("toLocalDateString", () => { + it("returns undefined for nullish or empty input", () => { + expect(toLocalDateString(undefined)).toBeUndefined(); + expect(toLocalDateString(null)).toBeUndefined(); + expect(toLocalDateString("")).toBeUndefined(); + }); + + it("returns undefined for malformed strings", () => { + expect(toLocalDateString("not-a-date")).toBeUndefined(); + }); + + it("returns undefined for invalid Date instances", () => { + expect(toLocalDateString(new Date("not-a-date"))).toBeUndefined(); + }); + + it("formats an ISO string in the user's local timezone", () => { + // Near UTC midnight β€” the UTC split ("2026-04-19") differs from the local + // date for any tz with a positive offset. We pin parity with date-fns so + // the assertion holds regardless of where CI runs. + const iso = "2026-04-19T23:15:00Z"; + const expected = format(parseISO(iso), "yyyy-MM-dd"); + + expect(toLocalDateString(iso)).toBe(expected); + }); + + it("formats a Date instance using its local calendar day", () => { + const date = new Date(2026, 3, 20, 10, 0, 0); // April 20, 2026 local + expect(toLocalDateString(date)).toBe("2026-04-20"); + }); +}); diff --git a/ui/lib/date-utils.ts b/ui/lib/date-utils.ts index 40717b4e14..7bce896580 100644 --- a/ui/lib/date-utils.ts +++ b/ui/lib/date-utils.ts @@ -1,4 +1,26 @@ -import { formatDistanceToNow } from "date-fns"; +import { format, formatDistanceToNow, parseISO } from "date-fns"; + +/** + * Formats an ISO string or Date into a `yyyy-MM-dd` string in the user's local + * timezone. Mirrors the format used by `DateWithTime`, so UI chips/URLs built + * with this helper match what the user sees in tables and pickers. Returns + * undefined for null, empty, or malformed input so callers can guard on it + * (e.g. `isDisabled={!toLocalDateString(x)}`). Do NOT use this for UTC-based + * date bucketing (e.g. chart axes partitioned server-side by UTC day) β€” that + * use case needs a separate UTC helper. + */ +export function toLocalDateString( + value: string | Date | null | undefined, +): string | undefined { + if (!value) return undefined; + try { + const date = typeof value === "string" ? parseISO(value) : value; + if (isNaN(date.getTime())) return undefined; + return format(date, "yyyy-MM-dd"); + } catch { + return undefined; + } +} /** * Formats a duration in seconds to a human-readable string like "2h 5m 30s". diff --git a/ui/lib/findings-scan-filters.test.ts b/ui/lib/findings-scan-filters.test.ts index fcf32b507d..a1e4d4a4e0 100644 --- a/ui/lib/findings-scan-filters.test.ts +++ b/ui/lib/findings-scan-filters.test.ts @@ -50,7 +50,7 @@ describe("resolveFindingScanDateFilters", () => { { id: "scan-1", attributes: { - inserted_at: "2026-04-07T10:00:00Z", + completed_at: "2026-04-07T10:00:00Z", }, }, ], @@ -68,7 +68,7 @@ describe("resolveFindingScanDateFilters", () => { const loadScan = vi.fn().mockResolvedValue({ id: "scan-2", attributes: { - inserted_at: "2026-04-05T08:00:00Z", + completed_at: "2026-04-05T08:00:00Z", }, }); @@ -97,7 +97,7 @@ describe("resolveFindingScanDateFilters", () => { { id: "scan-1", attributes: { - inserted_at: "2026-04-07T10:00:00Z", + completed_at: "2026-04-07T10:00:00Z", }, }, ], diff --git a/ui/lib/findings-scan-filters.ts b/ui/lib/findings-scan-filters.ts index dfbb1bc44c..a5984cb141 100644 --- a/ui/lib/findings-scan-filters.ts +++ b/ui/lib/findings-scan-filters.ts @@ -1,7 +1,11 @@ interface ScanDateSource { id: string; attributes?: { - inserted_at?: string; + // Findings are persisted when the scan finishes, so their `inserted_at` + // aligns with the scan's `completed_at` β€” not the scan's `inserted_at` + // (which is when the scan row was first created and can fall on a + // different UTC day for scans that cross midnight). + completed_at?: string; }; } @@ -34,10 +38,10 @@ function hasInsertedAtFilter(filters: Record): boolean { } export function buildFindingScanDateFilters( - scanInsertedAtValues: string[], + scanCompletedAtValues: string[], ): Record { const dates = Array.from( - new Set(scanInsertedAtValues.map(formatScanDate).filter(Boolean)), + new Set(scanCompletedAtValues.map(formatScanDate).filter(Boolean)), ).sort() as string[]; if (dates.length === 0) { @@ -82,11 +86,11 @@ export async function resolveFindingScanDateFilters({ }); } - const scanInsertedAtValues = scanIds - .map((scanId) => scansById.get(scanId)?.attributes?.inserted_at) - .filter((insertedAt): insertedAt is string => Boolean(insertedAt)); + const scanCompletedAtValues = scanIds + .map((scanId) => scansById.get(scanId)?.attributes?.completed_at) + .filter((completedAt): completedAt is string => Boolean(completedAt)); - const dateFilters = buildFindingScanDateFilters(scanInsertedAtValues); + const dateFilters = buildFindingScanDateFilters(scanCompletedAtValues); if (Object.keys(dateFilters).length === 0) { return filters; diff --git a/ui/lib/helper-filters.test.ts b/ui/lib/helper-filters.test.ts index fcff4a85d8..858a95e68f 100644 --- a/ui/lib/helper-filters.test.ts +++ b/ui/lib/helper-filters.test.ts @@ -1,16 +1,39 @@ import { describe, expect, it } from "vitest"; +import type { ScanEntity } from "@/types/scans"; + import { + getScanEntityLabel, hasDateFilter, hasDateOrScanFilter, hasHistoricalFindingFilter, } from "./helper-filters"; +function makeScan(overrides: Partial = {}): ScanEntity { + return { + id: "scan-1", + providerInfo: { + provider: "aws", + alias: "Production", + uid: "123456789012", + }, + attributes: { + name: "Nightly scan", + completed_at: "2026-04-07T10:00:00Z", + }, + ...overrides, + }; +} + describe("hasDateOrScanFilter", () => { it("returns true for scan filters", () => { expect(hasDateOrScanFilter({ "filter[scan__in]": "scan-1" })).toBe(true); }); + it("returns true for exact scan filters", () => { + expect(hasDateOrScanFilter({ "filter[scan]": "scan-1" })).toBe(true); + }); + it("returns true for inserted_at filters", () => { expect( hasDateOrScanFilter({ "filter[inserted_at__gte]": "2026-04-01" }), @@ -30,6 +53,43 @@ describe("hasDateFilter", () => { }); }); +describe("getScanEntityLabel", () => { + it("combines provider display name and scan name with a dash", () => { + expect(getScanEntityLabel(makeScan())).toBe("AWS - Nightly scan"); + }); + + it("uses the provider type even when the account alias is present", () => { + // Guard against regressions where alias/uid leak back into the label. + expect( + getScanEntityLabel( + makeScan({ + providerInfo: { + provider: "azure", + alias: "Production", + uid: "subscription-xyz", + }, + }), + ), + ).toBe("Azure - Nightly scan"); + }); + + it("renders the provider display name for non-AWS providers", () => { + expect( + getScanEntityLabel(makeScan({ providerInfo: { provider: "gcp" } })), + ).toBe("Google Cloud - Nightly scan"); + }); + + it("returns only the provider name when the scan name is missing", () => { + expect( + getScanEntityLabel( + makeScan({ + attributes: { name: "", completed_at: "2026-04-07T10:00:00Z" }, + }), + ), + ).toBe("AWS"); + }); +}); + describe("hasHistoricalFindingFilter", () => { it("returns true for inserted_at filters", () => { expect( @@ -43,6 +103,10 @@ describe("hasHistoricalFindingFilter", () => { ); }); + it("returns true for exact scan filters", () => { + expect(hasHistoricalFindingFilter({ "filter[scan]": "scan-1" })).toBe(true); + }); + it("returns false when neither date nor scan filters are active", () => { expect( hasHistoricalFindingFilter({ "filter[provider_type__in]": "aws" }), diff --git a/ui/lib/helper-filters.ts b/ui/lib/helper-filters.ts index 7280deb53d..ae510bdc83 100644 --- a/ui/lib/helper-filters.ts +++ b/ui/lib/helper-filters.ts @@ -1,6 +1,10 @@ import { ProviderProps, ProvidersApiResponse, ScanProps } from "@/types"; import { FilterEntity } from "@/types/filters"; -import { GroupFilterEntity, ProviderConnectionStatus } from "@/types/providers"; +import { + getProviderDisplayName, + GroupFilterEntity, + ProviderConnectionStatus, +} from "@/types/providers"; import { ScanEntity } from "@/types/scans"; /** @@ -31,7 +35,10 @@ export const extractFiltersAndQuery = ( */ export const hasDateOrScanFilter = (searchParams: Record) => Object.keys(searchParams).some( - (key) => key.includes("inserted_at") || key.includes("scan__in"), + (key) => + key.includes("inserted_at") || + key.includes("scan__in") || + key === "filter[scan]", ); /** @@ -96,6 +103,22 @@ export const isScanEntity = (entity: ScanEntity) => { return entity && entity.providerInfo && entity.attributes; }; +/** + * Canonical human label for a scan entity: "{Provider name} - {scan name}". + * Provider name comes from `getProviderDisplayName` (e.g. "AWS", "Google Cloud"), + * never the account alias/uid β€” those identify the account, not the provider. + * Shared by the findings filter chips and the multi-select trigger badge so + * both surfaces stay in sync. Returns the provider name alone when the scan + * name is empty, or the scan name alone if the provider type doesn't resolve. + */ +export function getScanEntityLabel(scan: ScanEntity): string { + const providerLabel = getProviderDisplayName(scan.providerInfo.provider); + const scanName = scan.attributes.name || ""; + + if (providerLabel && scanName) return `${providerLabel} - ${scanName}`; + return providerLabel || scanName; +} + /** * Creates a scan details mapping for filters from completed scans. * Used to provide detailed information for scan filters in the UI. diff --git a/ui/types/filters.ts b/ui/types/filters.ts index ccaf7f2c53..9630318712 100644 --- a/ui/types/filters.ts +++ b/ui/types/filters.ts @@ -39,7 +39,9 @@ export enum FilterType { RESOURCE_TYPE = "resource_type__in", SEVERITY = "severity__in", STATUS = "status__in", - DELTA = "delta__in", + // The API only registers `delta` (exact, singular). `delta__in` is silently + // dropped, so the dropdown, URL, and backend must all use `delta`. + DELTA = "delta", CATEGORY = "category__in", RESOURCE_GROUPS = "resource_groups__in", } @@ -68,11 +70,13 @@ export type FilterParam = | "filter[severity__in]" | "filter[status__in]" | "filter[delta__in]" + | "filter[delta]" | "filter[region__in]" | "filter[service__in]" | "filter[resource_type__in]" | "filter[category__in]" | "filter[resource_groups__in]" + | "filter[scan]" | "filter[scan__in]" | "filter[scan_id]" | "filter[scan_id__in]" From 2a9c538aff5e411b3385f3cd38ce43107528fbf5 Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Mon, 20 Apr 2026 14:01:29 +0200 Subject: [PATCH 08/26] chore: review changelog for v5.24.1 (#10791) --- api/CHANGELOG.md | 2 +- prowler/CHANGELOG.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index f3dab7cf9e..a0c50c8110 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to the **Prowler API** are documented in this file. ### 🐞 Fixed +- Finding group resources endpoints now include findings without associated resources (orphaned IaC findings) as simulated resource rows, and return one row per finding when multiple findings share a resource [(#10708)](https://github.com/prowler-cloud/prowler/pull/10708) - Attack Paths: Missing `tenant_id` filter while getting related findings after scan completes [(#10722)](https://github.com/prowler-cloud/prowler/pull/10722) - Finding group counters `pass_count`, `fail_count` and `manual_count` now exclude muted findings [(#10753)](https://github.com/prowler-cloud/prowler/pull/10753) - Silent data loss in `ResourceFindingMapping` bulk insert that left findings orphaned when `INSERT ... ON CONFLICT DO NOTHING` dropped rows without raising; added explicit `unique_fields` [(#10724)](https://github.com/prowler-cloud/prowler/pull/10724) @@ -27,7 +28,6 @@ All notable changes to the **Prowler API** are documented in this file. - Worker-beat race condition on cold start: replaced `sleep 15` with API service healthcheck dependency (Docker Compose) and init containers (Helm), aligned Gunicorn default port to `8080` [(#10603)](https://github.com/prowler-cloud/prowler/pull/10603) - API container startup crash on Linux due to root-owned bind-mount preventing JWT key generation [(#10646)](https://github.com/prowler-cloud/prowler/pull/10646) -- Finding group resources endpoints now include findings without associated resources (orphan IaC findings) as simulated resource rows, and return one row per finding when multiple findings share a resource [(#10708)](https://github.com/prowler-cloud/prowler/pull/10708) ### πŸ” Security diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 9a3369b8fb..d61a76b657 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,17 +2,18 @@ All notable changes to the **Prowler SDK** are documented in this file. -## [5.24.1] (Prowler UNRELEASED) +## [5.24.1] (Prowler v5.24.1) ### πŸ”„ Changed -- bumped `msgraph-sdk` from 1.23.0 to 1.55.0 and `azure-mgmt-resource` from 23.3.0 to 24.0.0, removing `marshmallow` as is a transitively dev dependency [(#10733)](https://github.com/prowler-cloud/prowler/pull/10733) +- `msgraph-sdk` from 1.23.0 to 1.55.0 and `azure-mgmt-resource` from 23.3.0 to 24.0.0, removing `marshmallow` as is a transitively dev dependency [(#10733)](https://github.com/prowler-cloud/prowler/pull/10733) ### 🐞 Fixed - Cloudflare account-scoped API tokens failing connection test in the App with `CloudflareUserTokenRequiredError` [(#10723)](https://github.com/prowler-cloud/prowler/pull/10723) - `prowler image --registry` failing with `ImageNoImagesProvidedError` due to registry arguments not being forwarded to `ImageProvider` in `init_global_provider` [(#10470)](https://github.com/prowler-cloud/prowler/pull/10470) - Google Workspace Calendar checks false FAIL on unconfigured settings with secure Google defaults [(#10726)](https://github.com/prowler-cloud/prowler/pull/10726) +- Google Workspace Drive checks false FAIL on unconfigured settings with secure Google defaults [(#10727)](https://github.com/prowler-cloud/prowler/pull/10727) - Cloudflare `validate_credentials` can hang in an infinite pagination loop when the SDK repeats accounts, blocking connection tests [(#10771)](https://github.com/prowler-cloud/prowler/pull/10771) --- @@ -43,7 +44,6 @@ All notable changes to the **Prowler SDK** are documented in this file. - `prowler image --registry-list` crashes with `AttributeError` because `ImageProvider.__init__` returns early before registering the global provider [(#10691)](https://github.com/prowler-cloud/prowler/pull/10691) - Vercel firewall config handling for team-scoped projects and current API response shapes [(#10695)](https://github.com/prowler-cloud/prowler/pull/10695) -- Google Workspace Drive checks false FAIL on unconfigured settings with secure Google defaults [(#10727)](https://github.com/prowler-cloud/prowler/pull/10727) --- From dcec79d259c7dac22a2c90e3b5b1d24dd87fbef1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:43:19 +0200 Subject: [PATCH 09/26] chore(deps): bump pyasn1 from 0.6.2 to 0.6.3 in /api (#10366) --- api/poetry.lock | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/api/poetry.lock b/api/poetry.lock index e066685a9b..6bad57a758 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "about-time" @@ -2974,7 +2974,7 @@ files = [ [package.dependencies] autopep8 = "*" Django = ">=4.2" -gprof2dot = ">=2017.9.19" +gprof2dot = ">=2017.09.19" sqlparse = "*" [[package]] @@ -4582,7 +4582,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.3.6" +jsonschema-specifications = ">=2023.03.6" referencing = ">=0.28.4" rpds-py = ">=0.7.1" @@ -4790,7 +4790,7 @@ librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""] mongodb = ["pymongo (==4.15.3)"] msgpack = ["msgpack (==1.1.2)"] pyro = ["pyro4 (==4.82)"] -qpid = ["qpid-python (==1.36.0.post1)", "qpid-tools (==1.36.0.post1)"] +qpid = ["qpid-python (==1.36.0-1)", "qpid-tools (==1.36.0-1)"] redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2,<6.5)"] slmq = ["softlayer_messaging (>=1.0.3)"] sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"] @@ -4811,7 +4811,7 @@ files = [ ] [package.dependencies] -certifi = ">=14.5.14" +certifi = ">=14.05.14" durationpy = ">=0.7" google-auth = ">=1.0.1" oauthlib = ">=3.2.2" @@ -6920,14 +6920,14 @@ pydantic = ">=2.12.0,<3.0.0" [[package]] name = "pyasn1" -version = "0.6.2" +version = "0.6.3" description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"}, - {file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"}, + {file = "pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"}, + {file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"}, ] [[package]] @@ -7194,7 +7194,7 @@ files = [ ] [package.dependencies] -astroid = ">=3.2.2,<=3.3.0.dev0" +astroid = ">=3.2.2,<=3.3.0-dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, @@ -8209,10 +8209,10 @@ files = [ ] [package.dependencies] -botocore = ">=1.37.4,<2.0a0" +botocore = ">=1.37.4,<2.0a.0" [package.extras] -crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] +crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] [[package]] name = "safety" From 4346401a0aab31def9c2b37cc5cc91dd732d9db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pe=C3=B1a?= Date: Mon, 20 Apr 2026 17:16:01 +0200 Subject: [PATCH 10/26] fix(api): align latest_resources scan selection with completed_at (#10802) --- api/CHANGELOG.md | 8 ++ api/src/backend/api/tests/test_views.py | 108 ++++++++++++++++++++++++ api/src/backend/api/v1/views.py | 7 +- 3 files changed, 121 insertions(+), 2 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index a0c50c8110..c45b89dee7 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.25.2] (Prowler v5.24.2) + +### 🐞 Fixed + +- `/finding-groups/latest//resources` now selects the latest completed scan per provider by `-completed_at` (then `-inserted_at`) instead of `-inserted_at`, matching the `/finding-groups/latest` summary path and the daily-summary upsert so overlapping scans no longer produce diverging `delta`/`new_count` between the two endpoints [(#10802)](https://github.com/prowler-cloud/prowler/pull/10802) + +--- + ## [1.25.1] (Prowler v5.24.1) ### πŸ”„ Changed diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index c519aa104b..3d2107a03e 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -17271,3 +17271,111 @@ class TestFindingGroupViewSet: attrs = item["attributes"] assert "finding_id" in attrs assert attrs["finding_id"] in rds_finding_ids + + def test_latest_resources_picks_scan_by_completed_at_when_overlap( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + resources_fixture, + ): + """Overlapping scans on the same provider must resolve to the scan + with the latest completed_at, matching the /latest summary path and + the daily-summary upsert (keyed on midnight(completed_at)). Picking + by inserted_at here produced /resources and /latest reading from + different scans and reporting diverging delta/new counts. + """ + tenant = tenants_fixture[0] + provider = providers_fixture[0] + resource = resources_fixture[0] + check_id = "overlap_regression_check" + + t0 = datetime.now(timezone.utc) - timedelta(hours=5) + t1 = t0 + timedelta(hours=1) + t1_end = t1 + timedelta(minutes=30) + t2 = t0 + timedelta(hours=4) + + scan_long = Scan.objects.create( + name="long overlap scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=t0, + completed_at=t2, + ) + scan_short = Scan.objects.create( + name="short overlap scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=t1, + completed_at=t1_end, + ) + # inserted_at is auto_now_add so override with .update() to recreate + # the overlap shape: short scan inserted later but completed earlier. + Scan.all_objects.filter(pk=scan_long.pk).update(inserted_at=t0) + Scan.all_objects.filter(pk=scan_short.pk).update(inserted_at=t1) + scan_long.refresh_from_db() + scan_short.refresh_from_db() + + assert scan_short.inserted_at > scan_long.inserted_at + assert scan_long.completed_at > scan_short.completed_at + + long_finding = Finding.objects.create( + tenant_id=tenant.id, + uid=f"{check_id}_long", + scan=scan_long, + delta=None, + status=Status.FAIL, + status_extended="long scan finding", + impact=Severity.high, + impact_extended="high", + severity=Severity.high, + raw_result={"status": Status.FAIL, "severity": Severity.high}, + check_id=check_id, + check_metadata={ + "CheckId": check_id, + "checktitle": "Overlap regression", + "Description": "Overlapping scan regression.", + }, + first_seen_at=t0, + muted=False, + ) + long_finding.add_resources([resource]) + + short_finding = Finding.objects.create( + tenant_id=tenant.id, + uid=f"{check_id}_short", + scan=scan_short, + delta="new", + status=Status.FAIL, + status_extended="short scan finding", + impact=Severity.high, + impact_extended="high", + severity=Severity.high, + raw_result={"status": Status.FAIL, "severity": Severity.high}, + check_id=check_id, + check_metadata={ + "CheckId": check_id, + "checktitle": "Overlap regression", + "Description": "Overlapping scan regression.", + }, + first_seen_at=t1, + muted=False, + ) + short_finding.add_resources([resource]) + + response = authenticated_client.get( + reverse( + "finding-group-latest_resources", + kwargs={"check_id": check_id}, + ), + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + attrs = data[0]["attributes"] + assert attrs["finding_id"] == str(long_finding.id) + assert attrs["delta"] is None diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index d9b81e5156..aaba084e94 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -8145,10 +8145,13 @@ class FindingGroupViewSet(BaseRLSViewSet): tenant_id = request.tenant_id queryset = self._get_finding_queryset() - # Get latest completed scan for each provider + # Order by -completed_at (matching the /latest summary path and the + # daily summary upsert keyed on midnight(completed_at)) so that + # overlapping scans do not make /resources and /latest read from + # different scans and report diverging counts. latest_scan_ids = ( Scan.objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED) - .order_by("provider_id", "-inserted_at") + .order_by("provider_id", "-completed_at", "-inserted_at") .distinct("provider_id") .values_list("id", flat=True) ) From 3406c5ec64afff44d5c71c1aa8fc56fd96715723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Mon, 20 Apr 2026 17:22:05 +0200 Subject: [PATCH 11/26] chore(skills): improve prowler-compliance (#10627) --- skills/prowler-compliance/SKILL.md | 578 +++++++++++++++++- .../assets/audit_framework_template.py | 207 +++++++ .../assets/build_inventory.py | 100 +++ .../assets/configs/ccc.yaml | 120 ++++ .../prowler-compliance/assets/dump_section.py | 92 +++ .../assets/parsers/__init__.py | 0 .../assets/parsers/finos_ccc.py | 223 +++++++ .../prowler-compliance/assets/query_checks.py | 86 +++ .../assets/sync_framework.py | 536 ++++++++++++++++ 9 files changed, 1932 insertions(+), 10 deletions(-) create mode 100644 skills/prowler-compliance/assets/audit_framework_template.py create mode 100644 skills/prowler-compliance/assets/build_inventory.py create mode 100644 skills/prowler-compliance/assets/configs/ccc.yaml create mode 100644 skills/prowler-compliance/assets/dump_section.py create mode 100644 skills/prowler-compliance/assets/parsers/__init__.py create mode 100644 skills/prowler-compliance/assets/parsers/finos_ccc.py create mode 100644 skills/prowler-compliance/assets/query_checks.py create mode 100644 skills/prowler-compliance/assets/sync_framework.py diff --git a/skills/prowler-compliance/SKILL.md b/skills/prowler-compliance/SKILL.md index 1853d23d8b..51c68eb05f 100644 --- a/skills/prowler-compliance/SKILL.md +++ b/skills/prowler-compliance/SKILL.md @@ -1,16 +1,28 @@ --- name: prowler-compliance description: > - Creates and manages Prowler compliance frameworks. - Trigger: When working with compliance frameworks (CIS, NIST, PCI-DSS, SOC2, GDPR, ISO27001, ENS, MITRE ATT&CK). + Creates, syncs, audits and manages Prowler compliance frameworks end-to-end. + Covers the four-layer architecture (SDK models β†’ JSON catalogs β†’ output + formatters β†’ API/UI), upstream sync workflows, cloud-auditor check-mapping + reviews, output formatter creation, and framework-specific attribute models. + Trigger: When working with compliance frameworks (CIS, NIST, PCI-DSS, SOC2, + GDPR, ISO27001, ENS, MITRE ATT&CK, CCC, C5, CSA CCM, KISA ISMS-P, + Prowler ThreatScore, FedRAMP, HIPAA), syncing with upstream catalogs, + auditing check-to-requirement mappings, adding output formatters, or fixing + compliance JSON bugs (duplicate IDs, empty Version, wrong Section, stale + check refs). license: Apache-2.0 metadata: author: prowler-cloud - version: "1.1" + version: "1.2" scope: [root, sdk] auto_invoke: - "Creating/updating compliance frameworks" - "Mapping checks to compliance controls" + - "Syncing compliance framework with upstream catalog" + - "Auditing check-to-requirement mappings as a cloud auditor" + - "Adding a compliance output formatter (per-provider class + table dispatcher)" + - "Fixing compliance JSON bugs (duplicate IDs, empty Section, stale refs)" allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task --- @@ -18,10 +30,82 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task Use this skill when: - Creating a new compliance framework for any provider +- **Syncing an existing framework with an upstream source of truth** (CIS, FINOS CCC, CSA CCM, NIST, ENS, etc.) - Adding requirements to existing frameworks - Mapping checks to compliance controls +- **Auditing existing check mappings as a cloud auditor** (user asks "are these mappings correct?", "which checks apply to this requirement?", "review the mappings") +- **Adding a new output formatter** (new framework needs a table dispatcher + per-provider classes + CSV models) +- **Fixing JSON bugs**: duplicate IDs, empty Version, wrong Section, stale check refs, inconsistent FamilyName, padded tangential check mappings +- **Registering a framework in the CLI table dispatcher or API export map** +- Investigating why a finding/check isn't showing under the expected compliance framework in the UI - Understanding compliance framework structures and attributes +## Four-Layer Architecture (Mental Model) + +Prowler compliance is a **four-layer system** hanging off one Pydantic model tree. Bugs usually happen where one layer doesn't match another, so know all four before touching anything. + +### Layer 1: SDK / Core Models β€” `prowler/lib/check/` + +- **`compliance_models.py`** β€” Pydantic **v1** model tree (`from pydantic.v1 import`). One `*_Requirement_Attribute` class per framework type + `Generic_Compliance_Requirement_Attribute` as fallback. +- `Compliance_Requirement.Attributes: list[Union[...]]` β€” **`Generic_Compliance_Requirement_Attribute` MUST be LAST** in the Union or every framework-specific attribute falls through to Generic (Pydantic v1 tries union members in order). +- **`compliance.py`** β€” runtime linker. `get_check_compliance()` builds the key as `f"{Framework}-{Version}"` **only if `Version` is non-empty**. An empty Version makes the key just `"{Framework}"` β€” this breaks downstream filters and tests that expect the versioned key. +- `Compliance.get_bulk(provider)` walks `prowler/compliance/{provider}/` and parses every `.json` file. No central index β€” just directory scan. + +### Layer 2: JSON Frameworks β€” `prowler/compliance/{provider}/` + +See "Compliance Framework Location" and "Framework-Specific Attribute Structures" sections below. + +### Layer 3: Output Formatters β€” `prowler/lib/outputs/compliance/{framework}/` + +**Every framework directory follows this exact convention** β€” do not deviate: + +``` +{framework}/ +β”œβ”€β”€ __init__.py +β”œβ”€β”€ {framework}.py # ONLY get_{framework}_table() β€” NO function docstring +β”œβ”€β”€ {framework}_{provider}.py # One class per provider (e.g., CCC_AWS, CCC_Azure, CCC_GCP) +└── models.py # One Pydantic v2 BaseModel per provider (CSV columns) +``` + +- **`{framework}.py`** holds the **table dispatcher function** `get_{framework}_table()`. It prints the pass/fail/muted summary table. **Must NOT import `Finding` or `ComplianceOutput`** β€” doing so creates a circular import with `prowler/lib/outputs/compliance/compliance.py`. Only imports: `colorama`, `tabulate`, `prowler.config.config.orange_color`. +- **`{framework}_{provider}.py`** holds a per-provider class like `CCC_AWS(ComplianceOutput)` with a `transform()` method that walks findings and emits rows. This file IS allowed to import `Finding` because it's not on the dispatcher import chain. +- **`models.py`** holds one Pydantic v2 `BaseModel` per provider. Field names become CSV column headers (**public API** β€” renaming breaks downstream consumers). +- **Never collapse per-provider files into a unified parameterized class**, even when DRY-tempting. Every framework in Prowler follows the per-provider file pattern and reviewers will reject the refactor. CSV columns differ per provider (`AccountId`/`Region` vs `SubscriptionId`/`Location` vs `ProjectId`/`Location`) β€” three classes is the convention. +- **No function docstring on `get_{framework}_table()`** β€” no other framework has one; stay consistent. +- Register in `prowler/lib/outputs/compliance/compliance.py` β†’ `display_compliance_table()` with an `elif compliance_framework.startswith("{framework}_"):` branch. Import the table function at the top of the file. + +### Layer 4: API / UI + +- **API table dispatcher**: `api/src/backend/tasks/jobs/export.py` β†’ `COMPLIANCE_CLASS_MAP` keyed by provider. Uses `startswith` predicates: `(lambda name: name.startswith("ccc_"), CCC_AWS)`. **Never use exact match** (`name == "ccc_aws"`) β€” it's inconsistent and breaks versioning. +- **API lazy loader**: `api/src/backend/api/compliance.py` β€” `LazyComplianceTemplate` and `LazyChecksMapping` load compliance per provider on first access. +- **UI mapper routing**: `ui/lib/compliance/compliance-mapper.ts` routes framework names β†’ per-framework mapper. +- **UI per-framework mapper**: `ui/lib/compliance/{framework}.tsx` flattens `Requirements` into a 3-level tree (Framework β†’ Category β†’ Control β†’ Requirement) for the accordion view. Groups by `Attributes[0].FamilyName` and `Attributes[0].Section`. +- **UI detail panel**: `ui/components/compliance/compliance-custom-details/{framework}-details.tsx`. +- **UI types**: `ui/types/compliance.ts` β€” TypeScript mirrors of the attribute metadata. + +### The CLI Pipeline (end-to-end) + +``` +prowler aws --compliance ccc_aws + ↓ +Compliance.get_bulk("aws") β†’ parses prowler/compliance/aws/*.json + ↓ +update_checks_metadata_with_compliance() β†’ attaches compliance info to CheckMetadata + ↓ +execute_checks() β†’ runs checks, produces Finding objects + ↓ +get_check_compliance(finding, "aws", bulk_checks_metadata) + β†’ dict "{Framework}-{Version}" β†’ [requirement_ids] + ↓ +CCC_AWS(findings, compliance).transform() β†’ per-provider class builds CSV rows + ↓ +batch_write_data_to_file() β†’ writes {output_filename}_ccc_aws.csv + ↓ +display_compliance_table() β†’ get_ccc_table() β†’ prints stdout summary +``` + +--- + ## Compliance Framework Location Frameworks are JSON files located in: `prowler/compliance/{provider}/{framework_name}_{provider}.json` @@ -455,14 +539,453 @@ Prowler ThreatScore is a custom security scoring framework developed by Prowler - **M365:** `cis_4.0_m365.json`, `iso27001_2022_m365.json` - **NHN:** `iso27001_2022_nhn.json` +## Workflow A: Sync a Framework With an Upstream Catalog + +Use when the framework is maintained upstream (CIS Benchmarks, FINOS CCC, CSA CCM, NIST, ENS, etc.) and Prowler needs to catch up. + +### Step 1 β€” Cache the upstream source + +Download every upstream file to a local cache so subsequent iterations don't hit the network. For FINOS CCC: + +```bash +mkdir -p /tmp/ccc_upstream +catalogs="core/ccc storage/object management/auditlog management/logging ..." +for p in $catalogs; do + safe=$(echo "$p" | tr '/' '_') + gh api "repos/finos/common-cloud-controls/contents/catalogs/$p/controls.yaml" \ + -H "Accept: application/vnd.github.raw" > "/tmp/ccc_upstream/${safe}.yaml" +done +``` + +### Step 2 β€” Run the generic sync runner against a framework config + +The sync tooling is split into three layers so adding a new framework only takes a YAML config (and optionally a new parser module for an unfamiliar upstream format): + +``` +skills/prowler-compliance/assets/ +β”œβ”€β”€ sync_framework.py # generic runner β€” works for any framework +β”œβ”€β”€ configs/ +β”‚ └── ccc.yaml # per-framework config (canonical example) +└── parsers/ + β”œβ”€β”€ __init__.py + └── finos_ccc.py # parser module for FINOS CCC YAML +``` + +**For frameworks that already have a config + parser** (today: FINOS CCC), run: + +```bash +python skills/prowler-compliance/assets/sync_framework.py \ + skills/prowler-compliance/assets/configs/ccc.yaml +``` + +The runner loads the config, validates it, dynamically imports the parser declared in `parser.module`, calls `parser.parse_upstream(config) -> list[dict]`, then applies generic post-processing (id uniqueness safety net, `FamilyName` normalization, legacy check-mapping preservation) and writes the provider JSONs. + +**To add a new framework sync**: + +1. **Write a config file** at `skills/prowler-compliance/assets/configs/{framework}.yaml`. See `configs/ccc.yaml` as the canonical example. Required top-level sections: + - `framework` β€” `name`, `display_name`, `version` (**never empty** β€” empty Version silently breaks `get_check_compliance()` key construction, so the runner refuses to start), `description_template` (accepts `{provider_display}`, `{provider_key}`, `{framework_name}`, `{framework_display}`, `{version}` placeholders). + - `providers` β€” list of `{key, display}` pairs, one per Prowler provider the framework targets. + - `output.path_template` β€” supports `{provider}`, `{framework}`, `{version}` placeholders. Examples: `"prowler/compliance/{provider}/ccc_{provider}.json"` for unversioned file names, `"prowler/compliance/{provider}/cis_{version}_{provider}.json"` for versioned ones. + - `upstream.dir` β€” local cache directory (populate via Step 1). + - `parser.module` β€” name of the module under `parsers/` to load (without `.py`). Everything else under `parser.` is opaque to the runner and passed to the parser as config. + - `post_processing.check_preservation.primary_key` β€” top-level field name for the primary legacy-mapping lookup (almost always `Id`). + - `post_processing.check_preservation.fallback_keys` β€” **config-driven fallback keys** for preserving check mappings when ids change. Each entry is a list of `Attributes[0]` field names composed into a tuple. Examples: + - CCC: `- [Section, Applicability]` (because `Applicability` is a CCC-only attribute, verified in `compliance_models.py:213`). + - CIS would use `- [Section, Profile]`. + - NIST would use `- [ItemId]`. + - List-valued fields (like `Applicability`) are automatically frozen to `frozenset` so the tuple is hashable. + - `post_processing.family_name_normalization` (optional) β€” map of raw β†’ canonical `FamilyName` values. The UI groups by `Attributes[0].FamilyName` exactly, so inconsistent upstream variants otherwise become separate tree branches. + +2. **Reuse an existing parser** if the upstream format matches one (currently only `finos_ccc` exists). Otherwise, **write a new parser** at `parsers/{name}.py` implementing: + + ```python + def parse_upstream(config: dict) -> list[dict]: + """Return Prowler-format requirements {Id, Description, Attributes: [...], Checks: []}. + + Ids MUST be unique in the returned list. The runner raises ValueError + on duplicates β€” it does NOT silently renumber, because mutating a + canonical upstream id (e.g. CIS '1.1.1' or NIST 'AC-2(1)') would be + catastrophic. The parser owns all upstream-format quirks: foreign-prefix + rewriting, genuine collision renumbering, shape handling. + """ + ``` + + The parser reads its own settings from `config['upstream']` and `config['parser']`. It does NOT load existing Prowler JSONs (the runner does that for check preservation) and does NOT write output (the runner does that too). + +**Gotchas the runner already handles for you** (learned from the FINOS CCC v2025.10 sync β€” they're documented here so you don't re-discover them): + +- **Multiple upstream YAML shapes**. Most FINOS CCC catalogs use `control-families: [...]`, but `storage/object` uses a top-level `controls: [...]` with a `family: "CCC.X.Y"` reference id and no human-readable family name. A parser that only handles shape 1 silently drops the shape-2 catalog β€” this exact bug dropped ObjStor from Prowler for a full iteration. `parsers/finos_ccc.py` handles both shapes; if you write a new parser for a similar format, test with at least one file of each shape. +- **Whitespace collapse**. Upstream YAML multi-line block scalars (`|`) preserve newlines. Prowler stores descriptions single-line. Collapse with `" ".join(value.split())` before emitting (see `parsers/finos_ccc.py::clean()`). +- **Foreign-prefix AR id rewriting**. Upstream sometimes aliases requirements across catalogs by keeping the original prefix (e.g., `CCC.AuditLog.CN08.AR01` appears nested under `CCC.Logging.CN03`). Rewrite the foreign id to fit its parent control: `CCC.Logging.CN03.AR01`. This logic is parser-specific because the id structure varies per framework (CCC uses 3-dot depth; CIS uses numeric dots; NIST uses `AC-2(1)`). +- **Genuine upstream collision renumbering**. Sometimes upstream has a real typo where two different requirements share the same id (e.g., `CCC.Core.CN14.AR02` defined twice for 30-day and 14-day backup variants). Renumber the second copy to the next free AR number (`.AR03`). The parser handles this; the runner asserts the final list has unique ids as a safety net. +- **Existing check mapping preservation**. The runner uses the `primary_key` + `fallback_keys` declared in config to look up the old `Checks` list for each requirement. For CCC this means primary index by `Id` plus fallback index by `(Section, frozenset(Applicability))` β€” the fallback recovers mappings for requirements whose ids were rewritten or renumbered by the parser. +- **FamilyName normalization**. Configured via `post_processing.family_name_normalization` β€” no code changes needed to collapse upstream variants like `"Logging & Monitoring"` β†’ `"Logging and Monitoring"`. +- **Populate `Version`**. The runner refuses to start on empty `framework.version` β€” fail-fast replaces the silent bug where `get_check_compliance()` would build the key as just `"{Framework}"`. + +### Step 3 β€” Validate before committing + +```python +from prowler.lib.check.compliance_models import Compliance +for prov in ['aws', 'azure', 'gcp']: + c = Compliance.parse_file(f"prowler/compliance/{prov}/ccc_{prov}.json") + print(f"{prov}: {len(c.Requirements)} reqs, version={c.Version}") +``` + +Any `ValidationError` means the Attribute fields don't match the `*_Requirement_Attribute` model. Either fix the JSON or extend the model in `compliance_models.py` (remember: Generic stays last). + +### Step 4 β€” Verify every check id exists + +```python +import json +from pathlib import Path +for prov in ['aws', 'azure', 'gcp']: + existing = {p.stem.replace('.metadata','') + for p in Path(f'prowler/providers/{prov}/services').rglob('*.metadata.json')} + with open(f'prowler/compliance/{prov}/ccc_{prov}.json') as f: + data = json.load(f) + refs = {c for r in data['Requirements'] for c in r['Checks']} + missing = refs - existing + assert not missing, f"{prov} missing: {missing}" +``` + +A stale check id silently becomes dead weight β€” no finding will ever map to it. This pre-validation **must run on every write**; bake it into the generator script. + +### Step 5 β€” Add an attribute model if needed + +Only if the framework has fields beyond `Generic_Compliance_Requirement_Attribute`. Add the class to `prowler/lib/check/compliance_models.py` and register it in `Compliance_Requirement.Attributes: list[Union[...]]`. **Generic stays last.** + +--- + +## Workflow B: Audit Check Mappings as a Cloud Auditor + +Use when the user asks to review existing mappings ("are these correct?", "verify that the checks apply", "audit the CCC mappings"). This is the highest-value compliance task β€” it surfaces padded mappings with zero actual coverage and missing mappings for legitimate coverage. + +### The golden rule + +> A Prowler check's title/risk MUST **literally describe what the requirement text says**. "Related" is not enough. If no check actually addresses the requirement, leave `Checks: []` (MANUAL) β€” **honest MANUAL is worth more than padded coverage**. + +### Audit process + +**Step 1 β€” Build a per-provider check inventory** (cache in `/tmp/`): + +```python +import json +from pathlib import Path +for provider in ['aws', 'azure', 'gcp']: + inv = {} + for meta in Path(f'prowler/providers/{provider}/services').rglob('*.metadata.json'): + with open(meta) as f: + d = json.load(f) + cid = d.get('CheckID') or meta.stem.replace('.metadata','') + inv[cid] = { + 'service': d.get('ServiceName', ''), + 'title': d.get('CheckTitle', ''), + 'risk': d.get('Risk', ''), + 'description': d.get('Description', ''), + } + with open(f'/tmp/checks_{provider}.json', 'w') as f: + json.dump(inv, f, indent=2) +``` + +**Step 2 β€” Keyword/service query helper** β€” see [assets/query_checks.py](assets/query_checks.py): + +```bash +python assets/query_checks.py aws encryption transit # keyword AND-search +python assets/query_checks.py aws --service iam # all iam checks +python assets/query_checks.py aws --id kms_cmk_rotation_enabled # full metadata +``` + +**Step 3 β€” Dump a framework section with current mappings** β€” see [assets/dump_section.py](assets/dump_section.py): + +```bash +python assets/dump_section.py ccc "CCC.Core." # all Core ARs across 3 providers +python assets/dump_section.py ccc "CCC.AuditLog." # all AuditLog ARs +``` + +**Step 4 β€” Encode explicit REPLACE decisions** β€” see [assets/audit_framework_template.py](assets/audit_framework_template.py). Structure: + +```python +DECISIONS = {} + +DECISIONS["CCC.Core.CN01.AR01"] = { + "aws": [ + "cloudfront_distributions_https_enabled", + "cloudfront_distributions_origin_traffic_encrypted", + # ... + ], + "azure": [ + "storage_secure_transfer_required_is_enabled", + "app_minimum_tls_version_12", + # ... + ], + "gcp": [ + "cloudsql_instance_ssl_connections", + ], + # Missing provider key = leave the legacy mapping untouched +} + +# Empty list = EXPLICITLY MANUAL (overwrites legacy) +DECISIONS["CCC.Core.CN01.AR07"] = { + "aws": [], # Prowler has no IANA port/protocol check + "azure": [], + "gcp": [], +} +``` + +**REPLACE, not PATCH.** Encoding every mapping as a full list (not add/remove delta) makes the audit reproducible and surfaces hidden assumptions from the legacy data. + +**Step 5 β€” Pre-validation**. The audit script MUST validate every check id against the inventory and **abort with stderr listing typos**. Common typos caught during a real audit: + +- `fsx_file_system_encryption_at_rest_using_kms` (doesn't exist) +- `cosmosdb_account_encryption_at_rest_with_cmk` (doesn't exist) +- `sqlserver_geo_replication` (doesn't exist) +- `redshift_cluster_audit_logging` (should be `redshift_cluster_encrypted_at_rest`) +- `postgresql_flexible_server_require_secure_transport` (should be `postgresql_flexible_server_enforce_ssl_enabled`) +- `storage_secure_transfer_required_enabled` (should be `storage_secure_transfer_required_is_enabled`) +- `sqlserver_minimum_tls_version_12` (should be `sqlserver_recommended_minimal_tls_version`) + +**Step 6 β€” Apply + validate + test**: + +```bash +python /path/to/audit_script.py # applies decisions, pre-validates +python -m pytest tests/lib/outputs/compliance/ tests/lib/check/ -q +``` + +### Audit Reference Table: Requirement Text β†’ Prowler Checks + +Use this table to map CCC-style / NIST-style / ISO-style requirements to the checks that actually verify them. Built from a real audit of 172 CCC ARs Γ— 3 providers. + +| Requirement text | AWS checks | Azure checks | GCP checks | +|---|---|---|---| +| **TLS in transit enforced** | `cloudfront_distributions_https_enabled`, `s3_bucket_secure_transport_policy`, `elbv2_ssl_listeners`, `elbv2_insecure_ssl_ciphers`, `elb_ssl_listeners`, `elb_insecure_ssl_ciphers`, `opensearch_service_domains_https_communications_enforced`, `rds_instance_transport_encrypted`, `redshift_cluster_in_transit_encryption_enabled`, `elasticache_redis_cluster_in_transit_encryption_enabled`, `dynamodb_accelerator_cluster_in_transit_encryption_enabled`, `dms_endpoint_ssl_enabled`, `kafka_cluster_in_transit_encryption_enabled`, `transfer_server_in_transit_encryption_enabled`, `glue_database_connections_ssl_enabled`, `sns_subscription_not_using_http_endpoints` | `storage_secure_transfer_required_is_enabled`, `storage_ensure_minimum_tls_version_12`, `postgresql_flexible_server_enforce_ssl_enabled`, `mysql_flexible_server_ssl_connection_enabled`, `mysql_flexible_server_minimum_tls_version_12`, `sqlserver_recommended_minimal_tls_version`, `app_minimum_tls_version_12`, `app_ensure_http_is_redirected_to_https`, `app_ftp_deployment_disabled` | `cloudsql_instance_ssl_connections` (almost only option) | +| **TLS 1.3 specifically** | Partial: `cloudfront_distributions_using_deprecated_ssl_protocols`, `elb*_insecure_ssl_ciphers`, `*_minimum_tls_version_12` | Partial: `*_minimum_tls_version_12` checks | None β€” accept as MANUAL | +| **SSH / port 22 hardening** | `ec2_instance_port_ssh_exposed_to_internet`, `ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22`, `ec2_networkacl_allow_ingress_tcp_port_22` | `network_ssh_internet_access_restricted`, `vm_linux_enforce_ssh_authentication` | `compute_firewall_ssh_access_from_the_internet_allowed`, `compute_instance_block_project_wide_ssh_keys_disabled`, `compute_project_os_login_enabled`, `compute_project_os_login_2fa_enabled` | +| **mTLS (mutual TLS)** | `kafka_cluster_mutual_tls_authentication_enabled`, `apigateway_restapi_client_certificate_enabled` | `app_client_certificates_on` | None β€” MANUAL | +| **Data at rest encrypted** | `s3_bucket_default_encryption`, `s3_bucket_kms_encryption`, `ec2_ebs_default_encryption`, `ec2_ebs_volume_encryption`, `rds_instance_storage_encrypted`, `rds_cluster_storage_encrypted`, `rds_snapshots_encrypted`, `dynamodb_tables_kms_cmk_encryption_enabled`, `redshift_cluster_encrypted_at_rest`, `neptune_cluster_storage_encrypted`, `documentdb_cluster_storage_encrypted`, `opensearch_service_domains_encryption_at_rest_enabled`, `kinesis_stream_encrypted_at_rest`, `firehose_stream_encrypted_at_rest`, `sns_topics_kms_encryption_at_rest_enabled`, `sqs_queues_server_side_encryption_enabled`, `efs_encryption_at_rest_enabled`, `athena_workgroup_encryption`, `glue_data_catalogs_metadata_encryption_enabled`, `backup_vaults_encrypted`, `backup_recovery_point_encrypted`, `cloudtrail_kms_encryption_enabled`, `cloudwatch_log_group_kms_encryption_enabled`, `eks_cluster_kms_cmk_encryption_in_secrets_enabled`, `sagemaker_notebook_instance_encryption_enabled`, `apigateway_restapi_cache_encrypted`, `kafka_cluster_encryption_at_rest_uses_cmk`, `dynamodb_accelerator_cluster_encryption_enabled`, `storagegateway_fileshare_encryption_enabled` | `storage_infrastructure_encryption_is_enabled`, `storage_ensure_encryption_with_customer_managed_keys`, `vm_ensure_attached_disks_encrypted_with_cmk`, `vm_ensure_unattached_disks_encrypted_with_cmk`, `sqlserver_tde_encryption_enabled`, `sqlserver_tde_encrypted_with_cmk`, `databricks_workspace_cmk_encryption_enabled`, `monitor_storage_account_with_activity_logs_cmk_encrypted` | `compute_instance_encryption_with_csek_enabled`, `dataproc_encrypted_with_cmks_disabled`, `bigquery_dataset_cmk_encryption`, `bigquery_table_cmk_encryption` | +| **CMEK required (customer-managed keys)** | `kms_cmk_are_used` | `storage_ensure_encryption_with_customer_managed_keys`, `vm_ensure_attached_disks_encrypted_with_cmk`, `vm_ensure_unattached_disks_encrypted_with_cmk`, `sqlserver_tde_encrypted_with_cmk`, `databricks_workspace_cmk_encryption_enabled` | `bigquery_dataset_cmk_encryption`, `bigquery_table_cmk_encryption`, `dataproc_encrypted_with_cmks_disabled`, `compute_instance_encryption_with_csek_enabled` | +| **Key rotation enabled** | `kms_cmk_rotation_enabled` | `keyvault_key_rotation_enabled`, `storage_key_rotation_90_days` | `kms_key_rotation_enabled` | +| **MFA for UI access** | `iam_root_mfa_enabled`, `iam_root_hardware_mfa_enabled`, `iam_user_mfa_enabled_console_access`, `iam_user_hardware_mfa_enabled`, `iam_administrator_access_with_mfa`, `cognito_user_pool_mfa_enabled` | `entra_privileged_user_has_mfa`, `entra_non_privileged_user_has_mfa`, `entra_user_with_vm_access_has_mfa`, `entra_security_defaults_enabled` | `compute_project_os_login_2fa_enabled` | +| **API access / credentials** | `iam_no_root_access_key`, `iam_user_no_setup_initial_access_key`, `apigateway_restapi_authorizers_enabled`, `apigateway_restapi_public_with_authorizer`, `apigatewayv2_api_authorizers_enabled` | `entra_conditional_access_policy_require_mfa_for_management_api`, `app_function_access_keys_configured`, `app_function_identity_is_configured` | `apikeys_api_restrictions_configured`, `apikeys_key_exists`, `apikeys_key_rotated_in_90_days` | +| **Log all admin/config changes** | `cloudtrail_multi_region_enabled`, `cloudtrail_multi_region_enabled_logging_management_events`, `cloudtrail_cloudwatch_logging_enabled`, `cloudtrail_log_file_validation_enabled`, `cloudwatch_log_metric_filter_*`, `cloudwatch_changes_to_*_alarm_configured`, `config_recorder_all_regions_enabled` | `monitor_diagnostic_settings_exists`, `monitor_diagnostic_setting_with_appropriate_categories`, `monitor_alert_*` | `iam_audit_logs_enabled`, `logging_log_metric_filter_and_alert_for_*`, `logging_sink_created` | +| **Log integrity (digital signatures)** | `cloudtrail_log_file_validation_enabled` (exact) | None | None | +| **Public access denied** | `s3_bucket_public_access`, `s3_bucket_public_list_acl`, `s3_bucket_public_write_acl`, `s3_account_level_public_access_blocks`, `apigateway_restapi_public`, `awslambda_function_url_public`, `awslambda_function_not_publicly_accessible`, `rds_instance_no_public_access`, `rds_snapshots_public_access`, `ec2_securitygroup_allow_ingress_from_internet_to_all_ports`, `sns_topics_not_publicly_accessible`, `sqs_queues_not_publicly_accessible` | `storage_blob_public_access_level_is_disabled`, `storage_ensure_private_endpoints_in_storage_accounts`, `containerregistry_not_publicly_accessible`, `keyvault_private_endpoints`, `app_function_not_publicly_accessible`, `aks_clusters_public_access_disabled`, `network_http_internet_access_restricted` | `cloudstorage_bucket_public_access`, `compute_instance_public_ip`, `cloudsql_instance_public_ip`, `compute_firewall_*_access_from_the_internet_allowed` | +| **IAM least privilege** | `iam_*_no_administrative_privileges`, `iam_policy_allows_privilege_escalation`, `iam_inline_policy_allows_privilege_escalation`, `iam_role_administratoraccess_policy`, `iam_group_administrator_access_policy`, `iam_user_administrator_access_policy`, `iam_policy_attached_only_to_group_or_roles`, `iam_role_cross_service_confused_deputy_prevention` | `iam_role_user_access_admin_restricted`, `iam_subscription_roles_owner_custom_not_created`, `iam_custom_role_has_permissions_to_administer_resource_locks` | `iam_sa_no_administrative_privileges`, `iam_no_service_roles_at_project_level`, `iam_role_kms_enforce_separation_of_duties`, `iam_role_sa_enforce_separation_of_duties` | +| **Password policy** | `iam_password_policy_minimum_length_14`, `iam_password_policy_uppercase`, `iam_password_policy_lowercase`, `iam_password_policy_symbol`, `iam_password_policy_number`, `iam_password_policy_expires_passwords_within_90_days_or_less`, `iam_password_policy_reuse_24` | None | None | +| **Credential rotation / unused** | `iam_rotate_access_key_90_days`, `iam_user_accesskey_unused`, `iam_user_console_access_unused` | None | `iam_sa_user_managed_key_rotate_90_days`, `iam_sa_user_managed_key_unused`, `iam_service_account_unused` | +| **VPC / flow logs** | `vpc_flow_logs_enabled` | `network_flow_log_captured_sent`, `network_watcher_enabled`, `network_flow_log_more_than_90_days` | `compute_subnet_flow_logs_enabled` | +| **Backup / DR / Multi-AZ** | `backup_vaults_exist`, `backup_plans_exist`, `backup_reportplans_exist`, `rds_instance_backup_enabled`, `rds_*_protected_by_backup_plan`, `rds_cluster_multi_az`, `neptune_cluster_backup_enabled`, `documentdb_cluster_backup_enabled`, `efs_have_backup_enabled`, `s3_bucket_cross_region_replication`, `dynamodb_table_protected_by_backup_plan` | `vm_backup_enabled`, `vm_sufficient_daily_backup_retention_period`, `storage_geo_redundant_enabled` | `cloudsql_instance_automated_backups`, `cloudstorage_bucket_log_retention_policy_lock`, `cloudstorage_bucket_sufficient_retention_period` | +| **Access analysis / discovery** | `accessanalyzer_enabled`, `accessanalyzer_enabled_without_findings` | None specific | `iam_account_access_approval_enabled`, `iam_cloud_asset_inventory_enabled` | +| **Object lock / retention** | `s3_bucket_object_lock`, `s3_bucket_object_versioning`, `s3_bucket_lifecycle_enabled`, `cloudtrail_bucket_requires_mfa_delete`, `s3_bucket_no_mfa_delete` | `storage_ensure_soft_delete_is_enabled`, `storage_blob_versioning_is_enabled`, `storage_ensure_file_shares_soft_delete_is_enabled` | `cloudstorage_bucket_log_retention_policy_lock`, `cloudstorage_bucket_soft_delete_enabled`, `cloudstorage_bucket_versioning_enabled`, `cloudstorage_bucket_sufficient_retention_period` | +| **Uniform bucket-level access** | `s3_bucket_acl_prohibited` | `storage_account_key_access_disabled`, `storage_default_to_entra_authorization_enabled` | `cloudstorage_bucket_uniform_bucket_level_access` | +| **Container vulnerability scanning** | `ecr_registry_scan_images_on_push_enabled`, `ecr_repositories_scan_vulnerabilities_in_latest_image` | `defender_container_images_scan_enabled`, `defender_container_images_resolved_vulnerabilities` | `artifacts_container_analysis_enabled`, `gcr_container_scanning_enabled` | +| **WAF / rate limiting** | `wafv2_webacl_with_rules`, `waf_*_webacl_with_rules`, `wafv2_webacl_logging_enabled`, `waf_global_webacl_logging_enabled` | None | None | +| **Deployment region restriction** | `organizations_scp_check_deny_regions` | None | None | +| **Secrets automatic rotation** | `secretsmanager_automatic_rotation_enabled`, `secretsmanager_secret_rotated_periodically` | `keyvault_rbac_secret_expiration_set`, `keyvault_non_rbac_secret_expiration_set` | None | +| **Certificate management** | `acm_certificates_expiration_check`, `acm_certificates_with_secure_key_algorithms`, `acm_certificates_transparency_logs_enabled` | `keyvault_key_expiration_set_in_non_rbac`, `keyvault_rbac_key_expiration_set`, `keyvault_non_rbac_secret_expiration_set` | None | +| **GenAI guardrails / input/output filtering** | `bedrock_guardrail_prompt_attack_filter_enabled`, `bedrock_guardrail_sensitive_information_filter_enabled`, `bedrock_agent_guardrail_enabled`, `bedrock_model_invocation_logging_enabled`, `bedrock_api_key_no_administrative_privileges`, `bedrock_api_key_no_long_term_credentials` | None | None | +| **ML dev environment security** | `sagemaker_notebook_instance_root_access_disabled`, `sagemaker_notebook_instance_without_direct_internet_access_configured`, `sagemaker_notebook_instance_vpc_settings_configured`, `sagemaker_models_vpc_settings_configured`, `sagemaker_training_jobs_vpc_settings_configured`, `sagemaker_training_jobs_network_isolation_enabled`, `sagemaker_training_jobs_volume_and_output_encryption_enabled` | None | None | +| **Threat detection / anomalous behavior** | `cloudtrail_threat_detection_enumeration`, `cloudtrail_threat_detection_privilege_escalation`, `cloudtrail_threat_detection_llm_jacking`, `guardduty_is_enabled`, `guardduty_no_high_severity_findings` | None | None | +| **Serverless private access** | `awslambda_function_inside_vpc`, `awslambda_function_not_publicly_accessible`, `awslambda_function_url_public` | `app_function_not_publicly_accessible` | None | + +### What Prowler Does NOT Cover (accept MANUAL honestly) + +Don't pad mappings for these β€” mark `Checks: []` and move on: + +- **TLS 1.3 version specifically** β€” Prowler verifies TLS is enforced, not always the exact version +- **IANA port-protocol consistency** β€” no check for "protocol running on its assigned port" +- **mTLS on most Azure/GCP services** β€” limited to App Service client certs on Azure, nothing on GCP +- **Rate limiting** on monitoring endpoints, load balancers, serverless invocations, vector ingestion +- **Session cookie expiry** (LB stickiness) +- **HTTP header scrubbing** (Server, X-Powered-By) +- **Certificate transparency verification for imports** +- **Model version pinning, red teaming, AI quality review** +- **Vector embedding validation, dimensional constraints, ANN vs exact search** +- **Secret region replication** (cross-region residency) +- **Lifecycle cleanup policies on container registries** +- **Row-level / column-level security in data warehouses** +- **Deployment region restriction on Azure/GCP** (AWS has `organizations_scp_check_deny_regions`, others don't) +- **Cross-tenant alert silencing permissions** +- **Field-level masking in logs** +- **Managed view enforcement for database access** +- **Automatic MFA delete on all S3 buckets** (only CloudTrail bucket variant exists for some frameworks β€” AWS has the generic `s3_bucket_no_mfa_delete` though) + +--- + +## Workflow C: Add a New Output Formatter + +Use when a new framework needs its own CSV columns or terminal table. Follow the c5/csa/ens layout exactly: + +```bash +mkdir -p prowler/lib/outputs/compliance/{framework} +touch prowler/lib/outputs/compliance/{framework}/__init__.py +``` + +### Step 1 β€” Create `{framework}.py` (table dispatcher ONLY) + +Copy from `prowler/lib/outputs/compliance/c5/c5.py` and change the function name + framework string. The `diff` between your file and `c5.py` should be just those two lines. **No function docstring** β€” other frameworks don't have one, stay consistent. + +### Step 2 β€” Create `models.py` + +One Pydantic v2 `BaseModel` per provider. Field names become CSV column headers (public API β€” don't rename later without a migration). + +```python +from typing import Optional +from pydantic import BaseModel + +class {Framework}_AWSModel(BaseModel): + Provider: str + Description: str + AccountId: str + Region: str + AssessmentDate: str + Requirements_Id: str + Requirements_Description: str + # ... provider-specific columns + Status: str + StatusExtended: str + ResourceId: str + ResourceName: str + CheckId: str + Muted: bool +``` + +### Step 3 β€” Create `{framework}_{provider}.py` for each provider + +Copy from `prowler/lib/outputs/compliance/c5/c5_aws.py` etc. Contains the `{Framework}_AWS(ComplianceOutput)` class with `transform()` that walks findings and emits model rows. This file IS allowed to import `Finding`. + +### Step 4 β€” Register everywhere + +**`prowler/lib/outputs/compliance/compliance.py`** (CLI table dispatcher): +```python +from prowler.lib.outputs.compliance.{framework}.{framework} import get_{framework}_table + +def display_compliance_table(...): + ... + elif compliance_framework.startswith("{framework}_"): + get_{framework}_table(findings, bulk_checks_metadata, + compliance_framework, output_filename, + output_directory, compliance_overview) +``` + +**`prowler/__main__.py`** (CLI output writer per provider): +Add imports at the top: +```python +from prowler.lib.outputs.compliance.{framework}.{framework}_aws import {Framework}_AWS +from prowler.lib.outputs.compliance.{framework}.{framework}_azure import {Framework}_Azure +from prowler.lib.outputs.compliance.{framework}.{framework}_gcp import {Framework}_GCP +``` +Add provider-specific `elif compliance_name.startswith("{framework}_"):` branches that instantiate the class and call `batch_write_data_to_file()`. + +**`api/src/backend/tasks/jobs/export.py`** (API export dispatcher): +```python +from prowler.lib.outputs.compliance.{framework}.{framework}_aws import {Framework}_AWS +# ... azure, gcp + +COMPLIANCE_CLASS_MAP = { + "aws": [ + # ... + (lambda name: name.startswith("{framework}_"), {Framework}_AWS), + ], + # ... azure, gcp +} +``` + +**Always use `startswith`**, never `name == "framework_aws"`. Exact match is a regression. + +### Step 5 β€” Add tests + +Create `tests/lib/outputs/compliance/{framework}/` with `{framework}_aws_test.py`, `{framework}_azure_test.py`, `{framework}_gcp_test.py`. See the test template in [references/test_template.md](references/test_template.md). + +Add fixtures to `tests/lib/outputs/compliance/fixtures.py`: one `Compliance` object per provider with 1 evaluated + 1 manual requirement to exercise both code paths in `transform()`. + +### Circular import warning + +**The table dispatcher file (`{framework}.py`) MUST NOT import `Finding`** (directly or transitively). The cycle is: + +``` +compliance.compliance imports get_{framework}_table + β†’ {framework}.py imports ComplianceOutput + β†’ compliance_output imports Finding + β†’ finding imports get_check_compliance from compliance.compliance + β†’ CIRCULAR +``` + +Keep `{framework}.py` bare β€” only `colorama`, `tabulate`, `prowler.config.config`. Put anything that imports `Finding` in the per-provider `{framework}_{provider}.py` files. + +--- + +## Conventions and Hard-Won Gotchas + +These are lessons from the FINOS CCC v2025.10 sync + 172-AR audit pass (April 2026). Learn them once; save days of debugging. + +1. **Per-provider files are non-negotiable.** Never collapse `{framework}_aws.py`, `{framework}_azure.py`, `{framework}_gcp.py` into a single parameterized class, no matter how DRY-tempting. Every other framework in the codebase follows the per-provider pattern and reviewers will reject the refactor. The CSV column names differ per provider β€” three classes is the convention. +2. **`{framework}.py` has NO function docstring.** Other frameworks don't have them. Don't add one to be "helpful". +3. **Circular import protection**: the table dispatcher file MUST NOT import `Finding` (directly or transitively). Split the code so `{framework}.py` only has `get_{framework}_table()` with bare imports, and `{framework}_{provider}.py` holds the class that needs `Finding`. +4. **`Generic_Compliance_Requirement_Attribute` is the fallback** β€” in the `Compliance_Requirement.Attributes` Union in `compliance_models.py`, Generic MUST be LAST because Pydantic v1 tries union members in order. Putting Generic first means every framework-specific attribute falls through to Generic and the specific model is never used. +5. **Pydantic v1 imports.** `from pydantic.v1 import BaseModel` in `compliance_models.py` β€” not v2. Mixing causes validation errors. Pydantic v2 is used in the CSV models (`models.py`) β€” that's fine because they're separate trees. +6. **`get_check_compliance()` key format** is `f"{Framework}-{Version}"` ONLY if Version is set. Empty Version β†’ key is `"{Framework}"` (no version suffix). Tests that mock compliance dicts must match this exact format β€” when a framework ships with `Version: ""`, downstream code and tests break silently. +7. **CSV column names from `models.py` are public API.** Don't rename a field without migrating downstream consumers β€” CSV headers change. +8. **Upstream YAML multi-line scalars** (`|` block scalars) preserve newlines. Collapse to single-line with `" ".join(value.split())` before writing to JSON. +9. **Upstream catalogs can use multiple shapes.** FINOS CCC uses `control-families: [...]` in most catalogs but `controls: [...]` at the top level in `storage/object`. Any sync script must handle both or silently drop entire catalogs. +10. **Foreign-prefix AR ids.** Upstream sometimes "imports" requirements from one catalog into another by keeping the original id prefix (e.g., `CCC.AuditLog.CN08.AR01` appearing under `CCC.Logging.CN03`). Prowler's compliance model requires unique ids within a catalog β€” rewrite the foreign id to fit the parent control: `CCC.AuditLog.CN08.AR01` (inside `CCC.Logging.CN03`) β†’ `CCC.Logging.CN03.AR01`. +11. **Genuine upstream id collisions.** Sometimes upstream has a real typo where two different requirements share the same id (e.g., `CCC.Core.CN14.AR02` defined twice for 30-day and 14-day backup variants). Renumber the second copy to the next free AR number. Preserve check mappings by matching on `(Section, frozenset(Applicability))` since the renumbered id won't match by id. +12. **`COMPLIANCE_CLASS_MAP` in `export.py` uses `startswith` predicates** for all modern frameworks. Exact match (`name == "ccc_aws"`) is an anti-pattern β€” it was present for CCC until April 2026 and was the reason CCC couldn't have versioned variants. +13. **Pre-validate every check id** against the per-provider inventory before writing the JSON. A typo silently creates an unreferenced check that will fail when findings try to map to it. The audit script MUST abort with stderr listing typos, not swallow them. +14. **REPLACE is better than PATCH** for audit decisions. Encoding every mapping explicitly makes the audit reproducible and surfaces hidden assumptions from the legacy data. A PATCH system that adds/removes is too easy to forget. +15. **When no check applies, MANUAL is correct.** Do not pad mappings with tangential checks "just in case". Prowler's compliance reports are meant to be actionable β€” padding them with noise breaks that. Honest manual reqs can be mapped later when new checks land. +16. **UI groups by `Attributes[0].FamilyName` and `Attributes[0].Section`.** If FamilyName has inconsistent variants within the same JSON (e.g., "Logging & Monitoring" vs "Logging and Monitoring"), the UI renders them as separate categories. Section empty β†’ the requirement falls into an orphan control with label "". Normalize before shipping. +17. **Provider coverage is asymmetric.** AWS has dense coverage (~586 checks across 80+ services): in-transit encryption, IAM, database encryption, backup. Azure (~167 checks) and GCP (~102 checks) are thinner especially for in-transit encryption, mTLS, and ML/AI. Accept the asymmetry in mappings β€” don't force GCP parity where Prowler genuinely can't verify. + +--- + +## Useful One-Liners + +```bash +# Count requirements per service prefix (CCC, CIS sections, etc.) +jq -r '.Requirements[].Id | split(".")[1]' prowler/compliance/aws/ccc_aws.json | sort | uniq -c + +# Find duplicate requirement IDs +jq -r '.Requirements[].Id' file.json | sort | uniq -d + +# Count manual requirements (no checks) +jq '[.Requirements[] | select((.Checks | length) == 0)] | length' file.json + +# List all unique check references in a framework +jq -r '.Requirements[].Checks[]' file.json | sort -u + +# List all unique Sections (to spot inconsistency) +jq '[.Requirements[].Attributes[0].Section] | unique' file.json + +# List all unique FamilyNames (to spot inconsistency) +jq '[.Requirements[].Attributes[0].FamilyName] | unique' file.json + +# Diff requirement ids between two versions of the same framework +diff <(jq -r '.Requirements[].Id' a.json | sort) <(jq -r '.Requirements[].Id' b.json | sort) + +# Find where a check id is used across all frameworks +grep -rl "my_check_name" prowler/compliance/ + +# Check if a Prowler check exists +find prowler/providers/aws/services -name "{check_id}.metadata.json" + +# Validate a JSON with Pydantic +python -c "from prowler.lib.check.compliance_models import Compliance; print(Compliance.parse_file('prowler/compliance/aws/ccc_aws.json').Framework)" +``` + +--- + ## Best Practices 1. **Requirement IDs**: Follow the original framework numbering exactly (e.g., "1.1", "A.5.1", "T1190", "ac_2_1") -2. **Check Mapping**: Map to existing checks when possible. Use `Checks: []` for manual-only requirements +2. **Check Mapping**: Map to existing checks when possible. Use `Checks: []` for manual-only requirements β€” honest MANUAL beats padded coverage 3. **Completeness**: Include all framework requirements, even those without automated checks -4. **Version Control**: Include framework version in `Name` and `Version` fields +4. **Version Control**: Include framework version in `Name` and `Version` fields. **Never leave `Version: ""`** β€” it breaks `get_check_compliance()` key format 5. **File Naming**: Use format `{framework}_{version}_{provider}.json` -6. **Validation**: Prowler validates JSON against Pydantic models at startup - invalid JSON will cause errors +6. **Validation**: Prowler validates JSON against Pydantic models at startup β€” invalid JSON will cause errors +7. **Pre-validate check ids** against the provider's `*.metadata.json` inventory before every commit +8. **Normalize FamilyName and Section** to avoid inconsistent UI tree branches +9. **Register everywhere**: SDK model (if needed) β†’ `compliance.py` dispatcher β†’ `__main__.py` CLI writer β†’ `export.py` API map β†’ UI mapper. Skipping any layer results in silent failures +10. **Audit, don't pad**: when reviewing mappings, apply the golden rule β€” the check's title/risk MUST literally describe what the requirement text says. Tangential relation doesn't count ## Commands @@ -482,11 +1005,46 @@ prowler aws --compliance cis_5.0_aws -M csv json html ## Code References -- **Compliance Models:** `prowler/lib/check/compliance_models.py` -- **Compliance Processing:** `prowler/lib/check/compliance.py` -- **Compliance Output:** `prowler/lib/outputs/compliance/` +### Layer 1 β€” SDK / Core +- **Compliance Models:** `prowler/lib/check/compliance_models.py` (Pydantic v1 model tree) +- **Compliance Processing / Linker:** `prowler/lib/check/compliance.py` (`get_check_compliance`, `update_checks_metadata_with_compliance`) +- **Check Utils:** `prowler/lib/check/utils.py` (`list_compliance_modules`) + +### Layer 2 β€” JSON Catalogs +- **Framework JSONs:** `prowler/compliance/{provider}/` (auto-discovered via directory walk) + +### Layer 3 β€” Output Formatters +- **Per-framework folders:** `prowler/lib/outputs/compliance/{framework}/` +- **Shared base class:** `prowler/lib/outputs/compliance/compliance_output.py` (`ComplianceOutput` + `batch_write_data_to_file`) +- **CLI table dispatcher:** `prowler/lib/outputs/compliance/compliance.py` (`display_compliance_table`) +- **Finding model:** `prowler/lib/outputs/finding.py` (**do not import transitively from table dispatcher files β€” circular import**) +- **CLI writer:** `prowler/__main__.py` (per-provider `elif compliance_name.startswith(...)` branches that instantiate per-provider classes) + +### Layer 4 β€” API / UI +- **API lazy loader:** `api/src/backend/api/compliance.py` (`LazyComplianceTemplate`, `LazyChecksMapping`) +- **API export dispatcher:** `api/src/backend/tasks/jobs/export.py` (`COMPLIANCE_CLASS_MAP` with `startswith` predicates) +- **UI framework router:** `ui/lib/compliance/compliance-mapper.ts` +- **UI per-framework mapper:** `ui/lib/compliance/{framework}.tsx` +- **UI detail panel:** `ui/components/compliance/compliance-custom-details/{framework}-details.tsx` +- **UI types:** `ui/types/compliance.ts` +- **UI icon:** `ui/components/icons/compliance/{framework}.svg` + registration in `IconCompliance.tsx` + +### Tests +- **Output formatter tests:** `tests/lib/outputs/compliance/{framework}/{framework}_{provider}_test.py` +- **Shared fixtures:** `tests/lib/outputs/compliance/fixtures.py` ## Resources -- **Templates:** See [assets/](assets/) for framework JSON templates +- **JSON Templates:** See [assets/](assets/) for framework JSON templates (cis, ens, iso27001, mitre_attack, prowler_threatscore, generic) +- **Config-driven compliance sync** (any upstream-backed framework): + - [assets/sync_framework.py](assets/sync_framework.py) β€” generic runner. Loads a YAML config, dynamically imports the declared parser, applies generic post-processing (id uniqueness safety net, `FamilyName` normalization, legacy check-mapping preservation with config-driven fallback keys), and writes the provider JSONs with Pydantic post-validation. Framework-agnostic β€” works for any compliance framework. + - [assets/configs/ccc.yaml](assets/configs/ccc.yaml) β€” canonical config example (FINOS CCC v2025.10). Copy and adapt for new frameworks. + - [assets/parsers/finos_ccc.py](assets/parsers/finos_ccc.py) β€” FINOS CCC YAML parser. Handles both upstream shapes (`control-families` and top-level `controls`), foreign-prefix AR rewriting, and genuine collision renumbering. Exposes `parse_upstream(config) -> list[dict]`. + - [assets/parsers/](assets/parsers/) β€” add new parser modules here for unfamiliar upstream formats (NIST OSCAL JSON, MITRE STIX, CIS Benchmarks, etc.). Each parser is a `{name}.py` file implementing `parse_upstream(config) -> list[dict]` with guaranteed-unique ids. +- **Reusable audit tooling** (added April 2026 after the FINOS CCC v2025.10 sync): + - [assets/audit_framework_template.py](assets/audit_framework_template.py) β€” explicit REPLACE decision ledger with pre-validation against the per-provider inventory. Drop-in template for auditing any framework. + - [assets/query_checks.py](assets/query_checks.py) β€” keyword/service/id query helper over `/tmp/checks_{provider}.json`. + - [assets/dump_section.py](assets/dump_section.py) β€” dumps every AR for a given id prefix across all 3 providers with current check mappings. + - [assets/build_inventory.py](assets/build_inventory.py) β€” generates `/tmp/checks_{provider}.json` from `*.metadata.json` files. - **Documentation:** See [references/compliance-docs.md](references/compliance-docs.md) for additional resources +- **Related skill:** [prowler-compliance-review](../prowler-compliance-review/SKILL.md) β€” PR review checklist and validator script for compliance framework PRs diff --git a/skills/prowler-compliance/assets/audit_framework_template.py b/skills/prowler-compliance/assets/audit_framework_template.py new file mode 100644 index 0000000000..f2d58603d7 --- /dev/null +++ b/skills/prowler-compliance/assets/audit_framework_template.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +""" +Cloud-auditor pass template for any Prowler compliance framework. + +Encode explicit REPLACE decisions per (requirement_id, provider) pair below. +Each decision FULLY overwrites the legacy Checks list for that requirement. + +Workflow: + 1. Run build_inventory.py first to cache per-provider check metadata. + 2. Run dump_section.py to see current mappings for the catalog you're auditing. + 3. Fill in DECISIONS below with explicit check lists. + 4. Run this script β€” it pre-validates every check id against the inventory + and aborts with stderr listing typos before writing. + +Decision rules (apply as a hostile cloud auditor): + - The Prowler check's title/risk MUST literally describe what the AR text says. + "Related" is not enough. + - If no check actually addresses the requirement, leave `[]` (= MANUAL). + HONEST MANUAL is worth more than padded coverage. + - Missing provider key = leave the legacy mapping untouched. + - Empty list `[]` = explicitly MANUAL (overwrites legacy). + +Usage: + # 1. Copy this file to /tmp/audit_.py and fill in DECISIONS + # 2. Edit FRAMEWORK_KEY below to match your framework file naming + # 3. Run: + python /tmp/audit_.py +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +# --------------------------------------------------------------------------- +# Configure for your framework +# --------------------------------------------------------------------------- + +# Framework file basename inside prowler/compliance/{provider}/. +# If your framework is called "cis_5.0_aws.json", FRAMEWORK_KEY is "cis_5.0". +# If the file is "ccc_aws.json", FRAMEWORK_KEY is "ccc". +FRAMEWORK_KEY = "ccc" + +# Which providers to apply decisions to. +PROVIDERS = ["aws", "azure", "gcp"] + +PROWLER_DIR = Path("prowler/compliance") +CHECK_INV = {prov: Path(f"/tmp/checks_{prov}.json") for prov in PROVIDERS} + + +# --------------------------------------------------------------------------- +# DECISIONS β€” encode one entry per requirement you want to audit +# --------------------------------------------------------------------------- + +# DECISIONS[requirement_id][provider] = list[str] of check ids +# See SKILL.md β†’ "Audit Reference Table: Requirement Text β†’ Prowler Checks" +# for a comprehensive mapping cheat sheet built from a 172-AR CCC audit. + +DECISIONS: dict[str, dict[str, list[str]]] = {} + +# ---- Example entries (delete and replace with your own) ---- + +# Example 1: TLS in transit enforced (non-SSH traffic) +# DECISIONS["CCC.Core.CN01.AR01"] = { +# "aws": [ +# "cloudfront_distributions_https_enabled", +# "cloudfront_distributions_origin_traffic_encrypted", +# "s3_bucket_secure_transport_policy", +# "elbv2_ssl_listeners", +# "rds_instance_transport_encrypted", +# "kafka_cluster_in_transit_encryption_enabled", +# "redshift_cluster_in_transit_encryption_enabled", +# "opensearch_service_domains_https_communications_enforced", +# ], +# "azure": [ +# "storage_secure_transfer_required_is_enabled", +# "app_minimum_tls_version_12", +# "postgresql_flexible_server_enforce_ssl_enabled", +# "sqlserver_recommended_minimal_tls_version", +# ], +# "gcp": [ +# "cloudsql_instance_ssl_connections", +# ], +# } + +# Example 2: MANUAL β€” no Prowler check exists +# DECISIONS["CCC.Core.CN01.AR07"] = { +# "aws": [], # no IANA port/protocol check exists in Prowler +# "azure": [], +# "gcp": [], +# } + +# Example 3: Reuse a decision for multiple sibling ARs +# DECISIONS["CCC.ObjStor.CN05.AR02"] = DECISIONS["CCC.ObjStor.CN05.AR01"] + + +# --------------------------------------------------------------------------- +# Driver β€” do not edit below +# --------------------------------------------------------------------------- + +def load_inventory(provider: str) -> dict: + path = CHECK_INV[provider] + if not path.exists(): + raise SystemExit( + f"Check inventory missing: {path}\n" + f"Run: python skills/prowler-compliance/assets/build_inventory.py {provider}" + ) + with open(path) as f: + return json.load(f) + + +def resolve_json_path(provider: str) -> Path: + """Resolve the JSON file path for a given provider. + + Handles both shapes: {FRAMEWORK_KEY}_{provider}.json (ccc_aws.json) and + cases where FRAMEWORK_KEY already contains the provider suffix. + """ + candidates = [ + PROWLER_DIR / provider / f"{FRAMEWORK_KEY}_{provider}.json", + PROWLER_DIR / provider / f"{FRAMEWORK_KEY}.json", + ] + for c in candidates: + if c.exists(): + return c + raise SystemExit( + f"Could not find framework JSON for provider={provider} " + f"with FRAMEWORK_KEY={FRAMEWORK_KEY}. Tried: {candidates}" + ) + + +def plan_for_provider( + provider: str, +) -> tuple[Path, dict, tuple[int, int, int], list[tuple[str, str]]]: + """Build the updated JSON for one provider without writing it. + + Returns (path, mutated_data, (touched, added, removed), unknowns). + Writing is deferred to a second pass so that a typo in any provider + aborts the whole run before any file on disk changes. + """ + path = resolve_json_path(provider) + with open(path) as f: + data = json.load(f) + inv = load_inventory(provider) + + touched = 0 + add_count = 0 + rm_count = 0 + unknown: list[tuple[str, str]] = [] + + for req in data["Requirements"]: + rid = req["Id"] + if rid not in DECISIONS or provider not in DECISIONS[rid]: + continue + new_checks = list(dict.fromkeys(DECISIONS[rid][provider])) + for c in new_checks: + if c not in inv: + unknown.append((rid, c)) + before = set(req.get("Checks") or []) + after = set(new_checks) + rm_count += len(before - after) + add_count += len(after - before) + req["Checks"] = new_checks + touched += 1 + + return path, data, (touched, add_count, rm_count), unknown + + +def main() -> int: + if not DECISIONS: + print("No DECISIONS encoded. Fill in the DECISIONS dict and re-run.") + return 1 + print(f"Applying {len(DECISIONS)} decisions to framework '{FRAMEWORK_KEY}'...") + + # Pass 1: validate every provider before touching disk. A typo in any + # provider must abort the run before ANY file has been rewritten. + plans: list[tuple[str, Path, dict, tuple[int, int, int]]] = [] + all_unknown: list[tuple[str, str, str]] = [] + for provider in PROVIDERS: + path, data, counts, unknown = plan_for_provider(provider) + for rid, c in unknown: + all_unknown.append((provider, rid, c)) + plans.append((provider, path, data, counts)) + + if all_unknown: + print("\n!! UNKNOWN CHECK IDS (typos?):", file=sys.stderr) + for provider, rid, c in all_unknown: + print(f" {provider} {rid} -> {c}", file=sys.stderr) + print( + "\nAborting: fix the check ids above and re-run. " + "No files were modified.", + file=sys.stderr, + ) + return 2 + + # Pass 2: all providers validated cleanly β€” write. + for provider, path, data, (touched, added, removed) in plans: + with open(path, "w") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + print( + f" {provider}: touched={touched} added={added} removed={removed}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/prowler-compliance/assets/build_inventory.py b/skills/prowler-compliance/assets/build_inventory.py new file mode 100644 index 0000000000..f743aa75a5 --- /dev/null +++ b/skills/prowler-compliance/assets/build_inventory.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +Build a per-provider check inventory by scanning Prowler's check metadata files. + +Outputs one JSON per provider at /tmp/checks_{provider}.json with the shape: + { + "check_id": { + "service": "...", + "subservice": "...", + "resource": "...", + "severity": "...", + "title": "...", + "description": "...", + "risk": "..." + }, + ... + } + +This is the reference used by audit_framework_template.py for pre-validation +(every check id in the audit ledger must exist in the inventory) and by +query_checks.py for keyword/service lookup. + +Usage: + python skills/prowler-compliance/assets/build_inventory.py + # Or for a specific provider: + python skills/prowler-compliance/assets/build_inventory.py aws + +Output: + /tmp/checks_{provider}.json for every provider discovered under + prowler/providers/ with a services/ directory. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +PROVIDERS_ROOT = Path("prowler/providers") + + +def discover_providers() -> list[str]: + """Return every provider that currently has a services/ directory. + + Derived from the filesystem so new providers are picked up automatically + and stale hard-coded lists cannot drift from the repo. + """ + if not PROVIDERS_ROOT.exists(): + return [] + return sorted( + p.name + for p in PROVIDERS_ROOT.iterdir() + if p.is_dir() and (p / "services").is_dir() + ) + + +def build_for_provider(provider: str) -> dict: + inventory: dict[str, dict] = {} + base = Path(f"prowler/providers/{provider}/services") + if not base.exists(): + print(f" skip {provider}: no services directory", file=sys.stderr) + return inventory + for meta_path in base.rglob("*.metadata.json"): + try: + with open(meta_path) as f: + data = json.load(f) + except Exception as exc: + print(f" warn: cannot parse {meta_path}: {exc}", file=sys.stderr) + continue + cid = data.get("CheckID") or meta_path.stem.replace(".metadata", "") + inventory[cid] = { + "service": data.get("ServiceName", ""), + "subservice": data.get("SubServiceName", ""), + "resource": data.get("ResourceType", ""), + "severity": data.get("Severity", ""), + "title": data.get("CheckTitle", ""), + "description": data.get("Description", ""), + "risk": data.get("Risk", ""), + } + return inventory + + +def main() -> int: + providers = sys.argv[1:] or discover_providers() + if not providers: + print( + f"error: no providers found under {PROVIDERS_ROOT}/", + file=sys.stderr, + ) + return 1 + for provider in providers: + inv = build_for_provider(provider) + out_path = Path(f"/tmp/checks_{provider}.json") + with open(out_path, "w") as f: + json.dump(inv, f, indent=2) + print(f" {provider}: {len(inv)} checks β†’ {out_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/prowler-compliance/assets/configs/ccc.yaml b/skills/prowler-compliance/assets/configs/ccc.yaml new file mode 100644 index 0000000000..deb757ffc9 --- /dev/null +++ b/skills/prowler-compliance/assets/configs/ccc.yaml @@ -0,0 +1,120 @@ +# FINOS Common Cloud Controls (CCC) sync config for sync_framework.py. +# +# Usage: +# python skills/prowler-compliance/assets/sync_framework.py \ +# skills/prowler-compliance/assets/configs/ccc.yaml +# +# Prerequisite: run the upstream fetch step from SKILL.md Workflow A Step 1 to +# populate upstream.dir with the raw FINOS catalog YAML files. + +framework: + name: CCC + display_name: Common Cloud Controls Catalog (CCC) + version: v2025.10 + # The {provider_display} placeholder is replaced at output time with the + # per-provider display string from the providers list below. + description_template: "Common Cloud Controls Catalog (CCC) for {provider_display}" + +providers: + - key: aws + display: AWS + - key: azure + display: Azure + - key: gcp + display: GCP + +output: + # Supported placeholders: {provider}, {framework}, {version}. + # For versioned frameworks like CIS the template would be + # "prowler/compliance/{provider}/cis_{version}_{provider}.json". + path_template: "prowler/compliance/{provider}/ccc_{provider}.json" + +upstream: + # Directory containing the cached FINOS catalog YAMLs. Populate via + # SKILL.md Workflow A Step 1 (gh api raw download commands). + dir: /tmp/ccc_upstream + fetch_docs: "See SKILL.md Workflow A Step 1 for gh api fetch commands" + +parser: + # Name of the parser module under parsers/ (loaded dynamically by the + # runner). For FINOS CCC YAML this is always finos_ccc. + module: finos_ccc + + # FINOS CCC catalog files in load order. Core first so its ARs render + # first in the output JSON. + catalog_files: + - core_ccc.yaml + - management_auditlog.yaml + - management_logging.yaml + - management_monitoring.yaml + - storage_object.yaml + - networking_loadbalancer.yaml + - networking_vpc.yaml + - crypto_key.yaml + - crypto_secrets.yaml + - database_warehouse.yaml + - database_vector.yaml + - database_relational.yaml + - devtools_build.yaml + - devtools_container-registry.yaml + - identity_iam.yaml + - ai-ml_gen-ai.yaml + - ai-ml_mlde.yaml + - app-integration_message.yaml + - compute_serverless-computing.yaml + + # Shape-2 catalogs (storage/object) reference the family via id only + # (e.g. "CCC.ObjStor.Data") with no human-readable title or description + # in the YAML. Map the suffix (after the last dot) to a canonical title + # and description so the generated JSON has consistent FamilyName fields + # regardless of upstream shape. + family_id_title: + Data: Data + IAM: Identity and Access Management + Identity: Identity and Access Management + Encryption: Encryption + Logging: Logging and Monitoring + Network: Network Security + Availability: Availability + Integrity: Integrity + Confidentiality: Confidentiality + family_id_description: + Data: "The Data control family ensures the confidentiality, integrity, availability, and sovereignty of data across its lifecycle." + IAM: "The Identity and Access Management control family ensures that only trusted and authenticated entities can access resources." + +post_processing: + # Collapse FamilyName variants that appear inconsistently across upstream + # catalogs. The Prowler UI groups by Attributes[0].FamilyName exactly, + # so each variant would otherwise become a separate tree branch. + family_name_normalization: + "Logging & Monitoring": "Logging and Monitoring" + "Logging and Metrics Publication": "Logging and Monitoring" + + # Preserve existing Checks lists from the legacy Prowler JSON when + # regenerating. The runner builds two lookup tables from the legacy + # output: a primary index by Id, and fallback indexes composed of + # attribute field names. + # + # primary_key: the top-level requirement field to use as the primary + # lookup key (almost always "Id") + # fallback_keys: a list of composite keys. Each composite key is a list + # of Attributes[0] field names to join into a tuple. List-valued fields + # (like Applicability) are frozen to frozenset so the tuple is hashable. + # + # CCC uses (Section, Applicability) because Applicability is a CCC-only + # top-level attribute field. CIS would use (Section, Profile). NIST would + # use (ItemId,). The fallback is how renumbered or rewritten ids still + # recover their check mappings. + # + # legacy_path_template (optional): path to read legacy Checks FROM. + # Defaults to output.path_template, which is correct for unversioned + # frameworks (like CCC) where regeneration overwrites the same file. + # For versioned frameworks that write to a new file on each version + # bump (e.g. cis_5.1_aws.json while the legacy mappings live in + # cis_5.0_aws.json), set this to the previous-version path so Checks + # are preserved instead of lost: + # legacy_path_template: "prowler/compliance/{provider}/cis_5.0_{provider}.json" + check_preservation: + primary_key: Id + fallback_keys: + - [Section, Applicability] diff --git a/skills/prowler-compliance/assets/dump_section.py b/skills/prowler-compliance/assets/dump_section.py new file mode 100644 index 0000000000..ca2fff0e1b --- /dev/null +++ b/skills/prowler-compliance/assets/dump_section.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +""" +Dump every requirement of a compliance framework for a given id prefix across +providers, with their current Check mappings. + +Useful for reviewing a whole control family in one pass before encoding audit +decisions in audit_framework_template.py. + +Usage: + # Dump all CCC.Core requirements across aws/azure/gcp + python skills/prowler-compliance/assets/dump_section.py ccc "CCC.Core." + + # Dump all CIS 5.0 section 1 requirements for AWS only + python skills/prowler-compliance/assets/dump_section.py cis_5.0_aws "1." + +Arguments: + framework_key: file prefix inside prowler/compliance/{provider}/ without + the provider suffix. Examples: + - "ccc" β†’ loads ccc_aws.json / ccc_azure.json / ccc_gcp.json + - "cis_5.0_aws" β†’ loads only that one file + - "iso27001_2022" β†’ loads all providers + id_prefix: Requirement id prefix to filter by (e.g. "CCC.Core.", + "1.1.", "A.5."). +""" +from __future__ import annotations + +import json +import sys +from collections import defaultdict +from pathlib import Path + +PROWLER_COMPLIANCE_DIR = Path("prowler/compliance") + + +def main() -> int: + if len(sys.argv) < 3: + print(__doc__) + return 1 + + framework_key = sys.argv[1] + id_prefix = sys.argv[2] + + # Find matching JSON files across all providers + candidates: list[tuple[str, Path]] = [] + for prov_dir in sorted(PROWLER_COMPLIANCE_DIR.iterdir()): + if not prov_dir.is_dir(): + continue + for json_path in prov_dir.glob("*.json"): + stem = json_path.stem + if stem == framework_key or stem.startswith(f"{framework_key}_") \ + or stem == f"{framework_key}_{prov_dir.name}": + candidates.append((prov_dir.name, json_path)) + + if not candidates: + print(f"No files matching '{framework_key}'", file=sys.stderr) + return 2 + + discovered_providers = sorted({prov for prov, _ in candidates}) + + by_id: dict[str, dict] = defaultdict(dict) + for prov, path in candidates: + with open(path) as f: + data = json.load(f) + for req in data["Requirements"]: + if req["Id"].startswith(id_prefix): + by_id[req["Id"]][prov] = { + "desc": req.get("Description", ""), + "sec": (req.get("Attributes") or [{}])[0].get("Section", ""), + "obj": (req.get("Attributes") or [{}])[0].get( + "SubSectionObjective", "" + ), + "checks": req.get("Checks") or [], + } + + for ar_id in sorted(by_id): + rows = by_id[ar_id] + sample = next(iter(rows.values())) + print(f"\n### {ar_id}") + print(f" desc: {sample['desc']}") + if sample["sec"]: + print(f" sec : {sample['sec']}") + if sample["obj"]: + print(f" obj : {sample['obj']}") + for prov in discovered_providers: + if prov in rows: + checks = rows[prov]["checks"] + print(f" {prov}: ({len(checks)}) {checks}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/prowler-compliance/assets/parsers/__init__.py b/skills/prowler-compliance/assets/parsers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/skills/prowler-compliance/assets/parsers/finos_ccc.py b/skills/prowler-compliance/assets/parsers/finos_ccc.py new file mode 100644 index 0000000000..a613b15857 --- /dev/null +++ b/skills/prowler-compliance/assets/parsers/finos_ccc.py @@ -0,0 +1,223 @@ +""" +FINOS Common Cloud Controls (CCC) YAML parser. + +Reads cached upstream YAML files and emits Prowler-format requirements +(``{Id, Description, Attributes: [...], Checks: []}``). This module is +agnostic to providers, JSON output paths, framework metadata and legacy +check-mapping preservation β€” those are handled by ``sync_framework.py``. + +Contract +-------- +``parse_upstream(config: dict) -> list[dict]`` + Returns a list of Prowler-format requirement dicts with **guaranteed + unique ids**. Foreign-prefix AR rewriting and genuine collision + renumbering both happen inside this module β€” the runner treats id + uniqueness as a contract violation, not as something to fix. + +Config keys consumed +-------------------- +This parser reads the following config entries (the rest of the config is +opaque to it): + +- ``upstream.dir`` β€” directory containing the cached YAMLs +- ``parser.catalog_files`` β€” ordered list of YAML filenames to load +- ``parser.family_id_title`` β€” suffix β†’ canonical family title (shape 2) +- ``parser.family_id_description`` β€” suffix β†’ family description (shape 2) + +Upstream shapes +--------------- +FINOS CCC catalogs come in two shapes: + +1. ``control-families: [{title, description, controls: [...]}]`` + (used by most catalogs) +2. ``controls: [{id, family: "CCC.X.Y", ...}]`` (no families wrapper; used + by ``storage/object``). The ``family`` field references a family id with + no human-readable title in the file β€” the title/description come from + ``config.parser.family_id_title`` / ``family_id_description``. + +Id rewriting rules +------------------ +- **Foreign-prefix rewriting**: upstream intentionally aliases requirements + across catalogs by keeping the original prefix (e.g. ``CCC.AuditLog.CN08.AR01`` + appears nested under ``CCC.Logging.CN03``). Prowler requires unique ids + within a catalog file, so we rename the AR to fit its parent control: + ``CCC.Logging.CN03.AR01``. See ``rewrite_ar_id()``. +- **Genuine collision renumbering**: sometimes upstream has a real typo + where two distinct requirements share the same id (e.g. + ``CCC.Core.CN14.AR02`` appears twice for 30-day and 14-day backup variants). + The second copy is renumbered to the next free AR number within the + control. See the ``seen_ids`` logic in ``emit_requirement()``. +""" +from __future__ import annotations + +from pathlib import Path + +import yaml + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def clean(value: str | None) -> str: + """Trim and collapse internal whitespace/newlines into single spaces. + + Upstream YAML uses ``|`` block scalars that preserve newlines; Prowler + stores descriptions as single-line text. + """ + if not value: + return "" + return " ".join(value.split()) + + +def flatten_mappings(mappings): + """Convert upstream ``{reference-id, entries: [{reference-id, ...}]}`` to + Prowler's ``{ReferenceId, Identifiers: [...]}``. + """ + if not mappings: + return [] + out = [] + for m in mappings: + ids = [] + for entry in m.get("entries") or []: + eid = entry.get("reference-id") + if eid: + ids.append(eid) + out.append({"ReferenceId": m.get("reference-id", ""), "Identifiers": ids}) + return out + + +def ar_prefix(ar_id: str) -> str: + """Return the first three dot-segments of an AR id (the parent control). + + e.g. ``CCC.Core.CN01.AR01`` -> ``CCC.Core.CN01``. + """ + return ".".join(ar_id.split(".")[:3]) + + +def rewrite_ar_id(parent_control_id: str, original_ar_id: str, ar_index: int) -> str: + """If an AR's id doesn't share its parent control's prefix, rename it. + + Example + ------- + parent ``CCC.Logging.CN03`` + AR id ``CCC.AuditLog.CN08.AR01`` with + index 0 -> ``CCC.Logging.CN03.AR01``. + """ + if ar_prefix(original_ar_id) == parent_control_id: + return original_ar_id + return f"{parent_control_id}.AR{ar_index + 1:02d}" + + +def emit_requirement( + control: dict, + family_name: str, + family_desc: str, + seen_ids: set[str], + requirements: list[dict], +) -> None: + """Translate one FINOS control + its assessment-requirements into + Prowler-format requirement dicts and append them to ``requirements``. + + Applies foreign-prefix rewriting and genuine-collision renumbering so + the final list is guaranteed to have unique ids. + """ + control_id = clean(control.get("id")) + control_title = clean(control.get("title")) + section = f"{control_id} {control_title}".strip() + objective = clean(control.get("objective")) + threat_mappings = flatten_mappings(control.get("threat-mappings")) + guideline_mappings = flatten_mappings(control.get("guideline-mappings")) + ars = control.get("assessment-requirements") or [] + for idx, ar in enumerate(ars): + raw_id = clean(ar.get("id")) + if not raw_id: + continue + new_id = rewrite_ar_id(control_id, raw_id, idx) + # Renumber on genuine upstream collision (find next free AR number) + if new_id in seen_ids: + base = ".".join(new_id.split(".")[:-1]) + n = 1 + while f"{base}.AR{n:02d}" in seen_ids: + n += 1 + new_id = f"{base}.AR{n:02d}" + seen_ids.add(new_id) + + requirements.append( + { + "Id": new_id, + "Description": clean(ar.get("text")), + "Attributes": [ + { + "FamilyName": family_name, + "FamilyDescription": family_desc, + "Section": section, + "SubSection": "", + "SubSectionObjective": objective, + "Applicability": list(ar.get("applicability") or []), + "Recommendation": clean(ar.get("recommendation")), + "SectionThreatMappings": threat_mappings, + "SectionGuidelineMappings": guideline_mappings, + } + ], + "Checks": [], + } + ) + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + + +def parse_upstream(config: dict) -> list[dict]: + """Walk upstream YAMLs and emit Prowler-format requirements. + + Handles both top-level shapes (``control-families`` and ``controls``). + Ids are guaranteed unique in the returned list. + """ + upstream_dir = Path(config["upstream"]["dir"]) + parser_cfg = config.get("parser") or {} + catalog_files = parser_cfg.get("catalog_files") or [] + family_id_title = parser_cfg.get("family_id_title") or {} + family_id_description = parser_cfg.get("family_id_description") or {} + + requirements: list[dict] = [] + seen_ids: set[str] = set() + + for filename in catalog_files: + path = upstream_dir / filename + if not path.exists(): + # parser.catalog_files is the closed set of upstream catalogs + # that define the framework. Silently skipping a missing file + # would emit valid-looking JSON with part of the framework + # dropped, defeating the whole point of a canonical sync. + raise FileNotFoundError( + f"upstream catalog file not found: {path}\n" + f" hint: refresh the upstream cache (see SKILL.md Workflow A " + f"Step 1), or remove {filename!r} from parser.catalog_files " + f"if it has been retired upstream." + ) + with open(path) as f: + doc = yaml.safe_load(f) or {} + + # Shape 1: control-families wrapper + for family in doc.get("control-families") or []: + family_name = clean(family.get("title")) + family_desc = clean(family.get("description")) + for control in family.get("controls") or []: + emit_requirement( + control, family_name, family_desc, seen_ids, requirements + ) + + # Shape 2: top-level controls with family reference id + for control in doc.get("controls") or []: + family_ref = clean(control.get("family")) + suffix = family_ref.split(".")[-1] if family_ref else "" + family_name = family_id_title.get(suffix, suffix or "Data") + family_desc = family_id_description.get(suffix, "") + emit_requirement( + control, family_name, family_desc, seen_ids, requirements + ) + + return requirements diff --git a/skills/prowler-compliance/assets/query_checks.py b/skills/prowler-compliance/assets/query_checks.py new file mode 100644 index 0000000000..46405be982 --- /dev/null +++ b/skills/prowler-compliance/assets/query_checks.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" +Keyword/service/id lookup over a Prowler check inventory produced by +build_inventory.py. + +Usage: + # Keyword AND-search across id + title + risk + description + python skills/prowler-compliance/assets/query_checks.py aws encryption transit + + # Show all checks for a service + python skills/prowler-compliance/assets/query_checks.py aws --service iam + + # Show full metadata for one check id + python skills/prowler-compliance/assets/query_checks.py aws --id kms_cmk_rotation_enabled +""" +from __future__ import annotations + +import json +import sys + + +def main() -> int: + if len(sys.argv) < 3: + print(__doc__) + return 1 + + provider = sys.argv[1] + try: + with open(f"/tmp/checks_{provider}.json") as f: + inv = json.load(f) + except FileNotFoundError: + print( + f"No inventory for {provider}. Run build_inventory.py first.", + file=sys.stderr, + ) + return 2 + + if sys.argv[2] == "--service": + if len(sys.argv) < 4: + print("usage: --service ") + return 1 + svc = sys.argv[3] + hits = [cid for cid in sorted(inv) if inv[cid].get("service") == svc] + for cid in hits: + print(f" {cid}") + print(f" {inv[cid].get('title', '')}") + print(f"\n{len(hits)} checks in service '{svc}'") + elif sys.argv[2] == "--id": + if len(sys.argv) < 4: + print("usage: --id ") + return 1 + cid = sys.argv[3] + if cid not in inv: + print(f"NOT FOUND: {cid}") + return 3 + m = inv[cid] + print(f"== {cid} ==") + print(f"service : {m.get('service')}") + print(f"severity: {m.get('severity')}") + print(f"resource: {m.get('resource')}") + print(f"title : {m.get('title')}") + print(f"desc : {m.get('description', '')[:500]}") + print(f"risk : {m.get('risk', '')[:500]}") + else: + keywords = [k.lower() for k in sys.argv[2:]] + hits = 0 + for cid in sorted(inv): + m = inv[cid] + blob = " ".join( + [ + cid, + m.get("title", ""), + m.get("risk", ""), + m.get("description", ""), + ] + ).lower() + if all(k in blob for k in keywords): + hits += 1 + print(f" {cid} [{m.get('service', '')}]") + print(f" {m.get('title', '')[:120]}") + print(f"\n{hits} matches for {' + '.join(keywords)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/prowler-compliance/assets/sync_framework.py b/skills/prowler-compliance/assets/sync_framework.py new file mode 100644 index 0000000000..9e070f2691 --- /dev/null +++ b/skills/prowler-compliance/assets/sync_framework.py @@ -0,0 +1,536 @@ +#!/usr/bin/env python3 +""" +Generic, config-driven compliance framework sync runner. + +Usage: + python skills/prowler-compliance/assets/sync_framework.py \ + skills/prowler-compliance/assets/configs/ccc.yaml + +Pipeline: + 1. Load and validate the YAML config (fail fast on missing or empty + required fields β€” notably ``framework.version``, which silently + breaks ``get_check_compliance()`` key construction if empty). + 2. Dynamically import the parser module declared in ``parser.module`` + (resolved as ``parsers.{name}`` under this script's directory). + 3. Call ``parser.parse_upstream(config) -> list[dict]`` to get raw + Prowler-format requirements. The parser owns all upstream-format + quirks (foreign-prefix AR rewriting, collision renumbering, shape + handling) and MUST return ids that are unique within the returned + list. + 4. **Safety net**: assert id uniqueness. The runner raises + ``ValueError`` on any duplicate β€” it does NOT silently renumber, + because mutating a canonical upstream id (e.g. CIS ``1.1.1`` or + NIST ``AC-2(1)``) would be catastrophic. + 5. Apply generic ``FamilyName`` normalization from + ``post_processing.family_name_normalization`` (optional). + 6. Preserve legacy ``Checks`` lists from the existing Prowler JSON + using a config-driven primary key + fallback key chain. CCC uses + ``(Section, Applicability)`` as fallback; CIS would use + ``(Section, Profile)``; NIST would use ``(ItemId,)``. + For versioned frameworks (e.g. ``cis__.json``) + where a version bump writes to a brand-new file, set + ``post_processing.check_preservation.legacy_path_template`` to + point at the previous version's file so its Checks are preserved + instead of silently lost. Defaults to ``output.path_template`` + when omitted, which is correct for unversioned frameworks. + 7. Wrap each provider's requirements in the framework metadata dict + built from the config templates. + 8. Write each provider's JSON to the path resolved from + ``output.path_template`` (supports ``{framework}``, ``{version}`` + and ``{provider}`` placeholders). + 9. Pydantic-validate the written JSON via ``Compliance.parse_file()`` + and report the load counts per provider. + +The runner is strictly generic β€” it never mentions CCC, knows nothing +about YAML shapes, and can handle any upstream-backed framework given a +parser module and a config file. +""" +from __future__ import annotations + +import importlib +import json +import sys +from pathlib import Path +from typing import Any + +import yaml + +# Make sibling `parsers/` package importable regardless of the runner's +# invocation directory. +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + + +# --------------------------------------------------------------------------- +# Config loading and validation +# --------------------------------------------------------------------------- + + +class ConfigError(ValueError): + """Raised when the sync config is malformed or missing required fields.""" + + +def _require(cfg: dict, dotted_path: str) -> Any: + """Fetch a dotted-path key from nested dicts. Raises ConfigError on + missing or empty values (empty-string, empty-list, None).""" + current: Any = cfg + parts = dotted_path.split(".") + for i, part in enumerate(parts): + if not isinstance(current, dict) or part not in current: + raise ConfigError(f"config: missing required field '{dotted_path}'") + current = current[part] + if current in ("", None, [], {}): + raise ConfigError(f"config: field '{dotted_path}' must not be empty") + return current + + +def load_config(path: Path) -> dict: + if not path.exists(): + raise ConfigError(f"config file not found: {path}") + with open(path) as f: + cfg = yaml.safe_load(f) or {} + if not isinstance(cfg, dict): + raise ConfigError(f"config root must be a mapping, got {type(cfg).__name__}") + + # Required fields β€” fail fast. Empty Version in particular silently + # breaks get_check_compliance() key construction. + _require(cfg, "framework.name") + _require(cfg, "framework.display_name") + _require(cfg, "framework.version") + _require(cfg, "framework.description_template") + _require(cfg, "providers") + _require(cfg, "output.path_template") + _require(cfg, "upstream.dir") + _require(cfg, "parser.module") + _require(cfg, "post_processing.check_preservation.primary_key") + + providers = cfg["providers"] + if not isinstance(providers, list) or not providers: + raise ConfigError("config: 'providers' must be a non-empty list") + for idx, p in enumerate(providers): + if not isinstance(p, dict) or "key" not in p or "display" not in p: + raise ConfigError( + f"config: providers[{idx}] must have 'key' and 'display' fields" + ) + + return cfg + + +# --------------------------------------------------------------------------- +# Parser loading +# --------------------------------------------------------------------------- + + +def load_parser(parser_module_name: str): + try: + return importlib.import_module(f"parsers.{parser_module_name}") + except ImportError as exc: + raise ConfigError( + f"cannot import parser 'parsers.{parser_module_name}': {exc}" + ) from exc + + +# --------------------------------------------------------------------------- +# Post-processing: id uniqueness safety net +# --------------------------------------------------------------------------- + + +def assert_unique_ids(requirements: list[dict]) -> None: + """Enforce the parser contract: every requirement must have a unique Id. + + The runner never renumbers silently β€” a duplicate is a parser bug. + """ + seen: set[str] = set() + dups: list[str] = [] + for req in requirements: + rid = req.get("Id") + if not rid: + raise ValueError(f"requirement missing Id: {req}") + if rid in seen: + dups.append(rid) + seen.add(rid) + if dups: + raise ValueError( + f"parser returned duplicate requirement ids: {sorted(set(dups))}" + ) + + +# --------------------------------------------------------------------------- +# Post-processing: FamilyName normalization +# --------------------------------------------------------------------------- + + +def normalize_family_names(requirements: list[dict], norm_map: dict[str, str]) -> None: + """Apply ``Attributes[0].FamilyName`` normalization in place.""" + if not norm_map: + return + for req in requirements: + for attr in req.get("Attributes") or []: + name = attr.get("FamilyName") + if name in norm_map: + attr["FamilyName"] = norm_map[name] + + +# --------------------------------------------------------------------------- +# Post-processing: legacy check-mapping preservation +# --------------------------------------------------------------------------- + + +def _freeze(value: Any) -> Any: + """Make a value hashable for use in composite lookup keys. + + Lists become frozensets (order-insensitive match). Scalars pass through. + """ + if isinstance(value, list): + return frozenset(value) + return value + + +def _build_fallback_key(attrs: dict, field_names: list[str]) -> tuple | None: + """Build a composite tuple key from the given attribute field names. + + Returns None if any field is missing or falsy β€” that key will be + skipped (the lookup table just won't have an entry for it). + """ + parts = [] + for name in field_names: + if name not in attrs: + return None + value = attrs[name] + if value in ("", None, [], {}): + return None + parts.append(_freeze(value)) + return tuple(parts) + + +def load_legacy_check_maps( + legacy_path: Path, + primary_key: str, + fallback_keys: list[list[str]], +) -> tuple[dict[str, list[str]], list[dict[tuple, list[str]]]]: + """Read the existing Prowler JSON and build lookup tables for check + preservation. + + Fails fast on ambiguous preservation keys. If two distinct legacy + requirements share the same primary value or the same fallback tuple, + merging their ``Checks`` silently would corrupt the preserved mapping + for unrelated requirements. Raises ``ValueError`` listing every + conflict so the user can either dedupe the legacy data or strengthen + ``check_preservation`` in the sync config. + + Returns + ------- + by_primary : dict + ``{primary_value: [checks]}`` β€” e.g. ``{ar_id: [checks]}``. + by_fallback : list[dict] + One lookup dict per entry in ``fallback_keys``. Each maps a + composite tuple key to its preserved checks list. + """ + by_primary: dict[str, list[str]] = {} + by_fallback: list[dict[tuple, list[str]]] = [{} for _ in fallback_keys] + + if not legacy_path.exists(): + return by_primary, by_fallback + + with open(legacy_path) as f: + data = json.load(f) + + # Track which legacy requirement Ids contributed to each bucket so we + # can surface ambiguity after the scan completes. + primary_sources: dict[str, list[str]] = {} + fallback_sources: list[dict[tuple, list[str]]] = [{} for _ in fallback_keys] + + for req in data.get("Requirements") or []: + legacy_id = req.get("Id") or "" + checks = req.get("Checks") or [] + + pv = req.get(primary_key) + if pv: + primary_sources.setdefault(pv, []).append(legacy_id) + bucket = by_primary.setdefault(pv, []) + for c in checks: + if c not in bucket: + bucket.append(c) + + attributes = req.get("Attributes") or [] + if not attributes: + continue + attrs = attributes[0] + for i, field_names in enumerate(fallback_keys): + key = _build_fallback_key(attrs, field_names) + if key is None: + continue + fallback_sources[i].setdefault(key, []).append(legacy_id) + bucket = by_fallback[i].setdefault(key, []) + for c in checks: + if c not in bucket: + bucket.append(c) + + conflicts: list[str] = [] + for pv, ids in primary_sources.items(): + if len(ids) > 1: + conflicts.append( + f"primary_key={primary_key!r} value={pv!r} shared by {ids}" + ) + for i, field_names in enumerate(fallback_keys): + for key, ids in fallback_sources[i].items(): + if len(ids) > 1: + conflicts.append( + f"fallback_key={field_names} value={key!r} shared by {ids}" + ) + if conflicts: + details = "\n - ".join(conflicts) + raise ValueError( + f"ambiguous preservation keys in {legacy_path} β€” cannot " + f"faithfully preserve Checks across distinct requirements:\n" + f" - {details}\n" + f"Fix: dedupe the legacy JSON, or strengthen " + f"'post_processing.check_preservation' in the sync config " + f"(e.g. add a more discriminating field to fallback_keys)." + ) + + return by_primary, by_fallback + + +def lookup_preserved_checks( + req: dict, + by_primary: dict, + by_fallback: list[dict], + primary_key: str, + fallback_keys: list[list[str]], +) -> list[str]: + """Return preserved check ids for a requirement, trying the primary + key first then each fallback in order.""" + pv = req.get(primary_key) + if pv and pv in by_primary: + return list(by_primary[pv]) + attributes = req.get("Attributes") or [] + if not attributes: + return [] + attrs = attributes[0] + for i, field_names in enumerate(fallback_keys): + key = _build_fallback_key(attrs, field_names) + if key and key in by_fallback[i]: + return list(by_fallback[i][key]) + return [] + + +# --------------------------------------------------------------------------- +# Provider output assembly +# --------------------------------------------------------------------------- + + +def resolve_output_path(template: str, framework: dict, provider_key: str) -> Path: + return Path( + template.format( + provider=provider_key, + framework=framework["name"].lower(), + version=framework["version"], + ) + ) + + +def build_provider_json( + config: dict, + provider: dict, + base_requirements: list[dict], +) -> tuple[dict, dict[str, int]]: + """Produce the provider-specific JSON dict ready to dump. + + Returns ``(json_dict, counts)`` where ``counts`` tracks how each + requirement's checks were resolved (primary, fallback, or none). + """ + framework = config["framework"] + preservation = config["post_processing"]["check_preservation"] + primary_key = preservation["primary_key"] + fallback_keys = preservation.get("fallback_keys") or [] + + # For versioned frameworks, the file we WRITE (output.path_template + # resolved at the new version) is not the file we want to READ legacy + # Checks from. Allow the config to override the legacy source path so + # a version bump can still preserve mappings from the previous file. + legacy_template = ( + preservation.get("legacy_path_template") + or config["output"]["path_template"] + ) + legacy_path = resolve_output_path( + legacy_template, framework, provider["key"] + ) + by_primary, by_fallback = load_legacy_check_maps( + legacy_path, primary_key, fallback_keys + ) + + counts = {"primary": 0, "fallback": 0, "none": 0} + enriched: list[dict] = [] + for req in base_requirements: + # Try primary key first + pv = req.get(primary_key) + checks: list[str] = [] + source = "none" + if pv and pv in by_primary: + checks = list(by_primary[pv]) + source = "primary" + else: + attributes = req.get("Attributes") or [] + if attributes: + attrs = attributes[0] + for i, field_names in enumerate(fallback_keys): + key = _build_fallback_key(attrs, field_names) + if key and key in by_fallback[i]: + checks = list(by_fallback[i][key]) + source = "fallback" + break + counts[source] += 1 + enriched.append( + { + "Id": req["Id"], + "Description": req["Description"], + # Shallow-copy attribute dicts so providers don't share refs + "Attributes": [dict(a) for a in req.get("Attributes") or []], + "Checks": checks, + } + ) + + description = framework["description_template"].format( + provider_display=provider["display"], + provider_key=provider["key"], + framework_name=framework["name"], + framework_display=framework["display_name"], + version=framework["version"], + ) + out = { + "Framework": framework["name"], + "Version": framework["version"], + "Provider": provider["display"], + "Name": framework["display_name"], + "Description": description, + "Requirements": enriched, + } + return out, counts + + +# --------------------------------------------------------------------------- +# Pydantic post-validation +# --------------------------------------------------------------------------- + + +def pydantic_validate(json_path: Path) -> int: + """Import Prowler lazily so the runner still works without Prowler + installed (validation step is skipped in that case).""" + try: + from prowler.lib.check.compliance_models import Compliance + except ImportError: + print( + " note: prowler package not importable β€” skipping Pydantic validation", + file=sys.stderr, + ) + return -1 + try: + parsed = Compliance.parse_file(str(json_path)) + except Exception as exc: + raise RuntimeError( + f"Pydantic validation failed for {json_path}: {exc}" + ) from exc + return len(parsed.Requirements) + + +# --------------------------------------------------------------------------- +# Driver +# --------------------------------------------------------------------------- + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: sync_framework.py ", file=sys.stderr) + return 1 + + config_path = Path(sys.argv[1]) + try: + config = load_config(config_path) + except ConfigError as exc: + print(f"config error: {exc}", file=sys.stderr) + return 2 + + framework_name = config["framework"]["name"] + upstream_dir = Path(config["upstream"]["dir"]) + if not upstream_dir.exists(): + print( + f"error: upstream cache dir {upstream_dir} not found\n" + f" hint: {config['upstream'].get('fetch_docs', '(see SKILL.md Workflow A Step 1)')}", + file=sys.stderr, + ) + return 3 + + parser_module_name = config["parser"]["module"] + print( + f"Sync: framework={framework_name} version={config['framework']['version']} " + f"parser={parser_module_name}" + ) + + try: + parser = load_parser(parser_module_name) + except ConfigError as exc: + print(f"parser error: {exc}", file=sys.stderr) + return 4 + + print(f"Parsing upstream from {upstream_dir}...") + try: + base_requirements = parser.parse_upstream(config) + except FileNotFoundError as exc: + # A missing catalog declared in parser.catalog_files is a hard + # failure: emitting JSON with part of the framework silently + # dropped would violate the canonical-sync contract. + print(f"upstream error: {exc}", file=sys.stderr) + return 6 + print(f" parser returned {len(base_requirements)} requirements") + + # Safety-net: parser contract + try: + assert_unique_ids(base_requirements) + except ValueError as exc: + print(f"parser contract violation: {exc}", file=sys.stderr) + return 5 + + # Post-processing: family name normalization + norm_map = ( + config.get("post_processing", {}) + .get("family_name_normalization") + or {} + ) + normalize_family_names(base_requirements, norm_map) + + # Per-provider output + print() + for provider in config["providers"]: + provider_json, counts = build_provider_json( + config, provider, base_requirements + ) + out_path = resolve_output_path( + config["output"]["path_template"], + config["framework"], + provider["key"], + ) + out_path.parent.mkdir(parents=True, exist_ok=True) + with open(out_path, "w") as f: + json.dump(provider_json, f, indent=2, ensure_ascii=False) + f.write("\n") + + validated = pydantic_validate(out_path) + validated_msg = ( + f" pydantic_reqs={validated}" if validated >= 0 else " pydantic=skipped" + ) + print( + f" {provider['key']}: total={len(provider_json['Requirements'])} " + f"matched_primary={counts['primary']} " + f"matched_fallback={counts['fallback']} " + f"new_or_unmatched={counts['none']}{validated_msg}" + ) + print(f" wrote {out_path}") + + print("\nDone.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 98b9449e14f7d57c8e3da2cfa117c893e650ae3f Mon Sep 17 00:00:00 2001 From: Boon Date: Mon, 20 Apr 2026 23:30:21 +0800 Subject: [PATCH 12/26] feat: add nginx reverse proxy configuration (#8516) (#10780) Co-authored-by: Boon --- contrib/reverse-proxy/README.md | 64 +++++++++++++++++ .../docker-compose.reverse-proxy.yml | 42 +++++++++++ contrib/reverse-proxy/nginx.conf | 70 +++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 contrib/reverse-proxy/README.md create mode 100644 contrib/reverse-proxy/docker-compose.reverse-proxy.yml create mode 100644 contrib/reverse-proxy/nginx.conf diff --git a/contrib/reverse-proxy/README.md b/contrib/reverse-proxy/README.md new file mode 100644 index 0000000000..a6387e689a --- /dev/null +++ b/contrib/reverse-proxy/README.md @@ -0,0 +1,64 @@ +# Prowler Reverse Proxy Configuration + +Ready-to-use nginx configuration for running Prowler behind a reverse proxy. + +## Problem + +Prowler's default Docker setup exposes two separate services: +- **UI** on port 3000 +- **API** on port 8080 + +This causes CORS issues and authentication failures (especially SAML SSO) when accessed through an external reverse proxy, since the proxy typically exposes a single domain. + +## Solution + +This adds an nginx container that unifies both services behind a single port, correctly forwarding headers so that Django generates proper URLs for SAML ACS callbacks and API responses. + +## Quick Start + +From the prowler root directory: + + docker compose -f docker-compose.yml \ + -f contrib/reverse-proxy/docker-compose.reverse-proxy.yml \ + up -d + +Access Prowler at http://localhost (port 80). + +## With an External Reverse Proxy + +Point your external reverse proxy to the prowler-nginx container on port 80. + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| PROWLER_PROXY_PORT | 80 | Port exposed by the nginx proxy | + +### Example: Traefik + + services: + nginx: + labels: + - "traefik.enable=true" + - "traefik.http.routers.prowler.rule=Host(`prowler.example.com`)" + - "traefik.http.routers.prowler.tls.certresolver=letsencrypt" + - "traefik.http.services.prowler.loadbalancer.server.port=80" + +### Example: Caddy + + prowler.example.com { + reverse_proxy prowler-nginx:80 + } + +## SAML SSO + +If using SAML SSO behind a reverse proxy, also set the SAML_ACS_BASE_URL environment variable: + + SAML_ACS_BASE_URL=https://prowler.example.com + +## Architecture + + Internet -> External Reverse Proxy -> prowler-nginx:80 + |-- /api/* -> prowler-api:8080 + |-- /accounts/saml/ -> prowler-api:8080 + +-- /* -> prowler-ui:3000 diff --git a/contrib/reverse-proxy/docker-compose.reverse-proxy.yml b/contrib/reverse-proxy/docker-compose.reverse-proxy.yml new file mode 100644 index 0000000000..08c52f3558 --- /dev/null +++ b/contrib/reverse-proxy/docker-compose.reverse-proxy.yml @@ -0,0 +1,42 @@ +# Prowler Reverse Proxy - Docker Compose Override +# +# Use this alongside the main docker-compose.yml to add an nginx +# reverse proxy that unifies UI and API behind a single port. +# +# Usage: +# docker compose -f docker-compose.yml -f contrib/reverse-proxy/docker-compose.reverse-proxy.yml up -d +# +# Then access Prowler at http://localhost (port 80) or configure +# your external reverse proxy (Traefik, Caddy, Cloudflare Tunnel, +# Pangolin, etc.) to point to this container on port 80. +# +# For HTTPS with your own certs, see the README in this directory. +# +# Fixes: https://github.com/prowler-cloud/prowler/issues/8516 + +services: + nginx: + image: nginx:alpine + container_name: prowler-nginx + restart: unless-stopped + ports: + - "${PROWLER_PROXY_PORT:-80}:80" + volumes: + - ./contrib/reverse-proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - prowler-ui + - prowler-api + networks: + - prowler-network + + # Override UI to not expose port externally (nginx handles it) + prowler-ui: + ports: !reset [] + + # Override API to not expose port externally (nginx handles it) + prowler-api: + ports: !reset [] + +networks: + prowler-network: + driver: bridge diff --git a/contrib/reverse-proxy/nginx.conf b/contrib/reverse-proxy/nginx.conf new file mode 100644 index 0000000000..58520295bc --- /dev/null +++ b/contrib/reverse-proxy/nginx.conf @@ -0,0 +1,70 @@ +# Prowler Reverse Proxy Configuration +# Routes both UI and API through a single endpoint +# +# Usage: See docker-compose.reverse-proxy.yml +# Fixes: https://github.com/prowler-cloud/prowler/issues/8516 + +upstream prowler-ui { + server prowler-ui:3000; +} + +upstream prowler-api { + server prowler-api:8080; +} + +server { + listen 80; + server_name _; + + # Security headers + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # API requests β€” proxy to prowler-api + location /api/ { + proxy_pass http://prowler-api/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_read_timeout 300s; + proxy_connect_timeout 10s; + + # Handle large scan payloads + client_max_body_size 50m; + } + + # SAML endpoints β€” proxy to prowler-api + location /accounts/saml/ { + proxy_pass http://prowler-api/accounts/saml/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + } + + # Everything else β€” proxy to prowler-ui + location / { + proxy_pass http://prowler-ui/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + + # WebSocket support for Next.js HMR (dev) and live updates + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # Health check endpoint + location /health { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } +} From 8d48c26c1eadc419dd6222d15aaf7f55fd0c5cb4 Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Mon, 20 Apr 2026 17:57:32 +0200 Subject: [PATCH 13/26] chore(secrets): don't block for trufflehog (#10806) --- .github/workflows/find-secrets.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/find-secrets.yml b/.github/workflows/find-secrets.yml index 0aa955413f..88f84d6729 100644 --- a/.github/workflows/find-secrets.yml +++ b/.github/workflows/find-secrets.yml @@ -27,11 +27,12 @@ jobs: - name: Harden Runner uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 with: - egress-policy: block - allowed-endpoints: > - github.com:443 - ghcr.io:443 - pkg-containers.githubusercontent.com:443 + # We can't block as Trufflehog needs to verify secrets against vendors + egress-policy: audit + # allowed-endpoints: > + # github.com:443 + # ghcr.io:443 + # pkg-containers.githubusercontent.com:443 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 From 390bbdd1a623220db68352d25dfeb83784e4b4e3 Mon Sep 17 00:00:00 2001 From: "Pablo Fernandez Guerra (PFE)" <148432447+pfe-nazaries@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:11:51 +0200 Subject: [PATCH 14/26] refactor(ui): remove backward-compat redirect for legacy invitation links (#10797) Co-authored-by: Pablo F.G Co-authored-by: Claude Opus 4.7 (1M context) --- ui/CHANGELOG.md | 8 +++++++ .../accept/accept-invitation-client.tsx | 6 +---- ui/lib/invitation-routing.ts | 10 -------- ui/proxy.ts | 21 +++------------- ui/tests/auth/auth-middleware.spec.ts | 24 +++++++++++++++++++ ui/tests/sign-up/sign-up-page.ts | 1 - 6 files changed, 36 insertions(+), 34 deletions(-) delete mode 100644 ui/lib/invitation-routing.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 9a83d91fea..2ad497eb5e 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.25.0] (Prowler UNRELEASED) + +### ❌ Removed + +- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly + +--- + ## [1.24.1] (Prowler v5.24.1) ### 🐞 Fixed diff --git a/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx b/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx index 73e6dbe8fd..f334053b48 100644 --- a/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx +++ b/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx @@ -8,10 +8,6 @@ import { useEffect, useRef, useState } from "react"; import { acceptInvitation } from "@/actions/invitations"; import { Button } from "@/components/shadcn"; -import { - INVITATION_ACTION_PARAM, - INVITATION_SIGNUP_ACTION, -} from "@/lib/invitation-routing"; type AcceptState = | { kind: "no-token" } @@ -204,7 +200,7 @@ export function AcceptInvitationClient({ className="w-full" onClick={() => { router.push( - `/sign-up?invitation_token=${encodeURIComponent(token!)}&${INVITATION_ACTION_PARAM}=${INVITATION_SIGNUP_ACTION}`, + `/sign-up?invitation_token=${encodeURIComponent(token!)}`, ); }} > diff --git a/ui/lib/invitation-routing.ts b/ui/lib/invitation-routing.ts deleted file mode 100644 index 85f132d922..0000000000 --- a/ui/lib/invitation-routing.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Query param name + value used to bypass the backward-compat redirect - * in proxy.ts when the user explicitly chose "Create an account" - * from the invitation smart router. - * - * Client sends: /sign-up?invitation_token=…&action=signup - * Proxy skips redirect when "action" param is present. - */ -export const INVITATION_ACTION_PARAM = "action"; -export const INVITATION_SIGNUP_ACTION = "signup"; diff --git a/ui/proxy.ts b/ui/proxy.ts index 553de6e9ba..d3bb8eb20e 100644 --- a/ui/proxy.ts +++ b/ui/proxy.ts @@ -1,7 +1,7 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; +import type { NextAuthRequest } from "next-auth"; import { auth } from "@/auth.config"; -import { INVITATION_ACTION_PARAM } from "@/lib/invitation-routing"; const publicRoutes = [ "/sign-in", @@ -18,24 +18,9 @@ const isPublicRoute = (pathname: string): boolean => { }; // NextAuth's auth() wrapper - renamed from middleware to proxy -export default auth((req: NextRequest & { auth: any }) => { +export default auth((req: NextAuthRequest) => { const { pathname } = req.nextUrl; - // Backward compatibility: redirect old invitation links to new smart router - // Skip redirect when the user explicitly chose "Create an account" from the smart router - if ( - pathname === "/sign-up" && - req.nextUrl.searchParams.has("invitation_token") && - !req.nextUrl.searchParams.has(INVITATION_ACTION_PARAM) - ) { - const acceptUrl = new URL("/invitation/accept", req.url); - acceptUrl.searchParams.set( - "invitation_token", - req.nextUrl.searchParams.get("invitation_token")!, - ); - return NextResponse.redirect(acceptUrl); - } - const user = req.auth?.user; const sessionError = req.auth?.error; diff --git a/ui/tests/auth/auth-middleware.spec.ts b/ui/tests/auth/auth-middleware.spec.ts index d7f1d23619..b56a2b63ca 100644 --- a/ui/tests/auth/auth-middleware.spec.ts +++ b/ui/tests/auth/auth-middleware.spec.ts @@ -76,4 +76,28 @@ test.describe("Middleware Error Handling", () => { // Note: Billing and integrations permission tests removed // These features only exist in Prowler Cloud, not in the open-source version + + test( + "should not redirect /sign-up?invitation_token=... to /invitation/accept", + { tag: ["@e2e", "@auth", "@middleware", "@AUTH-MW-E2E-003"] }, + async ({ page, context }) => { + const signUpPage = new SignUpPage(page); + await context.clearCookies(); + + const token = "test-token-regression"; + const response = await page.goto( + `/sign-up?invitation_token=${token}`, + { waitUntil: "commit" }, + ); + + // The middleware must not rewrite the URL any more. Assert the final + // URL stayed on /sign-up with the token intact, and that the sign-up + // form actually rendered (guards against "URL stayed but page broke"). + expect(response?.status()).toBe(200); + await expect(page).toHaveURL( + `/sign-up?invitation_token=${token}`, + ); + await signUpPage.verifyPageLoaded(); + }, + ); }); diff --git a/ui/tests/sign-up/sign-up-page.ts b/ui/tests/sign-up/sign-up-page.ts index e3d3cc3e72..0afaccb6bf 100644 --- a/ui/tests/sign-up/sign-up-page.ts +++ b/ui/tests/sign-up/sign-up-page.ts @@ -54,7 +54,6 @@ export class SignUpPage extends BasePage { } async verifyPageLoaded(): Promise { - await expect(this.page).toHaveURL("/sign-up"); await expect(this.emailInput).toBeVisible(); await expect(this.submitButton).toBeVisible(); } From 6b0ba7965287c199ab4138efb6a313030d347f6d Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Tue, 21 Apr 2026 08:17:14 +0200 Subject: [PATCH 15/26] fix(changelog): relocate entries for the SDK (#10812) --- prowler/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index d61a76b657..eff2506c04 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -11,7 +11,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🐞 Fixed - Cloudflare account-scoped API tokens failing connection test in the App with `CloudflareUserTokenRequiredError` [(#10723)](https://github.com/prowler-cloud/prowler/pull/10723) -- `prowler image --registry` failing with `ImageNoImagesProvidedError` due to registry arguments not being forwarded to `ImageProvider` in `init_global_provider` [(#10470)](https://github.com/prowler-cloud/prowler/pull/10470) +- `prowler image --registry-list` crashes with `AttributeError` because `ImageProvider.__init__` returns early before registering the global provider [(#10691)](https://github.com/prowler-cloud/prowler/pull/10691) - Google Workspace Calendar checks false FAIL on unconfigured settings with secure Google defaults [(#10726)](https://github.com/prowler-cloud/prowler/pull/10726) - Google Workspace Drive checks false FAIL on unconfigured settings with secure Google defaults [(#10727)](https://github.com/prowler-cloud/prowler/pull/10727) - Cloudflare `validate_credentials` can hang in an infinite pagination loop when the SDK repeats accounts, blocking connection tests [(#10771)](https://github.com/prowler-cloud/prowler/pull/10771) @@ -94,6 +94,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Oracle Cloud `kms_key_rotation_enabled` now checks current key version age to avoid false positives on vaults without auto-rotation support [(#10450)](https://github.com/prowler-cloud/prowler/pull/10450) - OCI filestorage, blockstorage, KMS, and compute services now honor `--region` for scanning outside the tenancy home region [(#10472)](https://github.com/prowler-cloud/prowler/pull/10472) - OCI provider now supports multi-region filtering via `--region` [(#10473)](https://github.com/prowler-cloud/prowler/pull/10473) +- `prowler image --registry` failing with `ImageNoImagesProvidedError` due to registry arguments not being forwarded to `ImageProvider` in `init_global_provider` [(#10470)](https://github.com/prowler-cloud/prowler/pull/10470) - OCI multi-region support for identity client configuration in blockstorage, identity, and filestorage services [(#10520)](https://github.com/prowler-cloud/prowler/pull/10520) - Google Workspace Calendar checks now filter for customer-level policies only, skipping OU and group overrides that could produce incorrect audit results [(#10658)](https://github.com/prowler-cloud/prowler/pull/10658) From 858dfc2a00eaa6769c774d244a794df62c50824e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Arroba?= <19954079+cesararroba@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:58:24 +0200 Subject: [PATCH 16/26] fix(ci): remove broken resolved_reference step from setup-python-poetry (#10687) --- .github/actions/setup-python-poetry/action.yml | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/.github/actions/setup-python-poetry/action.yml b/.github/actions/setup-python-poetry/action.yml index fd96796b9b..dd5be4b3a2 100644 --- a/.github/actions/setup-python-poetry/action.yml +++ b/.github/actions/setup-python-poetry/action.yml @@ -64,19 +64,6 @@ runs: echo "Updated resolved_reference:" grep -A2 -B2 "resolved_reference" poetry.lock - - name: Update SDK resolved_reference to latest commit (prowler repo on push) - if: github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == 'prowler-cloud/prowler' - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - LATEST_COMMIT=$(curl -s "https://api.github.com/repos/prowler-cloud/prowler/commits/master" | jq -r '.sha') - echo "Latest commit hash: $LATEST_COMMIT" - sed -i '/url = "https:\/\/github\.com\/prowler-cloud\/prowler\.git"/,/resolved_reference = / { - s/resolved_reference = "[a-f0-9]\{40\}"/resolved_reference = "'"$LATEST_COMMIT"'"/ - }' poetry.lock - echo "Updated resolved_reference:" - grep -A2 -B2 "resolved_reference" poetry.lock - - name: Update poetry.lock (prowler repo only) if: github.repository == 'prowler-cloud/prowler' && inputs.update-lock == 'true' shell: bash From d3a1df347325ac06ae2cb5798478cded5b9e6fcd Mon Sep 17 00:00:00 2001 From: Javier Grau Date: Tue, 21 Apr 2026 03:29:42 -0400 Subject: [PATCH 17/26] chore(skills): centralize AI assistant config via symlinks (#9951) Co-authored-by: Alan Buscaglia Co-authored-by: Pepe Fagoaga --- skills/setup.sh | 62 +++++++++++++++++++++++++++++++++++--------- skills/setup_test.sh | 34 ++++++++++++------------ 2 files changed, 67 insertions(+), 29 deletions(-) diff --git a/skills/setup.sh b/skills/setup.sh index ec5512e8c5..c24706d718 100755 --- a/skills/setup.sh +++ b/skills/setup.sh @@ -1,10 +1,10 @@ #!/bin/bash # Setup AI Skills for Prowler development # Configures AI coding assistants that follow agentskills.io standard: -# - Claude Code: .claude/skills/ symlink + CLAUDE.md copies -# - Gemini CLI: .gemini/skills/ symlink + GEMINI.md copies +# - Claude Code: .claude/skills/ symlink + CLAUDE.md symlink +# - Gemini CLI: .gemini/skills/ symlink + GEMINI.md symlink # - Codex (OpenAI): .codex/skills/ symlink + AGENTS.md (native) -# - GitHub Copilot: .github/copilot-instructions.md copy +# - GitHub Copilot: .github/copilot-instructions.md symlink # # Usage: # ./setup.sh # Interactive mode (select AI assistants) @@ -37,6 +37,28 @@ SETUP_COPILOT=false # HELPER FUNCTIONS # ============================================================================= +add_to_gitignore() { + local pattern="$1" + local gitignore_file="$REPO_ROOT/.gitignore" + local header="# AI Coding assistants assets" + + # Create .gitignore if it doesn't exist + if [ ! -f "$gitignore_file" ]; then + touch "$gitignore_file" + fi + + # Check if pattern exists (exact match or at end of file) + if ! grep -qxF "$pattern" "$gitignore_file"; then + # Check if header exists + if ! grep -qxF "$header" "$gitignore_file"; then + echo -e "\n\n$header" >> "$gitignore_file" + fi + + echo "$pattern" >> "$gitignore_file" + echo -e "${GREEN} βœ“ Added $pattern to .gitignore${NC}" + fi +} + show_help() { echo "Usage: $0 [OPTIONS]" echo "" @@ -109,6 +131,7 @@ setup_claude() { if [ ! -d "$REPO_ROOT/.claude" ]; then mkdir -p "$REPO_ROOT/.claude" fi + add_to_gitignore ".claude/skills" if [ -L "$target" ]; then rm "$target" @@ -119,8 +142,9 @@ setup_claude() { ln -s "$SKILLS_SOURCE" "$target" echo -e "${GREEN} βœ“ .claude/skills -> skills/${NC}" - # Copy AGENTS.md to CLAUDE.md - copy_agents_md "CLAUDE.md" + # Link AGENTS.md to CLAUDE.md + link_agents_md "CLAUDE.md" + add_to_gitignore "CLAUDE.md" } setup_gemini() { @@ -129,6 +153,7 @@ setup_gemini() { if [ ! -d "$REPO_ROOT/.gemini" ]; then mkdir -p "$REPO_ROOT/.gemini" fi + add_to_gitignore ".gemini/skills" if [ -L "$target" ]; then rm "$target" @@ -139,8 +164,9 @@ setup_gemini() { ln -s "$SKILLS_SOURCE" "$target" echo -e "${GREEN} βœ“ .gemini/skills -> skills/${NC}" - # Copy AGENTS.md to GEMINI.md - copy_agents_md "GEMINI.md" + # Link AGENTS.md to GEMINI.md + link_agents_md "GEMINI.md" + add_to_gitignore "GEMINI.md" } setup_codex() { @@ -149,6 +175,7 @@ setup_codex() { if [ ! -d "$REPO_ROOT/.codex" ]; then mkdir -p "$REPO_ROOT/.codex" fi + add_to_gitignore ".codex/skills" if [ -L "$target" ]; then rm "$target" @@ -164,12 +191,19 @@ setup_codex() { setup_copilot() { if [ -f "$REPO_ROOT/AGENTS.md" ]; then mkdir -p "$REPO_ROOT/.github" - cp "$REPO_ROOT/AGENTS.md" "$REPO_ROOT/.github/copilot-instructions.md" + + # Link AGENTS.md -> .github/copilot-instructions.md + local target="$REPO_ROOT/.github/copilot-instructions.md" + ln -sf "../AGENTS.md" "$target" + echo -e "${GREEN} βœ“ AGENTS.md -> .github/copilot-instructions.md${NC}" + + # Add specifically the file, NOT the .github folder + add_to_gitignore ".github/copilot-instructions.md" fi } -copy_agents_md() { +link_agents_md() { local target_name="$1" local agents_files local count=0 @@ -179,11 +213,15 @@ copy_agents_md() { for agents_file in $agents_files; do local agents_dir agents_dir=$(dirname "$agents_file") - cp "$agents_file" "$agents_dir/$target_name" + + # Create relative symlink + # Since files are in same dir, we can just link to basename + (cd "$agents_dir" && ln -sf "$(basename "$agents_file")" "$target_name") + count=$((count + 1)) done - echo -e "${GREEN} βœ“ Copied $count AGENTS.md -> $target_name${NC}" + echo -e "${GREEN} βœ“ Linked $count AGENTS.md -> $target_name${NC}" } # ============================================================================= @@ -302,4 +340,4 @@ echo "Configured:" [ "$SETUP_COPILOT" = true ] && echo " β€’ GitHub Copilot: .github/copilot-instructions.md" echo "" echo -e "${BLUE}Note: Restart your AI assistant to load the skills.${NC}" -echo -e "${BLUE} AGENTS.md is the source of truth - edit it, then re-run this script.${NC}" +echo -e "${BLUE} AGENTS.md is the source of truth - changes are reflected automatically via symlinks.${NC}" diff --git a/skills/setup_test.sh b/skills/setup_test.sh index c0e80afe99..db4749ed3f 100755 --- a/skills/setup_test.sh +++ b/skills/setup_test.sh @@ -201,40 +201,40 @@ test_symlink_not_created_without_flag() { } # ============================================================================= -# TESTS: AGENTS.md COPYING +# TESTS: AGENTS.md LINKING # ============================================================================= -test_copy_claude_agents_md() { +test_link_claude_agents_md() { run_setup --claude > /dev/null - assert_file_exists "$TEST_DIR/CLAUDE.md" "Root CLAUDE.md should exist" && \ - assert_file_exists "$TEST_DIR/api/CLAUDE.md" "api/CLAUDE.md should exist" && \ - assert_file_exists "$TEST_DIR/ui/CLAUDE.md" "ui/CLAUDE.md should exist" + assert_symlink_exists "$TEST_DIR/CLAUDE.md" "Root CLAUDE.md should be a symlink" && \ + assert_symlink_exists "$TEST_DIR/api/CLAUDE.md" "api/CLAUDE.md should be a symlink" && \ + assert_symlink_exists "$TEST_DIR/ui/CLAUDE.md" "ui/CLAUDE.md should be a symlink" } -test_copy_gemini_agents_md() { +test_link_gemini_agents_md() { run_setup --gemini > /dev/null - assert_file_exists "$TEST_DIR/GEMINI.md" "Root GEMINI.md should exist" && \ - assert_file_exists "$TEST_DIR/api/GEMINI.md" "api/GEMINI.md should exist" && \ - assert_file_exists "$TEST_DIR/ui/GEMINI.md" "ui/GEMINI.md should exist" + assert_symlink_exists "$TEST_DIR/GEMINI.md" "Root GEMINI.md should be a symlink" && \ + assert_symlink_exists "$TEST_DIR/api/GEMINI.md" "api/GEMINI.md should be a symlink" && \ + assert_symlink_exists "$TEST_DIR/ui/GEMINI.md" "ui/GEMINI.md should be a symlink" } -test_copy_copilot_to_github() { +test_link_copilot_to_github() { run_setup --copilot > /dev/null - assert_file_exists "$TEST_DIR/.github/copilot-instructions.md" "Copilot instructions should exist" + assert_symlink_exists "$TEST_DIR/.github/copilot-instructions.md" "Copilot instructions should be a symlink" } -test_copy_codex_no_extra_files() { +test_link_codex_no_extra_files() { run_setup --codex > /dev/null assert_file_not_exists "$TEST_DIR/CODEX.md" "CODEX.md should not be created" } -test_copy_not_created_without_flag() { +test_link_not_created_without_flag() { run_setup --codex > /dev/null - assert_file_not_exists "$TEST_DIR/CLAUDE.md" "CLAUDE.md should not exist" && \ - assert_file_not_exists "$TEST_DIR/GEMINI.md" "GEMINI.md should not exist" + assert_symlink_not_exists "$TEST_DIR/CLAUDE.md" "CLAUDE.md should not exist" && \ + assert_symlink_not_exists "$TEST_DIR/GEMINI.md" "GEMINI.md should not exist" } -test_copy_content_matches_source() { +test_link_content_matches_source() { run_setup --claude > /dev/null local source_content target_content source_content=$(cat "$TEST_DIR/AGENTS.md") @@ -272,7 +272,7 @@ test_idempotent_multiple_runs() { run_setup --claude > /dev/null run_setup --claude > /dev/null assert_symlink_exists "$TEST_DIR/.claude/skills" "Symlink should still exist after second run" && \ - assert_file_exists "$TEST_DIR/CLAUDE.md" "CLAUDE.md should still exist after second run" + assert_symlink_exists "$TEST_DIR/CLAUDE.md" "CLAUDE.md should still be a symlink after second run" } # ============================================================================= From ac6dd03fb864c54ca669e76a248fb746377ab7dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Tue, 21 Apr 2026 11:39:04 +0200 Subject: [PATCH 18/26] feat(sdk): add universal compliance schema models and loaders (#10298) --- prowler/lib/check/compliance_models.py | 482 ++++++- .../check/universal_compliance_models_test.py | 1118 +++++++++++++++++ 2 files changed, 1599 insertions(+), 1 deletion(-) create mode 100644 tests/lib/check/universal_compliance_models_test.py diff --git a/prowler/lib/check/compliance_models.py b/prowler/lib/check/compliance_models.py index d1f3b8c35d..136b49f83b 100644 --- a/prowler/lib/check/compliance_models.py +++ b/prowler/lib/check/compliance_models.py @@ -1,9 +1,10 @@ +import json import os import sys from enum import Enum from typing import Optional, Union -from pydantic.v1 import BaseModel, ValidationError, root_validator +from pydantic.v1 import BaseModel, Field, ValidationError, root_validator from prowler.lib.check.utils import list_compliance_modules from prowler.lib.logger import logger @@ -430,3 +431,482 @@ def load_compliance_framework( sys.exit(1) else: return compliance_framework + + +# ─── Universal Compliance Schema Models (Phase 1-3) ───────────────────────── + + +class OutputFormats(BaseModel): + """Flags indicating in which output formats an attribute should be included.""" + + csv: bool = True + ocsf: bool = True + + +class AttributeMetadata(BaseModel): + """Schema descriptor for a single attribute field in a universal compliance framework.""" + + key: str + label: Optional[str] = None + type: str = "str" # str, int, float, list_str, list_dict, bool + enum: Optional[list] = None + required: bool = False + enum_display: Optional[dict] = None # enum_value -> EnumValueDisplay dict + enum_order: Optional[list] = None # explicit ordering of enum values + chart_label: Optional[str] = None # axis label when used in charts + output_formats: OutputFormats = Field(default_factory=OutputFormats) + + +class SplitByConfig(BaseModel): + """Column-splitting configuration (e.g. CIS Level 1/Level 2).""" + + field: str + values: list + + +class ScoringConfig(BaseModel): + """Weighted scoring configuration (e.g. ThreatScore).""" + + risk_field: str + weight_field: str + + +class TableLabels(BaseModel): + """Custom pass/fail labels for console table rendering.""" + + pass_label: str = "PASS" + fail_label: str = "FAIL" + provider_header: str = "Provider" + group_header: Optional[str] = None + status_header: str = "Status" + title: Optional[str] = None + results_title: Optional[str] = None + footer_note: Optional[str] = None + + +class TableConfig(BaseModel): + """Declarative rendering instructions for the console compliance table.""" + + group_by: str + split_by: Optional[SplitByConfig] = None + scoring: Optional[ScoringConfig] = None + labels: Optional[TableLabels] = None + + +class EnumValueDisplay(BaseModel): + """Per-enum-value visual metadata for PDF rendering. + + Replaces hardcoded DIMENSION_MAPPING, TIPO_ICONS, nivel colors. + """ + + label: Optional[str] = None # "Trazabilidad" + abbreviation: Optional[str] = None # "T" + color: Optional[str] = None # "#4286F4" + icon: Optional[str] = None # emoji + + +class ChartConfig(BaseModel): + """Declarative chart description for PDF reports.""" + + id: str + type: str # vertical_bar | horizontal_bar | radar + group_by: str # attribute key to group by + title: Optional[str] = None + x_label: Optional[str] = None + y_label: Optional[str] = None + value_source: str = "compliance_percent" + color_mode: str = "by_value" # by_value | fixed | by_group + fixed_color: Optional[str] = None + + +class ScoringFormula(BaseModel): + """Weighted scoring formula (e.g. ThreatScore).""" + + risk_field: str # "LevelOfRisk" + weight_field: str # "Weight" + risk_boost_factor: float = 0.25 # rfac = 1 + factor * risk_level + + +class CriticalRequirementsFilter(BaseModel): + """Filter for critical requirements section in PDF reports.""" + + filter_field: str # "LevelOfRisk" + min_value: Optional[int] = None # 4 (int-based filter) + filter_value: Optional[str] = None # "alto" (string-based filter) + status_filter: str = "FAIL" + title: Optional[str] = None # "Critical Failed Requirements" + + +class ReportFilter(BaseModel): + """Default report filtering for PDF generation.""" + + only_failed: bool = True + include_manual: bool = False + + +class I18nLabels(BaseModel): + """Localized labels for PDF report rendering.""" + + report_title: Optional[str] = None + page_label: str = "Page" + powered_by: str = "Powered by Prowler" + framework_label: str = "Framework:" + version_label: str = "Version:" + provider_label: str = "Provider:" + description_label: str = "Description:" + compliance_score_label: str = "Compliance Score by Sections" + requirements_index_label: str = "Requirements Index" + detailed_findings_label: str = "Detailed Findings" + + +class PDFConfig(BaseModel): + """Declarative PDF report configuration. + + Drives the API report generator from JSON data instead of hardcoded + Python config. Colors are hex strings (e.g. '#336699'). + """ + + language: str = "en" + logo_filename: Optional[str] = None + primary_color: Optional[str] = None + secondary_color: Optional[str] = None + bg_color: Optional[str] = None + sections: Optional[list] = None + section_short_names: Optional[dict] = None + group_by_field: Optional[str] = None + sub_group_by_field: Optional[str] = None + section_titles: Optional[dict] = None + charts: Optional[list] = None + scoring: Optional[ScoringFormula] = None + critical_filter: Optional[CriticalRequirementsFilter] = None + filter: Optional[ReportFilter] = None + labels: Optional[I18nLabels] = None + + +class UniversalComplianceRequirement(BaseModel): + """Universal requirement with flat dict-based attributes.""" + + id: str + description: str + name: Optional[str] = None + attributes: dict = Field(default_factory=dict) + checks: dict[str, list[str]] = Field(default_factory=dict) + tactics: Optional[list] = None + sub_techniques: Optional[list] = None + platforms: Optional[list] = None + technique_url: Optional[str] = None + + +class OutputsConfig(BaseModel): + """Container for output-related configuration (table, PDF, etc.).""" + + table_config: Optional[TableConfig] = None + pdf_config: Optional[PDFConfig] = None + + +class ComplianceFramework(BaseModel): + """Universal top-level container for any compliance framework. + + Provider may be explicit (single-provider JSON) or derived from checks + keys across all requirements. + """ + + framework: str + name: str + provider: Optional[str] = None + version: Optional[str] = None + description: str + icon: Optional[str] = None + requirements: list[UniversalComplianceRequirement] + attributes_metadata: Optional[list[AttributeMetadata]] = None + outputs: Optional[OutputsConfig] = None + + @root_validator + # noqa: F841 - since vulture raises unused variable 'cls' + def validate_attributes_against_metadata(cls, values): # noqa: F841 + """Validate every Requirement's attributes dict against attributes_metadata. + + Checks: + - Required keys (required=True) must be present in each Requirement. + - Enum-constrained keys must have a value within the declared enum list. + - Basic type validation (int, float, bool) for non-None values. + """ + metadata = values.get("attributes_metadata") + requirements = values.get("requirements", []) + if not metadata: + return values + + required_keys = {m.key for m in metadata if m.required} + valid_keys = {m.key for m in metadata} + enum_map = {m.key: m.enum for m in metadata if m.enum} + type_map = {m.key: m.type for m in metadata} + + type_checks = { + "int": int, + "float": (int, float), + "bool": bool, + } + + errors = [] + for req in requirements: + attrs = req.attributes + + # Required keys + for key in required_keys: + if key not in attrs or attrs[key] is None: + errors.append( + f"Requirement '{req.id}': missing required attribute '{key}'" + ) + + # Unknown keys β€” anything outside the declared schema is a typo or drift + unknown_keys = set(attrs) - valid_keys + for key in sorted(unknown_keys): + errors.append( + f"Requirement '{req.id}': unknown attribute '{key}' " + f"(not declared in attributes_metadata)" + ) + + # Enum validation + for key, allowed in enum_map.items(): + if key in attrs and attrs[key] is not None: + if attrs[key] not in allowed: + errors.append( + f"Requirement '{req.id}': attribute '{key}' value " + f"'{attrs[key]}' not in {allowed}" + ) + + # Type validation for non-string types + for key in attrs: + if key not in valid_keys or attrs[key] is None: + continue + expected_type = type_map.get(key, "str") + py_type = type_checks.get(expected_type) + if py_type and not isinstance(attrs[key], py_type): + errors.append( + f"Requirement '{req.id}': attribute '{key}' expected " + f"type {expected_type}, got {type(attrs[key]).__name__}" + ) + + if errors: + detail = "\n ".join(errors) + raise ValueError(f"attributes_metadata validation failed:\n {detail}") + + return values + + def get_providers(self) -> list: + """Derive the set of providers this framework supports. + + Inspects checks keys across all requirements. Falls back to the + explicit provider field for single-provider frameworks with no + requirement-level checks. + """ + providers = set() + for req in self.requirements: + providers.update(k.lower() for k in req.checks.keys()) + if self.provider and not providers: + providers.add(self.provider.lower()) + return sorted(providers) + + def supports_provider(self, provider: str) -> bool: + """Return True if this framework has checks for the given provider.""" + provider_lower = provider.lower() + for req in self.requirements: + if any(k.lower() == provider_lower for k in req.checks.keys()): + return True + return self.provider is not None and self.provider.lower() == provider_lower + + +# ─── Legacy-to-Universal Adapter (Phase 2) ────────────────────────────────── + + +def _infer_attribute_metadata(legacy: Compliance) -> Optional[list[AttributeMetadata]]: + """Introspect the first requirement's attribute model to build attributes_metadata.""" + try: + if not legacy.Requirements: + return None + + first_req = legacy.Requirements[0] + + # MITRE requirements have Tactics at top level, not in Attributes + if isinstance(first_req, Mitre_Requirement): + return None + + if not first_req.Attributes: + return None + + sample_attr = first_req.Attributes[0] + metadata = [] + + for field_name, field_obj in sample_attr.__fields__.items(): + field_type = field_obj.outer_type_ + type_str = "str" + enum_values = None + + origin = getattr(field_type, "__origin__", None) + if field_type is int: + type_str = "int" + elif field_type is float: + type_str = "float" + elif field_type is bool: + type_str = "bool" + elif origin is list: + args = getattr(field_type, "__args__", ()) + if args and args[0] is dict: + type_str = "list_dict" + else: + type_str = "list_str" + elif isinstance(field_type, type) and issubclass(field_type, Enum): + type_str = "str" + enum_values = [e.value for e in field_type] + + metadata.append( + AttributeMetadata( + key=field_name, + type=type_str, + enum=enum_values, + required=field_obj.required, + ) + ) + + return metadata + except Exception: + return None + + +def adapt_legacy_to_universal(legacy: Compliance) -> ComplianceFramework: + """Convert a legacy Compliance object to a ComplianceFramework.""" + universal_requirements = [] + legacy_provider_key = legacy.Provider.lower() + + for req in legacy.Requirements: + req_checks = {legacy_provider_key: list(req.Checks)} if req.Checks else {} + if isinstance(req, Mitre_Requirement): + # For MITRE, promote special fields and store raw attributes + raw_attrs = [attr.dict() for attr in req.Attributes] + attrs = {"_raw_attributes": raw_attrs} + universal_requirements.append( + UniversalComplianceRequirement( + id=req.Id, + description=req.Description, + name=req.Name, + attributes=attrs, + checks=req_checks, + tactics=req.Tactics, + sub_techniques=req.SubTechniques, + platforms=req.Platforms, + technique_url=req.TechniqueURL, + ) + ) + else: + # Standard requirement: flatten first attribute to dict + if req.Attributes: + attrs = req.Attributes[0].dict() + else: + attrs = {} + universal_requirements.append( + UniversalComplianceRequirement( + id=req.Id, + description=req.Description, + name=req.Name, + attributes=attrs, + checks=req_checks, + ) + ) + + inferred_metadata = _infer_attribute_metadata(legacy) + + return ComplianceFramework( + framework=legacy.Framework, + name=legacy.Name, + provider=legacy.Provider, + version=legacy.Version, + description=legacy.Description, + requirements=universal_requirements, + attributes_metadata=inferred_metadata, + ) + + +def load_compliance_framework_universal(path: str) -> ComplianceFramework: + """Load a compliance JSON as a ComplianceFramework, handling both new and legacy formats.""" + try: + with open(path, "r") as f: + data = json.load(f) + + if "attributes_metadata" in data or "requirements" in data: + # New universal format β€” parse directly + return ComplianceFramework(**data) + else: + # Legacy format β€” parse as Compliance, then adapt + legacy = Compliance(**data) + return adapt_legacy_to_universal(legacy) + except Exception as e: + logger.error( + f"Failed to load universal compliance framework from {path}: " + f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}" + ) + return None + + +def _load_jsons_from_dir(dir_path: str, provider: str, bulk: dict) -> None: + """Scan *dir_path* for JSON files and add matching frameworks to *bulk*.""" + for filename in os.listdir(dir_path): + file_path = os.path.join(dir_path, filename) + if not ( + os.path.isfile(file_path) + and filename.endswith(".json") + and os.stat(file_path).st_size > 0 + ): + continue + framework_name = filename.split(".json")[0] + if framework_name in bulk: + continue + fw = load_compliance_framework_universal(file_path) + if fw is None: + continue + if fw.provider and fw.provider.lower() == provider.lower(): + bulk[framework_name] = fw + elif fw.supports_provider(provider): + bulk[framework_name] = fw + + +def get_bulk_compliance_frameworks_universal(provider: str) -> dict: + """Bulk load all compliance frameworks relevant to the given provider. + + Scans: + + 1. The **top-level** ``prowler/compliance/`` directory for multi-provider + JSONs (``Checks`` keyed by provider, no ``Provider`` field). + 2. Every **provider sub-directory** (``prowler/compliance/{p}/``) so that + single-provider JSONs are also picked up. + + A framework is included when its explicit ``Provider`` matches + (case-insensitive) **or** any requirement has dict-style ``Checks`` + with a key for *provider*. + """ + bulk = {} + try: + available_modules = list_compliance_modules() + + # Resolve the compliance root once (parent of provider sub-dirs). + compliance_root = None + seen_paths = set() + + for module in available_modules: + dir_path = f"{module.module_finder.path}/{module.name.split('.')[-1]}" + if not os.path.isdir(dir_path) or dir_path in seen_paths: + continue + seen_paths.add(dir_path) + + # Remember the root the first time we see a valid sub-dir. + if compliance_root is None: + compliance_root = module.module_finder.path + + _load_jsons_from_dir(dir_path, provider, bulk) + + # Also scan top-level compliance/ for provider-agnostic JSONs. + if compliance_root and os.path.isdir(compliance_root): + _load_jsons_from_dir(compliance_root, provider, bulk) + + 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 new file mode 100644 index 0000000000..5a3e4aae56 --- /dev/null +++ b/tests/lib/check/universal_compliance_models_test.py @@ -0,0 +1,1118 @@ +import json +import os + +import pytest +from pydantic.v1 import ValidationError + +from prowler.lib.check.compliance_models import ( + AttributeMetadata, + ChartConfig, + Compliance, + ComplianceFramework, + CriticalRequirementsFilter, + EnumValueDisplay, + I18nLabels, + OutputFormats, + OutputsConfig, + PDFConfig, + ReportFilter, + ScoringConfig, + ScoringFormula, + SplitByConfig, + TableConfig, + TableLabels, + UniversalComplianceRequirement, + adapt_legacy_to_universal, + load_compliance_framework_universal, +) +from tests.lib.outputs.compliance.fixtures import ( + CIS_1_4_AWS, + ENS_RD2022_AWS, + KISA_ISMSP_AWS, + MITRE_ATTACK_AWS, + NIST_800_53_REVISION_4_AWS, + PROWLER_THREATSCORE_AWS, +) + + +class TestOutputFormats: + def test_defaults(self): + of = OutputFormats() + assert of.csv is True + assert of.ocsf is True + + def test_explicit_false(self): + of = OutputFormats(csv=False, ocsf=False) + assert of.csv is False + assert of.ocsf is False + + +class TestAttributeMetadata: + def test_basic(self): + meta = AttributeMetadata(key="Section", type="str") + assert meta.key == "Section" + assert meta.type == "str" + assert meta.output_formats.csv is True + assert meta.required is False + + def test_with_enum(self): + meta = AttributeMetadata( + key="Profile", + type="str", + enum=["Level 1", "Level 2"], + ) + assert meta.enum == ["Level 1", "Level 2"] + + def test_int_type(self): + meta = AttributeMetadata(key="LevelOfRisk", type="int", required=True) + assert meta.type == "int" + assert meta.required is True + + def test_enum_display_field(self): + meta = AttributeMetadata( + key="Dimensiones", + type="str", + enum=["confidencialidad", "integridad", "trazabilidad"], + enum_display={ + "confidencialidad": { + "label": "Confidencialidad", + "abbreviation": "C", + "color": "#FF6347", + }, + "integridad": { + "label": "Integridad", + "abbreviation": "I", + "color": "#4286F4", + }, + "trazabilidad": { + "label": "Trazabilidad", + "abbreviation": "T", + "color": "#32CD32", + }, + }, + ) + assert meta.enum_display is not None + assert meta.enum_display["confidencialidad"]["abbreviation"] == "C" + assert meta.enum_display["integridad"]["color"] == "#4286F4" + + def test_enum_order_field(self): + meta = AttributeMetadata( + key="Nivel", + type="str", + enum=["opcional", "bajo", "medio", "alto"], + enum_order=["alto", "medio", "bajo", "opcional"], + ) + assert meta.enum_order == ["alto", "medio", "bajo", "opcional"] + + def test_chart_label_field(self): + meta = AttributeMetadata( + key="Section", + type="str", + chart_label="Security Domain", + ) + assert meta.chart_label == "Security Domain" + + def test_output_formats_default_true(self): + meta = AttributeMetadata(key="Section") + assert meta.output_formats.csv is True + assert meta.output_formats.ocsf is True + + def test_output_formats_explicit_false(self): + meta = AttributeMetadata( + key="InternalNote", + output_formats=OutputFormats(csv=False, ocsf=False), + ) + assert meta.output_formats.csv is False + assert meta.output_formats.ocsf is False + + def test_new_fields_default_none(self): + meta = AttributeMetadata(key="Section") + assert meta.enum_display is None + assert meta.enum_order is None + assert meta.chart_label is None + + +class TestEnumValueDisplay: + def test_basic(self): + evd = EnumValueDisplay(label="Test") + assert evd.label == "Test" + assert evd.abbreviation is None + assert evd.color is None + assert evd.icon is None + + def test_dimension_style(self): + evd = EnumValueDisplay( + label="Trazabilidad", + abbreviation="T", + color="#4286F4", + ) + assert evd.label == "Trazabilidad" + assert evd.abbreviation == "T" + assert evd.color == "#4286F4" + + def test_tipo_style(self): + evd = EnumValueDisplay( + label="Requisito", + icon="⚠️", + ) + assert evd.icon == "⚠️" + assert evd.abbreviation is None + + +class TestChartConfig: + def test_horizontal_bar(self): + chart = ChartConfig( + id="section_compliance", + type="horizontal_bar", + group_by="Section", + title="Compliance Score by Domain", + y_label="Domain", + x_label="Compliance %", + ) + assert chart.type == "horizontal_bar" + assert chart.group_by == "Section" + assert chart.value_source == "compliance_percent" + assert chart.color_mode == "by_value" + + def test_vertical_bar(self): + chart = ChartConfig( + id="risk_distribution", + type="vertical_bar", + group_by="LevelOfRisk", + color_mode="fixed", + fixed_color="#336699", + ) + assert chart.type == "vertical_bar" + assert chart.fixed_color == "#336699" + + def test_radar(self): + chart = ChartConfig( + id="dimension_radar", + type="radar", + group_by="Dimensiones", + ) + assert chart.type == "radar" + + def test_defaults(self): + chart = ChartConfig(id="test", type="vertical_bar", group_by="Section") + assert chart.title is None + assert chart.x_label is None + assert chart.y_label is None + assert chart.value_source == "compliance_percent" + assert chart.color_mode == "by_value" + assert chart.fixed_color is None + + +class TestScoringFormula: + def test_threatscore_style(self): + formula = ScoringFormula( + risk_field="LevelOfRisk", + weight_field="Weight", + risk_boost_factor=0.25, + ) + assert formula.risk_field == "LevelOfRisk" + assert formula.weight_field == "Weight" + assert formula.risk_boost_factor == 0.25 + + def test_custom_boost_factor(self): + formula = ScoringFormula( + risk_field="Risk", + weight_field="Impact", + risk_boost_factor=0.5, + ) + assert formula.risk_boost_factor == 0.5 + + def test_default_boost_factor(self): + formula = ScoringFormula(risk_field="LevelOfRisk", weight_field="Weight") + assert formula.risk_boost_factor == 0.25 + + +class TestCriticalRequirementsFilter: + def test_int_based(self): + crf = CriticalRequirementsFilter( + filter_field="LevelOfRisk", + min_value=4, + title="Critical Failed Requirements", + ) + assert crf.filter_field == "LevelOfRisk" + assert crf.min_value == 4 + assert crf.filter_value is None + assert crf.status_filter == "FAIL" + assert crf.title == "Critical Failed Requirements" + + def test_string_based(self): + crf = CriticalRequirementsFilter( + filter_field="Nivel", + filter_value="alto", + ) + assert crf.filter_value == "alto" + assert crf.min_value is None + + def test_defaults(self): + crf = CriticalRequirementsFilter(filter_field="LevelOfRisk") + assert crf.status_filter == "FAIL" + assert crf.title is None + assert crf.min_value is None + assert crf.filter_value is None + + +class TestReportFilter: + def test_defaults(self): + rf = ReportFilter() + assert rf.only_failed is True + assert rf.include_manual is False + + def test_custom(self): + rf = ReportFilter(only_failed=False, include_manual=True) + assert rf.only_failed is False + assert rf.include_manual is True + + +class TestI18nLabels: + def test_english_defaults(self): + labels = I18nLabels() + assert labels.page_label == "Page" + assert labels.powered_by == "Powered by Prowler" + assert labels.framework_label == "Framework:" + assert labels.provider_label == "Provider:" + assert labels.report_title is None + + def test_spanish_override(self): + labels = I18nLabels( + report_title="Informe de Cumplimiento ENS", + page_label="PΓ‘gina", + powered_by="Generado por Prowler", + framework_label="Marco:", + version_label="VersiΓ³n:", + provider_label="Proveedor:", + description_label="DescripciΓ³n:", + compliance_score_label="PuntuaciΓ³n de Cumplimiento por Secciones", + requirements_index_label="Índice de Requisitos", + detailed_findings_label="Hallazgos Detallados", + ) + assert labels.page_label == "PΓ‘gina" + assert labels.provider_label == "Proveedor:" + assert labels.report_title == "Informe de Cumplimiento ENS" + + +class TestSplitByConfig: + def test_cis_style(self): + config = SplitByConfig(field="Profile", values=["Level 1", "Level 2"]) + assert config.field == "Profile" + assert len(config.values) == 2 + + def test_ens_style(self): + config = SplitByConfig( + field="Nivel", + values=["alto", "medio", "bajo", "opcional"], + ) + assert len(config.values) == 4 + + +class TestScoringConfig: + def test_threatscore_style(self): + config = ScoringConfig(risk_field="LevelOfRisk", weight_field="Weight") + assert config.risk_field == "LevelOfRisk" + assert config.weight_field == "Weight" + + +class TestTableLabels: + def test_defaults(self): + labels = TableLabels() + assert labels.pass_label == "PASS" + assert labels.fail_label == "FAIL" + assert labels.provider_header == "Provider" + + def test_ens_spanish(self): + labels = TableLabels( + pass_label="CUMPLE", + fail_label="NO CUMPLE", + provider_header="Proveedor", + ) + assert labels.pass_label == "CUMPLE" + + +class TestTableConfig: + def test_grouped_mode(self): + tc = TableConfig(group_by="Section") + assert tc.group_by == "Section" + assert tc.split_by is None + assert tc.scoring is None + + def test_split_mode(self): + tc = TableConfig( + group_by="Section", + split_by=SplitByConfig(field="Profile", values=["Level 1", "Level 2"]), + ) + assert tc.split_by is not None + assert tc.split_by.field == "Profile" + + def test_scored_mode(self): + tc = TableConfig( + group_by="Section", + scoring=ScoringConfig(risk_field="LevelOfRisk", weight_field="Weight"), + ) + assert tc.scoring is not None + + +class TestPDFConfig: + def test_defaults(self): + pdf = PDFConfig() + assert pdf.language == "en" + assert pdf.logo_filename is None + assert pdf.primary_color is None + assert pdf.sections is None + assert pdf.section_short_names is None + assert pdf.group_by_field is None + assert pdf.sub_group_by_field is None + assert pdf.section_titles is None + assert pdf.charts is None + assert pdf.scoring is None + assert pdf.critical_filter is None + assert pdf.filter is None + assert pdf.labels is None + + def test_csa_ccm_style(self): + pdf = PDFConfig( + primary_color="#336699", + secondary_color="#4D80B3", + bg_color="#F2F8FF", + group_by_field="Section", + sections=["Audit & Assurance", "Identity & Access Management"], + section_short_names={"Identity & Access Management": "IAM"}, + charts=[ + ChartConfig( + id="section_compliance", + type="horizontal_bar", + group_by="Section", + title="Compliance Score by Domain", + ).dict() + ], + filter=ReportFilter(only_failed=True, include_manual=False), + ) + assert pdf.primary_color == "#336699" + assert len(pdf.sections) == 2 + assert pdf.section_short_names["Identity & Access Management"] == "IAM" + assert pdf.group_by_field == "Section" + assert pdf.charts is not None + assert len(pdf.charts) == 1 + assert pdf.filter.only_failed is True + + def test_ens_style(self): + pdf = PDFConfig( + language="es", + logo_filename="ens_logo.png", + primary_color="#CC3333", + group_by_field="Marco", + sub_group_by_field="Categoria", + labels=I18nLabels( + page_label="PΓ‘gina", + provider_label="Proveedor:", + ), + ) + assert pdf.language == "es" + assert pdf.logo_filename == "ens_logo.png" + assert pdf.group_by_field == "Marco" + assert pdf.sub_group_by_field == "Categoria" + assert pdf.labels.page_label == "PΓ‘gina" + + def test_threatscore_style(self): + pdf = PDFConfig( + primary_color="#336699", + sections=["1. IAM", "2. Attack Surface"], + scoring=ScoringFormula( + risk_field="LevelOfRisk", + weight_field="Weight", + risk_boost_factor=0.25, + ), + critical_filter=CriticalRequirementsFilter( + filter_field="LevelOfRisk", + min_value=4, + title="Critical Failed Requirements", + ), + ) + assert pdf.scoring is not None + assert pdf.scoring.risk_field == "LevelOfRisk" + assert pdf.critical_filter.min_value == 4 + + def test_section_titles(self): + pdf = PDFConfig( + section_titles={ + "1": "1. Policy on Security", + "2": "2. Risk Management", + }, + ) + assert pdf.section_titles["1"] == "1. Policy on Security" + + def test_in_framework(self): + fw = ComplianceFramework( + framework="Test", + name="Test Framework", + description="Test", + requirements=[], + outputs=OutputsConfig( + pdf_config=PDFConfig( + primary_color="#336699", + sections=["Section A"], + charts=[ + ChartConfig( + id="test_chart", + type="vertical_bar", + group_by="Section", + ).dict() + ], + ), + ), + ) + assert fw.outputs is not None + assert fw.outputs.pdf_config is not None + assert fw.outputs.pdf_config.primary_color == "#336699" + assert fw.outputs.pdf_config.sections == ["Section A"] + assert fw.outputs.pdf_config.charts is not None + assert len(fw.outputs.pdf_config.charts) == 1 + assert fw.outputs.pdf_config.charts[0]["id"] == "test_chart" + assert fw.outputs.pdf_config.charts[0]["type"] == "vertical_bar" + + def test_framework_without_pdf_config(self): + fw = ComplianceFramework( + framework="Test", + name="Test Framework", + description="Test", + requirements=[], + ) + assert fw.outputs is None + + +class TestUniversalComplianceRequirement: + def test_flat_dict_attributes(self): + req = UniversalComplianceRequirement( + id="1.1", + description="Test requirement", + attributes={"Section": "IAM", "Profile": "Level 1"}, + checks={"aws": ["check_a", "check_b"]}, + ) + assert req.attributes["Section"] == "IAM" + assert len(req.checks["aws"]) == 2 + + def test_mitre_optional_fields(self): + req = UniversalComplianceRequirement( + id="T1190", + description="Exploit Public-Facing Application", + attributes={}, + checks={"aws": ["drs_job_exist"]}, + tactics=["Initial Access"], + sub_techniques=[], + platforms=["IaaS", "Linux"], + technique_url="https://attack.mitre.org/techniques/T1190/", + ) + assert req.tactics == ["Initial Access"] + assert req.technique_url == "https://attack.mitre.org/techniques/T1190/" + + def test_dict_checks_multi_provider(self): + req = UniversalComplianceRequirement( + id="1.1", + description="Multi-provider", + attributes={}, + checks={"aws": ["check_a"], "azure": ["check_b"]}, + ) + assert isinstance(req.checks, dict) + assert "aws" in req.checks + + def test_empty_checks(self): + req = UniversalComplianceRequirement( + id="manual-1", + description="Manual requirement", + attributes={"Section": "Governance"}, + checks={}, + ) + assert req.checks == {} + + def test_checks_default_is_empty_dict(self): + req = UniversalComplianceRequirement( + id="1.1", + description="No checks provided", + ) + assert req.checks == {} + + +class TestComplianceFramework: + def test_basic_framework(self): + fw = ComplianceFramework( + framework="TestFW", + name="Test Framework", + provider="AWS", + version="1.0", + description="A test framework", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="Test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"]}, + ) + ], + attributes_metadata=[ + AttributeMetadata(key="Section", type="str"), + ], + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + assert fw.framework == "TestFW" + assert fw.outputs.table_config.group_by == "Section" + assert len(fw.attributes_metadata) == 1 + assert len(fw.requirements) == 1 + + def test_optional_provider(self): + fw = ComplianceFramework( + framework="MultiCloud", + name="Multi-cloud framework", + description="A multi-provider framework", + requirements=[], + ) + assert fw.provider is None + + def test_get_providers_from_dict_checks(self): + fw = ComplianceFramework( + framework="MultiCloud", + name="Multi-cloud", + description="test", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={}, + checks={ + "aws": ["check_a"], + "azure": ["check_b"], + "gcp": ["check_c"], + }, + ), + UniversalComplianceRequirement( + id="1.2", + description="test2", + attributes={}, + checks={"aws": ["check_d"]}, + ), + ], + ) + providers = fw.get_providers() + assert providers == ["aws", "azure", "gcp"] + + def test_get_providers_fallback_to_explicit(self): + fw = ComplianceFramework( + framework="SingleCloud", + name="Single-cloud", + provider="AWS", + description="test", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={}, + checks={}, + ), + ], + ) + providers = fw.get_providers() + assert providers == ["aws"] + + def test_supports_provider_dict_checks(self): + fw = ComplianceFramework( + framework="MultiCloud", + name="Multi-cloud", + description="test", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={}, + checks={"aws": ["check_a"], "azure": ["check_b"]}, + ), + ], + ) + assert fw.supports_provider("aws") is True + assert fw.supports_provider("azure") is True + assert fw.supports_provider("gcp") is False + + def test_supports_provider_explicit_only(self): + """Framework with explicit provider but no per-requirement checks still supports the provider.""" + fw = ComplianceFramework( + framework="SingleCloud", + name="Single-cloud", + provider="AWS", + description="test", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="Manual requirement", + attributes={}, + checks={}, + ), + ], + ) + assert fw.supports_provider("aws") is True + assert fw.supports_provider("azure") is False + + def test_no_provider_field_with_dict_checks(self): + """Multi-provider JSON has no Provider field β€” providers derived from checks.""" + fw = ComplianceFramework( + framework="CSA_CCM", + name="CSA CCM 4.0", + description="Cloud Controls Matrix", + requirements=[ + UniversalComplianceRequirement( + id="A&A-01", + description="Audit & Assurance", + attributes={"Domain": "A&A"}, + checks={ + "aws": ["check_a"], + "azure": ["check_b"], + "gcp": ["check_c"], + }, + ), + ], + ) + assert fw.provider is None + assert fw.get_providers() == ["aws", "azure", "gcp"] + assert fw.supports_provider("aws") + assert fw.supports_provider("azure") + assert fw.supports_provider("gcp") + assert not fw.supports_provider("kubernetes") + + def test_icon_field(self): + fw = ComplianceFramework( + framework="CSA_CCM", + name="CSA CCM 4.0", + description="Cloud Controls Matrix", + icon="csa", + requirements=[], + ) + assert fw.icon == "csa" + + def test_icon_defaults_to_none(self): + fw = ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[], + ) + assert fw.icon is None + + +class TestAdaptLegacyToUniversal: + def test_adapt_cis(self): + fw = adapt_legacy_to_universal(CIS_1_4_AWS) + assert fw.framework == "CIS" + assert fw.provider == "AWS" + assert len(fw.requirements) == 2 + # First requirement should have flat attributes + req = fw.requirements[0] + assert "Section" in req.attributes + assert req.attributes["Section"] == "2. Storage" + assert req.tactics is None + # Checks must be wrapped in dict keyed by provider + assert isinstance(req.checks, dict) + assert "aws" in req.checks + + def test_adapt_ens(self): + fw = adapt_legacy_to_universal(ENS_RD2022_AWS) + assert fw.framework == "ENS" + req = fw.requirements[0] + assert "Marco" in req.attributes + assert req.attributes["Marco"] == "operacional" + + def test_adapt_mitre(self): + fw = adapt_legacy_to_universal(MITRE_ATTACK_AWS) + assert fw.framework == "MITRE-ATTACK" + req = fw.requirements[0] + assert req.tactics == ["Initial Access"] + assert req.technique_url == "https://attack.mitre.org/techniques/T1190/" + assert "_raw_attributes" in req.attributes + assert isinstance(req.checks, dict) + assert "aws" in req.checks + + def test_adapt_threatscore(self): + fw = adapt_legacy_to_universal(PROWLER_THREATSCORE_AWS) + req = fw.requirements[0] + assert req.attributes["LevelOfRisk"] == 5 + assert req.attributes["Weight"] == 1000 + + def test_adapt_generic(self): + fw = adapt_legacy_to_universal(NIST_800_53_REVISION_4_AWS) + req = fw.requirements[0] + assert "Section" in req.attributes + + def test_adapt_kisa(self): + fw = adapt_legacy_to_universal(KISA_ISMSP_AWS) + req = fw.requirements[0] + assert "Domain" in req.attributes + + def test_inferred_metadata_cis(self): + fw = adapt_legacy_to_universal(CIS_1_4_AWS) + assert fw.attributes_metadata is not None + keys = [m.key for m in fw.attributes_metadata] + assert "Section" in keys + assert "Profile" in keys + + def test_inferred_metadata_mitre_is_none(self): + fw = adapt_legacy_to_universal(MITRE_ATTACK_AWS) + assert fw.attributes_metadata is None + + def test_table_config_is_none(self): + fw = adapt_legacy_to_universal(CIS_1_4_AWS) + assert fw.outputs is None + + +class TestLoadComplianceFrameworkUniversal: + def test_load_universal_format(self, tmp_path): + data = { + "framework": "TestFW", + "name": "Test", + "provider": "AWS", + "version": "1.0", + "description": "desc", + "icon": "prowlerthreatscore", + "attributes_metadata": [{"key": "Section", "type": "str"}], + "outputs": {"table_config": {"group_by": "Section"}}, + "requirements": [ + { + "id": "1.1", + "description": "test", + "attributes": {"Section": "IAM"}, + "checks": {"aws": ["check_a"]}, + } + ], + } + path = tmp_path / "test.json" + path.write_text(json.dumps(data)) + fw = load_compliance_framework_universal(str(path)) + assert fw is not None + assert fw.framework == "TestFW" + assert fw.icon == "prowlerthreatscore" + assert fw.outputs.table_config.group_by == "Section" + + def test_load_universal_multi_provider(self, tmp_path): + data = { + "framework": "CSA_CCM", + "name": "CSA CCM 4.0", + "version": "4.0", + "description": "Cloud Controls Matrix", + "attributes_metadata": [{"key": "Domain", "type": "str"}], + "outputs": {"table_config": {"group_by": "Domain"}}, + "requirements": [ + { + "id": "A&A-01", + "description": "Audit", + "attributes": {"Domain": "Audit"}, + "checks": { + "aws": ["check_a"], + "azure": ["check_b"], + "gcp": ["check_c"], + }, + } + ], + } + path = tmp_path / "csa_ccm_4.0.json" + path.write_text(json.dumps(data)) + fw = load_compliance_framework_universal(str(path)) + assert fw is not None + assert fw.provider is None + assert fw.get_providers() == ["aws", "azure", "gcp"] + assert fw.supports_provider("aws") + assert not fw.supports_provider("kubernetes") + + def test_load_legacy_format(self, tmp_path): + data = { + "Framework": "SOC2", + "Name": "SOC2", + "Provider": "AWS", + "Version": "", + "Description": "desc", + "Requirements": [ + { + "Id": "1.1", + "Description": "test", + "Attributes": [{"Section": "Access Control"}], + "Checks": ["check_a"], + } + ], + } + path = tmp_path / "legacy.json" + path.write_text(json.dumps(data)) + fw = load_compliance_framework_universal(str(path)) + assert fw is not None + assert fw.framework == "SOC2" + assert fw.outputs is None + assert fw.requirements[0].attributes["Section"] == "Access Control" + assert fw.requirements[0].checks == {"aws": ["check_a"]} + + +class TestSmokeLoadAllJSONs: + """Parametrized smoke test: every existing compliance JSON must load as ComplianceFramework.""" + + @staticmethod + def _find_all_compliance_jsons(): + base = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "prowler", + "compliance", + ) + base = os.path.normpath(base) + jsons = [] + if os.path.isdir(base): + # Top-level JSONs (multi-provider) + for filename in os.listdir(base): + if filename.endswith(".json"): + jsons.append(os.path.join(base, filename)) + # Provider sub-directory JSONs + for provider_dir in os.listdir(base): + provider_path = os.path.join(base, provider_dir) + if os.path.isdir(provider_path): + for filename in os.listdir(provider_path): + if filename.endswith(".json"): + jsons.append(os.path.join(provider_path, filename)) + return jsons + + @pytest.mark.parametrize( + "json_path", + _find_all_compliance_jsons.__func__(), + ids=lambda p: os.path.basename(p), + ) + def test_loads_as_universal(self, json_path): + fw = load_compliance_framework_universal(json_path) + assert fw is not None, f"Failed to load {json_path}" + assert fw.framework + assert fw.name + assert len(fw.requirements) >= 0 + + +class TestBackwardCompat: + """Ensure Compliance.get_bulk still returns Compliance objects.""" + + def test_get_bulk_still_works(self): + # This test just validates the legacy path still returns Compliance objects + # We test with a constructed Compliance object + legacy = CIS_1_4_AWS + assert isinstance(legacy, Compliance) + assert legacy.Framework == "CIS" + + +class TestAttributesMetadataValidation: + """Validate that Requirement attributes match their attributes_metadata schema.""" + + def _metadata(self, required=False, enum=None, type_str="str"): + return [ + AttributeMetadata(key="Section", type="str", required=True), + AttributeMetadata(key="Level", type=type_str, required=required, enum=enum), + ] + + def test_valid_attributes_pass(self): + fw = ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"Section": "IAM", "Level": "high"}, + checks={}, + ), + ], + attributes_metadata=self._metadata(), + ) + assert len(fw.requirements) == 1 + + def test_missing_required_key_raises(self): + with pytest.raises( + ValidationError, match="missing required attribute 'Section'" + ): + ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"Level": "high"}, + checks={}, + ), + ], + attributes_metadata=self._metadata(), + ) + + def test_invalid_enum_value_raises(self): + with pytest.raises(ValidationError, match="not in"): + ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"Section": "IAM", "Level": "invalid"}, + checks={}, + ), + ], + attributes_metadata=self._metadata(enum=["high", "low"]), + ) + + def test_valid_enum_value_passes(self): + fw = ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"Section": "IAM", "Level": "high"}, + checks={}, + ), + ], + attributes_metadata=self._metadata(enum=["high", "low"]), + ) + assert len(fw.requirements) == 1 + + def test_wrong_type_int_raises(self): + with pytest.raises(ValidationError, match="expected type int"): + ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"Section": "IAM", "Level": "not_a_number"}, + checks={}, + ), + ], + attributes_metadata=self._metadata(type_str="int"), + ) + + def test_correct_type_int_passes(self): + fw = ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"Section": "IAM", "Level": 5}, + checks={}, + ), + ], + attributes_metadata=self._metadata(type_str="int"), + ) + assert fw.requirements[0].attributes["Level"] == 5 + + def test_none_optional_value_skips_validation(self): + """None values for non-required keys should not trigger type/enum errors.""" + fw = ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"Section": "IAM", "Level": None}, + checks={}, + ), + ], + attributes_metadata=self._metadata(enum=["high", "low"]), + ) + assert len(fw.requirements) == 1 + + def test_no_metadata_skips_validation(self): + """Frameworks without attributes_metadata should not be validated.""" + fw = ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"anything": "goes"}, + checks={}, + ), + ], + ) + assert len(fw.requirements) == 1 + + def test_unknown_attribute_key_raises(self): + """Typos like 'Sectoin' must be rejected by the schema validator.""" + with pytest.raises(ValidationError, match="unknown attribute 'Sectoin'"): + ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"Sectoin": "IAM", "Level": "high"}, + checks={}, + ), + ], + attributes_metadata=self._metadata(enum=["high", "low"]), + ) + + def test_multiple_unknown_keys_all_reported(self): + """Every unknown key must appear in the validation error (deterministic order).""" + with pytest.raises( + ValidationError, + match=r"unknown attribute 'Bogus1'[\s\S]*unknown attribute 'Bogus2'", + ): + ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={ + "Section": "IAM", + "Level": "high", + "Bogus1": "x", + "Bogus2": "y", + }, + checks={}, + ), + ], + attributes_metadata=self._metadata(enum=["high", "low"]), + ) + + def test_multiple_errors_reported(self): + """All validation errors should be collected and reported together.""" + with pytest.raises( + ValidationError, match="missing required attribute 'Section'" + ): + ComplianceFramework( + framework="Test", + name="Test", + description="d", + requirements=[ + UniversalComplianceRequirement( + id="1.1", + description="d", + attributes={"Level": "bad"}, + checks={}, + ), + UniversalComplianceRequirement( + id="1.2", + description="d", + attributes={"Level": "also_bad"}, + checks={}, + ), + ], + attributes_metadata=self._metadata(enum=["high", "low"]), + ) From fc3066bc60311fd81fa8d21814605dc8443958ff Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:48:57 +0200 Subject: [PATCH 19/26] refactor(ui): redesign compliance page layout and components (#10767) --- ui/CHANGELOG.md | 5 +- ui/actions/compliances/compliances.ts | 11 +- .../compliance/[compliancetitle]/page.tsx | 2 +- ui/app/(prowler)/compliance/page.test.tsx | 16 ++ ui/app/(prowler)/compliance/page.tsx | 169 ++++++++-------- .../compliance/compliance-card.test.tsx | 30 +++ ui/components/compliance/compliance-card.tsx | 131 +++++++----- .../compliance-download-container.test.tsx | 133 ++++++++++++ .../compliance-download-container.tsx | 151 +++++++++++--- .../compliance-header/compliance-filters.tsx | 76 +++++++ .../compliance-header.test.tsx | 18 ++ .../compliance-header/compliance-header.tsx | 13 +- .../compliance-header/data-compliance.tsx | 6 +- .../compliance/compliance-header/index.ts | 1 + .../compliance-header/scan-selector.tsx | 18 +- .../compliance/compliance-overview-grid.tsx | 70 +++++++ ui/components/compliance/index.ts | 2 + .../compliance-grid-skeleton.test.tsx | 17 ++ .../skeletons/compliance-grid-skeleton.tsx | 20 +- .../compliance/threatscore-badge.test.tsx | 32 +++ .../compliance/threatscore-badge.tsx | 189 ++++++++---------- ui/components/shadcn/index.ts | 1 + ui/components/shadcn/progress.tsx | 42 ++++ ui/lib/compliance/score-utils.ts | 6 + ui/lib/helper.ts | 10 +- ui/package.json | 1 + ui/pnpm-lock.yaml | 3 + 27 files changed, 862 insertions(+), 311 deletions(-) create mode 100644 ui/app/(prowler)/compliance/page.test.tsx create mode 100644 ui/components/compliance/compliance-card.test.tsx create mode 100644 ui/components/compliance/compliance-download-container.test.tsx create mode 100644 ui/components/compliance/compliance-header/compliance-filters.tsx create mode 100644 ui/components/compliance/compliance-header/compliance-header.test.tsx create mode 100644 ui/components/compliance/compliance-overview-grid.tsx create mode 100644 ui/components/compliance/skeletons/compliance-grid-skeleton.test.tsx create mode 100644 ui/components/compliance/threatscore-badge.test.tsx create mode 100644 ui/components/shadcn/progress.tsx diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 2ad497eb5e..d4f378adf8 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -4,9 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file. ## [1.25.0] (Prowler UNRELEASED) -### ❌ Removed +### πŸ”„ Changed -- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly +- Redesign compliance page with a horizontal ThreatScore card (always-visible pillar breakdown + ActionDropdown), client-side search for compliance frameworks, compact scan selector trigger, responsive mobile filters, download-started toasts for CSV/PDF exports, enhanced compliance cards with truncated titles, and Alert-based empty/error states; migrate Progress component from HeroUI to shadcn [(#10767)](https://github.com/prowler-cloud/prowler/pull/10767) +- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly [(#10797)](https://github.com/prowler-cloud/prowler/pull/10797) --- diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index b23723f670..d5f4fd4954 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -6,12 +6,10 @@ import { handleApiResponse } from "@/lib/server-actions-helper"; export const getCompliancesOverview = async ({ scanId, region, - query, filters = {}, }: { scanId?: string; region?: string | string[]; - query?: string; filters?: Record; } = {}) => { const headers = await getAuthHeaders({ contentType: false }); @@ -31,8 +29,6 @@ export const getCompliancesOverview = async ({ setParam("filter[scan_id]", scanId); setParam("filter[region__in]", region); - if (query) url.searchParams.set("filter[search]", query); - try { const response = await fetch(url.toString(), { headers, @@ -46,15 +42,16 @@ export const getCompliancesOverview = async ({ }; export const getComplianceOverviewMetadataInfo = async ({ - query = "", sort = "", filters = {}, -}) => { +}: { + sort?: string; + filters?: Record; +} = {}) => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/compliance-overviews/metadata`); - if (query) url.searchParams.append("filter[search]", query); if (sort) url.searchParams.append("sort", sort); Object.entries(filters).forEach(([key, value]) => { diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index dd86be1f8a..069826c3ae 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -78,7 +78,7 @@ export default async function ComplianceDetail({ await Promise.all([ getComplianceOverviewMetadataInfo({ filters: { - "filter[scan_id]": selectedScanId, + "filter[scan_id]": selectedScanId ?? undefined, }, }), getComplianceAttributes(complianceId), diff --git a/ui/app/(prowler)/compliance/page.test.tsx b/ui/app/(prowler)/compliance/page.test.tsx new file mode 100644 index 0000000000..42bbbe672f --- /dev/null +++ b/ui/app/(prowler)/compliance/page.test.tsx @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("Compliance overview page", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "page.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("delegates client-side search to ComplianceOverviewGrid", () => { + expect(source).toContain("ComplianceOverviewGrid"); + expect(source).not.toContain("filter[search]"); + }); +}); diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index e3e0a63705..92015cd142 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -1,3 +1,4 @@ +import { Info } from "lucide-react"; import { Suspense } from "react"; import { @@ -7,12 +8,14 @@ import { import { getThreatScore } from "@/actions/overview"; import { getScans } from "@/actions/scans"; import { - ComplianceCard, ComplianceSkeletonGrid, NoScansAvailable, ThreatScoreBadge, } from "@/components/compliance"; -import { ComplianceHeader } from "@/components/compliance/compliance-header/compliance-header"; +import { ComplianceFilters } from "@/components/compliance/compliance-header/compliance-filters"; +import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overview-grid"; +import { Alert, AlertDescription } from "@/components/shadcn/alert"; +import { Card, CardContent } from "@/components/shadcn/card/card"; import { ContentLayout } from "@/components/ui"; import { ExpandedScanData, @@ -30,12 +33,6 @@ export default async function Compliance({ const resolvedSearchParams = await searchParams; const searchParamsKey = JSON.stringify(resolvedSearchParams || {}); - const filters = Object.fromEntries( - Object.entries(resolvedSearchParams).filter(([key]) => - key.startsWith("filter["), - ), - ); - const scansData = await getScans({ filters: { "filter[state]": "completed", @@ -79,9 +76,12 @@ export default async function Compliance({ .filter(Boolean) as ExpandedScanData[]; // Use scanId from URL, or select the first scan if not provided - const selectedScanId = - resolvedSearchParams.scanId || expandedScansData[0]?.id || null; - const query = (filters["filter[search]"] as string) || ""; + const scanIdParam = resolvedSearchParams.scanId; + const scanIdFromUrl = Array.isArray(scanIdParam) + ? scanIdParam[0] + : scanIdParam; + const selectedScanId: string | null = + scanIdFromUrl || expandedScansData[0]?.id || null; // Find the selected scan const selectedScan = expandedScansData.find( @@ -102,7 +102,6 @@ export default async function Compliance({ // Fetch metadata if we have a selected scan const metadataInfoData = selectedScanId ? await getComplianceOverviewMetadataInfo({ - query, filters: { "filter[scan_id]": selectedScanId, }, @@ -131,28 +130,39 @@ export default async function Compliance({ {selectedScanId ? ( <> -
-
- -
- {threatScoreData && - typeof selectedScanId === "string" && - selectedScan && ( -
- -
- )} + {/* Row 1: Filters */} +
+
- }> + + {/* Row 2: ThreatScore card β€” full width, horizontal */} + {threatScoreData && + typeof selectedScanId === "string" && + selectedScan && ( +
+ +
+ )} + + {/* Row 3: Compliance grid with client-side search */} + + + + } + > key.startsWith("filter[")), - ); - - // Extract query from filters - const query = (filters["filter[search]"] as string) || ""; - // Only fetch compliance data if we have a valid scanId const compliancesData = scanId && scanId.trim() !== "" ? await getCompliancesOverview({ scanId, region: regionFilter, - query, }) : { data: [], errors: [] }; const type = compliancesData?.data?.type; + const frameworks = compliancesData?.data + ?.filter((compliance: ComplianceOverviewData) => { + return compliance.attributes.framework !== "ProwlerThreatScore"; + }) + .sort((a: ComplianceOverviewData, b: ComplianceOverviewData) => + a.attributes.framework.localeCompare(b.attributes.framework), + ); // Check if the response contains no data if ( @@ -204,58 +212,49 @@ const SSRComplianceGrid = async ({ type === "tasks" ) { return ( -
-
- No compliance data available for the selected scan. -
-
+ + + + This scan has no compliance data available yet, please select a + different one. + + ); } // Handle errors returned by the API if (compliancesData?.errors?.length > 0) { return ( -
-
Provide a valid scan ID.
-
+ + + Provide a valid scan ID. + ); } return ( -
- {compliancesData.data - .filter((compliance: ComplianceOverviewData) => { - // Filter out ProwlerThreatScore from the grid - return compliance.attributes.framework !== "ProwlerThreatScore"; - }) - .sort((a: ComplianceOverviewData, b: ComplianceOverviewData) => - a.attributes.framework.localeCompare(b.attributes.framework), - ) - .map((compliance: ComplianceOverviewData) => { - const { attributes, id } = compliance; - const { - framework, - version, - requirements_passed, - total_requirements, - } = attributes; - - return ( - - ); - })} -
+ + + + ); +}; + +const ComplianceOverviewPanel = ({ + children, +}: { + children: React.ReactNode; +}) => { + return ( + + {children} + ); }; diff --git a/ui/components/compliance/compliance-card.test.tsx b/ui/components/compliance/compliance-card.test.tsx new file mode 100644 index 0000000000..c7a199a7fc --- /dev/null +++ b/ui/components/compliance/compliance-card.test.tsx @@ -0,0 +1,30 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("ComplianceCard", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "compliance-card.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("keeps the shadcn Card base variant", () => { + expect(source).toContain('variant="base"'); + }); + + it("uses a responsive stacked layout for narrow screens", () => { + expect(source).toContain("flex-col"); + expect(source).toContain("sm:flex-row"); + }); + + it("uses the shadcn progress component instead of Hero UI", () => { + expect(source).toContain('from "@/components/shadcn/progress"'); + expect(source).not.toContain("@heroui/progress"); + }); + + it("places compact actions in the icon column on larger screens", () => { + expect(source).toContain('orientation="column"'); + expect(source).toContain('buttonWidth="icon"'); + }); +}); diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx index 5d1b0425ba..2c9f383f2b 100644 --- a/ui/components/compliance/compliance-card.tsx +++ b/ui/components/compliance/compliance-card.tsx @@ -1,11 +1,20 @@ "use client"; -import { Progress } from "@heroui/progress"; import Image from "next/image"; import { useRouter, useSearchParams } from "next/navigation"; import { Card, CardContent } from "@/components/shadcn/card/card"; +import { Progress } from "@/components/shadcn/progress"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; import { getReportTypeForFramework } from "@/lib/compliance/compliance-report-types"; +import { + getScoreIndicatorClass, + type ScoreColorVariant, +} from "@/lib/compliance/score-utils"; import { ScanEntity } from "@/types/scans"; import { getComplianceIcon } from "../icons"; @@ -45,13 +54,9 @@ export const ComplianceCard: React.FC = ({ (passingRequirements / totalRequirements) * 100, ); - const getRatingColor = (ratingPercentage: number) => { - if (ratingPercentage <= 10) { - return "danger"; - } - if (ratingPercentage <= 40) { - return "warning"; - } + const getRatingVariant = (value: number): ScoreColorVariant => { + if (value <= 10) return "danger"; + if (value <= 40) return "warning"; return "success"; }; @@ -80,58 +85,76 @@ export const ComplianceCard: React.FC = ({ onClick={navigateToDetail} > -
- {getComplianceIcon(title) && ( - {`${title} - )} -
-

- {formatTitle(title)} - {version ? ` - ${version}` : ""} -

- +
+ {getComplianceIcon(title) && ( + {`${title} + )} +
e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.stopPropagation(); + } }} - color={getRatingColor(ratingPercentage)} - /> -
- + role="group" + tabIndex={0} + > + +
+
+
+ + +

+ {formatTitle(title)} + {version ? ` - ${version}` : ""} +

+
+ + {formatTitle(title)} + {version ? ` - ${version}` : ""} + +
+
+
+ + Score: + + + {ratingPercentage}% + +
+ +
+
+ {passingRequirements} / {totalRequirements} Passing Requirements - -
e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.stopPropagation(); - } - }} - role="group" - tabIndex={0} - > - -
diff --git a/ui/components/compliance/compliance-download-container.test.tsx b/ui/components/compliance/compliance-download-container.test.tsx new file mode 100644 index 0000000000..4a13c35fbc --- /dev/null +++ b/ui/components/compliance/compliance-download-container.test.tsx @@ -0,0 +1,133 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { downloadComplianceCsvMock, downloadComplianceReportPdfMock } = + vi.hoisted(() => ({ + downloadComplianceCsvMock: vi.fn(), + downloadComplianceReportPdfMock: vi.fn(), + })); + +vi.mock("@/lib/helper", () => ({ + downloadComplianceCsv: downloadComplianceCsvMock, + downloadComplianceReportPdf: downloadComplianceReportPdfMock, +})); + +vi.mock("@/components/ui", () => ({ + toast: {}, +})); + +import { ComplianceDownloadContainer } from "./compliance-download-container"; + +describe("ComplianceDownloadContainer", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "compliance-download-container.tsx"); + const source = readFileSync(filePath, "utf8"); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses the shared action dropdown for the card actions mode", () => { + expect(source).toContain("ActionDropdown"); + expect(source).not.toContain("@heroui/button"); + }); + + it("should expose an accessible actions menu trigger", () => { + render( + , + ); + + expect( + screen.getByRole("button", { name: "Open compliance export actions" }), + ).toBeInTheDocument(); + }); + + it("should support fixed icon-sized dropdown trigger in column mode", () => { + render( + , + ); + + const trigger = screen.getByRole("button", { + name: "Open compliance export actions", + }); + expect(trigger.className).toContain("border-text-neutral-secondary"); + }); + + it("should open export actions from the compact trigger", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole("button", { name: "Open compliance export actions" }), + ); + + expect(screen.getByText("Download CSV report")).toBeInTheDocument(); + expect(screen.getByText("Download PDF report")).toBeInTheDocument(); + }); + + it("should trigger both downloads from the actions menu", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole("button", { name: "Open compliance export actions" }), + ); + await user.click( + screen.getByRole("menuitem", { name: /Download CSV report/i }), + ); + await user.click( + screen.getByRole("button", { name: "Open compliance export actions" }), + ); + await user.click( + screen.getByRole("menuitem", { name: /Download PDF report/i }), + ); + + expect(downloadComplianceCsvMock).toHaveBeenCalledWith( + "scan-1", + "compliance-1", + {}, + ); + expect(downloadComplianceReportPdfMock).toHaveBeenCalledWith( + "scan-1", + "threatscore", + {}, + ); + }); +}); diff --git a/ui/components/compliance/compliance-download-container.tsx b/ui/components/compliance/compliance-download-container.tsx index 526057ea5c..415da1d85a 100644 --- a/ui/components/compliance/compliance-download-container.tsx +++ b/ui/components/compliance/compliance-download-container.tsx @@ -4,6 +4,15 @@ import { DownloadIcon, FileTextIcon } from "lucide-react"; import { useState } from "react"; import { Button } from "@/components/shadcn/button/button"; +import { + ActionDropdown, + ActionDropdownItem, +} from "@/components/shadcn/dropdown"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; import { toast } from "@/components/ui"; import type { ComplianceReportType } from "@/lib/compliance/compliance-report-types"; import { @@ -18,6 +27,9 @@ interface ComplianceDownloadContainerProps { reportType?: ComplianceReportType; compact?: boolean; disabled?: boolean; + orientation?: "row" | "column"; + buttonWidth?: "auto" | "icon"; + presentation?: "buttons" | "dropdown"; } export const ComplianceDownloadContainer = ({ @@ -26,9 +38,14 @@ export const ComplianceDownloadContainer = ({ reportType, compact = false, disabled = false, + orientation = "row", + buttonWidth = "auto", + presentation = "buttons", }: ComplianceDownloadContainerProps) => { const [isDownloadingCsv, setIsDownloadingCsv] = useState(false); const [isDownloadingPdf, setIsDownloadingPdf] = useState(false); + const isIconWidth = buttonWidth === "icon"; + const isDropdown = presentation === "dropdown"; const handleDownloadCsv = async () => { if (isDownloadingCsv) return; @@ -52,40 +69,116 @@ export const ComplianceDownloadContainer = ({ const buttonClassName = cn( "border-button-primary text-button-primary hover:bg-button-primary/10", - compact && "h-7 px-2 text-xs", + compact && + !isIconWidth && + "h-7 px-2 text-xs sm:w-full sm:justify-center sm:px-2.5", + orientation === "column" && !isIconWidth && "w-full", + isIconWidth && "size-10 rounded-lg p-0", ); + const labelClassName = isIconWidth + ? "sr-only" + : compact + ? "sr-only sm:not-sr-only" + : undefined; + const showTooltip = compact || isIconWidth; return ( -
- - {reportType && ( - + {reportType && ( + + } + label="Download PDF report" + onSelect={handleDownloadPdf} + disabled={disabled || isDownloadingPdf} + /> + )} + + ) : ( +
+ + + + + {showTooltip && ( + Download CSV report + )} + + {reportType && ( + + + + + {showTooltip && ( + Download PDF report + )} + + )} +
)}
); diff --git a/ui/components/compliance/compliance-header/compliance-filters.tsx b/ui/components/compliance/compliance-header/compliance-filters.tsx new file mode 100644 index 0000000000..474b61d4e7 --- /dev/null +++ b/ui/components/compliance/compliance-header/compliance-filters.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; + +import { ClearFiltersButton } from "@/components/filters/clear-filters-button"; +import { + MultiSelect, + MultiSelectContent, + MultiSelectItem, + MultiSelectSelectAll, + MultiSelectSeparator, + MultiSelectTrigger, + MultiSelectValue, +} from "@/components/shadcn/select/multiselect"; +import { useUrlFilters } from "@/hooks/use-url-filters"; + +import { ScanSelector, SelectScanComplianceDataProps } from "./scan-selector"; + +interface ComplianceFiltersProps { + scans: SelectScanComplianceDataProps["scans"]; + uniqueRegions: string[]; + selectedScanId: string; +} + +export const ComplianceFilters = ({ + scans, + uniqueRegions, + selectedScanId, +}: ComplianceFiltersProps) => { + const router = useRouter(); + const searchParams = useSearchParams(); + const { updateFilter } = useUrlFilters(); + + const handleScanChange = (selectedKey: string) => { + const params = new URLSearchParams(searchParams); + params.set("scanId", selectedKey); + router.push(`?${params.toString()}`, { scroll: false }); + }; + + const regionValues = + searchParams.get("filter[region__in]")?.split(",").filter(Boolean) ?? []; + + return ( +
+
+ +
+ {uniqueRegions.length > 0 && ( +
+ updateFilter("region__in", values)} + > + + + + + Select All + + {uniqueRegions.map((region) => ( + + {region} + + ))} + + +
+ )} + +
+ ); +}; diff --git a/ui/components/compliance/compliance-header/compliance-header.test.tsx b/ui/components/compliance/compliance-header/compliance-header.test.tsx new file mode 100644 index 0000000000..b646199f7f --- /dev/null +++ b/ui/components/compliance/compliance-header/compliance-header.test.tsx @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("ComplianceHeader", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "compliance-header.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("renders the scan selector inside the shared filters grid using default layout", () => { + expect(source).toContain("prependElement"); + expect(source).toContain(" { const frameworkFilters = []; + const prependElement = showProviders ? ( + + ) : undefined; // Add CIS Profile Level filter if framework is CIS if (framework === "CIS") { @@ -42,6 +45,7 @@ export const ComplianceHeader = ({ key: "cis_profile_level", labelCheckboxGroup: "Level", values: ["Level 1", "Level 2"], + width: "wide" as const, index: 0, // Show first showSelectAll: false, // No "Select All" option since Level 2 includes Level 1 defaultValues: ["Level 2"], // Default to Level 2 selected (which includes Level 1) @@ -55,6 +59,7 @@ export const ComplianceHeader = ({ key: "region__in", labelCheckboxGroup: "Regions", values: uniqueRegions, + width: "wide" as const, index: 1, // Show after framework filters }, ] @@ -77,9 +82,11 @@ export const ComplianceHeader = ({ {selectedScan && } {/* Showed in the compliance page */} - {showProviders && } - {!hideFilters && allFilters.length > 0 && ( - + {!hideFilters && (allFilters.length > 0 || showProviders) && ( + )}
{logoPath && complianceTitle && ( diff --git a/ui/components/compliance/compliance-header/data-compliance.tsx b/ui/components/compliance/compliance-header/data-compliance.tsx index 992dc3d683..4787d00921 100644 --- a/ui/components/compliance/compliance-header/data-compliance.tsx +++ b/ui/components/compliance/compliance-header/data-compliance.tsx @@ -7,11 +7,13 @@ import { ScanSelector, SelectScanComplianceDataProps, } from "@/components/compliance/compliance-header/index"; +import { cn } from "@/lib/utils"; interface DataComplianceProps { scans: SelectScanComplianceDataProps["scans"]; + className?: string; } -export const DataCompliance = ({ scans }: DataComplianceProps) => { +export const DataCompliance = ({ scans, className }: DataComplianceProps) => { const router = useRouter(); const searchParams = useSearchParams(); @@ -36,7 +38,7 @@ export const DataCompliance = ({ scans }: DataComplianceProps) => { }; return ( -
+
{ const selectedScan = scans.find((item) => item.id === selectedScanId); + const triggerLabel = selectedScan ? getScanEntityLabel(selectedScan) : ""; return (