mirror of
https://github.com/usestrix/strix.git
synced 2026-08-28 05:25:00 +00:00
feat(viewer): support rich-rendered enterprise penetration report with professional PDF printing and HTML export
This commit is contained in:
parent
e89acc14c2
commit
fc1f3e065c
23 changed files with 2413 additions and 1599 deletions
|
|
@ -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]">
|
||||
排查 SSH、Web 管理控制台、VPN、SNMP 等常见开放端口及服务版本信息。
|
||||
</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}
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 <name></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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
10
strix/interface/viewer/static/assets/index-CKtYiG9q.css
Normal file
10
strix/interface/viewer/static/assets/index-CKtYiG9q.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
578
strix/interface/viewer/static/assets/index-DANgXQR5.js
Normal file
578
strix/interface/viewer/static/assets/index-DANgXQR5.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue