feat(ui): show the compliance an issue puts at risk (#13153)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Rubén De la Torre Vicoandalejandrobailo authored and GitHub committed 2026-10-26 16:44:04 +01:00
1 parent 1ae3856de0
commit 63b8c8969c
7 files changed
+505 -92

No files matched your search

@@ -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 ? (
<span className="border-border-neutral-tertiary flex size-7 shrink-0 items-center justify-center rounded-md border bg-slate-50">
<Image
src={icon}
alt={label}
width={20}
height={20}
className="size-5 object-contain"
/>
</span>
) : (
label
);
return (
<Tooltip>
<TooltipTrigger asChild>
{isNavigable ? (
<Button
type="button"
variant={icon ? "bare" : "outline"}
size={icon ? "icon-xs" : "sm"}
aria-label={`Open ${label} compliance details`}
onClick={() => onOpen(framework)}
>
{content}
</Button>
) : icon ? (
content
) : (
<Badge variant="tag" size="sm" aria-label={label}>
{content}
</Badge>
)}
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
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()}`;
}
@@ -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 ? (
<span className="border-border-neutral-tertiary flex size-7 shrink-0 items-center justify-center rounded-md border bg-slate-50">
<Image
src={icon}
alt={label}
width={20}
height={20}
className="size-5 object-contain"
/>
</span>
) : (
label
);
return (
<Tooltip>
<TooltipTrigger asChild>
{isNavigable ? (
<Button
type="button"
variant={icon ? "bare" : "outline"}
size={icon ? "icon-xs" : "sm"}
aria-label={`Open ${label} compliance details`}
onClick={() => onOpen(framework)}
>
{content}
</Button>
) : icon ? (
content
) : (
<Badge variant="tag" size="sm" aria-label={label}>
{content}
</Badge>
)}
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
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);
@@ -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(
<TooltipProvider>
<IssueCompliance
findings={findings}
scanId={scanId}
truncated={truncated}
/>
</TooltipProvider>,
);
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();
});
});
+160
View File
@@ -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<ComplianceState>({ 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 (
<section aria-label="Compliance affected">
<h4 className="mb-3 text-sm font-semibold">Compliance affected</h4>
{state.status === "loading" ? (
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading frameworks…
</p>
) : state.status === "unavailable" ? (
<IssuesLoadError
message="Could not load the frameworks for this issue."
onRetry={retry}
/>
) : (
<>
{state.frameworks.length === 0 ? (
<p className="text-text-neutral-tertiary text-sm">
{incomplete
? "None of the findings checked map to a framework requirement."
: "None of the findings behind this issue map to a framework requirement."}
</p>
) : (
<div className="flex flex-wrap items-center gap-2">
{state.frameworks.map((framework) => (
<ComplianceFrameworkChip
key={framework.complianceId}
framework={framework}
isNavigable={Boolean(scanId)}
onOpen={open}
/>
))}
</div>
)}
{incomplete && (
<p className="text-text-neutral-tertiary mt-2 text-xs">
Not every finding could be checked, so this list may be
incomplete.
</p>
)}
</>
)}
</section>
);
};
+38 -1
View File
@@ -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 ? <p>Compliance from a capped preview</p> : 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(<IssueDetail id="issue-1" issue={issue} />);
// 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: [] } },
+14
View File
@@ -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 && (
<IssueCompliance
findings={state.findings}
scanId={activeScanId}
truncated={state.findingsTruncated}
/>
)}
<IssueTriage id={id} status={issue.triage_status} />
</div>
);
@@ -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,
}));