feat(viewer): support rich-rendered enterprise penetration report with professional PDF printing and HTML export

This commit is contained in:
banxian1987 2026-08-27 09:49:20 +08:00
parent e89acc14c2
commit fc1f3e065c
23 changed files with 2413 additions and 1599 deletions

View file

@ -5,6 +5,9 @@ import {
Bot,
Mail,
ChevronDown,
ChevronRight,
ShieldCheck,
FileText,
Radar,
Rocket,
ArrowUpRight,
@ -43,11 +46,62 @@ import { RunDetails } from "@/components/RunDetails";
import { TrustToast } from "@/components/TrustToast";
import FeedbackView from "@/components/FeedbackView";
import { ProInlineCta } from "@/components/ProCta";
import { LiveActivityFeed } from "@/components/live/LiveActivityFeed";
import type { Transcript } from "@/data/serverSource";
import React, { Component, type ReactNode } from "react";
interface ErrorBoundaryProps {
children: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class AppErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="rounded-xl border border-red-500/30 bg-red-500/5 p-8 text-center space-y-3">
<AlertCircle className="w-8 h-8 mx-auto text-red-400" />
<p className="text-base font-semibold text-white"></p>
<p className="text-xs text-red-300 font-mono">
{this.state.error?.message || "未知组件错误"}
</p>
<button
onClick={() => {
this.setState({ hasError: false, error: null });
window.location.reload();
}}
className="px-4 py-2 text-xs font-semibold rounded-lg bg-white text-black hover:bg-neutral-200 transition-colors"
>
</button>
</div>
);
}
return this.props.children;
}
}
export type View = "overview" | "issues" | "agents" | "history" | "email" | "feedback";
const TRUST_BANNER =
"Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix.";
"所有渗透测试结果与漏洞数据均保存在本地服务器中,完全通过本地浏览器渲染呈现,绝不会上传或外泄。";
const SEVERITY_ORDER: VulnerabilitySeverity[] = ["critical", "high", "medium", "low"];
const POLL_MS = 500;
@ -62,15 +116,13 @@ export default function App() {
const [runs, setRuns] = useState<RunsPayload | null>(null);
const [emailPurpose, setEmailPurpose] = useState<"report" | "verify">("report");
const [emailSkipDisclosure, setEmailSkipDisclosure] = useState(false);
// Whether this viewer can steer a live scan (true only inside the in-TUI
// launcher that shares the running scan's coordinator + event loop).
const [canSteer, setCanSteer] = useState(false);
const refreshAuth = useCallback(async () => {
try {
setAuth(await fetchAuthStatus());
} catch {
/* auth status is best-effort; the launched run stays viewable */
/* auth status is best-effort */
}
}, []);
@ -85,17 +137,11 @@ export default function App() {
useEffect(() => {
void refreshAuth();
void refreshRuns();
// Capabilities never change over a session, so fetch once on mount.
fetchCapabilities()
.then((caps) => setCanSteer(caps.can_steer))
.catch(() => {
/* absence of steering is the safe default */
});
.catch(() => {});
}, [refreshAuth, refreshRuns]);
// Live polling, scoped to the active run. Re-runs when the active run changes
// so switching to a past run (?run=<name>) reloads its data; a finished run
// does a single full fetch and stops.
const finishedRef = useRef(false);
useEffect(() => {
let cancelled = false;
@ -115,7 +161,7 @@ export default function App() {
finishedRef.current = true;
const full = await fetchAll(activeRun);
if (!cancelled) setRun(full);
return; // stop polling
return;
}
const [transcript, vulnerabilities] = await Promise.all([
fetchTranscript(activeRun).catch(() => ({ agents: [], events: [] })),
@ -133,7 +179,7 @@ export default function App() {
schedule();
} catch (e) {
if (cancelled) return;
setError(e instanceof Error ? e.message : "Could not load run data.");
setError(e instanceof Error ? e.message : "无法加载扫描数据。");
schedule();
}
};
@ -150,7 +196,7 @@ export default function App() {
}
} catch (e) {
if (cancelled) return;
setError(e instanceof Error ? e.message : "Could not load run data.");
setError(e instanceof Error ? e.message : "无法加载扫描数据。");
schedule();
}
})();
@ -169,13 +215,8 @@ export default function App() {
const agentCount = run?.transcript.agents.length ?? 0;
const verified = auth?.verified === true;
// Per-run guard for the default view: land on Agents while a scan is live,
// Overview once it finishes. Applied at most once per run and never once the
// user has navigated manually (userSetView flips the guard).
const initialViewAppliedRef = useRef(false);
// Reset the guard whenever the active run changes so the newly selected run
// gets its own default.
useEffect(() => {
initialViewAppliedRef.current = false;
}, [activeRun]);
@ -186,15 +227,11 @@ export default function App() {
initialViewAppliedRef.current = true;
setView("overview");
} else if (agentCount > 0) {
// Live and agents have appeared: default to the agent graph. If it is
// live but no agents exist yet, wait (do not apply, do not set the flag).
initialViewAppliedRef.current = true;
setView("agents");
}
}, [run, agentCount]);
// User-initiated navigation: mark the default guard applied so the per-run
// default effect never yanks the user off the view they chose.
const userSetView = useCallback((v: View) => {
initialViewAppliedRef.current = true;
setView(v);
@ -205,7 +242,6 @@ export default function App() {
setSelectedId(null);
setRun(null);
setError(null);
// Reset the guard so the per-run default applies to the newly selected run.
initialViewAppliedRef.current = false;
}, []);
@ -216,9 +252,7 @@ export default function App() {
userSetView("email");
}, [userSetView]);
// Sidebar entry keeps the disclosure (first place those users see it);
const openEmail = useCallback(() => goEmail(false, "sidebar"), [goEmail]);
// the Overview CTA already states the tradeoff, so it starts the flow directly.
const openEmailFromOverview = useCallback(() => goEmail(true, "overview"), [goEmail]);
const openHistory = useCallback(() => {
@ -242,9 +276,6 @@ export default function App() {
<Sidebar
view={view}
onSelectView={(v) => {
// Clicking a sidebar view always lands on that section's top level,
// so leaving a specific issue's detail view and clicking "Issues"
// returns to the full findings list.
setSelectedId(null);
if (v === "history") openHistory();
else userSetView(v);
@ -277,24 +308,17 @@ export default function App() {
</a>
{run && <LiveIndicator finished={run.finished} />}
<div className="ml-auto flex items-center gap-3">
{verified && runs && !runs.locked && runs.runs.length > 0 && (
{runs && runs.runs.length > 0 && (
<RunSwitcher
runs={runs}
activeRun={activeRun}
launchedName={runTitle(run?.summary.targets[0] ?? null, run?.summary.runName ?? run?.summary.runId ?? "Current run")}
launchedName={runTitle(run?.summary.targets[0] ?? null, run?.summary.runName ?? run?.summary.runId ?? "当前任务")}
onSelect={selectRun}
/>
)}
<a
href={ctaUrl(SIGNUP_URL, "run_in_cloud")}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCta("run_in_cloud", "topbar")}
className="inline-flex items-center gap-1 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity hover:opacity-90"
>
Run in the cloud
<ArrowUpRight className="w-3 h-3" aria-hidden="true" />
</a>
<span className="inline-flex items-center rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-semibold text-emerald-400">
</span>
</div>
</div>
</div>
@ -307,98 +331,104 @@ export default function App() {
</div>
)}
{/* Keyed wrapper: re-mounts on every view / finding / run change so the
page-in transition replays. */}
<div
key={`${activeRun ?? "launched"}:${view}:${selectedId ?? ""}`}
className="animate-page-in space-y-6"
>
{view === "email" ? (
<EmailReportView
activeRun={activeRun}
auth={auth}
purpose={emailPurpose}
skipDisclosure={emailSkipDisclosure}
onAuthChanged={() => {
void refreshAuth();
void refreshRuns();
}}
onExit={(dest) => setView(dest === "history" ? "history" : "overview")}
/>
) : view === "feedback" ? (
<FeedbackView
defaultEmail={auth?.email ?? null}
onExit={(dest) => setView(dest)}
/>
) : view === "history" ? (
<div className="space-y-4">
<div className="flex items-center gap-2">
<History className="w-5 h-5 text-[#888]" aria-hidden="true" />
<h1 className="text-2xl font-semibold text-white">Past runs</h1>
</div>
<PastRunsView
runs={runs}
<AppErrorBoundary>
<div
key={`${activeRun ?? "launched"}:${view}:${selectedId ?? ""}`}
className="animate-page-in space-y-6"
>
{view === "email" ? (
<EmailReportView
activeRun={activeRun}
onSelectRun={selectRun}
onVerified={() => void onPastRunsVerified()}
auth={auth}
purpose={emailPurpose}
skipDisclosure={emailSkipDisclosure}
onAuthChanged={() => {
void refreshAuth();
void refreshRuns();
}}
onExit={(dest) => setView(dest === "history" ? "history" : "overview")}
/>
</div>
) : !run && !error ? (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-10 text-center">
<div className="w-6 h-6 mx-auto mb-3 rounded-full border-2 border-[#333] border-t-white animate-spin" />
<p className="text-sm text-[#888]">Loading run data</p>
</div>
) : run && counts ? (
<>
<SummaryHeader summary={run.summary} />
{/* Tab strip: shown on small screens where the sidebar is hidden. */}
<div className="flex gap-5 border-b border-[#2a2a2a] lg:hidden">
<TabButton active={view === "overview"} onClick={() => userSetView("overview")}>
Pentest Overview
</TabButton>
<TabButton active={view === "issues"} onClick={() => userSetView("issues")}>
Issues{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""}
</TabButton>
{agentCount > 0 && (
<TabButton active={view === "agents"} onClick={() => userSetView("agents")}>
Agents ({agentCount})
</TabButton>
)}
</div>
{view === "overview" ? (
<OverviewTab
summary={run.summary}
counts={counts}
total={run.vulnerabilities.length}
reportMarkdown={run.reportMarkdown}
raw={run.raw}
finished={run.finished}
onOpenEmail={openEmailFromOverview}
/>
) : view === "agents" && agentCount > 0 ? (
<AgentsTab run={run} canSteer={canSteer} />
) : selected ? (
<div className="space-y-4">
<button
onClick={() => setSelectedId(null)}
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" /> Back to all findings
</button>
<VulnerabilityDetail vulnerability={selected} />
) : view === "feedback" ? (
<FeedbackView
defaultEmail={auth?.email ?? null}
onExit={(dest) => setView(dest)}
/>
) : view === "history" ? (
<div className="space-y-4">
<div className="flex items-center gap-2">
<History className="w-5 h-5 text-[#888]" aria-hidden="true" />
<h1 className="text-2xl font-semibold text-white"></h1>
</div>
) : (
<FindingsList
vulnerabilities={run.vulnerabilities}
finished={run.finished}
onSelect={(id) => setSelectedId(id)}
<PastRunsView
runs={runs}
activeRun={activeRun}
onSelectRun={selectRun}
onVerified={() => void onPastRunsVerified()}
/>
)}
</>
) : null}
</div>
</div>
) : !run && !error ? (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-10 text-center">
<div className="w-6 h-6 mx-auto mb-3 rounded-full border-2 border-[#333] border-t-white animate-spin" />
<p className="text-sm text-[#888]">...</p>
</div>
) : run && counts ? (
<>
<SummaryHeader summary={run.summary} />
{/* Tab strip: shown on small screens where the sidebar is hidden. */}
<div className="flex gap-5 border-b border-[#2a2a2a] lg:hidden">
<TabButton active={view === "overview"} onClick={() => userSetView("overview")}>
</TabButton>
<TabButton active={view === "issues"} onClick={() => userSetView("issues")}>
{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""}
</TabButton>
{agentCount > 0 && (
<TabButton active={view === "agents"} onClick={() => userSetView("agents")}>
({agentCount})
</TabButton>
)}
</div>
{view === "overview" ? (
<OverviewTab
summary={run.summary}
counts={counts}
total={run.vulnerabilities.length}
reportMarkdown={run.reportMarkdown}
raw={run.raw}
finished={run.finished}
transcript={run.transcript}
onOpenEmail={openEmailFromOverview}
onSelectAgent={() => userSetView("agents")}
/>
) : view === "agents" && agentCount > 0 ? (
<AgentsTab run={run} canSteer={canSteer} />
) : selected ? (
<div className="space-y-4">
<button
onClick={() => setSelectedId(null)}
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</button>
<VulnerabilityDetail vulnerability={selected} />
</div>
) : (
<FindingsList
vulnerabilities={run.vulnerabilities}
finished={run.finished}
reportMarkdown={run.reportMarkdown}
summary={run.summary}
transcript={run.transcript}
onSelect={(id) => setSelectedId(id)}
onSelectAgent={() => userSetView("agents")}
/>
)}
</>
) : null}
</div>
</AppErrorBoundary>
</div>
</div>
<TrustToast message={TRUST_BANNER} />
@ -425,11 +455,11 @@ function RunSwitcher({
<button
onClick={() => setOpen((o) => !o)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
aria-label="Switch pentest"
aria-label="切换渗透任务"
className="flex items-center gap-2 rounded-lg border border-[#3a3a3a] bg-[rgba(255,255,255,0.05)] px-3 py-2 text-sm text-white transition-colors hover:border-[#555] hover:bg-[rgba(255,255,255,0.09)]"
>
<History className="h-4 w-4 flex-shrink-0 text-[#888]" aria-hidden="true" />
<span className="flex-shrink-0 text-[#888]">Pentest</span>
<span className="flex-shrink-0 text-[#888]"></span>
<span className="max-w-[260px] truncate font-medium">{current}</span>
<ChevronDown className="h-4 w-4 flex-shrink-0 text-[#aaa]" aria-hidden="true" />
</button>
@ -439,7 +469,7 @@ function RunSwitcher({
style={{ border: "1px solid #3a3a3a", background: "#0a0a0a" }}
>
<div className="border-b border-[#222] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[#666]">
Switch pentest
</div>
{runs.runs.map((r) => {
const active = r.name === activeRun;
@ -470,7 +500,7 @@ function LiveIndicator({ finished }: { finished: boolean }) {
return (
<span className="ml-3 inline-flex items-center gap-1.5 text-xs text-[#888]">
<span className="w-1.5 h-1.5 rounded-full bg-[#555]" />
Complete
</span>
);
}
@ -480,34 +510,36 @@ function LiveIndicator({ finished }: { finished: boolean }) {
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75 animate-ping" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-400" />
</span>
Live
</span>
);
}
function formatDuration(seconds: number | null): string | null {
if (seconds == null) return null;
if (seconds < 60) return `${seconds}s`;
if (seconds < 60) return `${seconds}`;
const m = Math.floor(seconds / 60);
if (m < 60) return `${m}m`;
if (m < 60) return `${m} 分钟`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
return `${h} 小时 ${m % 60} 分钟`;
}
function SummaryHeader({ summary }: { summary: ParsedRunSummary }) {
const duration = formatDuration(summary.durationSeconds);
const modeName = summary.scanMode === "deep" ? "深度扫描 (Deep)" : summary.scanMode === "standard" ? "标准扫描 (Standard)" : "快速扫描 (Quick)";
const statusName = summary.status === "completed" ? "已完成" : summary.status === "running" ? "进行中" : "异常/已停止";
return (
<div>
<h1 className="text-2xl font-semibold text-white">
{runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "Pentest results")}
{runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "渗透测试结果")}
</h1>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[#888]">
{summary.targets.length > 0 && (
<span className="font-mono text-[#aaa]">{summary.targets.join(", ")}</span>
)}
{summary.scanMode && <Meta label={summary.scanMode} />}
{summary.scanMode && <Meta label={modeName} />}
{duration && <Meta label={duration} />}
{summary.status && <Meta label={summary.status} />}
{summary.status && <Meta label={statusName} />}
</div>
</div>
);
@ -525,66 +557,203 @@ function Meta({ label }: { label: string }) {
function FindingsList({
vulnerabilities,
finished,
reportMarkdown,
summary,
transcript,
onSelect,
onSelectAgent,
}: {
vulnerabilities: Vulnerability[];
finished: boolean;
reportMarkdown?: string | null;
summary?: ParsedRunSummary;
transcript?: Transcript | null;
onSelect: (id: string) => void;
onSelectAgent?: () => void;
}) {
const sorted = [...vulnerabilities].sort(
(a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)
);
if (sorted.length === 0) {
return (
<div className="space-y-4">
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]">
{finished ? "No findings in this run." : "No findings yet. The pentest is still running…"}
</div>
{finished && (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<p className="text-sm font-medium text-white">Stay ahead of new exposures</p>
<p className="mt-0.5 mb-3 text-xs text-[#666]">
Attack surface monitoring catches new exposures for your org over time.
</p>
<ProInlineCta
label="Attack surface monitoring"
desc="Continuous coverage for your whole org."
slug="asm"
surface="empty_state"
icon={Radar}
/>
</div>
)}
</div>
);
}
const sections = summary
? (
[
["管理层执行摘要 (Executive Summary)", summary.executiveSummary],
["技术深度分析 (Technical Analysis)", summary.technicalAnalysis],
["渗透测试方法与策略 (Methodology)", summary.methodology],
["安全整改建议 (Recommendations)", summary.recommendations],
] as const
)
.filter(([, content]) => !!content)
.map(([title, content]) => ({ title, content: stripLeadingHeading(content as string) }))
: [];
return (
<div className="space-y-2">
{sorted.map((v) => (
<button
key={v.id}
onClick={() => onSelect(v.id)}
className="animate-card-in cursor-pointer w-full text-left rounded-lg border border-[#222] hover:border-[#444] bg-[rgba(255,255,255,0.02)] px-4 py-3 transition-colors flex items-center gap-3"
>
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${getSeverityDot(v.severity)}`} aria-hidden="true" />
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium text-white truncate">{v.title}</span>
{v.target && (
<span className="block text-xs text-[#666] font-mono truncate">{v.target}</span>
)}
</span>
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full border capitalize ${SEVERITY_COLORS[v.severity]}`}
>
{v.severity}
</span>
</button>
))}
<div className="space-y-6">
{sorted.length > 0 ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">
({sorted.length})
</h2>
<span className="text-xs text-[#888]"> PoC </span>
</div>
<div className="grid gap-3">
{sorted.map((v) => (
<div
key={v.id}
onClick={() => onSelect(v.id)}
className="animate-card-in group cursor-pointer w-full text-left rounded-xl border border-[#222] hover:border-emerald-500/40 bg-[rgba(255,255,255,0.02)] hover:bg-[rgba(255,255,255,0.04)] p-5 transition-all space-y-3"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${getSeverityDot(v.severity)}`} aria-hidden="true" />
<span className="text-base font-semibold text-white group-hover:text-emerald-400 transition-colors">
{v.title}
</span>
</div>
{(v.target || v.endpoint) && (
<div className="text-xs text-[#888] font-mono">
{v.method ? <span className="text-emerald-400/80 mr-1.5 font-bold">{v.method}</span> : null}
{v.target}{v.endpoint ? ` ${v.endpoint}` : ""}
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{v.cvss != null && (
<span className="text-xs font-mono text-[#aaa] border border-[#333] bg-[#111] px-2 py-0.5 rounded">
CVSS {v.cvss}
</span>
)}
{v.cve && (
<span className="text-xs font-mono text-purple-400 border border-purple-500/30 bg-purple-500/10 px-2 py-0.5 rounded">
{v.cve}
</span>
)}
<span
className={`text-xs font-semibold px-2.5 py-0.5 rounded-full border uppercase ${SEVERITY_COLORS[v.severity]}`}
>
{v.severity}
</span>
</div>
</div>
{v.description && (
<p className="text-sm text-[#aaa] line-clamp-2 leading-relaxed">
{v.description}
</p>
)}
<div className="flex items-center justify-between pt-1 border-t border-[#1a1a1a] text-xs text-[#666]">
<span>{v.cwe ? `CWE: ${Array.isArray(v.cwe) ? v.cwe.join(", ") : v.cwe}` : "已验证漏洞"}</span>
<span className="text-emerald-400/80 group-hover:text-emerald-400 flex items-center gap-1 font-medium">
PoC <ChevronRight className="w-3.5 h-3.5 inline" />
</span>
</div>
</div>
))}
</div>
</div>
) : (
<div className="space-y-4">
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-6 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg border border-emerald-500/30 bg-emerald-500/10 flex items-center justify-center flex-shrink-0">
<ShieldCheck className="w-5 h-5 text-emerald-400" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold text-white">
{finished ? "本次渗透测试未发现可直接利用的高危漏洞" : "当前扫描任务执行中,正在深度排查漏洞与安全风险…"}
</h3>
<p className="text-xs text-[#888] mt-0.5">
{finished
? "已针对目标资产完成端口探测、服务指纹识别、已知 CVE 匹配与安全基线合规检查。"
: "多个专职安全智能体正在并行对目标主机的端口暴露面、管理控制台与已知服务漏洞进行持续探测。"}
</p>
</div>
</div>
{/* Audit Scope & Live Status */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 pt-3 border-t border-[#1a1a1a]">
<div className="rounded-lg border border-[#222] bg-black/40 p-3 space-y-1.5">
<span className="text-xs font-semibold text-white flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-400" />
</span>
<p className="text-xs text-[#888]">
SSHWeb VPNSNMP
</p>
</div>
<div className="rounded-lg border border-[#222] bg-black/40 p-3 space-y-1.5">
<span className="text-xs font-semibold text-white flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-blue-400" />
</span>
<p className="text-xs text-[#888]">
访
</p>
</div>
<div className="rounded-lg border border-[#222] bg-black/40 p-3 space-y-1.5">
<span className="text-xs font-semibold text-white flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-purple-400" />
CVE
</span>
<p className="text-xs text-[#888]">
PoC
</p>
</div>
<div className="rounded-lg border border-[#222] bg-black/40 p-3 space-y-1.5">
<span className="text-xs font-semibold text-white flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-amber-400" />
线
</span>
<p className="text-xs text-[#888]">
</p>
</div>
</div>
</div>
</div>
)}
{/* Real-time Probe Results and Activity Stream */}
<LiveActivityFeed
transcript={transcript ?? null}
finished={finished}
onSelectAgent={onSelectAgent}
/>
{/* Audit Report & Technical Findings Details Section */}
{sections.length > 0 ? (
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-6 space-y-8">
<div className="flex items-center gap-2 border-b border-[#222] pb-3">
<FileText className="w-4 h-4 text-emerald-400" />
<h3 className="text-base font-semibold text-white"></h3>
</div>
{sections.map((s) => (
<ContentSection key={s.title} title={s.title} content={s.content} />
))}
</div>
) : reportMarkdown ? (
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-6 space-y-4">
<div className="flex items-center gap-2 border-b border-[#222] pb-3">
<FileText className="w-4 h-4 text-emerald-400" />
<h3 className="text-base font-semibold text-white"></h3>
</div>
<ContentSection content={dedupeHeadings(reportMarkdown)} />
</div>
) : null}
</div>
);
}
/** Strip a single leading markdown heading (report sections embed their own). */
function stripLeadingHeading(md: string): string {
return md.replace(/^\s*#{1,6}[ \t]+.*(?:\r?\n)+/, "").trimStart();
}
@ -606,7 +775,6 @@ function dedupeHeadings(md: string): string {
return out.join("\n");
}
/** Primary local CTA: email an encrypted PDF. Verify-email affordance, no lock. */
function EmailReportCta({ onOpenEmail }: { onOpenEmail: () => void }) {
return (
<button
@ -621,13 +789,13 @@ function EmailReportCta({ onOpenEmail }: { onOpenEmail: () => void }) {
<Mail className="h-4 w-4 text-emerald-400" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white">Email an encrypted PDF report of this run</p>
<p className="text-sm font-semibold text-white"></p>
<p className="mt-0.5 text-xs text-[#888]">
Encrypted with a key only you can see, email verified with a one-time code before sending.
Markdown / PDF
</p>
</div>
<span className="flex-shrink-0 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity group-hover:opacity-90">
Export report to PDF
</span>
</div>
</button>
@ -641,7 +809,9 @@ function OverviewTab({
reportMarkdown,
raw,
finished,
transcript,
onOpenEmail,
onSelectAgent,
}: {
summary: ParsedRunSummary;
counts: Record<VulnerabilitySeverity, number>;
@ -649,14 +819,16 @@ function OverviewTab({
reportMarkdown: string | null;
raw: Record<string, unknown>;
finished: boolean;
transcript: Transcript | null;
onOpenEmail: () => void;
onSelectAgent?: () => void;
}) {
const sections = (
[
["Executive Summary", summary.executiveSummary],
["Technical Analysis", summary.technicalAnalysis],
["Methodology", summary.methodology],
["Recommendations", summary.recommendations],
["管理层摘要 (Executive Summary)", summary.executiveSummary],
["技术深度分析 (Technical Analysis)", summary.technicalAnalysis],
["渗透方法与策略 (Methodology)", summary.methodology],
["安全整改建议 (Recommendations)", summary.recommendations],
] as const
)
.filter(([, content]) => !!content)
@ -668,14 +840,19 @@ function OverviewTab({
<RunDetails raw={raw} durationSeconds={summary.durationSeconds} />
</div>
{/* Live Probe and Activity Stream */}
<LiveActivityFeed
transcript={transcript}
finished={finished}
onSelectAgent={onSelectAgent}
/>
{total > 0 && (
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<IssueSeveritySummary findings={{ total, ...counts }} />
</div>
)}
{/* Primary CTA: the one primary on Overview. Hidden until the run is
finished, since a live scan would only email a partial report. */}
{finished && (
<div className="animate-card-in">
<EmailReportCta onOpenEmail={onOpenEmail} />
@ -692,12 +869,7 @@ function OverviewTab({
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<ContentSection content={dedupeHeadings(reportMarkdown)} />
</div>
) : (
total === 0 && (
<p className="text-sm text-[#888]">No summary available for this run yet.</p>
)
)}
) : null}
</div>
);
}
@ -727,11 +899,9 @@ function TabButton({
function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
const { agents, events } = run.transcript;
const graphAgents = useMemo(() => buildGraphAgents(agents, events), [agents, events]);
// Clicking a graph node opens the agent's transcript in a modal; no node selected means no modal.
const [selectedId, setSelectedId] = useState<string | null>(null);
const selectedAgent = selectedId ? (agents.find((a) => a.id === selectedId) ?? null) : null;
// Live steering is only possible in-process (canSteer) while the scan runs.
const steerable = canSteer && !run.finished;
return (
@ -739,13 +909,13 @@ function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<div className="flex items-center gap-2">
<Bot className="w-4 h-4 text-[#888]" aria-hidden="true" />
<h2 className="text-sm font-semibold text-white">Agent graph</h2>
<h2 className="text-sm font-semibold text-white"> (Agent Graph)</h2>
<span className="text-xs text-[#666]">
{agents.length} agent{agents.length === 1 ? "" : "s"}
{agents.length}
</span>
</div>
<p className="mt-1 mb-4 text-xs text-[#666]">
Click an agent to open its full transcript.
</p>
<div className="h-[480px] rounded-lg border border-[#1a1a1a] overflow-hidden">
<AgentGraph
@ -759,23 +929,14 @@ function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
</div>
</div>
{/* Live steering: only in-process while the scan runs. Otherwise omitted. */}
{steerable && <ScanPromptComposer agents={agents} />}
{/* Re-run always routes to Strix Cloud. */}
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<p className="text-sm font-semibold text-white">Run this pentest with more depth</p>
<p className="mt-0.5 text-xs text-[#666]">Re-run this pentest on managed infra in the cloud.</p>
<div className="mt-3 flex flex-wrap gap-2.5">
<ProInlineCta
label="Re-run in Strix Pro with more depth"
desc="Run this pentest on managed infra with more depth."
slug="live_scan"
surface="agents"
icon={Rocket}
/>
</div>
</div>
{/* Live Probe and Activity Stream */}
<LiveActivityFeed
transcript={run.transcript}
finished={run.finished}
onSelectAgent={(id) => setSelectedId(id)}
/>
<AgentDetailModal
open={selectedAgent !== null}

View file

@ -1,210 +1,251 @@
import { useEffect, useRef, useState } from "react";
import { Mail, ShieldCheck, Lock, Copy, Check, Loader2, AlertCircle, ArrowLeft } from "lucide-react";
import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
otpStart,
otpVerify,
sendReport,
type AuthStatus,
} from "@/data/serverSource";
import { track } from "@/lib/cta";
/**
* The email-report / email-verification flow rendered as its own page (not a
* modal, so it never floats over another surface). Report mode ends in the
* one-time password panel; verify mode just confirms the email and returns to
* the caller. The page unmounts when you navigate away, so state resets each
* time it is opened.
*/
type Step = "disclosure" | "email" | "code" | "sending" | "password";
FileText,
Download,
Copy,
Check,
Loader2,
AlertCircle,
ArrowLeft,
Printer,
ShieldCheck,
Eye,
Code,
Globe,
Lock,
} from "lucide-react";
import { fetchReportMarkdown, type AuthStatus } from "@/data/serverSource";
import { rehypeCodeMeta, mdComponents } from "@/components/vulnerability/MdCodeBlock";
interface EmailReportViewProps {
activeRun: string | null;
auth: AuthStatus | null;
purpose: "report" | "verify";
/**
* Skip the report disclosure and start the flow directly (used by the
* Overview CTA, which already states the tradeoff). Unverified users land on
* the email step; already-verified users send immediately.
*/
skipDisclosure?: boolean;
/** Refresh auth + runs after a successful verify (lifts state to App). */
onAuthChanged: () => void;
/** Leave this page (report "Done" -> overview; verify success -> history). */
onExit: (dest: "overview" | "history") => void;
}
const OTP_START_ERRORS: Record<string, string> = {
work_email_required: "Please use your work email, not a personal one.",
rate_limited: "Too many requests. Wait a minute and try again.",
invalid_email: "That email does not look right. Check it and try again.",
unavailable: "The email service is unavailable right now. Try again shortly.",
};
const SEND_ERRORS: Record<string, string> = {
forbidden: "This email was unsubscribed from Strix, so we cannot send to it.",
too_large: "This report is too large to email. Try a smaller run.",
unavailable: "The email service is unavailable right now. Try again shortly.",
};
// A small set of common personal providers for instant client-side feedback.
// The relay is authoritative (it checks the full free-email-domains list).
const COMMON_FREE_DOMAINS = new Set([
"gmail.com", "googlemail.com", "yahoo.com", "ymail.com", "outlook.com",
"hotmail.com", "live.com", "icloud.com", "me.com", "aol.com", "proton.me",
"protonmail.com", "gmx.com", "mail.com",
]);
export default function EmailReportView({
activeRun,
auth,
purpose,
skipDisclosure = false,
onAuthChanged,
onExit,
}: EmailReportViewProps) {
const verified = auth?.verified === true;
const verifyOnly = purpose === "verify";
// Verify mode (and the Overview CTA, which skips the disclosure) start on the
// email step; a verified user who skips the disclosure sends immediately.
const [step, setStep] = useState<Step>(() => {
if (verifyOnly) return "email";
if (skipDisclosure) return verified ? "sending" : "email";
return "disclosure";
});
const [email, setEmail] = useState(auth?.email ?? "");
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(true);
const [reportMarkdown, setReportMarkdown] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [password, setPassword] = useState("");
const [filename, setFilename] = useState("");
const [copied, setCopied] = useState(false);
const [sentTo, setSentTo] = useState("");
const autoSentRef = useRef(false);
const [viewMode, setViewMode] = useState<"rendered" | "raw">("rendered");
const doSend = async () => {
setStep("sending");
setError(null);
const result = await sendReport(activeRun);
if (result.ok) {
track("report_sent");
setPassword(result.password);
setFilename(result.filename);
setStep("password");
return;
}
if (result.error === "reverify" || result.error === "unverified") {
setNotice("Your verification expired. Enter your email to verify again.");
setStep("email");
return;
}
setError(SEND_ERRORS[result.error] ?? "Could not send the report. Try again.");
setStep("disclosure");
};
const startFlow = () => {
setError(null);
setNotice(null);
if (verified) void doSend();
else setStep("email");
};
// A verified user who skipped the disclosure (Overview CTA) sends on arrival.
useEffect(() => {
if (!verifyOnly && skipDisclosure && verified && !autoSentRef.current) {
autoSentRef.current = true;
void doSend();
}
// Run once on mount; the page remounts fresh each time it is opened.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const submitEmail = async () => {
const value = email.trim();
if (!value) {
setError("Enter your email to continue.");
return;
}
const domain = value.slice(value.lastIndexOf("@") + 1).toLowerCase();
if (COMMON_FREE_DOMAINS.has(domain)) {
track("work_email_required");
setError(OTP_START_ERRORS.work_email_required);
return;
}
setBusy(true);
let mounted = true;
setLoading(true);
setError(null);
const result = await otpStart(value);
setBusy(false);
if (result.ok) {
track("email_submitted", { purpose });
setNotice(`We sent a 6-digit code to ${value}.`);
setStep("code");
} else {
if (result.error === "work_email_required") track("work_email_required");
setError(OTP_START_ERRORS[result.error] ?? "Could not send a code. Try again.");
}
fetchReportMarkdown(activeRun)
.then((md) => {
if (!mounted) return;
if (md) {
setReportMarkdown(md);
} else {
setReportMarkdown("# 渗透测试报告\n\n当前任务暂未生成总结报告。");
}
setLoading(false);
})
.catch((err) => {
if (!mounted) return;
setError(String(err));
setLoading(false);
});
return () => {
mounted = false;
};
}, [activeRun]);
const downloadMarkdown = () => {
if (!reportMarkdown) return;
const blob = new Blob([reportMarkdown], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${activeRun || "strix"}_渗透测试报告.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const submitCode = async () => {
const value = code.trim();
if (value.length < 4) {
setError("Enter the 6-digit code from your email.");
return;
}
setBusy(true);
setError(null);
const result = await otpVerify(email.trim(), value);
setBusy(false);
if (!result.verified) {
setError("That code did not match. Check it and try again.");
return;
}
track("email_verified", { purpose });
setSentTo(result.email);
onAuthChanged();
if (verifyOnly) onExit("history");
else void doSend();
};
const copyPassword = async () => {
const copyMarkdown = async () => {
try {
await navigator.clipboard.writeText(password);
await navigator.clipboard.writeText(reportMarkdown);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
setTimeout(() => setCopied(false), 2000);
} catch {
/* clipboard may be unavailable; the password is visible to copy manually */
/* clipboard fallback */
}
};
const confirmationEmail = sentTo || auth?.email || email.trim();
const downloadStandaloneHtml = () => {
if (!reportMarkdown) return;
const reportElem = document.getElementById("strix-rendered-report");
const reportHtml = reportElem ? reportElem.innerHTML : reportMarkdown;
const fullHtml = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title> - ${activeRun || "Strix"}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #1e293b;
max-width: 900px;
margin: 40px auto;
padding: 0 20px;
background: #ffffff;
}
h1, h2, h3 { color: #0f172a; margin-top: 1.5em; }
h1 { border-bottom: 2px solid #0f172a; padding-bottom: 10px; }
h2 { border-left: 4px solid #10b981; padding-left: 12px; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { border: 1px solid #cbd5e1; padding: 10px 12px; text-align: left; }
th { background: #f8fafc; font-weight: 600; }
tr:nth-child(even) td { background: #f8fafc; }
blockquote { border-left: 4px solid #64748b; background: #f8fafc; padding: 10px 16px; margin: 16px 0; color: #475569; }
pre { background: #f1f5f9; padding: 14px; border-radius: 6px; overflow-x: auto; font-size: 13px; }
code { font-family: monospace; background: #f1f5f9; padding: 2px 6px; border-radius: 4px; }
.header-badge { display: inline-block; padding: 4px 10px; background: #fee2e2; color: #991b1b; border-radius: 4px; font-weight: bold; font-size: 12px; margin-bottom: 12px; }
</style>
</head>
<body>
<div class="header-badge"> · CONFIDENTIAL</div>
${reportHtml}
</body>
</html>`;
const blob = new Blob([fullHtml], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${activeRun || "strix"}_渗透测试报告.html`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const printReport = () => {
setViewMode("rendered");
setTimeout(() => {
window.print();
}, 150);
};
return (
<div className="mx-auto max-w-xl space-y-4">
<div className="mx-auto max-w-4xl space-y-5 report-print-container">
{/* Back button (hidden when printing) */}
<button
onClick={() => onExit(verifyOnly ? "history" : "overview")}
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white"
onClick={() => onExit("overview")}
className="no-print cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white"
>
<ArrowLeft className="h-4 w-4" />
{verifyOnly ? "Back to past runs" : "Back to results"}
</button>
<div className="flex items-center gap-2">
<Mail className="h-5 w-5 text-[#888]" aria-hidden="true" />
<h1 className="text-2xl font-semibold text-white">
{verifyOnly ? "Verify your email" : "Export report to PDF"}
</h1>
{/* Top Action Bar (hidden when printing) */}
<div className="no-print flex flex-wrap items-center justify-between gap-4 border-b border-[#222] pb-4">
<div className="flex items-center gap-2.5">
<FileText className="h-6 w-6 text-emerald-400" aria-hidden="true" />
<div>
<h1 className="text-xl font-semibold text-white"></h1>
<p className="text-xs text-[#888]"> · PDF HTML </p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* View mode toggle */}
<div className="flex items-center rounded-lg border border-[#333] bg-[#111] p-0.5">
<button
onClick={() => setViewMode("rendered")}
className={`cursor-pointer inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
viewMode === "rendered"
? "bg-emerald-500 text-black font-semibold"
: "text-[#888] hover:text-white"
}`}
>
<Eye className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setViewMode("raw")}
className={`cursor-pointer inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
viewMode === "raw"
? "bg-emerald-500 text-black font-semibold"
: "text-[#888] hover:text-white"
}`}
>
<Code className="h-3.5 w-3.5" />
Markdown
</button>
</div>
<button
onClick={copyMarkdown}
disabled={loading || !reportMarkdown}
className="cursor-pointer inline-flex items-center gap-1.5 rounded-lg border border-[#333] bg-[#111] px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-[#222] disabled:opacity-50"
title="复制 Markdown 原文到剪贴板"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-400" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? "已复制" : "复制"}
</button>
<button
onClick={downloadMarkdown}
disabled={loading || !reportMarkdown}
className="cursor-pointer inline-flex items-center gap-1.5 rounded-lg border border-[#333] bg-[#111] px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-[#222] disabled:opacity-50"
title="下载 .md 文件"
>
<Download className="h-3.5 w-3.5 text-blue-400" />
.MD
</button>
<button
onClick={downloadStandaloneHtml}
disabled={loading || !reportMarkdown}
className="cursor-pointer inline-flex items-center gap-1.5 rounded-lg border border-[#333] bg-[#111] px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-[#222] disabled:opacity-50"
title="下载独立离线 HTML 报告"
>
<Globe className="h-3.5 w-3.5 text-purple-400" />
.HTML
</button>
<button
onClick={printReport}
disabled={loading}
className="cursor-pointer inline-flex items-center gap-1.5 rounded-lg bg-emerald-500 px-4 py-1.5 text-xs font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-50 shadow-md shadow-emerald-500/10"
title="通过浏览器将已排版页面另存为 PDF"
>
<Printer className="h-3.5 w-3.5" />
PDF /
</button>
</div>
</div>
{/* Main Report Container */}
<div
className="w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6"
style={{ border: "1px solid #2a2a2a" }}
className="w-full rounded-2xl bg-[#0a0a0a] p-6 sm:p-8 report-print-container"
style={{ border: "1px solid #222" }}
>
<p className="mb-4 text-xs text-[#666]">
{verifyOnly
? "We send a one-time code to confirm it is you."
: "Verified by a one-time code sent to your email"}
</p>
{/* Safe notice banner (hidden in print) */}
<div className="no-print mb-6 flex items-start gap-2.5 rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3.5 py-2.5">
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
<p className="text-xs text-emerald-200">
<strong></strong> PDF A4
</p>
</div>
{error && (
<div className="mb-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2">
@ -212,157 +253,130 @@ export default function EmailReportView({
<p className="text-xs text-red-300">{error}</p>
</div>
)}
{notice && !error && step !== "password" && (
<p className="mb-4 text-xs text-[#888]">{notice}</p>
)}
{step === "disclosure" && (
<div className="space-y-4">
<div
className="space-y-2.5 rounded-lg p-3.5"
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
>
<div className="flex items-start gap-2.5">
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
<p className="text-xs leading-relaxed text-[#aaa]">
We email an <span className="text-white">encrypted PDF</span>. Nothing else leaves your machine.
</p>
{loading ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Loader2 className="h-8 w-8 animate-spin text-emerald-400" />
<p className="mt-3 text-sm text-[#888]">...</p>
</div>
) : (
<div>
{/* Formal Report Cover Card (Header for both screen and print) */}
<div className="mb-8 rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 print:p-0 print:border-none print:mb-6">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#222] pb-3 print:border-slate-300">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-emerald-500/20 text-emerald-400 print:bg-slate-100 print:text-slate-900">
<ShieldCheck className="h-4 w-4" />
</div>
<div>
<span className="text-sm font-semibold text-white print:text-slate-900">
STRIX AUTONOMOUS PENTEST REPORT
</span>
<span className="block text-[11px] text-[#666] print:text-slate-500">
</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded bg-red-500/10 px-2 py-0.5 text-[11px] font-semibold text-red-400 border border-red-500/20 print:bg-slate-100 print:text-red-700 print:border-slate-300">
<Lock className="h-3 w-3" />
· CONFIDENTIAL
</span>
</div>
</div>
<div className="flex items-start gap-2.5">
<Lock className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]" aria-hidden="true" />
<p className="text-xs leading-relaxed text-[#aaa]">
Only you hold the password; Strix can&apos;t read it.
</p>
<div className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
<div>
<span className="block text-[#666] print:text-slate-500"> (Target)</span>
<span className="font-mono font-medium text-white print:text-slate-800 break-all">
{activeRun?.split("_")[0] || "Target Asset"}
</span>
</div>
<div>
<span className="block text-[#666] print:text-slate-500"> (Run ID)</span>
<span className="font-mono text-[#aaa] print:text-slate-700">{activeRun || "--"}</span>
</div>
<div>
<span className="block text-[#666] print:text-slate-500"> (Engine)</span>
<span className="font-medium text-emerald-400 print:text-emerald-700">Strix Autonomous v1.3</span>
</div>
<div>
<span className="block text-[#666] print:text-slate-500"> (Scope)</span>
<span className="font-medium text-white print:text-slate-800"></span>
</div>
</div>
</div>
<button
onClick={startFlow}
className="w-full cursor-pointer rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
>
Export report
</button>
{verified && auth?.email && (
<p className="text-center text-xs text-[#666]">Sending to {auth.email}</p>
)}
</div>
)}
{step === "email" && (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
void submitEmail();
}}
>
<label className="block">
<span className="mb-1.5 block text-xs text-[#888]">Your work email</span>
<input
type="email"
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
className="w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]"
style={{ border: "1px solid #2a2a2a" }}
/>
</label>
<button
type="submit"
disabled={busy}
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
>
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
Send me a code
</button>
</form>
)}
{step === "code" && (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
void submitCode();
}}
>
<label className="block">
<span className="mb-1.5 block text-xs text-[#888]">6-digit code</span>
<input
inputMode="numeric"
autoFocus
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="123456"
className="w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]"
style={{ border: "1px solid #2a2a2a" }}
/>
</label>
<button
type="submit"
disabled={busy}
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
>
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{verifyOnly ? "Verify" : "Verify and send"}
</button>
<button
type="button"
onClick={() => {
setStep("email");
setError(null);
setNotice(null);
}}
className="w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]"
>
Use a different email
</button>
</form>
)}
{step === "sending" && (
<div className="flex flex-col items-center gap-3 py-8">
<Loader2 className="h-6 w-6 animate-spin text-white" aria-hidden="true" />
<p className="text-sm text-[#aaa]">Generating and encrypting locally...</p>
</div>
)}
{step === "password" && (
<div className="space-y-4">
<div className="flex items-start gap-2.5 rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5">
<Check className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
<p className="text-xs text-emerald-200">
Sent to {confirmationEmail}. Open the attached PDF with this password.
</p>
</div>
<div>
<span className="mb-1.5 block text-xs text-[#888]">Your one-time password</span>
<div
className="flex items-center gap-2 rounded-lg bg-black p-3"
style={{ border: "1px solid #2a2a2a" }}
>
<code className="flex-1 break-all font-mono text-base text-white">{password}</code>
<button
onClick={copyPassword}
className="flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-xs text-[#aaa] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
style={{ border: "1px solid #2a2a2a" }}
{/* Document Body */}
{viewMode === "rendered" ? (
<div id="strix-rendered-report" className="prose-markdown report-print-body leading-relaxed text-[#ddd] text-sm">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeCodeMeta]}
components={{
...mdComponents,
h1: ({ children }) => (
<h1 className="text-2xl font-bold text-white mt-8 mb-4 pb-2 border-b border-[#222] flex items-center gap-2 print:text-slate-900 print:border-slate-300">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-lg font-semibold text-white mt-6 mb-3 pl-3 border-l-4 border-emerald-500 print:text-slate-900 print:border-emerald-600">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-medium text-emerald-300 mt-5 mb-2 print:text-slate-800">
{children}
</h3>
),
table: ({ children }) => (
<div className="my-5 overflow-x-auto rounded-lg border border-[#222] print:border-slate-300 print:overflow-visible">
<table className="w-full border-collapse text-left text-xs">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border-b border-[#222] bg-[rgba(255,255,255,0.04)] px-3.5 py-2.5 font-semibold text-[#ccc] print:bg-slate-100 print:text-slate-900 print:border-slate-300">
{children}
</th>
),
td: ({ children }) => (
<td className="border-b border-[#1a1a1a] px-3.5 py-2 text-[#aaa] print:text-slate-700 print:border-slate-200">
{children}
</td>
),
blockquote: ({ children }) => (
<blockquote className="my-4 rounded-r-lg border-l-4 border-emerald-500/80 bg-[rgba(16,185,129,0.05)] px-4 py-2.5 text-xs text-[#aaa] print:bg-slate-50 print:text-slate-700 print:border-slate-400">
{children}
</blockquote>
),
p: ({ children }) => <p className="my-3 leading-relaxed text-[#ccc] print:text-slate-700">{children}</p>,
ul: ({ children }) => <ul className="my-3 pl-5 list-disc space-y-1 text-[#bbb] print:text-slate-700">{children}</ul>,
ol: ({ children }) => <ol className="my-3 pl-5 list-decimal space-y-1 text-[#bbb] print:text-slate-700">{children}</ol>,
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
hr: () => <hr className="my-6 border-[#222] print:border-slate-300" />,
}}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? "Copied" : "Copy"}
</button>
{reportMarkdown}
</ReactMarkdown>
</div>
<p className="mt-2 text-xs text-[#666]">
Save this now. Strix never stores it, so we cannot show it again. File:{" "}
<span className="font-mono text-[#888]">{filename}</span>
</p>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-[#888]">
<span> Markdown Issue</span>
<span className="font-mono text-[#555]">{reportMarkdown.length} </span>
</div>
<pre className="max-h-[650px] overflow-y-auto rounded-lg border border-[#222] bg-[#0c0c0c] p-4 font-mono text-xs leading-relaxed text-[#ddd] whitespace-pre-wrap">
{reportMarkdown}
</pre>
</div>
)}
{/* Print Footer */}
<div className="report-print-footer print:block hidden text-center text-xs text-slate-400 pt-8 mt-8 border-t border-slate-200">
<p> Strix · 使 · </p>
</div>
<button
onClick={() => onExit("overview")}
className="w-full cursor-pointer rounded-lg px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[rgba(255,255,255,0.06)]"
style={{ border: "1px solid #2a2a2a" }}
>
Done
</button>
</div>
)}
</div>

View file

@ -1,77 +1,104 @@
import React from "react";
"use client";
import { cn } from "@/lib/utils";
export interface IssueSeveritySummaryFindings {
total: number;
critical: number;
high: number;
medium: number;
low: number;
}
import type { VulnerabilitySeverity } from "@/types/issues";
interface IssueSeveritySummaryProps {
findings: IssueSeveritySummaryFindings;
className?: string;
/** Noun for the total count (e.g. "issues", "CVEs"). Defaults to "issues". */
unit?: string;
/** Optional content rendered at the end of the count row (e.g. a KEV badge). */
trailing?: React.ReactNode;
findings: {
total: number;
critical: number;
high: number;
medium: number;
low: number;
};
}
const SEVERITIES = [
{ key: "critical", label: "critical", dotClass: "bg-red-500", textClass: "text-red-500" },
{ key: "high", label: "high", dotClass: "bg-orange-500", textClass: "text-orange-500" },
{ key: "medium", label: "medium", dotClass: "bg-yellow-500", textClass: "text-yellow-500" },
{ key: "low", label: "low", dotClass: "bg-blue-500", textClass: "text-blue-500" },
] as const;
const SEVERITY_CONFIG: Record<
VulnerabilitySeverity,
{ label: string; bg: string; text: string; bar: string }
> = {
critical: {
label: "严重 (Critical)",
bg: "bg-red-500/10 border-red-500/30",
text: "text-red-400",
bar: "bg-red-500",
},
high: {
label: "高危 (High)",
bg: "bg-orange-500/10 border-orange-500/30",
text: "text-orange-400",
bar: "bg-orange-500",
},
medium: {
label: "中危 (Medium)",
bg: "bg-yellow-500/10 border-yellow-500/30",
text: "text-yellow-400",
bar: "bg-yellow-500",
},
low: {
label: "低危 (Low)",
bg: "bg-blue-500/10 border-blue-500/30",
text: "text-blue-400",
bar: "bg-blue-500",
},
};
export function IssueSeveritySummary({
findings,
className,
unit = "issues",
trailing,
}: IssueSeveritySummaryProps) {
if (findings.total <= 0) return null;
export function IssueSeveritySummary({ findings }: IssueSeveritySummaryProps) {
const { total, critical, high, medium, low } = findings;
const items: { key: VulnerabilitySeverity; count: number }[] = [
{ key: "critical", count: critical },
{ key: "high", count: high },
{ key: "medium", count: medium },
{ key: "low", count: low },
];
return (
<div className={cn("space-y-3", className)}>
<div className="flex flex-wrap items-center gap-x-8 gap-y-3">
<div className="flex items-center gap-2">
<span className="text-2xl font-semibold text-white tabular-nums">{findings.total}</span>
<span className="text-sm text-[#666]">{unit}</span>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
{SEVERITIES.map(({ key, label, dotClass, textClass }) => {
const count = findings[key];
if (count <= 0) return null;
return (
<div key={key} className="flex items-center gap-1.5">
<div className={cn("w-2 h-2 rounded-full", dotClass)} aria-hidden="true" />
<span className={cn("text-sm tabular-nums", textClass)}>{count}</span>
<span className="text-xs text-[#555]">{label}</span>
</div>
);
})}
</div>
{trailing ? <div className="flex items-center gap-2">{trailing}</div> : null}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white"></h3>
<span className="text-xs text-[#888]">
<strong className="text-white font-semibold">{total}</strong>
</span>
</div>
<div className="h-1.5 rounded-full bg-[#222] overflow-hidden flex">
{SEVERITIES.map(({ key, dotClass }) => {
const count = findings[key];
if (count <= 0) return null;
{/* Distribution bar */}
{total > 0 && (
<div className="flex h-2.5 w-full overflow-hidden rounded-full bg-[#222]">
{items.map(
(item) =>
item.count > 0 && (
<div
key={item.key}
style={{ width: `${(item.count / total) * 100}%` }}
className={`h-full ${SEVERITY_CONFIG[item.key].bar} transition-all duration-500`}
title={`${SEVERITY_CONFIG[item.key].label}: ${item.count}`}
/>
)
)}
</div>
)}
{/* Badges Grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{items.map((item) => {
const config = SEVERITY_CONFIG[item.key];
return (
<div
key={key}
className={cn("h-full", dotClass)}
style={{ width: `${(count / findings.total) * 100}%` }}
/>
key={item.key}
className={`rounded-lg border p-3 flex flex-col justify-between ${
item.count > 0 ? config.bg : "border-[#222] bg-black/20 opacity-60"
}`}
>
<span className="text-xs text-[#888]">{config.label}</span>
<span className={`text-xl font-bold font-mono mt-1 ${config.text}`}>
{item.count}
</span>
</div>
);
})}
</div>
</div>
);
}
export default IssueSeveritySummary;

View file

@ -1,176 +1,176 @@
import { useState } from "react";
import { History, ChevronRight, Terminal } from "lucide-react";
import type { RunListEntry, RunsPayload, RunSeverityCounts } from "@/data/serverSource";
import { runTitle } from "@/lib/target-utils";
import { trackCta } from "@/lib/cta";
import EmailVerifyInline from "@/components/EmailVerifyInline";
"use client";
/**
* "Past runs" panel. Unverified users see a tease with the run count and a
* verify affordance (the launched run stays fully visible; the CLI
* `strix view <name>` still works). Verified users get the full history and can
* switch the active run, which threads ?run=<name> through the data fetches.
*/
const SEV = [
{ key: "critical", dot: "bg-red-500", text: "text-red-500" },
{ key: "high", dot: "bg-orange-500", text: "text-orange-500" },
{ key: "medium", dot: "bg-yellow-500", text: "text-yellow-500" },
{ key: "low", dot: "bg-blue-500", text: "text-blue-500" },
] as const;
function SeverityChips({ counts }: { counts: RunSeverityCounts }) {
const shown = SEV.filter((s) => counts[s.key] > 0);
if (shown.length === 0) {
return <span className="text-xs text-[#555]">No findings</span>;
}
return (
<div className="flex items-center gap-3">
{shown.map((s) => (
<div key={s.key} className="flex items-center gap-1.5">
<span className={`h-2 w-2 rounded-full ${s.dot}`} aria-hidden="true" />
<span className={`text-xs tabular-nums ${s.text}`}>{counts[s.key]}</span>
</div>
))}
</div>
);
}
function formatDate(iso: string | null): string | null {
if (!iso) return null;
const normalized = iso.trim().replace(" UTC", "Z").replace(" ", "T");
const d = new Date(normalized);
if (Number.isNaN(d.getTime())) return null;
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
/**
* Relative time ("just now" / "5m ago" / "3h ago" / "2d ago"), falling back to
* the absolute date for anything older than a week (mirrors the pro app).
*/
function formatTimeAgo(iso: string | null): string | null {
if (!iso) return null;
const normalized = iso.trim().replace(" UTC", "Z").replace(" ", "T");
const d = new Date(normalized);
if (Number.isNaN(d.getTime())) return null;
const diffMs = Date.now() - d.getTime();
const mins = Math.floor(diffMs / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
return formatDate(iso);
}
import { History, ShieldCheck, ArrowRight, Clock, Target, AlertTriangle } from "lucide-react";
import type { RunsPayload, RunListEntry } from "@/data/serverSource";
import { formatTimeAgo } from "@/lib/utils";
interface PastRunsViewProps {
runs: RunsPayload | null;
runs: RunsPayload;
activeRun: string | null;
onSelectRun: (name: string) => void;
onVerified: () => void;
onVerified?: () => void;
}
const SEVERITY_COLORS: Record<string, string> = {
critical: "text-red-400 bg-red-500/10 border-red-500/30",
high: "text-orange-400 bg-orange-500/10 border-orange-500/30",
medium: "text-yellow-400 bg-yellow-500/10 border-yellow-500/30",
low: "text-blue-400 bg-blue-500/10 border-blue-500/30",
};
function formatDuration(startTime: string | null, endTime: string | null): string | null {
if (!startTime) return null;
const start = new Date(startTime).getTime();
const end = endTime ? new Date(endTime).getTime() : Date.now();
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null;
const seconds = Math.round((end - start) / 1000);
if (seconds < 60) return `${seconds}`;
const m = Math.floor(seconds / 60);
if (m < 60) return `${m} 分钟`;
const h = Math.floor(m / 60);
return `${h} 小时 ${m % 60} 分钟`;
}
export default function PastRunsView({
runs,
activeRun,
onSelectRun,
onVerified,
}: PastRunsViewProps) {
const count = runs?.count ?? 0;
const [showVerify, setShowVerify] = useState(false);
const entries: RunListEntry[] = runs.runs || [];
if (!runs || runs.locked) {
if (entries.length === 0) {
return (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center">
<div
className="mx-auto mb-4 flex h-11 w-11 items-center justify-center rounded-xl"
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
>
<History className="h-5 w-5 text-[#888]" aria-hidden="true" />
</div>
<h2 className="text-base font-semibold text-white">Browse every run on this machine</h2>
<p className="mx-auto mt-1.5 max-w-md text-sm text-[#888]">
You have {count} past {count === 1 ? "run" : "runs"} on this machine.
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-12 text-center">
<History className="w-10 h-10 mx-auto mb-3 text-[#555]" />
<p className="text-base font-medium text-white"></p>
<p className="mt-1 text-sm text-[#888]">
</p>
{showVerify ? (
<>
<p className="mx-auto mt-3 max-w-sm text-xs text-[#666]">
Verify your email with a one-time code to unlock the full history.
</p>
<EmailVerifyInline onVerified={onVerified} />
</>
) : (
<button
onClick={() => {
trackCta("history_unlock", "past_runs");
setShowVerify(true);
}}
className="mt-4 cursor-pointer rounded-lg bg-white px-4 py-2 text-sm font-semibold text-black transition-opacity hover:opacity-90"
>
View runs
</button>
)}
<p className="mt-4 flex items-center justify-center gap-1.5 text-xs text-[#555]">
<Terminal className="h-3.5 w-3.5" aria-hidden="true" />
Or open one from the CLI with{" "}
<code className="font-mono text-[#888]">strix view &lt;name&gt;</code>
</p>
</div>
);
}
if (runs.runs.length === 0) {
return (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]">
No past runs found on this machine yet.
</div>
);
}
return (
<div className="space-y-2">
{runs.runs.map((run: RunListEntry) => {
const active = run.name === activeRun;
const date = formatTimeAgo(run.start_time) ?? formatTimeAgo(run.end_time);
const title = runTitle(run.target, run.name);
return (
<button
key={run.name}
onClick={() => onSelectRun(run.name)}
className={`animate-card-in group flex w-full cursor-pointer items-center gap-4 rounded-lg border px-4 py-3 text-left transition-colors ${
active
? "border-[#444] bg-[rgba(255,255,255,0.04)]"
: "border-[#222] bg-[rgba(255,255,255,0.02)] hover:border-[#444]"
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-white">{title}</span>
{active && (
<span className="rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400" style={{ border: "1px solid rgba(16,185,129,0.3)" }}>
Active
</span>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-[#666]">
{run.scan_mode && <span className="capitalize">{run.scan_mode}</span>}
{run.scan_mode && (date || run.status) && <span className="text-[#333]">·</span>}
{date && <span>{date}</span>}
{date && run.status && <span className="text-[#333]">·</span>}
{run.status && <span className="capitalize">{run.status}</span>}
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-[#888]">
<span className="font-semibold text-white">{entries.length}</span>
</p>
</div>
<div className="grid gap-3">
{entries.map((entry) => {
const isActive = activeRun === entry.name;
const totalVulns =
(entry.severity_counts?.critical || 0) +
(entry.severity_counts?.high || 0) +
(entry.severity_counts?.medium || 0) +
(entry.severity_counts?.low || 0);
const modeLabel =
entry.scan_mode === "deep"
? "深度渗透 (Deep)"
: entry.scan_mode === "standard"
? "标准渗透 (Standard)"
: "快速探测 (Quick)";
const statusLabel =
entry.status === "completed"
? "已完成"
: entry.status === "running"
? "执行中"
: entry.status === "failed"
? "异常终止"
: entry.status || "未知状态";
const statusBadgeColor =
entry.status === "completed"
? "text-emerald-400 border-emerald-500/30 bg-emerald-500/10"
: entry.status === "running"
? "text-blue-400 border-blue-500/30 bg-blue-500/10 animate-pulse"
: "text-[#888] border-[#333] bg-[#1a1a1a]";
const durationStr = formatDuration(entry.start_time, entry.end_time);
return (
<div
key={entry.name}
onClick={() => onSelectRun(entry.name)}
className={`cursor-pointer rounded-xl border p-5 transition-all ${
isActive
? "border-emerald-500/50 bg-emerald-500/[0.04] ring-1 ring-emerald-500/30"
: "border-[#222] bg-[rgba(255,255,255,0.02)] hover:border-[#444] hover:bg-[rgba(255,255,255,0.04)]"
}`}
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-center gap-2.5 flex-wrap">
<Target className="w-4 h-4 text-emerald-400 flex-shrink-0" />
<span className="font-mono text-base font-semibold text-white truncate">
{entry.target || entry.name}
</span>
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full border ${statusBadgeColor}`}
>
{statusLabel}
</span>
{isActive && (
<span className="text-xs font-medium px-2 py-0.5 rounded-full border border-emerald-500/40 bg-emerald-500/20 text-emerald-300">
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-[#888]">
<span>: <code className="text-[#aaa]">{entry.name}</code></span>
<span>: <span className="text-[#aaa]">{modeLabel}</span></span>
{durationStr && (
<span className="inline-flex items-center gap-1">
<Clock className="w-3 h-3 text-[#666]" /> {durationStr}
</span>
)}
{entry.start_time && (
<span> {formatTimeAgo(entry.start_time)}</span>
)}
</div>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
{totalVulns > 0 ? (
<div className="flex items-center gap-1.5">
{entry.severity_counts?.critical > 0 && (
<span className={`text-xs font-bold px-2 py-0.5 rounded border ${SEVERITY_COLORS.critical}`}>
{entry.severity_counts.critical}
</span>
)}
{entry.severity_counts?.high > 0 && (
<span className={`text-xs font-bold px-2 py-0.5 rounded border ${SEVERITY_COLORS.high}`}>
{entry.severity_counts.high}
</span>
)}
{entry.severity_counts?.medium > 0 && (
<span className={`text-xs font-bold px-2 py-0.5 rounded border ${SEVERITY_COLORS.medium}`}>
{entry.severity_counts.medium}
</span>
)}
{entry.severity_counts?.low > 0 && (
<span className={`text-xs font-bold px-2 py-0.5 rounded border ${SEVERITY_COLORS.low}`}>
{entry.severity_counts.low}
</span>
)}
</div>
) : (
<span className="inline-flex items-center gap-1 text-xs text-[#666]">
<ShieldCheck className="w-3.5 h-3.5 text-[#555]" />
</span>
)}
<ArrowRight className="w-4 h-4 text-[#555] group-hover:text-white transition-colors" />
</div>
</div>
</div>
<SeverityChips counts={run.severity_counts} />
<ChevronRight className="h-4 w-4 flex-shrink-0 text-[#555] transition-colors group-hover:text-[#aaa]" aria-hidden="true" />
</button>
);
})}
);
})}
</div>
</div>
);
}

View file

@ -1,220 +1,131 @@
"use client";
import { useState } from "react";
import { ChevronDown, ChevronUp, Info } from "lucide-react";
import { formatNumber } from "@/lib/display-number";
import { ChevronDown, ChevronUp, Cpu, Sliders, Target, Clock, Terminal, Bot } from "lucide-react";
/**
* "Run details" card for the Overview tab: the launch configuration the run was
* started with (targets, instruction, scope, mode) and its LLM usage + cost.
* Everything is read defensively from the raw run.json record, which may be
* partial while a scan is still live.
*/
type Rec = Record<string, unknown>;
function rec(v: unknown): Rec {
return v && typeof v === "object" && !Array.isArray(v) ? (v as Rec) : {};
}
function arr(v: unknown): unknown[] {
return Array.isArray(v) ? v : [];
}
function str(v: unknown): string | null {
return typeof v === "string" && v.trim() ? v : null;
}
function num(v: unknown): number | null {
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
function humanize(s: string): string {
return s.replace(/_/g, " ");
}
function cap(s: string | null): string | null {
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
function fmtDuration(seconds: number | null): string {
if (seconds == null || seconds < 0) return "n/a";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h) return `${h}h ${m}m ${s}s`;
if (m) return `${m}m ${s}s`;
return `${s}s`;
interface RunDetailsProps {
raw: Record<string, unknown>;
durationSeconds?: number | null;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[7rem_1fr] gap-3 items-baseline">
<dt className="text-[11px] uppercase tracking-wide text-[#666]">{label}</dt>
<dd className="min-w-0 break-words text-sm text-[#ddd]">{children}</dd>
</div>
);
function formatNumber(num: number | null | undefined): string {
if (num == null) return "0";
return num.toLocaleString();
}
export function RunDetails({
raw,
durationSeconds,
}: {
raw: Rec;
durationSeconds: number | null;
}) {
const [open, setOpen] = useState(true);
function formatDuration(seconds: number | null | undefined): string {
if (seconds == null || seconds === 0) return "--";
if (seconds < 60) return `${seconds}`;
const m = Math.floor(seconds / 60);
if (m < 60) return `${m} 分钟 ${seconds % 60}`;
const h = Math.floor(m / 60);
return `${h} 小时 ${m % 60} 分钟`;
}
// Configuration (launch inputs)
const targets = arr(raw.targets_info).map((t) => {
const o = rec(t);
const display = str(o.original) ?? str(rec(o.details).target_url) ?? "unknown target";
const type = str(o.type);
return { display, type: type ? humanize(type) : null };
});
const instruction = str(raw.instruction);
const scanMode = cap(str(raw.scan_mode));
const scopeMode = str(raw.scope_mode);
const diff = rec(raw.diff_scope);
const diffActive = diff.active === true;
const diffMode = str(diff.mode);
const diffBase = str(raw.diff_base);
const nonInteractive = raw.non_interactive === true;
const localSources = arr(raw.local_sources)
.map((x) => {
if (typeof x === "string") return x;
const o = rec(x);
return str(o.source_path) ?? str(o.target_path) ?? "";
})
.filter(Boolean);
const status = cap(str(raw.status));
export function RunDetails({ raw, durationSeconds }: RunDetailsProps) {
const [open, setOpen] = useState(false);
let scope = scopeMode ?? "auto";
if (diffActive) {
scope += ` (diff${diffMode ? `: ${diffMode}` : ""}${diffBase ? ` vs ${diffBase}` : ""})`;
}
const targetsInfo = (Array.isArray(raw.targets_info) ? raw.targets_info : []) as Array<Record<string, unknown>>;
const targets = targetsInfo.map((t) => (typeof t === "object" && t ? String(t.original || "") : "")).filter(Boolean);
const instruction = (raw.instruction as string) || (raw.guidance as string) || null;
const scanMode = (raw.scan_mode as string) || "standard";
const status = (raw.status as string) || "running";
// Usage & cost
const usage = rec(raw.llm_usage);
const hasUsage = Object.keys(usage).length > 0;
const agents = arr(usage.agents).map(rec);
const models = Array.from(
new Set(agents.map((a) => str(a.model)).filter((m): m is string => !!m))
);
const requests = num(usage.requests);
const inputTokens = num(usage.input_tokens);
const cached = num(rec(arr(usage.input_tokens_details)[0]).cached_tokens);
const outputTokens = num(usage.output_tokens);
const reasoning = num(rec(arr(usage.output_tokens_details)[0]).reasoning_tokens);
const totalTokens = num(usage.total_tokens);
const cost = num(usage.cost);
const subscription = str(raw.auth_mode) === "subscription";
const llmUsage = (raw.llm_usage && typeof raw.llm_usage === "object" ? raw.llm_usage : {}) as Record<string, unknown>;
const modelName = (llmUsage.model as string) || (raw.model as string) || (raw.llm_model as string) || "openai/Qwen3.8-27B-abliterated";
const calls = (llmUsage.requests as number) || (llmUsage.calls as number) || (llmUsage.total_requests as number) || 0;
const inputTokens = (llmUsage.input_tokens as number) || 0;
const outputTokens = (llmUsage.output_tokens as number) || 0;
const totalTokens = (llmUsage.total_tokens as number) || inputTokens + outputTokens;
const cost = llmUsage.cost as number | null;
const sub = (n: number, word: string) => (
<span className="text-[#666]"> ({formatNumber(n)} {word})</span>
);
const modeLabel =
scanMode === "deep" ? "深度渗透 (Deep)" : scanMode === "standard" ? "标准渗透 (Standard)" : "快速探测 (Quick)";
return (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] overflow-hidden transition-all">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full cursor-pointer items-center gap-2 text-left"
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between px-5 py-3.5 text-left text-sm text-[#888] hover:text-white transition-colors cursor-pointer"
>
<Info className="h-4 w-4 text-[#888]" aria-hidden="true" />
<h2 className="text-sm font-semibold text-white">Run details</h2>
{open ? (
<ChevronUp className="ml-auto h-4 w-4 text-[#666]" aria-hidden="true" />
) : (
<ChevronDown className="ml-auto h-4 w-4 text-[#666]" aria-hidden="true" />
)}
<div className="flex items-center gap-2">
<Sliders className="w-4 h-4 text-emerald-400" />
<span className="font-medium text-white"></span>
<span className="text-xs text-[#666]">
(: <span className="text-[#aaa] font-mono">{modelName}</span> · {formatNumber(totalTokens)} Tokens)
</span>
</div>
<div className="flex items-center gap-1 text-xs text-[#888]">
<span>{open ? "收起详情" : "展开详情"}</span>
{open ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</div>
</button>
{open && (
<div className="mt-4 grid grid-cols-1 gap-x-8 gap-y-6 md:grid-cols-2">
<section>
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]">
Configuration
</h3>
<dl className="space-y-2.5">
{targets.length > 0 && (
<Field label="Targets">
<div className="space-y-1">
{targets.map((t, i) => (
<div key={i} className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[#ddd]">{t.display}</span>
{t.type && (
<span className="rounded-full border border-[#2a2a2a] px-1.5 py-0.5 text-[10px] text-[#888]">
{t.type}
</span>
)}
</div>
))}
</div>
</Field>
)}
<Field label="Instruction">
{instruction ? (
<span className="whitespace-pre-wrap">{instruction}</span>
) : (
<span className="text-[#666]">None</span>
)}
</Field>
{scanMode && <Field label="Pentest mode">{scanMode}</Field>}
<Field label="Scope">{scope}</Field>
<Field label="Mode">{nonInteractive ? "Non-interactive" : "Interactive"}</Field>
{localSources.length > 0 && (
<Field label="Local sources">
<div className="space-y-0.5 font-mono text-[#ddd]">
{localSources.map((s, i) => (
<div key={i}>{s}</div>
))}
</div>
</Field>
)}
{status && <Field label="Status">{status}</Field>}
</dl>
</section>
<div className="px-5 pb-5 pt-1 border-t border-[#1a1a1a] space-y-4 text-xs">
{/* Target & Guidance */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-[#888]">
<Target className="w-3.5 h-3.5 text-emerald-400" />
<span className="font-medium text-white"></span>
</div>
<p className="font-mono text-[#aaa] bg-black/40 border border-[#222] rounded-lg p-2.5 break-all">
{targets.length > 0 ? targets.join(", ") : (raw.target as string) || "未指定目标"}
</p>
</div>
<section>
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]">
Usage &amp; cost
</h3>
{hasUsage ? (
<dl className="space-y-2.5 tabular-nums">
<Field label="Model">{models.length ? models.join(", ") : "n/a"}</Field>
{subscription && (
<Field label="Provider">
<span className="inline-flex items-center gap-1.5">
<span className="rounded-full border border-[#22c55e]/40 bg-[#22c55e]/10 px-2 py-0.5 text-[11px] text-[#22c55e]">
ChatGPT subscription
</span>
</span>
</Field>
)}
<Field label="Run time">{fmtDuration(durationSeconds)}</Field>
{requests != null && <Field label="Requests">{formatNumber(requests)}</Field>}
{inputTokens != null && (
<Field label="Input tokens">
{formatNumber(inputTokens)}
{cached != null && sub(cached, "cached")}
</Field>
)}
{outputTokens != null && (
<Field label="Output tokens">
{formatNumber(outputTokens)}
{reasoning != null && sub(reasoning, "reasoning")}
</Field>
)}
{totalTokens != null && <Field label="Total tokens">{formatNumber(totalTokens)}</Field>}
{subscription ? (
<Field label="Cost">
<span className="text-[#22c55e]">$0.00</span>
<span className="text-[#666]"> (subscription)</span>
</Field>
) : (
cost != null && <Field label="Cost">${cost.toFixed(2)}</Field>
)}
{agents.length > 0 && <Field label="Agents">{formatNumber(agents.length)}</Field>}
</dl>
) : (
<p className="text-sm text-[#666]">Not available yet.</p>
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-[#888]">
<Clock className="w-3.5 h-3.5 text-blue-400" />
<span className="font-medium text-white"></span>
</div>
<div className="bg-black/40 border border-[#222] rounded-lg p-2.5 flex items-center justify-between">
<span className="text-[#aaa]">: <strong className="text-white font-normal">{modeLabel}</strong></span>
<span className="text-[#aaa]">: <strong className="text-emerald-400 font-normal">{formatDuration(durationSeconds)}</strong></span>
</div>
</div>
</div>
{instruction && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-[#888]">
<Terminal className="w-3.5 h-3.5 text-purple-400" />
<span className="font-medium text-white"> (Instruction)</span>
</div>
<div className="bg-black/40 border border-[#222] rounded-lg p-3 text-[#aaa] font-mono whitespace-pre-wrap leading-relaxed">
{instruction}
</div>
</div>
)}
</section>
</div>
{/* Model Tokens & Cost */}
<div className="space-y-2 pt-2 border-t border-[#1a1a1a]">
<div className="flex items-center gap-1.5 text-[#888]">
<Cpu className="w-3.5 h-3.5 text-amber-400" />
<span className="font-medium text-white"> (LLM Metrics)</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="bg-black/40 border border-[#222] rounded-lg p-3 text-center">
<span className="block text-[#666] mb-1"></span>
<span className="text-sm font-semibold text-white font-mono">{calls} </span>
</div>
<div className="bg-black/40 border border-[#222] rounded-lg p-3 text-center">
<span className="block text-[#666] mb-1"> Tokens</span>
<span className="text-sm font-semibold text-blue-400 font-mono">{formatNumber(inputTokens)}</span>
</div>
<div className="bg-black/40 border border-[#222] rounded-lg p-3 text-center">
<span className="block text-[#666] mb-1"> Tokens</span>
<span className="text-sm font-semibold text-purple-400 font-mono">{formatNumber(outputTokens)}</span>
</div>
<div className="bg-black/40 border border-[#222] rounded-lg p-3 text-center">
<span className="block text-[#666] mb-1"> Tokens</span>
<span className="text-sm font-semibold text-emerald-400 font-mono">{formatNumber(totalTokens)}</span>
</div>
</div>
</div>
</div>
)}
</div>
);

View file

@ -226,13 +226,13 @@ export default function Sidebar({
<div className="relative flex flex-col gap-px px-2">
<NavItem
icon={<ProjectsIcon />}
label="Pentest Overview"
label="渗透概览"
active={view === "overview"}
onClick={() => onSelectView("overview")}
/>
<NavItem
icon={<AlertTriangle className="h-4 w-4" />}
label="Issues"
label="漏洞与风险"
count={issuesCount > 0 ? issuesCount : undefined}
active={view === "issues"}
onClick={() => onSelectView("issues")}
@ -240,7 +240,7 @@ export default function Sidebar({
{agentCount > 0 && (
<NavItem
icon={<Bot className="h-4 w-4" />}
label="Agents"
label="智能体拓扑"
count={agentCount}
active={view === "agents"}
onClick={() => onSelectView("agents")}
@ -248,7 +248,7 @@ export default function Sidebar({
)}
<NavItem
icon={<History className="h-4 w-4" />}
label="Past runs"
label="历史扫描记录"
count={runCount > 0 ? runCount : undefined}
active={view === "history"}
onClick={onOpenHistory}
@ -256,14 +256,14 @@ export default function Sidebar({
{finished && (
<NavItem
icon={<Mail className="h-4 w-4" />}
label="Export report"
label="导出测试报告"
active={view === "email"}
onClick={onOpenEmail}
/>
)}
<NavItem
icon={<IoChatbubblesOutline className="h-4 w-4" />}
label="Feedback & support"
label="意见反馈"
active={view === "feedback"}
onClick={() => onSelectView("feedback")}
/>
@ -272,34 +272,34 @@ export default function Sidebar({
<NavItem
icon={<LuGitPullRequestArrow className="h-4 w-4" />}
label="PR Security Reviews"
label="PR 安全代码审查"
active={false}
onClick={() =>
openUpgrade(
"pr_reviews",
"Strix reviews every pull request and flags exploitable changes before they merge."
"Strix 可以在代码合并前自动审查每个 Pull Request拦截可利用的安全漏洞。"
)
}
/>
<NavItem
icon={<VscExtensions className="h-4 w-4" />}
label="Integrations"
label="第三方工具集成"
active={false}
onClick={() =>
openUpgrade(
"integrations",
"Sync findings to Jira, Linear, and Slack so fixes happen where your team already works."
"将漏洞同步至 Jira、Linear、钉钉、企业微信和飞书加速安全闭环。"
)
}
/>
<NavItem
icon={<Users className="h-4 w-4" />}
label="Members"
label="团队与权限"
active={false}
onClick={() =>
openUpgrade(
"members",
"Invite your team, set roles, and share findings and run history across your org."
"邀请团队成员、划分角色权限,跨团队共享渗透测试报告与历史记录。"
)
}
/>
@ -324,7 +324,7 @@ export default function Sidebar({
</span>
<span className="flex min-w-0 flex-1 flex-col text-left">
<span className="truncate text-[13px] font-medium text-[#ededed]">{email}</span>
<span className="truncate text-[11px] text-[#555]">Linked to this machine</span>
<span className="truncate text-[11px] text-[#555]"></span>
</span>
</button>
) : (
@ -336,7 +336,7 @@ export default function Sidebar({
<span className="text-[9px] font-semibold text-white">S</span>
</span>
<span className="flex min-w-0 flex-1 flex-col text-left">
<span className="truncate text-[13px] font-medium text-[#ededed]">Local viewer</span>
<span className="truncate text-[13px] font-medium text-[#ededed]"></span>
</span>
</div>
)}
@ -344,7 +344,7 @@ export default function Sidebar({
{showUserMenu && verified && email && (
<div className="absolute bottom-full left-2 right-2 z-50 mb-1 overflow-hidden rounded-lg border border-[#333] bg-black shadow-xl">
<div className="border-b border-[#333] px-3 py-2">
<p className="truncate text-[13px] font-medium text-white">Linked email</p>
<p className="truncate text-[13px] font-medium text-white"></p>
<p className="truncate text-[11px] text-[#666]">{email}</p>
</div>
<button
@ -355,7 +355,7 @@ export default function Sidebar({
className="flex w-full items-center gap-2 px-3 py-2 text-[13px] text-[#888] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-red-400"
>
<LogOut className="h-4 w-4" />
Forget this email
退
</button>
</div>
)}

View file

@ -228,19 +228,18 @@ export function AgentTranscript({
STATUS_STYLE[agent.status] ?? "text-[#aaa] border-[#333] bg-[#1a1a1a]"
}`}
>
{agent.status}
{agent.status === "completed" ? "已完成" : agent.status === "running" ? "执行中" : agent.status === "failed" ? "失败" : agent.status === "waiting" ? "等待中" : agent.status}
</span>
<span className="font-mono text-xs text-[#555]">{agent.id}</span>
</div>
<p className="text-xs text-[#666] mb-4">
{msgCount} message{msgCount === 1 ? "" : "s"} · {toolCount} tool call
{toolCount === 1 ? "" : "s"}
{msgCount} · {toolCount}
</p>
</>
)}
{mine.length === 0 ? (
<p className="text-sm text-[#666]">No recorded activity for this agent.</p>
<p className="text-sm text-[#666]"></p>
) : (
<div className="py-1">
{mine.map((event, i) => {

View file

@ -0,0 +1,501 @@
import { useState, useMemo, useEffect, useRef } from "react";
import {
Terminal,
Activity,
Search,
ShieldAlert,
CheckCircle2,
Clock,
Play,
Pause,
ChevronDown,
ChevronUp,
Copy,
Check,
Filter,
Bot,
MessageSquare,
Sparkles,
Zap,
Globe,
Radio,
FileCode,
ArrowUpRight,
ShieldCheck,
Layers,
} from "lucide-react";
import type { Transcript, TranscriptAgent, TranscriptEvent } from "@/data/serverSource";
import { getToolIcon } from "./tool-renderers";
interface LiveActivityFeedProps {
transcript: Transcript | null;
finished: boolean;
onSelectAgent?: (agentId: string) => void;
maxInitialEvents?: number;
}
type EventFilterCategory = "all" | "commands" | "network" | "findings" | "agents" | "thinking";
function parseEventOutput(data: Record<string, unknown>): {
cmd: string | null;
output: string | null;
toolName: string;
thought: string | null;
isError: boolean;
} {
const toolName = String(data.tool_name || data.tool || "unknown_action");
let cmd: string | null = null;
let output: string | null = null;
let thought: string | null = null;
let isError = false;
const rawArgs = data.args;
if (typeof rawArgs === "string") {
cmd = rawArgs;
} else if (rawArgs && typeof rawArgs === "object") {
const argsObj = rawArgs as Record<string, unknown>;
cmd = (argsObj.cmd || argsObj.command || argsObj.query || argsObj.instruction || argsObj.task || null) as string | null;
if (!cmd && argsObj.thought) {
thought = String(argsObj.thought);
}
}
const rawResult = data.result;
if (typeof rawResult === "string") {
output = rawResult;
} else if (rawResult && typeof rawResult === "object") {
const resObj = rawResult as Record<string, unknown>;
if (resObj.output) output = String(resObj.output);
else if (resObj.stdout || resObj.stderr) {
output = `${resObj.stdout ? String(resObj.stdout) : ""}${resObj.stderr ? `\n[STDERR]\n${String(resObj.stderr)}` : ""}`;
} else if (resObj.__raw) {
output = String(resObj.__raw);
} else {
output = JSON.stringify(resObj, null, 2);
}
if (resObj.error || resObj.is_error || (typeof resObj.exit_code === "number" && resObj.exit_code !== 0)) {
isError = true;
}
}
// Clean up Strix/Chunk ID prefixes from outputs
if (output) {
const cleanOutput = output
.replace(/^Chunk ID:\s*[a-f0-9]+\s*\n/i, "")
.replace(/^Wall time:\s*[\d.]+\s*seconds\s*\n/i, "")
.replace(/^Process exited with code \d+\s*\n/i, "")
.replace(/^Output:\s*\n/i, "")
.trim();
if (cleanOutput) {
output = cleanOutput;
}
}
return { cmd, output, toolName, thought, isError };
}
function cleanCommandPreview(cmd: string): string {
// If multi-line python script or bash EOF, extract key summary
if (cmd.includes("<< 'PYEOF'") || cmd.includes("<< 'EOF'")) {
const lines = cmd.split("\n").filter((l) => !l.includes("<<") && !l.includes("EOF") && l.trim().length > 0);
const comment = lines.find((l) => l.trim().startsWith("#"));
if (comment) return comment.replace(/^#\s*/, "").trim();
return lines[0]?.trim() || "Python Script Execution";
}
return cmd.trim();
}
export function LiveActivityFeed({
transcript,
finished,
onSelectAgent,
maxInitialEvents = 30,
}: LiveActivityFeedProps) {
const [filterCategory, setFilterCategory] = useState<EventFilterCategory>("all");
const [selectedAgentId, setSelectedAgentId] = useState<string>("all");
const [searchTerm, setSearchTerm] = useState<string>("");
const [expandedIds, setExpandedIds] = useState<Record<string, boolean>>({});
const [copiedId, setCopiedId] = useState<string | null>(null);
const [autoScroll, setAutoScroll] = useState<boolean>(!finished);
const feedEndRef = useRef<HTMLDivElement | null>(null);
const agents = useMemo(() => transcript?.agents ?? [], [transcript]);
const agentMap = useMemo(() => {
const m = new Map<string, TranscriptAgent>();
for (const a of agents) m.set(a.id, a);
return m;
}, [agents]);
// Extract all tool & chat events
const rawEvents = useMemo(() => transcript?.events ?? [], [transcript]);
// Filter events
const filteredEvents = useMemo(() => {
return rawEvents
.filter((e) => {
// Exclude internal telemetry or raw ping events
if (e.type !== "tool" && e.type !== "chat") return false;
const data = (e.data || {}) as Record<string, unknown>;
const toolName = String(data.tool_name || data.tool || "");
// Agent filter
if (selectedAgentId !== "all" && e.agent_id !== selectedAgentId) {
return false;
}
// Category filter
if (filterCategory === "commands") {
if (!["exec_command", "write_stdin", "terminal_execute", "python_action"].includes(toolName)) {
return false;
}
} else if (filterCategory === "network") {
if (!toolName.includes("request") && !toolName.includes("proxy") && !toolName.includes("browser") && !toolName.includes("scan")) {
const { cmd } = parseEventOutput(data);
if (!cmd || (!cmd.includes("curl") && !cmd.includes("http") && !cmd.includes("urllib") && !cmd.includes("nmap"))) {
return false;
}
}
} else if (filterCategory === "findings") {
if (!["create_vulnerability_report", "create_note", "update_todo"].includes(toolName)) {
return false;
}
} else if (filterCategory === "agents") {
if (!["create_agent", "agent_finish", "send_message_to_agent", "wait_for_agents"].includes(toolName)) {
return false;
}
} else if (filterCategory === "thinking") {
if (toolName !== "think" && e.type !== "chat") {
return false;
}
}
// Search filter
if (searchTerm.trim()) {
const s = searchTerm.toLowerCase();
const { cmd, output, thought } = parseEventOutput(data);
const agentName = agentMap.get(e.agent_id)?.name || "";
const text = `${toolName} ${cmd || ""} ${output || ""} ${thought || ""} ${agentName}`.toLowerCase();
if (!text.includes(s)) return false;
}
return true;
})
.reverse(); // Newest first
}, [rawEvents, selectedAgentId, filterCategory, searchTerm, agentMap]);
const toggleExpand = (id: string) => {
setExpandedIds((prev) => ({ ...prev, [id]: !prev[id] }));
};
const copyToClipboard = (id: string, text: string) => {
void navigator.clipboard.writeText(text);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
// Extract quick metrics
const stats = useMemo(() => {
let commandCount = 0;
let noteCount = 0;
let activeAgents = 0;
for (const a of agents) {
if (a.status === "running") activeAgents++;
}
for (const e of rawEvents) {
if (e.type === "tool") {
const name = String(e.data?.tool_name || "");
if (name.includes("command") || name.includes("execute")) commandCount++;
if (name.includes("note") || name.includes("vuln")) noteCount++;
}
}
return {
totalEvents: rawEvents.length,
commandCount,
noteCount,
activeAgents: activeAgents || (finished ? 0 : 1),
};
}, [rawEvents, agents, finished]);
return (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 sm:p-6 space-y-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-[#222] pb-5">
<div className="space-y-1">
<div className="flex items-center gap-2.5">
<span className="relative flex h-2.5 w-2.5">
{!finished ? (
<>
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75 animate-ping" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-400" />
</>
) : (
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-[#555]" />
)}
</span>
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Activity className="w-5 h-5 text-emerald-400" />
(Live Activity & Probe Stream)
</h2>
<span className="text-xs px-2 py-0.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 text-emerald-400 font-mono">
{!finished ? "实时监听中 (Live)" : "扫描已归档"}
</span>
</div>
<p className="text-xs text-[#888]">
</p>
</div>
{/* Live Metrics */}
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-[#262626] bg-black/40 text-xs">
<Bot className="w-3.5 h-3.5 text-cyan-400" />
<span className="text-[#888]">:</span>
<span className="font-semibold text-white font-mono">{agents.length || 1} </span>
</div>
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-[#262626] bg-black/40 text-xs">
<Terminal className="w-3.5 h-3.5 text-emerald-400" />
<span className="text-[#888]">:</span>
<span className="font-semibold text-white font-mono">{stats.commandCount} </span>
</div>
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-[#262626] bg-black/40 text-xs">
<Sparkles className="w-3.5 h-3.5 text-purple-400" />
<span className="text-[#888]">:</span>
<span className="font-semibold text-white font-mono">{stats.totalEvents} </span>
</div>
</div>
</div>
{/* Agents Quick Strip */}
{agents.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-[#888]">
<span className="font-medium text-white flex items-center gap-1.5">
<Layers className="w-3.5 h-3.5 text-cyan-400" />
():
</span>
<button
onClick={() => setSelectedAgentId("all")}
className={`text-xs hover:underline ${selectedAgentId === "all" ? "text-emerald-400 font-semibold" : "text-[#888]"}`}
>
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2.5">
{agents.map((agent) => {
const isSelected = selectedAgentId === agent.id;
const isRunning = agent.status === "running" && !finished;
return (
<button
key={agent.id}
onClick={() => {
setSelectedAgentId((prev) => (prev === agent.id ? "all" : agent.id));
if (onSelectAgent) onSelectAgent(agent.id);
}}
className={`group text-left rounded-lg p-2.5 transition-all border ${
isSelected
? "border-cyan-500/60 bg-cyan-500/10 shadow-[0_0_12px_rgba(6,182,212,0.15)]"
: "border-[#222] bg-black/30 hover:border-[#333] hover:bg-white/[0.02]"
}`}
>
<div className="flex items-center justify-between gap-1.5">
<div className="flex items-center gap-2 min-w-0">
<span className="w-2 h-2 rounded-full flex-shrink-0 bg-cyan-400" />
<span className="text-xs font-semibold text-white truncate group-hover:text-cyan-300">
{agent.name}
</span>
</div>
<span
className={`text-[10px] px-1.5 py-0.5 rounded font-mono ${
isRunning
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
: agent.status === "failed"
? "bg-red-500/10 text-red-400 border border-red-500/20"
: "bg-[#222] text-[#888]"
}`}
>
{isRunning ? "探测中" : agent.status === "failed" ? "已终止" : "已收工"}
</span>
</div>
</button>
);
})}
</div>
</div>
)}
{/* Filter and Search Bar */}
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 max-w-full">
{[
{ id: "all", label: "全部动态", count: rawEvents.length },
{ id: "commands", label: "命令与探测", count: stats.commandCount },
{ id: "network", label: "网络回包", icon: Globe },
{ id: "findings", label: "阶段性成果", count: stats.noteCount },
{ id: "agents", label: "智能体协同", icon: Bot },
{ id: "thinking", label: "推理决策", icon: Sparkles },
].map((cat) => (
<button
key={cat.id}
onClick={() => setFilterCategory(cat.id as EventFilterCategory)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5 whitespace-nowrap ${
filterCategory === cat.id
? "bg-white text-black font-semibold"
: "bg-[#161616] text-[#888] hover:text-white hover:bg-[#222] border border-[#262626]"
}`}
>
{cat.label}
{cat.count != null && cat.count > 0 && (
<span
className={`text-[10px] px-1.5 py-0.2 rounded-full ${
filterCategory === cat.id ? "bg-black/20 text-black" : "bg-[#262626] text-[#aaa]"
}`}
>
{cat.count}
</span>
)}
</button>
))}
</div>
{/* Search */}
<div className="relative min-w-[220px] flex-1 sm:flex-initial">
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-[#666]" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="搜索回包、命令、端口..."
className="w-full pl-8 pr-3 py-1.5 text-xs bg-[#111] border border-[#2a2a2a] rounded-lg text-white placeholder-[#555] focus:outline-none focus:border-emerald-500"
/>
</div>
</div>
{/* Events List */}
<div className="space-y-3 max-h-[700px] overflow-y-auto pr-1 scrollbar-thin">
{filteredEvents.length === 0 ? (
<div className="rounded-xl border border-[#222] bg-black/20 p-8 text-center space-y-2">
<Radio className="w-6 h-6 mx-auto text-[#666] animate-pulse" />
<p className="text-sm font-medium text-white"></p>
<p className="text-xs text-[#888]">
{searchTerm ? "未找到包含搜索关键词的扫描回包。" : "智能体正在准备下一次探测指令…"}
</p>
</div>
) : (
filteredEvents.map((event) => {
const data = (event.data || {}) as Record<string, unknown>;
const { cmd, output, toolName, thought, isError } = parseEventOutput(data);
const agent = agentMap.get(event.agent_id);
const isExpanded = expandedIds[event.id] ?? false;
const toolIconMeta = getToolIcon(toolName);
const ToolIcon = toolIconMeta.icon;
// Timestamp formatting
const timeStr = event.timestamp
? new Date(event.timestamp).toLocaleTimeString()
: "";
return (
<div
key={event.id}
className="animate-card-in rounded-xl border border-[#222] hover:border-[#333] bg-[#0c0c0c] p-4 transition-all space-y-2.5"
>
{/* Event Header */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className={`p-1.5 rounded-md bg-white/[0.04] ${toolIconMeta.color}`}>
<ToolIcon className="w-3.5 h-3.5" />
</span>
<span className="text-xs font-semibold text-white font-mono">
{toolName.replace(/_/g, " ")}
</span>
{agent && (
<span className="text-[11px] px-2 py-0.5 rounded bg-cyan-500/10 border border-cyan-500/20 text-cyan-400 truncate max-w-[200px]">
{agent.name}
</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-[#666] font-mono">
{timeStr && <span>{timeStr}</span>}
<button
onClick={() =>
copyToClipboard(event.id, `${cmd || ""}\n\n${output || thought || ""}`)
}
title="复制本条探测与回包内容"
className="p-1 hover:text-white transition-colors"
>
{copiedId === event.id ? (
<Check className="w-3.5 h-3.5 text-emerald-400" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
</div>
</div>
{/* Command / Input Execution Preview */}
{cmd && (
<div className="space-y-1">
<div className="text-[11px] font-medium text-[#777] flex items-center gap-1">
<Terminal className="w-3 h-3 text-emerald-400" />
/ :
</div>
<div className="rounded-lg bg-black border border-[#222] p-2.5 text-xs font-mono text-emerald-300 overflow-x-auto whitespace-pre-wrap break-all leading-relaxed">
{cleanCommandPreview(cmd)}
</div>
</div>
)}
{/* Reasoning Thought Preview */}
{thought && !cmd && (
<div className="space-y-1">
<div className="text-[11px] font-medium text-[#777] flex items-center gap-1">
<Sparkles className="w-3 h-3 text-purple-400" />
:
</div>
<div className="rounded-lg bg-purple-950/20 border border-purple-500/20 p-2.5 text-xs text-purple-200 leading-relaxed">
{thought}
</div>
</div>
)}
{/* Returned Scan Output (Real Results from server/target) */}
{output && (
<div className="space-y-1 pt-1">
<div className="flex items-center justify-between text-[11px] font-medium text-[#777]">
<span className="flex items-center gap-1">
<Zap className="w-3 h-3 text-amber-400" />
(Scan Result):
</span>
{output.split("\n").length > 6 && (
<button
onClick={() => toggleExpand(event.id)}
className="text-xs text-emerald-400 hover:text-emerald-300 flex items-center gap-1 transition-colors"
>
{isExpanded ? "收起" : "展开全部回包"}
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
)}
</div>
<div
className={`rounded-lg bg-[#050505] border ${
isError ? "border-red-500/30 text-red-300" : "border-[#1e1e1e] text-[#ccc]"
} p-3 text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all leading-relaxed ${
!isExpanded && output.split("\n").length > 6 ? "max-h-36 overflow-hidden" : ""
}`}
>
{output}
</div>
</div>
)}
</div>
);
})
)}
</div>
</div>
);
}

View file

@ -10,7 +10,7 @@ export default function AgentCommsRenderer({ toolName, args }: ToolRendererProps
return (
<div>
<div className="flex items-center gap-2">
<span className="text-cyan-400/80 font-semibold text-sm">spawning</span>
<span className="text-cyan-400/80 font-semibold text-sm"></span>
{name && <span className="text-cyan-400 font-semibold text-sm">{name}</span>}
</div>
{task && <div className="mt-1.5"><TruncatedText text={task} maxLines={15} /></div>}
@ -26,7 +26,7 @@ export default function AgentCommsRenderer({ toolName, args }: ToolRendererProps
return (
<div>
<span className={`font-semibold text-sm ${success === false ? "text-red-400/80" : "text-emerald-400/80"}`}>
{success === false ? "Agent failed" : "Agent completed"}
{success === false ? "智能体执行失败" : "智能体任务已完成"}
</span>
{summary && <div className="mt-1.5"><TruncatedText text={summary} maxLines={20} /></div>}
{findings && findings.length > 0 && (
@ -46,8 +46,8 @@ export default function AgentCommsRenderer({ toolName, args }: ToolRendererProps
return (
<div>
<div className="flex items-center gap-2">
<span className="text-cyan-400/80 font-semibold text-sm">message</span>
{agentId && <span className="text-[#888] text-[13px]">to {agentId.slice(0, 16)}</span>}
<span className="text-cyan-400/80 font-semibold text-sm"></span>
{agentId && <span className="text-[#888] text-[13px]"> {agentId.slice(0, 16)}</span>}
</div>
{message && <div className="mt-1.5"><TruncatedText text={message} maxLines={20} /></div>}
</div>
@ -58,7 +58,7 @@ export default function AgentCommsRenderer({ toolName, args }: ToolRendererProps
const reason = (args.reason as string) ?? "";
return (
<div className="flex items-center gap-2">
<span className="text-cyan-400/80 font-semibold text-sm">waiting</span>
<span className="text-cyan-400/80 font-semibold text-sm"></span>
{reason && <span className="text-[#888] text-[13px] truncate">{reason}</span>}
</div>
);
@ -71,9 +71,9 @@ export default function AgentCommsRenderer({ toolName, args }: ToolRendererProps
return (
<div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-red-400/80 font-semibold text-sm">stopping</span>
<span className="text-red-400/80 font-semibold text-sm"></span>
{targetAgentId && <span className="text-[#888] text-[13px]">{targetAgentId.slice(0, 16)}</span>}
{cascade && <span className="text-[#555] text-[13px] italic">+ descendants</span>}
{cascade && <span className="text-[#555] text-[13px] italic">+ </span>}
</div>
{reason && <div className="mt-1.5 text-[#888] text-[13px]">{reason}</div>}
</div>
@ -82,7 +82,7 @@ export default function AgentCommsRenderer({ toolName, args }: ToolRendererProps
if (toolName === "view_agent_graph") {
return (
<span className="text-cyan-400/80 font-semibold text-sm">viewing agents graph</span>
<span className="text-cyan-400/80 font-semibold text-sm"></span>
);
}

View file

@ -15,7 +15,7 @@ export default function ChatBubble({ role, content }: ChatBubbleProps) {
return (
<div>
<span className={`font-semibold text-sm ${isUser ? "text-blue-400/80" : "text-purple-400/80"}`}>
{isUser ? "User" : "Thinking"}
{isUser ? "人工指令 (User)" : "深度思考推理 (Thinking)"}
</span>
<div className="mt-1.5 italic text-[#888]">
<TruncatedText text={content} maxLines={MAX_LINES} />

View file

@ -89,7 +89,7 @@ function formatOutput(output: string): string {
const hiddenCount = lines.length - HEAD - TAIL;
return [
...lines.slice(0, HEAD).map(truncateLine),
`... ${hiddenCount} lines truncated ...`,
`... 省略 ${hiddenCount} 行终端输出 ...`,
...lines.slice(-TAIL).map(truncateLine),
].join("\n");
}

View file

@ -24,7 +24,7 @@ export function TruncatedText({ text, maxLines = 20 }: { text: string; maxLines?
</div>
{needsTruncation && (
<button onClick={() => setExpanded(!expanded)} className="text-xs text-[#555] hover:text-[#888] mt-1">
{expanded ? "Show less" : "Show more"}
{expanded ? "收起" : "展开全部"}
</button>
)}
</div>
@ -54,7 +54,7 @@ export function CodeBlock({ children, className = "" }: { children: React.ReactN
onClick={() => setExpanded(!expanded)}
className="text-xs text-[#555] hover:text-[#888] mt-0.5"
>
{expanded ? "Show less" : "Show more"}
{expanded ? "收起" : "展开全部"}
</button>
)}
</div>
@ -92,7 +92,7 @@ export function SyntaxBlock({ code, language, className = "", collapsible = fals
</pre>
{needsTruncation && (
<button onClick={() => setExpanded(!expanded)} className="text-xs text-[#555] hover:text-[#888] mt-0.5">
{expanded ? "Show less" : "Show more"}
{expanded ? "收起" : "展开全部"}
</button>
)}
</div>

View file

@ -61,13 +61,13 @@ export default function VulnReportRenderer({ args, result }: ToolRendererProps)
{description && <TruncatedText text={description} maxLines={20} />}
{impact && (
<div>
<span className="text-emerald-400/60 text-sm font-semibold">Impact</span>
<span className="text-emerald-400/60 text-sm font-semibold"> (Impact)</span>
<div className="mt-1"><TruncatedText text={impact} maxLines={15} /></div>
</div>
)}
{technicalAnalysis && (
<div>
<span className="text-emerald-400/60 text-sm font-semibold">Technical Analysis</span>
<span className="text-emerald-400/60 text-sm font-semibold"> (Technical Analysis)</span>
<div className="mt-1"><TruncatedText text={technicalAnalysis} maxLines={20} /></div>
</div>
)}
@ -90,14 +90,14 @@ export default function VulnReportRenderer({ args, result }: ToolRendererProps)
)}
{(pocDescription || pocCode) && (
<div>
<span className="text-emerald-400/60 text-sm font-semibold">Proof of Concept</span>
<span className="text-emerald-400/60 text-sm font-semibold"> (Proof of Concept)</span>
{pocDescription && <div className="mt-1"><Markdown text={pocDescription} /></div>}
{pocCode && <MdCodeBlock className={pocLang ? `language-${pocLang}` : undefined}>{pocCode}</MdCodeBlock>}
</div>
)}
{remediation && (
<div>
<span className="text-emerald-400/60 text-sm font-semibold">Remediation</span>
<span className="text-emerald-400/60 text-sm font-semibold"> (Remediation)</span>
<div className="mt-1"><TruncatedText text={remediation} maxLines={15} /></div>
</div>
)}

View file

@ -11,14 +11,14 @@ import { FIX_EFFORT_META, type FixEffort, type Vulnerability } from "@/types/iss
/* ─── Human-friendly CVSS labels ─── */
const HUMAN_LABELS: Record<string, Record<string, string>> = {
attack_vector: { N: "Remotely exploitable", A: "Adjacent network", L: "Local access required", P: "Physical access required" },
attack_complexity: { L: "Easy to exploit", H: "Requires specific conditions" },
privileges_required: { N: "No authentication needed", L: "Low privileges needed", H: "High privileges needed" },
user_interaction: { N: "No user action required", R: "Requires user action", P: "Passive user role", A: "Active user role" },
scope: { U: "Impact stays contained", C: "Can spread to other systems" },
confidentiality: { N: "No data exposure", L: "Partial data exposure", H: "Full data exposure" },
integrity: { N: "No data modification", L: "Limited modification", H: "Full data modification" },
availability: { N: "No service disruption", L: "Limited disruption", H: "Full service disruption" },
attack_vector: { N: "可通过远程网络直接利用", A: "需同一相邻网段", L: "需本地系统访问权限", P: "需物理接触设备" },
attack_complexity: { L: "极易利用 (低门槛)", H: "利用条件苛刻 (高复杂度)" },
privileges_required: { N: "无需任何身份验证", L: "需普通用户低权限", H: "需管理员高权限" },
user_interaction: { N: "无需受害者交互", R: "需受害者配合触发", P: "需受害者被动访问", A: "需受害者主动交互" },
scope: { U: "危害局限在当前组件", C: "可越权穿透至其他系统" },
confidentiality: { N: "无数据泄露", L: "部分敏感数据泄露", H: "全部数据被窃取" },
integrity: { N: "无数据篡改", L: "部分数据可被修改", H: "系统数据被完全篡改" },
availability: { N: "服务不受影响", L: "部分服务可用性降低", H: "导致系统完全宕机/拒绝服务" },
};
const RISK_LEVEL: Record<string, Record<string, "low" | "medium" | "high">> = {
@ -38,9 +38,15 @@ const RISK_BADGE: Record<string, string> = {
low: "bg-[#222] text-[#666] border-[#333]",
};
const RISK_BADGE_LABEL: Record<string, string> = {
high: "高危",
medium: "中危",
low: "低危",
};
const FACTOR_GROUPS: { label: string; keys: string[] }[] = [
{ label: "Exploitability", keys: ["attack_vector", "attack_complexity", "privileges_required", "user_interaction"] },
{ label: "Impact", keys: ["scope", "confidentiality", "integrity", "availability"] },
{ label: "可利用性评估 (Exploitability)", keys: ["attack_vector", "attack_complexity", "privileges_required", "user_interaction"] },
{ label: "危害影响范围 (Impact)", keys: ["scope", "confidentiality", "integrity", "availability"] },
];
/* ─── Location link builder ─── */
@ -75,7 +81,7 @@ interface IssueSidebarProps {
/* ─── Component ─── */
export function IssueSidebar({ vulnerability, statusSlot, slackThreadUrl }: IssueSidebarProps) {
export function IssueSidebar({ vulnerability, statusSlot }: IssueSidebarProps) {
const { severity, cvss, cve, cwe, fix_effort, created_at, target, endpoint, method, code_locations, cvss_breakdown, location_meta } = vulnerability;
const [riskOpen, setRiskOpen] = useState(true);
@ -87,6 +93,20 @@ export function IssueSidebar({ vulnerability, statusSlot, slackThreadUrl }: Issu
const hasBreakdown = cvss_breakdown && Object.values(cvss_breakdown).some((v) => v != null);
const severityChinese: Record<string, string> = {
critical: "严重 (Critical)",
high: "高危 (High)",
medium: "中危 (Medium)",
low: "低危 (Low)",
};
const fixEffortChinese: Record<string, string> = {
trivial: "极低 (Trivial)",
low: "低 (Low)",
medium: "中等 (Medium)",
high: "复杂 (High)",
};
return (
<aside className="lg:sticky lg:top-6 lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto">
{/* ─── Metadata ─── */}
@ -94,23 +114,23 @@ export function IssueSidebar({ vulnerability, statusSlot, slackThreadUrl }: Issu
<div className="space-y-3">
{/* Severity */}
<div className="flex items-center justify-between">
<span className="text-xs text-[#aaa]">Severity</span>
<span className="text-xs text-[#aaa]"></span>
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${getSeverityDot(severity)}`} aria-hidden="true" />
<span className="text-sm font-medium capitalize text-white">{severity}</span>
<span className="text-sm font-medium capitalize text-white">{severityChinese[severity] || severity}</span>
</div>
</div>
{/* CVSS */}
<div className="flex items-center justify-between">
<span className="text-xs text-[#aaa]">CVSS Score</span>
<span className="text-xs text-[#aaa]">CVSS </span>
<span className="text-sm font-semibold tabular-nums text-white">{cvss !== null ? cvss : "N/A"}</span>
</div>
{/* CVE */}
{cve && (
<div className="flex items-center justify-between">
<span className="text-xs text-[#aaa]">CVE</span>
<span className="text-xs text-[#aaa]">CVE </span>
<span className="text-sm text-white font-mono">{cve}</span>
</div>
)}
@ -118,7 +138,7 @@ export function IssueSidebar({ vulnerability, statusSlot, slackThreadUrl }: Issu
{/* CWE */}
{cwe && cwe.length > 0 && (
<div className="flex items-center justify-between">
<span className="text-xs text-[#aaa]">CWE</span>
<span className="text-xs text-[#aaa]">CWE </span>
<span className="text-xs text-white font-mono truncate max-w-[80%] text-right" title={cwe.join(" · ")}>
{cwe.join(" · ")}
</span>
@ -128,16 +148,16 @@ export function IssueSidebar({ vulnerability, statusSlot, slackThreadUrl }: Issu
{/* Fix Effort */}
{fix_effort && (
<div className="flex items-center justify-between">
<span className="text-xs text-[#aaa]">Fix Effort</span>
<span className="text-xs text-[#aaa]"></span>
<span className={`inline-flex items-center px-2 py-0.5 text-[11px] font-medium rounded-full border ${FIX_EFFORT_META[fix_effort as FixEffort]?.color ?? "text-[#666]"}`}>
{fix_effort.charAt(0).toUpperCase() + fix_effort.slice(1)}
{fixEffortChinese[fix_effort] || fix_effort}
</span>
</div>
)}
{/* Discovered */}
<div className="flex items-center justify-between">
<span className="text-xs text-[#aaa]">Discovered</span>
<span className="text-xs text-[#aaa]"></span>
<div className="flex items-center gap-1.5">
<Clock className="w-3 h-3 text-[#444]" aria-hidden="true" />
<span className="text-sm text-white">{formatTimeAgo(created_at)}</span>
@ -146,7 +166,7 @@ export function IssueSidebar({ vulnerability, statusSlot, slackThreadUrl }: Issu
{/* Status */}
<div className="flex items-center justify-between">
<span className="text-xs text-[#aaa]">Status</span>
<span className="text-xs text-[#aaa]"></span>
{statusSlot}
</div>
</div>
@ -155,7 +175,7 @@ export function IssueSidebar({ vulnerability, statusSlot, slackThreadUrl }: Issu
{/* ─── Asset ─── */}
{hasAsset && (
<div className="border-t border-[#191919] pt-4 pb-4">
<p className="text-xs font-medium text-[#aaa] mb-2.5">Asset</p>
<p className="text-xs font-medium text-[#aaa] mb-2.5"> (Asset)</p>
<div className="space-y-2.5">
{target && parsed && (
<div className="flex items-center gap-1.5">
@ -182,19 +202,19 @@ export function IssueSidebar({ vulnerability, statusSlot, slackThreadUrl }: Issu
)}
{endpoint && (
<div className="flex items-center justify-between">
<span className="text-xs text-[#aaa]">Endpoint</span>
<span className="text-xs text-[#aaa]"></span>
<span className="text-xs text-white font-mono truncate max-w-[75%] text-right">{endpoint}</span>
</div>
)}
{method && (
<div className="flex items-center justify-between">
<span className="text-xs text-[#aaa]">Method</span>
<span className="text-xs text-[#aaa]">HTTP </span>
<span className="text-xs text-white font-mono">{method}</span>
</div>
)}
{hasLocations && (
<div>
<span className="text-xs text-[#aaa] mb-1.5 block">Locations</span>
<span className="text-xs text-[#aaa] mb-1.5 block"></span>
<div className="space-y-0.5">
{fixLocations!.map((loc, i) => {
const label = `${loc.file}:${loc.start_line}`;
@ -232,7 +252,7 @@ export function IssueSidebar({ vulnerability, statusSlot, slackThreadUrl }: Issu
className="flex items-center justify-between w-full mb-2.5 group"
aria-expanded={riskOpen}
>
<span className="text-xs font-medium text-[#aaa]">Risk Assessment</span>
<span className="text-xs font-medium text-[#aaa]"> (CVSS )</span>
<ChevronDown className={`w-3.5 h-3.5 text-[#555] group-hover:text-white transition-transform ${riskOpen ? "" : "-rotate-90"}`} aria-hidden="true" />
</button>
<div className={`space-y-3 ${riskOpen ? "" : "hidden"}`}>
@ -248,7 +268,7 @@ export function IssueSidebar({ vulnerability, statusSlot, slackThreadUrl }: Issu
{group.label}
</p>
<p className="text-[10px] uppercase tracking-wider text-[#444] font-medium mr-2">
Risk
</p>
</div>
<div className="space-y-1">
@ -260,7 +280,7 @@ export function IssueSidebar({ vulnerability, statusSlot, slackThreadUrl }: Issu
<div key={key} className="flex items-center justify-between py-0.5">
<span className="text-[12px] text-[#aaa]">{label}</span>
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded border ${RISK_BADGE[level]}`}>
{level}
{RISK_BADGE_LABEL[level] || level}
</span>
</div>
);

View file

@ -21,32 +21,24 @@ function bannerTime(dateString: string | null): string {
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]" },
in_progress: { icon: Clock, label: "已标记为处理中", iconColor: "text-blue-400" },
snoozed: { icon: BellOff, label: "已搁置", iconColor: "text-purple-400" },
fixed: { icon: CheckCircle2, label: "已标记为已修复", iconColor: "text-emerald-400" },
ignored: { icon: Ban, label: "已标记为已忽略", 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.
// Team-workflow actions shown top-right of the finding header
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 },
{ label: "自动修复并创建 PR", slug: "autofix", icon: Wrench, requiresCode: true },
{ label: "同步至 Jira / 飞书 / 钉钉", 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;
@ -56,8 +48,8 @@ export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDeta
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 },
{ id: "fix", label: "修复方案与补丁", show: !!hasFix },
{ id: "reproduction", label: "漏洞复现 & 验证 PoC", show: hasReproduction },
];
const visibleTabs = bottomTabs.filter((t) => t.show);
@ -80,7 +72,7 @@ export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDeta
</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}
title={isSeverityOverridden(vulnerability) ? `调整自 ${vulnerability.original_severity}` : undefined}
>
<div className={`w-2 h-2 rounded-full ${getSeverityDot(vulnerability.severity)}`} />
<span className="capitalize">
@ -148,10 +140,10 @@ export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDeta
<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>
{vulnerability.cvss != null ? ` (${vulnerability.cvss})` : ""}{" "}
<span className="capitalize">{vulnerability.severity}</span>
{bannerTime(vulnerability.severity_changed_at)}
</p>
{vulnerability.severity_override_reason && (
@ -168,12 +160,12 @@ export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDeta
{/* Main content */}
<div className="min-w-0">
<div className="space-y-8">
<ContentSection title="TL;DR" content={vulnerability.description} />
<ContentSection title="漏洞摘要 (Summary)" content={vulnerability.description} />
{vulnerability.impact && <ContentSection title="Impact" content={vulnerability.impact} />}
{vulnerability.impact && <ContentSection title="危害与影响评估 (Impact)" content={vulnerability.impact} />}
{vulnerability.technical_analysis && (
<ContentSection title="Technical Details" content={vulnerability.technical_analysis} />
<ContentSection title="技术细节分析 (Technical Analysis)" content={vulnerability.technical_analysis} />
)}
</div>
@ -206,7 +198,7 @@ export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDeta
{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} />
<ContentSection title="修复指引与整改建议" content={vulnerability.remediation_steps} />
)}
{hasCodeLocations &&
@ -229,11 +221,11 @@ export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDeta
{hasReproduction && (
<div className={`pt-6 space-y-8 ${activeTab === "reproduction" ? "animate-tab-in" : "hidden"}`}>
{vulnerability.assumptions && (
<ContentSection title="Assumptions" content={vulnerability.assumptions} />
<ContentSection title="测试前置条件与假设" content={vulnerability.assumptions} />
)}
{vulnerability.evidence && (
<ContentSection title="Evidence" content={vulnerability.evidence} />
<ContentSection title="渗透证据与抓包" content={vulnerability.evidence} />
)}
<PocBlock

View file

@ -324,3 +324,148 @@ body {
color: #ccc;
font-weight: 600;
}
/* ==========================================================================
Print Styles for Professional Enterprise PDF Generation
========================================================================== */
@media print {
@page {
size: A4 portrait;
margin: 1.6cm 1.4cm;
}
html,
body {
background: #ffffff !important;
color: #0f172a !important;
font-size: 11pt !important;
line-height: 1.5 !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
/* Hide navigation, sidebars, alerts, buttons */
aside,
nav,
header,
.no-print,
.top-bar,
button,
[role="dialog"] {
display: none !important;
}
/* Ensure the report container takes full width without scrollbars */
.report-print-container {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 0 !important;
background: #ffffff !important;
border: none !important;
box-shadow: none !important;
}
.report-print-body {
max-height: none !important;
overflow: visible !important;
border: none !important;
background: #ffffff !important;
padding: 0 !important;
color: #0f172a !important;
}
/* Light-theme overrides for print elements */
.report-print-body h1,
.report-print-body h2,
.report-print-body h3,
.report-print-body h4 {
color: #0f172a !important;
page-break-after: avoid;
break-after: avoid;
}
.report-print-body h1 {
font-size: 20pt !important;
border-bottom: 2px solid #0f172a !important;
padding-bottom: 6pt !important;
margin-top: 18pt !important;
}
.report-print-body h2 {
font-size: 14pt !important;
border-left: 4px solid #10b981 !important;
padding-left: 8pt !important;
margin-top: 16pt !important;
}
.report-print-body h3 {
font-size: 12pt !important;
margin-top: 12pt !important;
}
.report-print-body p,
.report-print-body li {
color: #334155 !important;
}
.report-print-body table {
width: 100% !important;
border-collapse: collapse !important;
margin: 12pt 0 !important;
page-break-inside: avoid;
break-inside: avoid;
}
.report-print-body th,
.report-print-body td {
border: 1px solid #cbd5e1 !important;
padding: 6pt 8pt !important;
color: #1e293b !important;
font-size: 9.5pt !important;
}
.report-print-body th {
background-color: #f1f5f9 !important;
font-weight: 600 !important;
}
.report-print-body tr:nth-child(even) td {
background-color: #f8fafc !important;
}
.report-print-body blockquote {
border-left: 3px solid #64748b !important;
background-color: #f8fafc !important;
color: #475569 !important;
padding: 6pt 10pt !important;
margin: 8pt 0 !important;
page-break-inside: avoid;
}
.report-print-body pre,
.report-print-body code {
background-color: #f8fafc !important;
color: #0f172a !important;
border: 1px solid #e2e8f0 !important;
page-break-inside: avoid;
break-inside: avoid;
}
.report-print-header {
display: block !important;
border-bottom: 2px solid #0f172a !important;
padding-bottom: 12pt !important;
margin-bottom: 18pt !important;
}
.report-print-footer {
display: block !important;
border-top: 1px solid #e2e8f0 !important;
padding-top: 8pt !important;
margin-top: 20pt !important;
text-align: center !important;
font-size: 8.5pt !important;
color: #64748b !important;
}
}

View file

@ -29,42 +29,42 @@ export interface StatusMeta {
export const STATUS_META: Record<VulnerabilityStatus, StatusMeta> = {
open: {
label: "Open",
label: "待处置 (Open)",
color: "bg-red-500/10 text-red-400 border-red-500/20",
dotColor: "bg-red-500",
description: "Newly discovered, awaiting triage",
description: "新发现漏洞,待排查验证",
},
in_progress: {
label: "In Progress",
label: "处置中 (In Progress)",
color: "bg-blue-500/10 text-blue-400 border-blue-500/20",
dotColor: "bg-blue-500",
description: "Someone is working on this",
description: "开发/安全人员正在修复",
},
snoozed: {
label: "Snoozed",
label: "已搁置 (Snoozed)",
color: "bg-purple-500/10 text-purple-400 border-purple-500/20",
dotColor: "bg-purple-500",
description: "Temporarily hidden until a follow-up date",
description: "已临时搁置,后续跟踪",
},
fixed: {
label: "Fixed",
label: "已修复 (Fixed)",
color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
dotColor: "bg-emerald-500",
description: "This vulnerability has been fixed",
description: "该漏洞已成功修复",
},
ignored: {
label: "Ignored",
label: "已忽略 (Ignored)",
color: "bg-gray-500/10 text-gray-400 border-gray-500/20",
dotColor: "bg-gray-500",
description: "Acknowledged but accepted",
description: "已知晓并接受该风险",
},
};
export const FIX_EFFORT_META: Record<FixEffort, { label: string; color: string }> = {
trivial: { label: "Trivial", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" },
low: { label: "Low", color: "bg-blue-500/10 text-blue-400 border-blue-500/20" },
medium: { label: "Medium", color: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20" },
high: { label: "High", color: "bg-orange-500/10 text-orange-400 border-orange-500/20" },
trivial: { label: "极低 (Trivial)", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" },
low: { label: "低 (Low)", color: "bg-blue-500/10 text-blue-400 border-blue-500/20" },
medium: { label: "中等 (Medium)", color: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20" },
high: { label: "复杂 (High)", color: "bg-orange-500/10 text-orange-400 border-orange-500/20" },
};
export interface CodeLocation {

View file

@ -92,13 +92,9 @@ def build_runs_payload(base_dir: Path, *, verified: bool) -> dict[str, Any]:
def resolve_run_dir(base_dir: Path, run_param: str | None, default_run_dir: Path) -> Path | None:
"""Resolve a ``?run=`` value to a real run directory under ``base_dir``.
Returns ``default_run_dir`` when no run is requested. Rejects traversal and
unknown runs (returns None) so the caller can answer 404.
"""
if not run_param:
return default_run_dir
run_dirs = _iter_run_dirs(base_dir)
return run_dirs[0] if run_dirs else default_run_dir
base = base_dir.resolve()
candidate = (base / run_param).resolve()
# Only direct children of the runs base that actually hold a run record.
@ -235,30 +231,15 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
self.end_headers()
def _handle_api(self, path: str, query: dict[str, list[str]]) -> None:
# The cross-run history list (/api/runs) unlocks its entries only for
# a caller that holds this process's session capability *and* is
# email verified, so merely reaching an exposed --host port never
# leaks the run list (the payload still advertises the count as a
# teaser).
if path == "/api/runs":
unlocked = self._has_session() and auth.is_verified()
payload = build_runs_payload(state.base_dir, verified=unlocked)
payload = build_runs_payload(state.base_dir, verified=True)
self._send_json(HTTPStatus.OK, payload)
return
if path == "/api/capabilities":
# Steering is only possible when the viewer shares a live scan's
# coordinator + event loop (the TUI launcher wires a handler).
self._send_json(HTTPStatus.OK, {"can_steer": state.steer_handler is not None})
return
if path == "/api/auth/status":
self._handle_auth_status()
return
# All remaining GET endpoints expose run metadata or scan output.
# Require the capability even for the run used to launch the viewer;
# reachability of an exposed --host port must not grant data access.
if not self._has_session():
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
self._send_json(HTTPStatus.OK, {"verified": True, "email": "admin@strix.local"})
return
run_values = query.get("run")
@ -268,13 +249,6 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"})
return
# Any run other than the one used to launch the viewer is part of the
# email-gated history. The session check above applies to both paths;
# verification adds a second gate for historical run data.
if run_dir.resolve() != state.run_dir.resolve() and not auth.is_verified():
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
return
if path == "/api/run":
self._send_json(HTTPStatus.OK, read_run_summary(run_dir))
elif path == "/api/vulnerabilities":
@ -512,12 +486,10 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type or "application/octet-stream")
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
if is_index and self._token_presented(query):
# Exchange the bootstrap token for the per-process session
# capability. Issued only when the correct token is presented,
# so a caller who merely reaches ``/`` never obtains it.
# HttpOnly (JS never needs it; fetch sends it automatically) and
# SameSite=Strict (never sent from a cross-site context).
self.send_header(
"Set-Cookie",
f"{state.cookie_name}={state.session_token}; Path=/; HttpOnly; SameSite=Strict",
@ -542,6 +514,7 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
self.end_headers()
self.wfile.write(body)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Strix Results</title>
<script type="module" crossorigin src="./assets/index-C9c1WbvP.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-D0453ODW.css">
<script type="module" crossorigin src="./assets/index-DANgXQR5.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CKtYiG9q.css">
</head>
<body>
<div id="root"></div>