mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-08 12:24:45 +00:00
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { Send } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { useEffect, useState } from "react";
|
|
|
|
import { getIssue, getIssueFindings } from "@/actions/issues";
|
|
import { IssueCompliance } from "@/components/issues/issue-compliance";
|
|
import { IssueFindings } from "@/components/issues/issue-findings";
|
|
import { IssueGraph } from "@/components/issues/issue-graph";
|
|
import { IssueTriage } from "@/components/issues/issue-triage";
|
|
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 { riskFactors } from "@/lib/issues/risk-factors";
|
|
import { 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_FINDINGS_LIMIT,
|
|
ISSUE_OUTCOME_LABEL,
|
|
IssueAttributes,
|
|
IssueFinding,
|
|
LegName,
|
|
LegState,
|
|
} from "@/types/issues";
|
|
|
|
const LEG_QUESTIONS: Record<LegName, string> = {
|
|
reachability: "Can an attacker reach it?",
|
|
access: "Can an attacker authenticate or bypass authentication?",
|
|
target: "Is there a target worth reaching?",
|
|
controls: "Are protective controls in place?",
|
|
verification: "Would the activity be detected?",
|
|
};
|
|
const LEG_LABELS: Record<LegName, string> = {
|
|
reachability: "Reachability",
|
|
access: "Access",
|
|
target: "Target",
|
|
controls: "Controls",
|
|
verification: "Verification",
|
|
};
|
|
const STATE_LABELS: Record<LegState, string> = {
|
|
yes: "Yes",
|
|
no: "No",
|
|
partial: "Partly",
|
|
unknown: "Unknown",
|
|
};
|
|
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;
|
|
}
|
|
|
|
type ResourcesState =
|
|
| { status: "loading" }
|
|
| { status: "error" }
|
|
| {
|
|
status: "loaded";
|
|
resources: string[];
|
|
findings: IssueFinding[] | undefined;
|
|
/** `getIssueFindings` capped the preview at ISSUE_FINDINGS_LIMIT. */
|
|
findingsTruncated: boolean;
|
|
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 [attempt, setAttempt] = useState(0);
|
|
// Resource UIDs are only included by the detail endpoint, fetched on expansion.
|
|
const [state, setState] = useState<ResourcesState>({ status: "loading" });
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
getIssue(id)
|
|
.then(async (detail) => {
|
|
if (cancelled) return;
|
|
const attributes = detail?.data?.attributes;
|
|
if (!attributes?.resource_uids) {
|
|
setState({ status: "error" });
|
|
return;
|
|
}
|
|
const detailScanId =
|
|
detail?.data.relationships?.scan?.data?.id ?? scanId;
|
|
const detailProviderId =
|
|
detail?.data.relationships?.provider?.data?.id ?? providerId;
|
|
const findingUids = attributes.finding_uids ?? [];
|
|
const findings = detailScanId
|
|
? await getIssueFindings(detailScanId, findingUids)
|
|
: findingUids.length === 0
|
|
? []
|
|
: undefined;
|
|
if (!cancelled)
|
|
setState({
|
|
status: "loaded",
|
|
resources: attributes.resource_uids,
|
|
findings,
|
|
findingsTruncated: new Set(findingUids).size > ISSUE_FINDINGS_LIMIT,
|
|
scanId: detailScanId,
|
|
providerId: detailProviderId,
|
|
});
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setState({ status: "error" });
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [id, scanId, providerId, attempt]);
|
|
|
|
const retry = () => {
|
|
setState({ status: "loading" });
|
|
setAttempt((value) => value + 1);
|
|
};
|
|
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)}>
|
|
{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>
|
|
);
|
|
};
|