mirror of
https://github.com/usestrix/strix.git
synced 2026-08-25 20:32:38 +02:00
266 lines
12 KiB
TypeScript
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]">·</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">
|
|
“{vulnerability.status_note}”
|
|
</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">
|
|
“{vulnerability.severity_override_reason}”
|
|
</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>
|
|
);
|
|
}
|