Compare commits

...
33 changed files with 1601 additions and 1264 deletions

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.12.15-slim-trixie@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f AS build
FROM python:3.12.16-slim-trixie@sha256:8523d6abeb48ceae0b7240d30c5808fc26cb79c1a635bacdf19cf3baecfbec07 AS build
LABEL maintainer="https://github.com/prowler-cloud/prowler"
LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.12.15-slim-trixie@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f AS build
FROM python:3.12.16-slim-trixie@sha256:8523d6abeb48ceae0b7240d30c5808fc26cb79c1a635bacdf19cf3baecfbec07 AS build
LABEL maintainer="https://github.com/prowler-cloud/api"
@@ -0,0 +1 @@
Python base image from 3.12.15 to 3.12.16 in the API container image, patching CVE-2026-115557
@@ -0,0 +1 @@
MongoDB Atlas connection errors now include the HTTP status, error code and detail returned by Atlas instead of a generic HTTP error, and a custom exception message no longer persists into later errors of the same type
@@ -0,0 +1 @@
Python base image from 3.12.15 to 3.12.16 in the SDK container image, patching CVE-2026-115557
@@ -38,7 +38,9 @@ class MongoDBAtlasBaseException(ProwlerException):
def __init__(self, code, file=None, original_exception=None, message=None):
provider = "MongoDB Atlas"
error_info = self.MONGODBATLAS_ERROR_CODES.get((code, self.__class__.__name__))
error_info = dict(
self.MONGODBATLAS_ERROR_CODES.get((code, self.__class__.__name__))
)
if message:
error_info["message"] = message
super().__init__(
@@ -236,7 +236,12 @@ class MongodbatlasProvider(Provider):
message="MongoDB Atlas authentication failed. Please check your API credentials.",
)
response.raise_for_status()
if not response.ok:
raise MongoDBAtlasIdentityError(
file=os.path.basename(__file__),
message=MongodbatlasProvider._describe_api_error(response),
)
organizations_data = response.json()
# Extract organization information from the response
@@ -266,7 +271,7 @@ class MongodbatlasProvider(Provider):
return identity
except MongoDBAtlasAuthenticationError:
except (MongoDBAtlasAuthenticationError, MongoDBAtlasIdentityError):
raise
except Exception as error:
logger.critical(
@@ -276,6 +281,22 @@ class MongodbatlasProvider(Provider):
original_exception=error,
)
@staticmethod
def _describe_api_error(response) -> str:
"""Build an identity error message that keeps the reason Atlas returns."""
message = f"MongoDB Atlas identity setup failed - HTTP {response.status_code}"
try:
body = response.json()
except ValueError:
return message
if not isinstance(body, dict):
return message
if body.get("errorCode"):
message += f" {body['errorCode']}"
if body.get("detail"):
message += f": {body['detail']}"
return message
def print_credentials(self):
"""Print the MongoDB Atlas credentials"""
report_lines = [
@@ -136,6 +136,60 @@ class TestMongodbatlasProvider:
with pytest.raises(MongoDBAtlasIdentityError):
MongodbatlasProvider.setup_identity(session)
@patch("requests.get")
def test_setup_identity_forbidden_keeps_atlas_error_detail(self, mock_get):
mock_response = MagicMock()
mock_response.status_code = 403
mock_response.ok = False
mock_response.json.return_value = {
"detail": "IP address 203.0.113.10 is not allowed to access this resource.",
"error": 403,
"errorCode": "IP_ADDRESS_NOT_ON_ACCESS_LIST",
"reason": "Forbidden",
}
mock_get.return_value = mock_response
session = MongoDBAtlasSession(
public_key=ATLAS_PUBLIC_KEY,
private_key=ATLAS_PRIVATE_KEY,
)
with pytest.raises(MongoDBAtlasIdentityError) as exc_info:
MongodbatlasProvider.setup_identity(session)
assert str(exc_info.value) == (
"MongoDBAtlasIdentityError[8003]: MongoDB Atlas identity setup failed"
" - HTTP 403 IP_ADDRESS_NOT_ON_ACCESS_LIST: IP address 203.0.113.10"
" is not allowed to access this resource."
)
@patch("requests.get")
def test_setup_identity_error_without_json_body_reports_status(self, mock_get):
mock_response = MagicMock()
mock_response.status_code = 502
mock_response.ok = False
mock_response.json.side_effect = ValueError("No JSON")
mock_get.return_value = mock_response
session = MongoDBAtlasSession(
public_key=ATLAS_PUBLIC_KEY,
private_key=ATLAS_PRIVATE_KEY,
)
with pytest.raises(MongoDBAtlasIdentityError) as exc_info:
MongodbatlasProvider.setup_identity(session)
assert exc_info.value.message == (
"MongoDB Atlas identity setup failed - HTTP 502"
)
def test_custom_message_does_not_leak_into_later_exceptions(self):
MongoDBAtlasIdentityError(message="custom detail")
assert (
MongoDBAtlasIdentityError().message == "MongoDB Atlas identity setup failed"
)
def test_test_connection_success(self):
"""Test successful connection test"""
with (
@@ -1,75 +0,0 @@
import { render, screen } from "@testing-library/react";
import { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getIssue, notFound } = vi.hoisted(() => ({
getIssue: vi.fn(),
notFound: vi.fn(() => {
throw new Error("NOT_FOUND");
}),
}));
vi.mock("next/navigation", () => ({ notFound }));
vi.mock("@/actions/issues", () => ({ getIssue }));
vi.mock("@/components/shadcn/content-layout", () => ({
ContentLayout: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/components/shared/feature-flag-route", () => ({
FeatureFlagRoute: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/components/issues/issue-detail", () => ({
IssueDetail: ({ scanId }: { scanId?: string }) => (
<div data-testid="issue-detail">{scanId}</div>
),
}));
import IssuePage from "./page";
const params = Promise.resolve({ id: "issue-1" });
describe("IssuePage", () => {
beforeEach(() => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
getIssue.mockReset();
});
it("returns not found in Local Server without asking the API", async () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
await expect(IssuePage({ params })).rejects.toThrow("NOT_FOUND");
expect(getIssue).not.toHaveBeenCalled();
});
it("returns not found when the API has no such issue", async () => {
getIssue.mockResolvedValue({ error: "Not found", status: 404 });
await expect(IssuePage({ params })).rejects.toThrow("NOT_FOUND");
});
it("shows the issue with the detail the list opens in place", async () => {
getIssue.mockResolvedValue({
data: {
type: "issues",
id: "issue-1",
attributes: {
pattern: "aws_public_bucket",
cloud: "aws",
account: "prod",
title: "Public bucket",
verdict: "likely",
},
relationships: { scan: { data: { type: "scans", id: "scan-1" } } },
},
});
render(await IssuePage({ params }));
expect(getIssue).toHaveBeenCalledWith("issue-1");
expect(
screen.getByRole("heading", { name: "Public bucket" }),
).toBeInTheDocument();
expect(screen.getByText("Likely")).toBeInTheDocument();
expect(screen.getByTestId("issue-detail")).toHaveTextContent("scan-1");
expect(screen.getByRole("link", { name: "← All issues" })).toHaveAttribute(
"href",
"/issues",
);
});
});
-69
View File
@@ -1,69 +0,0 @@
import { ShieldAlert } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getIssue } from "@/actions/issues";
import { IssueDetail } from "@/components/issues/issue-detail";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { EntityInfo } from "@/components/shadcn/entities";
import { FeatureFlagRoute } from "@/components/shared/feature-flag-route";
import { FEATURE_FLAG } from "@/lib/feature-flags";
import { VERDICTS } from "@/lib/issues/verdicts";
import { isCloud } from "@/lib/shared/env";
/**
* One issue on its own URL, so a path can be shared or linked from a ticket.
* The body is the same detail the list opens in place.
*/
export default async function IssuePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
if (!isCloud()) notFound();
const { id } = await params;
const issue = (await getIssue(id))?.data;
// handleApiResponse resolves {error, status} on a 4xx: no data is no issue.
if (!issue?.attributes) notFound();
const { attributes, relationships } = issue;
const verdict = VERDICTS.find((item) => item.id === attributes.verdict);
return (
<ContentLayout title="Issue" icon={<ShieldAlert />}>
<FeatureFlagRoute flag={FEATURE_FLAG.ISSUES}>
<div className="flex min-w-0 flex-col gap-4">
<Button asChild variant="link" size="link-sm" className="self-start">
<Link href="/issues">← All issues</Link>
</Button>
<Card variant="base" className="min-w-0 gap-3">
<div className="flex flex-wrap items-center gap-3">
<Badge variant={verdict?.variant ?? "tag"}>
{verdict?.label ?? attributes.verdict}
</Badge>
<span className="text-text-neutral-tertiary text-xs">
{attributes.pattern}
</span>
</div>
<h2 className="text-text-neutral-primary text-lg font-semibold break-words">
{attributes.title}
</h2>
<EntityInfo
cloudProvider={attributes.cloud}
entityAlias={attributes.account}
/>
<IssueDetail
id={issue.id}
issue={attributes}
scanId={relationships?.scan?.data?.id}
providerId={relationships?.provider?.data?.id}
/>
</Card>
</div>
</FeatureFlagRoute>
</ContentLayout>
);
}
@@ -26,10 +26,13 @@ const ISSUE_FILTER_KEYS = [
export const IssuesList = async ({
searchParams,
filters: filterControls,
providerUids,
}: {
searchParams: SearchParamsProps;
/** The scope selectors, rendered above the verdict tabs: they scope every tab. */
filters?: ReactNode;
/** Provider uid by provider id, for the issue panel. */
providerUids?: Record<string, string>;
}) => {
const filters: Record<string, string> = {};
ISSUE_FILTER_KEYS.forEach((key) => {
@@ -87,7 +90,11 @@ export const IssuesList = async ({
{rows === null ? (
<IssuesLoadError />
) : rows.length ? (
<IssuesTable issues={rows} metadata={issues?.meta} />
<IssuesTable
issues={rows}
metadata={issues?.meta}
providerUids={providerUids}
/>
) : (
<p className="text-text-neutral-tertiary text-sm">
No issues match this filter.
+8 -3
View File
@@ -27,6 +27,12 @@ export default async function Issues({
getAllProviderGroups(),
]);
const providers = providersData?.data ?? [];
// The issue only references its provider; the uid shows in the panel.
const providerUids = Object.fromEntries(
providers.map((provider) => [provider.id, provider.attributes.uid]),
);
return (
<ContentLayout title="Issues" icon={<ShieldAlert />}>
{/* Deployment gate above (notFound); per-account rollout here, also a
@@ -41,13 +47,12 @@ export default async function Issues({
>
<IssuesList
searchParams={resolvedSearchParams}
providerUids={providerUids}
filters={
// The three scope selectors share one row on desktop, like the
// other list pages.
<div className="mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<ProviderAccountSelectors
providers={providersData?.data ?? []}
/>
<ProviderAccountSelectors providers={providers} />
<ProviderGroupSelector
groups={providerGroupsData?.data ?? []}
/>
+36 -22
View File
@@ -1,14 +1,10 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { IssueAttributes } from "@/types/issues";
const { getIssue, getIssueFindings } = vi.hoisted(() => ({
getIssue: vi.fn(),
getIssueFindings: vi.fn(),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
vi.mock("@/actions/issues", () => ({ getIssue, getIssueFindings }));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: () => <p>graph</p>,
}));
@@ -18,35 +14,53 @@ import { IssueAttackPath } from "./issue-attack-path";
const issue = { legs: {} } as IssueAttributes;
describe("IssueAttackPath", () => {
it("says there is nothing to draw, not a load error, without resources", async () => {
// Given: no resources, and findings that cannot load without a scan
getIssue.mockResolvedValue({
data: { attributes: { resource_uids: [], finding_uids: ["f1"] } },
});
// When
render(<IssueAttackPath id="issue-1" issue={issue} />);
it("says there is nothing to draw, not a load error, without resources", () => {
// Given: no resources, and findings that could not load without a scan
render(
<IssueAttackPath
issue={issue}
evidence={{
status: "loaded",
resources: [],
findings: undefined,
findingsTruncated: false,
}}
onRetry={vi.fn()}
onOpenFinding={vi.fn()}
/>,
);
// Then
expect(
await screen.findByText("This issue has no affected resources to draw."),
screen.getByText("This issue has no affected resources to draw."),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
});
it("offers a retry when the findings for the drawn resources fail", async () => {
// Given
getIssue.mockResolvedValue({
data: { attributes: { resource_uids: ["bucket"], finding_uids: ["f1"] } },
});
const onRetry = vi.fn();
render(
<IssueAttackPath
issue={issue}
evidence={{
status: "loaded",
resources: ["bucket"],
findings: undefined,
findingsTruncated: false,
}}
onRetry={onRetry}
onOpenFinding={vi.fn()}
/>,
);
// When
render(<IssueAttackPath id="issue-1" issue={issue} />);
expect(
screen.getByText("Could not load the attack path for this issue."),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
// Then
expect(
await screen.findByText("Could not load the attack path for this issue."),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
expect(onRetry).toHaveBeenCalled();
});
});
+18 -19
View File
@@ -2,26 +2,25 @@
import { IssueGraph } from "@/components/issues/issue-graph";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import { useIssueEvidence } from "@/hooks/use-issue-evidence";
import { IssueAttributes } from "@/types/issues";
import type { IssueEvidenceState } from "./use-issue-evidence";
interface IssueAttackPathProps {
id: string;
issue: IssueAttributes;
scanId?: string;
providerId?: string;
evidence: IssueEvidenceState;
onRetry: () => void;
onOpenFinding: (findingId: string) => void;
}
/** The issue's graph for the side panel; findings open on the full page. */
/** The issue's graph, or why there is none to draw yet. */
export const IssueAttackPath = ({
id,
issue,
scanId,
providerId,
evidence,
onRetry,
onOpenFinding,
}: IssueAttackPathProps) => {
const { state, retry } = useIssueEvidence(id, scanId, providerId);
if (state.status === "loading") {
if (evidence.status === "loading") {
return (
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading the attack path…
@@ -30,29 +29,29 @@ export const IssueAttackPath = ({
}
// Nothing to draw beats a findings error: without resources there is no
// graph for the findings to land on.
if (state.status === "loaded" && state.resources.length === 0) {
if (evidence.status === "loaded" && evidence.resources.length === 0) {
return (
<p className="text-text-neutral-tertiary text-sm">
This issue has no affected resources to draw.
</p>
);
}
if (state.status === "error" || state.findings === undefined) {
if (evidence.status === "error" || evidence.findings === undefined) {
return (
<IssuesLoadError
message="Could not load the attack path for this issue."
onRetry={retry}
onRetry={onRetry}
/>
);
}
return (
<IssueGraph
issue={issue}
resourceUids={state.resources}
findings={state.findings}
readOnly
{...(state.providerId && state.scanId
? { providerId: state.providerId, scanId: state.scanId }
resourceUids={evidence.resources}
findings={evidence.findings}
onOpenFinding={onOpenFinding}
{...(evidence.providerId && evidence.scanId
? { providerId: evidence.providerId, scanId: evidence.scanId }
: { providerId: undefined, scanId: undefined })}
/>
);
+16 -7
View File
@@ -9,6 +9,7 @@ import {
complianceFrameworkLabel,
} from "@/components/compliance/compliance-framework-chip";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
import { IssueFinding } from "@/types/issues";
@@ -115,13 +116,21 @@ export const IssueCompliance = ({
);
};
// Laid out like the finding panel's "Compliance Frameworks" header row.
return (
<section aria-label="Compliance affected">
<h4 className="mb-3 text-sm font-semibold">Compliance affected</h4>
<section aria-label="Compliance affected" className="flex flex-col gap-1.5">
<span className="text-text-neutral-tertiary text-xs font-medium">
Compliance Frameworks:
</span>
{state.status === "loading" ? (
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading frameworks…
</p>
<div className="flex flex-wrap items-center gap-2">
<span role="status" className="sr-only">
Loading frameworks…
</span>
<Skeleton className="size-7 rounded-md" />
<Skeleton className="size-7 rounded-md" />
<Skeleton className="size-7 rounded-md" />
</div>
) : state.status === "unavailable" ? (
<IssuesLoadError
message="Could not load the frameworks for this issue."
@@ -130,7 +139,7 @@ export const IssueCompliance = ({
) : (
<>
{state.frameworks.length === 0 ? (
<p className="text-text-neutral-tertiary text-sm">
<p className="text-text-neutral-tertiary text-xs">
{incomplete
? "None of the findings checked map to a framework requirement."
: "None of the findings behind this issue map to a framework requirement."}
@@ -148,7 +157,7 @@ export const IssueCompliance = ({
</div>
)}
{incomplete && (
<p className="text-text-neutral-tertiary mt-2 text-xs">
<p className="text-text-neutral-tertiary text-xs">
Not every finding could be checked, so this list may be
incomplete.
</p>
-326
View File
@@ -1,326 +0,0 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { IssueDetail } from "@/components/issues/issue-detail";
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
import { ISSUE_FINDINGS_LIMIT, IssueAttributes } from "@/types/issues";
const { getIssueMock, getIssueFindingsMock } = vi.hoisted(() => ({
getIssueMock: vi.fn(),
getIssueFindingsMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
vi.mock("@/actions/issues", () => ({
getIssue: getIssueMock,
getIssueFindings: getIssueFindingsMock,
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,
}));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: ({ resourceUids }: { resourceUids: string[] }) => (
<div data-testid="issue-graph">{resourceUids.length} resources</div>
),
}));
const resourceUids = Array.from({ length: 15 }, (_, i) => `resource-${i + 1}`);
const issue = {
pattern: "unmodelled",
cloud: "aws",
account: "prod",
title: "Findings no attack path models yet (300)",
verdict: "unknown",
severity: "high",
outcome: "",
attacker_start: "",
legs: {},
fix: "Review these findings individually.",
findings_count: 300,
triage_status: "open",
inserted_at: "",
updated_at: "",
} as unknown as IssueAttributes;
describe("IssueDetail", () => {
beforeEach(() => {
getIssueMock.mockReset();
getIssueFindingsMock.mockResolvedValue([]);
useJiraDispatchStore.getState().closeJiraDispatch();
});
it("links to the issue's scan findings and reveals every folded resource", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: resourceUids } },
});
const user = userEvent.setup();
render(<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />);
expect(
screen.getByRole("link", { name: /failed findings/i }),
).toHaveAttribute(
"href",
"/findings?filter%5Bscan__in%5D=scan-1&filter%5Bstatus__in%5D=FAIL",
);
// The list leaves the uids out; the detail fetches them when it opens.
expect(getIssueMock).toHaveBeenCalledWith("issue-1");
expect(await screen.findByText("resource-1")).toBeInTheDocument();
expect(screen.queryByText("resource-15")).not.toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Show more resources" }),
);
expect(screen.getByText("resource-15")).toBeInTheDocument();
expect(screen.getByTestId("issue-graph")).toHaveTextContent("15 resources");
});
it("says what a path buys the attacker and where it starts", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: [] } },
});
render(
<IssueDetail
id="issue-1"
issue={{
...issue,
pattern: "aws_public_bucket",
outcome: "data_exposure",
attacker_start: "internet",
}}
/>,
);
expect(screen.getByText("Data exposure")).toBeInTheDocument();
expect(screen.getByText("The internet")).toBeInTheDocument();
// Shown inline so keyboard users can read it too.
expect(
screen.getByText(/Where the attacker has to be standing/),
).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: [] } },
});
render(<IssueDetail id="issue-1" issue={issue} />);
expect(screen.queryByText("Outcome")).not.toBeInTheDocument();
});
it("links each affected resource to it on the Resources page", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: ["arn:aws:s3:::bucket"] } },
});
render(<IssueDetail id="issue-1" issue={issue} providerId="provider-1" />);
expect(
await screen.findByRole("link", {
name: "Open in Resources: arn:aws:s3:::bucket",
}),
).toHaveAttribute(
"href",
"/resources?filter%5Buid%5D=arn%3Aaws%3As3%3A%3A%3Abucket&filter%5Bprovider_id__in%5D=provider-1",
);
});
it("says so when the resources cannot be loaded", async () => {
getIssueMock.mockResolvedValue(undefined);
render(<IssueDetail id="issue-1" issue={issue} />);
expect(
await screen.findByText(/could not load the resources/i),
).toBeInTheDocument();
expect(screen.queryByTestId("issue-graph")).not.toBeInTheDocument();
});
it("reports a failed findings request and retries using the issue scan", async () => {
// Given
getIssueMock.mockResolvedValue({
data: {
attributes: {
resource_uids: resourceUids,
finding_uids: ["finding-uid"],
},
},
});
getIssueFindingsMock
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([]);
render(<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />);
// When
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not load the findings for this issue",
);
expect(screen.queryByTestId("issue-graph")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
// Then
expect(await screen.findByTestId("issue-graph")).toBeInTheDocument();
expect(getIssueFindingsMock).toHaveBeenLastCalledWith("scan-1", [
"finding-uid",
]);
});
it("names the conditions that make the path, explained by each leg", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: [] } },
});
render(
<IssueDetail
id="issue-1"
issue={{
...issue,
outcome: "data_exposure",
attacker_start: "internet",
legs: {
...issue.legs,
controls: {
state: "no",
text: "The public access block is off.",
evidence: [],
},
},
}}
/>,
);
const factors = screen.getByRole("list", { name: "Risk factors" });
expect(factors).toHaveTextContent("Internet-reachable");
expect(screen.getByText("No control blocking")).toHaveAttribute(
"title",
"The public access block is off.",
);
});
it("lists each folded finding once, with its own triage and a link to it", async () => {
getIssueMock.mockResolvedValue({
data: {
attributes: { resource_uids: ["bucket"], finding_uids: ["f-1", "f-2"] },
},
});
const finding = {
id: "finding-1",
uid: "f-1",
checkId: "s3_bucket_public_access",
checkTitle: "Bucket is public",
severity: "critical",
triageStatus: "risk_accepted",
resourceUid: "bucket",
};
getIssueFindingsMock.mockResolvedValue([
finding,
// The same finding on a second resource is still one finding.
{ ...finding, resourceUid: "other-bucket" },
{
...finding,
id: "finding-2",
uid: "f-2",
checkTitle: "Bucket has no logging",
triageStatus: "open",
},
]);
render(<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />);
const section = await screen.findByRole("region", { name: "Findings" });
const link = within(section).getByRole("link", {
name: "Bucket is public",
});
const destination = new URL(link.getAttribute("href")!, "https://app.test");
expect(Object.fromEntries(destination.searchParams)).toEqual({
"filter[scan__in]": "scan-1",
"filter[check_id__in]": "s3_bucket_public_access",
"filter[resource_uid]": "other-bucket",
"filter[muted]": "include",
expandedCheckId: "s3_bucket_public_access",
});
expect(within(section).getAllByRole("listitem")).toHaveLength(2);
expect(within(section).getByText("Risk Accepted")).toBeInTheDocument();
expect(within(section).getByText("Showing 2 of 300.")).toBeInTheDocument();
});
it("opens the global Jira flow with unique finding IDs", async () => {
// Given
const user = userEvent.setup();
getIssueMock.mockResolvedValue({
data: {
attributes: { resource_uids: ["bucket"], finding_uids: ["f-1"] },
},
});
const finding = {
id: "finding-1",
uid: "f-1",
checkId: "s3_public",
checkTitle: "Bucket is public",
severity: "critical",
triageStatus: "open",
resourceUid: "bucket",
};
getIssueFindingsMock.mockResolvedValue([
finding,
{ ...finding, resourceUid: "other-bucket" },
{ ...finding, id: "finding-2", uid: "f-2" },
]);
const view = render(
<IssueDetail id="issue-1" issue={issue} scanId="scan-1" />,
);
// When
await user.click(
await screen.findByRole("button", { name: "Send to Jira" }),
);
view.unmount();
// Then: the globally hosted modal survives the detail closing.
expect(useJiraDispatchStore.getState().activePayload).toMatchObject({
findingTitle: issue.title,
selection: {
kind: "target-list",
targetType: "finding_id",
targetIds: ["finding-1", "finding-2"],
},
});
});
});
-273
View File
@@ -1,273 +0,0 @@
"use client";
import { Send } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
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";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { CodeSnippet } from "@/components/shadcn/code-snippet";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { useIssueEvidence } from "@/hooks/use-issue-evidence";
import { riskFactors } from "@/lib/issues/risk-factors";
import {
LEG_LABELS,
LEG_QUESTIONS,
LEG_STATE_LABELS,
legBadgeVariant,
} from "@/lib/issues/verdicts";
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
import {
ATTACKER_START_LABEL,
ISSUE_OUTCOME_LABEL,
IssueAttributes,
LegName,
} from "@/types/issues";
const RESOURCE_PAGE_SIZE = 12;
// The issue carries resource uids, not ids, so this opens the Resources list
// on that one resource rather than its drawer.
const resourceHref = (uid: string, providerId?: string) =>
`/resources?${new URLSearchParams({
"filter[uid]": uid,
// A uid is unique within an account, not across them.
...(providerId && { "filter[provider_id__in]": providerId }),
})}`;
interface IssueDetailProps {
id: string;
issue: IssueAttributes;
scanId?: string;
providerId?: string;
}
export const IssueDetail = ({
id,
issue,
scanId,
providerId,
}: IssueDetailProps) => {
const openJiraDispatch = useJiraDispatchStore(
(state) => state.openJiraDispatch,
);
const [visibleResources, setVisibleResources] = useState(RESOURCE_PAGE_SIZE);
const { state, retry } = useIssueEvidence(id, scanId, providerId);
const activeScanId = state.status === "loaded" ? state.scanId : scanId;
const factors = riskFactors(issue);
const uniqueFindings =
state.status === "loaded"
? Array.from(
new Map(
state.findings?.map((finding) => [finding.id, finding]),
).values(),
)
: [];
const jiraPayload = createJiraDispatchPayload({
findingTitle: issue.title,
targetIds: uniqueFindings.map((finding) => finding.id),
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
description: `Send ${uniqueFindings.length} of ${issue.findings_count} findings on this attack path.`,
});
return (
<div className="flex min-w-0 flex-col gap-4 py-4">
{/* The conditions that hold at once: the verdict says how bad, these
say why, and which part to argue with. */}
{factors.length > 0 && (
<ul aria-label="Risk factors" className="flex flex-wrap gap-1.5">
{factors.map((factor) => (
<li key={factor.label}>
<Badge variant={factor.variant} title={factor.title}>
{factor.label}
</Badge>
</li>
))}
</ul>
)}
{state.status === "loaded" && state.findings === undefined && (
<IssuesLoadError
message="Could not load the findings for this issue."
onRetry={retry}
/>
)}
{state.status === "loaded" &&
state.findings !== undefined &&
state.resources.length > 0 && (
<IssueGraph
issue={issue}
resourceUids={state.resources}
findings={state.findings}
{...(state.providerId && state.scanId
? { providerId: state.providerId, scanId: state.scanId }
: { providerId: undefined, scanId: undefined })}
/>
)}
{issue.outcome && issue.attacker_start && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<InfoField label="Outcome">
{ISSUE_OUTCOME_LABEL[issue.outcome]}
</InfoField>
{/* Inline, not an InfoTooltip: its trigger has no tab stop. */}
<InfoField label="Starts from">
{ATTACKER_START_LABEL[issue.attacker_start]}
<p className="text-text-neutral-tertiary text-xs">
Where the attacker has to be standing. Whether they also need a
credential is the Access leg.
</p>
</InfoField>
</div>
)}
<section aria-label="Exploitability assessment">
<h4 className="mb-3 text-sm font-semibold">
Exploitability assessment
</h4>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{(Object.keys(LEG_QUESTIONS) as LegName[]).map((name) => {
const leg = issue.legs[name];
if (!leg) return null;
return (
<Card key={name} variant="inner" className="min-w-0 gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h5 className="text-sm font-medium">{LEG_LABELS[name]}</h5>
<Badge variant={legBadgeVariant(name, leg.state)}>
{LEG_STATE_LABELS[leg.state]}
</Badge>
</div>
<p className="text-text-neutral-tertiary text-xs">
{LEG_QUESTIONS[name]}
</p>
<p className="text-text-neutral-secondary text-sm">
{leg.text}
</p>
{leg.evidence.length > 0 && (
<ul className="flex flex-col gap-1">
{leg.evidence.map((line) => (
<li key={line}>
<CodeSnippet
value={line}
multiline
hideCopyButton
transparent
/>
</li>
))}
</ul>
)}
</Card>
);
})}
</div>
</section>
{state.status === "loading" ? (
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading issue details…
</p>
) : state.status === "error" ? (
<IssuesLoadError
message="Could not load the resources for this issue."
onRetry={retry}
/>
) : state.resources.length > 0 ? (
<section aria-label="Affected resources">
<h4 className="mb-3 text-sm font-semibold">
Affected resources ({state.resources.length})
</h4>
<ul className="flex flex-col gap-2">
{state.resources.slice(0, visibleResources).map((uid) => (
<li
key={uid}
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
>
<CodeSnippet value={uid} multiline ariaLabel={`Copy ${uid}`} />
<Button asChild variant="link" size="link-sm">
<Link
href={resourceHref(uid, state.providerId)}
aria-label={`Open in Resources: ${uid}`}
>
Open in Resources
</Link>
</Button>
</li>
))}
</ul>
{visibleResources < state.resources.length && (
<Button
variant="link"
size="link-sm"
onClick={() =>
setVisibleResources((value) => value + RESOURCE_PAGE_SIZE)
}
>
Show more resources
</Button>
)}
{visibleResources > RESOURCE_PAGE_SIZE && (
<Button
variant="link"
size="link-sm"
onClick={() => setVisibleResources(RESOURCE_PAGE_SIZE)}
>
Show fewer resources
</Button>
)}
</section>
) : null}
<section aria-label="Remediation">
<h4 className="mb-3 text-sm font-semibold">Remediation</h4>
<p className="text-text-neutral-secondary text-sm">{issue.fix}</p>
<div className="flex flex-wrap items-center gap-3">
{activeScanId && (
<Button asChild variant="link" size="link-sm">
<Link
href={`/findings?${new URLSearchParams({ "filter[scan__in]": activeScanId, "filter[status__in]": "FAIL" })}`}
>
Open this scan&apos;s failed findings
</Link>
</Button>
)}
{jiraPayload && (
// shortcut: the ticket groups the loaded preview (ISSUE_FINDINGS_LIMIT
// findings), not every finding of a larger issue; page through
// finding_uids if tickets for big issues need all of them.
<Button
variant="outline"
size="sm"
onClick={() => openJiraDispatch(jiraPayload)}
>
<Send aria-hidden="true" />
Send to Jira
</Button>
)}
</div>
</section>
{state.status === "loaded" &&
state.findings &&
state.findings.length > 0 && (
<IssueFindings
findings={uniqueFindings}
scanId={activeScanId}
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>
);
};
+76 -68
View File
@@ -1,83 +1,91 @@
import Link from "next/link";
import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { SeverityBadge } from "@/components/shadcn/table/severity-badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/shadcn/table/table";
import { TriageStatusBadge } from "@/components/shared/triage/triage-status-badge";
import { MUTED_FILTER } from "@/lib/findings-filters";
import type { IssueFindingsProps } from "./types";
const HEADERS = ["Finding", "Severity", "Triage"] as const;
/**
* The findings the issue folds, each with its own triage. That triage stays on
* the finding: the issue's status answers for the combination, the finding's
* for whether that one check is right.
* The findings the issue folds, laid out like the finding panel's "Other
* findings" table. Each keeps its own triage: the issue's status answers for
* the combination, the finding's for whether that one check is right.
*/
export const IssueFindings = ({
findings,
scanId,
total,
onOpenFinding,
}: IssueFindingsProps) => {
return (
<section aria-label="Findings">
<h4 className="mb-3 text-sm font-semibold">
Findings ({total.toLocaleString()})
</h4>
<ul className="grid grid-cols-1 gap-2 lg:grid-cols-2">
{findings.map((finding) => (
<li key={finding.id} className="min-w-0">
<Card variant="inner" padding="md" className="h-full">
<div className="flex flex-col gap-1">
<div className="flex items-start justify-between gap-3">
{scanId ? (
<div className="min-w-0">
<Button
asChild
variant="link"
size="link-sm"
className="max-w-full"
>
<Link
href={`/findings?${new URLSearchParams({
"filter[scan__in]": scanId,
"filter[check_id__in]": finding.checkId,
"filter[resource_uid]": finding.resourceUid,
"filter[muted]": MUTED_FILTER.INCLUDE,
expandedCheckId: finding.checkId,
})}`}
title={finding.checkTitle}
>
<span className="truncate">{finding.checkTitle}</span>
</Link>
</Button>
</div>
) : (
<span className="text-text-neutral-primary min-w-0 text-sm break-words">
{finding.checkTitle}
</span>
)}
<SeverityBadge severity={finding.severity} />
</div>
<div className="flex items-baseline justify-between gap-3">
<span className="text-text-neutral-tertiary min-w-0 truncate font-mono text-xs">
{finding.checkId}
</span>
<TriageStatusBadge
status={finding.triageStatus}
showLabel={false}
/>
</div>
</div>
</Card>
</li>
))}
</ul>
{findings.length < total && (
<p className="text-text-neutral-tertiary mt-2 text-xs">
Showing {findings.length.toLocaleString()} of {total.toLocaleString()}
.
</p>
)}
<section aria-label="Findings" className="flex flex-col gap-2">
<div className="flex items-center justify-end">
<span className="text-text-neutral-tertiary text-sm">
{findings.length < total
? `Showing ${findings.length.toLocaleString()} of ${total.toLocaleString()}`
: `${total.toLocaleString()} Total Entries`}
</span>
</div>
<Table>
<TableHeader>
<TableRow>
{HEADERS.map((header) => (
<TableHead key={header}>
<span className="text-text-neutral-secondary text-sm font-medium">
{header}
</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{findings.map((finding) => (
<TableRow
key={finding.id}
className="group cursor-pointer"
onClick={() => onOpenFinding(finding.id)}
>
{/* max-w-0 lets the title take the spare width and truncate. */}
<TableCell className="w-full max-w-0">
{/* The row opens the finding; the button is its keyboard stop. */}
<button
type="button"
title={finding.checkTitle}
className="text-text-neutral-primary block max-w-full truncate text-left text-sm"
onClick={(event) => {
event.stopPropagation();
onOpenFinding(finding.id);
}}
>
{finding.checkTitle}
</button>
{/* One check fails on several resources: this tells them apart. */}
<p
className="text-text-neutral-tertiary truncate font-mono text-xs"
title={finding.resourceUid}
>
{finding.resourceUid}
</p>
</TableCell>
<TableCell>
<SeverityBadge severity={finding.severity} />
</TableCell>
<TableCell>
<TriageStatusBadge
status={finding.triageStatus}
showLabel={false}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
);
};
+20 -44
View File
@@ -6,22 +6,8 @@ import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
import { GraphNode } from "@/types/attack-paths";
import { IssueAttributes } from "@/types/issues";
const { getFindings, navigateToFinding } = vi.hoisted(() => ({
getFindings: vi.fn(),
navigateToFinding: vi.fn(),
}));
const { getFindings } = vi.hoisted(() => ({ getFindings: vi.fn() }));
vi.mock("@/actions/findings", () => ({ getFindings }));
vi.mock("@/hooks/use-finding-details", () => ({
useFindingDetails: () => ({
findingDetails: null,
findingDetailLoading: false,
resetFindingDetails: vi.fn(),
navigateToFinding,
}),
}));
vi.mock("@/components/findings/table", () => ({
FindingDetailDrawer: () => null,
}));
vi.mock("@/components/attack-paths/graph/attack-path-graph", () => ({
AttackPathGraph: ({
data,
@@ -52,7 +38,7 @@ const issue = { legs: {} } as IssueAttributes;
it("requires provider and scan identifiers together in the graph props", () => {
type Props = ComponentProps<typeof IssueGraph>;
type BaseProps = Pick<Props, "issue" | "resourceUids">;
type BaseProps = Pick<Props, "issue" | "resourceUids" | "onOpenFinding">;
expectTypeOf<BaseProps>().toExtend<Props>();
expectTypeOf<
@@ -63,31 +49,10 @@ it("requires provider and scan identifiers together in the graph props", () => {
});
describe("IssueGraph finding lookup", () => {
const openFinding = vi.fn();
beforeEach(() => {
navigateToFinding.mockResolvedValue(undefined);
});
it("leaves findings closed in a read-only graph", async () => {
// Given: a host that cannot hold a second detail view, such as the panel
render(
<IssueGraph
issue={issue}
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
readOnly
/>,
);
// When
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
// Then
expect(navigateToFinding).not.toHaveBeenCalled();
expect(getFindings).not.toHaveBeenCalled();
expect(
screen.getByText(/Open the full path to inspect a finding/),
).toBeInTheDocument();
openFinding.mockReset();
});
it("opens a folded finding by id without another lookup", async () => {
@@ -109,6 +74,7 @@ describe("IssueGraph finding lookup", () => {
]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
@@ -116,7 +82,7 @@ describe("IssueGraph finding lookup", () => {
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
// Then
expect(navigateToFinding).toHaveBeenCalledWith("folded-finding");
expect(openFinding).toHaveBeenCalledWith("folded-finding");
expect(getFindings).not.toHaveBeenCalled();
});
@@ -136,6 +102,7 @@ describe("IssueGraph finding lookup", () => {
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
await user.click(screen.getByRole("button", { name: "Open finding" }));
@@ -149,7 +116,7 @@ describe("IssueGraph finding lookup", () => {
"filter[status]": "FAIL",
},
});
expect(navigateToFinding).toHaveBeenCalledWith("finding-1");
expect(openFinding).toHaveBeenCalledWith("finding-1");
});
it("rejects a finding from a newer scan", async () => {
@@ -167,10 +134,11 @@ describe("IssueGraph finding lookup", () => {
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
expect(navigateToFinding).not.toHaveBeenCalled();
expect(openFinding).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent(
"no longer available for the issue's scan",
);
@@ -186,6 +154,7 @@ describe("IssueGraph finding lookup", () => {
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
@@ -209,6 +178,7 @@ describe("IssueGraph finding lookup", () => {
resourceUids={["bucket"]}
providerId="provider-1"
scanId="scan-1"
onOpenFinding={openFinding}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
@@ -224,7 +194,13 @@ describe("IssueGraph finding lookup", () => {
});
it("does not look up findings without a provider and scan", async () => {
render(<IssueGraph issue={issue} resourceUids={["bucket"]} />);
render(
<IssueGraph
issue={issue}
resourceUids={["bucket"]}
onOpenFinding={openFinding}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Open finding" }));
expect(getFindings).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent(
+8 -38
View File
@@ -4,14 +4,12 @@ import { useEffect, useRef, useState } from "react";
import { getFindings } from "@/actions/findings";
import { AttackPathGraph } from "@/components/attack-paths/graph/attack-path-graph";
import { FindingDetailDrawer } from "@/components/findings/table";
import {
buildIssueGraph,
GRAPH_RESOURCE_LIMIT,
} from "@/components/issues/issue-graph.adapter";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Card } from "@/components/shadcn/card/card";
import { useFindingDetails } from "@/hooks/use-finding-details";
import { useMediaQuery } from "@/hooks/use-media-query";
import { isProwlerFindingNode } from "@/lib/attack-paths/node-types";
import { GraphNode } from "@/types/attack-paths";
@@ -21,11 +19,8 @@ interface IssueGraphBaseProps {
issue: IssueAttributes;
resourceUids: string[];
findings?: IssueFinding[];
/**
* No finding drawer on click: for hosts inside the side panel, where a
* second detail view would replace the one holding the graph.
*/
readOnly?: boolean;
/** Called with the clicked finding's id; the host shows its detail. */
onOpenFinding: (findingId: string) => void;
}
interface ScopedIssueGraphProps extends IssueGraphBaseProps {
@@ -51,9 +46,8 @@ export const IssueGraph = ({
findings = [],
providerId,
scanId,
readOnly = false,
onOpenFinding,
}: IssueGraphProps) => {
const finding = useFindingDetails();
const canKeepReadableZoom = useMediaQuery(READABLE_ZOOM_MEDIA_QUERY);
const view = buildIssueGraph(issue, resourceUids, findings);
const shownFindings = new Set(
@@ -64,7 +58,6 @@ export const IssueGraph = ({
const pending = useRef(false);
const mounted = useRef(true);
const [loading, setLoading] = useState(false);
const [lookupComplete, setLookupComplete] = useState(false);
const [error, setError] = useState<string | null>(null);
const expandedResources = new Set(
view.nodes
@@ -82,8 +75,6 @@ export const IssueGraph = ({
const handleNodeClick = async (node: GraphNode) => {
if (!isProwlerFindingNode(node.labels) || pending.current) return;
setError(null);
setLookupComplete(false);
finding.resetFindingDetails();
let findingId = node.properties.finding_id;
if (!findingId && (!providerId || !scanId)) {
setError(
@@ -120,8 +111,7 @@ export const IssueGraph = ({
}
findingId = match.id;
}
await finding.navigateToFinding(String(findingId));
if (mounted.current) setLookupComplete(true);
onOpenFinding(String(findingId));
} catch {
if (mounted.current)
setError("Could not load this finding. Select the finding to retry.");
@@ -130,9 +120,6 @@ export const IssueGraph = ({
if (mounted.current) setLoading(false);
}
};
const detailFailed =
lookupComplete && !finding.findingDetailLoading && !finding.findingDetails;
return (
<section
aria-label="Finding evidence"
@@ -144,20 +131,16 @@ export const IssueGraph = ({
{Math.min(resourceUids.length, GRAPH_RESOURCE_LIMIT)} of{" "}
{resourceUids.length} affected resources, plus supporting checks cited
by the assessment. These links show evidence associations, not confirmed
attack paths.
{readOnly && " Open the full path to inspect a finding."}
attack paths. Select a finding to open it.
</p>
{loading && (
<p role="status" className="text-sm">
Loading finding details…
</p>
)}
{(error || detailFailed) && (
{error && (
<Alert variant="error">
<AlertDescription>
{error ??
"Could not load this finding. Select the finding to retry."}
</AlertDescription>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card
@@ -169,23 +152,10 @@ export const IssueGraph = ({
data={{ nodes: view.nodes, edges: view.edges }}
view={view}
expandedResources={expandedResources}
onNodeClick={readOnly ? undefined : handleNodeClick}
onNodeClick={handleNodeClick}
minFitZoom={canKeepReadableZoom ? ISSUE_GRAPH_MIN_ZOOM : undefined}
/>
</Card>
{finding.findingDetails && (
<FindingDetailDrawer
key={finding.findingDetails.id}
finding={finding.findingDetails}
defaultOpen
onOpenChange={(open) => {
if (!open) {
setLookupComplete(false);
finding.resetFindingDetails();
}
}}
/>
)}
</section>
);
};
+533
View File
@@ -0,0 +1,533 @@
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useJiraDispatchStore } from "@/store/jira-dispatch/store";
import {
ISSUE_FINDINGS_LIMIT,
IssueAttributes,
IssueFinding,
IssueResource,
} from "@/types/issues";
import { IssuePanel } from "./issue-panel";
const { getIssue, getIssueFindings, getFindingById } = vi.hoisted(() => ({
getIssue: vi.fn(),
getIssueFindings: vi.fn(),
getFindingById: vi.fn(),
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/issues",
useRouter: () => ({ refresh: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/actions/issues", () => ({
getIssue,
getIssueFindings,
getIssueNotes: async () => [],
}));
vi.mock("@/actions/findings", () => ({ getFindingById }));
// The real helper sits behind the server-only `@/lib` barrel.
vi.mock("@/lib/finding-detail", () => ({
expandFindingWithRelationships: (response?: { data?: unknown }) =>
response?.data ?? null,
}));
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,
}));
vi.mock("@/components/findings/table", () => ({
FindingDetailDrawer: ({ finding }: { finding: { id: string } }) => (
<p>Detail of {finding.id}</p>
),
}));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: ({
resourceUids,
onOpenFinding,
}: {
resourceUids: string[];
onOpenFinding: (id: string) => void;
}) => (
<button type="button" onClick={() => onOpenFinding("graph-finding")}>
Graph of {resourceUids.length} resources
</button>
),
}));
// The real panel portals into the app shell's side panel, absent here.
vi.mock("@/components/side-panel/detail-side-panel", () => ({
DetailSidePanel: ({
title,
children,
}: {
title: string;
children: ReactNode;
}) => <section aria-label={title}>{children}</section>,
}));
const resourceUids = Array.from({ length: 15 }, (_, i) => `resource-${i + 1}`);
const attributes = {
pattern: "unmodelled",
cloud: "aws",
account: "prod",
title: "Findings no attack path models yet (300)",
verdict: "unknown",
severity: "high",
outcome: "",
attacker_start: "",
legs: {},
fix: "Review these findings individually.",
findings_count: 300,
triage_status: "open",
inserted_at: "",
updated_at: "",
} as unknown as IssueAttributes;
const issueWith = (
overrides: Partial<IssueAttributes> = {},
scanId: string | undefined = "scan-1",
): IssueResource => ({
type: "issues",
id: "issue-1",
attributes: { ...attributes, ...overrides },
relationships: {
provider: { data: { type: "providers", id: "provider-1" } },
...(scanId && { scan: { data: { type: "scans", id: scanId } } }),
},
});
const finding: IssueFinding = {
id: "finding-1",
uid: "f-1",
checkId: "s3_bucket_public_access",
checkTitle: "Bucket is public",
severity: "critical",
triageStatus: "risk_accepted",
resourceUid: "bucket",
};
const detailWith = (resource_uids: string[], finding_uids: string[] = []) => ({
data: { attributes: { resource_uids, finding_uids } },
});
const renderPanel = (
issue: IssueResource = issueWith(),
providerUid?: string,
) =>
render(
<IssuePanel
issue={issue}
providerUid={providerUid}
open
onOpenChange={vi.fn()}
/>,
);
const openTab = (name: string) =>
userEvent.click(screen.getByRole("tab", { name }));
describe("IssuePanel", () => {
beforeEach(() => {
getIssue.mockReset();
getIssueFindings.mockReset().mockResolvedValue([]);
getFindingById.mockReset();
useJiraDispatchStore.getState().closeJiraDispatch();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("says where the path starts and shows each leg's evidence", async () => {
// Given
getIssue.mockResolvedValue(detailWith([]));
// When
renderPanel(
issueWith({
outcome: "data_exposure",
attacker_start: "internet",
legs: {
reachability: {
state: "yes",
text: "Public access is enabled.",
evidence: ["s3_public · arn:aws:s3:::bucket"],
},
} as IssueAttributes["legs"],
}),
);
// Then
expect(screen.getByText("Data exposure")).toBeInTheDocument();
expect(screen.getByText("The internet")).toBeInTheDocument();
// Shown inline so keyboard users can read it too.
expect(
screen.getByText(/Where the attacker has to be standing/),
).toBeInTheDocument();
expect(
within(
screen.getByRole("list", { name: "Reachability evidence" }),
).getByText("s3_public · arn:aws:s3:::bucket"),
).toBeInTheDocument();
expect(
screen.queryByRole("link", { name: "Open the full path" }),
).not.toBeInTheDocument();
});
it("names the provider with its account uid, like the finding panel", () => {
// Given
getIssue.mockResolvedValue(detailWith([]));
// When
renderPanel(issueWith(), "123456789012");
// Then
expect(screen.getByText("Provider")).toBeInTheDocument();
expect(screen.getByText("prod")).toBeInTheDocument();
expect(screen.getByText("123456789012")).toBeInTheDocument();
expect(screen.getByText("unmodelled")).toBeInTheDocument();
});
it("claims no outcome for an issue that is not one path", () => {
getIssue.mockResolvedValue(detailWith([]));
renderPanel();
expect(screen.queryByText("Leads to")).not.toBeInTheDocument();
});
it("pages through every affected resource and links each to Resources", async () => {
// Given
getIssue.mockResolvedValue(detailWith(resourceUids));
renderPanel();
// When
await openTab("Resources");
// Then: the list leaves the uids out, so the panel fetches them
expect(getIssue).toHaveBeenCalledWith("issue-1");
expect(await screen.findByText("resource-1")).toBeInTheDocument();
expect(screen.queryByText("resource-15")).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Open in Resources: resource-1" }),
).toHaveAttribute(
"href",
"/resources?filter%5Buid%5D=resource-1&filter%5Bprovider_id__in%5D=provider-1",
);
await userEvent.click(
screen.getByRole("button", { name: "Show more resources" }),
);
expect(screen.getByText("resource-15")).toBeInTheDocument();
});
it("says so when the resources cannot be loaded", async () => {
getIssue.mockResolvedValue(undefined);
renderPanel();
await openTab("Resources");
expect(
await screen.findByText(/could not load the resources/i),
).toBeInTheDocument();
});
it("reports a failed findings request and retries using the issue scan", async () => {
// Given
getIssue.mockResolvedValue(detailWith(resourceUids, ["finding-uid"]));
getIssueFindings
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([finding]);
renderPanel();
// When
await openTab("Findings");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not load the findings for this issue",
);
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
// Then
expect(
await screen.findByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
expect(getIssueFindings).toHaveBeenLastCalledWith("scan-1", [
"finding-uid",
]);
});
it("lists each folded finding once, with its own triage", async () => {
// Given
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1", "f-2"]));
getIssueFindings.mockResolvedValue([
finding,
// The same finding on a second resource is still one finding.
{ ...finding, resourceUid: "other-bucket" },
{
...finding,
id: "finding-2",
uid: "f-2",
checkTitle: "Bucket has no logging",
triageStatus: "open",
},
]);
renderPanel();
// When
await openTab("Findings");
// Then
const section = await screen.findByRole("region", { name: "Findings" });
// One header row plus one row per finding.
expect(within(section).getAllByRole("row")).toHaveLength(3);
expect(within(section).getByText("Risk Accepted")).toBeInTheDocument();
// Cards of one check tell their resources apart.
expect(within(section).getByText("other-bucket")).toBeInTheDocument();
expect(within(section).getByText("Showing 2 of 300")).toBeInTheDocument();
});
it("tells the compliance section when the findings preview was capped", async () => {
// Given: one more finding than the preview loads
getIssue.mockResolvedValue(
detailWith(
[],
Array.from({ length: ISSUE_FINDINGS_LIMIT + 1 }, (_, i) => `uid-${i}`),
),
);
getIssueFindings.mockResolvedValue([finding]);
renderPanel();
// When
await openTab("Findings");
// Then
expect(
await screen.findByText("Compliance from a capped preview"),
).toBeInTheDocument();
});
it("opens a finding in place and goes back to the tab it came from", async () => {
// Given
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
getIssueFindings.mockResolvedValue([finding]);
getFindingById.mockResolvedValue({
data: {
id: "finding-1",
attributes: { check_metadata: { checktitle: "Bucket is public" } },
relationships: {},
},
});
renderPanel();
await openTab("Findings");
// When
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
// Then: the finding replaces the tabs, with a way back
expect(await screen.findByText("Detail of finding-1")).toBeInTheDocument();
expect(getFindingById).toHaveBeenCalledWith(
"finding-1",
"resources,scan.provider",
);
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
// When
await userEvent.click(screen.getByText("Issue details"));
// Then
expect(screen.getByRole("tab", { name: "Findings" })).toHaveAttribute(
"aria-selected",
"true",
);
});
it("refreshes the findings behind the tabs when a finding closes", async () => {
// Given: the finding is triaged while open in the panel
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
getIssueFindings
.mockResolvedValueOnce([{ ...finding, triageStatus: "open" }])
.mockResolvedValueOnce([{ ...finding, triageStatus: "risk_accepted" }]);
getFindingById.mockResolvedValue({
data: { id: "finding-1", attributes: {}, relationships: {} },
});
renderPanel();
await openTab("Findings");
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
await screen.findByText("Detail of finding-1");
// When
await userEvent.click(screen.getByText("Issue details"));
// Then: the row shows the new triage without dropping to a loading state
expect(
screen.getByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
expect(await screen.findByText("Risk Accepted")).toBeInTheDocument();
expect(screen.queryByText("Loading issue details…")).toBeNull();
});
it.each([
{
failure: "the findings request",
fail: () => getIssueFindings.mockResolvedValueOnce(undefined),
failing: getIssueFindings,
},
{
failure: "the issue request",
fail: () => getIssue.mockRejectedValueOnce(new Error("Offline")),
failing: getIssue,
},
])(
"keeps the loaded rows when $failure fails on a background refresh",
async ({ fail, failing }) => {
// Given: the findings loaded, and a finding is open
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
getIssueFindings.mockResolvedValueOnce([finding]);
getFindingById.mockResolvedValue({
data: { id: "finding-1", attributes: {}, relationships: {} },
});
renderPanel();
await openTab("Findings");
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
await screen.findByText("Detail of finding-1");
fail();
// When: closing it refreshes, and the refresh fails
await userEvent.click(screen.getByText("Issue details"));
// Then: once the failing refresh has settled, the rows stay
await waitFor(() => expect(failing).toHaveBeenCalledTimes(2));
await act(async () => {
await failing.mock.results[1]?.value?.catch(() => undefined);
});
expect(
screen.getByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
expect(screen.queryByRole("alert")).toBeNull();
},
);
it("keeps the rows on screen while the background refresh is pending", async () => {
// Given: the refresh after closing the finding never answers
getIssue
.mockResolvedValueOnce(detailWith(["bucket"], ["f-1"]))
.mockReturnValueOnce(new Promise(() => {}));
getIssueFindings.mockResolvedValueOnce([finding]);
getFindingById.mockResolvedValue({
data: { id: "finding-1", attributes: {}, relationships: {} },
});
renderPanel();
await openTab("Findings");
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
await screen.findByText("Detail of finding-1");
// When
await userEvent.click(screen.getByText("Issue details"));
await waitFor(() => expect(getIssue).toHaveBeenCalledTimes(2));
// Then: the loaded rows stay, with no loading state in their place
expect(
screen.getByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
expect(screen.queryByText("Loading issue details…")).toBeNull();
});
it("applies refreshed resources even when the refreshed findings fail", async () => {
// Given: the refresh brings a new resource but loses the findings
getIssue
.mockResolvedValueOnce(detailWith(["bucket"], ["f-1"]))
.mockResolvedValueOnce(detailWith(["bucket", "new-bucket"], ["f-1"]));
getIssueFindings
.mockResolvedValueOnce([finding])
.mockResolvedValueOnce(undefined);
getFindingById.mockResolvedValue({
data: { id: "finding-1", attributes: {}, relationships: {} },
});
renderPanel();
await openTab("Findings");
await userEvent.click(
await screen.findByRole("button", { name: "Bucket is public" }),
);
await screen.findByText("Detail of finding-1");
// When
await userEvent.click(screen.getByText("Issue details"));
await waitFor(() => expect(getIssueFindings).toHaveBeenCalledTimes(2));
// Then: the findings stay, the resources follow the refresh
expect(
screen.getByRole("button", { name: "Bucket is public" }),
).toBeInTheDocument();
await openTab("Resources");
expect(await screen.findByText("new-bucket")).toBeInTheDocument();
});
it("opens a finding clicked on the attack path in place", async () => {
// Given
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1"]));
getIssueFindings.mockResolvedValue([finding]);
getFindingById.mockResolvedValue(undefined);
renderPanel();
await openTab("Attack path");
// When
await userEvent.click(
await screen.findByRole("button", { name: "Graph of 1 resources" }),
);
// Then: a failed detail says so and can be retried
expect(getFindingById).toHaveBeenCalledWith(
"graph-finding",
"resources,scan.provider",
);
expect(
await screen.findByText("Could not load this finding."),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(getFindingById).toHaveBeenCalledTimes(2);
});
it("sends the unique findings to Jira from the actions menu", async () => {
// Given: Prowler Cloud, where several findings can go in one dispatch
vi.stubEnv("UI_CLOUD_ENABLED", "true");
getIssue.mockResolvedValue(detailWith(["bucket"], ["f-1", "f-2"]));
getIssueFindings.mockResolvedValue([
finding,
{ ...finding, resourceUid: "other-bucket" },
{ ...finding, id: "finding-2", uid: "f-2" },
]);
const view = renderPanel();
// When
await userEvent.click(
await screen.findByRole("button", { name: "Issue actions" }),
);
await userEvent.click(
await screen.findByRole("menuitem", { name: "Send 2 Findings to Jira" }),
);
view.unmount();
// Then: the globally hosted modal survives the panel closing.
expect(useJiraDispatchStore.getState().activePayload).toMatchObject({
findingTitle: attributes.title,
selection: {
kind: "target-list",
targetType: "finding_id",
targetIds: ["finding-1", "finding-2"],
},
});
});
});
+401 -126
View File
@@ -1,16 +1,38 @@
"use client";
import Link from "next/link";
import { ArrowLeft, Box, Tag } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { JiraDispatchActionItem } from "@/components/findings/jira-dispatch-action-item";
import { MarkdownContainer } from "@/components/findings/markdown-container";
import { FindingDetailDrawer } from "@/components/findings/table";
import { IssueAttackPath } from "@/components/issues/issue-attack-path";
import { IssueCompliance } from "@/components/issues/issue-compliance";
import { IssueFindings } from "@/components/issues/issue-findings";
import { IssueResources } from "@/components/issues/issue-resources";
import { IssueTriage } from "@/components/issues/issue-triage";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import {
BreadcrumbNavigation,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/shadcn";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { CodeSnippet } from "@/components/shadcn/code-snippet";
import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { EntityInfo } from "@/components/shadcn/entities";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import { SeverityBadge } from "@/components/shadcn/table/severity-badge";
import { DetailSidePanel } from "@/components/side-panel/detail-side-panel";
import { useFindingDetails } from "@/hooks/use-finding-details";
import {
LEG_LABELS,
LEG_QUESTIONS,
@@ -18,6 +40,9 @@ import {
legBadgeVariant,
VERDICTS,
} from "@/lib/issues/verdicts";
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
import {
ATTACKER_START_LABEL,
ISSUE_OUTCOME_LABEL,
@@ -25,149 +50,399 @@ import {
LegName,
} from "@/types/issues";
import { useIssueEvidence } from "./use-issue-evidence";
const ISSUE_PANEL_TAB = {
EXPLOITABILITY: "exploitability",
ATTACK_PATH: "attack-path",
FINDINGS: "findings",
RESOURCES: "resources",
REMEDIATION: "remediation",
TRIAGE: "triage",
} as const;
type IssuePanelTab = (typeof ISSUE_PANEL_TAB)[keyof typeof ISSUE_PANEL_TAB];
const ISSUE_PANEL_TABS: { id: IssuePanelTab; label: string }[] = [
{ id: ISSUE_PANEL_TAB.EXPLOITABILITY, label: "Exploitability" },
{ id: ISSUE_PANEL_TAB.ATTACK_PATH, label: "Attack path" },
{ id: ISSUE_PANEL_TAB.FINDINGS, label: "Findings" },
{ id: ISSUE_PANEL_TAB.RESOURCES, label: "Resources" },
{ id: ISSUE_PANEL_TAB.REMEDIATION, label: "Remediation" },
{ id: ISSUE_PANEL_TAB.TRIAGE, label: "Triage" },
];
interface IssuePanelProps {
issue: IssueResource;
/** The provider's account uid, which the issue itself does not carry. */
providerUid?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* The verdict and the reasoning behind it, from the list row alone (no
* fetch). Graph, resources, findings and compliance stay on the issue page.
*/
export const IssuePanel = ({ issue, open, onOpenChange }: IssuePanelProps) => {
/** Everything about one issue, including drilling into its findings. */
export const IssuePanel = ({
issue,
providerUid,
open,
onOpenChange,
}: IssuePanelProps) => (
<DetailSidePanel
open={open}
onOpenChange={onOpenChange}
title={issue.attributes.title}
description="The issue's verdict, attack path, evidence, fix and triage"
>
{/* Mounted only while open, so the evidence loads when someone looks. */}
<IssuePanelContent issue={issue} providerUid={providerUid} />
</DetailSidePanel>
);
const IssuePanelContent = ({
issue,
providerUid,
}: Pick<IssuePanelProps, "issue" | "providerUid">) => {
const router = useRouter();
const { attributes } = issue;
const verdict = VERDICTS.find((item) => item.id === attributes.verdict);
const scanId = issue.relationships?.scan?.data?.id;
const providerId = issue.relationships?.provider?.data?.id;
const {
state: evidence,
retry,
refresh,
} = useIssueEvidence(issue.id, scanId, providerId);
// Controlled so closing a finding returns to the tab it was opened from.
const [tab, setTab] = useState<IssuePanelTab>(ISSUE_PANEL_TAB.EXPLOITABILITY);
const [openFindingId, setOpenFindingId] = useState<string | null>(null);
const finding = useFindingDetails();
const activeScanId = evidence.status === "loaded" ? evidence.scanId : scanId;
const scanFindingsHref =
activeScanId &&
`/findings?${new URLSearchParams({ "filter[scan__in]": activeScanId, "filter[status__in]": "FAIL" })}`;
const loadedFindings =
evidence.status === "loaded" ? evidence.findings : undefined;
// The findings request returns one row per finding and resource.
const uniqueFindings = Array.from(
new Map(loadedFindings?.map((item) => [item.id, item])).values(),
);
// shortcut: the ticket groups the loaded preview (ISSUE_FINDINGS_LIMIT
// findings), not every finding of a larger issue; page through finding_uids
// if tickets for big issues need all of them.
const jiraPayload = createJiraDispatchPayload({
findingTitle: attributes.title,
targetIds: uniqueFindings.map((item) => item.id),
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
description: `Send ${uniqueFindings.length} of ${attributes.findings_count} findings on this attack path.`,
});
const openFinding = (findingId: string) => {
setOpenFindingId(findingId);
void finding.navigateToFinding(findingId);
};
const closeFinding = () => {
setOpenFindingId(null);
finding.resetFindingDetails();
// The finding may have been triaged or muted while open.
refresh();
};
// A finding opens in place, like a resource's findings do: a second detail
// view would take the side panel over and close this issue.
if (openFindingId) {
return (
<div className="flex h-full min-h-0 flex-col gap-4">
<BreadcrumbNavigation
mode="custom"
customItems={[
{
name: "Issue details",
icon: <ArrowLeft />,
isClickable: true,
onClick: closeFinding,
},
{
name: finding.findingDetailLoading
? "Loading..."
: (finding.findingDetails?.attributes?.check_metadata
?.checktitle ?? "Finding Detail"),
isLast: true,
},
]}
/>
{finding.findingDetailLoading ? (
<LoadingState label="Loading finding details..." />
) : finding.findingDetails ? (
// The detail scrolls inside itself, so it needs a bounded height.
<div className="min-h-0 flex-1">
<FindingDetailDrawer
key={finding.findingDetails.id}
finding={finding.findingDetails}
inline
/>
</div>
) : (
<IssuesLoadError
message="Could not load this finding."
onRetry={() => openFinding(openFindingId)}
/>
)}
</div>
);
}
const evidencePending =
evidence.status === "loading" ? (
<p role="status" className="text-text-neutral-tertiary text-sm">
Loading issue details…
</p>
) : null;
return (
<DetailSidePanel
open={open}
onOpenChange={onOpenChange}
title={attributes.title}
description="Why this issue got its verdict and how to fix it"
>
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
<header className="flex flex-col gap-2">
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
<header className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<Badge variant={verdict?.variant ?? "tag"}>
{verdict?.label ?? attributes.verdict}
</Badge>
<SeverityBadge severity={attributes.severity} />
</div>
<h3 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
{attributes.title}
</h3>
<EntityInfo
cloudProvider={attributes.cloud}
entityAlias={attributes.account}
/>
<p className="text-text-neutral-tertiary text-xs break-all">
{attributes.findings_count.toLocaleString()}{" "}
{attributes.findings_count === 1 ? "finding" : "findings"} ·{" "}
{attributes.pattern}
</p>
<Button asChild variant="link" size="link-sm" className="self-start">
<Link href={`/issues/${issue.id}`}>Open the full path</Link>
</Button>
</header>
<Tabs
defaultValue="exploitability"
className="flex min-h-0 w-full flex-1 flex-col"
{/* Same actions menu as the resource and finding panels. Jira joins
once the findings it sends have loaded. */}
<div className="shrink-0">
{scanFindingsHref || jiraPayload ? (
<ActionDropdown variant="bordered" ariaLabel="Issue actions">
{scanFindingsHref && (
<ActionDropdownItem
icon={<Tag className="size-5" />}
label="Open this scan's failed findings"
onSelect={() => router.push(scanFindingsHref)}
/>
)}
<JiraDispatchActionItem
label={buildJiraActionLabel({
findingCount: uniqueFindings.length,
})}
payload={jiraPayload}
/>
</ActionDropdown>
) : (
evidence.status === "loading" && (
<Skeleton className="size-8 rounded-md" />
)
)}
</div>
</div>
<h3 className="text-text-neutral-primary line-clamp-2 text-lg leading-tight font-medium">
{attributes.title}
</h3>
{/* Under the title, like the finding panel's frameworks. */}
{evidence.status === "loading" ? (
<div aria-hidden="true" className="flex flex-col gap-1.5">
<Skeleton className="h-4 w-28 rounded" />
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="size-7 rounded-md" />
<Skeleton className="size-7 rounded-md" />
<Skeleton className="size-7 rounded-md" />
</div>
</div>
) : (
loadedFindings &&
loadedFindings.length > 0 && (
<IssueCompliance
findings={loadedFindings}
scanId={activeScanId}
truncated={
evidence.status === "loaded" && evidence.findingsTruncated
}
/>
)
)}
{/* The finding panel's Provider / Service / Region row. */}
<div
data-responsive-container
className="@container mt-2 flex min-w-0 flex-col"
>
<div className="mb-4 shrink-0">
<TabsList>
<TabsTrigger value="exploitability">Exploitability</TabsTrigger>
<TabsTrigger value="attack-path">Attack path</TabsTrigger>
<TabsTrigger value="remediation">Remediation</TabsTrigger>
<TabsTrigger value="triage">Triage</TabsTrigger>
</TabsList>
</div>
<div className="minimal-scrollbar min-h-0 flex-1 overflow-y-auto">
<TabsContent value="exploitability" className="flex flex-col gap-4">
{attributes.attacker_start && attributes.outcome && (
<div className="grid grid-cols-2 gap-3">
<InfoField label="Starts from">
{ATTACKER_START_LABEL[attributes.attacker_start]}
</InfoField>
<InfoField label="Leads to">
{ISSUE_OUTCOME_LABEL[attributes.outcome]}
</InfoField>
</div>
)}
<ul
aria-label="Exploitability assessment"
className="divide-border-neutral-secondary flex flex-col divide-y"
<div className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-[minmax(0,1fr)_minmax(0,0.55fr)_minmax(0,1fr)] @md:gap-x-8">
<div className="col-span-2 flex min-w-0 flex-col gap-1 @md:col-span-1">
<span className="text-text-neutral-secondary text-[10px] whitespace-nowrap">
Provider
</span>
<EntityInfo
cloudProvider={attributes.cloud}
nameIcon={<Box className="size-4" />}
entityAlias={attributes.account}
entityId={providerUid}
/>
</div>
<InfoField label="Findings" variant="compact" className="min-w-0">
<span className="block truncate whitespace-nowrap">
{attributes.findings_count.toLocaleString()}
</span>
</InfoField>
<InfoField label="Pattern" variant="compact" className="min-w-0">
<span
className="block truncate whitespace-nowrap"
title={attributes.pattern}
>
{(Object.keys(LEG_LABELS) as LegName[]).map((name) => {
const leg = attributes.legs[name];
if (!leg) return null;
return (
<li key={name} className="flex items-start gap-3 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium">
{LEG_LABELS[name]}
</span>
<span className="text-text-neutral-tertiary text-xs">
{LEG_QUESTIONS[name]}
</span>
<span className="text-text-neutral-secondary text-sm">
{leg.text}
</span>
</div>
<Badge variant={legBadgeVariant(name, leg.state)}>
{LEG_STATE_LABELS[leg.state]}
</Badge>
</li>
);
})}
</ul>
</TabsContent>
{/* Mounted only while selected, so the graph's requests run when
someone asks for it. */}
<TabsContent value="attack-path">
<IssueAttackPath
id={issue.id}
issue={attributes}
scanId={scanId}
providerId={providerId}
/>
</TabsContent>
<TabsContent value="remediation" className="flex flex-col gap-2">
<p className="text-text-neutral-secondary text-sm">
{attributes.fix}
</p>
{scanId && (
<Button
asChild
variant="link"
size="link-sm"
className="self-start"
>
<Link
href={`/findings?${new URLSearchParams({ "filter[scan__in]": scanId, "filter[status__in]": "FAIL" })}`}
>
Open this scan&apos;s failed findings
</Link>
</Button>
)}
</TabsContent>
<TabsContent value="triage">
<IssueTriage
key={`${issue.id}-${attributes.updated_at}`}
id={issue.id}
status={attributes.triage_status}
/>
</TabsContent>
{attributes.pattern}
</span>
</InfoField>
</div>
</Tabs>
</div>
</DetailSidePanel>
</div>
</header>
<Tabs
value={tab}
onValueChange={(value) => setTab(value as IssuePanelTab)}
className="flex min-h-0 w-full flex-1 flex-col"
>
<div className="mb-4 shrink-0">
<TabsList>
{ISSUE_PANEL_TABS.map(({ id, label }) => (
// Six tabs truncate on a phone; the tooltip names them.
<TabsTrigger key={id} value={id} tooltip={label}>
{label}
</TabsTrigger>
))}
</TabsList>
</div>
<div className="minimal-scrollbar min-h-0 flex-1 overflow-y-auto">
<TabsContent
value={ISSUE_PANEL_TAB.EXPLOITABILITY}
className="flex flex-col gap-4"
>
{attributes.attacker_start && attributes.outcome && (
<div className="grid grid-cols-2 gap-3">
{/* Inline, not an InfoTooltip: its trigger has no tab stop. */}
<InfoField label="Starts from">
{ATTACKER_START_LABEL[attributes.attacker_start]}
<p className="text-text-neutral-tertiary text-xs">
Where the attacker has to be standing. Whether they also
need a credential is the Access leg.
</p>
</InfoField>
<InfoField label="Leads to">
{ISSUE_OUTCOME_LABEL[attributes.outcome]}
</InfoField>
</div>
)}
<ul
aria-label="Exploitability assessment"
className="divide-border-neutral-secondary flex flex-col divide-y"
>
{(Object.keys(LEG_LABELS) as LegName[]).map((name) => {
const leg = attributes.legs[name];
if (!leg) return null;
return (
<li key={name} className="flex items-start gap-3 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium">
{LEG_LABELS[name]}
</span>
<span className="text-text-neutral-tertiary text-xs">
{LEG_QUESTIONS[name]}
</span>
<span className="text-text-neutral-secondary text-sm">
{leg.text}
</span>
{leg.evidence.length > 0 && (
<ul
aria-label={`${LEG_LABELS[name]} evidence`}
className="mt-1 flex flex-col gap-1"
>
{leg.evidence.map((line) => (
<li key={line}>
<CodeSnippet
value={line}
multiline
hideCopyButton
transparent
/>
</li>
))}
</ul>
)}
</div>
<Badge variant={legBadgeVariant(name, leg.state)}>
{LEG_STATE_LABELS[leg.state]}
</Badge>
</li>
);
})}
</ul>
</TabsContent>
<TabsContent value={ISSUE_PANEL_TAB.ATTACK_PATH}>
<IssueAttackPath
issue={attributes}
evidence={evidence}
onRetry={retry}
onOpenFinding={openFinding}
/>
</TabsContent>
<TabsContent value={ISSUE_PANEL_TAB.FINDINGS}>
{evidencePending ??
(evidence.status === "error" || loadedFindings === undefined ? (
<IssuesLoadError
message="Could not load the findings for this issue."
onRetry={retry}
/>
) : uniqueFindings.length === 0 ? (
<p className="text-text-neutral-tertiary text-sm">
No findings to show for this issue.
</p>
) : (
<IssueFindings
findings={uniqueFindings}
total={attributes.findings_count}
onOpenFinding={openFinding}
/>
))}
</TabsContent>
<TabsContent value={ISSUE_PANEL_TAB.RESOURCES}>
{evidencePending ??
(evidence.status === "error" ? (
<IssuesLoadError
message="Could not load the resources for this issue."
onRetry={retry}
/>
) : (
evidence.status === "loaded" && (
<IssueResources
resources={evidence.resources}
providerId={evidence.providerId}
/>
)
))}
</TabsContent>
{/* Laid out like the finding panel's Remediation tab. */}
<TabsContent value={ISSUE_PANEL_TAB.REMEDIATION}>
<div className="flex flex-col gap-1 px-1">
<span className="text-text-neutral-primary text-sm font-semibold">
Remediation:
</span>
<div className="text-text-neutral-primary text-sm">
<MarkdownContainer>{attributes.fix}</MarkdownContainer>
</div>
</div>
</TabsContent>
<TabsContent value={ISSUE_PANEL_TAB.TRIAGE}>
<IssueTriage
key={`${issue.id}-${attributes.updated_at}`}
id={issue.id}
status={attributes.triage_status}
/>
</TabsContent>
</div>
</Tabs>
</div>
);
};
+108
View File
@@ -0,0 +1,108 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/shadcn/button/button";
import { CodeSnippet } from "@/components/shadcn/code-snippet";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/shadcn/table/table";
const RESOURCE_PAGE_SIZE = 12;
// The issue carries resource uids, not ids, so this opens the Resources list
// on that one resource rather than its drawer.
const resourceHref = (uid: string, providerId?: string) =>
`/resources?${new URLSearchParams({
"filter[uid]": uid,
// A uid is unique within an account, not across them.
...(providerId && { "filter[provider_id__in]": providerId }),
})}`;
interface IssueResourcesProps {
resources: string[];
providerId?: string;
}
/** The resources the issue folds, a page at a time, as a table. */
export const IssueResources = ({
resources,
providerId,
}: IssueResourcesProps) => {
const [visible, setVisible] = useState(RESOURCE_PAGE_SIZE);
if (resources.length === 0) {
return (
<p className="text-text-neutral-tertiary text-sm">
This issue has no affected resources.
</p>
);
}
return (
<section aria-label="Affected resources" className="flex flex-col gap-2">
<div className="flex items-center justify-end">
<span className="text-text-neutral-tertiary text-sm">
{resources.length.toLocaleString()} Total Entries
</span>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<span className="text-text-neutral-secondary text-sm font-medium">
Resource
</span>
</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{resources.slice(0, visible).map((uid) => (
<TableRow key={uid}>
<TableCell className="max-w-0">
<CodeSnippet value={uid} ariaLabel={`Copy ${uid}`} />
</TableCell>
<TableCell className="text-right whitespace-nowrap">
<Button asChild variant="link" size="link-sm">
<Link
href={resourceHref(uid, providerId)}
aria-label={`Open in Resources: ${uid}`}
>
Open in Resources
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex flex-wrap items-center gap-3">
{visible < resources.length && (
<Button
variant="link"
size="link-sm"
onClick={() => setVisible((value) => value + RESOURCE_PAGE_SIZE)}
>
Show more resources
</Button>
)}
{visible > RESOURCE_PAGE_SIZE && (
<Button
variant="link"
size="link-sm"
onClick={() => setVisible(RESOURCE_PAGE_SIZE)}
>
Show fewer resources
</Button>
)}
</div>
</section>
);
};
+45 -43
View File
@@ -23,18 +23,13 @@ vi.mock("@/actions/issues", () => ({
getIssueFindings: async () => [],
getIssueNotes: async () => [],
}));
vi.mock("@/actions/findings", () => ({ getFindingById: vi.fn() }));
vi.mock("@/components/findings/table", () => ({
FindingDetailDrawer: () => null,
}));
vi.mock("@/components/issues/issue-graph", () => ({
IssueGraph: ({
resourceUids,
readOnly,
}: {
resourceUids: string[];
readOnly?: boolean;
}) => (
<p>
Graph of {resourceUids.join(", ")}
{readOnly ? " (read-only)" : ""}
</p>
IssueGraph: ({ resourceUids }: { resourceUids: string[] }) => (
<p>Graph of {resourceUids.join(", ")}</p>
),
}));
// The real panel portals into the app shell's side panel, absent here.
@@ -135,47 +130,54 @@ describe("IssuesTable", () => {
it("opens the issue in the side panel and closes it", async () => {
// Given
const user = userEvent.setup();
render(<IssuesTable issues={[issue]} />);
// When
await user.click(screen.getByRole("button", { name: "Public bucket" }));
// Then: the assessment tab leads, the full page stays one click away
const panel = screen.getByRole("region", { name: "Public bucket" });
expect(
within(panel).getByRole("tab", { name: "Exploitability" }),
).toHaveAttribute("aria-selected", "true");
expect(within(panel).getByText("Block Public Access off")).toBeVisible();
expect(
within(panel).getByRole("link", { name: "Open the full path" }),
).toHaveAttribute("href", "/issues/issue-1");
// When
await user.click(within(panel).getByRole("tab", { name: "Remediation" }));
// Then
expect(within(panel).getByText("Restrict access")).toBeVisible();
expect(
within(panel).getByRole("link", {
name: "Open this scan's failed findings",
}),
).toHaveAttribute(
"href",
"/findings?filter%5Bscan__in%5D=scan-1&filter%5Bstatus__in%5D=FAIL",
);
// When
getIssue.mockResolvedValue({
data: {
...issue,
attributes: { ...issue.attributes, resource_uids: ["bucket"] },
},
});
render(<IssuesTable issues={[issue]} />);
// When
await user.click(screen.getByRole("button", { name: "Public bucket" }));
// Then: the assessment tab leads, and nothing sends the user elsewhere
const panel = screen.getByRole("region", { name: "Public bucket" });
expect(
within(panel).getByRole("tab", { name: "Exploitability" }),
).toHaveAttribute("aria-selected", "true");
expect(within(panel).getByText("Block Public Access off")).toBeVisible();
expect(
within(panel).queryByRole("link", { name: "Open the full path" }),
).not.toBeInTheDocument();
// When
await user.click(within(panel).getByRole("tab", { name: "Remediation" }));
// Then
expect(within(panel).getByText("Restrict access")).toBeVisible();
// When: the scan's failed findings sit in the actions menu
await user.click(
within(panel).getByRole("button", { name: "Issue actions" }),
);
await user.click(
await screen.findByRole("menuitem", {
name: "Open this scan's failed findings",
}),
);
// Then
expect(push).toHaveBeenLastCalledWith(
"/findings?filter%5Bscan__in%5D=scan-1&filter%5Bstatus__in%5D=FAIL",
);
// When
await user.click(within(panel).getByRole("tab", { name: "Attack path" }));
// Then: the graph loads on demand and leaves finding drill-down to the page
// Then
expect(
await within(panel).findByText("Graph of bucket (read-only)"),
await within(panel).findByText("Graph of bucket"),
).toBeInTheDocument();
expect(getIssue).toHaveBeenCalledWith("issue-1");
+6
View File
@@ -169,9 +169,12 @@ const columns: ColumnDef<IssueRow>[] = [
export const IssuesTable = ({
issues,
metadata,
providerUids,
}: {
issues: IssueRow[];
metadata?: MetaDataProps;
/** Provider uid by provider id, shown in the panel's Provider field. */
providerUids?: Record<string, string>;
}) => {
const [openId, setOpenId] = useState<string | null>(null);
const openIssue = issues.find((issue) => issue.id === openId);
@@ -190,6 +193,9 @@ export const IssuesTable = ({
<IssuePanel
key={openIssue.id}
issue={openIssue}
providerUid={
providerUids?.[openIssue.relationships?.provider?.data?.id ?? ""]
}
open
onOpenChange={(open) => {
if (!open) setOpenId(null);
+93 -113
View File
@@ -4,18 +4,10 @@ import { page } from "vitest/browser";
import { worker } from "@/__tests__/msw/worker";
import { render } from "@/__tests__/render-browser";
import {
getFindingGroups,
getFindingGroupResources,
} from "@/actions/finding-groups";
import { getScan } from "@/actions/scans";
import { ExploitabilityChart } from "@/app/(prowler)/_overview/exploitability/_components/exploitability-chart";
import { TopIssues } from "@/app/(prowler)/_overview/exploitability/_components/top-issues";
import { IssuesList } from "@/app/(prowler)/issues/_components/issues-list";
import { IssueDetail } from "@/components/issues/issue-detail";
import { GlobalSidePanel } from "@/components/side-panel/global-side-panel";
import { applyDefaultMutedFilter, extractFiltersAndQuery } from "@/lib";
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
import { IssueResource, IssuesOverviewAttributes } from "@/types/issues";
const resource = `arn:aws:s3:::production-customer-data-${"long-resource-name-".repeat(8)}`;
@@ -153,84 +145,57 @@ describe("Issues responsive layout", () => {
await page.viewport(1280, 800);
});
it("opens the selected finding's group and muted resource through the destination actions", async () => {
// Given: the destination returns the finding only for its supported filters.
it("opens a folded finding inside the panel and comes back to the issue", async () => {
// Given: the list next to the real side panel host the app shell mounts.
setup();
worker.use(
http.get("*/scans/scan-1", () =>
http.get("*/findings/f1", () =>
HttpResponse.json({
data: {
id: "scan-1",
type: "scans",
type: "findings",
id: "f1",
attributes: {
inserted_at: "2026-10-20T10:00:00Z",
completed_at: "2026-10-20T11:00:00Z",
uid: "finding-uid-1",
check_id: "s3_public",
status: "FAIL",
severity: "critical",
check_metadata: { checktitle: "S3 bucket is public" },
},
relationships: { resources: { data: [] } },
},
}),
),
http.get("*/finding-groups", ({ request }) => {
const params = new URL(request.url).searchParams;
const matches =
params.get("filter[check_id__in]") === "s3_public" &&
params.get("filter[resource_uid]") === resource &&
params.get("filter[scan__in]") === "scan-1" &&
params.get("filter[inserted_at]") === "2026-10-20" &&
params.get("filter[muted]") === "include";
return HttpResponse.json({
data: matches ? [{ id: "s3_public", type: "finding-groups" }] : [],
});
}),
http.get("*/finding-groups/s3_public/resources", ({ request }) => {
const params = new URL(request.url).searchParams;
const matches =
params.get("filter[resource_uid]") === resource &&
params.get("filter[muted]") === "include";
return HttpResponse.json({
data: matches
? [{ id: "r1", attributes: { finding_id: "f1", muted: true } }]
: [],
});
}),
);
const content = await IssuesList({ searchParams: {} });
const screen = await render(
<IssueDetail
id={issue.id}
issue={issue.attributes}
scanId="scan-1"
providerId="provider-1"
/>,
<>
{content}
<GlobalSidePanel />
</>,
);
const link = screen.getByRole("link", { name: "S3 bucket is public" });
await expect.element(link).toBeVisible();
await screen
.getByRole("row", { name: /Public bucket exposes customer data/ })
.getByText("Production")
.click();
await screen.getByRole("tab", { name: "Findings" }).click();
// When: resolve the same filters used by the Findings page and drill-down.
const destination = new URL(
link.element().getAttribute("href")!,
window.location.origin,
);
const { filters } = extractFiltersAndQuery(
Object.fromEntries(destination.searchParams),
);
const resolvedFilters = applyDefaultMutedFilter(
await resolveFindingScanDateFilters({
filters,
scans: [],
loadScan: async (id) => (await getScan(id))?.data,
}),
);
const groups = await getFindingGroups({ filters: resolvedFilters });
// When
await screen.getByRole("button", { name: "S3 bucket is public" }).click();
// Then: the finding takes the panel without closing the issue
const back = screen.getByRole("button", { name: "Issue details" });
await expect.element(back).toBeVisible();
await expect
.element(screen.getByRole("tab", { name: "Exploitability" }))
.not.toBeInTheDocument();
// When
await back.click();
// Then
expect(groups.data).toEqual([{ id: "s3_public", type: "finding-groups" }]);
expect(destination.searchParams.get("expandedCheckId")).toBe("s3_public");
const resources = await getFindingGroupResources({
checkId: "s3_public",
filters: resolvedFilters,
});
expect(resources.data).toEqual([
{ id: "r1", attributes: { finding_id: "f1", muted: true } },
]);
await expect
.element(screen.getByRole("tab", { name: "Findings" }))
.toHaveAttribute("aria-selected", "true");
});
it("opens a clicked row in the app's Details side panel", async () => {
@@ -251,9 +216,6 @@ describe("Issues responsive layout", () => {
.click();
// Then
await expect
.element(screen.getByRole("link", { name: "Open the full path" }))
.toHaveAttribute("href", "/issues/issue-1");
await expect
.element(screen.getByText("Block Public Access is disabled."))
.toBeVisible();
@@ -289,6 +251,15 @@ describe("Issues responsive layout", () => {
http.get("*/issues", () =>
HttpResponse.json({ data: [issue, secondIssue] }),
),
// The panel loads the evidence of whichever issue it opens.
http.get("*/issues/issue-2", () =>
HttpResponse.json({
data: {
...secondIssue,
attributes: { ...secondIssue.attributes, resource_uids: [] },
},
}),
),
);
const content = await IssuesList({ searchParams: {} });
const screen = await render(
@@ -314,8 +285,13 @@ describe("Issues responsive layout", () => {
// Then: the panel remounted on the new issue, back on its first tab
await expect
.element(screen.getByRole("link", { name: "Open the full path" }))
.toHaveAttribute("href", "/issues/issue-2");
.element(
screen.getByRole("heading", {
level: 3,
name: "Admin role assumable from any account",
}),
)
.toBeVisible();
await expect
.element(screen.getByRole("tab", { name: "Exploitability" }))
.toHaveAttribute("aria-selected", "true");
@@ -328,7 +304,7 @@ describe("Issues responsive layout", () => {
{ width: 375, theme: "dark" },
{ width: 1440, theme: "dark" },
])(
"keeps the list and the issue detail usable at $width px in $theme mode",
"keeps the list and the issue panel usable at $width px in $theme mode",
async ({ width, theme }) => {
await page.viewport(width, 1000);
localStorage.setItem("theme", theme);
@@ -337,16 +313,13 @@ describe("Issues responsive layout", () => {
searchParams: { "filter[pattern]": "aws_public_bucket" },
});
const screen = await render(
<main className="flex min-w-0 flex-col gap-6 p-4">
<h1 className="text-2xl font-semibold">Issues</h1>
{content}
<IssueDetail
id={issue.id}
issue={issue.attributes}
scanId="scan-1"
providerId="provider-1"
/>
</main>,
<>
<main className="flex min-w-0 flex-col gap-6 p-4">
<h1 className="text-2xl font-semibold">Issues</h1>
{content}
</main>
<GlobalSidePanel />
</>,
);
await expect
.element(screen.getByRole("button", { name: "Remove Pattern filter" }))
@@ -356,25 +329,46 @@ describe("Issues responsive layout", () => {
screen.getByText("The internet → Data exposure · aws_public_bucket"),
)
.toBeVisible();
await expect
.element(screen.getByRole("region", { name: "Affected resources" }))
.toBeVisible();
// When: the row opens in the panel
await screen
.getByRole("row", { name: /Public bucket exposes customer data/ })
.getByRole("button", { name: "Public bucket exposes customer data" })
.click();
// Then: every tab fits the viewport, long identifiers wrap
await expect
.element(screen.getByText("Partly", { exact: true }))
.toBeVisible();
await expect.element(screen.getByText("Public by design.")).toBeVisible();
const fits = (element: Element) =>
element.getBoundingClientRect().right <= width;
for (const snippet of screen
.getByText(resource, { exact: true })
.elements()) {
expect(snippet.scrollWidth).toBeLessThanOrEqual(
snippet.clientWidth + 1,
);
}
await screen.getByRole("tab", { name: "Findings" }).click();
await expect
.element(
screen.getByRole("img", { name: "Critical finding risk icon" }),
)
.element(screen.getByRole("button", { name: "S3 bucket is public" }))
.toBeVisible();
await screen.getByRole("tab", { name: "Resources" }).click();
const resources = screen.getByRole("region", {
name: "Affected resources",
});
await expect.element(resources).toBeVisible();
expect(fits(resources.element())).toBe(true);
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(width);
const detailBounds = screen
.getByRole("region", { name: "Affected resources" })
.element()
.getBoundingClientRect();
expect(detailBounds.right).toBeLessThanOrEqual(width);
await page.screenshot();
await screen.getByRole("tab", { name: "Triage" }).click();
await expect.element(screen.getByText("Public by design.")).toBeVisible();
if (width < 640) {
await screen.getByRole("tab", { name: "Attack path" }).click();
await expect
.poll(() => {
const node = screen
@@ -386,20 +380,6 @@ describe("Issues responsive layout", () => {
})
.toBe(true);
}
// Long resource identifiers and evidence must wrap within the detail.
for (const snippet of screen
.getByText(resource, { exact: true })
.elements()) {
expect(snippet.scrollWidth).toBeLessThanOrEqual(
snippet.clientWidth + 1,
);
}
await page.viewport(
width,
Math.max(1000, document.documentElement.scrollHeight),
);
await screen.getByRole("main").screenshot();
},
);
+1 -1
View File
@@ -4,9 +4,9 @@ import type { IssueFinding } from "@/types/issues";
export interface IssueFindingsProps {
/** One entry per finding, with a resource to open in the group drill-down. */
findings: IssueFinding[];
scanId?: string;
/** All the findings the issue folds; the list shows a bounded preview. */
total: number;
onOpenFinding: (findingId: string) => void;
}
export interface IssueTriageProps {
@@ -34,12 +34,18 @@ export const useIssueEvidence = (
useEffect(() => {
let cancelled = false;
// A failed background refresh keeps what is already shown; only a first
// load or a retry (which resets to loading) surfaces the error.
const fail = () =>
setState((previous) =>
previous.status === "loaded" ? previous : { status: "error" },
);
getIssue(id)
.then(async (detail) => {
if (cancelled) return;
const attributes = detail?.data?.attributes;
if (!attributes?.resource_uids) {
setState({ status: "error" });
fail();
return;
}
const detailScanId =
@@ -52,18 +58,30 @@ export const useIssueEvidence = (
: findingUids.length === 0
? []
: undefined;
if (!cancelled)
setState({
if (cancelled) return;
setState((previous) => {
// A refresh that loses the findings keeps the ones already shown,
// as long as they belong to the same scan; resources and provider
// always take the fresh answer.
const keepFindings =
findings === undefined &&
previous.status === "loaded" &&
previous.findings !== undefined &&
previous.scanId === detailScanId;
return {
status: "loaded",
resources: attributes.resource_uids,
findings,
findingsTruncated: new Set(findingUids).size > ISSUE_FINDINGS_LIMIT,
findings: keepFindings ? previous.findings : findings,
findingsTruncated: keepFindings
? previous.findingsTruncated
: new Set(findingUids).size > ISSUE_FINDINGS_LIMIT,
scanId: detailScanId,
providerId: detailProviderId,
});
};
});
})
.catch(() => {
if (!cancelled) setState({ status: "error" });
if (!cancelled) fail();
});
return () => {
cancelled = true;
@@ -75,5 +93,8 @@ export const useIssueEvidence = (
setAttempt((value) => value + 1);
};
return { state, retry };
/** Reloads in the background: what is shown stays until the answer lands. */
const refresh = () => setAttempt((value) => value + 1);
return { state, retry, refresh };
};
@@ -158,9 +158,8 @@ describe("getNavigationConfig", () => {
// Then
expect(sections[0]?.items.map((item) => item.label)).toEqual(["Overview"]);
expect(
items.find((item) => item.label === "Exploitability issues"),
).toMatchObject({
expect(sections[1]?.items[0]).toMatchObject({
label: "Exploitability issues",
href: "/issues",
});
expect(configuration.children).not.toEqual(
@@ -149,6 +149,19 @@ export function getNavigationConfig({
{
label: "SECURITY",
items: [
// First: the exploitable issues are what to fix before anything else.
...(isCloudEnvironment && issuesEnabled
? [
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/issues",
label: "Exploitability issues",
icon: Crosshair,
active: isRouteActive(pathname, "/issues"),
highlight: true,
} as const,
]
: []),
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/compliance",
@@ -163,18 +176,6 @@ export function getNavigationConfig({
icon: Tag,
active: isRouteActive(pathname, "/findings"),
},
...(isCloudEnvironment && issuesEnabled
? [
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/issues",
label: "Exploitability issues",
icon: Crosshair,
active: isRouteActive(pathname, "/issues"),
highlight: true,
} as const,
]
: []),
{
kind: NAVIGATION_ITEM_KIND.LINK,
href: "/attack-paths",
@@ -156,13 +156,19 @@ export function BreadcrumbNavigation({
}
return (
<div className={cn(className, "w-fit md:w-full")}>
<div className={cn(className, "w-fit max-w-full md:w-full")}>
<nav aria-label="Breadcrumb">
<ol className="flex flex-wrap items-center">
{/* From sm up, one line: earlier crumbs keep their width and the
last one truncates. Below sm crumbs wrap, so a long trail never
pushes the header controls off a phone screen. */}
<ol className="flex min-w-0 flex-wrap items-center sm:flex-nowrap">
{breadcrumbItems.map((breadcrumb, index) => (
<li
key={breadcrumb.path || index}
className="flex items-center"
className={cn(
"flex items-center",
index === breadcrumbItems.length - 1 ? "min-w-0" : "shrink-0",
)}
aria-current={
index === breadcrumbItems.length - 1 ? "page" : undefined
}
@@ -198,13 +204,16 @@ export function BreadcrumbNavigation({
</span>
</button>
) : (
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
{index === 0 && breadcrumb.icon ? (
<BreadcrumbIcon className="text-text-neutral-tertiary">
{breadcrumb.icon}
</BreadcrumbIcon>
) : null}
<span className="max-w-[150px] truncate text-sm font-medium text-gray-900 sm:max-w-none dark:text-gray-100">
<span
title={breadcrumb.name}
className="max-w-[150px] truncate text-sm font-medium text-gray-900 sm:max-w-none dark:text-gray-100"
>
{breadcrumb.name}
</span>
</div>
+74
View File
@@ -0,0 +1,74 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getFindingById } = vi.hoisted(() => ({ getFindingById: vi.fn() }));
vi.mock("@/actions/findings", () => ({ getFindingById }));
// The real helper sits behind the server-only `@/lib` barrel.
vi.mock("@/lib/finding-detail", () => ({
expandFindingWithRelationships: (response?: { data?: unknown }) =>
response?.data ?? null,
}));
import { useFindingDetails } from "./use-finding-details";
const deferred = () => {
let resolve!: (value: unknown) => void;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
};
describe("useFindingDetails", () => {
beforeEach(() => {
getFindingById.mockReset();
});
it("keeps a slow earlier lookup from overwriting a newer one", async () => {
// Given: lookup A is still pending when lookup B starts
const first = deferred();
const second = deferred();
getFindingById
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const { result } = renderHook(() => useFindingDetails());
act(() => {
void result.current.navigateToFinding("a");
});
act(() => {
void result.current.navigateToFinding("b");
});
// When: A answers after B started
await act(async () => first.resolve({ data: { id: "a" } }));
// Then: B is still loading and A is dropped
expect(result.current.findingDetails).toBeNull();
expect(result.current.findingDetailLoading).toBe(true);
// When
await act(async () => second.resolve({ data: { id: "b" } }));
// Then
expect(result.current.findingDetails).toEqual({ id: "b" });
expect(result.current.findingDetailLoading).toBe(false);
});
it("drops a lookup that answers after a reset", async () => {
// Given
const pending = deferred();
getFindingById.mockReturnValueOnce(pending.promise);
const { result } = renderHook(() => useFindingDetails());
act(() => {
void result.current.navigateToFinding("a");
});
// When: the finding closes, then its lookup answers
act(() => result.current.resetFindingDetails());
await act(async () => pending.resolve({ data: { id: "a" } }));
// Then
expect(result.current.findingDetails).toBeNull();
expect(result.current.findingDetailLoading).toBe(false);
});
});
+7 -3
View File
@@ -30,7 +30,10 @@ export function useFindingDetails(): UseFindingDetailsReturn {
if (findingFetchRef.current) {
findingFetchRef.current.abort();
}
findingFetchRef.current = new AbortController();
// Each lookup checks its own controller: the ref already points at a
// newer lookup by the time a slow one answers.
const controller = new AbortController();
findingFetchRef.current = controller;
setFindingDetailLoading(true);
try {
@@ -39,7 +42,7 @@ export function useFindingDetails(): UseFindingDetailsReturn {
"resources,scan.provider",
);
if (findingFetchRef.current?.signal.aborted) {
if (controller.signal.aborted) {
return;
}
@@ -52,13 +55,14 @@ export function useFindingDetails(): UseFindingDetailsReturn {
}
console.error("Error fetching finding:", error);
} finally {
if (!findingFetchRef.current?.signal.aborted) {
if (!controller.signal.aborted) {
setFindingDetailLoading(false);
}
}
};
const resetFindingDetails = () => {
findingFetchRef.current?.abort();
setFindingDetails(null);
setFindingDetailLoading(false);
};