diff --git a/ui/actions/findings/findings-by-resource.adapter.test.ts b/ui/actions/findings/findings-by-resource.adapter.test.ts index 24be4cd6ed..2c456a78a1 100644 --- a/ui/actions/findings/findings-by-resource.adapter.test.ts +++ b/ui/actions/findings/findings-by-resource.adapter.test.ts @@ -215,3 +215,72 @@ describe("adaptFindingsByResourceResponse — malformed input", () => { ); }); }); + +describe("adaptFindingsByResourceResponse — provider id", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should carry the provider id resolved through the scan include", () => { + // Given — scan.provider include path, as the drawer requests it + createDictMock.mockImplementation((type: string) => { + if (type === "scans") { + return { + "scan-1": { + id: "scan-1", + attributes: {}, + relationships: { provider: { data: { id: "provider-1" } } }, + }, + }; + } + if (type === "providers") { + return { + "provider-1": { + id: "provider-1", + attributes: { provider: "aws", alias: "prod", uid: "123" }, + }, + }; + } + return {}; + }); + + const input = { + data: { + id: "finding-1", + attributes: { + uid: "uid-1", + check_id: "s3_check", + status: "FAIL", + severity: "high", + check_metadata: {}, + }, + relationships: { + resources: { data: [] }, + scan: { data: { id: "scan-1" } }, + }, + }, + included: [], + }; + + // When + const [finding] = adaptFindingsByResourceResponse(input); + + // Then — the partial-scan request needs the id, not only the uid + expect(finding.providerId).toBe("provider-1"); + expect(finding.providerUid).toBe("123"); + }); + + it("should leave the provider id empty when the scan is not included", () => { + createDictMock.mockReturnValue({}); + + const [finding] = adaptFindingsByResourceResponse({ + data: { + id: "finding-1", + attributes: { uid: "uid-1", check_id: "s3_check", status: "FAIL" }, + relationships: { resources: { data: [] }, scan: { data: null } }, + }, + }); + + expect(finding.providerId).toBe(""); + }); +}); diff --git a/ui/actions/findings/findings-by-resource.adapter.ts b/ui/actions/findings/findings-by-resource.adapter.ts index 3ebae3c6c5..e4cd21abb1 100644 --- a/ui/actions/findings/findings-by-resource.adapter.ts +++ b/ui/actions/findings/findings-by-resource.adapter.ts @@ -64,6 +64,7 @@ export interface ResourceDrawerFinding { resourceDetails: string | null; resourceMetadata: Record | string | null; // Provider + providerId: string; providerType: ProviderType; providerAlias: string; providerUid: string; @@ -280,6 +281,7 @@ export function adaptFindingsByResourceResponse( | null | undefined) ?? null, // Provider + providerId: providerRelId ?? "", providerType: ((providerAttrs.provider as string | undefined) || "aws") as ProviderType, providerAlias: (providerAttrs.alias as string | undefined) || "", diff --git a/ui/actions/scans/scans.test.ts b/ui/actions/scans/scans.test.ts index 6b11d9c314..6e08a6f2a9 100644 --- a/ui/actions/scans/scans.test.ts +++ b/ui/actions/scans/scans.test.ts @@ -25,6 +25,10 @@ vi.mock("@/lib", () => ({ error instanceof Error ? error.message : String(error), })); +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + vi.mock("@/lib/server-actions-helper", () => ({ handleApiError: handleApiErrorMock, handleApiResponse: handleApiResponseMock, @@ -41,6 +45,7 @@ vi.mock("@/lib/report-download-access", () => ({ })); import { + createPartialScan, getComplianceCsv, getComplianceOcsf, getCompliancePdfReport, @@ -227,3 +232,64 @@ describe("report downloads for subscription-only tenants", () => { }); }); }); + +describe("createPartialScan", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + fetchMock.mockResolvedValue(new Response(null, { status: 202 })); + handleApiResponseMock.mockResolvedValue({ data: { id: "scan-1" } }); + }); + + it("posts the resource uids as a scan of one provider", async () => { + // When + const result = await createPartialScan({ + providerId: "provider-1", + resourceUids: ["arn:aws:s3:::bucket"], + }); + + // Then + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/scans", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + data: { + type: "scans", + attributes: { resource_uids: ["arn:aws:s3:::bucket"] }, + relationships: { + provider: { data: { type: "providers", id: "provider-1" } }, + }, + }, + }), + }), + ); + expect(handleApiResponseMock).toHaveBeenCalledWith( + expect.any(Response), + "/scans", + ); + expect(result).toEqual({ data: { id: "scan-1" } }); + expect(addScanOperationMock).toHaveBeenCalledWith("start", "scan-1"); + }); + + it("refuses more resources than the API accepts without calling it", async () => { + // Given — the API caps a partial scan at 10 resources. + const resourceUids = Array.from( + { length: 11 }, + (_, index) => `arn:aws:s3:::bucket-${index}`, + ); + + // When + const result = await createPartialScan({ + providerId: "provider-1", + resourceUids, + }); + + // Then + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + error: "Select between 1 and 10 resources to re-check", + }); + }); +}); diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index f6f637b605..cd08f5ce5a 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -22,6 +22,7 @@ import { import { addScanOperation } from "@/lib/sentry-breadcrumbs"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; import { SCAN_STATES } from "@/types/attack-paths"; +import { PARTIAL_SCAN_MAX_RESOURCES } from "@/types/partial-scans"; const ORGANIZATION_SCAN_CONCURRENCY_LIMIT = 5; @@ -186,6 +187,70 @@ export const scanOnDemand = async (formData: FormData) => { } }; +/** Prowler Cloud only: re-check up to PARTIAL_SCAN_MAX_RESOURCES resources of one provider. */ +export const createPartialScan = async ({ + providerId, + resourceUids, +}: { + providerId: string; + resourceUids: string[]; +}) => { + if (!providerId) { + return { error: "Provider ID is required" }; + } + if ( + resourceUids.length === 0 || + resourceUids.length > PARTIAL_SCAN_MAX_RESOURCES + ) { + return { + error: `Select between 1 and ${PARTIAL_SCAN_MAX_RESOURCES} resources to re-check`, + }; + } + + const headers = await getAuthHeaders({ contentType: true }); + + addScanOperation("create", undefined, { + provider_id: providerId, + partial: true, + resource_count: resourceUids.length, + }); + + const url = new URL(`${apiBaseUrl}/scans`); + + try { + const requestBody = { + data: { + type: "scans", + attributes: { resource_uids: resourceUids }, + relationships: { + provider: { + data: { + type: "providers", + id: providerId, + }, + }, + }, + }, + }; + + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(requestBody), + }); + + const result = await handleApiResponse(response, "/scans"); + if (result?.data?.id) { + addScanOperation("start", result.data.id); + revalidatePath("/scans"); + } + return result; + } catch (error) { + addScanOperation("create"); + return handleApiError(error); + } +}; + export const scheduleDaily = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); diff --git a/ui/components/findings/recheck-resource-action-item.test.tsx b/ui/components/findings/recheck-resource-action-item.test.tsx new file mode 100644 index 0000000000..723e456018 --- /dev/null +++ b/ui/components/findings/recheck-resource-action-item.test.tsx @@ -0,0 +1,89 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { isCloudMock, hasPermissionMock } = vi.hoisted(() => ({ + isCloudMock: vi.fn(() => true), + hasPermissionMock: vi.fn(() => true), +})); + +vi.mock("@/lib/shared/env", () => ({ + isCloud: isCloudMock, +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ hasPermission: hasPermissionMock }), +})); + +vi.mock("@/components/shadcn/dropdown", () => ({ + ActionDropdownItem: ({ + label, + onSelect, + }: { + label: string; + onSelect?: () => void; + }) => ( + + ), +})); + +import { usePartialScanStore } from "@/store/partial-scan/store"; + +import { + RECHECK_RESOURCE_LABEL, + RecheckResourceActionItem, +} from "./recheck-resource-action-item"; + +const target = { + providerId: "provider-1", + providerUid: "123456789012", + providerType: "aws", + providerAlias: "prod", + resourceUid: "arn:aws:s3:::bucket", + resourceName: "bucket", +}; + +describe("RecheckResourceActionItem", () => { + beforeEach(() => { + vi.clearAllMocks(); + isCloudMock.mockReturnValue(true); + hasPermissionMock.mockReturnValue(true); + usePartialScanStore.getState().closePartialScan(); + }); + + it("opens the confirmation with the resource as target", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: RECHECK_RESOURCE_LABEL }), + ); + + expect(usePartialScanStore.getState().activeTarget).toEqual(target); + }); + + it("is hidden outside Prowler Cloud", () => { + isCloudMock.mockReturnValue(false); + render(); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("is hidden without the manage_scans permission", () => { + hasPermissionMock.mockReturnValue(false); + render(); + + expect(hasPermissionMock).toHaveBeenCalledWith("manage_scans"); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("is hidden when the row cannot name a resource", () => { + render( + , + ); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/findings/recheck-resource-action-item.tsx b/ui/components/findings/recheck-resource-action-item.tsx new file mode 100644 index 0000000000..cc667a791d --- /dev/null +++ b/ui/components/findings/recheck-resource-action-item.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { RefreshCw } from "lucide-react"; + +import { ActionDropdownItem } from "@/components/shadcn/dropdown"; +import { useAuth } from "@/hooks/use-auth"; +import { + isPartialScanAvailable, + isPartialScanTarget, +} from "@/lib/partial-scans"; +import { isCloud } from "@/lib/shared/env"; +import { usePartialScanStore } from "@/store"; +import type { PartialScanTarget } from "@/types/partial-scans"; + +export const RECHECK_RESOURCE_LABEL = "Re-check resource"; + +interface RecheckResourceActionItemProps { + target: Partial | null | undefined; +} + +/** Prowler Cloud only: opens the partial-scan confirmation for one resource. */ +export const RecheckResourceActionItem = ({ + target, +}: RecheckResourceActionItemProps) => { + const { hasPermission } = useAuth(); + const openPartialScan = usePartialScanStore((state) => state.openPartialScan); + + const isAvailable = isPartialScanAvailable({ + cloudEnabled: isCloud(), + canManageScans: hasPermission("manage_scans"), + }); + if (!isAvailable || !isPartialScanTarget(target)) return null; + + return ( + } + label={RECHECK_RESOURCE_LABEL} + aria-label={RECHECK_RESOURCE_LABEL} + onSelect={() => openPartialScan(target)} + /> + ); +}; diff --git a/ui/components/findings/recheck-resource-modal-host.test.tsx b/ui/components/findings/recheck-resource-modal-host.test.tsx new file mode 100644 index 0000000000..3a1a68d3c0 --- /dev/null +++ b/ui/components/findings/recheck-resource-modal-host.test.tsx @@ -0,0 +1,56 @@ +import { render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { RecheckResourceModalMock } = vi.hoisted(() => ({ + RecheckResourceModalMock: vi.fn( + (_props: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + target: unknown; + }) => null, + ), +})); + +vi.mock("./recheck-resource-modal", () => ({ + RecheckResourceModal: RecheckResourceModalMock, +})); + +import { usePartialScanStore } from "@/store/partial-scan/store"; + +import { RecheckResourceModalHost } from "./recheck-resource-modal-host"; + +const target = { + providerId: "provider-1", + providerUid: "123456789012", + providerType: "aws", + resourceUid: "arn:aws:s3:::bucket", + resourceName: "bucket", +}; + +describe("RecheckResourceModalHost", () => { + beforeEach(() => { + vi.clearAllMocks(); + usePartialScanStore.getState().closePartialScan(); + }); + + it("renders nothing without an active target", () => { + render(); + + expect(RecheckResourceModalMock).not.toHaveBeenCalled(); + }); + + it("mounts the modal for the active target and closes through the store", () => { + usePartialScanStore.getState().openPartialScan(target); + + render(); + + expect(RecheckResourceModalMock).toHaveBeenCalledWith( + expect.objectContaining({ isOpen: true, target }), + undefined, + ); + + RecheckResourceModalMock.mock.calls[0][0].onOpenChange(false); + + expect(usePartialScanStore.getState().activeTarget).toBeNull(); + }); +}); diff --git a/ui/components/findings/recheck-resource-modal-host.tsx b/ui/components/findings/recheck-resource-modal-host.tsx new file mode 100644 index 0000000000..2cfcaaeb69 --- /dev/null +++ b/ui/components/findings/recheck-resource-modal-host.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { usePartialScanStore } from "@/store"; + +import { RecheckResourceModal } from "./recheck-resource-modal"; + +// One global modal, like Jira dispatch: it remounts per target so its state +// (pending, error) never leaks between resources. +export const RecheckResourceModalHost = () => { + const activeTarget = usePartialScanStore((state) => state.activeTarget); + const closePartialScan = usePartialScanStore( + (state) => state.closePartialScan, + ); + + if (!activeTarget) return null; + + return ( + !open && closePartialScan()} + target={activeTarget} + /> + ); +}; diff --git a/ui/components/findings/recheck-resource-modal.test.tsx b/ui/components/findings/recheck-resource-modal.test.tsx new file mode 100644 index 0000000000..52b236dfdc --- /dev/null +++ b/ui/components/findings/recheck-resource-modal.test.tsx @@ -0,0 +1,223 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { createPartialScanMock, getProvidersMock, refreshMock, toastMock } = + vi.hoisted(() => ({ + createPartialScanMock: vi.fn(), + getProvidersMock: vi.fn(), + refreshMock: vi.fn(), + toastMock: vi.fn(), + })); + +vi.mock("@/actions/scans", () => ({ + createPartialScan: createPartialScanMock, +})); + +vi.mock("@/actions/providers", () => ({ + getProviders: getProvidersMock, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: refreshMock }), +})); + +vi.mock("@/components/shadcn/toast", () => ({ + toast: toastMock, + ToastAction: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock("@/components/shadcn/modal", () => ({ + // The close button stands in for Escape and backdrop clicks. + Modal: ({ + open, + title, + children, + onOpenChange, + }: { + open: boolean; + title: string; + children: React.ReactNode; + onOpenChange: (open: boolean) => void; + }) => + open ? ( +
+ + {children} +
+ ) : null, +})); + +import { PARTIAL_SCAN_LAUNCH_ERROR } from "@/lib/partial-scans"; + +import { + PROVIDER_NOT_FOUND_ERROR, + RECHECK_RESOURCE_SUBMIT_LABEL, + RecheckResourceModal, +} from "./recheck-resource-modal"; + +const target = { + providerId: "provider-1", + providerUid: "123456789012", + providerType: "aws", + providerAlias: "prod", + resourceUid: "arn:aws:s3:::bucket", + resourceName: "bucket", +}; + +const submit = async () => { + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { name: RECHECK_RESOURCE_SUBMIT_LABEL }), + ); +}; + +describe("RecheckResourceModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + createPartialScanMock.mockResolvedValue({ data: { id: "scan-1" } }); + }); + + it("launches a partial scan for the one resource and closes", async () => { + const onOpenChange = vi.fn(); + render( + , + ); + + await submit(); + + expect(createPartialScanMock).toHaveBeenCalledWith({ + providerId: "provider-1", + resourceUids: ["arn:aws:s3:::bucket"], + }); + expect(getProvidersMock).not.toHaveBeenCalled(); + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ title: "Re-check launched" }), + ); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(refreshMock).toHaveBeenCalled(); + }); + + it("resolves the provider id from its uid and type when the row lacks it", async () => { + getProvidersMock.mockResolvedValue({ + data: [ + { id: "aws-1", attributes: { uid: "123456789012", provider: "aws" } }, + ], + }); + const { providerId: _omitted, ...rowTarget } = target; + render( + , + ); + + await submit(); + + expect(getProvidersMock).toHaveBeenCalledWith({ + filters: { "filter[uid]": "123456789012", "filter[provider]": "aws" }, + }); + expect(createPartialScanMock).toHaveBeenCalledWith({ + providerId: "aws-1", + resourceUids: ["arn:aws:s3:::bucket"], + }); + }); + + it("explains when the provider cannot be found and launches nothing", async () => { + getProvidersMock.mockResolvedValue({ data: [] }); + const { providerId: _omitted, ...rowTarget } = target; + render( + , + ); + + await submit(); + + expect(await screen.findByRole("alert")).toHaveTextContent( + PROVIDER_NOT_FOUND_ERROR, + ); + expect(createPartialScanMock).not.toHaveBeenCalled(); + }); + + it("keeps the modal open and shows the API reason when the re-check is refused", async () => { + // The 409 detail already tells the user what to do, so it is shown verbatim. + createPartialScanMock.mockResolvedValue({ + error: + "A scan is already running for this provider. Re-check these resources once it finishes.", + status: 409, + }); + const onOpenChange = vi.fn(); + render( + , + ); + + await submit(); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "A scan is already running for this provider.", + ); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(toastMock).not.toHaveBeenCalled(); + expect(refreshMock).not.toHaveBeenCalled(); + await waitFor(() => + expect( + screen.getByRole("button", { name: RECHECK_RESOURCE_SUBMIT_LABEL }), + ).toBeEnabled(), + ); + }); + + it("treats a response without a scan id as a failed launch", async () => { + // An empty 2xx becomes { success: true } and there is no scan to follow. + createPartialScanMock.mockResolvedValue({ success: true, status: 202 }); + const onOpenChange = vi.fn(); + render( + , + ); + + await submit(); + + expect(await screen.findByRole("alert")).toHaveTextContent( + PARTIAL_SCAN_LAUNCH_ERROR, + ); + expect(toastMock).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it("ignores a dismiss while the request is in flight", async () => { + let resolveLaunch!: (value: unknown) => void; + createPartialScanMock.mockReturnValue( + new Promise((resolve) => { + resolveLaunch = resolve; + }), + ); + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + await user.click( + screen.getByRole("button", { name: RECHECK_RESOURCE_SUBMIT_LABEL }), + ); + + await user.click(screen.getByRole("button", { name: "Dismiss" })); + + expect(onOpenChange).not.toHaveBeenCalled(); + + resolveLaunch({ data: { id: "scan-1" } }); + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)); + }); +}); diff --git a/ui/components/findings/recheck-resource-modal.tsx b/ui/components/findings/recheck-resource-modal.tsx new file mode 100644 index 0000000000..29177d8bfa --- /dev/null +++ b/ui/components/findings/recheck-resource-modal.tsx @@ -0,0 +1,176 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { type FormEvent, useState } from "react"; + +import { getProviders } from "@/actions/providers"; +import { createPartialScan } from "@/actions/scans"; +import { Button } from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; +import { toast, ToastAction } from "@/components/shadcn/toast"; +import { + findProviderIdForTarget, + getPartialScanErrorMessage, + PARTIAL_SCAN_LAUNCH_ERROR, +} from "@/lib/partial-scans"; +import type { PartialScanTarget } from "@/types/partial-scans"; + +export const RECHECK_RESOURCE_SUBMIT_LABEL = "Re-check resource"; +export const PROVIDER_NOT_FOUND_ERROR = + "We couldn't find the provider of this resource. Refresh the page and try again."; + +interface RecheckResourceModalProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + target: PartialScanTarget; +} + +const hasDisplayName = (name: string) => name.trim() !== "" && name !== "-"; + +export function RecheckResourceModal({ + isOpen, + onOpenChange, + target, +}: RecheckResourceModalProps) { + const router = useRouter(); + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(null); + + const resourceLabel = hasDisplayName(target.resourceName) + ? target.resourceName + : target.resourceUid; + + // Drill-down rows only know the provider by uid + type; the API needs its id. + const resolveProviderId = async () => { + if (target.providerId) return target.providerId; + + const response = await getProviders({ + filters: { + "filter[uid]": target.providerUid, + "filter[provider]": target.providerType, + }, + }); + + return findProviderIdForTarget(response?.data ?? [], target); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (isPending) return; + + setIsPending(true); + setError(null); + + try { + const providerId = await resolveProviderId(); + if (!providerId) { + setError(PROVIDER_NOT_FOUND_ERROR); + return; + } + + const result = await createPartialScan({ + providerId, + resourceUids: [target.resourceUid], + }); + const errorMessage = getPartialScanErrorMessage(result); + if (errorMessage) { + setError(errorMessage); + return; + } + // An empty 2xx has no scan to follow, so it is not a launch. + if (!result?.data?.id) { + setError(PARTIAL_SCAN_LAUNCH_ERROR); + return; + } + + toast({ + title: "Re-check launched", + description: `Only ${resourceLabel} is being re-checked. Its findings update once the scan completes.`, + action: ( + + View scan + + ), + }); + onOpenChange(false); + router.refresh(); + } catch { + setError(PARTIAL_SCAN_LAUNCH_ERROR); + } finally { + setIsPending(false); + } + }; + + return ( + { + if (!open && isPending) return; + onOpenChange(open); + }} + title="Re-check this resource" + description="Run a partial scan on a single resource instead of the whole provider." + size="lg" + > +
+
+

+ Resource +

+

+ {resourceLabel} +

+ {resourceLabel !== target.resourceUid && ( +

+ {target.resourceUid} +

+ )} + {target.providerAlias && ( +

+ Provider:{" "} + + {target.providerAlias} + +

+ )} +
+ +
+

+ Only the checks that last reported on this resource run again. Its + findings update when the scan completes; every other resource keeps + the results of the latest full scan. +

+

+ Overviews and compliance do not change until the next full scan. A + re-check is refused while the provider has a scan running or queued. +

+
+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+
+ ); +} diff --git a/ui/components/findings/table/column-finding-resources.test.tsx b/ui/components/findings/table/column-finding-resources.test.tsx index ab2f0d6070..3492ce411e 100644 --- a/ui/components/findings/table/column-finding-resources.test.tsx +++ b/ui/components/findings/table/column-finding-resources.test.tsx @@ -189,6 +189,14 @@ vi.mock("@/lib/shared/env", () => ({ isCloud: isCloudMock, })); +// The re-check menu item reads the session for manage_scans; grant it here. +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + permissions: { manage_scans: true }, + hasPermission: () => true, + }), +})); + vi.mock("./lighthouse-skills-launch", async (importOriginal) => { const actual = await importOriginal(); @@ -208,6 +216,7 @@ vi.mock("./notification-indicator", () => ({ })); import { useJiraDispatchStore } from "@/store/jira-dispatch/store"; +import { usePartialScanStore } from "@/store/partial-scan/store"; import type { FindingResourceRow } from "@/types"; import { FINDING_TRIAGE_DISABLED_REASON, @@ -320,6 +329,34 @@ describe("column-finding-resources", () => { useJiraDispatchStore.getState().closeJiraDispatch(); }); + it("offers a Cloud re-check identified by provider uid and type", async () => { + // Given — drill-down rows carry no provider id, only its uid and type + const user = userEvent.setup(); + isCloudMock.mockReturnValue(true); + usePartialScanStore.getState().closePartialScan(); + renderResourceActionsCell(); + + // When + await user.click(screen.getByRole("button", { name: "Re-check resource" })); + + // Then + expect(usePartialScanStore.getState().activeTarget).toEqual({ + providerUid: "123456789", + providerType: "aws", + providerAlias: "production", + resourceUid: "arn:aws:s3:::my-bucket", + resourceName: "my-bucket", + }); + }); + + it("hides the re-check outside Prowler Cloud", () => { + renderResourceActionsCell(); + + expect( + screen.queryByRole("button", { name: "Re-check resource" }), + ).not.toBeInTheDocument(); + }); + it("opens the finding drawer and launches a row skill with full context", async () => { // Given const user = userEvent.setup(); diff --git a/ui/components/findings/table/column-finding-resources.tsx b/ui/components/findings/table/column-finding-resources.tsx index c5cc656ebb..ed5afdf7d5 100644 --- a/ui/components/findings/table/column-finding-resources.tsx +++ b/ui/components/findings/table/column-finding-resources.tsx @@ -6,6 +6,7 @@ import { useContext, useState } from "react"; import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item"; import { MuteFindingsModal } from "@/components/findings/mute-findings-modal"; +import { RecheckResourceActionItem } from "@/components/findings/recheck-resource-action-item"; import { Checkbox } from "@/components/shadcn"; import { ActionDropdown, @@ -199,6 +200,15 @@ const ResourceRowActions = ({ })} payload={jiraPayload} /> + {isCloud() && ( { diff --git a/ui/components/findings/table/data-table-row-actions.test.tsx b/ui/components/findings/table/data-table-row-actions.test.tsx index daad634da2..7483c040e7 100644 --- a/ui/components/findings/table/data-table-row-actions.test.tsx +++ b/ui/components/findings/table/data-table-row-actions.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useJiraDispatchStore } from "@/store/jira-dispatch/store"; +import { usePartialScanStore } from "@/store/partial-scan/store"; import { FINDING_TRIAGE_DISABLED_REASON, FINDING_TRIAGE_STATUS, @@ -44,6 +45,14 @@ vi.mock("@/lib/shared/env", () => ({ isCloud: isCloudMock, })); +// The re-check menu item reads the session for manage_scans; grant it here. +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + permissions: { manage_scans: true }, + hasPermission: () => true, + }), +})); + vi.mock("./lighthouse-skills-launch", async (importOriginal) => { const actual = await importOriginal(); @@ -170,6 +179,58 @@ describe("DataTableRowActions", () => { useJiraDispatchStore.getState().closeJiraDispatch(); }); + it("offers a Cloud re-check of the row's resource with its provider id", async () => { + // Given — a flat finding row expanded with its resource and provider + const user = userEvent.setup(); + isCloudMock.mockReturnValue(true); + usePartialScanStore.getState().closePartialScan(); + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: "Re-check resource" })); + + // Then + expect(usePartialScanStore.getState().activeTarget).toEqual({ + providerId: "provider-1", + providerUid: "123", + providerType: "aws", + providerAlias: "prod", + resourceUid: "arn:aws:s3:::my-bucket", + resourceName: "my-bucket", + }); + }); + + it("does not offer a re-check outside Prowler Cloud", () => { + render( + , + ); + + expect( + screen.queryByRole("button", { name: "Re-check resource" }), + ).not.toBeInTheDocument(); + }); + it("launches a Lighthouse skill from the row submenu with finding context", async () => { // Given const user = userEvent.setup(); @@ -212,6 +273,10 @@ describe("DataTableRowActions", () => { ); expect(screen.queryByText("Lighthouse Skills")).not.toBeInTheDocument(); + // A group spans many resources, so a single-resource re-check is not offered. + expect( + screen.queryByRole("button", { name: "Re-check resource" }), + ).not.toBeInTheDocument(); expect( screen.queryByRole("button", { name: "Triage Decision" }), ).not.toBeInTheDocument(); diff --git a/ui/components/findings/table/data-table-row-actions.tsx b/ui/components/findings/table/data-table-row-actions.tsx index 3033003e57..ecef7f1da3 100644 --- a/ui/components/findings/table/data-table-row-actions.tsx +++ b/ui/components/findings/table/data-table-row-actions.tsx @@ -7,6 +7,7 @@ import { useContext, useState } from "react"; import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item"; import { MuteFindingsModal } from "@/components/findings/mute-findings-modal"; +import { RecheckResourceActionItem } from "@/components/findings/recheck-resource-action-item"; import { ActionDropdown, ActionDropdownItem, @@ -53,12 +54,17 @@ export interface FindingRowData { resource?: { attributes?: { name?: string; + uid?: string; }; }; provider?: { + // Expanded included providers carry their id at the top level. + id?: string; + data?: { id?: string }; attributes?: { alias?: string; provider?: string; + uid?: string; }; }; }; @@ -246,6 +252,20 @@ export function DataTableRowActions({ router.refresh(); }; + // Partial scans re-check one resource, so group rows never offer them. + const recheckTarget = isGroup + ? null + : { + providerId: + finding.relationships?.provider?.id ?? + finding.relationships?.provider?.data?.id, + providerUid: finding.relationships?.provider?.attributes?.uid, + providerType: finding.relationships?.provider?.attributes?.provider, + providerAlias: finding.relationships?.provider?.attributes?.alias, + resourceUid: finding.relationships?.resource?.attributes?.uid, + resourceName: finding.relationships?.resource?.attributes?.name, + }; + const launchSkill = useLighthouseSkillLaunch(); const launchPrompt = useLighthousePromptLaunch(); // Skills are finding-level only: group rows carry check ids, not finding @@ -300,6 +320,7 @@ export function DataTableRowActions({ onSelect={handleMuteClick} /> + {isCloud() && !isGroup && ( ({ isCloud: mockIsCloud, })); +// The re-check menu item reads the session for manage_scans; grant it here. +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + permissions: { manage_scans: true }, + hasPermission: () => true, + }), +})); + vi.mock("@/app/(prowler)/lighthouse/_lib/panel-chat-store", () => ({ requestPanelChatMessage: mockRequestPanelChatMessage, requestPanelSkillLaunch: mockRequestPanelSkillLaunch, @@ -543,6 +551,7 @@ vi.mock("../../muted", () => ({ // --------------------------------------------------------------------------- import type { ResourceDrawerFinding } from "@/actions/findings"; +import { usePartialScanStore } from "@/store/partial-scan/store"; import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel"; import type { FindingResourceRow } from "@/types"; import type { FindingComplianceFramework } from "@/types/compliance-watchlist"; @@ -655,6 +664,7 @@ const mockFinding: ResourceDrawerFinding = { resourceGroup: "default", resourceDetails: null, resourceMetadata: null, + providerId: "provider-1", providerType: "aws", providerAlias: "prod", providerUid: "123456789", @@ -2241,3 +2251,47 @@ describe("ResourceDetailDrawerContent — Metadata tab", () => { ).not.toBeInTheDocument(); }); }); + +describe("ResourceDetailDrawerContent — re-check resource", () => { + beforeEach(() => { + usePartialScanStore.getState().closePartialScan(); + }); + + it("should offer a re-check of the current resource with its provider id", async () => { + // Given — the drawer finding was fetched with scan.provider, so it knows the id + const user = userEvent.setup(); + mockIsCloud.mockReturnValue(true); + render( + , + ); + + // When + await user.click( + within(screen.getByRole("menu", { name: "Resource actions" })).getByRole( + "button", + { name: "Re-check resource" }, + ), + ); + + // Then + expect(usePartialScanStore.getState().activeTarget).toEqual({ + providerId: "provider-1", + providerUid: "123456789", + providerType: "aws", + providerAlias: "prod", + resourceUid: "arn:aws:s3:::bucket", + resourceName: "my-bucket", + }); + }); +}); 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 1f8d3cb75b..bbe765af85 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 @@ -27,6 +27,7 @@ import { import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item"; import { MarkdownContainer } from "@/components/findings/markdown-container"; import { MuteFindingsModal } from "@/components/findings/mute-findings-modal"; +import { RecheckResourceActionItem } from "@/components/findings/recheck-resource-action-item"; import { getComplianceIcon } from "@/components/icons"; import { Badge, @@ -784,6 +785,16 @@ export function ResourceDetailDrawerContent({ label={buildJiraActionLabel({ findingCount: 1 })} payload={jiraPayload} /> + {externalResourceTarget && ( } diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.test.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.test.tsx index 20761aeb36..12e95fd8fe 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.test.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer.test.tsx @@ -178,6 +178,7 @@ function drawerFinding( resourceGroup: "storage", resourceDetails: null, resourceMetadata: null, + providerId: "provider-1", providerType: "aws", providerAlias: "Production", providerUid: "123456789012", 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 49c0118ce9..88495ce62c 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 @@ -110,6 +110,7 @@ function makeDrawerFinding( resourceGroup: "default", resourceDetails: null, resourceMetadata: null, + providerId: "provider-1", providerType: "aws", providerAlias: "prod", providerUid: "123", diff --git a/ui/components/layout/main-layout/main-layout.test.tsx b/ui/components/layout/main-layout/main-layout.test.tsx index 6198f0daf9..27e4dd6e8b 100644 --- a/ui/components/layout/main-layout/main-layout.test.tsx +++ b/ui/components/layout/main-layout/main-layout.test.tsx @@ -15,6 +15,12 @@ vi.mock("@/components/findings/jira-dispatch-modal-host", () => ({ JiraDispatchModalHost: () =>
, })); +vi.mock("@/components/findings/recheck-resource-modal-host", () => ({ + RecheckResourceModalHost: () => ( +
+ ), +})); + describe("MainLayout", () => { it("mounts the shared Cloud upgrade modal with page content", () => { render( diff --git a/ui/components/layout/main-layout/main-layout.tsx b/ui/components/layout/main-layout/main-layout.tsx index 68193326ed..e863389e41 100644 --- a/ui/components/layout/main-layout/main-layout.tsx +++ b/ui/components/layout/main-layout/main-layout.tsx @@ -4,6 +4,7 @@ import { usePathname } from "next/navigation"; import { type ReactNode, Suspense } from "react"; import { JiraDispatchModalHost } from "@/components/findings/jira-dispatch-modal-host"; +import { RecheckResourceModalHost } from "@/components/findings/recheck-resource-modal-host"; import { AppSidebar } from "@/components/layout/app-sidebar"; import { CloudUpgradeModal } from "@/components/shared/cloud-upgrade-modal"; import { useMediaQuery } from "@/hooks/use-media-query"; @@ -40,6 +41,7 @@ export default function MainLayout({ children }: { children: ReactNode }) { +
is the reference for the app's (container-query) // breakpoints, so pushing it with the side panel re-evaluates them. diff --git a/ui/components/scans/table/cells/scan-info-cell.tsx b/ui/components/scans/table/cells/scan-info-cell.tsx index 226732d8fa..c0a524b4a8 100644 --- a/ui/components/scans/table/cells/scan-info-cell.tsx +++ b/ui/components/scans/table/cells/scan-info-cell.tsx @@ -27,12 +27,25 @@ export function ScanInfoCell({ scan }: { scan: ScanProps }) { } return ( -
+
+ {scan.attributes.is_partial && ( + + + + Partial + + + + Re-checked a few resources. Overviews still reflect the latest full + scan. + + + )}
); } diff --git a/ui/components/scans/table/scan-jobs-columns.test.tsx b/ui/components/scans/table/scan-jobs-columns.test.tsx index 496fd11e14..8d0e15143c 100644 --- a/ui/components/scans/table/scan-jobs-columns.test.tsx +++ b/ui/components/scans/table/scan-jobs-columns.test.tsx @@ -11,8 +11,17 @@ import { vi.mock("@/components/shadcn", async (importOriginal) => ({ ...(await importOriginal>()), - Badge: ({ children }: { children: ReactNode }) => {children}, + Badge: ({ + children, + tabIndex, + }: { + children: ReactNode; + tabIndex?: number; + }) => {children}, Progress: () =>
, + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: () => null, StackedCell: ({ primary, secondary, @@ -188,6 +197,25 @@ describe("getScanJobsColumns", () => { expect(screen.getByText("ID: scan-1")).toBeInTheDocument(); }); + it("labels a partial scan next to its alias", () => { + // Prowler Cloud exposes is_partial for re-checks of a few resources. + const scan = makeCompletedScan(); + renderCell("scanInfo", { + ...scan, + attributes: { ...scan.attributes, is_partial: true }, + }); + + expect(screen.getByText("Production scan")).toBeInTheDocument(); + // Focusable so keyboard users can reach the tooltip. + expect(screen.getByText("Partial")).toHaveAttribute("tabindex", "0"); + }); + + it("shows no partial label on a full scan", () => { + renderCell("scanInfo", makeCompletedScan()); + + expect(screen.queryByText("Partial")).not.toBeInTheDocument(); + }); + it("renders the completed duration column", () => { renderCell("duration", makeCompletedScan()); diff --git a/ui/lib/partial-scans.test.ts b/ui/lib/partial-scans.test.ts new file mode 100644 index 0000000000..aa0b8961eb --- /dev/null +++ b/ui/lib/partial-scans.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import { + findProviderIdForTarget, + getPartialScanErrorMessage, + isPartialScanAvailable, + isPartialScanTarget, + PARTIAL_SCAN_LAUNCH_ERROR, +} from "./partial-scans"; + +describe("isPartialScanAvailable", () => { + it("needs both Prowler Cloud and the manage_scans permission", () => { + expect( + isPartialScanAvailable({ cloudEnabled: true, canManageScans: true }), + ).toBe(true); + expect( + isPartialScanAvailable({ cloudEnabled: false, canManageScans: true }), + ).toBe(false); + expect( + isPartialScanAvailable({ cloudEnabled: true, canManageScans: false }), + ).toBe(false); + }); +}); + +describe("isPartialScanTarget", () => { + it("accepts a resource uid with a provider id", () => { + expect( + isPartialScanTarget({ + providerId: "provider-1", + resourceUid: "arn:aws:s3:::bucket", + resourceName: "bucket", + }), + ).toBe(true); + }); + + it("accepts a resource uid with a provider uid and type", () => { + expect( + isPartialScanTarget({ + providerUid: "123456789012", + providerType: "aws", + resourceUid: "arn:aws:s3:::bucket", + resourceName: "bucket", + }), + ).toBe(true); + }); + + it("rejects placeholder or missing identifiers", () => { + // Adapters fill unknown values with "-", which is not a real uid. + expect( + isPartialScanTarget({ + providerId: "provider-1", + resourceUid: "-", + resourceName: "bucket", + }), + ).toBe(false); + expect( + isPartialScanTarget({ + providerUid: "", + providerType: "aws", + resourceUid: "arn:aws:s3:::bucket", + }), + ).toBe(false); + expect(isPartialScanTarget(null)).toBe(false); + }); +}); + +describe("findProviderIdForTarget", () => { + const providers = [ + { id: "aws-1", attributes: { uid: "123456789012", provider: "aws" } }, + { id: "gcp-1", attributes: { uid: "123456789012", provider: "gcp" } }, + ] as never[]; + + it("matches on uid and provider type together", () => { + expect( + findProviderIdForTarget(providers, { + providerUid: "123456789012", + providerType: "gcp", + }), + ).toBe("gcp-1"); + }); + + it("returns undefined when nothing matches", () => { + expect( + findProviderIdForTarget(providers, { + providerUid: "000000000000", + providerType: "aws", + }), + ).toBeUndefined(); + }); +}); + +describe("getPartialScanErrorMessage", () => { + it("returns null for a created scan", () => { + expect(getPartialScanErrorMessage({ data: { id: "scan-1" } })).toBeNull(); + }); + + it("surfaces the API detail for a refused re-check", () => { + expect( + getPartialScanErrorMessage({ + error: "A scan is already running for this provider.", + status: 409, + }), + ).toBe("A scan is already running for this provider."); + }); + + it("falls back to a generic message when the error carries no detail", () => { + expect(getPartialScanErrorMessage({ status: 500 })).toBe( + PARTIAL_SCAN_LAUNCH_ERROR, + ); + }); +}); diff --git a/ui/lib/partial-scans.ts b/ui/lib/partial-scans.ts new file mode 100644 index 0000000000..b1c5f67f20 --- /dev/null +++ b/ui/lib/partial-scans.ts @@ -0,0 +1,51 @@ +import { + type ActionErrorResult, + getActionErrorMessage, + hasActionError, +} from "@/lib/action-errors"; +import type { PartialScanTarget } from "@/types/partial-scans"; +import type { ProviderProps } from "@/types/providers"; + +export const PARTIAL_SCAN_LAUNCH_ERROR = + "The re-check could not be launched. Please try again."; + +interface PartialScanAvailability { + cloudEnabled: boolean; + canManageScans: boolean; +} + +/** Partial scans are a Cloud feature that needs the manage_scans permission. */ +export const isPartialScanAvailable = ({ + cloudEnabled, + canManageScans, +}: PartialScanAvailability): boolean => cloudEnabled && canManageScans; + +const isMeaningful = (value: string | undefined): value is string => + typeof value === "string" && value.trim() !== "" && value !== "-"; + +/** A target needs a real resource uid and a way to identify its provider. */ +export const isPartialScanTarget = ( + target: Partial | null | undefined, +): target is PartialScanTarget => { + if (!target || !isMeaningful(target.resourceUid)) return false; + if (isMeaningful(target.providerId)) return true; + return isMeaningful(target.providerUid) && isMeaningful(target.providerType); +}; + +/** Provider uid is unique per provider type, so both together pick one row. */ +export const findProviderIdForTarget = ( + providers: Pick[], + target: Pick, +): string | undefined => + providers.find( + (provider) => + provider.attributes.uid === target.providerUid && + provider.attributes.provider === target.providerType, + )?.id; + +export const getPartialScanErrorMessage = ( + result: (ActionErrorResult & { data?: unknown }) | null | undefined, +): string | null => + hasActionError(result) + ? getActionErrorMessage(result, { fallback: PARTIAL_SCAN_LAUNCH_ERROR }) + : null; diff --git a/ui/store/index.ts b/ui/store/index.ts index 57fc46f86d..99a002f407 100644 --- a/ui/store/index.ts +++ b/ui/store/index.ts @@ -2,6 +2,7 @@ export * from "./cloud-upgrade/store"; export * from "./compliance/store"; export * from "./jira-dispatch/store"; export * from "./organizations/store"; +export * from "./partial-scan/store"; export * from "./provider-wizard/store"; export * from "./scans/store"; export * from "./ui/store"; diff --git a/ui/store/partial-scan/store.ts b/ui/store/partial-scan/store.ts new file mode 100644 index 0000000000..f804a7938e --- /dev/null +++ b/ui/store/partial-scan/store.ts @@ -0,0 +1,17 @@ +import { create } from "zustand"; + +import type { PartialScanTarget } from "@/types/partial-scans"; + +interface PartialScanStoreState { + activeTarget: PartialScanTarget | null; + openPartialScan: (target: PartialScanTarget) => void; + closePartialScan: () => void; +} + +// Menu items live inside dropdowns that unmount on select, so the confirmation +// modal is hosted once globally and driven through this store. +export const usePartialScanStore = create((set) => ({ + activeTarget: null, + openPartialScan: (activeTarget) => set({ activeTarget }), + closePartialScan: () => set({ activeTarget: null }), +})); diff --git a/ui/types/partial-scans.ts b/ui/types/partial-scans.ts new file mode 100644 index 0000000000..d5eedcc89f --- /dev/null +++ b/ui/types/partial-scans.ts @@ -0,0 +1,15 @@ +import type { ProviderType } from "./providers"; + +/** Mirrors `PARTIAL_SCAN_MAX_RESOURCES` in the Cloud API. */ +export const PARTIAL_SCAN_MAX_RESOURCES = 10; + +/** One resource to re-check. Prowler Cloud only. */ +export interface PartialScanTarget { + /** Provider UUID. Resolved from `providerUid` + `providerType` when absent. */ + providerId?: string; + providerUid: string; + providerType: ProviderType; + providerAlias?: string; + resourceUid: string; + resourceName: string; +} diff --git a/ui/types/scans.ts b/ui/types/scans.ts index 14b1c45a41..9d691aed7b 100644 --- a/ui/types/scans.ts +++ b/ui/types/scans.ts @@ -62,6 +62,8 @@ export interface ScanAttributes { completed_at: string | null; scheduled_at: string | null; next_scan_at: string | null; + /** Prowler Cloud only: true when the scan re-checked a few resources. */ + is_partial?: boolean; } export interface ScanRelationships {