diff --git a/ui/components/compliance/compliance-framework-chip.tsx b/ui/components/compliance/compliance-framework-chip.tsx new file mode 100644 index 0000000000..d2bc12b7b7 --- /dev/null +++ b/ui/components/compliance/compliance-framework-chip.tsx @@ -0,0 +1,101 @@ +"use client"; + +import Image from "next/image"; + +import { getComplianceIcon } from "@/components/icons"; +import { Badge, Button } from "@/components/shadcn"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import type { FindingComplianceFramework } from "@/types/compliance-watchlist"; + +/** Frameworks are not uniquely named — eight AWS ones are called "CIS" — so the + * version is part of the label, not decoration. */ +export function complianceFrameworkLabel( + framework: FindingComplianceFramework, +): string { + const name = framework.framework || framework.name; + return framework.version ? `${name} ${framework.version}` : name; +} + +interface ComplianceFrameworkChipProps { + framework: FindingComplianceFramework; + isNavigable: boolean; + onOpen: (framework: FindingComplianceFramework) => void; +} + +export function ComplianceFrameworkChip({ + framework, + isNavigable, + onOpen, +}: ComplianceFrameworkChipProps) { + const icon = getComplianceIcon(framework.complianceId); + const label = complianceFrameworkLabel(framework); + const content = icon ? ( + + {label} + + ) : ( + label + ); + + return ( + + + {isNavigable ? ( + + ) : icon ? ( + content + ) : ( + + {content} + + )} + + {label} + + ); +} + +export function buildComplianceDetailHref({ + complianceId, + framework, + version, + scanId, + regionFilter, +}: { + complianceId: string; + framework: string; + version: string; + scanId: string; + regionFilter: string | null; +}): string { + const params = new URLSearchParams(); + params.set("complianceId", complianceId); + if (version) { + params.set("version", version); + } + params.set("scanId", scanId); + + if (regionFilter) { + params.set("filter[region__in]", regionFilter); + } + + return `/compliance/${encodeURIComponent(framework)}?${params.toString()}`; +} 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 84bbac8b3d..f41b2dc3a0 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 @@ -9,7 +9,6 @@ import { VolumeOff, VolumeX, } from "lucide-react"; -import Image from "next/image"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { useState } from "react"; @@ -24,12 +23,15 @@ import { requestPanelChatMessage, requestPanelSkillLaunch, } from "@/app/(prowler)/lighthouse/_lib/panel-chat-store"; +import { + buildComplianceDetailHref, + ComplianceFrameworkChip, +} from "@/components/compliance/compliance-framework-chip"; 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 { RecheckResourceIconButton } from "@/components/findings/recheck-resource-icon-button"; -import { getComplianceIcon } from "@/components/icons"; import { Badge, Button, @@ -177,68 +179,6 @@ function renderRemediationCodeBlock({ ); } -/** Frameworks are not uniquely named — eight AWS ones are called "CIS" — so the - * version is part of the label, not decoration. */ -function complianceFrameworkLabel( - framework: FindingComplianceFramework, -): string { - const name = framework.framework || framework.name; - return framework.version ? `${name} ${framework.version}` : name; -} - -interface ComplianceFrameworkChipProps { - framework: FindingComplianceFramework; - isNavigable: boolean; - onOpen: (framework: FindingComplianceFramework) => void; -} - -function ComplianceFrameworkChip({ - framework, - isNavigable, - onOpen, -}: ComplianceFrameworkChipProps) { - const icon = getComplianceIcon(framework.complianceId); - const label = complianceFrameworkLabel(framework); - const content = icon ? ( - - {label} - - ) : ( - label - ); - - return ( - - - {isNavigable ? ( - - ) : icon ? ( - content - ) : ( - - {content} - - )} - - {label} - - ); -} - function parseSelectedScanIds(scanFilterValue: string | null): string[] { if (!scanFilterValue) { return []; @@ -250,33 +190,6 @@ function parseSelectedScanIds(scanFilterValue: string | null): string[] { .filter(Boolean); } -function buildComplianceDetailHref({ - complianceId, - framework, - version, - scanId, - regionFilter, -}: { - complianceId: string; - framework: string; - version: string; - scanId: string; - regionFilter: string | null; -}): string { - const params = new URLSearchParams(); - params.set("complianceId", complianceId); - if (version) { - params.set("version", version); - } - params.set("scanId", scanId); - - if (regionFilter) { - params.set("filter[region__in]", regionFilter); - } - - return `/compliance/${encodeURIComponent(framework)}?${params.toString()}`; -} - function buildResourceDetailHref(resourceId: string): string { const params = new URLSearchParams(); params.set("resourceId", resourceId); diff --git a/ui/components/issues/issue-compliance.test.tsx b/ui/components/issues/issue-compliance.test.tsx new file mode 100644 index 0000000000..52b266862d --- /dev/null +++ b/ui/components/issues/issue-compliance.test.tsx @@ -0,0 +1,185 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { TooltipProvider } from "@/components/shadcn/tooltip"; +import { + type FindingComplianceFramework, + WATCHLIST_SCOPE, +} from "@/types/compliance-watchlist"; +import { IssueFinding } from "@/types/issues"; + +import { COMPLIANCE_LOOKUP_LIMIT, IssueCompliance } from "./issue-compliance"; + +const { getFrameworksMock } = vi.hoisted(() => ({ + getFrameworksMock: vi.fn(), +})); + +vi.mock("@/actions/findings/finding-compliance-frameworks", () => ({ + getFindingComplianceFrameworks: getFrameworksMock, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn() }), +})); + +const framework = ( + complianceId: string, + name: string, + inWatchlist = false, +): FindingComplianceFramework => ({ + id: complianceId, + complianceId, + providerType: "aws", + scope: WATCHLIST_SCOPE.PROVIDER, + framework: name, + name, + version: "", + inWatchlist, +}); + +const finding = (id: string, checkId: string): IssueFinding => ({ + id, + uid: id, + checkId, + checkTitle: checkId, + severity: "high", + triageStatus: "open", + resourceUid: `r-${id}`, +}); + +const renderCompliance = ( + findings: IssueFinding[], + scanId?: string, + truncated?: boolean, +) => + render( + + + , + ); + +const INCOMPLETE = + "Not every finding could be checked, so this list may be incomplete."; + +describe("IssueCompliance", () => { + beforeEach(() => { + getFrameworksMock.mockResolvedValue({ frameworks: [], unavailable: false }); + }); + + it("looks up one finding per check, up to the limit", async () => { + const findings = [ + finding("f1", "check-a"), + finding("f2", "check-a"), + ...Array.from({ length: COMPLIANCE_LOOKUP_LIMIT + 2 }, (_, i) => + finding(`g${i}`, `check-${i}`), + ), + ]; + renderCompliance(findings); + + expect(await screen.findByText(/map to a framework/)).toBeInTheDocument(); + expect(getFrameworksMock).toHaveBeenCalledTimes(COMPLIANCE_LOOKUP_LIMIT); + expect(getFrameworksMock).toHaveBeenCalledWith("f2"); + }); + + it("lists each framework once, the watchlist first, and opens it in the scan", async () => { + // Given + getFrameworksMock + .mockResolvedValueOnce({ + frameworks: [ + framework("cis_aws", "CIS"), + framework("soc2", "SOC2", true), + ], + unavailable: false, + }) + .mockResolvedValueOnce({ + frameworks: [framework("cis_aws", "CIS")], + unavailable: false, + }); + const openMock = vi.spyOn(window, "open").mockImplementation(() => null); + renderCompliance( + [finding("f1", "check-a"), finding("f2", "check-b")], + "scan-1", + ); + + // When + const buttons = await screen.findAllByRole("button", { name: /^Open / }); + await userEvent.click(buttons[0]); + + // Then + expect(buttons.map((b) => b.getAttribute("aria-label"))).toEqual([ + "Open SOC2 compliance details", + "Open CIS compliance details", + ]); + expect(openMock).toHaveBeenCalledWith( + "/compliance/SOC2?complianceId=soc2&scanId=scan-1", + "_blank", + ); + }); + + it("says so when no framework could be loaded, and looks again on retry", async () => { + // Given + const user = userEvent.setup(); + getFrameworksMock.mockResolvedValueOnce({ + frameworks: [], + unavailable: true, + }); + renderCompliance([finding("f1", "check-a")]); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not load the frameworks for this issue.", + ); + + // When + await user.click(screen.getByRole("button", { name: "Retry" })); + + // Then + expect( + await screen.findByText(/behind this issue map to a framework/), + ).toBeInTheDocument(); + expect(getFrameworksMock).toHaveBeenCalledTimes(2); + }); + + it("does not claim no framework applies when one lookup failed", async () => { + // Given + getFrameworksMock + .mockResolvedValueOnce({ frameworks: [], unavailable: false }) + .mockResolvedValueOnce({ frameworks: [], unavailable: true }); + + // When + renderCompliance([finding("f1", "check-a"), finding("f2", "check-b")]); + + // Then + expect(await screen.findByText(INCOMPLETE)).toBeInTheDocument(); + expect( + screen.queryByText(/behind this issue map to a framework/), + ).not.toBeInTheDocument(); + }); + + it("stops loading when a lookup is rejected", async () => { + getFrameworksMock.mockRejectedValue(new Error("Network error")); + renderCompliance([finding("f1", "check-a")]); + + expect( + await screen.findByText("Could not load the frameworks for this issue."), + ).toBeInTheDocument(); + }); + + it("flags the list as incomplete when the findings are a capped preview", async () => { + getFrameworksMock.mockResolvedValue({ + frameworks: [framework("cis_aws", "CIS")], + unavailable: false, + }); + renderCompliance([finding("f1", "check-a")], "scan-1", true); + + expect( + await screen.findByRole("button", { + name: "Open CIS compliance details", + }), + ).toBeInTheDocument(); + expect(screen.getByText(INCOMPLETE)).toBeInTheDocument(); + }); +}); diff --git a/ui/components/issues/issue-compliance.tsx b/ui/components/issues/issue-compliance.tsx new file mode 100644 index 0000000000..3cf1afb573 --- /dev/null +++ b/ui/components/issues/issue-compliance.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { getFindingComplianceFrameworks } from "@/actions/findings/finding-compliance-frameworks"; +import { + buildComplianceDetailHref, + ComplianceFrameworkChip, + complianceFrameworkLabel, +} from "@/components/compliance/compliance-framework-chip"; +import { IssuesLoadError } from "@/components/issues/issues-load-error"; +import type { FindingComplianceFramework } from "@/types/compliance-watchlist"; +import { IssueFinding } from "@/types/issues"; + +// shortcut: frameworks are looked up for at most this many distinct checks, +// one request each; add an issue-level endpoint if issues fold more checks. +export const COMPLIANCE_LOOKUP_LIMIT = 6; + +type ComplianceState = + | { status: "loading" } + | { status: "unavailable" } + | { + status: "loaded"; + frameworks: FindingComplianceFramework[]; + /** Some lookups failed, so frameworks may be missing. */ + partial: boolean; + }; + +interface IssueComplianceProps { + findings: IssueFinding[]; + scanId?: string; + /** `findings` is a capped preview of a larger issue. */ + truncated?: boolean; +} + +/** + * The frameworks the issue puts at risk, from Prowler's own per-finding + * mapping. Frameworks belong to the check, so one finding per check answers + * for all of them. + */ +export const IssueCompliance = ({ + findings, + scanId, + truncated = false, +}: IssueComplianceProps) => { + const [state, setState] = useState({ status: "loading" }); + const [attempt, setAttempt] = useState(0); + const checks = new Map(findings.map((f) => [f.checkId, f.id])); + const representatives = Array.from(checks.values()).slice( + 0, + COMPLIANCE_LOOKUP_LIMIT, + ); + const key = representatives.join(","); + + useEffect(() => { + let cancelled = false; + Promise.all( + key + .split(",") + .filter(Boolean) + .map((id) => getFindingComplianceFrameworks(id)), + ) + .then((results) => { + if (cancelled) return; + if (results.length > 0 && results.every((r) => r.unavailable)) { + setState({ status: "unavailable" }); + return; + } + const byId = new Map( + results.flatMap((r) => r.frameworks).map((f) => [f.complianceId, f]), + ); + // Watchlist first: a public bucket maps into dozens of frameworks, and + // the ones this organization reports on should not be buried. + const frameworks = Array.from(byId.values()).sort( + (a, b) => + Number(b.inWatchlist) - Number(a.inWatchlist) || + complianceFrameworkLabel(a).localeCompare( + complianceFrameworkLabel(b), + ), + ); + setState({ + status: "loaded", + frameworks, + partial: results.some((r) => r.unavailable), + }); + }) + .catch(() => { + if (!cancelled) setState({ status: "unavailable" }); + }); + return () => { + cancelled = true; + }; + }, [key, attempt]); + + const retry = () => { + setState({ status: "loading" }); + setAttempt((value) => value + 1); + }; + + const incomplete = + state.status === "loaded" && + (state.partial || truncated || checks.size > COMPLIANCE_LOOKUP_LIMIT); + + const open = (framework: FindingComplianceFramework) => { + if (!scanId) return; + window.open( + buildComplianceDetailHref({ + complianceId: framework.complianceId, + framework: framework.framework || framework.name, + version: framework.version, + scanId, + regionFilter: null, + }), + "_blank", + ); + }; + + return ( +
+

Compliance affected

+ {state.status === "loading" ? ( +

+ Loading frameworks… +

+ ) : state.status === "unavailable" ? ( + + ) : ( + <> + {state.frameworks.length === 0 ? ( +

+ {incomplete + ? "None of the findings checked map to a framework requirement." + : "None of the findings behind this issue map to a framework requirement."} +

+ ) : ( +
+ {state.frameworks.map((framework) => ( + + ))} +
+ )} + {incomplete && ( +

+ Not every finding could be checked, so this list may be + incomplete. +

+ )} + + )} +
+ ); +}; diff --git a/ui/components/issues/issue-detail.test.tsx b/ui/components/issues/issue-detail.test.tsx index d6e78b240d..9eaa8be532 100644 --- a/ui/components/issues/issue-detail.test.tsx +++ b/ui/components/issues/issue-detail.test.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { IssueDetail } from "@/components/issues/issue-detail"; import { useJiraDispatchStore } from "@/store/jira-dispatch/store"; -import { IssueAttributes } from "@/types/issues"; +import { ISSUE_FINDINGS_LIMIT, IssueAttributes } from "@/types/issues"; const { getIssueMock, getIssueFindingsMock } = vi.hoisted(() => ({ getIssueMock: vi.fn(), @@ -19,6 +19,10 @@ vi.mock("@/actions/issues", () => ({ getIssueNotes: async () => [], })); +vi.mock("@/components/issues/issue-compliance", () => ({ + IssueCompliance: ({ truncated }: { truncated: boolean }) => + truncated ?

Compliance from a capped preview

: null, +})); vi.mock("@/components/findings/send-to-jira-modal", () => ({ SendToJiraModal: () => null, })); @@ -103,6 +107,39 @@ describe("IssueDetail", () => { ).toBeInTheDocument(); }); + it("tells the compliance section when the findings preview was capped", async () => { + // Given - one more finding than the preview loads + getIssueMock.mockResolvedValue({ + data: { + attributes: { + resource_uids: [], + finding_uids: Array.from( + { length: ISSUE_FINDINGS_LIMIT + 1 }, + (_, i) => `uid-${i}`, + ), + }, + relationships: { scan: { data: { type: "scans", id: "scan-1" } } }, + }, + }); + getIssueFindingsMock.mockResolvedValue([ + { + id: "f1", + checkId: "check-a", + checkTitle: "Check A", + severity: "high", + resourceUid: "r1", + }, + ]); + + // When + render(); + + // Then + expect( + await screen.findByText("Compliance from a capped preview"), + ).toBeInTheDocument(); + }); + it("claims no outcome for an issue that is not one path", async () => { getIssueMock.mockResolvedValue({ data: { attributes: { resource_uids: [] } }, diff --git a/ui/components/issues/issue-detail.tsx b/ui/components/issues/issue-detail.tsx index 66182abd35..5c40894bc1 100644 --- a/ui/components/issues/issue-detail.tsx +++ b/ui/components/issues/issue-detail.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import { getIssue, getIssueFindings } from "@/actions/issues"; +import { IssueCompliance } from "@/components/issues/issue-compliance"; import { IssueFindings } from "@/components/issues/issue-findings"; import { IssueGraph } from "@/components/issues/issue-graph"; import { IssueTriage } from "@/components/issues/issue-triage"; @@ -21,6 +22,7 @@ import { useJiraDispatchStore } from "@/store/jira-dispatch/store"; import { JIRA_DISPATCH_TARGET } from "@/types/integrations"; import { ATTACKER_START_LABEL, + ISSUE_FINDINGS_LIMIT, ISSUE_OUTCOME_LABEL, IssueAttributes, IssueFinding, @@ -64,6 +66,8 @@ type ResourcesState = status: "loaded"; resources: string[]; findings: IssueFinding[] | undefined; + /** `getIssueFindings` capped the preview at ISSUE_FINDINGS_LIMIT. */ + findingsTruncated: boolean; scanId?: string; providerId?: string; }; @@ -107,6 +111,7 @@ export const IssueDetail = ({ status: "loaded", resources: attributes.resource_uids, findings, + findingsTruncated: new Set(findingUids).size > ISSUE_FINDINGS_LIMIT, scanId: detailScanId, providerId: detailProviderId, }); @@ -309,6 +314,15 @@ export const IssueDetail = ({ total={issue.findings_count} /> )} + {state.status === "loaded" && + state.findings && + state.findings.length > 0 && ( + + )} ); diff --git a/ui/components/issues/issues-table.test.tsx b/ui/components/issues/issues-table.test.tsx index e87e260f1e..3a8c45e8ba 100644 --- a/ui/components/issues/issues-table.test.tsx +++ b/ui/components/issues/issues-table.test.tsx @@ -21,6 +21,9 @@ vi.mock("@/actions/issues", () => ({ getIssue, getIssueNotes: async () => [], })); +vi.mock("@/components/issues/issue-compliance", () => ({ + IssueCompliance: () => null, +})); vi.mock("@/components/findings/send-to-jira-modal", () => ({ SendToJiraModal: () => null, }));