mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-02 01:15:31 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67aa941012 | ||
|
|
f64e5e10ad | ||
|
|
008864797b |
No files matched your search
+1
-1
@@ -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
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ?? []}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 })}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user