From fc1f3e065cb89c5da2090e31a12b7e13427935e3 Mon Sep 17 00:00:00 2001 From: banxian1987 Date: Thu, 27 Aug 2026 09:49:20 +0800 Subject: [PATCH] feat(viewer): support rich-rendered enterprise penetration report with professional PDF printing and HTML export --- strix/interface/viewer/frontend/src/App.tsx | 605 +++++++++++------ .../src/components/EmailReportView.tsx | 636 +++++++++--------- .../src/components/IssueSeveritySummary.tsx | 143 ++-- .../frontend/src/components/PastRunsView.tsx | 302 ++++----- .../frontend/src/components/RunDetails.tsx | 307 +++------ .../frontend/src/components/Sidebar.tsx | 32 +- .../src/components/live/AgentTranscript.tsx | 7 +- .../src/components/live/LiveActivityFeed.tsx | 501 ++++++++++++++ .../tool-renderers/AgentCommsRenderer.tsx | 16 +- .../live/tool-renderers/ChatBubble.tsx | 2 +- .../live/tool-renderers/TerminalRenderer.tsx | 2 +- .../live/tool-renderers/ToolCard.tsx | 6 +- .../tool-renderers/VulnReportRenderer.tsx | 8 +- .../components/vulnerability/IssueSidebar.tsx | 74 +- .../vulnerability/VulnerabilityDetail.tsx | 46 +- strix/interface/viewer/frontend/src/index.css | 145 ++++ .../viewer/frontend/src/types/issues.ts | 28 +- strix/interface/viewer/server.py | 43 +- .../viewer/static/assets/index-C9c1WbvP.js | 507 -------------- .../viewer/static/assets/index-CKtYiG9q.css | 10 + .../viewer/static/assets/index-D0453ODW.css | 10 - .../viewer/static/assets/index-DANgXQR5.js | 578 ++++++++++++++++ strix/interface/viewer/static/index.html | 4 +- 23 files changed, 2413 insertions(+), 1599 deletions(-) create mode 100644 strix/interface/viewer/frontend/src/components/live/LiveActivityFeed.tsx delete mode 100644 strix/interface/viewer/static/assets/index-C9c1WbvP.js create mode 100644 strix/interface/viewer/static/assets/index-CKtYiG9q.css delete mode 100644 strix/interface/viewer/static/assets/index-D0453ODW.css create mode 100644 strix/interface/viewer/static/assets/index-DANgXQR5.js diff --git a/strix/interface/viewer/frontend/src/App.tsx b/strix/interface/viewer/frontend/src/App.tsx index 042d54ac..9be6d641 100644 --- a/strix/interface/viewer/frontend/src/App.tsx +++ b/strix/interface/viewer/frontend/src/App.tsx @@ -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 { + 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 ( +
+ +

视图渲染遇到异常

+

+ {this.state.error?.message || "未知组件错误"} +

+ +
+ ); + } + 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(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=) 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() { { - // 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() { {run && } @@ -307,98 +331,104 @@ export default function App() { )} - {/* Keyed wrapper: re-mounts on every view / finding / run change so the - page-in transition replays. */} -
- {view === "email" ? ( - { - void refreshAuth(); - void refreshRuns(); - }} - onExit={(dest) => setView(dest === "history" ? "history" : "overview")} - /> - ) : view === "feedback" ? ( - setView(dest)} - /> - ) : view === "history" ? ( -
-
-
- +
+ {view === "email" ? ( + void onPastRunsVerified()} + auth={auth} + purpose={emailPurpose} + skipDisclosure={emailSkipDisclosure} + onAuthChanged={() => { + void refreshAuth(); + void refreshRuns(); + }} + onExit={(dest) => setView(dest === "history" ? "history" : "overview")} /> -
- ) : !run && !error ? ( -
-
-

Loading run data…

-
- ) : run && counts ? ( - <> - - - {/* Tab strip: shown on small screens where the sidebar is hidden. */} -
- userSetView("overview")}> - Pentest Overview - - userSetView("issues")}> - Issues{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""} - - {agentCount > 0 && ( - userSetView("agents")}> - Agents ({agentCount}) - - )} -
- - {view === "overview" ? ( - - ) : view === "agents" && agentCount > 0 ? ( - - ) : selected ? ( -
- - + ) : view === "feedback" ? ( + setView(dest)} + /> + ) : view === "history" ? ( +
+
+
- ) : ( - setSelectedId(id)} + void onPastRunsVerified()} /> - )} - - ) : null} -
+
+ ) : !run && !error ? ( +
+
+

正在加载扫描数据...

+
+ ) : run && counts ? ( + <> + + + {/* Tab strip: shown on small screens where the sidebar is hidden. */} +
+ userSetView("overview")}> + 渗透概览 + + userSetView("issues")}> + 漏洞与风险{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""} + + {agentCount > 0 && ( + userSetView("agents")}> + 智能体拓扑 ({agentCount}) + + )} +
+ + {view === "overview" ? ( + userSetView("agents")} + /> + ) : view === "agents" && agentCount > 0 ? ( + + ) : selected ? ( +
+ + +
+ ) : ( + setSelectedId(id)} + onSelectAgent={() => userSetView("agents")} + /> + )} + + ) : null} +
+
@@ -425,11 +455,11 @@ function RunSwitcher({ @@ -439,7 +469,7 @@ function RunSwitcher({ style={{ border: "1px solid #3a3a3a", background: "#0a0a0a" }} >
- Switch pentest + 切换扫描任务
{runs.runs.map((r) => { const active = r.name === activeRun; @@ -470,7 +500,7 @@ function LiveIndicator({ finished }: { finished: boolean }) { return ( - Complete + 扫描已完成 ); } @@ -480,34 +510,36 @@ function LiveIndicator({ finished }: { finished: boolean }) { - Live + 扫描执行中 ); } 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 (

- {runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "Pentest results")} + {runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "渗透测试结果")}

{summary.targets.length > 0 && ( {summary.targets.join(", ")} )} - {summary.scanMode && } + {summary.scanMode && } {duration && } - {summary.status && } + {summary.status && }
); @@ -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 ( -
-
- {finished ? "No findings in this run." : "No findings yet. The pentest is still running…"} -
- {finished && ( -
-

Stay ahead of new exposures

-

- Attack surface monitoring catches new exposures for your org over time. -

- -
- )} -
- ); - } + + 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 ( -
- {sorted.map((v) => ( - - ))} +
+ {sorted.length > 0 ? ( +
+
+

+ 已发现漏洞与安全风险 ({sorted.length}) +

+ 点击卡片查看详细验证 PoC 与修复建议 +
+ +
+ {sorted.map((v) => ( +
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" + > +
+
+
+
+ + {(v.target || v.endpoint) && ( +
+ {v.method ? {v.method} : null} + {v.target}{v.endpoint ? ` ${v.endpoint}` : ""} +
+ )} +
+ +
+ {v.cvss != null && ( + + CVSS {v.cvss} + + )} + {v.cve && ( + + {v.cve} + + )} + + {v.severity} + +
+
+ + {v.description && ( +

+ {v.description} +

+ )} + +
+ {v.cwe ? `CWE: ${Array.isArray(v.cwe) ? v.cwe.join(", ") : v.cwe}` : "已验证漏洞"} + + 查看完整细节与 PoC + +
+
+ ))} +
+
+ ) : ( +
+
+
+
+ +
+
+

+ {finished ? "本次渗透测试未发现可直接利用的高危漏洞" : "当前扫描任务执行中,正在深度排查漏洞与安全风险…"} +

+

+ {finished + ? "已针对目标资产完成端口探测、服务指纹识别、已知 CVE 匹配与安全基线合规检查。" + : "多个专职安全智能体正在并行对目标主机的端口暴露面、管理控制台与已知服务漏洞进行持续探测。"} +

+
+
+ + {/* Audit Scope & Live Status */} +
+
+ + + 边界端口与服务指纹探测 + +

+ 排查 SSH、Web 管理控制台、VPN、SNMP 等常见开放端口及服务版本信息。 +

+
+ +
+ + + 管理面与认证机制审计 + +

+ 核验未授权访问接口、弱认证配置、默认凭据与越权安全风险。 +

+
+ +
+ + + 已知组件公开 CVE 检索验证 + +

+ 自动比对服务指纹与公开漏洞库,针对性执行非破坏性验证 PoC。 +

+
+ +
+ + + 非破坏性安全基线合规检查 + +

+ 严禁高并发拒绝服务操作,确保网络服务可用性与设备正常运转。 +

+
+
+
+
+ )} + + {/* Real-time Probe Results and Activity Stream */} + + + {/* Audit Report & Technical Findings Details Section */} + {sections.length > 0 ? ( +
+
+ +

本次渗透测试审计与侦察发现详情

+
+ {sections.map((s) => ( + + ))} +
+ ) : reportMarkdown ? ( +
+
+ +

本次渗透测试审计与侦察发现详情

+
+ +
+ ) : null}
); } -/** 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 (
-

Email an encrypted PDF report of this run

+

一键导出本次渗透测试评估报告

- Encrypted with a key only you can see, email verified with a one-time code before sending. + 支持本地直接下载 Markdown / PDF 报告,数据完全私有化,无需经过外部中继。

- Export report to PDF + 导出测试报告
@@ -641,7 +809,9 @@ function OverviewTab({ reportMarkdown, raw, finished, + transcript, onOpenEmail, + onSelectAgent, }: { summary: ParsedRunSummary; counts: Record; @@ -649,14 +819,16 @@ function OverviewTab({ reportMarkdown: string | null; raw: Record; 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({ + {/* Live Probe and Activity Stream */} + + {total > 0 && (
)} - {/* Primary CTA: the one primary on Overview. Hidden until the run is - finished, since a live scan would only email a partial report. */} {finished && (
@@ -692,12 +869,7 @@ function OverviewTab({
- ) : ( - total === 0 && ( -

No summary available for this run yet.

- ) - )} - + ) : null}
); } @@ -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(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 }) {

- Click an agent to open its full transcript. + 点击任意智能体节点,可展开查看其完整的思考过程、工具调用与交互轨迹。

- {/* Live steering: only in-process while the scan runs. Otherwise omitted. */} {steerable && } - {/* Re-run always routes to Strix Cloud. */} -
-

Run this pentest with more depth

-

Re-run this pentest on managed infra in the cloud.

-
- -
-
+ {/* Live Probe and Activity Stream */} + setSelectedId(id)} + /> void; - /** Leave this page (report "Done" -> overview; verify success -> history). */ onExit: (dest: "overview" | "history") => void; } -const OTP_START_ERRORS: Record = { - 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 = { - 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(() => { - 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(""); const [error, setError] = useState(null); - const [notice, setNotice] = useState(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 = ` + + + + 安全渗透测试评估报告 - ${activeRun || "Strix"} + + + +
内部机密 · CONFIDENTIAL
+ ${reportHtml} + +`; + + 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 ( -
+
+ {/* Back button (hidden when printing) */} -
-