Files
strix/strix/viewer/frontend/src/components/vulnerability/VulnerabilityDetail.tsx
T

266 lines
12 KiB
TypeScript

"use client";
import React, { useState } from "react";
import { Clock, CheckCircle2, Ban, History, BellOff, Wrench, GitMerge } from "lucide-react";
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
import { Vulnerability, VulnerabilityStatus, SEVERITY_COLORS, STATUS_META, isSeverityOverridden } from "@/types/issues";
import { formatTimeAgo } from "@/lib/utils";
import { getSeverityDot } from "@/lib/vulnerability-utils";
import { formatStrixId } from "@/lib/display-number";
import { ContentSection } from "@/components/vulnerability/ContentSection";
import { CodeDiffBlock } from "@/components/vulnerability/CodeDiffBlock";
import { PocBlock } from "@/components/vulnerability/PocBlock";
import { IssueSidebar } from "@/components/vulnerability/IssueSidebar";
function bannerTime(dateString: string | null): string {
if (!dateString) return "";
const diffInSeconds = Math.floor((Date.now() - new Date(dateString).getTime()) / 1000);
if (diffInSeconds < 604800) return ` ${formatTimeAgo(dateString)}`;
return ` on ${formatTimeAgo(dateString)}`;
}
const STATUS_BANNER: Record<VulnerabilityStatus, { icon: React.ElementType; label: string; iconColor: string } | null> = {
open: null,
in_progress: { icon: Clock, label: "Marked as In Progress", iconColor: "text-blue-400" },
snoozed: { icon: BellOff, label: "Snoozed", iconColor: "text-purple-400" },
fixed: { icon: CheckCircle2, label: "Marked as Fixed", iconColor: "text-emerald-400" },
ignored: { icon: Ban, label: "Marked as Ignored", iconColor: "text-[#888]" },
};
type BottomTab = "fix" | "reproduction";
// Team-workflow actions shown top-right of the finding header; each links out
// to sign-up. `requiresCode` actions only appear when the finding has concrete
// code locations to act on -- an autofix PR makes no sense for a black-box
// finding with no code to change.
const WORKFLOW_CTAS: { label: string; slug: string; icon: React.ElementType; requiresCode?: boolean }[] = [
{ label: "Auto-fix & open a PR", slug: "autofix", icon: Wrench, requiresCode: true },
{ label: "Sync to Jira / Linear", slug: "integrations", icon: GitMerge },
];
interface VulnerabilityDetailProps {
vulnerability: Vulnerability;
}
/**
* Self-contained finding detail (header + status banners + content grid),
* without page chrome. Shared by the public /share/issues page and the local
* /results view so both render findings identically.
*/
export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDetailProps) {
const currentMeta = STATUS_META[vulnerability.status];
const hasCodeLocations = vulnerability.code_locations && vulnerability.code_locations.length > 0;
const hasFix = hasCodeLocations || vulnerability.remediation_steps;
const hasReproduction = !!(vulnerability.evidence || vulnerability.assumptions || vulnerability.poc_description || vulnerability.poc_script_code);
const [activeTab, setActiveTab] = useState<BottomTab>("fix");
const bottomTabs: { id: BottomTab; label: string; show: boolean }[] = [
{ id: "fix", label: "Fix", show: !!hasFix },
{ id: "reproduction", label: "Reproduction", show: hasReproduction },
];
const visibleTabs = bottomTabs.filter((t) => t.show);
return (
<div className="space-y-6">
{/* Header: title/badges on the left, workflow actions top-right. */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="mb-2">
{vulnerability.display_number && (
<span className="text-xs font-mono text-[#555] block mb-1">
{formatStrixId(vulnerability.display_number)}
</span>
)}
<h1 className="text-2xl font-semibold text-white">{vulnerability.title}</h1>
</div>
<div className="flex flex-wrap items-center gap-3">
<span className={`inline-flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded-full border ${currentMeta.color}`}>
{currentMeta.label}
</span>
<div
className={`inline-flex items-center gap-1.5 px-3 py-1 text-sm font-semibold rounded-full border ${SEVERITY_COLORS[vulnerability.severity]}`}
title={isSeverityOverridden(vulnerability) ? `Adjusted from ${vulnerability.original_severity}` : undefined}
>
<div className={`w-2 h-2 rounded-full ${getSeverityDot(vulnerability.severity)}`} />
<span className="capitalize">
{vulnerability.severity}
{!isSeverityOverridden(vulnerability) && vulnerability.cvss ? ` ${vulnerability.cvss}` : ""}
</span>
{isSeverityOverridden(vulnerability) && (
<History className="w-3 h-3 opacity-70" aria-hidden="true" />
)}
</div>
{vulnerability.cve && (
<>
<span className="text-[#333]">&middot;</span>
<span className="text-sm text-[#666] font-mono">{vulnerability.cve}</span>
</>
)}
</div>
</div>
<div className="flex flex-shrink-0 flex-wrap items-center gap-2">
{WORKFLOW_CTAS.filter((cta) => !cta.requiresCode || hasCodeLocations).map((cta) => {
const Icon = cta.icon;
return (
<a
key={cta.slug}
href={ctaUrl(SIGNUP_URL, cta.slug)}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta(cta.slug, "finding_detail")}
className="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity hover:opacity-90"
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
{cta.label}
</a>
);
})}
</div>
</div>
{/* Status banner */}
{vulnerability.status !== "open" && (() => {
const banner = STATUS_BANNER[vulnerability.status];
if (!banner) return null;
const BannerIcon = banner.icon;
return (
<div className="rounded-lg px-4 py-3.5 flex gap-3" style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
<BannerIcon className={`w-5 h-5 flex-shrink-0 mt-0.5 ${banner.iconColor}`} aria-hidden="true" />
<div className="min-w-0">
<p className="text-sm font-semibold text-white">
{banner.label}{bannerTime(vulnerability.status_changed_at)}
</p>
{vulnerability.status_note && (
<p className="text-sm text-[#666] italic mt-1">
&ldquo;{vulnerability.status_note}&rdquo;
</p>
)}
</div>
</div>
);
})()}
{/* Severity override banner */}
{isSeverityOverridden(vulnerability) && (
<div className="rounded-lg px-4 py-3.5 flex gap-3" style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
<History className="w-5 h-5 flex-shrink-0 mt-0.5 text-orange-400" aria-hidden="true" />
<div className="min-w-0">
<p className="text-sm font-semibold text-white">
Severity changed manually from{" "}
<span className="capitalize">{vulnerability.original_severity}</span>
{vulnerability.cvss != null ? ` (${vulnerability.cvss})` : ""} to{" "}
<span className="capitalize">{vulnerability.severity}</span>
{bannerTime(vulnerability.severity_changed_at)}
</p>
{vulnerability.severity_override_reason && (
<p className="text-sm text-[#666] italic mt-1">
&ldquo;{vulnerability.severity_override_reason}&rdquo;
</p>
)}
</div>
</div>
)}
{/* Content grid */}
<div className="grid grid-cols-1 lg:grid-cols-[1fr_340px] gap-8">
{/* Main content */}
<div className="min-w-0">
<div className="space-y-8">
<ContentSection title="TL;DR" content={vulnerability.description} />
{vulnerability.impact && <ContentSection title="Impact" content={vulnerability.impact} />}
{vulnerability.technical_analysis && (
<ContentSection title="Technical Details" content={vulnerability.technical_analysis} />
)}
</div>
{/* Bottom tabs */}
{visibleTabs.length > 0 && (
<div className="mt-10">
<div className="border-b border-[#2a2a2a]">
<nav className="flex gap-6" aria-label="Tabs">
{visibleTabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`relative min-w-[80px] text-center pb-3 text-[16px] font-semibold transition-colors ${
activeTab === tab.id
? "text-white"
: "text-[#666] hover:text-white"
}`}
aria-current={activeTab === tab.id ? "page" : undefined}
>
{tab.label}
{activeTab === tab.id && (
<span className="absolute bottom-0 inset-x-0 h-0.5 bg-white rounded-full" />
)}
</button>
))}
</nav>
</div>
{/* Fix tab */}
{hasFix && (
<div className={`pt-6 space-y-6 ${activeTab === "fix" ? "animate-tab-in" : "hidden"}`}>
{vulnerability.remediation_steps && (
<ContentSection title="How do I fix it?" content={vulnerability.remediation_steps} />
)}
{hasCodeLocations &&
vulnerability.code_locations!
.filter((loc) => loc.fix_before && loc.fix_after)
.map((loc, i) => (
<CodeDiffBlock
key={`fix-${i}`}
file={loc.file}
startLine={loc.start_line}
endLine={loc.end_line}
before={loc.fix_before!}
after={loc.fix_after!}
/>
))}
</div>
)}
{/* Reproduction tab */}
{hasReproduction && (
<div className={`pt-6 space-y-8 ${activeTab === "reproduction" ? "animate-tab-in" : "hidden"}`}>
{vulnerability.assumptions && (
<ContentSection title="Assumptions" content={vulnerability.assumptions} />
)}
{vulnerability.evidence && (
<ContentSection title="Evidence" content={vulnerability.evidence} />
)}
<PocBlock
description={vulnerability.poc_description}
scriptCode={vulnerability.poc_script_code}
/>
</div>
)}
</div>
)}
</div>
{/* Sidebar */}
<div className="lg:border-l lg:border-[#2a2a2a] lg:pl-6">
<IssueSidebar
vulnerability={vulnerability}
statusSlot={
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full border ${currentMeta.color}`}>
<div className={`w-1.5 h-1.5 rounded-full ${currentMeta.dotColor}`} />
{currentMeta.label}
</span>
}
/>
</div>
</div>
</div>
);
}