Files
dsh-purge/lib/redteam/client.js
T
叫我小杨同学andCursor 76e64d65e0 fix: 发布 1.1.65,截图报告与默认提示词
接入上游截图登记和六段报告,默认提示词按当前文稿重新加密。缺外部工具或密钥时技能仍可加载。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 16:52:44 +08:00

4219 lines
271 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* RedTeam 控制台 —— 浏览器半侧(手写 bundle)
*
* 格式与外壳的模块加载器一致:window.__ModuleLoader__.load({ id, factory })。
* 源文件由 scripts/build-client.mjs 抽成工厂片段,嵌进根目录 client.js(id: dsh-purge)。
* 本文件不再作为独立浏览器模块加载;id 仅作源码占位,便于 build 脚本定位 factory。
* 基座外无依赖:只用平台 seed 里的 react 与 ctx.slots。
*/
window.__ModuleLoader__.load({
id: 'dsh-purge',
factory: (require) => {
var module = { exports: {} }
var exports = module.exports
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
const React = require('react')
const h = React.createElement
/*
* 演练台自己是一块浮动窗:position:fixed,用 left/top/width/height 放。
* 不给官方 frame 加 padding,不改官方输入框的宽度。嵌在清洗面板里时走 .rt-embedded。
*/
const CSS = `
.rt-dock{position:fixed;top:12px;right:12px;z-index:20;display:flex;flex-direction:column;box-sizing:border-box;
background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#1c1c1c) 52%,transparent);
backdrop-filter:blur(20px) saturate(1.3);-webkit-backdrop-filter:blur(20px) saturate(1.3);
border:1px solid color-mix(in srgb,var(--dsw-alias-border-l1,#444) 70%,transparent);
border-radius:12px;
box-shadow:0 18px 48px color-mix(in srgb,#000 28%,transparent);pointer-events:auto;color:var(--dsw-alias-label-primary);
font-size:13px;line-height:1.5}
.rt-dock.rt-embedded{position:relative;inset:auto;width:100%;height:100%;max-width:none;border:0;border-radius:0;box-shadow:none;background:transparent!important;backdrop-filter:none;-webkit-backdrop-filter:none}
.rt-grip,.rt-resize-r,.rt-resize-b,.rt-resize-c{position:absolute;z-index:3;touch-action:none}
.rt-grip{left:-3px;top:0;bottom:0;width:8px;cursor:ew-resize;background:transparent}
.rt-resize-r{right:-3px;top:0;bottom:14px;width:8px;cursor:ew-resize}
.rt-resize-b{left:8px;right:14px;bottom:-3px;height:8px;cursor:ns-resize}
.rt-resize-c{right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize}
.rt-head{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--dsw-alias-border-l1)}
.rt-title{font-weight:600;font-size:14px;display:flex;align-items:center;gap:6px;white-space:nowrap}
.rt-dot{width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-brand-primary)}
.rt-spacer{flex:1}
.rt-btn{border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:inherit;
border-radius:6px;padding:3px 9px;font-size:12px;cursor:pointer;font-family:inherit;white-space:nowrap}
.rt-btn:hover{border-color:var(--dsw-alias-border-l2)}
.rt-btn-primary{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);color:#fff}
.rt-btn-primary:hover{opacity:.9}
.rt-btn:disabled{opacity:.5;cursor:default}
.rt-tabs{display:flex;flex-wrap:wrap;gap:2px;padding:6px 12px 0;border-bottom:1px solid var(--dsw-alias-border-l1)}
.rt-tab{padding:8px 10px 7px;border-radius:0;cursor:pointer;font-size:12.5px;color:var(--dsw-alias-label-secondary);
display:inline-flex;align-items:center;gap:5px;border-bottom:2px solid transparent;margin-bottom:-1px;background:transparent}
.rt-tab:hover{color:var(--dsw-alias-label-primary)}
.rt-tab.on{color:var(--dsw-alias-label-primary);background:transparent;font-weight:600;border-bottom-color:var(--dsw-alias-brand-primary,#6dbf8c)}
/* 未读红点:该页签有新内容(新资产/新漏洞/新得分/新步骤…),点开看过就消失 */
.rt-tab-dot{width:7px;height:7px;border-radius:50%;background:#ef4444;flex:none;
box-shadow:0 0 0 2px color-mix(in srgb, #ef4444 22%, transparent)}
.rt-body{flex:1;min-height:0;display:flex;flex-direction:column}
.rt-split{flex:1;min-height:0;display:flex}
.rt-side{width:200px;flex:none;border-right:1px solid var(--dsw-alias-border-l1);overflow:auto;padding:8px}
.rt-main{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;overflow:hidden}
.rt-seg{padding:7px 8px;border-radius:6px;cursor:pointer;margin-bottom:4px;border:1px solid transparent}
.rt-seg:hover{background:var(--dsw-alias-bg-layer-2)}
.rt-seg.on{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-brand-primary)}
.rt-seg-cidr{font-family:ui-monospace,Menlo,monospace;font-size:12.5px}
.rt-seg-meta{font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:2px}
.rt-toolbar{display:flex;gap:6px;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);flex-wrap:wrap;align-items:center}
.rt-input{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);color:inherit;
border-radius:6px;padding:4px 8px;font-size:12px;font-family:inherit;outline:none;min-width:0}
.rt-input:focus{border-color:var(--dsw-alias-brand-primary)}
.rt-input option,.rt-eng-select option{background:var(--dsw-alias-bg-layer-1,#1c1c1c);color:var(--dsw-alias-label-primary,#f2f2f2)}
.rt-eng-select{color:var(--dsw-alias-label-primary,#f2f2f2);background:var(--dsw-alias-bg-layer-2,#2a2a2a)}
.rt-table{flex:1;overflow:auto}
.rt-row{display:grid;grid-template-columns:150px 74px 104px 1.15fr 1fr;gap:8px;padding:6px 10px;
border-bottom:1px solid var(--dsw-alias-border-l1);align-items:center;cursor:pointer;font-size:12.5px}
.rt-row:hover{background:var(--dsw-alias-bg-layer-2)}
.rt-row.head{cursor:default;color:var(--dsw-alias-label-secondary);font-size:11.5px;font-weight:600;position:sticky;top:0;
background:var(--dsw-alias-bg-layer-1);z-index:1}
.rt-row.head:hover{background:var(--dsw-alias-bg-layer-1)}
.rt-mono{font-family:ui-monospace,Menlo,monospace}
.rt-tag{display:inline-block;padding:0 5px;border-radius:4px;font-size:11px;margin-right:4px;
border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary);white-space:nowrap}
.rt-tag-passive{color:#8b5cf6;border-color:#8b5cf655;background:#8b5cf61a}
.rt-tag-active{color:#f59e0b;border-color:#f59e0b55;background:#f59e0b1a}
.rt-tag-live{color:#10b981;border-color:#10b98155;background:#10b9811a}
.rt-tag-warn{color:#ef4444;border-color:#ef444455;background:#ef44441a}
/* 纪律提示条:不满足交付要求(非冰蝎/哥斯拉马、没有 suo5 隧道)时顶在区块最上方 */
.rt-hint{border-radius:6px;padding:7px 9px;margin-bottom:8px;font-size:11.5px;line-height:1.6;
border:1px dashed #ef444488;background:#ef44440f;color:var(--dsw-alias-label-primary)}
.rt-hint b{color:#ef4444}
.rt-tag-dead{color:var(--dsw-alias-label-secondary)}
.rt-expand{grid-column:1/-1;padding:8px 4px 10px;font-size:12px;color:var(--dsw-alias-label-secondary)}
.rt-kv{display:flex;gap:8px;margin-bottom:3px;align-items:baseline}
.rt-kv b{color:var(--dsw-alias-label-primary);font-weight:600;min-width:64px;flex:none}
/* 图谱视图已移除(见 AssetsTab:资产关系由「域名维度」与 redteam_attack_path 工具承担) */
.rt-pane{flex:1;min-height:0;overflow:auto;padding:12px}
.rt-card{border:1px solid color-mix(in srgb,var(--dsw-alias-border-l1,#444) 75%,transparent);border-radius:8px;padding:10px;margin-bottom:10px;
background:color-mix(in srgb,var(--dsw-alias-bg-layer-2,#2a2a2a) 48%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
.rt-card h4{margin:0 0 6px;font-size:13px}
.rt-textarea{width:100%;min-height:260px;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);
color:inherit;border-radius:6px;padding:8px;font-size:12.5px;font-family:ui-monospace,Menlo,monospace;
line-height:1.6;resize:vertical;outline:none;box-sizing:border-box}
.rt-textarea:focus{border-color:var(--dsw-alias-brand-primary)}
.rt-list{width:210px;flex:none;border-right:1px solid var(--dsw-alias-border-l1);overflow:auto;padding:8px}
.rt-item{padding:7px 8px;border-radius:6px;cursor:pointer;margin-bottom:4px;border:1px solid transparent}
.rt-item:hover{background:var(--dsw-alias-bg-layer-2)}
.rt-item.on{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-brand-primary)}
.rt-item-name{font-weight:600;font-size:12.5px}
.rt-item-desc{font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:2px;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.rt-empty{padding:24px;text-align:center;color:var(--dsw-alias-label-secondary);font-size:12.5px}
.rt-err{color:var(--dsw-alias-state-error-primary);font-size:12px;padding:6px 10px}
.rt-foot{padding:8px 12px;border-top:1px solid var(--dsw-alias-border-l1);font-size:11px;
color:var(--dsw-alias-label-secondary);display:flex;gap:12px;flex-wrap:wrap;align-items:center}
.rt-icon-btn{display:flex;align-items:center;justify-content:center;gap:6px;width:100%;border:1px solid var(--dsw-alias-border-l1);
background:transparent;color:inherit;border-radius:6px;padding:6px 8px;cursor:pointer;font-family:inherit;font-size:12.5px}
.rt-icon-btn:hover{background:var(--dsw-alias-bg-layer-2)}
.rt-icon-btn.on{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}
.rt-hbtn{border:1px solid var(--dsw-alias-border-l1);background:transparent;color:inherit;border-radius:6px;
padding:2px 8px;font-size:12px;cursor:pointer;font-family:inherit;display:flex;align-items:center;gap:5px}
.rt-hbtn:hover{background:var(--dsw-alias-bg-layer-2)}
.rt-hbtn.on{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}
.rt-test{display:inline-block;padding:0 5px;border-radius:4px;font-size:11px;white-space:nowrap;
border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary)}
.rt-test-testing{color:#f59e0b;border-color:#f59e0b55;background:#f59e0b1a}
.rt-test-tested{color:#10b981;border-color:#10b98155;background:#10b9811a}
.rt-test-blocked{color:#fff;background:#ef4444;border-color:#ef4444}
.rt-test-abandoned{color:#94a3b8;border-color:#94a3b855;background:#94a3b81a}
.rt-test-no_surface{color:#6366f1;border-color:#6366f155;background:#6366f11a}
.rt-scope-testing{color:#5b21b6;background:#ddd6fe}
.rt-pri{display:inline-block;padding:0 6px;border-radius:4px;font-size:11px;font-weight:600;white-space:nowrap}
.rt-pri-high{color:#fff;background:#ef4444}
.rt-pri-medium{color:#fff;background:#f59e0b}
.rt-pri-low{color:#fff;background:#94a3b8}
.rt-score-row{display:grid;grid-template-columns:16px 62px minmax(0,1fr) 116px 66px;gap:8px;padding:7px 10px;
border-bottom:1px solid var(--dsw-alias-border-l1);align-items:center;font-size:12.5px;cursor:pointer}
.rt-score-row:hover{background:var(--dsw-alias-bg-layer-2)}
.rt-score-row.head{cursor:default;color:var(--dsw-alias-label-secondary);font-size:11.5px;font-weight:600;
position:sticky;top:0;background:var(--dsw-alias-bg-layer-1);z-index:1}
.rt-score-detail{grid-column:1/-1;padding:8px 4px 10px;font-size:12px;color:var(--dsw-alias-label-secondary)}
/* 得分目标按合并版的 8 个类别分组:类别头 + 组内按分值升序 */
.rt-score-group{display:flex;align-items:center;gap:8px;padding:7px 10px 5px;margin-top:2px;
font-size:12px;font-weight:600;border-top:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2)}
.rt-score-group:first-child{border-top:none}
.rt-score-group .rt-sg-sub{font-weight:400;font-size:11px;color:var(--dsw-alias-label-secondary)}
/* ── 会话与入口:每张卡片分「标题行 / 关键事实 / 可用命令 / 备注」四段,避免一行糊在一起 ── */
.rt-sess-facts{display:flex;flex-direction:column;gap:2px;margin-top:5px}
.rt-sess-fact{display:flex;gap:6px;font-size:11.5px;line-height:1.5}
.rt-sess-fact>b{flex:none;min-width:62px;font-weight:600;color:var(--dsw-alias-label-secondary)}
.rt-sess-fact>span{min-width:0;overflow-wrap:anywhere}
.rt-sess-cmd{margin-top:6px}
.rt-sess-cmd>b{display:block;font-size:11px;color:var(--dsw-alias-label-secondary);margin-bottom:3px;font-weight:600}
.rt-sess-fold{margin-top:6px;font-size:11.5px}
.rt-sess-fold>summary{cursor:pointer;color:var(--dsw-alias-label-secondary);user-select:none}
.rt-sess-fold>summary:hover{color:var(--dsw-alias-label-primary)}
.rt-score-row .rt-scope{font-size:10.5px;color:var(--dsw-alias-label-secondary)}
.rt-score-form{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:6px}
.rt-score-form input,.rt-score-form select{width:100%;box-sizing:border-box}
.rt-vrow{display:grid;grid-template-columns:62px minmax(0,1fr) 132px 108px 74px 52px;gap:8px;padding:6px 10px;
border-bottom:1px solid var(--dsw-alias-border-l1);align-items:center;font-size:12.5px;cursor:pointer}
.rt-vrow:hover{background:var(--dsw-alias-bg-layer-2)}
.rt-vrow.head{cursor:default;color:var(--dsw-alias-label-secondary);font-size:11.5px;font-weight:600;
position:sticky;top:0;background:var(--dsw-alias-bg-layer-1);z-index:1}
.rt-vdetail{grid-column:1/-1;padding:8px 4px 10px;font-size:12px;color:var(--dsw-alias-label-secondary)}
.rt-vdetail .rt-kv{margin-bottom:4px}
.rt-actions{display:flex;gap:6px;margin-top:6px}
.rt-section{padding:8px 10px 2px;font-size:11.5px;font-weight:600;color:var(--dsw-alias-label-secondary)}
.rt-link{color:var(--dsw-alias-brand-primary);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rt-link:hover{text-decoration:underline}
.rt-full{position:fixed;inset:0;z-index:60;display:flex;flex-direction:column;
background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5;
border-top:3px solid var(--dsw-alias-brand-primary)}
.rt-full .rt-head{padding:12px 18px}
.rt-full .rt-tabs{padding:10px 18px 0;gap:6px;flex-wrap:wrap}
.rt-full .rt-side{width:260px}
.rt-full .rt-list{width:280px}
.rt-full .rt-row{grid-template-columns:190px 90px 130px 1.4fr 1.2fr}
.rt-full .rt-vrow{grid-template-columns:80px minmax(0,1.6fr) 200px 150px 90px 64px}
.rt-full .rt-pane{padding:18px}
.rt-full .rt-textarea{min-height:60vh}
.rt-full .rt-foot{padding:10px 18px;font-size:12px}
.rt-full .rt-body{max-width:1400px;width:100%;margin:0 auto;flex:1;min-height:0;display:flex;flex-direction:column}
.rt-chain{flex:1;overflow:auto;padding:10px 12px}
.rt-step{display:flex;gap:10px;padding:8px 6px;border-left:2px solid var(--dsw-alias-border-l1);margin-left:6px}
.rt-step:last-child{border-left-color:transparent}
.rt-step-dot{width:22px;height:22px;flex:none;border-radius:50%;display:flex;align-items:center;justify-content:center;
font-size:11px;font-weight:700;color:#fff;background:#64748b;margin-left:-13px}
.rt-step-body{min-width:0}
.rt-step-title{font-weight:600;font-size:13px}
.rt-step-meta{font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-top:2px;word-break:break-word}
.rt-stage-recon{background:#6366f1}
.rt-stage-vuln{background:#f59e0b}
.rt-stage-exploit{background:#ef4444}
.rt-stage-access{background:#10b981}
.rt-stage-pivot{background:#8b5cf6}
.rt-stage-data{background:#0ea5e9}
.rt-stage-other{background:#64748b}
.rt-step-head{display:flex;align-items:baseline;gap:6px;flex-wrap:wrap}
.rt-step-time{font-size:11px;color:var(--dsw-alias-label-secondary);margin-left:auto;white-space:nowrap}
.rt-step-detail{font-size:12px;margin-top:5px;white-space:pre-wrap;word-break:break-word;
border-left:2px solid var(--dsw-alias-border-l1);padding:2px 0 2px 9px;line-height:1.6}
.rt-chip{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;padding:1px 7px;border-radius:5px;
border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);margin:4px 5px 0 0;max-width:100%}
.rt-chip>i{font-style:normal;color:var(--dsw-alias-label-secondary);font-size:10.5px}
.rt-chip>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:420px}
.rt-stage-tag{display:inline-block;padding:0 6px;border-radius:4px;font-size:11px;color:#fff;font-weight:600}
.rt-st-recon{background:#6366f1}.rt-st-vuln{background:#f59e0b}.rt-st-exploit{background:#ef4444}
.rt-st-access{background:#10b981}.rt-st-pivot{background:#8b5cf6}.rt-st-data{background:#0ea5e9}.rt-st-other{background:#64748b}
.rt-sev{display:inline-block;padding:0 6px;border-radius:4px;font-size:11px;font-weight:600;border:1px solid transparent}
.rt-sev-critical{color:#fff;background:#b91c1c}.rt-sev-high{color:#fff;background:#ef4444}
.rt-sev-medium{color:#7c2d12;background:#fdba74}.rt-sev-low{color:#1e3a8a;background:#bfdbfe}
.rt-sev-info{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-border-l1)}
.rt-dot-on{display:inline-block;width:7px;height:7px;border-radius:50%;background:#10b981;box-shadow:0 0 0 3px #10b98133}
.rt-dot-off{display:inline-block;width:7px;height:7px;border-radius:50%;background:#ef4444;box-shadow:0 0 0 3px #ef444433}
.rt-dot-unk{display:inline-block;width:7px;height:7px;border-radius:50%;background:#94a3b8;box-shadow:0 0 0 3px #94a3b833}
.rt-sess-grid{display:grid;grid-template-columns:1fr;gap:8px;padding:10px 12px}
.rt-sess{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:9px 10px;background:var(--dsw-alias-bg-layer-2)}
.rt-sess-head{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
.rt-sess-title{font-weight:600;font-size:12.5px;font-family:ui-monospace,Menlo,monospace;word-break:break-all}
.rt-sess-sub{font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-top:3px;word-break:break-word}
.rt-code{font-family:ui-monospace,Menlo,monospace;font-size:11px;background:var(--dsw-alias-bg-base);
border:1px solid var(--dsw-alias-border-l1);border-radius:4px;padding:1px 5px;cursor:pointer;word-break:break-all}
.rt-code:hover{border-color:var(--dsw-alias-brand-primary)}
.rt-evi{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;overflow:hidden;margin-top:8px}
.rt-evi-head{display:flex;align-items:center;gap:8px;padding:5px 9px;background:var(--dsw-alias-bg-layer-2);
font-size:11.5px;font-weight:600;border-bottom:1px solid var(--dsw-alias-border-l1)}
.rt-evi-body{margin:0;padding:9px 11px;font-family:ui-monospace,Menlo,monospace;font-size:11.5px;line-height:1.6;
white-space:pre-wrap;word-break:break-word;max-height:340px;overflow:auto;background:var(--dsw-alias-bg-base)}
.rt-evi-body.req{max-height:220px}
.rt-hl-req{color:#10b981;font-weight:600}
.rt-hl-res{color:#0ea5e9;font-weight:600}
.rt-gain{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:600;padding:2px 8px;border-radius:12px;
color:#065f46;background:#a7f3d0;border:1px solid #10b98155}
.rt-total{font-size:20px;font-weight:700;font-family:ui-monospace,Menlo,monospace}
.rt-sidehead{padding:7px 8px 2px;font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);
display:flex;align-items:center;gap:5px}
.rt-sidehead-btn{cursor:pointer;outline:none;padding:6px 6px 5px;border-radius:5px;user-select:none}
.rt-sidehead-btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1)}
.rt-sidehead-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
.rt-scope{display:inline-block;padding:0 4px;border-radius:3px;font-size:10px;font-weight:700;line-height:15px;flex:none}
.rt-scope-internal{color:#0e7490;background:#a5f3fc}
.rt-scope-external{color:#9a3412;background:#fed7aa}
.rt-hits{display:flex;flex-direction:column;gap:6px;margin-top:7px}
.rt-hit{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #10b981;border-radius:6px;
padding:7px 9px;background:var(--dsw-alias-bg-layer-2)}
.rt-hit-head{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
.rt-hit-idx{width:16px;height:16px;border-radius:50%;background:#10b981;color:#fff;font-size:10.5px;font-weight:700;
display:inline-flex;align-items:center;justify-content:center;flex:none}
.rt-hit-target{font-family:ui-monospace,Menlo,monospace;font-weight:600;font-size:12px;word-break:break-all}
.rt-hit-time{margin-left:auto;font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
.rt-hit-evi{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;line-height:1.65;white-space:pre-wrap;word-break:break-word;
background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);border-radius:5px;padding:6px 8px;margin-top:5px}
.rt-hit-note{font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-top:4px}
.rt-cred{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #f59e0b;border-radius:6px;
padding:8px 10px;background:var(--dsw-alias-bg-layer-2);margin-bottom:7px}
.rt-cred-head{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
.rt-cred-host{font-family:ui-monospace,Menlo,monospace;font-weight:600;font-size:12.5px;word-break:break-all}
.rt-secret{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;line-height:1.6;background:#fef3c7;color:#78350f;
border:1px solid #f59e0b66;border-radius:5px;padding:6px 9px;margin-top:6px;white-space:pre-wrap;word-break:break-all;
user-select:all;cursor:text}
.rt-secret-none{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;color:var(--dsw-alias-state-error-primary);
border:1px dashed var(--dsw-alias-state-error-primary);border-radius:5px;padding:5px 9px;margin-top:6px}
.rt-cred-meta{font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-top:5px;word-break:break-word}
.rt-stage-score{background:#10b981}
.rt-counted{font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700;color:#065f46;background:#a7f3d0;border:1px solid #10b98155;border-radius:9px;padding:0 7px}
.rt-scorepts{font-size:11.5px;font-weight:700;color:#065f46;background:#a7f3d0;border:1px solid #10b98155;
border-radius:10px;padding:0 7px;white-space:nowrap}
.rt-livebar{display:flex;align-items:center;gap:7px;padding:7px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);
background:var(--dsw-alias-bg-layer-2);flex-wrap:wrap}
.rt-live-dot{width:8px;height:8px;border-radius:50%;background:#10b981;flex:none;animation:rt-pulse 1.6s ease-in-out infinite}
.rt-live-dot.idle{background:#94a3b8;animation:none}
@keyframes rt-pulse{0%,100%{opacity:1;box-shadow:0 0 0 0 #10b98166}50%{opacity:.5;box-shadow:0 0 0 5px #10b98100}}
.rt-live-body{padding:8px 12px 2px;max-height:44vh;overflow:auto}
.rt-atest{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #10b981;border-radius:6px;
padding:7px 10px;background:var(--dsw-alias-bg-layer-2);margin-bottom:6px}
.rt-atest.past{border-left-color:#94a3b8;opacity:.85}
.rt-atest-head{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
.rt-atest-ip{font-family:ui-monospace,Menlo,monospace;font-weight:600;font-size:12.5px;word-break:break-all}
.rt-atest-meta{font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-top:3px;word-break:break-word}
.rt-atest-notes{font-family:ui-monospace,Menlo,monospace;font-size:11px;line-height:1.6;white-space:pre-wrap;
word-break:break-word;background:var(--dsw-alias-bg-base);border-radius:4px;padding:5px 7px;margin-top:4px;max-height:76px;overflow:auto}
.rt-concl{display:flex;align-items:center;gap:4px;padding:6px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);
background:var(--dsw-alias-bg-layer-2);flex-wrap:wrap}
.rt-concl-i{display:inline-flex;align-items:baseline;gap:4px;padding:2px 8px;border-radius:6px;cursor:pointer;
border:1px solid transparent}
.rt-concl-i:hover{border-color:var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1)}
.rt-concl-i.on{border-color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-1)}
.rt-concl-i b{font-family:ui-monospace,Menlo,monospace;font-size:14px;font-weight:700}
.rt-concl-i>span{color:var(--dsw-alias-label-secondary);font-size:11.5px}
.rt-more{color:var(--dsw-alias-brand-primary);font-size:11.5px;cursor:pointer;user-select:none;margin-top:4px;display:inline-block}
.rt-more:hover{text-decoration:underline}
.rt-subtabs{display:flex;gap:4px;padding:6px 10px 0;border-bottom:1px solid var(--dsw-alias-border-l1);align-items:center}
.rt-subtab{padding:4px 10px;border-radius:6px 6px 0 0;cursor:pointer;font-size:12px;color:var(--dsw-alias-label-secondary)}
.rt-subtab.on{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);font-weight:600}
.rt-sevbar{width:3px;border-radius:2px;align-self:stretch;flex:none;margin-right:2px}
/* ── 折叠层次体系 ─────────────────────────────────────────────────────────────
L1 折叠头 .rt-sec :通栏、无圆角无边框、左色条、深底 —— 永远是"扁"的
L2 内容卡 .rt-atest/.rt-hit/.rt-cred/.rt-evi:内缩、有边框、圆角 —— 立起来
L3 详情/长文本 .rt-clip-body / .rt-evi-body :无边框、最浅、等宽
L4 子项容器 .rt-sec-body:左缩进 + 竖引导线,表明"属于上面那个头"
──────────────────────────────────────────────────────────────────────────── */
.rt-sec-wrap{margin:0}
.rt-sec{display:flex;align-items:center;gap:8px;padding:7px 12px 7px 9px;cursor:pointer;
background:var(--dsw-alias-bg-layer-2);border-left:3px solid var(--dsw-alias-border-l2);
border-top:1px solid var(--dsw-alias-border-l1);user-select:none;outline:none}
.rt-sec:hover{background:var(--dsw-alias-bg-layer-1)}
.rt-sec:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
.rt-sec.flat{cursor:default}
.rt-sec.flat:hover{background:var(--dsw-alias-bg-layer-2)}
.rt-sec-caret{flex:none;width:11px;font-size:10px;color:var(--dsw-alias-label-secondary);text-align:center}
.rt-sec-title{font-weight:600;font-size:13px;white-space:nowrap}
.rt-sec-count{font-size:11.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
.rt-sec-sub{font-size:11.5px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rt-sec-right{margin-left:auto;font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap;flex:none}
.rt-sec.t-stage{border-left-color:#8b5cf6}
.rt-sec.t-target{border-left-color:#0ea5e9}
.rt-sec.t-folder{border-left-color:#64748b}
.rt-sec.t-test{border-left-color:#10b981}
.rt-sec.t-queue{border-left-color:#f59e0b}
.rt-sec.t-past{border-left-color:#94a3b8}
.rt-sec-body{margin-left:12px;border-left:1px solid var(--dsw-alias-border-l1);padding:7px 0 3px 10px}
.rt-sec-body>.rt-atest:last-child,.rt-sec-body>.rt-hit:last-child{margin-bottom:2px}
/* 长文本折叠(L3):默认预览 2 行并渐隐,展开后限高滚动 */
.rt-clip{margin-top:6px}
.rt-clip-head{display:flex;align-items:center;gap:6px}
.rt-clip-label{font-size:11px;color:var(--dsw-alias-label-secondary);font-weight:600}
.rt-clip-body{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;line-height:1.65;white-space:pre-wrap;
word-break:break-word;background:var(--dsw-alias-bg-base);border-radius:5px;padding:6px 8px;margin-top:3px}
.rt-clip:not(.open) .rt-clip-body{max-height:46px;overflow:hidden;
-webkit-mask-image:linear-gradient(180deg,#000 55%,transparent);mask-image:linear-gradient(180deg,#000 55%,transparent)}
.rt-clip.open .rt-clip-body{max-height:340px;overflow:auto}
.rt-flow{padding:10px 12px 16px}
.rt-flow-start,.rt-flow-end{font-size:11.5px;color:var(--dsw-alias-label-secondary);padding:4px 0}
.rt-flow-end{font-weight:600;color:var(--dsw-alias-label-primary)}
.rt-flow-link{display:flex;align-items:center;gap:8px;padding:3px 0 3px 10px}
.rt-flow-arrow{color:var(--dsw-alias-border-l2);font-size:11px;flex:none}
.rt-flow-action{font-size:11.5px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rt-flow-action.inferred{opacity:.7;font-style:italic}
.rt-flow-node{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #10b981;border-radius:8px;
padding:8px 11px;background:var(--dsw-alias-bg-layer-2);cursor:pointer;outline:none}
.rt-flow-node:hover{border-color:var(--dsw-alias-border-l2)}
.rt-flow-node:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
.rt-flow-node.overflow{border-left-color:#94a3b8;opacity:.75}
.rt-flow-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.rt-flow-idx{width:18px;height:18px;border-radius:50%;background:#10b981;color:#fff;font-size:11px;font-weight:700;
display:inline-flex;align-items:center;justify-content:center;flex:none}
.rt-flow-node.overflow .rt-flow-idx{background:#94a3b8}
.rt-flow-name{font-weight:600;font-size:13px}
.rt-flow-pts{margin-left:auto;font-family:ui-monospace,Menlo,monospace;font-size:12.5px;font-weight:700;color:#065f46;
background:#a7f3d0;border:1px solid #10b98155;border-radius:10px;padding:0 8px;white-space:nowrap}
.rt-flow-pts.off{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1);border-color:var(--dsw-alias-border-l1)}
.rt-flow-times{font-size:11px;color:var(--dsw-alias-label-secondary)}
.rt-flow-target{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;margin-top:4px;word-break:break-all;
color:var(--dsw-alias-label-secondary)}
.rt-flow-gain{font-size:12px;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rt-flow-detail{margin-top:7px;padding-top:7px;border-top:1px dashed var(--dsw-alias-border-l1)}
.rt-hflow-wrap{flex:1;min-height:0;display:flex;flex-direction:column;overflow:auto}
.rt-hflow{display:flex;align-items:center;gap:0;padding:16px 12px;overflow-x:auto;flex-wrap:nowrap}
.rt-hflow-item{display:flex;align-items:center;flex:none}
.rt-hflow-start,.rt-hflow-end{font-size:11.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap;padding:0 4px}
.rt-hflow-end{font-weight:600;color:var(--dsw-alias-label-primary)}
.rt-hflow-arrow{color:var(--dsw-alias-border-l2);padding:0 5px;font-size:13px;flex:none}
.rt-hflow-node{display:flex;align-items:center;gap:5px;border:1px solid var(--dsw-alias-border-l1);
border-left:3px solid #10b981;border-radius:7px;padding:5px 8px;background:var(--dsw-alias-bg-layer-2);
cursor:pointer;white-space:nowrap;outline:none}
.rt-hflow-node:hover{border-color:var(--dsw-alias-border-l2)}
.rt-hflow-node:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
.rt-hflow-node.overflow{border-left-color:#94a3b8;opacity:.72}
.rt-hflow-node.open{border-color:var(--dsw-alias-brand-primary)}
.rt-hflow-pts{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;font-weight:700;color:#065f46;background:#a7f3d0;
border-radius:8px;padding:0 6px}
.rt-hflow-node.overflow .rt-hflow-pts{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1)}
.rt-hflow-name{font-size:11.5px}
.rt-hflow-n{font-size:10.5px;color:var(--dsw-alias-label-secondary)}
.rt-rep-list{padding:10px 12px 14px}
.rt-rep-tools{display:flex;gap:6px;justify-content:flex-end;margin-bottom:8px}
.rt-rep{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #10b981;border-radius:8px;
padding:9px 11px;margin-bottom:10px;background:var(--dsw-alias-bg-layer-2)}
.rt-rep-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.rt-rep-idx{font-size:15px;color:#10b981;font-weight:700;flex:none}
.rt-rep-name{font-weight:600;font-size:13px}
.rt-rep-meta{font-size:12px;margin-top:4px;color:var(--dsw-alias-label-secondary);word-break:break-word}
.rt-rep-meta b{color:var(--dsw-alias-label-primary);font-weight:600;margin-right:2px}
.rt-rep-missing{font-size:11.5px;color:var(--dsw-alias-state-error-primary);margin-top:6px;
border:1px dashed var(--dsw-alias-state-error-primary);border-radius:5px;padding:5px 8px}
.rt-rep-req{margin-top:7px;border:1px solid var(--dsw-alias-border-l1);border-radius:6px;overflow:hidden}
.rt-rep-req-head{display:flex;align-items:center;gap:7px;padding:4px 8px;font-size:11.5px;font-weight:600;
background:var(--dsw-alias-bg-layer-1);border-bottom:1px solid var(--dsw-alias-border-l1)}
.rt-rep-http{margin:0;padding:8px 10px;font-family:ui-monospace,Menlo,monospace;font-size:11.5px;line-height:1.6;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow:auto;background:var(--dsw-alias-bg-base)}
/* ── 报告里的「这一步怎么来的」:动作步骤 / 命令 / 凭据 / 隧道 ───────────── */
.rt-rep-trace{margin-top:8px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;padding:8px 10px;
background:var(--dsw-alias-bg-base)}
.rt-rep-trace-head{display:flex;align-items:center;gap:7px;margin-bottom:5px}
.rt-rep-trace-title{font-weight:600;font-size:12.5px}
.rt-rep-trace-how{font-size:12px;color:var(--dsw-alias-label-secondary);margin-bottom:6px}
.rt-rep-step{border-left:2px solid var(--dsw-alias-border-l1);padding:2px 0 6px 9px;margin-bottom:6px}
.rt-rep-step-head{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.rt-rep-step-no{width:17px;height:17px;border-radius:50%;background:var(--dsw-alias-brand-primary);color:#fff;
font-size:10.5px;display:flex;align-items:center;justify-content:center;flex:none}
.rt-rep-step-title{font-weight:600;font-size:12.5px}
.rt-rep-step-detail{font-size:12px;color:var(--dsw-alias-label-secondary);margin-top:2px}
.rt-rep-step-cmd{font-size:11.5px;margin-top:4px;display:flex;gap:6px;align-items:flex-start;flex-wrap:wrap}
.rt-rep-step-cmd .rt-mono{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);
border-radius:5px;padding:2px 6px;word-break:break-all}
.rt-rep-step-result{font-size:11.5px;margin-top:3px;word-break:break-word}
.rt-rep-src{margin-top:6px;padding-top:6px;border-top:1px dashed var(--dsw-alias-border-l1)}
.rt-rep-src-title{font-weight:600;font-size:12px;margin-bottom:3px}
/* ── 全链路攻击路径图 ─────────────────────────────────────────────── */
.rt-ap{padding:10px 12px 18px;overflow:auto}
.rt-ap-stage{margin-bottom:2px}
.rt-ap-head{display:flex;align-items:center;gap:8px;padding:7px 10px;background:var(--dsw-alias-bg-layer-2);
border-left:4px solid #64748b;border-top:1px solid var(--dsw-alias-border-l1);border-radius:6px 6px 0 0}
.rt-ap-no{width:20px;height:20px;border-radius:5px;color:#fff;font-size:11px;font-weight:700;flex:none;
display:inline-flex;align-items:center;justify-content:center}
.rt-ap-name{font-weight:700;font-size:13.5px}
.rt-ap-en{font-size:10px;color:var(--dsw-alias-label-secondary);letter-spacing:.3px}
.rt-ap-phase{font-size:10.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
.rt-ap-goal{display:flex;align-items:baseline;gap:8px;padding:6px 10px 6px 9px;font-size:12px;
border-left:4px solid #64748b;background:var(--dsw-alias-bg-layer-1)}
.rt-ap-goal-tag{font-size:10.5px;font-weight:700;border:1px solid;border-radius:4px;padding:0 5px;white-space:nowrap;flex:none}
.rt-ap-result{padding:7px 10px 3px;border-left:4px solid transparent}
.rt-ap-result-head{display:flex;align-items:center;gap:8px;margin-bottom:5px;flex-wrap:wrap}
.rt-ap-pts{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;font-weight:700;color:#065f46;background:#a7f3d0;
border:1px solid #10b98155;border-radius:10px;padding:0 7px;white-space:nowrap}
.rt-ap-pts.off{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1);border-color:var(--dsw-alias-border-l1)}
/* 按服务封顶/自建而不计分的命中:分值标灰(+0),避免看着像又加了分 */
.rt-ap-pts.uncounted{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1);border-color:var(--dsw-alias-border-l1)}
.rt-ap-nth{font-size:10.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
.rt-ap-hit{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #10b981;border-radius:6px;
padding:6px 9px;margin-bottom:5px;background:var(--dsw-alias-bg-layer-2);cursor:pointer;outline:none}
.rt-ap-hit:hover{border-color:var(--dsw-alias-border-l2)}
.rt-ap-hit:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
.rt-ap-hit.overflow{border-left-color:#94a3b8;opacity:.75}
.rt-ap-hit-head{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
.rt-ap-hit-name{font-weight:600;font-size:12.5px}
.rt-ap-hit-target{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--dsw-alias-label-secondary);
margin-left:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:46%}
.rt-ap-act{font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:3px}
.rt-ap-act.inferred{font-style:italic;opacity:.75}
.rt-ap-none{font-size:11.5px;color:var(--dsw-alias-label-secondary);padding:2px 0 4px}
.rt-ap-trans{display:flex;align-items:center;gap:7px;padding:2px 0 2px 14px}
.rt-ap-trans-t{font-size:11px;color:var(--dsw-alias-label-secondary)}
/* 横向路径图 */
.rt-ap-h{display:flex;align-items:stretch;padding:10px 12px 14px;overflow-x:auto;flex:1;min-height:0}
.rt-ap-col{display:flex;align-items:stretch;flex:none}
.rt-ap-col-arrow{align-self:center;color:var(--dsw-alias-border-l2);padding:0 6px;font-size:12px;flex:none}
.rt-hcol{width:228px;display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l1);
border-top:3px solid #64748b;border-radius:7px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}
.rt-hcol-goal{font-size:11px;color:var(--dsw-alias-label-secondary);padding:5px 8px;line-height:1.45;
border-bottom:1px solid var(--dsw-alias-border-l1);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.rt-hcol-body{padding:6px 8px;flex:1;min-height:0;overflow:auto}
.rt-hcol-hit{display:flex;align-items:center;gap:5px;font-size:11px;margin-bottom:3px}
.rt-hcol-hit.off{opacity:.6}
.rt-hcol-pts{font-family:ui-monospace,Menlo,monospace;font-size:10.5px;font-weight:700;color:#065f46;background:#a7f3d0;
border-radius:7px;padding:0 5px;flex:none}
.rt-hcol-hit.off .rt-hcol-pts{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2)}
.rt-hcol-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rt-hcol-none{font-size:11px;color:var(--dsw-alias-label-secondary)}
.rt-hcol-secs{display:flex;flex-wrap:wrap;gap:3px;margin-top:6px}
.rt-hcol-sec{font-size:10px;color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l1);
border-radius:4px;padding:0 4px}
.rt-hcol-attck{font-family:ui-monospace,Menlo,monospace;font-size:9.5px;color:var(--dsw-alias-label-secondary);
padding:4px 8px;border-top:1px solid var(--dsw-alias-border-l1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rt-ap-cum{font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
.rt-ap-sub{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);margin:6px 0 3px}
.rt-ap-assets,.rt-ap-tunnels{display:flex;flex-direction:column;gap:3px}
.rt-ap-asset{display:flex;align-items:center;gap:6px;font-size:11.5px;padding:3px 6px;border-radius:5px;
background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1)}
.rt-ap-tunnel{display:flex;align-items:center;gap:6px;font-size:11.5px;padding:4px 7px;border-radius:5px;
background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #f59e0b}
.rt-hcol-sub{font-size:10.5px;color:var(--dsw-alias-label-secondary);margin-bottom:2px;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:flex;gap:5px;align-items:center}
.rt-hcol-sub.off{opacity:.6}
.rt-hit-row{display:flex;flex-wrap:wrap;align-items:center;gap:2px 7px;font-size:11.5px;padding:4px 7px;border-radius:5px;
background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);margin-bottom:3px}
/* 资产与内容都自适应换行:长域名/长口令/长结果一律换行显示,不截断成省略号 */
.rt-hit-asset{font-family:ui-monospace,Menlo,monospace;font-weight:600;flex:0 1 auto;max-width:100%;
overflow-wrap:anywhere;word-break:break-word}
.rt-hit-txt{flex:1 1 100%;color:var(--dsw-alias-label-primary);line-height:1.55;
white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}
.rt-hit-txt.none{color:var(--dsw-alias-state-error-primary)}
.rt-hit-row .rt-hit-time{flex:none;margin-left:auto}
/* 自己注册/自建的账号:留痕但不计分,整行压暗 */
.rt-hit-row.self-created{opacity:.72;border-left:3px solid #ef444488}
/* 同一资产同一端口的重复账号/库权限:服务已拿满,不计分(只作留痕) */
.rt-hit-row.service-capped{opacity:.72;border-left:3px solid #f59e0b88}
.rt-hit-row.service-capped .rt-hit-idx{background:#f59e0b}
.rt-rep-group{margin-bottom:14px}
.rt-rep-stage{display:flex;align-items:center;gap:8px;padding:7px 10px;margin-bottom:7px;
background:var(--dsw-alias-bg-layer-2);border-left:4px solid #64748b;border-radius:6px;
cursor:pointer;outline:none;user-select:none}
.rt-rep-stage:hover{background:var(--dsw-alias-bg-layer-1)}
.rt-rep-stage:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
.rt-rep-no{width:20px;height:20px;border-radius:5px;color:#fff;font-size:11px;font-weight:700;flex:none;
display:inline-flex;align-items:center;justify-content:center}
.rt-rep-stage-name{font-weight:700;font-size:13px}
.rt-rep-stage-n{font-size:11px;color:var(--dsw-alias-label-secondary)}
/* 折叠后仍要能一眼看到"这一阶段拿了多少分",所以分数留在头上 */
.rt-rep-pts{font-size:11px;font-weight:600;padding:1px 6px;border-radius:5px;border:1px solid transparent;flex:none}
.rt-rep-body{padding-left:6px}
/* ── 知识库(POC/EXP) ──────────────────────────────────────────── */
.rt-kb-filter{display:flex;align-items:center;gap:7px;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l1)}
/* 知识库归类总览:一行标签,点一下按该类筛选 */
.rt-kb-cats{display:flex;flex-wrap:wrap;gap:6px;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l1)}
/* 知识库分组标题(按归类分组时每组一条) */
.rt-kb-cat{display:flex;align-items:center;gap:7px;padding:7px 10px;background:var(--dsw-alias-bg-layer-2);
border-bottom:1px solid var(--dsw-alias-border-l1);position:sticky;top:0;z-index:1}
.rt-kb-cat-name{font-weight:600;font-size:12.5px}
/* 技能可用性徽章(技能库页签) */
.rt-avail{display:inline-block;padding:0 5px;border-radius:4px;font-size:10.5px;white-space:nowrap;
border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary)}
.rt-avail-available{color:#10b981;border-color:#10b98155;background:#10b9811a}
.rt-avail-broken{color:#ef4444;border-color:#ef444455;background:#ef44441a}
.rt-avail-unknown{color:#94a3b8;border-color:#94a3b855;background:#94a3b81a}
/* 版本 / 更新弹窗 */
.rt-modal{position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center}
.rt-modal-box{width:min(560px,92vw);max-height:80vh;overflow:auto;background:var(--dsw-alias-bg-layer-1);
border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:14px 16px;font-size:12.5px;line-height:1.7;
box-shadow:0 18px 48px rgba(0,0,0,.35);color:var(--dsw-alias-label-primary)}
.rt-modal-box .rt-kv b{min-width:76px}
.rt-kb-check{display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap;cursor:pointer}
.rt-kb{border:1px solid var(--dsw-alias-border-l1);border-radius:7px;margin:0 0 8px;overflow:hidden;
background:var(--dsw-alias-bg-layer-2)}
.rt-kb.open{border-color:var(--dsw-alias-brand-primary)}
.rt-kb-head{display:flex;align-items:center;gap:7px;padding:7px 9px;cursor:pointer;outline:none;flex-wrap:wrap}
.rt-kb-head:hover{background:var(--dsw-alias-bg-layer-1)}
.rt-kb-head:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
.rt-kb-title{font-weight:600;font-size:12.5px;overflow-wrap:anywhere}
.rt-kb-kind{font-size:10px;font-weight:700;letter-spacing:.4px;padding:1px 5px;border-radius:4px;
color:#fff;background:#64748b;flex:none}
.rt-kb-kind.k-exp{background:#ef4444}
.rt-kb-kind.k-poc{background:#f59e0b}
.rt-kb-kind.k-template{background:#8b5cf6}
.rt-kb-kind.k-script{background:#0ea5e9}
.rt-kb-kind.k-payload{background:#10b981}
.rt-kb-sub{font-size:11px;color:var(--dsw-alias-label-secondary);padding:0 9px 7px;overflow-wrap:anywhere}
.rt-kb-body{padding:0 9px 9px}
.rt-kb-actions{display:flex;gap:6px;margin:7px 0}
.rt-kb-body .rt-kv span{overflow-wrap:anywhere;word-break:break-word}
/* 本机 nuclei 模板命中:路径要能完整看到(复制成命令直接跑) */
.rt-kb-tpl{margin-top:12px;border-top:1px dashed var(--dsw-alias-border-l1);padding-top:8px}
.rt-kb-tpl-row{display:flex;align-items:center;gap:7px;font-size:11.5px;padding:3px 7px;border-radius:5px;
background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);margin-bottom:3px}
.rt-kb-tpl-path{flex:0 1 auto;font-weight:600;overflow-wrap:anywhere}
.rt-kb-tpl-name{flex:1 1 auto;min-width:0;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}
.rt-md{flex:1;overflow:auto;margin:0;padding:14px 16px;font-family:ui-monospace,Menlo,monospace;font-size:12.5px;
line-height:1.65;white-space:pre-wrap;word-break:break-word;background:var(--dsw-alias-bg-base)}
.rt-weblink{display:block;font-size:11.5px;margin-top:1px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rt-shots{margin:8px 0 4px;padding:7px 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;
background:var(--dsw-alias-bg-layer-1)}
.rt-shots-head{display:flex;align-items:baseline;gap:8px;font-size:11.5px;margin-bottom:6px}
.rt-shots-n{color:var(--dsw-alias-label-secondary)}
.rt-shot-row{display:flex;gap:8px;flex-wrap:wrap}
.rt-shot{display:flex;flex-direction:column;gap:3px;max-width:220px}
.rt-shot-thumb{border:1px solid var(--dsw-alias-border-l1);border-radius:5px;background:#0b1020;
object-fit:contain;display:block;width:auto;max-width:216px}
.rt-shot-loading{display:flex;align-items:center;justify-content:center;width:150px;font-size:11px;
color:var(--dsw-alias-label-secondary)}
.rt-shot-cap{display:flex;align-items:center;gap:5px;font-size:10.5px;overflow-wrap:anywhere}
.rt-shot-label{color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:170px}
.rt-shot-zoom{position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.82);display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:10px;cursor:zoom-out;padding:18px}
.rt-shot-zoom img{max-width:96vw;max-height:86vh;border-radius:6px;box-shadow:0 18px 48px rgba(0,0,0,.5)}
.rt-shot-zoom-cap{color:#e5e7eb;font-size:12px;text-align:center;max-width:90vw;overflow-wrap:anywhere}` /* ---------------------------------------------------------- 桥接与状态 */
/** 是否在「全面浏览」独立窗口里(URL hash 标记,复用同一套界面代码)。 */
const isFullWindow = () => {
try { return String(window.location.hash || '') === '#redteam-full' } catch { return false }
}
const api = (req) => fetch('/redteam/api', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(req),
}).then((res) => res.json())
let ui = { open: true, tab: 'assets' }
const subs = new Set()
const setUI = (patch) => {
ui = Object.assign({}, ui, patch)
const snap = Object.assign({}, ui)
for (const f of Array.from(subs)) {
try { f(snap) } catch { /* ignore subscriber errors */ }
}
}
/* 保留导出。面板尺寸只写在自己的元素上,不再改官方 frame 的宽度。 */
let dockWidthTag = null
const setDockWidth = () => {}
const useUI = () => {
const [st, setSt] = React.useState(() => Object.assign({}, ui))
React.useEffect(() => {
const f = (snap) => setSt(Object.assign({}, snap || ui))
subs.add(f)
setSt(Object.assign({}, ui))
return () => { subs.delete(f) }
}, [])
return st
}
/* 知识库归类(与 core.js 的 POC_CATEGORIES 一致;面板按它分组) */
const POC_CAT_NAME = {
rce: '远程命令执行', deserialization: '反序列化', 'file-upload': '文件上传 getshell',
sqli: 'SQL 注入', unauthorized: '未授权访问', 'auth-bypass': '认证绕过 / 越权',
'weak-password': '弱口令 / 爆破', ssrf: 'SSRF', xxe: 'XXE',
'path-traversal': '目录穿越 / 任意文件读', 'info-leak': '信息泄露',
privesc: '提权 / 横向', tunnel: '隧道 / 代理', other: '其它',
}
const POC_CAT_ORDER = Object.keys(POC_CAT_NAME)
/* 角色 code → 中文(报告/知识库都要标"谁发现的") */
const ROLE_LABEL = {
plan: '主会话(指挥)', recon: '信息收集', assess: '资产梳理',
'vuln-scan': '漏洞发现', exploit: '漏洞利用', internal: '内网渗透',
}
const fmt = (s) => (s ? String(s).replace('T', ' ').slice(0, 16) : '—')
const shotCache = new Map()
const shotLoading = new Map()
const shotSrc = (eng, id) => shotCache.get(eng + ':' + id) || null
const loadShot = (eng, id) => {
const key = eng + ':' + id
if (shotCache.has(key) || shotLoading.has(key)) return
const p = api({ op: 'shotData', engagement: eng, id: id }).then((r) => {
shotCache.set(key, r && r.base64 ? 'data:' + (r.mime || 'image/png') + ';base64,' + r.base64 : '')
shotLoading.delete(key)
const snap = Object.assign({}, ui)
for (const f of Array.from(subs)) {
try { f(snap) } catch { /* ignore subscriber errors */ }
}
}).catch(() => { shotLoading.delete(key) })
shotLoading.set(key, p)
}
const SHOT_KIND = { screenshot: '现场截图', card: '记录生成的证据摘录' }
const ShotImage = ({ eng, shot, height }) => {
const [zoom, setZoom] = React.useState(false)
const src = shotSrc(eng, shot.id)
if (src === null) { loadShot(eng, shot.id); }
const card = shot.kind === 'card'
return h('div', { className: 'rt-shot' },
src === null
? h('div', { className: 'rt-shot-thumb rt-shot-loading', style: { height: (height || 96) + 'px' } }, '载入中…')
: h('img', {
className: 'rt-shot-thumb', src: src, alt: shot.label || ('证据 ' + shot.id),
style: { height: (height || 96) + 'px', cursor: 'zoom-in' },
onClick: () => setZoom(true),
}),
h('div', { className: 'rt-shot-cap' },
h('span', { className: 'rt-tag ' + (card ? 'rt-tag-warn' : 'rt-tag-live'), title: card
? '由当日记录生成的证据摘录,不是现场截图 —— 报告里同样会标注'
: '现场截图' }, SHOT_KIND[shot.kind] || shot.kind || '证据'),
h('span', { className: 'rt-shot-label', title: shot.label || shot.note || '' },
shot.label || shot.target || ('#' + shot.id))),
zoom
? h('div', { className: 'rt-shot-zoom', onClick: () => setZoom(false) },
h('img', { src: src, alt: shot.label || '' }),
h('div', { className: 'rt-shot-zoom-cap' }, (shot.label || shot.target || '')
+ (card ? ' · 由当日记录生成(非现场截图)' : '')
+ (shot.captured_at ? ' · ' + fmt(shot.captured_at) : '')
+ ' · 点击任意处关闭'))
: null)
}
const ShotStrip = ({ eng, shots, total, title }) => {
const list = shots || []
if (list.length === 0) return null
return h('div', { className: 'rt-shots' },
h('div', { className: 'rt-shots-head' },
h('b', null, title || '证据截图'),
h('span', { className: 'rt-shots-n' }, '显示 ' + list.length + ' 张'
+ (total && total > list.length ? '(该得分点共 ' + total + ' 张,取代表图)' : ''))),
h('div', { className: 'rt-shot-row' }, list.map((sh) => h(ShotImage, { key: 'sh' + sh.id, eng: eng, shot: sh }))))
}
/** 发现时间在列表里只留「月-日 时:分」:整行宽度紧张,完整时间进悬浮提示 */
const fmtShort = (s) => {
const t = String(s || '').replace('T', ' ')
return t.length >= 16 ? t.slice(5, 16) : (t || '—')
}
const provLabel = (p) => (p === 'passive' ? '被动' : p === 'active' ? '主动' : '未知')
/**
* 给「可点击但不是 <button>」的元素补上键盘可达性,返回可直接展开进 props 的对象。
*
* 为什么需要:面板里大量用 div/span 当按钮(表格行、页签、结论条、C 段条目…),
* 它们鼠标能点、键盘完全够不着 —— 而这类元素此前有 16 处是各写各的,
* 写法还不一致(有的只有 role、有的漏了 Space 键)。
* 统一到一个工厂后,新增可点击元素只要 `...clickable(fn, { label })` 就有完整语义。
*
* 注意:**不要**用它包真正的 `<button>`(原生按钮自带全部语义)。
* @param onActivate - 激活回调(鼠标点击 / Enter / Space 都走它)。
* @param options - `{ label?, expanded? }`:label 进 aria-label,expanded 进 aria-expanded。
* @returns props 片段:role / tabIndex / onClick / onKeyDown / aria-*
*/
const clickable = (onActivate, options = {}) => {
const props = {
role: 'button',
tabIndex: 0,
onClick: onActivate,
onKeyDown: (e) => {
/* Enter 与 Space 是按钮的标准激活键;Space 还要阻止页面滚动 */
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onActivate(e)
}
},
}
if (options.label !== undefined) props['aria-label'] = String(options.label)
if (options.expanded !== undefined) props['aria-expanded'] = options.expanded ? 'true' : 'false'
return props
}
/**
* 复制文本到剪贴板,**返回真实的成功与否**。
*
* clipboard API 在非安全上下文(http + 非 localhost)、页面失焦、权限被拒时都会
* 返回被拒绝的 Promise —— 同步 try/catch 抓不到,于是界面会显示"已复制"而剪贴板是空的。
* 这里 await 真实结果,并在不可用时退回 execCommand('copy')(老浏览器/非安全上下文仍可用)。
* @param text - 要复制的文本。
* @returns Promise<boolean>
*/
const copyText = async (text) => {
const value = text === undefined || text === null ? '' : String(text)
if (value === '') return false
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(value)
return true
}
} catch (e) { /* 落到下面的兜底方案 */ }
/* 兜底:临时 textarea + execCommand —— 非安全上下文里唯一还能用的办法 */
try {
const ta = document.createElement('textarea')
ta.value = value
ta.setAttribute('readonly', '')
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
const okFlag = document.execCommand('copy')
document.body.removeChild(ta)
return okFlag === true
} catch (e) { return false }
}
/**
* 复制并给出**如实**的界面反馈(成功/失败文案统一)。
* @param text - 要复制的文本。
* @param label - 成功时提示里显示的名字。
* @param onResult - 可选回调:`(ok, message) => void`;不传时返回 Promise<boolean>。
*/
const copyWithFeedback = async (text, label, onResult) => {
const okFlag = await copyText(text)
const message = okFlag ? '已复制:' + label : '复制失败(浏览器未授权剪贴板)—— 请手动选中文本复制'
if (typeof onResult === 'function') onResult(okFlag, message)
return okFlag
}
function ProvTag(props) {
if (!props.p) return h('span', { className: 'rt-tag' }, '未知')
return h('span', { className: 'rt-tag rt-tag-' + props.p }, provLabel(props.p))
}
const TEST_LABEL = { untested: '未测试', testing: '测试中', tested: '已测试', blocked: '被封禁', abandoned: '已放弃', no_surface: '无攻击面' }
const PRI_LABEL = { high: '高', medium: '中', low: '低' }
/** 资产易打性徽章。 */
function PriTag(props) {
if (!props.p) return h('span', { className: 'rt-tag' }, '未评')
return h('span', { className: 'rt-pri rt-pri-' + props.p, title: props.title || '' }, PRI_LABEL[props.p] || props.p)
}
/** 资产测试状态徽章。 */
function TestTag(props) {
const st = props.s || 'untested'
return h('span', { className: 'rt-test rt-test-' + st }, TEST_LABEL[st] || st)
}
/* ---------------------------------------------------------- 资产测绘 */
function AssetsTab(props) {
const eng = props.engagement
const snapshot = props.snapshot
const refreshKey = props.refreshKey || 0
const onRefresh = props.onRefresh
const [view, setView] = React.useState('list')
const [cidr, setCidr] = React.useState(null)
const [q, setQ] = React.useState('')
const [qApplied, setQApplied] = React.useState('')
const [service, setService] = React.useState('')
const [port, setPort] = React.useState('')
const [prov, setProv] = React.useState('')
const [testStatus, setTestStatus] = React.useState('')
const [priority, setPriority] = React.useState('')
const [scope, setScope] = React.useState('')
const [assetState, setAssetState] = React.useState('')
const [sort, setSort] = React.useState('priority')
const [showAll, setShowAll] = React.useState(null)
const [state, setState] = React.useState({ loading: false, error: null, total: 0, items: [] })
const [domains, setDomains] = React.useState(null)
const [web, setWeb] = React.useState(null)
const [openId, setOpenId] = React.useState(null)
const [detail, setDetail] = React.useState(null)
/* 左侧「外网 C 段 / 内网 C 段」两组各自折叠,状态按靶标记住 */
const sideCollapse = useCollapse('assets-side:' + eng)
const seq = React.useRef(0)
const onData = props.onData
React.useEffect(() => {
if (!eng) return
const my = ++seq.current
setState((s) => Object.assign({}, s, { loading: true, error: null }))
api({
op: 'assets', engagement: eng, cidr: cidr || undefined, q: qApplied || undefined,
service: service || undefined, port: port || undefined,
provenance: prov || undefined, test_status: testStatus || undefined,
priority: priority || undefined, scope: scope || undefined,
state: assetState || undefined, sort: sort, limit: 400,
}).then((r) => {
if (my !== seq.current) return
if (!r || r.ok === false) {
setState({ loading: false, error: (r && r.error) || '查询失败', total: 0, items: [] })
return
}
setState({ loading: false, error: null, total: r.total, items: r.items || [] })
/* C 段/统计可能因本轮采集新增:让外层重新拉一次快照,左侧分类立即更新 */
if (onData) onData()
}, (e) => {
if (my === seq.current) setState({ loading: false, error: String((e && e.message) || e), total: 0, items: [] })
})
}, [eng, cidr, qApplied, service, port, prov, testStatus, priority, scope, assetState, sort, refreshKey])
React.useEffect(() => {
if (!eng || view !== 'domain') return
setDomains(null)
api({ op: 'domains', engagement: eng, cidr: cidr || undefined })
.then((r) => setDomains((r && r.items) || []), () => setDomains([]))
}, [eng, view, cidr, refreshKey])
React.useEffect(() => {
if (!eng || view !== 'web') return
setWeb(null)
api({ op: 'web', engagement: eng, cidr: cidr || undefined })
.then((r) => setWeb((r && r.items) || []), () => setWeb([]))
}, [eng, view, cidr, refreshKey])
const toggleRow = (id) => {
if (openId === id) { setOpenId(null); setDetail(null); return }
setOpenId(id)
setDetail(null)
api({ op: 'asset', engagement: eng, id: id }).then((r) => {
if (r && r.ok && r.asset) setDetail(r.asset)
}, () => {})
}
const segs = (snapshot && snapshot.segments) || []
const sideChildren = []
sideChildren.push(h('div', Object.assign({
key: 'all', className: 'rt-seg' + (cidr === null && view !== 'testing' ? ' on' : ''),
}, clickable(() => { setView('list'); setCidr(null) }, { label: '全部 C 段', expanded: cidr === null && view !== 'testing' })),
h('div', { className: 'rt-seg-cidr' }, '全部 C 段'),
h('div', { className: 'rt-seg-meta' }, segs.length + ' 个网段')))
sideChildren.push(h('div', Object.assign({
key: 'testing', className: 'rt-seg' + (view === 'testing' ? ' on' : ''),
title: '正在跑的扫描 / 探测与进度(5 秒自动刷新)',
}, clickable(() => { setView('testing'); setCidr(null) }, { label: '当前测试', expanded: view === 'testing' })),
h('div', { className: 'rt-seg-cidr', style: { display: 'flex', alignItems: 'baseline', gap: 5 } },
h('span', { className: 'rt-scope rt-scope-testing', title: '当前测试(既非内网也非外网)' }, '测'),
h('span', { style: { flex: 1 } }, '当前测试')),
h('div', { className: 'rt-seg-meta' }, '正在跑的扫描与探测')))
/* C 段按内外网分组:先外网(互联网可达,通常是入口)再内网(打进去之后才看得到) */
const segBlock = (title, list, kind) => {
/* 外网 / 内网两组各自可折叠(状态按靶标记住),默认展开 */
const open = sideCollapse.isOpen('scope:' + kind, true)
const toggle = sideCollapse.toggle('scope:' + kind, true)
const out = [h('div', {
key: 'h' + kind, className: 'rt-sidehead rt-sidehead-btn',
role: 'button', tabIndex: 0, 'aria-expanded': open ? 'true' : 'false',
title: (open ? '收起本组' : '展开本组') + '(并切回资产列表)',
onClick: () => { setView('list'); toggle() },
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setView('list'); toggle(e) } },
},
h('span', { className: 'rt-sec-caret' }, open ? '▾' : '▸'),
h('span', { className: 'rt-scope rt-scope-' + kind }, kind === 'internal' ? '内网' : '外网'),
h('span', null, title + ' · ' + list.length + ' 个 C 段'),
h('span', { className: 'rt-spacer' }),
h('span', { style: { fontWeight: 400 } }, list.reduce((n, x) => n + (x.assets || 0), 0) + ' 资产'))]
if (!open) return out
for (const s of list) {
/* 一段一行:只给 C 段 + 资产数(端口数不再占位,归属与存活放进悬浮提示) */
out.push(h('div', Object.assign({
key: s.cidr, className: 'rt-seg' + (cidr === s.cidr ? ' on' : ''),
title: (s.org || '未知归属') + ' · 存活 ' + (s.live || 0) + '/' + (s.assets || 0) + ' 台',
}, clickable(() => { setView('list'); setCidr(s.cidr) },
{ label: '筛选 C 段 ' + s.cidr, expanded: cidr === s.cidr && view !== 'testing' })),
h('div', { className: 'rt-seg-cidr', style: { display: 'flex', alignItems: 'baseline', gap: 5 } },
h('span', { className: 'rt-scope rt-scope-' + kind }, kind === 'internal' ? '内' : '外'),
h('span', { style: { flex: 1 } }, s.cidr),
h('span', { className: 'rt-seg-meta', style: { margin: 0, whiteSpace: 'nowrap' } },
s.assets + ' 台'))))
}
return out
}
const segExternal = segs.filter((x) => x.scope !== 'internal')
const segInternal = segs.filter((x) => x.scope === 'internal')
if (segExternal.length) sideChildren.push(...segBlock('外网资产', segExternal, 'external'))
if (segInternal.length) sideChildren.push(...segBlock('内网资产', segInternal, 'internal'))
const side = h('div', { className: 'rt-side' }, sideChildren)
const toolbar = h('div', { className: 'rt-toolbar' },
h('input', {
className: 'rt-input', style: { flex: '1 1 150px' }, placeholder: '搜索 IP / 域名 / 指纹(回车)',
value: q, onChange: (e) => setQ(e.target.value),
onKeyDown: (e) => { if (e.key === 'Enter') setQApplied(q) },
}),
h('button', { className: 'rt-btn', onClick: () => setQApplied(q) }, '搜索'),
h('input', {
className: 'rt-input', style: { width: '78px' }, placeholder: '服务',
value: service, onChange: (e) => setService(e.target.value),
}),
h('input', {
className: 'rt-input', style: { width: '60px' }, placeholder: '端口',
value: port, onChange: (e) => setPort(e.target.value),
}),
h('select', { className: 'rt-input', value: scope, onChange: (e) => setScope(e.target.value) },
h('option', { value: '' }, '内外网不限'),
h('option', { value: 'external' }, '仅外网资产'),
h('option', { value: 'internal' }, '仅内网资产')),
h('select', { className: 'rt-input', value: prov, onChange: (e) => setProv(e.target.value), title: '按端口来源过滤(列表已不再单列显示,详情里可见)' },
h('option', { value: '' }, '来源不限'),
h('option', { value: 'passive' }, '仅被动'),
h('option', { value: 'active' }, '仅主动')),
h('select', { className: 'rt-input', value: priority, onChange: (e) => setPriority(e.target.value) },
h('option', { value: '' }, '易打性不限'),
h('option', { value: 'high' }, '易打(高)'),
h('option', { value: 'medium' }, '一般(中)'),
h('option', { value: 'low' }, '难打(低)')),
h('select', { className: 'rt-input', value: testStatus, onChange: (e) => setTestStatus(e.target.value) },
h('option', { value: '' }, '测试状态不限'),
h('option', { value: 'untested' }, '未测试'),
h('option', { value: 'testing' }, '测试中'),
h('option', { value: 'tested' }, '已测试'),
h('option', { value: 'blocked' }, '被封禁'),
h('option', { value: 'abandoned' }, '已放弃'),
h('option', { value: 'no_surface' }, '无攻击面')),
h('div', { className: 'rt-spacer' }),
h('button', { className: 'rt-btn', title: '重新拉取快照与当前视图数据', onClick: () => { if (onRefresh) onRefresh() } }, '刷新'),
h('button', { className: 'rt-btn' + (view === 'list' ? ' rt-btn-primary' : ''), onClick: () => setView('list') }, '列表'),
h('button', { className: 'rt-btn' + (view === 'timeline' ? ' rt-btn-primary' : ''), title: '按发现时间看资产(什么时候发现、哪天收了多少)', onClick: () => setView('timeline') }, '发现时间'),
h('button', { className: 'rt-btn' + (view === 'domain' ? ' rt-btn-primary' : ''), onClick: () => setView('domain') }, '域名'),
h('button', { className: 'rt-btn' + (view === 'web' ? ' rt-btn-primary' : ''), onClick: () => setView('web') }, 'Web'))
/* ── 结论行:一屏看清家底,数字点一下就是筛选 ───────────────────── */
const tests = (snapshot && snapshot.tests) || {}
const snapStats = (snapshot && snapshot.stats) || {}
const noFilter = !testStatus && !priority && !scope && !prov && !assetState && !cidr
const conclItem = (key, label, value, active, onClick) => h('span', Object.assign({
key: key, className: 'rt-concl-i' + (active ? ' on' : ''), title: '点击筛选 / 再点取消',
}, clickable(onClick, { label: '按「' + label + '」筛选(' + (value || 0) + ')', expanded: active })),
h('b', null, String(value || 0)), h('span', null, label))
const toggleTest = (v) => { setTestStatus((cur) => (cur === v ? '' : v)); setAssetState('') }
const conclusion = h('div', { className: 'rt-concl' },
conclItem('all', '台资产', snapStats.assets, noFilter, () => {
setTestStatus(''); setPriority(''); setScope(''); setProv(''); setAssetState(''); setCidr(null)
}),
conclItem('live', '存活', snapStats.liveAssets, assetState === 'live', () => { setAssetState((v) => (v === 'live' ? '' : 'live')); setTestStatus('') }),
conclItem('untested', '待测', tests.untested, testStatus === 'untested', () => toggleTest('untested')),
conclItem('testing', '测试中', tests.testing, testStatus === 'testing', () => toggleTest('testing')),
conclItem('tested', '已测', tests.tested, testStatus === 'tested', () => toggleTest('tested')),
conclItem('giveup', '放弃', (tests.abandoned || 0) + (tests.blocked || 0), testStatus === 'abandoned,blocked', () => toggleTest('abandoned,blocked')),
h('div', { className: 'rt-spacer' }),
h('select', {
className: 'rt-input', value: sort, onChange: (e) => setSort(e.target.value),
title: '列表排序方式',
},
h('option', { value: 'priority' }, '排序:易打性优先'),
h('option', { value: 'todo' }, '排序:待测优先'),
h('option', { value: 'discovered' }, '排序:最近发现优先'),
h('option', { value: 'ports' }, '排序:端口多优先'),
h('option', { value: 'ip' }, '排序:按 IP')))
const head = h('div', { className: 'rt-row head' },
h('span', null, 'IP'), h('span', null, '易打'), h('span', null, '测试状态'),
h('span', null, '开放端口 / 服务'), h('span', null, '指纹'))
const rowNodes = []
for (const it of state.items) {
const openPorts = it.ports.filter((p) => p.state === 'open')
const portText = openPorts.map((p) => p.port + (p.service ? '/' + p.service : '')).join(', ') || '—'
const fpText = it.fingerprints.map((f) => [f.vendor, f.product, f.version].filter(Boolean).join(' ')).join(' / ') || '—'
/* 端口最多列 3 个,其余用 +N;主被动来源不再占列,进详情 */
const shownPorts = openPorts.slice(0, 3).map((p) => p.port + (p.service ? '/' + p.service : '')).join(', ')
const morePorts = openPorts.length > 3 ? ' +' + (openPorts.length - 3) : ''
rowNodes.push(h('div', Object.assign({
key: 'r' + it.id, className: 'rt-row',
}, clickable(() => toggleRow(it.id), { label: '展开资产 ' + it.ip, expanded: openId === it.id })),
h('span', { className: 'rt-mono', style: { display: 'flex', alignItems: 'baseline', gap: 4, flexWrap: 'wrap' } },
h('span', {
className: 'rt-scope rt-scope-' + (it.scope === 'internal' ? 'internal' : 'external'),
}, it.scope === 'internal' ? '内' : '外'),
h('span', {
title: it.state === 'live' ? '存活' : String(it.state),
style: {
width: 7, height: 7, borderRadius: '50%', flex: 'none', marginTop: 4,
background: it.state === 'live' ? '#10b981' : '#94a3b8',
},
}),
h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.ip),
/* 发现时间挂在 IP 下面一行:IP 列宽度有限,不另开列(列宽一改整表要跟着调) */
h('span', {
style: { fontSize: 10.5, color: 'var(--dsw-alias-label-secondary)', whiteSpace: 'nowrap' },
title: '发现时间:' + (it.discovered_at ? fmt(it.discovered_at) : '未记录')
+ (it.last_seen ? '\n最近采集:' + fmt(it.last_seen) : ''),
}, it.discovered_at ? fmtShort(it.discovered_at) : '发现时间未知')),
h('span', null, h(PriTag, { p: it.priority, title: it.potential || '' })),
h('span', null, h(TestTag, { s: it.test_status }),
it.blocked_count ? h('span', { className: 'rt-tag', style: { color: '#ef4444', borderColor: '#ef444455', marginLeft: 4 } }, '封' + it.blocked_count) : null),
h('span', { title: portText }, (shownPorts || '—') + morePorts),
h('span', { title: fpText }, fpText.length > 30 ? fpText.slice(0, 30) + '…' : fpText)))
if (openId !== it.id) continue
const d = detail && detail.id === it.id ? detail : null
const portRows = []
const fpRows = []
const obsRows = []
if (d) {
for (const p of (d.ports || [])) {
if (p.state !== 'open') continue
portRows.push(h('div', { key: 'p' + p.port, className: 'rt-kv' },
h('b', { className: 'rt-mono' }, p.port + '/' + p.proto),
h('span', null, [p.service, p.product, p.version].filter(Boolean).join(' ') || '未知服务'),
p.url ? h('a', { className: 'rt-link', href: p.url, target: '_blank', rel: 'noreferrer', title: p.url }, p.title ? p.title : p.url) : null,
h(ProvTag, { p: p.provenance })))
}
for (const f of (d.fingerprints || [])) {
fpRows.push(h('div', { key: 'f' + (f.product || '') + (f.version || '') + (f.evidence || ''), className: 'rt-kv' },
h('b', null, f.category || '—'),
h('span', null, [f.vendor, f.product, f.version].filter(Boolean).join(' ') + (f.evidence ? '(' + f.evidence + ')' : '')),
h(ProvTag, { p: f.provenance })))
}
for (const o of (d.observations || []).slice(0, 8)) {
obsRows.push(h('div', { key: 'o' + (o.attr || '') + (o.value || '') + (o.collected_at || ''), className: 'rt-kv' },
h('b', null, o.attr || '—'),
h('span', null, (o.value || '') + ' · ' + (o.tool || '未知工具') + ' · ' + fmt(o.collected_at)),
h(ProvTag, { p: o.provenance })))
}
}
/* 详情分三层:3 行必读 → 「展开全部」后才是溯源、原始记录与备注全文 */
const all = showAll === it.id
const noteLines = d && d.test_notes ? d.test_notes.split('\n').filter(Boolean) : []
const inner = d
? h('div', null,
/* 必读三行 */
h('div', { className: 'rt-kv' }, h('b', null, '测试'), h('span', null,
h(TestTag, { s: d.test_status }),
d.blocked_count ? h('span', { className: 'rt-tag', style: { marginLeft: 6, color: '#ef4444', borderColor: '#ef444455' } }, '被封 ' + d.blocked_count + ' 次') : null,
h('span', { style: { marginLeft: 8, color: 'var(--dsw-alias-label-secondary)' } },
(d.test_updated_at ? fmt(d.test_updated_at) : '未测过') + (d.test_updated_by ? ' · ' + d.test_updated_by : '')))),
h('div', { className: 'rt-kv' }, h('b', null, '易打性'), h('span', null,
h(PriTag, { p: d.priority }),
h('span', { style: { marginLeft: 8 } }, d.potential || '未评估'),
d.assess_reason ? h('span', { style: { marginLeft: 8, color: 'var(--dsw-alias-label-secondary)' } }, d.assess_reason) : null)),
h('div', { className: 'rt-kv' }, h('b', null, '攻击面'), h('span', null,
d.test_surface || (portRows.length ? '未记录(开放端口见下)' : '无开放端口'),
d.scope ? h('span', { className: 'rt-scope rt-scope-' + (d.scope === 'internal' ? 'internal' : 'external'), style: { marginLeft: 8 } },
d.scope === 'internal' ? '内网资产' : '外网资产') : null)),
h('span', Object.assign({ className: 'rt-more' },
clickable(() => setShowAll(all ? null : it.id), { label: all ? '收起资产详情' : '展开资产详情', expanded: all })),
all ? '收起全部 ▲' : '展开全部(端口 · 指纹 · 采集溯源 · 测试记录)▼'),
all ? h('div', null,
h('div', { className: 'rt-kv' }, h('b', null, '主机名'), h('span', null, (d.names || []).map((n) => n.name).join(', ') || '—')),
h('div', { className: 'rt-kv' }, h('b', null, '发现时间'),
h('span', { title: '本条资产第一次进入资产库的时刻(重复采集只刷新"最近采集")' }, fmt(d.discovered_at)),
h('b', { style: { minWidth: 0, marginLeft: 8 } }, '最近采集'), h('span', null, fmt(d.last_seen)),
h('b', { style: { minWidth: 0, marginLeft: 8 } }, '数据源首见'), h('span', null, fmt(d.first_seen))),
h('div', { className: 'rt-kv' }, h('b', null, 'C 段'), h('span', null, d.segment_cidr)),
h('div', { className: 'rt-kv' }, h('b', null, '易打性'), h('span', null,
(d.priority || '未评估') + (d.potential ? ' · 预期:' + d.potential : '')
+ (d.assess_reason ? ' · 依据:' + d.assess_reason : '')
+ (d.assessed_at ? '(' + fmt(d.assessed_at) + ')' : ''))),
h('div', { className: 'rt-kv' }, h('b', null, '来源'), h('span', null,
'被动端口 ' + (d.passive || 0) + ' · 主动端口 ' + (d.active || 0))),
h('div', { style: { margin: '6px 0 3px', fontWeight: 600 } }, '开放端口 / 服务'),
h('div', null, portRows.length ? portRows : '—'),
h('div', { style: { margin: '6px 0 3px', fontWeight: 600 } }, '指纹'),
h('div', null, fpRows.length ? fpRows : '—'),
noteLines.length
? h(Clip, { key: 'notes', label: '测试记录(' + noteLines.length + ' 条)', text: noteLines.join('\n') })
: null,
h('div', { style: { margin: '6px 0 3px', fontWeight: 600 } }, '采集溯源(最近 8 条)'),
h('div', null, obsRows.length ? obsRows : '—'))
: null)
: h('div', null, '加载中…')
rowNodes.push(h('div', {
key: 'd' + it.id, className: 'rt-row',
style: { cursor: 'default', gridTemplateColumns: '1fr' },
}, h('div', { className: 'rt-expand' }, inner)))
}
const listPane = h('div', { className: 'rt-table' }, head, rowNodes,
!state.loading && !state.items.length ? h('div', { className: 'rt-empty' }, '没有匹配的资产') : null)
/* 域名维度:域名 → 关联资产 */
const domainPane = h('div', { className: 'rt-table' },
domains === null ? h('div', { className: 'rt-empty' }, '加载中…')
: domains.length
? domains.map((g) => h('div', { key: g.domain },
h('div', { className: 'rt-section', style: { padding: '8px 10px 4px' } },
h('a', { className: 'rt-link', href: 'http://' + g.domain, target: '_blank', rel: 'noreferrer' }, g.domain),
h('span', { className: 'rt-tag', style: { marginLeft: 8 } }, g.count + ' 个资产')),
g.assets.map((a) => h('div', {
key: String(g.domain) + '\u0000' + a.id + '\u0000' + a.ip, className: 'rt-row',
style: { gridTemplateColumns: '150px 130px 70px 1fr', cursor: 'pointer' },
onClick: () => { setView('list'); setQ(''); setQApplied(a.ip) },
},
h('span', { className: 'rt-mono' }, a.ip),
h('span', { className: 'rt-mono' }, a.segment),
h('span', null, h('span', { className: 'rt-tag rt-tag-' + (a.state === 'live' ? 'live' : 'dead') }, a.state === 'live' ? '存活' : a.state)),
h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)' } }, (a.names || []).join(', '))))))
: h('div', { className: 'rt-empty' }, cidr ? '该 C 段下暂无域名(切换到「全部 C 段」看全量)' : '暂无域名数据(信息收集阶段会写入域名)'))
/* Web 资产:标题 + 可直接点击的 URL */
const webPane = h('div', { className: 'rt-table' },
h('div', { className: 'rt-row head', style: { gridTemplateColumns: '1.6fr 1.2fr 130px 110px' } },
h('span', null, 'URL(可点击)'), h('span', null, '标题'), h('span', null, '资产'), h('span', null, '服务')),
(web || []).map((w) => {
const url = w.url || ('http' + (w.port === 443 || w.port === 8443 || w.port === 9443 ? 's' : '') + '://' + w.ip + (w.port === 80 || w.port === 443 ? '' : ':' + w.port))
return h('div', {
key: 'w' + w.port_id, className: 'rt-row',
style: { gridTemplateColumns: '1.6fr 1.2fr 130px 110px', cursor: 'default' },
},
h('a', { className: 'rt-link rt-mono', href: url, target: '_blank', rel: 'noreferrer', title: url }, url),
h('span', { title: w.title || '' }, w.title || '—'),
h('span', { className: 'rt-mono' }, w.ip + ' · ' + w.segment_cidr),
h('span', { style: { fontSize: 11 } }, [w.service, w.product, w.version].filter(Boolean).join(' ')))
}),
web === null ? h('div', { className: 'rt-empty' }, '加载中…')
: (!web.length ? h('div', { className: 'rt-empty' }, cidr ? '该 C 段下暂无 Web 资产' : '暂无 Web 资产(HTTP 探测后会写入 URL 与标题)') : null))
let pane = listPane
if (view === 'testing') pane = h(TestingTab, { engagement: eng, refreshKey: refreshKey })
else if (view === 'domain') pane = domainPane
else if (view === 'web') pane = webPane
/* 发现时间视图自带滚动容器,直接放进 rt-main 的 flex 里 */
else if (view === 'timeline') pane = h(DiscoveryView, { engagement: eng, refreshKey: refreshKey })
return h('div', { className: 'rt-split' }, side,
h('div', { className: 'rt-main' }, view === 'testing' ? null : toolbar,
conclusion,
state.error ? h('div', { className: 'rt-err' }, state.error) : null,
pane))
}
/* ---------------------------------------------------------- 资产发现时间线 */
/**
* 资产发现时间线:什么时候发现了什么、哪天收了多少。
* ⚠️ 这个组件曾在"移除图谱视图"时被连带删掉 —— 删除区间的结束标记选得太宽
* (图谱画布的收尾与它挨着),bundle 自检的 "client.js 有资产「发现时间」视图"
* 因此变红。恢复时把插入点固定成"资产测绘注释块之前",避免再被别的删除波及。
*/
function DiscoveryView(props) {
const eng = props.engagement
const refreshKey = props.refreshKey || 0
const [data, setData] = React.useState(null)
const [err, setErr] = React.useState(null)
const [day, setDay] = React.useState('')
React.useEffect(() => {
if (!eng) return
setData(null)
api({ op: 'discoveryTimeline', engagement: eng, limit: 200 })
.then((r) => { if (!r || r.ok === false) setErr((r && r.error) || '加载失败'); else { setErr(null); setData(r) } },
(e) => setErr(String((e && e.message) || e)))
}, [eng, refreshKey])
if (err) return h('div', { className: 'rt-pane' }, h('div', { className: 'rt-err' }, err))
if (!data) return h('div', { className: 'rt-empty' }, '加载中…')
const recent = (data.recent || []).filter((a) => day === '' || String(a.discovered_at || '').slice(0, 10) === day)
return h('div', { className: 'rt-pane', style: { flex: 1, minHeight: 0 } },
h('div', { className: 'rt-card' },
h('h4', null, '资产发现时间线'),
h('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
'共 ' + ((data.span && data.span.total) || 0) + ' 台 · 最早 '
+ (data.span && data.span.first ? fmt(data.span.first) : '—')
+ ' · 最近 ' + (data.span && data.span.last ? fmt(data.span.last) : '—')
+ '(发现时间 = 第一次进入资产库的时刻;重复采集只刷新"最近采集")')),
(data.days || []).length === 0 ? h('div', { className: 'rt-empty' }, '还没有资产。') : null,
h('div', { className: 'rt-card' },
h('h4', null, '按天统计', day
? h('span', Object.assign({ className: 'rt-tag', style: { marginLeft: 6, cursor: 'pointer' } },
clickable(() => setDay(''), { label: '清除按天筛选:' + day })), '清除筛选:' + day)
: null),
h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 8 } },
(data.days || []).map((d) => h('div', Object.assign({
key: d.day, className: 'rt-seg' + (day === d.day ? ' on' : ''), style: { marginBottom: 0, cursor: 'pointer' },
title: d.day + ':新增 ' + d.assets + ' 台(内网 ' + d.internal + ' / 外网 ' + d.external + ')',
}, clickable(() => setDay(day === d.day ? '' : d.day),
{ label: '筛选 ' + d.day + ' 发现的资产', expanded: day === d.day })),
h('div', { className: 'rt-seg-cidr' }, d.day),
h('div', { className: 'rt-seg-meta' }, d.assets + ' 台 · 内 ' + d.internal + ' / 外 ' + d.external))))),
h('div', { className: 'rt-card' },
h('h4', null, day ? day + ' 发现的资产(' + recent.length + ')' : '最近发现的资产(' + recent.length + ')'),
recent.length === 0 ? h('div', { className: 'rt-empty' }, '这一天没有新增资产。') : null,
recent.map((a) => h('div', { key: a.id, className: 'rt-kv' },
h('b', { className: 'rt-mono', style: { minWidth: 110 } },
h('span', { className: 'rt-scope rt-scope-' + (a.scope === 'internal' ? 'internal' : 'external'), style: { marginRight: 4 } },
a.scope === 'internal' ? '内' : '外'),
a.ip),
h('span', { style: { minWidth: 132, fontSize: 11.5, color: 'var(--dsw-alias-label-secondary)' } },
a.discovered_at ? fmt(a.discovered_at) : '时间未知'),
h('span', { style: { flex: 1 } }, (a.primary_name || '—') + (a.open_ports ? ' · ' + a.open_ports + ' 端口' : '')),
h(PriTag, { p: a.priority })))))
}
/* ---------------------------------------------------------- 智能体提示词 */
function PromptsTab(props) {
const eng = props.engagement
const refreshKey = props.refreshKey || 0
const [roles, setRoles] = React.useState([])
const [active, setActive] = React.useState(null)
const [draft, setDraft] = React.useState('')
const [msg, setMsg] = React.useState(null)
const [busy, setBusy] = React.useState(false)
React.useEffect(() => {
/* 无靶标也允许:服务端回内置默认,不再报 engagement required */
api(eng ? { op: 'prompts', engagement: eng } : { op: 'prompts' }).then((r) => {
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '读取失败' }); return }
setRoles(r.roles || [])
if (r.roles && r.roles.length) setActive((cur) => cur || r.roles[0].role)
if (r.builtin) setMsg({ ok: '当前为内置默认提示词(创建靶标后可按靶标保存修改)' })
}, (e) => setMsg({ err: String((e && e.message) || e) }))
}, [eng, refreshKey])
React.useEffect(() => {
const r = roles.find((x) => x.role === active)
if (r) setDraft(r.content || '')
}, [active, roles])
const save = () => {
if (!eng) { setMsg({ err: '请先创建靶标,再保存提示词' }); return }
setBusy(true)
setMsg(null)
api({ op: 'savePrompt', engagement: eng, role: active, content: draft }).then((r) => {
setBusy(false)
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '保存失败' }); return }
setRoles((list) => list.map((x) => (x.role === active
? Object.assign({}, x, { content: draft, updated_at: new Date().toISOString() })
: x)))
setMsg({ ok: '已保存' })
}, (e) => { setBusy(false); setMsg({ err: String((e && e.message) || e) }) })
}
/* 老靶标的提示词是旧版模板;这里可以把当前角色(或全部)恢复成内置最新版 */
const reset = (all) => {
if (!eng) { setMsg({ err: '请先创建靶标,再恢复默认提示词' }); return }
setBusy(true); setMsg(null)
api({ op: 'resetPrompts', engagement: eng, role: all ? undefined : active }).then((r) => {
setBusy(false)
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '恢复失败' }); return }
setMsg({ ok: '已恢复内置默认:' + (r.reset || []).join('、') })
api({ op: 'prompts', engagement: eng }).then((rr) => {
if (rr && rr.ok) {
setRoles(rr.roles || [])
const cur2 = (rr.roles || []).find((x) => x.role === active)
if (cur2) setDraft(cur2.content || '')
}
}, () => {})
}, (e) => { setBusy(false); setMsg({ err: String((e && e.message) || e) }) })
}
const cur = roles.find((x) => x.role === active)
const items = roles.map((r) => h('div', Object.assign({
key: r.role, className: 'rt-item' + (active === r.role ? ' on' : ''),
}, clickable(() => { setActive(r.role); setMsg(null) },
{ label: '查看「' + r.title + '」提示词', expanded: active === r.role })),
h('div', { className: 'rt-item-name' }, r.title),
h('div', { className: 'rt-item-desc' }, (r.content || '').replace(/[#*`]/g, '').slice(0, 60) || '(空)')))
return h('div', { className: 'rt-split' },
h('div', { className: 'rt-list' }, items),
h('div', { className: 'rt-main' },
h('div', { className: 'rt-toolbar' },
h('span', { style: { fontWeight: 600 } }, cur ? cur.title : '提示词'),
h('span', { className: 'rt-tag' }, '更新 ' + fmt(cur && cur.updated_at)),
h('div', { className: 'rt-spacer' }),
h('button', {
className: 'rt-btn', disabled: busy || !active, title: '把当前角色恢复成内置最新版提示词',
onClick: () => reset(false),
}, '恢复默认为当前'),
h('button', {
className: 'rt-btn', disabled: busy, title: '四个角色全部恢复成内置最新版提示词',
onClick: () => reset(true),
}, '全部恢复默认'),
h('button', { className: 'rt-btn rt-btn-primary', disabled: busy || !active, onClick: save }, busy ? '保存中…' : '保存')),
msg ? h('div', { className: msg.err ? 'rt-err' : 'rt-foot' }, msg.err || msg.ok) : null,
h('div', { className: 'rt-pane' },
h('textarea', {
className: 'rt-textarea', value: draft, spellCheck: false,
onChange: (e) => setDraft(e.target.value),
placeholder: '该角色的系统提示词(Markdown)',
}),
h('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)', marginTop: 6 } },
'保存后写入 agents/' + (active || 'role') + '.md。子会话任务第一行写 redteamRole: recon、assess、vuln-scan、exploit 或 internal 后,对应文件进入系统提示词。'))))
}
/* ---------------------------------------------------------- 技能库(DSH 原生) */
function SkillsTab(props) {
const refreshKey = props.refreshKey || 0
const [items, setItems] = React.useState([])
const [meta, setMeta] = React.useState({})
const [err, setErr] = React.useState(null)
const [q, setQ] = React.useState('')
const [srcOnly, setSrcOnly] = React.useState(false)
const [active, setActive] = React.useState(null)
const [detail, setDetail] = React.useState(null)
const [busy, setBusy] = React.useState(false)
const [brokenOnly, setBrokenOnly] = React.useState(false)
/* refresh=true 跳过后端 30 秒可用性缓存(技能正文/环境变量可能刚改过) */
const load = (force) => {
setBusy(true)
api({ op: 'skillCatalog', refresh: force === true }).then((r) => {
setBusy(false)
if (!r || r.ok === false) { setErr((r && r.error) || '读取失败'); return }
setErr(null)
setMeta(r || {})
setItems(r.items || [])
}, (e) => { setBusy(false); setErr(String((e && e.message) || e)) })
}
React.useEffect(load, [refreshKey])
const open = (skill) => {
const name = skill && skill.name
const key = (skill && skill.library ? skill.library : 'redteam') + ':' + name
if (active === key) { setActive(null); setDetail(null); return }
setActive(key)
setDetail(null)
api({ op: 'skillRead', name: name, library: skill.library || 'redteam' }).then((r) => {
if (r && r.ok) setDetail(r)
else setErr((r && r.error) || '读取失败')
}, (e) => setErr(String((e && e.message) || e)))
}
const needle = q.trim().toLowerCase()
let filtered = needle
? items.filter((s) => (s.name + ' ' + s.description + ' ' + s.whenToUse).toLowerCase().indexOf(needle) >= 0)
: items
if (srcOnly) filtered = filtered.filter((s) => s.library !== 'official' && s.fromPlugin === true)
if (brokenOnly) filtered = filtered.filter((s) => s.library !== 'official' && (s.availability === 'broken' || s.availability === 'unknown'))
/* 目录聚合:一眼看出"这么多技能是哪来的"(本项目/别的插件/自带根…) */
const dirs = (meta.byDir || []).filter((d) => d.n > 0).slice(0, 6)
const availSummary = meta.availability ? meta.availability.summary : null
const needRestart = err !== null && String(err).indexOf('unknown op') >= 0
const renderSkill = (s) => h('div', Object.assign({
key: (s.library || 'redteam') + ':' + s.name, className: 'rt-item' + (active === ((s.library || 'redteam') + ':' + s.name) ? ' on' : ''),
}, clickable(() => open(s), { label: '查看技能 ' + s.name, expanded: active === ((s.library || 'redteam') + ':' + s.name) })),
h('div', { className: 'rt-item-name' }, s.name,
s.modelInvocable === false ? h('span', { className: 'rt-tag', style: { marginLeft: 6 } }, '仅人工') : null,
s.library === 'official'
? h('span', { className: 'rt-tag', style: { marginLeft: 6 }, title: '官方技能目录,不按红队工具路径检查' }, '官方')
: h('span', {
className: 'rt-avail rt-avail-' + (s.availability || 'unknown'),
style: { marginLeft: 6 },
title: (s.availability === 'available'
? '可用:技能正文可以加载。缺的外部工具或密钥记在详情里,不阻止调用。'
: (s.problems || []).join('\n') || '未知'),
}, s.availability === 'available' ? '可用' : s.availability === 'broken' ? '不可用' : '未知'),
h('div', { className: 'rt-item-desc' }, s.description || '(无描述)'),
h('div', { className: 'rt-kb-sub' },
[s.source ? '来源 ' + s.source : null,
s.fromPlugin ? '本插件自带' : null,
s.provider ? s.provider : null,
s.dir ? s.dir : null].filter(Boolean).join(' · ')),
(s.problems || []).length > 0 && s.availability !== 'available' && s.library !== 'official'
? h('div', { className: 'rt-kb-sub', style: { color: 'var(--dsw-alias-state-warn-primary, #f59e0b)' } },
'⚠ ' + String(s.problems[0]).slice(0, 60))
: null))
const redteamRows = filtered.filter((s) => s.library !== 'official').map(renderSkill)
const officialRows = filtered.filter((s) => s.library === 'official').map(renderSkill)
const listItems = []
if (redteamRows.length > 0) {
listItems.push(h('div', { key: 'head-redteam', className: 'rt-kb-sub', style: { margin: '8px 0 4px', fontWeight: 600 } }, '红队技能'))
listItems.push.apply(listItems, redteamRows)
}
if (officialRows.length > 0) {
listItems.push(h('div', { key: 'head-official', className: 'rt-kb-sub', style: { margin: '8px 0 4px', fontWeight: 600 } }, '官方技能'))
listItems.push.apply(listItems, officialRows)
}
return h('div', { className: 'rt-split' },
h('div', { className: 'rt-list' },
h('input', {
className: 'rt-input', style: { width: '100%', marginBottom: 8, boxSizing: 'border-box' },
placeholder: '过滤技能', value: q, onChange: (e) => setQ(e.target.value),
}),
h('label', { className: 'rt-kb-check', style: { display: 'flex', margin: '0 0 6px' } },
h('input', { type: 'checkbox', checked: srcOnly, onChange: (e) => setSrcOnly(e.target.checked) }),
'只看本插件自带(' + (meta.fromPlugin || 0) + ' 个)'),
availSummary && (availSummary.broken > 0 || availSummary.unknown > 0)
? h('label', {
className: 'rt-kb-check', style: { display: 'flex', margin: '0 0 8px' },
title: '只看有明确缺口(缺 key / 缺本机路径 / 基础设施还是占位符)或判不了可用性的技能',
},
h('input', { type: 'checkbox', checked: brokenOnly, onChange: (e) => setBrokenOnly(e.target.checked) }),
'只看不可用/未知(' + ((availSummary.broken || 0) + (availSummary.unknown || 0)) + ' 个)')
: null,
availSummary
? h('div', { style: { display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6 } },
h('span', { className: 'rt-avail rt-avail-available' }, '可用 ' + (availSummary.available || 0)),
availSummary.broken > 0 ? h('span', { className: 'rt-avail rt-avail-broken' }, '不可用 ' + availSummary.broken) : null,
availSummary.unknown > 0 ? h('span', { className: 'rt-avail rt-avail-unknown' }, '未知 ' + availSummary.unknown) : null,
h('span', Object.assign({
className: 'rt-tag', style: { cursor: 'pointer' },
title: '技能正文或环境变量刚改过?点这里跳过 30 秒缓存重查',
}, clickable(() => load(true), { label: '重新检查技能可用性' })), '重查可用性'))
: null,
h('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)', marginBottom: 6 } },
'红队 ' + ((meta.libraries && meta.libraries.redteam) || 0) + ' · 官方 ' + ((meta.libraries && meta.libraries.official) || 0),
items.length > 100 ? h('div', { style: { marginTop: 3 } },
'(技能多来自其它插件注册的根或你自己的技能目录;本插件只自带 ' + (meta.fromPlugin || 0) + ' 个)') : null),
listItems),
h('div', { className: 'rt-main' },
h('div', { className: 'rt-toolbar' },
h('span', { style: { fontWeight: 600 } }, detail ? detail.name : '技能目录(DSH 原生)'),
h('div', { className: 'rt-spacer' }),
h('button', { className: 'rt-btn', disabled: busy, onClick: () => load(true) }, busy ? '刷新中…' : '刷新')),
err
? (needRestart
? h('div', { className: 'rt-empty' }, '该模块的宿主代码已更新,需重启一次当前宿主(官方 exe / 社区版 / dsh web)后生效')
: h('div', { className: 'rt-err' }, err))
: null,
detail
? h('div', { className: 'rt-pane' },
h('div', { className: 'rt-kv' }, h('b', null, '描述'), h('span', null, detail.description || '—')),
h('div', { className: 'rt-kv' }, h('b', null, '何时使用'), h('span', null, detail.whenToUse || '—')),
h('div', { className: 'rt-kv' }, h('b', null, '来源'), h('span', null, (detail.provider || '—') + ' / ' + (detail.source || '—'))),
(() => {
const s2 = items.find((x) => ((x.library || 'redteam') + ':' + x.name) === active)
if (!s2) return null
if (s2.library === 'official') {
return h('div', { className: 'rt-kv' }, h('b', null, '类型'), h('span', null, '官方技能。由宿主按任务调用,不按红队工具路径检查。'))
}
const avail = s2.availability || 'unknown'
return h('div', null,
h('div', { className: 'rt-kv' }, h('b', null, '可用性'),
h('span', { className: 'rt-avail rt-avail-' + avail },
avail === 'available' ? '可用' : avail === 'broken' ? '不可用(有明确缺口)' : '未知(正文读不到)')),
(s2.problems || []).length > 0
? h('div', { className: 'rt-kv' }, h('b', null, avail === 'available' ? '未装上的外部工具' : '缺口'),
h('span', null, s2.problems.map((x, i) => h('div', { key: 'p' + i }, '· ' + x))))
: null,
(s2.needs_user || []).length > 0
? h('div', { className: 'rt-kv' }, h('b', null, '需要你提供'),
h('span', { style: { color: 'var(--dsw-alias-state-warn-primary, #f59e0b)' } },
s2.needs_user.map((x, i) => h('div', { key: 'n' + i }, '· ' + x))))
: null)
})(),
detail.path ? h('div', { className: 'rt-kv' }, h('b', null, '文件'), h('span', { className: 'rt-mono', style: { wordBreak: 'break-all' } }, detail.path)) : null,
h('pre', { className: 'rt-md', style: { border: '1px solid var(--dsw-alias-border-l1)', borderRadius: 6, maxHeight: '52vh' } }, detail.content || '(空)'))
: h('div', { className: 'rt-pane' },
h('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginBottom: 8 } },
meta.note || '技能由 DSH 原生 skill 体系管理,红队智能体通过 skill 工具调用。'),
availSummary
? h('div', { style: { fontSize: 12, marginBottom: 8, display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' } },
h('span', { className: 'rt-avail rt-avail-available' }, '可用 ' + (availSummary.available || 0)),
availSummary.broken > 0 ? h('span', { className: 'rt-avail rt-avail-broken' }, '不可用 ' + availSummary.broken) : null,
availSummary.unknown > 0 ? h('span', { className: 'rt-avail rt-avail-unknown' }, '未知 ' + availSummary.unknown) : null,
h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } },
'(' + (meta.availability && meta.availability.cached ? '缓存于 ' : '检查于 ')
+ (meta.availability && meta.availability.checked_at ? fmt(meta.availability.checked_at) : '—') + ')'))
: null,
meta.availability && (meta.availability.broken || []).length > 0
? h('div', { className: 'rt-hint' },
h('div', { style: { fontWeight: 600, marginBottom: 4 } }, '现在跑不起来的技能:'),
meta.availability.broken.slice(0, 12).map((b) => h('div', { key: b.name },
'· ' + b.name + (b.problems && b.problems.length ? ' — ' + b.problems[0] : ''))))
: null,
(meta.byDir || []).slice(0, 6).map((d) => h('div', { key: d.key, className: 'rt-mono', style: { fontSize: 11, marginBottom: 2, overflowWrap: 'anywhere' } },
d.n + ' 个 · ' + d.key)),
(meta.byDir || []).length > 6
? h('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)' } },
'…另有 ' + ((meta.byDir || []).length - 6) + ' 个目录(共 ' + items.length + ' 个技能)')
: null,
h('div', { className: 'rt-empty' }, '左侧选择技能查看内容')))
)
}
/* ---------------------------------------------------------- 漏洞战果 */
const SEV_LABEL = { critical: '严重', high: '高危', medium: '中危', low: '低危', info: '信息' }
const STATUS_LABEL = { candidate: '待验证', confirmed: '已确认', 'false-positive': '误报', exploited: '已利用', fixed: '已修复' }
const sevClass = (s) => 'rt-sev rt-sev-' + (SEV_LABEL[s] ? s : 'info')
/** 目标归并:按「scheme://host:port」聚合,避免带路径的 URL 把分组打散。 */
function targetKeyOf(v) {
const t = String(v.target || '').trim()
if (t !== '') {
const url = /^([a-z][a-z0-9+.-]*:\/\/[^/?#\s]+)/i.exec(t)
if (url) return url[1]
const head = /^([^\s/?#]+)/.exec(t)
if (head) return head[1]
return t
}
return v.asset_ip || '(未指定目标)'
}
/* ---------------------------------------------------------- 证据渲染 */
/** 把一段原始 HTTP 报文按「请求行/状态行 + 头 + 体」着色,便于人眼扫读。 */
function HttpBlock(props) {
const text = String(props.text || '')
if (!text) return null
const lines = text.split(/\r?\n/)
const nodes = lines.map((ln, i) => {
let cls = null
if (i === 0 && /^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|TRACE)\s/.test(ln)) cls = 'rt-hl-req'
else if (i === 0 && /^HTTP\//.test(ln)) cls = 'rt-hl-res'
else if (/^[A-Za-z0-9-]+:/.test(ln)) {
const name = ln.split(':')[0].toLowerCase()
if (name === 'host' || name === 'cookie' || name === 'authorization' || name === 'content-type') cls = 'rt-hl-req'
}
return h('div', { key: 'l' + i, className: cls || undefined }, ln === '' ? '\u00a0' : ln)
})
return h('pre', { className: 'rt-evi-body' + (props.compact ? ' req' : '') }, nodes)
}
/** 漏洞详情里的证据区:结构化证据文本 + 该漏洞的原始 HTTP 请求/响应记录。 */
function detailEvidence(v) {
const evi = String(v.evidence || '').trim()
const looksHttp = /^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|HTTP\/)/m.test(evi) || /\n[A-Za-z-]+: /.test(evi)
const blocks = []
if (evi) {
blocks.push(h('div', { key: 'ev', className: 'rt-evi' },
h('div', { className: 'rt-evi-head' }, '证据摘要',
h('span', { className: 'rt-tag' }, looksHttp ? '原始报文' : '文本'),
h('div', { className: 'rt-spacer' }),
h('button', {
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
onClick: (e) => { e.stopPropagation(); copyText(evi) },
}, '复制')),
looksHttp ? h(HttpBlock, { text: evi }) : h('pre', { className: 'rt-evi-body' }, evi)))
}
const http = v.http_evidence || []
for (const e of http) {
blocks.push(h('div', { key: 'h' + e.id, className: 'rt-evi' },
h('div', { className: 'rt-evi-head' },
e.label || 'HTTP 证据',
h('span', { className: 'rt-tag' }, (e.method || '') + ' ' + (e.status === null || e.status === undefined ? '' : e.status)),
h('div', { className: 'rt-spacer' }),
h('span', { style: { fontWeight: 400, color: 'var(--dsw-alias-label-secondary)' } }, fmt(e.captured_at || e.created_at))),
e.request ? h('div', null,
h('div', { className: 'rt-evi-head', style: { borderTop: 'none' } }, '▸ 请求(可直接粘进 Burp Repeater)'),
h(HttpBlock, { text: e.request, compact: true })) : null,
e.response ? h('div', null,
h('div', { className: 'rt-evi-head' }, '▸ 响应'),
h(HttpBlock, { text: e.response })) : null,
e.note ? h('pre', { className: 'rt-evi-body', style: { maxHeight: 80 } }, e.note) : null))
}
if (blocks.length === 0) {
blocks.push(h('div', { key: 'none', className: 'rt-kv' }, h('b', null, '证据'),
h('span', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, '缺失 —— 未验证/无证据的漏洞不计入报告,请补 redteam_http_evidence_add')))
}
return h('div', { key: 'eviwrap' }, blocks)
}
function FindingsTab(props) {
const eng = props.engagement
const refreshKey = props.refreshKey || 0
const [sev, setSev] = React.useState('')
const [status, setStatus] = React.useState('')
const [q, setQ] = React.useState('')
const [qApplied, setQApplied] = React.useState('')
const [state, setState] = React.useState({ loading: false, error: null, total: 0, items: [], stats: null })
const [creds, setCreds] = React.useState([])
const [accesses, setAccesses] = React.useState([])
const [files, setFiles] = React.useState([])
const [openId, setOpenId] = React.useState(null)
const [msg, setMsg] = React.useState(null)
/* 子页签:漏洞 / 凭据 / 访问会话(凭据不再铺在漏洞页底部) */
const [subTab, setSubTab] = React.useState('vulns')
/* 默认按目标聚合:322 条平铺没法读,先看"哪台被打下什么" */
const [grouped, setGrouped] = React.useState(true)
const collapse = useCollapse('findings:' + eng)
const load = () => {
if (!eng) return
api({
op: 'vulns', engagement: eng, severity: sev || undefined,
status: status || undefined, q: qApplied || undefined, limit: 200,
}).then((r) => {
if (!r || r.ok === false) {
setState({ loading: false, error: (r && r.error) || '查询失败', total: 0, items: [], stats: null })
return
}
setState({ loading: false, error: null, total: r.total, items: r.items || [], stats: r.stats || null })
}, (e) => setState({ loading: false, error: String((e && e.message) || e), total: 0, items: [], stats: null }))
api({ op: 'credentials', engagement: eng }).then((r) => setCreds((r && r.items) || []), () => {})
api({ op: 'access', engagement: eng }).then((r) => setAccesses((r && r.items) || []), () => {})
api({ op: 'attackFiles', engagement: eng }).then((r) => setFiles((r && r.items) || []), () => {})
}
React.useEffect(load, [eng, sev, status, qApplied, refreshKey])
const setVulnStatus = (id, next) => {
setMsg(null)
api({ op: 'updateVuln', engagement: eng, id: id, patch: { status: next } }).then((r) => {
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '更新失败' }); return }
setMsg({ ok: '已更新为「' + (STATUS_LABEL[next] || next) + '」' })
load()
}, (e) => setMsg({ err: String((e && e.message) || e) }))
}
const stats = (state.stats && state.stats.bySeverity) ? state.stats : { bySeverity: {}, byStatus: {} }
const needRestart = state.error !== null && String(state.error).indexOf('unknown op') >= 0
const allFiles = (files || []).flatMap((g) => (g.files || []).map((f) => Object.assign({ target: g.target }, f)))
const filesByVuln = new Map()
const orphanFiles = []
for (const f of allFiles) {
const vid = f.vuln_id === null || f.vuln_id === undefined ? null : Number(f.vuln_id)
if (vid === null) { orphanFiles.push(f); continue }
if (!filesByVuln.has(vid)) filesByVuln.set(vid, [])
filesByVuln.get(vid).push(f)
}
/* ── 结论行:只给结论,数字点一下就是筛选 ─────────────────────── */
const concl = (key, label, value, active, onClick) => h('span', Object.assign({
key: key, className: 'rt-concl-i' + (active ? ' on' : ''), title: '点击筛选 / 再点取消',
}, clickable(onClick, { label: '按「' + label + '」筛选(' + (value || 0) + ')', expanded: active })),
h('b', null, String(value || 0)), h('span', null, label))
const conclusion = h('div', { className: 'rt-concl' },
concl('conf', '已确认', (stats.byStatus.confirmed || 0), status === 'confirmed', () => setStatus((v) => (v === 'confirmed' ? '' : 'confirmed'))),
concl('exp', '已利用', (stats.byStatus.exploited || 0), status === 'exploited', () => setStatus((v) => (v === 'exploited' ? '' : 'exploited'))),
concl('crit', '严重', (stats.bySeverity.critical || 0), sev === 'critical', () => setSev((v) => (v === 'critical' ? '' : 'critical'))),
concl('high', '高危', (stats.bySeverity.high || 0), sev === 'high', () => setSev((v) => (v === 'high' ? '' : 'high'))),
concl('med', '中危', (stats.bySeverity.medium || 0), sev === 'medium', () => setSev((v) => (v === 'medium' ? '' : 'medium'))),
concl('gain', '拿到权限', (stats.withGained || 0), false, () => {}),
h('div', { className: 'rt-spacer' }),
h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)' } },
'默认按目标聚合 · 共 ' + (stats.targetGroups || 0) + ' 个目标'))
const head = h('div', { className: 'rt-vrow head' },
h('span', null, '等级'), h('span', null, '漏洞 / 编号'), h('span', null, '目标'),
h('span', null, '拿到什么'), h('span', null, '状态'), h('span', null, '置信'))
/* 单条漏洞(行 + 展开详情),聚合视图与平铺视图共用 */
const vulnRows = (v, compact) => {
const out = []
const gainedList = String(v.gained || '').split(/[、,;,;]/).map((x) => x.trim()).filter(Boolean)
const open = openId === v.id
out.push(h('div', {
key: 'v' + v.id, className: 'rt-vrow', role: 'button', tabIndex: 0,
style: compact ? { cursor: 'pointer', gridTemplateColumns: '58px minmax(0,1fr) 104px 68px' } : { cursor: 'pointer' },
onClick: () => setOpenId(open ? null : v.id),
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpenId(open ? null : v.id) } },
},
h('span', null, h('span', { className: sevClass(v.severity) }, SEV_LABEL[v.severity] || v.severity)),
h('span', { title: v.title || '' }, h('span', { className: 'rt-sec-caret' }, open ? '▾' : '▸'), (v.cve ? v.cve + ' ' : '') + (v.title || '')),
compact ? null : h('span', { className: 'rt-mono', title: v.target || '' }, v.target || v.asset_ip || '—'),
h('span', { title: v.gained || '' },
gainedList.length
? h('span', { className: 'rt-gain', style: { maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block' } }, gainedList[0] + (gainedList.length > 1 ? ' +' + (gainedList.length - 1) : ''))
: h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, '—')),
h('span', null, h('span', { className: 'rt-tag' }, STATUS_LABEL[v.status] || v.status)),
compact ? null : h('span', null, v.confidence === null || v.confidence === undefined ? '—' : Math.round(v.confidence * 100) + '%')))
if (openId !== v.id) return out
out.push(h('div', {
key: 'vd' + v.id, className: 'rt-vrow',
style: { cursor: 'default', gridTemplateColumns: '1fr' },
}, h('div', { className: 'rt-vdetail' },
h('div', { className: 'rt-kv' }, h('b', null, '拿到什么'),
gainedList.length
? h('span', null, gainedList.map((g, gi) => h('span', { key: 'g' + gi, className: 'rt-gain', style: { marginRight: 6 } }, g)))
: h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } },
'未记录 —— 拿到权限/成果后请用 redteam_vuln_update 补 gained(例:服务器权限、内网隧道、后台管理员账号)')),
h('div', { className: 'rt-kv' }, h('b', null, '目标'), h('span', { className: 'rt-mono', style: { wordBreak: 'break-all' } }, v.target || '—')),
h('div', { className: 'rt-kv' }, h('b', null, '资产'), h('span', null, (v.asset_ip || '—') + ' · ' + (v.segment_cidr || ''))),
h('div', { className: 'rt-kv' }, h('b', null, '来源'), h('span', null, (v.source || '—') + ' · ' + (v.found_by_agent || '—') + ' · ' + fmt(v.found_at))),
detailEvidence(v),
(() => {
const mine = filesByVuln.get(Number(v.id)) || []
return h('div', { className: 'rt-kv' }, h('b', null, '攻击文件'),
mine.length
? h('span', null, mine.map((f, fi) => h('div', { key: 'af' + fi, style: { marginBottom: 3 } },
'· ' + (f.kind ? '[' + f.kind + '] ' : '') + (f.name || '(未命名)')
+ (f.description ? ' — ' + f.description : ''),
h('div', { className: 'rt-mono', style: { fontSize: 11, marginLeft: 12, color: 'var(--dsw-alias-label-secondary)', overflowWrap: 'anywhere' } }, f.path || ''))))
: h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } },
'(无)用 redteam_attack_file_add 把这条漏洞打通的脚本/马/EXP 归档,并带上 vuln_id'))
})(),
h('div', { className: 'rt-actions' },
h('button', { className: 'rt-btn', onClick: (e) => { e.stopPropagation(); setVulnStatus(v.id, 'confirmed') } }, '确认'),
h('button', { className: 'rt-btn', onClick: (e) => { e.stopPropagation(); setVulnStatus(v.id, 'exploited') } }, '已利用'),
h('button', { className: 'rt-btn', onClick: (e) => { e.stopPropagation(); setVulnStatus(v.id, 'false-positive') } }, '误报'),
h('button', { className: 'rt-btn', onClick: (e) => { e.stopPropagation(); setVulnStatus(v.id, 'fixed') } }, '已修复')))))
return out
}
const rows = []
if (grouped) {
/* 按目标聚合成组:先看"哪台被打下什么",再点进去看具体漏洞 */
const groups = new Map()
for (const v of state.items) {
const key = targetKeyOf(v)
if (!groups.has(key)) groups.set(key, [])
groups.get(key).push(v)
}
const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 }
const list = Array.from(groups.entries()).map(([key, vs]) => ({
key,
vulns: vs,
top: vs.slice().sort((a, b) => (SEV_RANK[a.severity] ?? 9) - (SEV_RANK[b.severity] ?? 9))[0],
gained: Array.from(new Set(vs.flatMap((v) => String(v.gained || '').split(/[、,;,;]/).map((x) => x.trim()).filter(Boolean)))),
exploited: vs.filter((v) => v.status === 'exploited').length,
})).sort((a, b) => (SEV_RANK[a.top.severity] ?? 9) - (SEV_RANK[b.top.severity] ?? 9) || b.vulns.length - a.vulns.length)
for (const g of list) {
const key = 'target:' + g.key
const defOpen = false
rows.push(h(Section, {
key: 'sec:' + g.key, tone: 'target',
title: g.key,
count: g.vulns.length + ' 个漏洞',
sub: [
SEV_LABEL[g.top.severity] || g.top.severity,
g.exploited ? '已利用 ' + g.exploited : null,
g.top.asset_ip && g.top.asset_ip !== g.key ? '资产 ' + g.top.asset_ip : null,
].filter(Boolean).join(' · '),
right: g.gained.length
? g.gained.slice(0, 2).join(' / ') + (g.gained.length > 2 ? ' +' + (g.gained.length - 2) : '')
: '未记录权限',
open: collapse.isOpen(key, defOpen),
onToggle: collapse.toggle(key, defOpen),
}, g.vulns.map((v) => vulnRows(v, true))))
}
if (!list.length && !state.loading && !state.error) {
rows.push(h('div', { key: 'none', className: 'rt-empty' }, '暂无漏洞记录'))
}
} else {
for (const v of state.items) rows.push(...vulnRows(v))
}
const toolbar = h('div', { className: 'rt-toolbar' },
h('input', {
className: 'rt-input', style: { flex: '1 1 140px' }, placeholder: '搜索标题 / CVE / 目标(回车)',
value: q, onChange: (e) => setQ(e.target.value),
onKeyDown: (e) => { if (e.key === 'Enter') setQApplied(q) },
}),
h('button', { className: 'rt-btn', onClick: () => setQApplied(q) }, '搜索'),
h('select', { className: 'rt-input', value: sev, onChange: (e) => setSev(e.target.value) },
h('option', { value: '' }, '全部等级'),
h('option', { value: 'critical' }, '严重'),
h('option', { value: 'high' }, '高危'),
h('option', { value: 'medium' }, '中危'),
h('option', { value: 'low' }, '低危'),
h('option', { value: 'info' }, '信息')),
h('select', { className: 'rt-input', value: status, onChange: (e) => setStatus(e.target.value) },
h('option', { value: '' }, '全部状态'),
h('option', { value: 'candidate' }, '待验证'),
h('option', { value: 'confirmed' }, '已确认'),
h('option', { value: 'exploited' }, '已利用'),
h('option', { value: 'false-positive' }, '误报'),
h('option', { value: 'fixed' }, '已修复')))
const credSection = h('div', null,
h('div', { className: 'rt-section' }, '凭据 · ' + creds.length + (creds.length ? '(明文直显,注意屏幕分享/录屏)' : '')),
creds.length
? h('div', { style: { padding: '4px 10px 0' } }, creds.map((c) => h('div', { key: 'c' + c.id, className: 'rt-cred' },
h('div', { className: 'rt-cred-head' },
h('span', { className: 'rt-cred-host' }, c.host),
c.username ? h('span', { className: 'rt-tag' }, c.username) : null,
h('span', { className: 'rt-tag rt-tag-passive' }, c.secret_type || 'password'),
c.privilege ? h('span', { className: 'rt-tag rt-tag-active' }, c.privilege) : null,
h('div', { className: 'rt-spacer' }),
h('button', {
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
onClick: (e) => { e.stopPropagation(); copyText(String(c.secret_value || '')) },
}, '复制')),
c.secret_value
? h('div', { className: 'rt-secret', title: '点击可全选' }, c.secret_value)
: h('div', { className: 'rt-secret-none' }, '未记明文 —— 请用 redteam_credential_add 的 secret_value 补上,面板才能直显'),
h('div', { className: 'rt-cred-meta' },
[c.source ? '来源 ' + c.source : null,
c.tool ? '工具 ' + c.tool : null,
c.secret_ref ? '证据 ' + c.secret_ref : null,
c.found_by_agent ? 'by ' + c.found_by_agent : null,
c.found_at ? fmt(c.found_at) : null].filter(Boolean).join(' · ')),
c.note ? h('div', { className: 'rt-cred-meta' }, '备注:' + c.note) : null)))
: h('div', { className: 'rt-empty' }, '暂无凭据(拿到口令/密钥/Hash 后用 redteam_credential_add 落库,秒级可复用)'))
const accessSection = h('div', null,
h('div', { className: 'rt-section' }, '已获得访问会话 · ' + accesses.length),
accesses.length
? accesses.map((a) => h('div', { key: 'a' + a.id, className: 'rt-vrow', style: { cursor: 'default', gridTemplateColumns: '1fr 110px 80px 80px 1fr' } },
h('span', { className: 'rt-mono' }, a.host),
h('span', null, a.username || '—'),
h('span', null, h('span', { className: 'rt-tag rt-tag-active' }, a.method || '—')),
h('span', null, a.privilege || '—'),
h('span', { className: 'rt-mono', title: a.session_ref || '' }, a.session_ref || '—')))
: h('div', { className: 'rt-empty' }, '暂无'))
const subTabBtn = (key, label, n) => h('span', Object.assign({
className: 'rt-subtab' + (subTab === key ? ' on' : ''),
}, clickable(() => setSubTab(key), { label: label + '(' + n + ')', expanded: subTab === key })),
label + ' ' + n)
return h('div', { className: 'rt-main' }, toolbar, conclusion,
h('div', { className: 'rt-subtabs' },
subTabBtn('vulns', '漏洞', state.total || 0),
subTabBtn('files', '攻击文件', allFiles.length),
subTabBtn('creds', '凭据', creds.length),
subTabBtn('access', '访问会话', accesses.length),
h('div', { className: 'rt-spacer' }),
grouped ? h('button', {
className: 'rt-btn', title: '展开所有目标',
onClick: () => collapse.setAll((state.items || []).map((v) => 'target:' + targetKeyOf(v)), true),
}, '全部展开') : null,
grouped ? h('button', {
className: 'rt-btn', title: '收起所有目标',
onClick: () => collapse.setAll((state.items || []).map((v) => 'target:' + targetKeyOf(v)), false),
}, '全部收起') : null,
h('button', {
className: 'rt-btn' + (grouped ? ' rt-btn-primary' : ''),
title: grouped ? '当前:按目标聚合(先看哪台被打下什么)' : '当前:平铺每条漏洞',
onClick: () => setGrouped((g) => !g),
}, grouped ? '按目标聚合' : '平铺列表')),
msg ? h('div', { className: msg.err ? 'rt-err' : 'rt-foot' }, msg.err || msg.ok) : null,
needRestart ? h('div', { className: 'rt-empty' }, '该模块的宿主代码已更新,需重启一次当前宿主(官方 exe / 社区版 / dsh web)后生效') : null,
state.error && !needRestart ? h('div', { className: 'rt-err' }, state.error) : null,
subTab === 'files'
? h('div', { className: 'rt-body', style: { overflow: 'auto' } },
h('div', { className: 'rt-hint', style: { marginBottom: 8 } },
'攻击文件按靶标目录组织(原「攻击文件」页签);每条漏洞的关联文件也能在该漏洞详情里直接看到。'
+ (orphanFiles.length ? ' 目前有 ' + orphanFiles.length + ' 个文件没挂 vuln_id,建议补上以便与漏洞对应。' : '')),
h(AttackFilesTab, { engagement: eng, refreshKey: refreshKey }))
: subTab === 'creds'
? h('div', { className: 'rt-body', style: { overflow: 'auto' } }, credSection)
: subTab === 'access'
? h('div', { className: 'rt-body', style: { overflow: 'auto' } }, accessSection)
: h('div', { className: 'rt-table' }, grouped ? null : head, rows,
!state.loading && !state.items.length && !state.error ? h('div', { className: 'rt-empty' }, '暂无漏洞记录') : null))
}
/* ---------------------------------------------------------- 攻击链 */
const STAGE_LABEL = { recon: '信息收集', vuln: '漏洞发现', exploit: '漏洞利用', access: '获得权限', pivot: '内网突破', data: '敏感数据', other: '其他' }
/* ---------------------------------------------------------- 攻击链(五阶段) */
/**
* 按攻击面位置串成一条链:
* ① 信息收集 → ② 互联网资产权限 → ③ 边界突破 → ④ 内网资产权限 → ⑤ 靶标权限。
* 每阶段只讲两件事:这一步拿到多少分(累计多少)、涉及的资产/隧道是哪些。
* A = 竖向链(详细);B = 横向链(一屏看完)。
*/
function ChainTab(props) {
const eng = props.engagement
const refreshKey = props.refreshKey || 0
const [data, setData] = React.useState(null)
const [err, setErr] = React.useState(null)
const [loading, setLoading] = React.useState(false)
const [view, setView] = React.useState('A')
const [openId, setOpenId] = React.useState(null)
const load = () => {
if (!eng) return
setLoading(true)
api({ op: 'scoreChain', engagement: eng }).then((r) => {
setLoading(false)
if (!r || r.ok === false) { setErr((r && r.error) || '读取失败'); return }
setErr(null); setData(r)
}, (e) => { setLoading(false); setErr(String((e && e.message) || e)) })
}
React.useEffect(load, [eng, refreshKey])
const stages = (data && data.stages) || []
const summary = (data && data.summary) || null
const toggle = (id) => setOpenId((cur) => (cur === id ? null : id))
const CIRCLED = ['①', '②', '③', '④', '⑤', '⑥']
/* 单次得分的详情 */
const hitDetail = (x) => h('div', { className: 'rt-flow-detail' },
h('div', { className: 'rt-kv' }, h('b', null, '目标'), h('span', { className: 'rt-mono', style: { wordBreak: 'break-all' } }, x.target || '—')),
x.asset_ip ? h('div', { className: 'rt-kv' }, h('b', null, '资产'), h('span', { className: 'rt-mono' }, x.asset_ip)) : null,
x.vuln_title ? h('div', { className: 'rt-kv' }, h('b', null, '利用漏洞'), h('span', null, [x.vuln_cve, x.vuln_title].filter(Boolean).join(' '))) : null,
h('div', { className: 'rt-kv' }, h('b', null, '次数'), h('span', null,
'同类第 ' + x.nth_of_point + ' 次 · +' + (x.points || 0) + ' 分')),
h('div', { className: 'rt-kv' }, h('b', null, '记录'), h('span', null, fmt(x.recorded_at) + (x.recorded_by ? ' · ' + x.recorded_by : ''))),
x.evidence ? h(Clip, { label: '结果与证据', text: x.evidence }) : null)
const hitRow = (x) => h('div', {
key: 'h' + x.id,
className: 'rt-ap-hit' + (openId === x.id ? ' open' : ''),
role: 'button', tabIndex: 0,
onClick: () => toggle(x.id),
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(x.id) } },
},
h('div', { className: 'rt-ap-hit-head' },
h('span', { className: 'rt-ap-pts' + (x.counted ? '' : ' uncounted'), title: x.capped_reason || undefined },
'+' + (x.counted ? (x.points || 0) : 0)),
h('span', { className: 'rt-ap-hit-name' }, x.point_name),
x.capped ? h('span', { className: 'rt-tag rt-tag-warn', title: x.capped_reason || '同一资产同一端口只算分值最高的一条' }, '服务已拿满 · 不计分') : null,
x.nth_of_point > 1 ? h('span', { className: 'rt-ap-nth' }, '第 ' + x.nth_of_point + ' 次') : null,
h('span', { className: 'rt-ap-hit-target' }, x.target || x.asset_ip || '—')),
x.action
? h('div', { className: 'rt-ap-act' + (x.action_inferred ? ' inferred' : '') },
'动作:' + (x.action.title || '(未命名步骤)') + (x.action_inferred ? '(推断)' : ''))
: null,
openId === x.id ? hitDetail(x) : null)
/* 资产行(信息收集阶段 / 各阶段涉及资产) */
const assetRow = (a) => h('div', { key: 'a' + a.id, className: 'rt-ap-asset' },
h('span', { className: 'rt-scope rt-scope-' + (a.scope === 'internal' ? 'internal' : 'external') },
a.scope === 'internal' ? '内' : '外'),
h('span', { className: 'rt-mono', style: { fontWeight: 600 } }, a.ip),
a.segment_cidr ? h('span', { className: 'rt-ap-nth' }, a.segment_cidr) : null,
h('span', { className: 'rt-ap-nth' }, '端口 ' + (a.open_ports || 0)),
a.vulns ? h('span', { className: 'rt-ap-nth' }, '漏洞 ' + a.vulns) : null,
a.priority ? h('span', { className: 'rt-tag' }, '易打 ' + a.priority) : null,
h('div', { className: 'rt-spacer' }),
h('span', { className: 'rt-ap-pts' }, '+' + (a.points || 0) + ' 分'),
h('span', { className: 'rt-ap-nth' }, (a.hits || 0) + ' 次'))
/* 攻击链只讲"打到哪了、拿了多少分":不展示打法要点与工具清单,
那些是执行细节,混在链路里会淹没得分与资产信息。 */
/* ── A:竖向攻击链 ─────────────────────────────────────────── */
const stageA = (st, i) => {
const isRecon = st.code === 'recon'
const body = []
if (isRecon) {
body.push(h('div', { key: 'at', className: 'rt-ap-sub' }, '拿到分数的资产 · ' + st.assetCount + ' 台'))
body.push(h('div', { key: 'al', className: 'rt-ap-assets' },
st.assets.length ? st.assets.map(assetRow) : h('div', { className: 'rt-ap-none' }, '还没有产生得分的资产')))
} else {
body.push(h('div', { key: 'ht', className: 'rt-ap-sub' },
'本阶段命中 ' + st.hits + ' 次' + (st.points ? ' · +' + st.points + ' 分' : '')))
body.push(h('div', { key: 'hl', className: 'rt-ap-hits' },
st.items.length ? st.items.map(hitRow) : h('div', { className: 'rt-ap-none' }, '本阶段还没有得分')))
if (st.code === 'boundary') {
const selfOnly = (st.tunnels_self_only || []).length
body.push(h('div', { key: 'tt', className: 'rt-ap-sub' },
'跨越靶标边界的通道 · ' + st.tunnels.length + ' 条' + (selfOnly ? '(另有 ' + selfOnly + ' 条只在自己 VPS/自建服务器上,不算突破)' : '')))
if (selfOnly && st.tunnels.length === 0) {
body.push(h('div', { key: 'tw', className: 'rt-ap-none' },
'⚠️ 现有的通道都在自己的服务器上,没有碰到目标 —— 不计边界突破。需要目标侧发起的通道(反弹 shell 到我这 / 目标上跑 frp 客户端 / 经目标 WebShell 的 suo5)。'))
}
body.push(h('div', { key: 'tl', className: 'rt-ap-tunnels' },
st.tunnels.length
? st.tunnels.map((t) => h('div', { key: 't' + t.id, className: 'rt-ap-tunnel' },
h('span', { className: 'rt-tag rt-tag-passive' }, t.kind || 'tunnel'),
h('span', { className: 'rt-mono', style: { fontWeight: 600 } }, t.listen || '—'),
h('span', {
className: 'rt-tag ' + (t.status === 'active' ? 'rt-tag-live' : ''),
style: t.status === 'active' ? {} : { opacity: .7 },
}, t.status === 'active' ? '可用' : (t.status || '未知')),
h('span', { className: 'rt-ap-nth', style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' } },
'可达 ' + (t.reach || '—'))))
: h('div', { className: 'rt-ap-none' }, '还没有建立隧道(建好后用 redteam_tunnel_add 登记)')))
}
if (st.assets.length && st.code !== 'boundary') {
body.push(h('div', { key: 'at', className: 'rt-ap-sub' }, '涉及资产 · ' + st.assetCount + ' 台'))
body.push(h('div', { key: 'al', className: 'rt-ap-assets' }, st.assets.map(assetRow)))
}
}
return h('div', { key: 'st' + st.code, className: 'rt-ap-stage' },
h('div', { className: 'rt-ap-head', style: { borderLeftColor: st.color } },
h('span', { className: 'rt-ap-no', style: { background: st.color } }, CIRCLED[i] || ('0' + (i + 1))),
h('span', { className: 'rt-ap-name' }, st.name),
st.subtitle ? h('span', { className: 'rt-ap-en' }, st.subtitle) : null,
h('div', { className: 'rt-spacer' }),
st.points > 0 ? h('span', { className: 'rt-ap-pts', style: { background: st.color + '22', color: st.color, borderColor: st.color + '66' } }, '+' + st.points) : null,
h('span', { className: 'rt-ap-cum' }, '累计 ' + st.cumulative + ' 分')),
h('div', { className: 'rt-ap-goal', style: { borderLeftColor: st.color } },
h('span', { className: 'rt-ap-goal-tag', style: { color: st.color, borderColor: st.color + '66' } }, '阶段目标'),
h('span', null, st.goal)),
h('div', { className: 'rt-ap-result', style: { borderLeftColor: st.color } }, body),
i < stages.length - 1
? h('div', { className: 'rt-ap-trans' },
h('span', { className: 'rt-flow-arrow' }, '▼'),
st.transition ? h('span', { className: 'rt-ap-trans-t' }, st.transition) : null,
h('span', { className: 'rt-ap-trans-t', style: { marginLeft: 'auto' } }, '累计 ' + st.cumulative + ' 分'))
: h('div', { className: 'rt-ap-trans' },
h('span', { className: 'rt-flow-arrow' }, '▼'),
h('span', { className: 'rt-ap-trans-t' }, '合计 ' + ((summary && summary.points) || 0) + ' 分')))
}
/* ── B:横向攻击链(一屏看完) ─────────────────────────────── */
const stageB = (st, i) => h('div', { key: 'c' + st.code, className: 'rt-ap-col' },
i > 0 ? h('span', { className: 'rt-ap-col-arrow' }, '▶') : null,
h('div', { className: 'rt-hcol', style: { borderTopColor: st.color } },
h('div', { className: 'rt-ap-head', style: { borderLeftColor: st.color, padding: '5px 8px' } },
h('span', { className: 'rt-ap-no', style: { background: st.color, width: 16, height: 16, fontSize: 10 } }, String(i + 1)),
h('span', { className: 'rt-ap-name', style: { fontSize: 12 } }, st.name),
h('div', { className: 'rt-spacer' }),
h('span', { className: 'rt-ap-cum', style: { fontWeight: 700 } }, st.cumulative)),
h('div', { className: 'rt-hcol-goal', title: st.goal }, st.goal),
h('div', { className: 'rt-hcol-body' },
st.code === 'boundary'
? h('div', null,
h('div', { className: 'rt-hcol-hit' }, h('span', { className: 'rt-hcol-pts' }, '+' + st.points), h('span', { className: 'rt-hcol-name' }, '隧道 ' + st.tunnels.length + ' 条')),
st.tunnels.slice(0, 3).map((t) => h('div', { key: 't' + t.id, className: 'rt-hcol-sub' }, '▸ ' + (t.kind || '') + ' ' + (t.listen || ''))))
: st.code === 'recon'
? h('div', null,
h('div', { className: 'rt-hcol-hit' }, h('span', { className: 'rt-hcol-pts' }, st.assetCount), h('span', { className: 'rt-hcol-name' }, '台资产拿到分')),
st.assets.slice(0, 6).map((a) => h('div', { key: 'a' + a.id, className: 'rt-hcol-sub', title: a.ip + ' +' + a.points + ' 分' },
(a.scope === 'internal' ? '内 ' : '外 ') + a.ip + ' +' + a.points)))
: h('div', null,
h('div', { className: 'rt-hcol-hit' }, h('span', { className: 'rt-hcol-pts' }, '+' + st.points), h('span', { className: 'rt-hcol-name' }, st.hits + ' 次命中')),
st.items.slice(0, 6).map((x) => h('div', { key: 'c' + x.id, className: 'rt-hcol-sub', title: x.point_name + ' ' + (x.target || '') },
h('span', { className: 'rt-hcol-pts' }, '+' + x.points), h('span', { className: 'rt-hcol-name' }, x.point_name))),
st.items.length > 6 ? h('div', { className: 'rt-hcol-none' }, '…另有 ' + (st.items.length - 6) + ' 次') : null,
st.assetCount ? h('div', { className: 'rt-hcol-none' }, '涉及 ' + st.assetCount + ' 台资产') : null))))
return h('div', { className: 'rt-main' },
h('div', { className: 'rt-toolbar' },
h('span', { style: { fontWeight: 600 } }, '攻击链'),
h('span', { className: 'rt-tag', style: { fontSize: 10.5 } }, '信息收集 → 互联网资产权限 → 边界突破 → 内网资产权限 → 靶标权限'),
summary ? h('span', { className: 'rt-tag rt-tag-live' }, '总分 ' + summary.points + ' 分') : null,
h('div', { className: 'rt-spacer' }),
h('button', { className: 'rt-btn' + (view === 'A' ? ' rt-btn-primary' : ''), title: '竖向攻击链:逐阶段向下看细节', onClick: () => setView('A') }, '链路 A'),
h('button', { className: 'rt-btn' + (view === 'B' ? ' rt-btn-primary' : ''), title: '横向攻击链:一屏看完五个阶段', onClick: () => setView('B') }, '链路 B'),
h('button', { className: 'rt-btn', disabled: loading, onClick: load }, loading ? '加载中…' : '刷新')),
err ? h('div', { className: 'rt-err' }, err) : null,
!stages.length && !err && data !== null
? h('div', { className: 'rt-empty' }, '还没有得分记录。拿到成果后用 redteam_score_hit 记分,这条链才会长出来。')
: view === 'A'
? h('div', { className: 'rt-ap' }, stages.map(stageA))
: h('div', { className: 'rt-ap-h' }, stages.map(stageB)))
}
/* ---------------------------------------------------------- 得分复现报告 */
/**
* 报告分组兜底:host 只给平铺条目时,用 scoreChain 的阶段信息把条目按攻击链顺序分组。
* 依据是两边共同的 score_hit id —— scoreChain 的每条 item 都带 stage_code,
* scoreReport 的每条 item 带同样的 id。这样即使 host 侧版本较旧或阶段行缺失,
* 报告页也能正常显示,而不是误报"还没有可交付的成果"。
*/
function groupByStage(reportItems, chain) {
const byId = new Map((reportItems || []).map((x) => [x.id, x]))
const groups = []
const stageList = (chain && chain.stages) || []
stageList.forEach((st, si) => {
const items = (st.items || []).map((x) => byId.get(x.id)).filter(Boolean)
if (items.length === 0) return
groups.push({ code: st.code, name: st.name, color: st.color || '#64748b',
ordinal: st.ordinal || si + 1, points: st.points || 0, cumulative: st.cumulative || 0, items })
})
/* scoreChain 完全没有阶段信息时,至少把得分归到「其他」,不要让条目凭空消失 */
const grouped = new Set(groups.reduce((acc, g) => acc.concat(g.items.map((x) => x.id)), []))
const rest = (reportItems || []).filter((x) => !grouped.has(x.id))
if (rest.length > 0) {
groups.push({ code: 'other', name: '其他得分', color: '#64748b', ordinal: groups.length + 1,
points: rest.reduce((n, x) => n + (x.counted ? x.points : 0), 0),
cumulative: 0, items: rest })
}
return groups
}
function ReportTab(props) {
const eng = props.engagement
const refreshKey = props.refreshKey || 0
const [data, setData] = React.useState(null)
const [err, setErr] = React.useState(null)
const [busy, setBusy] = React.useState(false)
const [msg, setMsg] = React.useState(null)
/* 报告按阶段折叠:默认全开,折叠状态按靶标记住(阶段多时便于逐段交付) */
const collapse = useCollapse('report:' + eng)
const load = () => {
if (!eng) return
setBusy(true); setMsg(null)
api({ op: 'scoreReport', engagement: eng }).then((r) => {
if (!r || r.ok === false) { setBusy(false); setErr((r && r.error) || '生成失败'); return }
/* 老 host 只给平铺条目、不给阶段分组(或阶段行缺失):自己按攻击链分组,
否则报告页会误报"还没有可交付的成果"。分组数据取自 scoreChain。 */
if ((!r.stages || r.stages.length === 0) && (r.items || []).length > 0) {
api({ op: 'scoreChain', engagement: eng }).then((c) => {
setBusy(false); setErr(null); setData(Object.assign({}, r, { stages: groupByStage(r.items, c) }))
}, () => { setBusy(false); setErr(null); setData(r) })
return
}
setBusy(false); setErr(null); setData(r)
}, (e) => { setBusy(false); setErr(String((e && e.message) || e)) })
}
React.useEffect(load, [eng, refreshKey])
/* 复制结果必须是真实的:clipboard API 失败时(非安全上下文/失焦/被拒)要如实提示,
否则用户以为复制成功、粘到 Yakit 里是空的。 */
const copy = (text, label) => copyWithFeedback(text, label, (okFlag, message) => {
setMsg(okFlag ? { ok: message } : { err: message })
})
const download = (text, label) => {
try {
const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'report-' + String(label || 'score').replace(/[^\w.\-]/g, '_') + '.md'
a.click()
URL.revokeObjectURL(url)
setMsg({ ok: '已下载:' + label })
} catch (e) { setMsg({ err: '下载失败:' + ((e && e.message) || e) }) }
}
const items = (data && data.items) || []
const stages = (data && data.stages) || []
const summary = (data && data.summary) || null
const mdText = (data && data.markdown) || ''
const CIRCLED = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫', '⑬', '⑭', '⑮', '⑯', '⑰', '⑱', '⑲', '⑳']
/* 每项一条,平铺不折叠:目标 → 拿到什么 → 复现请求(可直接粘进 Yakit)→ 响应 */
const card = (x) => h('div', { key: 'r' + x.id, className: 'rt-rep' },
h('div', { className: 'rt-rep-head' },
h('span', { className: 'rt-rep-idx' }, String(x.seq)),
h('span', { className: 'rt-rep-name' }, x.point_name),
h('span', { className: 'rt-flow-pts' }, '+' + x.points + ' 分'),
x.nth_of_point > 1 ? h('span', { className: 'rt-tag' }, '同类第 ' + x.nth_of_point + ' 次') : null,
h('div', { className: 'rt-spacer' }),
h('button', {
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
onClick: () => copy((x.requests || []).map((v) => v.request || '').filter(Boolean).join('\n\n'), '第 ' + x.seq + ' 项请求'),
}, '复制请求')),
h('div', { className: 'rt-rep-meta' },
h('span', null, h('b', null, '目标 ')), h('span', { className: 'rt-mono' }, x.target || x.asset_ip || '—')),
x.gained ? h('div', { className: 'rt-rep-meta' }, h('b', null, '拿到 '), h('span', null, x.gained)) : null,
x.vuln ? h('div', { className: 'rt-rep-meta' }, h('b', null, '利用漏洞 '),
h('span', null, [x.vuln.cve, x.vuln.title].filter(Boolean).join(' '))) : null,
x.evidence ? h('div', { className: 'rt-rep-meta' }, h('b', null, '结果 '),
h('span', null, String(x.evidence).replace(/\n+/g, ' '))) : null,
x.recorded_at ? h('div', { className: 'rt-rep-meta' }, h('b', null, '取得时间 '),
h('span', null, fmt(x.recorded_at) + (x.recorded_by ? '(' + (ROLE_LABEL[x.recorded_by] || x.recorded_by) + ')' : ''))) : null,
h(ShotStrip, { eng: eng, shots: x.shots, total: x.shot_total, title: '证据截图' }),
/* ── 复现步骤:动作记录(含实际命令与回显)+ 凭据 + 隧道 + WebShell ─────
报告的交付价值全在这块:账号密码怎么来的、隧道怎么搭的,用户照着就能复现。 */
h('details', { className: 'rt-rep-trace', open: x.incomplete === true || (x.steps || []).length === 0 },
h('summary', { className: 'rt-rep-trace-head', style: { cursor: 'pointer' } },
h('span', { className: 'rt-rep-trace-title' }, '复现步骤(点击展开)'),
(x.steps || []).length ? h('span', { className: 'rt-tag' }, (x.steps || []).length + ' 个动作') : null,
(x.credentials || []).length ? h('span', { className: 'rt-tag' }, (x.credentials || []).length + ' 条凭据') : null,
(x.tunnels || []).length ? h('span', { className: 'rt-tag' }, (x.tunnels || []).length + ' 条隧道') : null,
x.incomplete ? h('span', { className: 'rt-tag rt-tag-warn' }, '复现材料待补') : h('span', { className: 'rt-tag rt-tag-live' }, '可复现')),
h('div', { style: { paddingTop: 6 } },
x.how ? h('div', { className: 'rt-rep-trace-how' }, x.how) : null,
(x.steps || []).length === 0
? h('div', { className: 'rt-rep-missing' },
'⚠️ 没有关联的攻击步骤:说不清这一步是怎么做的。请用 redteam_chain_add 补上动作(title / detail / tool / result),记分时也可以带 point_code + evidence 一次完成。')
: (x.steps || []).map((s, si) => h('div', { key: 's' + s.id, className: 'rt-rep-step' },
h('div', { className: 'rt-rep-step-head' },
h('span', { className: 'rt-rep-step-no' }, String(si + 1)),
h('span', { className: 'rt-rep-step-title' }, s.title || '(未命名动作)'),
s.agent ? h('span', { className: 'rt-tag' }, ROLE_LABEL[s.agent] || s.agent) : null,
s.stage_code ? h('span', { className: 'rt-tag' }, s.stage_code) : null,
s.inferred ? h('span', { className: 'rt-tag' }, '按同资产推断') : null,
h('div', { className: 'rt-spacer' }),
s.recorded_at ? h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)' } }, fmt(s.recorded_at)) : null),
s.detail ? h('div', { className: 'rt-rep-step-detail' }, s.detail) : null,
h('div', { className: 'rt-rep-step-cmd' },
h('b', null, '执行 '),
s.tool
? h('span', { className: 'rt-mono' }, s.tool)
: h('span', { style: { color: 'var(--dsw-alias-state-warn-primary, #f59e0b)' } }, '未记录实际命令(redteam_chain_add 的 tool)')),
s.tool ? h('div', { style: { marginTop: 3 } },
h('button', {
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
onClick: () => copy(String(s.tool), '第 ' + (si + 1) + ' 步命令'),
}, '复制命令')) : null,
s.result ? h('div', { className: 'rt-rep-step-result' }, h('b', null, '结果 '), String(s.result).replace(/\n+/g, ' ')) : null,
s.evidence_ref ? h('div', { className: 'rt-rep-step-result' }, h('b', null, '证据 '), h('span', { className: 'rt-mono' }, s.evidence_ref)) : null)),
/* 账号密码怎么来的:凭据的来源 + 取得方式 */
(x.credentials || []).length
? h('div', { className: 'rt-rep-src' },
h('div', { className: 'rt-rep-src-title' }, '拿到的凭据(来源可追溯)'),
(x.credentials || []).map((c) => h('div', { key: 'c' + c.id, className: 'rt-kv' },
h('b', { className: 'rt-mono', style: { minWidth: 120 } }, c.host || '—'),
h('span', null, (c.username || '(无用户名)') + ' / ' + (c.secret_type || 'password')
+ (c.privilege ? ' · 权限 ' + c.privilege : '')
+ ' · 来源 ' + (c.source || '未标注')
+ (c.tool ? ' · 取得方式 ' + String(c.tool).replace(/\n+/g, ' ').slice(0, 160) : '')))))
: null,
/* 隧道怎么搭的 */
(x.tunnels || []).length
? h('div', { className: 'rt-rep-src' },
h('div', { className: 'rt-rep-src-title' }, '用到的隧道 / 通道'),
(x.tunnels || []).map((t) => h('div', { key: 't' + t.id, className: 'rt-kv' },
h('b', { className: 'rt-mono', style: { minWidth: 120 } }, (t.kind || 'socks5') + ' ' + (t.listen || '')),
h('span', null, '入口 ' + (t.entry || '未登记')
+ (t.reach ? ' · 可达 ' + t.reach : '')
+ ' · 目标侧 ' + (t.entry_kind || '未声明')
+ (t.legit === false ? ' · ⚠️ 不算跨越靶标边界' : '')
+ (t.status ? ' · ' + t.status : '')
+ (t.command ? ' · 命令 ' + String(t.command).replace(/\n+/g, ' ').slice(0, 160) : '')))))
: null,
(x.webshells || []).length
? h('div', { className: 'rt-rep-src' },
h('div', { className: 'rt-rep-src-title' }, '用到的 WebShell'),
(x.webshells || []).map((w) => h('div', { key: 'w' + w.id, className: 'rt-kv' },
h('b', { className: 'rt-mono', style: { minWidth: 120 } }, w.shell_type || 'shell'),
h('span', null, (w.url || '') + (w.pass_key ? ' · 口令/密钥 ' + w.pass_key : '')
+ (w.privilege ? ' · 权限 ' + w.privilege : '') + (w.status ? ' · ' + w.status : '')))))
: null,
(x.gaps || []).length ? h('div', { className: 'rt-rep-missing' }, '⚠️ 复现缺口:' + x.gaps.join(';')) : null),
(x.requests || []).length === 0
? h('div', { className: 'rt-rep-missing' }, '⚠️ 这一项没有原始请求记录,无法直接复现 —— 请用 redteam_http_evidence_add 补上')
: (x.requests || []).map((r, ri) => h('div', { key: 'q' + ri, className: 'rt-rep-req' },
h('div', { className: 'rt-rep-req-head' },
h('span', null, '复现请求 ' + (ri + 1) + (r.source === 'auto' ? '(按目标路径自动匹配,请核对)' : '')),
h('span', { className: 'rt-tag' }, (r.method || 'GET') + ' ' + (r.status === null || r.status === undefined ? '' : r.status)),
h('div', { className: 'rt-spacer' }),
h('button', {
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
onClick: () => copy(String(r.request || ''), '请求 ' + (ri + 1)),
}, '复制到 Yakit')),
h('pre', { className: 'rt-rep-http' }, r.request || ((r.method || 'GET') + ' ' + (r.url || '') + ' HTTP/1.1')),
r.response ? h('div', null,
h('div', { className: 'rt-rep-req-head' }, h('span', null, '响应摘要')),
h('pre', { className: 'rt-rep-http', style: { maxHeight: 160 } }, String(r.response).slice(0, 1600))) : null)),
x.note ? h('div', { className: 'rt-rep-meta' }, h('b', null, '备注 '), h('span', null, x.note)) : null))
return h('div', { className: 'rt-main' },
h('div', { className: 'rt-toolbar' },
h('span', { style: { fontWeight: 600 } }, '攻击得分链路复现报告'),
summary ? h('span', { className: 'rt-tag rt-tag-live' }, '合计 ' + summary.points + ' 分') : null,
summary ? h('span', { className: 'rt-tag' }, summary.count + ' 项得分') : null,
summary ? h('span', { className: 'rt-tag' + (summary.missingRequests ? '' : ' rt-tag-live') },
summary.withRequests + '/' + summary.count + ' 项带原始请求') : null,
summary ? h('span', {
className: 'rt-tag' + (summary.incomplete ? ' rt-tag-warn' : ' rt-tag-live'),
title: '复现链是否完整:有攻击步骤、写了实际命令、关联了漏洞/凭据/隧道',
}, '可复现 ' + (summary.count - (summary.incomplete || 0)) + '/' + summary.count) : null,
summary && summary.serviceCappedExcluded
? h('span', {
className: 'rt-tag rt-tag-warn',
title: (summary.serviceCapped || []).map((x) => (x.service || '') + '|' + (x.point_name || '')).join('\n')
|| '同一资产同一端口的重复账号/数据库权限命中:服务已拿满,不计分、不进报告',
}, '同服务已拿满不计分 ' + summary.serviceCappedExcluded)
: null,
h('div', { className: 'rt-spacer' }),
h('button', { className: 'rt-btn', disabled: busy || !mdText, onClick: () => copy(mdText, '整份报告') }, '复制全文'),
h('button', { className: 'rt-btn', disabled: busy || !mdText, onClick: () => download(mdText, (data && data.target) || eng) }, '下载 .md'),
h('button', { className: 'rt-btn', disabled: busy, onClick: load }, busy ? '生成中…' : '重新生成')),
msg ? h('div', { className: msg.err ? 'rt-err' : 'rt-foot' }, msg.err || msg.ok) : null,
err ? h('div', { className: 'rt-err' }, err) : null,
h('div', { className: 'rt-body', style: { overflow: 'auto' } },
stages.length
? h('div', { className: 'rt-rep-list' },
h('div', { className: 'rt-rep-tools' },
h('button', { className: 'rt-btn', onClick: () => collapse.setAll(stages.map((s) => 'st:' + s.code), true) }, '全部展开'),
h('button', { className: 'rt-btn', onClick: () => collapse.setAll(stages.map((s) => 'st:' + s.code), false) }, '全部折叠')),
stages.map((st) => {
const open = collapse.isOpen('st:' + st.code, true)
const toggle = collapse.toggle('st:' + st.code, true)
return h('div', { key: 'g' + st.code, className: 'rt-rep-group' },
h('div', {
className: 'rt-rep-stage', role: 'button', tabIndex: 0,
style: { borderLeftColor: st.color },
'aria-expanded': open ? 'true' : 'false',
onClick: toggle,
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(e) } },
},
h('span', { className: 'rt-sec-caret' }, open ? '▾' : '▸'),
h('span', { className: 'rt-rep-no', style: { background: st.color } }, CIRCLED[st.ordinal - 1] || st.ordinal),
h('span', { className: 'rt-rep-stage-name' }, st.name),
h('span', { className: 'rt-rep-stage-n' }, st.items.length + ' 项'),
h('div', { className: 'rt-spacer' }),
h('span', { className: 'rt-rep-pts', style: { background: st.color + '22', color: st.color, borderColor: st.color + '66' } },
'+' + st.points + ' 分'),
h('span', { className: 'rt-ap-cum' }, '累计 ' + st.cumulative + ' 分')),
open ? h('div', { className: 'rt-rep-body' }, st.items.map(card)) : null)
}))
: (data === null ? h('div', { className: 'rt-empty' }, '加载中…')
: h('div', { className: 'rt-empty' },
h('div', null, '还没有可交付的成果。'),
h('div', { style: { marginTop: 6, fontSize: 12 } },
'本报告只收录"拿到了分"的成果;没有得分的漏洞不进报告。拿到成果后用 redteam_score_hit 记分(目标资产 + 拿到的东西),并补 redteam_http_evidence_add 以便复现。')))),
h('div', { className: 'rt-foot' },
h('span', null, '口径:只收录得分成果,每条附带可粘进 Yakit Repeater 的原始请求')))
}
/* ---------------------------------------------------------- 攻击文件 */
const FILE_KIND = { poc: 'POC', exp: 'EXP', script: '脚本', wordlist: '字典', other: '其他' }
function AttackFilesTab(props) {
const eng = props.engagement
const refreshKey = props.refreshKey || 0
const [folders, setFolders] = React.useState([])
const [err, setErr] = React.useState(null)
const [busy, setBusy] = React.useState(false)
const collapse = useCollapse('files:' + eng)
const [detail, setDetail] = React.useState(null)
const load = () => {
if (!eng) return
setBusy(true)
api({ op: 'attackFiles', engagement: eng }).then((r) => {
setBusy(false)
if (!r || r.ok === false) { setErr((r && r.error) || '读取失败'); return }
setErr(null)
setFolders(r.items || [])
}, (e) => { setBusy(false); setErr(String((e && e.message) || e)) })
}
React.useEffect(load, [eng, refreshKey])
const openFile = (f) => {
if (detail && detail.id === f.id) { setDetail(null); return }
setDetail(null)
api({ op: 'readAttackFile', engagement: eng, id: f.id }).then((r) => {
if (r && r.ok) setDetail(r)
else setErr((r && r.error) || '读取失败')
}, (e) => setErr(String((e && e.message) || e)))
}
const rows = []
for (const folder of folders) {
const fKey = 'folder:' + folder.folder
const fileNodes = []
for (const f of folder.files) {
fileNodes.push(h('div', {
key: 'a' + f.id, className: 'rt-vrow', style: { gridTemplateColumns: '18px 1.2fr 60px 1.6fr', cursor: 'pointer' },
role: 'button', tabIndex: 0,
onClick: () => openFile(f),
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFile(f) } },
},
h('span', { className: 'rt-sec-caret' }, detail && detail.id === f.id ? '▾' : '▸'),
h('span', { className: 'rt-mono' }, f.name),
h('span', null, h('span', { className: 'rt-tag rt-tag-active' }, FILE_KIND[f.kind] || f.kind || '—')),
h('span', { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-secondary)' }, title: f.description || '' }, f.description || '—')))
if (!detail || detail.id !== f.id) continue
fileNodes.push(h('div', {
key: 'ad' + f.id, className: 'rt-vrow', style: { cursor: 'default', gridTemplateColumns: '1fr' },
}, h('div', { className: 'rt-vdetail' },
h('div', { className: 'rt-kv' }, h('b', null, '效果'), h('span', null, detail.evidence || '—')),
h('div', { className: 'rt-kv' }, h('b', null, '路径'), h('span', { className: 'rt-mono', style: { wordBreak: 'break-all' } }, detail.path)),
h('div', { className: 'rt-kv' }, h('b', null, '记录'), h('span', null, fmt(detail.created_at) + (detail.created_by ? ' · ' + detail.created_by : ''))),
h('pre', { className: 'rt-md', style: { border: '1px solid var(--dsw-alias-border-l1)', borderRadius: 6, maxHeight: '40vh', padding: '10px 12px' } }, detail.content || '(空)'))))
}
rows.push(h(Section, {
key: 'sec:' + folder.folder, tone: 'folder', title: folder.folder + '/',
count: folder.count + ' 个文件',
open: collapse.isOpen(fKey, true), onToggle: collapse.toggle(fKey, true),
}, fileNodes.length ? fileNodes : h('div', { className: 'rt-atest-meta' }, '(空)')))
}
const total = folders.reduce((n, f) => n + f.count, 0)
return h('div', { className: 'rt-main' },
h('div', { className: 'rt-toolbar' },
h('span', { style: { fontWeight: 600 } }, '攻击文件'),
h('span', { className: 'rt-tag' }, folders.length + ' 个目标 / ' + total + ' 个文件'),
h('div', { className: 'rt-spacer' }),
h('button', { className: 'rt-btn', disabled: busy, onClick: load }, busy ? '刷新中…' : '刷新')),
err ? h('div', { className: 'rt-err' }, err) : null,
h('div', { className: 'rt-table' },
rows,
!folders.length ? h('div', { className: 'rt-empty' }, '暂无攻击文件(打通的脚本/POC/EXP 会按目标文件夹出现在这里)') : null),
h('div', { className: 'rt-foot' }, h('span', null, '目录:attack-files/<IP|URL主机|C段>/ | 只收录实际生效的文件')))
}
/* ---------------------------------------------------------- 知识库(POC/EXP,全局共享) */
/**
* 知识库页:打 Nday/1day 之前先在这里搜。检索框支持 CVE / 组件 / 关键字 / 正文关键词;
* 命中就展开拿全文(可直接复制去用),没有就说明要去互联网找或自己搓,验证后回填。
* 这里是**全局**的:不随靶标切换,一个靶标沉淀的通用 POC 后面所有靶标都能用。
*/
const POC_KIND_LABEL = { poc: 'POC', exp: 'EXP', script: '脚本', template: '模板', payload: '载荷' }
const POC_SOURCE_LABEL = { web: '互联网', self: '手搓', manual: '人工', 'nuclei-template': 'nuclei 模板', kb: '知识库' }
function KnowledgeTab(props) {
const refreshKey = props.refreshKey || 0
const [data, setData] = React.useState(null)
const [err, setErr] = React.useState(null)
const [busy, setBusy] = React.useState(false)
const [msg, setMsg] = React.useState(null)
const [q, setQ] = React.useState('')
const [kind, setKind] = React.useState('')
const [category, setCategory] = React.useState('')
const [engagement, setEngagement] = React.useState('')
const [assetTarget, setAssetTarget] = React.useState('')
const [grouped, setGrouped] = React.useState(true)
const [verifiedOnly, setVerifiedOnly] = React.useState(false)
const [openId, setOpenId] = React.useState(null)
const [detail, setDetail] = React.useState(null)
const [detailBusy, setDetailBusy] = React.useState(false)
const [tplOffset, setTplOffset] = React.useState(0)
const tplPageSize = 40
const query = (over) => {
const extra = over || {}
const offset = Object.prototype.hasOwnProperty.call(extra, 'templateOffset') ? extra.templateOffset : 0
setTplOffset(offset)
const params = Object.assign({
q: q.trim() || undefined,
kind: kind || undefined,
category: category || undefined,
engagement: engagement || undefined,
asset_target: assetTarget.trim() || undefined,
verified: verifiedOnly || undefined,
templateOffset: offset || 0,
templateLimit: tplPageSize,
}, extra)
setBusy(true); setMsg(null)
api(Object.assign({ op: 'pocSearch' }, params)).then((r) => {
setBusy(false)
if (!r || r.ok === false) { setErr((r && r.error) || '读取失败'); return }
setErr(null); setData(r)
}, (e) => { setBusy(false); setErr(String((e && e.message) || e)) })
}
/* 首次进入与刷新键变化时拉全量(检索是显式动作,避免边打字边打接口) */
React.useEffect(() => { query({ q: undefined, kind: undefined, category: undefined, engagement: undefined, asset_target: undefined, verified: undefined }) }, [refreshKey])
const open = (id) => {
if (openId === id) { setOpenId(null); setDetail(null); return }
setOpenId(id); setDetail(null); setDetailBusy(true)
api({ op: 'pocGet', id: id }).then((r) => {
setDetailBusy(false)
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '读取失败' }); return }
setDetail(r)
}, (e) => { setDetailBusy(false); setMsg({ err: String((e && e.message) || e) }) })
}
/* 同报告页:await 真实结果再提示 */
const copy = (text, label) => copyWithFeedback(text, label, (okFlag, message) => {
setMsg(okFlag ? { ok: message } : { err: message })
})
const useIt = (row) => {
api({ op: 'pocUse', id: row.id, used_on: '控制台手动标记' }).then(() => {
setMsg({ ok: '已记一次复用:' + row.title })
query()
}, (e) => setMsg({ err: String((e && e.message) || e) }))
}
const stats = (data && data.stats) || { total: 0, verified: 0, reused: 0, byKind: [], bySource: [] }
const items = (data && data.items) || []
const tpl = (data && data.templates) || { dir: null, total: 0, matched: 0, offset: 0, items: [], byCategory: [] }
const tplByCat = new Map((tpl.byCategory || []).map((c) => [c.code, c.n || 0]))
const catShown = (c) => (c.n || 0) + (tplByCat.get(c.code) || 0)
const tplItems = tpl.items || []
const tplMatched = tpl.matched != null ? tpl.matched : tpl.total
const tplStart = Number(tpl.offset) || 0
const turnTpl = (next) => {
const offset = Math.max(0, next)
setTplOffset(offset)
query({ templateOffset: offset })
}
const card = (x) => {
const isOpen = openId === x.id
const d = isOpen && detail && detail.id === x.id ? detail : null
return h('div', { key: 'p' + x.id, className: 'rt-kb' + (isOpen ? ' open' : '') },
h('div', {
className: 'rt-kb-head', role: 'button', tabIndex: 0, 'aria-expanded': isOpen ? 'true' : 'false',
onClick: () => open(x.id),
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(x.id) } },
},
h('span', { className: 'rt-sec-caret' }, isOpen ? '▾' : '▸'),
h('span', { className: 'rt-kb-kind k-' + (x.kind || 'poc') }, POC_KIND_LABEL[x.kind] || x.kind || 'POC'),
h('span', { className: 'rt-kb-title' }, x.title),
x.cve ? h('span', { className: 'rt-tag rt-tag-passive' }, x.cve) : null,
x.component ? h('span', { className: 'rt-tag' }, x.component) : null,
x.verified === 1
? h('span', { className: 'rt-tag rt-tag-live' }, '已验证')
: h('span', { className: 'rt-tag rt-tag-warn' }, '未验证'),
h('div', { className: 'rt-spacer' }),
h('span', { className: 'rt-tag' }, POC_SOURCE_LABEL[x.source] || x.source || '—'),
x.hit_count ? h('span', { className: 'rt-tag' }, '复用 ' + x.hit_count) : null),
/* 来源行:归类 · 影响版本 · 建立时间 · 来源靶标 · 发现资产 —— 一条知识"什么时候、
在哪个单位的哪台资产上发现的"一眼可见 */
h('div', { className: 'rt-kb-sub' },
[x.category ? '归类 ' + (POC_CAT_NAME[x.category] || x.category) : null,
x.versions ? '影响版本 ' + x.versions : null,
x.language || null,
x.tags || null,
'建立时间 ' + fmt(x.created_at),
(x.engagement_name || x.engagement_id) ? '来源靶标 ' + (x.engagement_name || x.engagement_id) : null,
x.asset_target ? '发现资产 ' + x.asset_target : null,
x.found_by_agent ? '发现角色 ' + (ROLE_LABEL[x.found_by_agent] || x.found_by_agent) : null,
x.source_url ? '来源 ' + x.source_url : null].filter(Boolean).join(' · ')),
isOpen
? h('div', { className: 'rt-kb-body' },
detailBusy && !d ? h('div', { className: 'rt-empty' }, '读取中…') : null,
d ? h('div', null,
d.usage ? h('div', { className: 'rt-kv' }, h('b', null, '用法'), h('span', { className: 'rt-mono' }, d.usage)) : null,
d.description ? h('div', { className: 'rt-kv' }, h('b', null, '说明'), h('span', null, d.description)) : null,
d.verified_note ? h('div', { className: 'rt-kv' }, h('b', null, '验证证据'), h('span', null, d.verified_note)) : null,
d.used_on ? h('div', { className: 'rt-kv' }, h('b', null, '最近使用'), h('span', null, d.used_on)) : null,
d.path ? h('div', { className: 'rt-kv' }, h('b', null, '落盘'), h('span', { className: 'rt-mono' }, d.path)) : null,
h('div', { className: 'rt-kb-actions' },
h('button', { className: 'rt-btn', disabled: !d.content, onClick: () => copy(d.content || '', 'POC 正文') }, '复制正文'),
h('button', { className: 'rt-btn', onClick: () => useIt(x) }, '记一次复用'),
d.source_url ? h('button', { className: 'rt-btn', onClick: () => copy(d.source_url, '来源链接') }, '复制来源') : null),
d.content
? h('pre', { className: 'rt-rep-http' }, d.content.length > 12000 ? d.content.slice(0, 12000) + '\n…(已截断,完整内容见落盘文件)' : d.content)
: h('div', { className: 'rt-empty' }, '这条只有元数据,没有正文 —— 拿到正文后用 redteam_poc_update 补上'))
: null)
: null)
}
return h('div', { className: 'rt-main' },
h('div', { className: 'rt-toolbar' },
h('span', { style: { fontWeight: 600 } }, '知识库 · POC / EXP'),
h('span', { className: 'rt-tag' }, stats.total + ' 条'),
h('span', { className: 'rt-tag rt-tag-live' }, '已验证 ' + stats.verified),
h('span', { className: 'rt-tag' }, '累计复用 ' + (stats.reused || 0)),
(stats.uncategorized || 0) > 0 && (stats.uncategorized || 0) !== stats.total
? h('span', { className: 'rt-tag rt-tag-warn', title: '这些条目还没归类:智能体回填时用 redteam_poc_add 的 category 参数标一下' }, '未归类 ' + stats.uncategorized)
: null,
tpl.total ? h('span', { className: 'rt-tag rt-tag-passive' }, '本机模板 ' + tpl.total) : null,
h('div', { className: 'rt-spacer' }),
h('button', { className: 'rt-btn' + (grouped ? ' rt-btn-primary' : ''), title: '按归类分组显示 / 平铺显示', onClick: () => setGrouped(!grouped) }, grouped ? '按归类分组' : '平铺显示'),
h('button', { className: 'rt-btn', disabled: busy, onClick: () => query() }, busy ? '检索中…' : '刷新')),
/* 归类总览:点一下就是按该类筛选,一眼看清"哪类武器攒了多少、哪类还是空的" */
h('div', { className: 'rt-kb-cats' },
(stats.byCategory || []).filter((c) => catShown(c) > 0 || POC_CAT_ORDER.includes(c.code)).map((c) => h('span', {
key: c.code,
className: 'rt-concl-i' + (category === c.code ? ' on' : ''),
title: (c.hint || POC_CAT_NAME[c.code] || c.code)
+ ' · 本机模板 ' + (tplByCat.get(c.code) || 0)
+ ' · 知识库 ' + (c.n || 0)
+ (c.verified ? '(已验证 ' + c.verified + ')' : ''),
onClick: () => {
const next = category === c.code ? '' : c.code
setCategory(next)
query({ category: next || undefined })
},
},
h('b', null, String(catShown(c))),
h('span', null, (POC_CAT_NAME[c.code] || c.code) + (c.verified ? '(已验证 ' + c.verified + ')' : ''))))),
h('div', { className: 'rt-kb-filter' },
h('input', {
className: 'rt-input', style: { flex: 1, minWidth: 140 }, placeholder: '搜 CVE / 组件 / 关键字(正文也会搜)',
value: q, onChange: (e) => setQ(e.target.value),
onKeyDown: (e) => { if (e.key === 'Enter') query() },
}),
h('select', { className: 'rt-input', style: { maxWidth: 120 }, value: category, onChange: (e) => { setCategory(e.target.value); query({ category: e.target.value || undefined }) } },
h('option', { value: '' }, '全部归类'),
POC_CAT_ORDER.map((k) => h('option', { key: k, value: k }, POC_CAT_NAME[k]))),
h('select', { className: 'rt-input', style: { maxWidth: 120 }, value: engagement, onChange: (e) => { setEngagement(e.target.value); query({ engagement: e.target.value || undefined }) } },
h('option', { value: '' }, '全部来源靶标'),
(stats.byEngagement || []).map((e) => h('option', { key: e.engagement, value: e.engagement }, e.engagement + '(' + e.n + ')'))),
h('input', {
className: 'rt-input', style: { width: 120 }, placeholder: '发现资产筛选',
value: assetTarget, onChange: (e) => setAssetTarget(e.target.value),
onKeyDown: (e) => { if (e.key === 'Enter') query() },
}),
h('select', { className: 'rt-input', style: { maxWidth: 110 }, value: kind, onChange: (e) => { setKind(e.target.value); query({ kind: e.target.value || undefined }) } },
h('option', { value: '' }, '全部类型'),
Object.keys(POC_KIND_LABEL).map((k) => h('option', { key: k, value: k }, POC_KIND_LABEL[k]))),
h('label', { className: 'rt-kb-check' },
h('input', { type: 'checkbox', checked: verifiedOnly, onChange: (e) => { setVerifiedOnly(e.target.checked); query({ verified: e.target.checked || undefined }) } }),
'只看已验证'),
h('button', { className: 'rt-btn rt-btn-primary', onClick: () => query() }, '检索')),
msg ? h('div', { className: msg.err ? 'rt-err' : 'rt-foot' }, msg.err || msg.ok) : null,
err ? h('div', { className: 'rt-err' },
/engagement required|unknown op/i.test(err)
/* 老 host 还没有知识库接口:讲清怎么恢复,别让人对着 "engagement required" 发懵 */
? '知识库接口由 host 侧提供,当前宿主还是旧进程 —— 请完全退出后重新打开(官方 exe / 社区版 / dsh web 任一),再刷新页面。'
: err) : null,
!err && data && data.dbPath
? h('div', { className: 'rt-foot', style: { marginBottom: 6, fontSize: 11 } },
'知识库:' + data.dbPath
+ (data.root ? '(根目录 ' + data.root + ')' : ''))
: null,
h('div', { className: 'rt-table' },
(() => {
if (!grouped) return items.map(card)
/* 分组渲染:按内置归类顺序,未知归类挂到末尾的「其它」 */
const buckets = new Map()
for (const x of items) {
const code = POC_CAT_NAME[x.category] ? x.category : 'other'
if (!buckets.has(code)) buckets.set(code, [])
buckets.get(code).push(x)
}
const order = POC_CAT_ORDER.filter((c) => buckets.has(c))
for (const code of buckets.keys()) if (!order.includes(code)) order.push(code)
const out = []
for (const code of order) {
const list = buckets.get(code)
out.push(h('div', { key: 'g' + code, className: 'rt-kb-cat' },
h('span', { className: 'rt-kb-cat-name' }, POC_CAT_NAME[code] || code),
h('span', { className: 'rt-tag' }, list.length + ' 条'),
h('span', { className: 'rt-tag rt-tag-live' }, '已验证 ' + list.filter((x) => x.verified === 1).length),
h('span', { className: 'rt-spacer' }),
h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)' } },
'最近建立 ' + fmt(list.map((x) => x.created_at).filter(Boolean).sort().pop()))))
for (const x of list) out.push(card(x))
}
return out
})(),
tplItems.length
? h('div', { className: 'rt-kb-tpl' },
h('div', { className: 'rt-ap-sub' },
'本机 nuclei 模板 · ' + (tplStart + 1) + '–' + (tplStart + tplItems.length) + ' / ' + tplMatched
+ '(直接 `nuclei -t <模板路径>`)'),
tplItems.map((t, i) => h('div', { key: 't' + tplStart + '-' + i, className: 'rt-kb-tpl-row' },
h('span', { className: 'rt-tag' }, t.severity || '—'),
t.category ? h('span', { className: 'rt-tag' }, POC_CAT_NAME[t.category] || t.category) : null,
h('span', { className: 'rt-mono rt-kb-tpl-path', title: t.path }, t.path),
h('span', { className: 'rt-kb-tpl-name', title: t.name }, t.name || ''),
h('button', {
className: 'rt-btn', style: { padding: '0 6px', fontSize: 10.5 },
onClick: () => copy('nuclei -t ' + t.path + ' -u <目标>', '模板命令'),
}, '复制命令'))),
h('div', { className: 'rt-kb-actions' },
h('button', {
className: 'rt-btn', disabled: busy || tplStart <= 0,
onClick: () => turnTpl(tplStart - tplPageSize),
}, '上一页'),
h('button', {
className: 'rt-btn', disabled: busy || tplStart + tplItems.length >= tplMatched,
onClick: () => turnTpl(tplStart + tplPageSize),
}, '下一页')),
tpl.dir ? h('div', { className: 'rt-foot' }, h('span', null, '模板目录:' + tpl.dir)) : null)
: null,
data === null ? h('div', { className: 'rt-empty' }, '加载中…') : null,
data !== null && !items.length && !tplItems.length
? h('div', { className: 'rt-empty' },
h('div', null,
q || kind || verifiedOnly
? '没有命中:换个关键字再试,或去互联网找 / 自己手搓后回填。'
: (tpl.total
? ('POC/EXP 库还是空的;本机已有 ' + tpl.total + ' 个 nuclei 模板,但这一页没有列出来。请完全退出后重新打开客户端。')
: (tpl.dir
? '知识库还是空的(本机模板目录存在但没有 yaml,请跑 nuclei -update-templates)。'
: '知识库还是空的。本机也还没有 nuclei 模板库 —— 在演练机上跑 setup.sh 或 `nuclei -update-templates`,装好后这里会出现「本机模板 N」。'))),
h('div', { style: { marginTop: 6, fontSize: 12 } },
'打 Nday/1day 的标准顺序:① redteam_poc_search 先查这里(顺带搜本机 nuclei 模板库)→ ② 都没有就互联网搜索(web_search / GitHub / ExploitDB / 厂商公告)或自己手搓 → ③ 在真实目标上验证有效后 redteam_poc_add 回填,后面的靶标直接就能用。'),
data.dbPath ? h('div', { style: { marginTop: 6, fontSize: 11 }, className: 'rt-mono' }, '知识库文件:' + data.dbPath) : null,
tpl.dir ? h('div', { style: { marginTop: 6, fontSize: 11 }, className: 'rt-mono' }, '模板目录:' + tpl.dir) : null)
: null),
h('div', { className: 'rt-foot' },
h('span', null, '全局共享(跨靶标)| 落盘:pocs/<code>/ | 只收录通用可复用的 POC/EXP,靶标专用脚本走「攻击文件」')))
}
/* ---------------------------------------------------------- 环境适配(Windows / 非 Kali) */
/**
* 借鉴 Z3r0 的「系统配置」思路:路径与密钥进集中配置,不靠 bash/ps1。
* 落盘 $DSH_HOME/redteam/config.json —— 用户填工具绝对路径或整包搜索目录。
*/
function EnvTab(props) {
const refreshKey = props.refreshKey || 0
const [data, setData] = React.useState(null)
const [draft, setDraft] = React.useState(null)
const [err, setErr] = React.useState(null)
const [msg, setMsg] = React.useState(null)
const [busy, setBusy] = React.useState(false)
const [folderPath, setFolderPath] = React.useState('')
const applyDraftFrom = (r) => {
const c = (r && r.config) || {}
setDraft({
platform: c.platform || 'auto',
toolkitDir: c.toolkitDir || '',
nucleiTemplatesDir: c.nucleiTemplatesDir || '',
binDirsText: (c.binDirs || []).join('\n'),
tools: Object.assign({}, c.tools || {}),
env: Object.assign({ FOFA_KEY: '', REDTEAM_VPS_HOST: '', REDTEAM_VPS_KEY: '' }, c.env || {}),
notes: c.notes || '',
})
if (c.toolkitDir) setFolderPath(c.toolkitDir)
}
const load = () => {
setBusy(true); setMsg(null)
api({ op: 'platformConfigGet' }).then((r) => {
setBusy(false)
if (!r || r.ok === false) { setErr((r && r.error) || '读取失败'); return }
setErr(null); setData(r)
applyDraftFrom(r)
}, (e) => { setBusy(false); setErr(String((e && e.message) || e)) })
}
React.useEffect(load, [refreshKey])
const save = () => {
if (!draft) return
setBusy(true); setMsg(null)
const config = {
platform: draft.platform,
toolkitDir: draft.toolkitDir,
nucleiTemplatesDir: draft.nucleiTemplatesDir,
binDirs: String(draft.binDirsText || '').split(/\r?\n/).map((x) => x.trim()).filter(Boolean),
tools: draft.tools,
env: draft.env,
notes: draft.notes,
envAdaptConfigured: true,
envAdaptSkip: false,
}
api({ op: 'platformConfigSave', config }).then((r) => {
setBusy(false)
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '保存失败' }); return }
setData(r)
setMsg({ ok: '已保存到 ' + ((r.config && r.config.path) || '$DSH_HOME/redteam/config.json') })
applyDraftFrom(r)
}, (e) => { setBusy(false); setMsg({ err: String((e && e.message) || e) }) })
}
const assignFolder = () => {
const dir = String(folderPath || (draft && draft.toolkitDir) || '').trim()
if (!dir) { setMsg({ err: '请先填写工具所在文件夹路径' }); return }
setBusy(true); setMsg(null)
api({ op: 'platformAssignToolkit', dir }).then((r) => {
setBusy(false)
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '自动分配失败' }); return }
setData(r)
applyDraftFrom(r)
setMsg({
ok: '已从文件夹分配 ' + (r.assignedCount || 0) + ' 个工具'
+ (r.nucleiTemplatesDir ? ',并识别 nuclei-templates' : ''),
})
}, (e) => { setBusy(false); setMsg({ err: String((e && e.message) || e) }) })
}
const setTool = (id, value) => setDraft((d) => d ? Object.assign({}, d, { tools: Object.assign({}, d.tools, { [id]: value }) }) : d)
const setEnv = (key, value) => setDraft((d) => d ? Object.assign({}, d, { env: Object.assign({}, d.env, { [key]: value }) }) : d)
const runtime = (data && data.runtime) || {}
const adapt = (data && data.adapt) || {}
const tools = (data && data.tools && data.tools.items) || []
const found = tools.filter((t) => t.path).length
const defaultToolkit = (data && data.toolkitDir) || '$DSH_HOME/redteam/toolkit'
const defaultNuclei = (data && data.nucleiTemplatesDir) || (defaultToolkit + '/nuclei-templates')
return h('div', { className: 'rt-main' },
h('div', { className: 'rt-toolbar' },
h('span', { style: { fontWeight: 600 } }, '环境适配'),
h('span', { className: 'rt-tag' }, runtime.effective === 'windows' ? 'Windows' : 'Linux/Kali'),
adapt.kali ? h('span', { className: 'rt-tag rt-tag-live' }, 'Kali') : null,
h('span', { className: 'rt-tag' + (adapt.ready ? ' rt-tag-live' : ' rt-tag-warn') },
adapt.ready ? ('就绪 · ' + (adapt.reason || '')) : '待配置'),
h('span', { className: 'rt-tag' + (found ? ' rt-tag-live' : ' rt-tag-warn') }, '工具 ' + found + '/' + tools.length),
h('div', { className: 'rt-spacer' }),
h('button', { className: 'rt-btn', disabled: busy, onClick: load }, busy ? '读取中…' : '刷新'),
h('button', { className: 'rt-btn rt-btn-primary', disabled: busy || !draft, onClick: save }, '保存')),
h('div', { className: 'rt-pane', style: { minHeight: 0 } },
err ? h('div', { className: 'rt-err' }, err) : null,
msg ? h('div', { className: msg.err ? 'rt-err' : 'rt-foot' }, msg.err || msg.ok) : null,
h('div', { className: 'rt-card', style: { margin: '0 0 8px', fontSize: 12, lineHeight: 1.6 } },
adapt.message
|| '均可选填:留空则用默认路径/自动查找。填了的覆盖默认。密钥类只在需要测绘/反弹时才填。'),
h('div', { className: 'rt-card', style: { margin: '0 0 8px', fontSize: 12, lineHeight: 1.6 } },
h('div', { style: { fontWeight: 600, marginBottom: 6 } }, '整包文件夹 → 自动分配工具'),
h('div', { style: { marginBottom: 8, opacity: 0.9 } },
'工具都在同一个文件夹(含子目录)时,填路径后点「自动分配」:会写入 toolkitDir,并按文件名匹配 nmap / nuclei / fscan 等填到下方。'),
h('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
h('input', {
className: 'rt-input', style: { flex: 1 },
placeholder: '例如 D:\\pentest-tools 或 /opt/toolkit',
value: folderPath,
onChange: (e) => setFolderPath(e.target.value),
onKeyDown: (e) => { if (e.key === 'Enter') assignFolder() },
}),
h('button', {
className: 'rt-btn rt-btn-primary', disabled: busy,
onClick: assignFolder,
}, busy ? '扫描中…' : '自动分配'))),
data && data.hint ? h('div', { className: 'rt-card', style: { margin: '0 0 8px', fontSize: 12, lineHeight: 1.6 } }, data.hint) : null,
data && data.egress ? h('div', { className: 'rt-card', style: { margin: '0 0 8px', fontSize: 12, lineHeight: 1.55 } },
h('div', { style: { fontWeight: 600, marginBottom: 6 } }, '出网状态(借鉴 Z3r0 egress,只读)'),
h('div', { className: 'rt-mono', style: { fontSize: 11 } }, 'HTTP_PROXY=' + (data.egress.http_proxy || '(空)')),
h('div', { className: 'rt-mono', style: { fontSize: 11 } }, 'HTTPS_PROXY=' + (data.egress.https_proxy || '(空)')),
h('div', { className: 'rt-mono', style: { fontSize: 11 } }, 'ALL_PROXY=' + (data.egress.all_proxy || '(空)')),
h('div', { className: 'rt-mono', style: { fontSize: 11 } }, 'NO_PROXY=' + (data.egress.no_proxy || '(空)')),
h('div', { style: { marginTop: 6, opacity: 0.85 } }, data.egress.note || '')) : null,
!draft ? h('div', { className: 'rt-empty' }, '加载中…') : h('div', null,
h('div', { className: 'rt-card' },
h('h4', null, '平台与目录'),
h('div', { style: { display: 'grid', gap: 8 } },
h('label', { style: { fontSize: 12 } }, '平台模式',
h('select', {
className: 'rt-input', style: { display: 'block', width: '100%', marginTop: 4 },
value: draft.platform,
onChange: (e) => setDraft(Object.assign({}, draft, { platform: e.target.value })),
},
h('option', { value: 'auto' }, 'auto(跟随本机,默认)'),
h('option', { value: 'windows' }, 'windows'),
h('option', { value: 'linux' }, 'linux'))),
h('label', { style: { fontSize: 12 } }, '工具箱目录 toolkitDir(留空=默认)',
h('input', {
className: 'rt-input', style: { display: 'block', width: '100%', marginTop: 4 },
placeholder: '默认:' + defaultToolkit,
value: draft.toolkitDir,
onChange: (e) => setDraft(Object.assign({}, draft, { toolkitDir: e.target.value })),
})),
h('label', { style: { fontSize: 12 } }, 'nuclei 模板目录(留空=自动:toolkit/nuclei-templates)',
h('input', {
className: 'rt-input', style: { display: 'block', width: '100%', marginTop: 4 },
placeholder: '默认:' + defaultNuclei,
value: draft.nucleiTemplatesDir,
onChange: (e) => setDraft(Object.assign({}, draft, { nucleiTemplatesDir: e.target.value })),
})),
h('label', { style: { fontSize: 12 } }, '工具搜索目录 binDirs(留空=只搜 toolkit + PATH;每行一个)',
h('textarea', {
className: 'rt-input', rows: 4, style: { display: 'block', width: '100%', marginTop: 4, fontFamily: 'var(--dsw-font-mono, monospace)' },
placeholder: '可选。例如:' + String.fromCharCode(10) + 'D:\\tools' + String.fromCharCode(10) + 'D:\\pentest-bin',
value: draft.binDirsText,
onChange: (e) => setDraft(Object.assign({}, draft, { binDirsText: e.target.value })),
})))),
h('div', { className: 'rt-card' },
h('h4', null, '密钥与 VPS(留空=不覆盖;进程环境变量优先)'),
h('div', { style: { display: 'grid', gap: 8 } },
['FOFA_KEY', 'REDTEAM_VPS_HOST', 'REDTEAM_VPS_KEY'].map((k) => h('label', { key: k, style: { fontSize: 12 } }, k + '(可选)',
h('input', {
className: 'rt-input', style: { display: 'block', width: '100%', marginTop: 4 },
type: k === 'FOFA_KEY' ? 'password' : 'text',
placeholder: k === 'REDTEAM_VPS_KEY' ? '留空;需要时填私钥绝对路径' : (k === 'REDTEAM_VPS_HOST' ? '留空;需要时填 user@host' : '留空;测绘时再填'),
value: (draft.env && draft.env[k]) || '',
onChange: (e) => setEnv(k, e.target.value),
}))))),
h('div', { className: 'rt-card' },
h('h4', null, '工具可执行文件(留空=自动查找)'),
h('div', { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-secondary)', marginBottom: 8 } },
'解析顺序:本表绝对路径 → toolkitDir → binDirs → PATH。留空即走默认查找。'),
tools.map((t) => h('div', { key: t.id, style: { marginBottom: 8 } },
h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 } },
h('span', { style: { fontWeight: 600, minWidth: 88 } }, t.label || t.id),
t.path
? h('span', { className: 'rt-tag rt-tag-live', title: t.path }, t.source + ' · 已找到')
: h('span', { className: 'rt-tag rt-tag-warn' }, '未找到(可留空等装好)')),
h('input', {
className: 'rt-input', style: { width: '100%' },
placeholder: '留空自动找:' + (t.names || []).slice(0, 3).join(' / '),
value: (draft.tools && draft.tools[t.id]) || '',
onChange: (e) => setTool(t.id, e.target.value),
}),
t.path ? h('div', { className: 'rt-mono', style: { fontSize: 10.5, marginTop: 2, opacity: 0.8 } }, t.path) : null))),
h('div', { className: 'rt-card' },
h('h4', null, '备注'),
h('textarea', {
className: 'rt-input', rows: 3, style: { width: '100%' },
placeholder: '可选。例如:工具来自某某绿色包',
value: draft.notes,
onChange: (e) => setDraft(Object.assign({}, draft, { notes: e.target.value })),
})),
h('div', { className: 'rt-foot' },
h('span', null,
'配置文件:' + ((data && data.config && data.config.path) || '$DSH_HOME/redteam/config.json')
+ ' | 留空字段全部走默认')))))
}
/* ---------------------------------------------------------- 得分目标 */
function ScoreTab(props) {
const eng = props.engagement
const refreshKey = props.refreshKey || 0
const [data, setData] = React.useState(null)
const [err, setErr] = React.useState(null)
const [busy, setBusy] = React.useState(false)
const [openId, setOpenId] = React.useState(null)
const [form, setForm] = React.useState(null)
const [msg, setMsg] = React.useState(null)
const load = () => {
if (!eng) return
setBusy(true)
api({ op: 'scores', engagement: eng }).then((r) => {
setBusy(false)
if (!r || r.ok === false) { setErr((r && r.error) || '读取失败'); return }
setErr(null)
setData(r)
}, (e) => { setBusy(false); setErr(String((e && e.message) || e)) })
}
React.useEffect(load, [eng, refreshKey])
/* builtin 要一路带到表单:内置得分点的分值/名称/口径由《得分规则》锁定,
界面必须把输入框置灰并说明原因 —— 以前是「能改、提示已保存、刷新后变回去」,
用户以为是 bug。 */
const startEdit = (p) => setForm({
id: p.id, name: p.name, category: p.category || '', points: p.points,
description: p.description || '', enabled: p.enabled, builtin: p.builtin === true,
src: p.src, cap: p.cap, rule: p.rule,
})
const startNew = () => { setForm({ name: '', category: '', points: 10, description: '', enabled: true, builtin: false }); setMsg(null) }
const setField = (k, v) => setForm((f) => Object.assign({}, f, { [k]: v }))
const save = () => {
if (!form || !form.name) { setMsg({ err: '名称不能为空' }); return }
setBusy(true)
setMsg(null)
api({ op: 'saveScorePoint', engagement: eng, point: form }).then((r) => {
setBusy(false)
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '保存失败' }); return }
/* overridden === false 表示后端**没有采纳**提交的分值(内置点由规则锁定)。
这时不能笼统说「已保存」 —— 那句话会让用户以为分值改成功了。 */
if (r.overridden === false) {
setMsg({ err: '已保存「启用/停用」。分值未改动:这是随《得分规则》分发的内置得分点,'
+ '分值 / 上限 / 计分口径由规则锁定(同一条规则的上限按组内所有得分点累计,'
+ '单独改分值会让一条命中吃掉整组上限)。要自定义分值时请「+ 新增得分点」。' })
} else {
setMsg({ ok: r.note || '已保存' })
}
setForm(null)
load()
}, (e) => { setBusy(false); setMsg({ err: String((e && e.message) || e) }) })
}
const remove = () => {
if (!form || !form.id) { setForm(null); return }
setBusy(true)
setMsg(null)
api({ op: 'deleteScorePoint', engagement: eng, id: form.id }).then((r) => {
setBusy(false)
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '删除失败' }); return }
setMsg({ ok: '已删除' })
setForm(null)
setOpenId(null)
load()
}, (e) => { setBusy(false); setMsg({ err: String((e && e.message) || e) }) })
}
const summary = (data && data.summary) || { achievedPoints: 0, pointCount: 0, hitPointCount: 0, hitCount: 0, selfCreatedHits: 0, serviceCappedHits: 0 }
const items = (data && data.items) || []
/* 按合并版的 8 个类别分组渲染(后端 ruleGroups 已排好序、组内按分值升序)。
兼容:后端没给 ruleGroups(老 host)时退回平铺。 */
const groups = (data && data.ruleGroups && data.ruleGroups.length)
? data.ruleGroups
: [{ key: 'all', name: '', capSum: 0, points: 0, counted: 0, tiers: items }]
const rows = []
for (const g of groups) {
if (g.name) {
rows.push(h('div', { key: 'g-' + g.key, className: 'rt-score-group' },
h('span', null, g.name),
h('span', { className: 'rt-sg-sub' },
g.tiers.length + ' 项'
+ (g.capSum > 0 ? ' · 各项上限合计 ' + g.capSum + ' 分(各项独立,不跨项累加)' : '')),
h('div', { className: 'rt-spacer' }),
g.points > 0
? h('span', { className: 'rt-tag rt-tag-active' }, '+' + g.points + ' 分')
: null))
}
for (const p of g.tiers) {
const achieved = p.hits.length > 0
const open = openId === p.id
rows.push(h('div', {
key: 'sp' + p.id, className: 'rt-score-row', role: 'button', tabIndex: 0,
onClick: () => setOpenId(open ? null : p.id),
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpenId(open ? null : p.id) } },
},
h('span', { className: 'rt-sec-caret' }, open ? '▾' : '▸'),
h('span', null, h('span', {
className: achieved ? 'rt-pri rt-pri-high' : 'rt-pri rt-pri-low',
style: achieved ? {} : { background: 'var(--dsw-alias-bg-layer-2)', color: 'var(--dsw-alias-label-secondary)' },
}, p.points + '分')),
h('span', { title: p.description || '' },
p.name,
p.tier ? h('span', { className: 'rt-scope' }, ' ' + p.tier) : null),
/* 命中与上限:这一条计入几次、上限用了多少(G3 各项上限独立,到顶就不再累计) */
h('span', { title: p.capped > 0
? '已计入 ' + p.counted + ' 次,另有 ' + p.capped + ' 条因「' + (p.scope_label || '计分口径') + '」或已达上限不计分'
: (p.scope_label || '') },
h('span', { className: 'rt-sec-count' }, p.hits.length > 0 ? p.counted + ' 次命中' : '未命中'),
p.cap > 0
? h('span', { className: 'rt-scope' }, ' 上限 ' + (p.cap_used === null ? 0 : p.cap_used) + '/' + p.cap)
: null),
h('span', { style: { textAlign: 'right' } }, p.earned > 0
? h('span', { className: 'rt-tag rt-tag-active' }, '+' + p.earned + ' 分')
: h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, '—'))))
if (!open) continue
/* 命中记录:一行一条 —— 第一行给"序号 + 资产 + 时间 + 复制",内容另起一行自适应换行 */
const hitNodes = p.hits.map((hh, hi) => h('div', {
key: 'h' + hh.id,
className: 'rt-hit-row' + (hh.self_created ? ' self-created' : '') + (hh.capped ? ' service-capped' : ''),
},
h('span', { className: 'rt-hit-idx' }, String(hi + 1)),
h('span', { className: 'rt-hit-asset', title: hh.target || hh.asset_ip || '' },
hh.asset_ip || hh.target || '未指定资产'),
hh.self_created ? h('span', { className: 'rt-tag rt-tag-warn', title: '自己注册/自建的账号不算得分权限,只作过程记录' }, '自建 · 不计分') : null,
hh.capped ? h('span', {
className: 'rt-tag rt-tag-warn',
title: hh.capped_reason || '同一资产同一端口只算分值最高的一条,这条不计分',
}, '服务已拿满 · 不计分' + (hh.service ? '(' + hh.service + ')' : '')) : null,
hh.shot_count ? h('span', { className: 'rt-tag', title: '这条命中有证据截图(展开得分点即可看到代表图)' },
'图 ' + hh.shot_count) : null,
h('span', { className: 'rt-hit-time' }, fmt(hh.recorded_at)),
h('button', {
className: 'rt-btn', style: { padding: '0 5px', fontSize: 10.5 },
title: '复制这一条',
onClick: (e) => {
e.stopPropagation()
copyText((hh.asset_ip || hh.target || '') + ' ' + (hh.evidence || ''))
},
}, '复制'),
hh.evidence
? h('span', { className: 'rt-hit-txt', title: hh.evidence }, String(hh.evidence).replace(/\n+/g, ' '))
: h('span', { className: 'rt-hit-txt none' }, '未填账号密码/结果')))
rows.push(h('div', {
key: 'spd' + p.id, className: 'rt-score-row',
style: { cursor: 'default', gridTemplateColumns: '1fr' },
}, h('div', { className: 'rt-score-detail' },
p.description ? h('div', { className: 'rt-kv' }, h('b', null, '得分条件'), h('span', null, p.description)) : null,
h('div', { className: 'rt-kv' }, h('b', null, '状态'),
h('span', null, (p.enabled ? '启用' : '停用') + ' · ' + p.points + ' 分/次 · 命中 ' + p.hits.length +
' 次 = ' + p.earned + ' 分' + (p.self_created ? '(另有 ' + p.self_created + ' 次自建不计分)' : '') +
(p.capped ? '(另有 ' + p.capped + ' 次同服务重复命中不计分)' : ''))),
p.capped
? h('div', { className: 'rt-kv' }, h('b', null, '服务封顶'),
h('span', null, p.service_summary + ';账号权限与数据库权限按「同资产同端口」只算一次,拿到最高权限账号即该服务拿满'))
: null,
p.hits.length
? h('div', null,
h(ShotStrip, { eng: eng, shots: p.shots, total: p.shot_total,
title: '证据截图 · ' + (p.shot_total || (p.shots || []).length) + ' 张' }),
h('div', { className: 'rt-section', style: { padding: '6px 0 0' } },
'命中记录 · ' + p.hits.length + '(只记资产与账号密码,详细复现见报告)'),
h('div', { className: 'rt-hits' }, hitNodes))
: h('div', { className: 'rt-kv' }, h('b', null, '命中记录'),
h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } },
'还没有 —— 拿下成果后用 redteam_score_hit 记分:写明目标资产 + 拿到的账号密码/权限')),
h('div', { className: 'rt-actions' },
h('button', { className: 'rt-btn', onClick: (e) => { e.stopPropagation(); startEdit(p) } }, '编辑')))))
}
}
return h('div', { className: 'rt-main' },
h('div', { className: 'rt-toolbar' },
h('span', { style: { fontWeight: 600 } }, '得分目标'),
/* 只显示已拿下的总分,不显示目标分数、不显示进度条 */
h('span', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)', marginLeft: 4 } }, '总分'),
h('span', { className: 'rt-total' }, String(summary.achievedPoints)),
h('span', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, '分'),
h('span', { className: 'rt-tag' }, summary.pointCount + ' 个得分点'),
h('span', { className: 'rt-tag' }, '命中 ' + summary.countedHits + ' 次'),
summary.selfCreatedHits
? h('span', {
className: 'rt-tag rt-tag-warn',
title: '自己注册/自己创建的账号不算得分权限,只作过程记录(不计分、不占上限、不进报告)',
}, '自建不计分 ' + summary.selfCreatedHits)
: null,
summary.serviceCappedHits
? h('span', {
className: 'rt-tag rt-tag-warn',
title: '账号权限与数据库权限按「同资产同端口」只算一次:该服务已拿满,这些重复命中不计分(只作留痕)',
}, '服务已拿满不计分 ' + summary.serviceCappedHits)
: null,
h('div', { className: 'rt-spacer' }),
h('button', { className: 'rt-btn', onClick: startNew }, '+ 新增得分点'),
h('button', { className: 'rt-btn', disabled: busy, onClick: load }, busy ? '刷新中…' : '刷新')),
msg ? h('div', { className: msg.err ? 'rt-err' : 'rt-foot' }, msg.err || msg.ok) : null,
err ? h('div', { className: 'rt-err' }, err) : null,
form ? h('div', { className: 'rt-pane', style: { flex: 'none', borderBottom: '1px solid var(--dsw-alias-border-l1)' } },
/* 内置得分点(builtin):分值 / 名称 / 分类由《得分规则》锁定,输入框置灰。
可改的只有「启用 / 停用」。这样界面上就不会再出现「改完提示已保存、刷新变回去」的困惑。 */
form.builtin
? h('div', { className: 'rt-hint', style: { marginBottom: 8 } },
h('b', null, '内置得分点(来自《突破入侵类得分规则(合并版)》)'),
h('div', { style: { marginTop: 3 } },
'分值、上限、计分口径与名称由规则锁定 —— 同一条规则的上限按组内所有得分点累计,'
+ '单独改分值会让一条命中吃掉整组上限。这里可以改「启用 / 停用」;'
+ '要自定义分值时请返回上一屏点「+ 新增得分点」。'),
form.src !== null && form.src !== undefined
? h('div', { style: { marginTop: 3, color: 'var(--dsw-alias-label-secondary)' } },
'规则原文序号 ' + form.src + (form.rule ? ' · 上限分组 rule=' + form.rule : '')
+ (form.cap > 0 ? ' · 上限 ' + form.cap + ' 分' : ' · 不设上限'))
: null)
: null,
h('div', { className: 'rt-score-form' },
h('input', {
className: 'rt-input', placeholder: '名称(必填)', value: form.name,
readOnly: form.builtin === true,
title: form.builtin ? '内置得分点的名称由规则锁定' : '',
onChange: (e) => setField('name', e.target.value),
}),
h('input', {
className: 'rt-input', placeholder: '分类,如 账号权限', value: form.category,
readOnly: form.builtin === true,
title: form.builtin ? '内置得分点的分类由规则锁定(决定它属于面板哪一组)' : '',
onChange: (e) => setField('category', e.target.value),
}),
h('input', {
className: 'rt-input', type: 'number', placeholder: '单次分值',
readOnly: form.builtin === true,
title: form.builtin
? '内置得分点的分值由《得分规则》锁定,不能在这里改'
: '这一类的单次分值;每命中一次就按这个分值累加(受该条规则上限约束)',
value: form.points, onChange: (e) => setField('points', Number(e.target.value)),
}),
h('select', { className: 'rt-input', value: form.enabled ? '1' : '0', onChange: (e) => setField('enabled', e.target.value === '1') },
h('option', { value: '1' }, '启用'),
h('option', { value: '0' }, '停用'))),
h('input', {
className: 'rt-input', style: { width: '100%', marginBottom: 6, boxSizing: 'border-box' },
placeholder: '得分条件说明', value: form.description,
readOnly: form.builtin === true,
title: form.builtin ? '内置得分点的条款正文由规则锁定' : '',
onChange: (e) => setField('description', e.target.value),
}),
h('div', { className: 'rt-actions' },
h('button', { className: 'rt-btn rt-btn-primary', disabled: busy, onClick: save }, '保存'),
form.id
? (form.builtin
? h('button', {
className: 'rt-btn', disabled: true,
title: '内置得分点不能删除:删掉会让面板缺一条规则、报告少一类成果(下次启动还会自动补回来)。要让它不参与计分请改用「停用」。',
}, '删除(内置项不可删)')
: h('button', { className: 'rt-btn', disabled: busy, onClick: remove }, '删除'))
: null,
h('button', { className: 'rt-btn', onClick: () => setForm(null) }, '取消'))) : null,
h('div', { className: 'rt-table' },
h('div', { className: 'rt-score-row head' },
h('span', null, ''), h('span', null, '分值'), h('span', null, '得分点(按分值从低到高)'),
h('span', null, '命中 / 上限'), h('span', { style: { textAlign: 'right' } }, '已得分')),
rows,
!items.length ? h('div', { className: 'rt-empty' }, '暂无得分点,点右上角「新增得分点」') : null))
}
/* ---------------------------------------------------------- 折叠底座 */
/**
* 折叠状态按「页签 + 靶标」持久化到 localStorage,切页签/刷新后保持不变。
* 只记录用户显式点过的键;没点过的用调用方给的默认值。
*/
const COLLAPSE_KEY = 'rt-collapse:'
const collapseLoad = (prefix) => {
try {
const raw = window.localStorage.getItem(COLLAPSE_KEY + prefix)
const parsed = raw ? JSON.parse(raw) : null
return parsed && typeof parsed === 'object' ? parsed : {}
} catch (e) { return {} }
}
const collapseSave = (prefix, value) => {
try { window.localStorage.setItem(COLLAPSE_KEY + prefix, JSON.stringify(value)) } catch (e) { /* 隐私模式等 */ }
}
/** 一个页签一个 hook:isOpen(key, defaultOpen) / toggle(key, defaultOpen) / setAll(keys, open) */
function useCollapse(prefix) {
const [state, setState] = React.useState(() => collapseLoad(prefix))
React.useEffect(() => { setState(collapseLoad(prefix)) }, [prefix])
const write = (next) => { collapseSave(prefix, next); setState(next) }
const isOpen = (key, defaultOpen) => {
const v = state[key]
return v === undefined ? defaultOpen !== false : v === true
}
const toggle = (key, defaultOpen) => (e) => {
if (e && e.stopPropagation) e.stopPropagation()
write(Object.assign({}, state, { [key]: !isOpen(key, defaultOpen) }))
}
const setAll = (keys, open) => {
const next = Object.assign({}, state)
for (const k of keys) next[k] = open
write(next)
}
return { isOpen: isOpen, toggle: toggle, setAll: setAll }
}
/**
* 统一折叠头(L1):▾ 固定在最左、标题加粗、计数紧跟、右侧放时间或操作,
* 内容缩进 12px 并带一条竖引导线。always=true 表示"常显"(不可折叠)。
*/
function Section(props) {
const always = props.always === true
const open = always || props.open === true
const head = h('div', {
className: 'rt-sec t-' + (props.tone || 'target') + (always ? ' flat' : ''),
role: always ? undefined : 'button',
tabIndex: always ? undefined : 0,
'aria-expanded': always ? undefined : (open ? 'true' : 'false'),
onClick: always ? undefined : props.onToggle,
onKeyDown: always ? undefined : (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (props.onToggle) props.onToggle(e) }
},
},
h('span', { className: 'rt-sec-caret' }, always ? '▍' : (open ? '▾' : '▸')),
props.title ? h('span', { className: 'rt-sec-title' }, props.title) : null,
props.count !== undefined && props.count !== null ? h('span', { className: 'rt-sec-count' }, String(props.count)) : null,
props.sub ? h('span', { className: 'rt-sec-sub' }, props.sub) : null,
props.right ? h('span', { className: 'rt-sec-right' }, props.right) : null)
return h('div', { className: 'rt-sec-wrap' }, head,
open && props.children ? h('div', { className: 'rt-sec-body' }, props.children) : null)
}
/** 长文本折叠(L3):固定预览高度 + 渐隐 + 展开/复制。 */
function Clip(props) {
const [open, setOpen] = React.useState(false)
const text = String(props.text === undefined || props.text === null ? '' : props.text)
if (text.trim() === '') return null
return h('div', { className: 'rt-clip' + (open ? ' open' : '') },
h('div', { className: 'rt-clip-head' },
props.label ? h('span', { className: 'rt-clip-label' }, props.label) : null,
h('div', { className: 'rt-spacer' }),
h('button', {
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
onClick: (e) => { e.stopPropagation(); copyText(text) },
}, '复制'),
h('button', {
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
onClick: (e) => { e.stopPropagation(); setOpen((v) => !v) },
}, open ? '收起' : '展开')),
h('div', { className: 'rt-clip-body' }, text))
}
/* ---------------------------------------------------------- 当前测试(实时) */
const TEST_STATUS_LABEL = {
untested: '未测试', testing: '测试中', tested: '已测试',
blocked: '被封禁', abandoned: '已放弃', no_surface: '无攻击面',
}
/**
* 当前测试页:agent 正在打哪台、打到哪一步、还有什么在排队。
* 5 秒轮询自动刷新;三个区块可折叠,其中「正在测」始终完整展开。
*/
function TestingTab(props) {
const eng = props.engagement
const refreshKey = props.refreshKey || 0
const [data, setData] = React.useState(null)
const [err, setErr] = React.useState(null)
const [at, setAt] = React.useState(null)
const [auto, setAuto] = React.useState(true)
/* 折叠状态按「页签 + 靶标」记忆;正在测永远常显,不参与折叠 */
const collapse = useCollapse('testing:' + eng)
/* 5 秒轮询要有在途守卫:网络慢或后端卡住时,请求会越堆越多、
而且旧响应回来会覆盖新状态(界面上表现为数字来回跳)。 */
const inflight = React.useRef(false)
const seq = React.useRef(0)
const load = () => {
if (!eng || inflight.current) return
inflight.current = true
const my = ++seq.current
api({ op: 'activeTests', engagement: eng, limit: 20 }).then((r) => {
if (my !== seq.current) return /* 切了靶标:这条已经过期,丢掉 */
if (!r || r.ok === false) { setErr((r && r.error) || '读取失败'); return }
setErr(null); setData(r); setAt(new Date())
}, (e) => { if (my === seq.current) setErr(String((e && e.message) || e)) })
.finally(() => { if (my === seq.current) inflight.current = false })
}
React.useEffect(() => { seq.current += 1; load() }, [eng, refreshKey])
React.useEffect(() => {
if (!eng || !auto) return undefined
const timer = setInterval(load, 5000)
return () => clearInterval(timer)
}, [eng, auto, refreshKey])
const testing = (data && data.testing) || []
const recent = (data && data.recent) || []
const queue = (data && data.queue) || []
const stats = (data && data.stats) || {}
/* 一台资产一张卡:一眼看清"打的是谁、打到哪、拿到什么" */
const card = (a, past) => {
const chips = []
chips.push(h('span', { key: 'sc', className: 'rt-scope rt-scope-' + (a.scope === 'internal' ? 'internal' : 'external') },
a.scope === 'internal' ? '内网' : '外网'))
if (a.segment_cidr) chips.push(h('span', { key: 'seg', className: 'rt-tag' }, a.segment_cidr))
if (a.open_ports) chips.push(h('span', { key: 'p', className: 'rt-tag rt-tag-active' }, '开放 ' + a.open_ports + ' 端口'))
if (a.vulns) chips.push(h('span', { key: 'v', className: 'rt-tag rt-tag-live' }, '已确认漏洞 ' + a.vulns))
if (a.webshells) chips.push(h('span', { key: 'w', className: 'rt-tag rt-tag-passive' }, 'WebShell ' + a.webshells))
if (a.tunnels) chips.push(h('span', { key: 't', className: 'rt-tag rt-tag-passive' }, '隧道 ' + a.tunnels))
if (a.priority) chips.push(h('span', { key: 'pr', className: 'rt-tag' }, '易打 ' + a.priority))
if (a.blocked_count) chips.push(h('span', { key: 'b', className: 'rt-tag', style: { color: '#ef4444', borderColor: '#ef444455' } }, '被封 ' + a.blocked_count + ' 次'))
const noteLines = a.test_notes
? String(a.test_notes).split('\n').slice(-3).map((l) => (l.length > 240 ? l.slice(0, 240) + ' …' : l))
: []
return h('div', { key: 'at' + a.id, className: 'rt-atest' + (past ? ' past' : '') },
h('div', { className: 'rt-atest-head' },
past ? null : h('span', { className: 'rt-live-dot' }),
h('span', { className: 'rt-atest-ip' }, a.ip),
h('span', { className: 'rt-tag ' + (a.test_status === 'testing' ? 'rt-tag-live' : '') }, TEST_STATUS_LABEL[a.test_status] || a.test_status),
h('div', { className: 'rt-spacer' }),
h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)' } },
fmt(a.test_updated_at) + (a.test_updated_by ? ' · ' + a.test_updated_by : ''))),
chips.length ? h('div', null, chips) : null,
a.test_surface ? h('div', { className: 'rt-atest-meta' }, '测试面:' + a.test_surface) : null,
a.potential ? h('div', { className: 'rt-atest-meta' }, '预期得分:' + a.potential) : null,
noteLines.length ? h(Clip, { label: '测试记录', text: noteLines.join('\n') }) : null)
}
const concl = (label, value, tone) => h('span', { key: label, className: 'rt-concl-i', style: { cursor: 'default' } },
h('b', null, String(value || 0)), h('span', null, label))
const conclusion = h('div', { className: 'rt-concl' },
concl('测试中', stats.testing, 'live'),
concl('待测', stats.untested, ''),
concl('已测', stats.tested, ''),
concl('放弃', (stats.abandoned || 0) + (stats.blocked || 0), ''),
concl('无攻击面', stats.no_surface, ''),
h('div', { className: 'rt-spacer' }),
at ? h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)' } },
'更新于 ' + at.toLocaleTimeString('zh-CN', { hour12: false })) : null,
h('button', {
className: 'rt-btn', style: { padding: '0 7px', fontSize: 11 }, title: '展开最近动过与待测队列',
onClick: () => collapse.setAll(['testing:' + eng, 'recent:' + eng, 'queue:' + eng], true),
}, '全部展开'),
h('button', {
className: 'rt-btn', style: { padding: '0 7px', fontSize: 11 }, title: '只留正在测',
onClick: () => collapse.setAll(['testing:' + eng, 'recent:' + eng, 'queue:' + eng], false),
}, '全部收起'),
h('button', {
className: 'rt-btn' + (auto ? ' rt-btn-primary' : ''), style: { padding: '0 7px', fontSize: 11 },
title: '每 5 秒自动刷新', onClick: () => setAuto((x) => !x),
}, auto ? '实时 · 5s' : '已暂停'),
h('button', { className: 'rt-btn', style: { padding: '0 7px', fontSize: 11 }, onClick: load }, '刷新'))
const body = []
/* 正在测:常显,完整展开(不可折叠) */
body.push(h(Section, {
key: 'testing', tone: 'test', title: '正在测',
count: testing.length + ' 台',
sub: testing.length ? 'agent 正在打这些资产' : 'agent 开始测某台资产后会实时出现在这里',
open: collapse.isOpen('testing:' + eng, true),
onToggle: collapse.toggle('testing:' + eng, true),
}, testing.length
? testing.map((a) => card(a, false))
: h('div', { className: 'rt-atest-meta', style: { paddingBottom: 4 } }, '当前没有资产处于「测试中」')))
/* 最近动过(默认折叠,可记忆) */
body.push(h(Section, {
key: 'recent', tone: 'past', title: '最近动过', count: recent.length + ' 台',
sub: '已测过的资产',
open: collapse.isOpen('recent:' + eng, false),
onToggle: collapse.toggle('recent:' + eng, false),
}, recent.length ? recent.map((a) => card(a, true)) : h('div', { className: 'rt-atest-meta' }, '暂无')))
/* 待测队列(默认折叠,可记忆) */
body.push(h(Section, {
key: 'queue', tone: 'queue', title: '待测队列',
count: (data ? (data.untested || 0) : 0) + ' 台',
sub: '按易打性与端口数排出先打哪几台',
open: collapse.isOpen('queue:' + eng, false),
onToggle: collapse.toggle('queue:' + eng, false),
}, queue.length
? queue.map((a) => card(a, true)).concat([
h('div', { key: 'queueHint', className: 'rt-atest-meta', style: { paddingTop: 4 } },
'完整清单(含筛选与排序)见「资产测绘」页')])
: h('div', { className: 'rt-atest-meta' }, '没有待测资产')))
return h('div', { className: 'rt-main' },
conclusion,
err ? h('div', { className: 'rt-err' }, err) : null,
data === null && !err ? h('div', { className: 'rt-empty' }, '加载中…') : null,
h('div', { className: 'rt-body', style: { overflow: 'auto' } },
h('div', { style: { padding: '6px 12px 14px' } }, body)))
}
/* ---------------------------------------------------------- 会话与入口(WebShell / 隧道) */
/**
* 打内网最容易出的问题:拿到 WebShell 或隧道之后忘了登记,过一会儿就"忘了还有入口可用"。
* 这个页签把事实库里的 WebShell 与隧道集中展示,带连通状态,并可一键让 host 侧实测。
*/
function SessionTab(props) {
const eng = props.engagement
const refreshKey = props.refreshKey || 0
const [data, setData] = React.useState(null)
const [err, setErr] = React.useState(null)
const [busy, setBusy] = React.useState(false)
const [msg, setMsg] = React.useState(null)
const [copied, setCopied] = React.useState(null)
const load = () => {
if (!eng) return
api({ op: 'sessions', engagement: eng }).then((r) => {
if (!r || r.ok === false) { setErr((r && r.error) || '读取失败'); return }
setErr(null)
setData(r)
}, (e) => setErr(String((e && e.message) || e)))
}
React.useEffect(load, [eng, refreshKey])
const probe = () => {
setBusy(true); setMsg(null)
api({ op: 'probeSessions', engagement: eng, timeoutMs: 6000 }).then((r) => {
setBusy(false)
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '检测失败' }); load(); return }
const on = (r.webshells || []).filter((x) => x.status === 'online').length
const act = (r.tunnels || []).filter((x) => x.status === 'active').length
setMsg({ ok: '检测完成:WebShell 在线 ' + on + '/' + (r.webshells || []).length + ',隧道可用 ' + act + '/' + (r.tunnels || []).length })
load()
}, (e) => { setBusy(false); setMsg({ err: String((e && e.message) || e) }) })
}
/* 只有真的写进剪贴板才把按钮点亮成「已复制」;失败时如实报错 */
const copy = (key, text) => copyWithFeedback(text, text, (okFlag, message) => {
if (!okFlag) { setMsg({ err: message }); return }
setCopied(key)
setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500)
})
const markTunnel = (t, status) => {
setMsg(null)
api({ op: 'updateTunnel', engagement: eng, id: t.id, patch: { status: status, check_note: '界面手动标记' } })
.then(() => load(), (e) => setMsg({ err: String((e && e.message) || e) }))
}
const markShell = (w, status) => {
setMsg(null)
api({ op: 'updateWebshell', engagement: eng, id: w.id, patch: { status: status, check_note: '界面手动标记' } })
.then(() => load(), (e) => setMsg({ err: String((e && e.message) || e) }))
}
const sessCollapse = useCollapse('sessions:' + eng)
const totals = (data && data.totals) || {}
const shells = (data && data.webshells) || []
const tunnels = (data && data.tunnels) || []
const statusDot = (s) => h('span', { className: s === 'online' || s === 'active' ? 'rt-dot-on' : (s === 'unknown' || !s ? 'rt-dot-unk' : 'rt-dot-off') })
/* 卡片里统一用「标签 + 值」两列,长文本自动换行 —— 比一行点分隔好扫读 */
const fact = (label, value) => h('div', { className: 'rt-sess-fact' }, h('b', null, label), h('span', null, value))
/* 交付要求:马必须是冰蝎/哥斯拉加密马(用户才连得上),内网必须走 suo5 隧道 */
const isEncryptedShell = (t) => /behinder|godzilla|冰蝎|哥斯拉/i.test(String(t || ''))
const isSuo5 = (t) => /suo5/i.test(String(t || ''))
/* 提示条:只留一句结论 + 可展开的做法(原来是把三段长解释平铺,正文全被淹掉) */
const hint = (key, title, detail) => h('div', { key, className: 'rt-hint' },
h('b', null, title),
h('details', { className: 'rt-sess-fold', style: { marginTop: 3 } },
h('summary', null, '怎么做(点击展开)'),
h('div', { style: { marginTop: 4 } }, detail)))
const hints = []
const badShells = shells.filter((w) => !isEncryptedShell(w.shell_type))
const legitTunnels = tunnels.filter((t) => t.legit === true)
if (badShells.length > 0) {
hints.push(hint('h1', '有 ' + badShells.length + ' 个入口不是冰蝎马/哥斯拉马(用户连不上,不算可交付入口)',
'用技能 webshell-toolkit 重新上传冰蝎马(behinder)或哥斯拉马(godzilla),'
+ '并把 shell_type + pass_key 写进 redteam_webshell_add。只作临时中转的可在备注里写明。'))
}
if (tunnels.length > 0 && legitTunnels.length === 0) {
hints.push(hint('h3', '现有 ' + tunnels.length + ' 条通道都不算"跨越靶标边界"(不算边界/内网突破)',
'自己的 VPS / 自建服务器上开的 socks5、frp、代理不算隧道。必须是目标侧发起的通道:'
+ '目标反弹 shell 到我方、目标上跑 frp/Stowaway 客户端、或经目标 WebShell 建的 suo5/HTTP 隧道;登记时用 entry_kind 说明。'))
}
const activeSuo5 = tunnels.filter((t) => isSuo5(t.kind) && t.status === 'active')
if (shells.length > 0 && activeSuo5.length === 0) {
hints.push(hint('h2', '还没有可用的 suo5 隧道(打进内网的标准通道)',
'用技能 suo5-tunnel 通过上面的 WebShell 建 socks5 隧道,再 redteam_tunnel_add'
+ '(kind=suo5、listen=127.0.0.1:1080、entry=WebShell URL、reach=可达网段)登记,'
+ '最后点「检测连通性」确认 status=active。'))
}
const shellCards = shells.map((w) => h('div', { key: 'w' + w.id, className: 'rt-sess' },
h('div', { className: 'rt-sess-head' },
statusDot(w.status),
h('span', { className: 'rt-sess-title' }, w.url),
h('span', { className: 'rt-tag rt-tag-active' }, w.shell_type || 'webshell'),
isEncryptedShell(w.shell_type) ? null : h('span', { className: 'rt-tag rt-tag-warn' }, '用户连不上'),
w.privilege ? h('span', { className: 'rt-tag' }, w.privilege) : null,
h('div', { className: 'rt-spacer' }),
copied === 'cmd' + w.id
? h('span', { className: 'rt-tag', style: { color: '#10b981' } }, '已复制')
: h('button', {
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
onClick: () => copy('cmd' + w.id, 'curl -s "' + w.url + '"'),
}, '复制 URL'),
h('button', {
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
onClick: () => markShell(w, 'offline'),
}, '标记失效')),
h('div', { className: 'rt-sess-facts' },
fact('类型', (isEncryptedShell(w.shell_type) ? '加密马(冰蝎/哥斯拉,用户可直连)' : '非加密马 —— 用户连不上,仅可作临时中转')),
w.pass_key ? fact('连接口令', h('span', { className: 'rt-mono' }, w.pass_key)) : null,
w.asset_ip ? fact('所在资产', w.asset_ip) : null,
w.privilege ? fact('权限', w.privilege) : null,
w.secret_ref ? fact('凭据引用', h('span', { className: 'rt-mono' }, w.secret_ref)) : null,
fact('最后检测', (w.last_check ? fmt(w.last_check) : '未检测')
+ (w.latency_ms !== null && w.latency_ms !== undefined ? '(' + w.latency_ms + 'ms)' : '')),
w.check_note ? fact('检测说明', w.check_note) : null,
w.note ? fact('备注', w.note) : null)))
const tunnelCards = tunnels.map((t) => {
const proxy = t.listen ? 'socks5://' + t.listen : ''
const gogoCmd = t.listen ? './gogo -i <内网CIDR> -m ss --ping -p top2,win,db --proxy ' + proxy : ''
const fscanCmd = t.listen ? './fscan -h <内网CIDR> -np -nobr -nopoc -socks5 ' + t.listen + ' -o intranet.txt' : ''
return h('div', { key: 't' + t.id, className: 'rt-sess' },
h('div', { className: 'rt-sess-head' },
statusDot(t.status),
h('span', { className: 'rt-sess-title' }, t.listen || '(未填监听地址)'),
h('span', { className: 'rt-tag rt-tag-passive' }, t.kind || 'tunnel'),
isSuo5(t.kind) ? null : h('span', { className: 'rt-tag rt-tag-warn' }, '非 suo5 标准通道'),
/* 只有跨越靶标边界的通道才算突破凭证(自己 VPS/自建服务器上开的不算) */
t.legit === true
? h('span', { className: 'rt-tag rt-tag-live', title: t.entry_kind_label || '' }, '目标侧通道')
: (t.legit === false
? h('span', { className: 'rt-tag rt-tag-warn', title: '只在自己 VPS/自建服务器上开的通道,没有碰到目标 —— 不算边界突破/内网突破' }, '不算突破')
: h('span', { className: 'rt-tag', title: '未声明 entry_kind:请说明目标侧的那一端是什么(target-outbound / target-http / target-agent)' }, '待确认')),
t.reach ? h('span', { className: 'rt-tag' }, '可达 ' + t.reach) : null,
h('div', { className: 'rt-spacer' }),
h('button', {
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
onClick: () => copy('sock' + t.id, t.listen || ''),
}, copied === 'sock' + t.id ? '已复制' : '复制地址'),
t.status === 'active'
? h('button', { className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 }, onClick: () => markTunnel(t, 'down') }, '标记失效')
: h('button', { className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 }, onClick: () => markTunnel(t, 'active') }, '标记可用')),
h('div', { className: 'rt-sess-facts' },
fact('目标侧入口', t.entry || '未登记'),
fact('跨越边界', t.legit === true
? h('span', { className: 'rt-tag rt-tag-live' }, t.entry_kind_label || t.entry_kind || '已确认目标侧')
: (t.legit === false
? h('span', { className: 'rt-tag rt-tag-warn' }, '不算突破(只在自己 VPS 上开代理)')
: h('span', { className: 'rt-tag' }, '未声明 entry_kind,待确认'))),
t.reach ? fact('可达网段', t.reach) : null,
t.asset_ip ? fact('所在资产', t.asset_ip) : null,
fact('最后检测', (t.last_check ? fmt(t.last_check) : '未检测')
+ (t.latency_ms !== null && t.latency_ms !== undefined ? '(' + t.latency_ms + 'ms)' : '')),
t.check_note ? fact('检测说明', t.check_note) : null,
t.note ? fact('备注', t.note) : null),
/* 命令默认折叠:卡片首要信息是"这条通道能不能用、通向哪",命令按需展开 */
t.command ? h('details', { className: 'rt-sess-fold' },
h('summary', null, '建立命令(点击展开 / 复制)'),
h('div', Object.assign({ className: 'rt-code', title: '点击复制' }, clickable(() => copy('c' + t.id, t.command), { label: '复制命令' })),
copied === 'c' + t.id ? '已复制' : t.command)) : null,
t.status === 'active' && t.listen
? h('details', { className: 'rt-sess-fold' },
h('summary', null, '走这条隧道扫描(gogo / fscan 命令)'),
h('div', { className: 'rt-sess-cmd' },
h('div', Object.assign({ className: 'rt-code', title: '点击复制' }, clickable(() => copy('g' + t.id, gogoCmd), { label: '复制 gogo 命令' })),
copied === 'g' + t.id ? '已复制' : gogoCmd),
h('div', Object.assign({ className: 'rt-code', style: { display: 'block', marginTop: 3 }, title: '点击复制' }, clickable(() => copy('f' + t.id, fscanCmd), { label: '复制 fscan 命令' })),
copied === 'f' + t.id ? '已复制' : fscanCmd)))
: null)
})
return h('div', { className: 'rt-main' },
h('div', { className: 'rt-toolbar' },
h('span', { style: { fontWeight: 600 } }, '会话与入口'),
h('span', { className: 'rt-tag' }, 'WebShell ' + (totals.webshellsOnline || 0) + '/' + (totals.webshells || 0) + ' 在线'),
h('span', { className: 'rt-tag' }, '隧道 ' + (totals.tunnelsActive || 0) + '/' + (totals.tunnels || 0) + ' 可用'),
h('span', { className: 'rt-tag' }, '凭据 ' + (totals.credentials || 0)),
h('span', { className: 'rt-tag' }, '访问会话 ' + (totals.access || 0)),
h('div', { className: 'rt-spacer' }),
h('button', { className: 'rt-btn rt-btn-primary', disabled: busy, onClick: probe }, busy ? '检测中…' : '检测连通性'),
h('button', { className: 'rt-btn', onClick: load }, '刷新')),
msg ? h('div', { className: msg.err ? 'rt-err' : 'rt-foot' }, msg.err || msg.ok) : null,
err ? h('div', { className: 'rt-err' }, err) : null,
h('div', { className: 'rt-body', style: { overflow: 'auto' } },
!shells.length && !tunnels.length
? h('div', { className: 'rt-empty' },
h('div', null, '还没有登记任何 WebShell 或隧道。'),
h('div', { style: { marginTop: 6, fontSize: 12 } },
'拿到 WebShell 用 redteam_webshell_add;建好隧道用 redteam_tunnel_add(suo5 / socks5 / ssh -R);之后智能体用 redteam_sessions 就能看到。'))
: null,
hints.length ? h('div', { style: { padding: '8px 10px 0' } }, hints) : null,
h(Section, {
key: 'webshells', tone: 'queue', title: 'WebShell',
count: (totals.webshellsOnline || 0) + '/' + (totals.webshells || 0) + ' 在线',
sub: '已上线的可控入口',
open: sessCollapse.isOpen('shells', true), onToggle: sessCollapse.toggle('shells', true),
}, shells.length
? h('div', { className: 'rt-sess-grid', style: { padding: 0 } }, shellCards)
: h('div', { className: 'rt-atest-meta' }, '暂无(拿到 WebShell 后用 redteam_webshell_add 登记)')),
h(Section, {
key: 'tunnels', tone: 'test', title: '内网隧道',
count: (totals.tunnelsActive || 0) + '/' + (totals.tunnels || 0) + ' 可用',
sub: '可直接给扫描器当代理用',
open: sessCollapse.isOpen('tunnels', true), onToggle: sessCollapse.toggle('tunnels', true),
}, tunnels.length
? h('div', { className: 'rt-sess-grid', style: { padding: 0 } }, tunnelCards)
: h('div', { className: 'rt-atest-meta' }, '暂无(建好隧道后用 redteam_tunnel_add 登记)'))))
}
/* ---------------------------------------------------------- 错误边界 */
/**
* 单个页签渲染出错时只降级该页签,不拖垮整个面板:面板与侧栏按钮保持可用,
* 用户可一键回到资产测绘。(此前面板整块消失、按钮点不开就是缺了这层保护。)
*/
class RtBoundary extends React.Component {
constructor(props) {
super(props)
this.state = { error: null }
}
static getDerivedStateFromError(error) {
return { error: error }
}
componentDidCatch(error) {
try { console.error('[redteam-ui] 页面渲染出错:', error) } catch (e) { /* ignore */ }
}
render() {
if (this.state.error) {
const msg = this.state.error && this.state.error.message ? this.state.error.message : String(this.state.error)
return h('div', { className: 'rt-pane' },
h('div', { className: 'rt-err' }, '该页面渲染出错:' + msg),
h('button', {
className: 'rt-btn',
onClick: () => { this.setState({ error: null }); setUI({ tab: 'assets' }) },
}, '回到资产测绘'))
}
return this.props.children
}
}
/**
* 智能体页签:并发名额(最多 3 个)+ 六个角色与它们的提示词。
* 主会话派活前先看这里的名额,用户也能一眼看到"现在还能拉起几个智能体"。
*/
function AgentsTab(props) {
const eng = props.engagement
const refreshKey = props.refreshKey || 0
const [roles, setRoles] = React.useState(null)
const [err, setErr] = React.useState(null)
const [concurrency, setConcurrency] = React.useState(null)
const [busy, setBusy] = React.useState(false)
const load = () => {
setBusy(true)
api({ op: eng ? 'prompts' : 'bootstrap', engagement: eng || undefined }).then((r) => {
setBusy(false)
if (!r || r.ok === false) { setErr((r && r.error) || '读取失败'); return }
setErr(null)
if (r.roles) setRoles(r.roles)
}, (e) => { setBusy(false); setErr(String((e && e.message) || e)) })
/* 并发名额按"当前跑着的子智能体"算;host 侧读 subagents 注册表 */
api({ op: 'agentsStatus' }).then((r) => {
if (r && r.ok !== false) setConcurrency(r)
}, () => {})
}
React.useEffect(load, [eng, refreshKey])
const used = concurrency ? concurrency.used : null
const max = concurrency ? concurrency.max : 3
return h('div', { className: 'rt-main' },
h('div', { className: 'rt-toolbar' },
h('span', { style: { fontWeight: 600 } }, '作战智能体'),
h('span', { className: 'rt-tag rt-tag-passive' }, '并发上限 ' + max),
used === null
? h('span', { className: 'rt-tag' }, '占用未知')
: h('span', { className: 'rt-tag' + (used >= max ? ' rt-tag-warn' : ' rt-tag-live') }, '在跑 ' + used + ' / 剩余 ' + Math.max(max - used, 0)),
h('div', { className: 'rt-spacer' }),
h('button', { className: 'rt-btn', disabled: busy, onClick: load }, busy ? '读取中…' : '刷新')),
h('div', { className: 'rt-pane' },
err ? h('div', { className: 'rt-err' }, err) : null,
h('div', { className: 'rt-card' },
h('h4', null, '并发规则(硬约束)'),
h('div', { style: { fontSize: 12, lineHeight: 1.7 } },
'· 同一靶标**同时最多 ' + max + ' 个执行智能体**;主会话派活前用 `redteam_agent_slot` 占位,满了直接拒绝。',
h('br'),
'· **默认一个一个派、按顺序推进**;只有确实互不依赖的活才并行。',
h('br'),
'· 每个会话有**自己的靶标绑定**:多会话并行不会把报告写串(子智能体继承父会话的靶标)。')),
h('div', { className: 'rt-card' },
h('h4', null, '六个角色'),
h('div', { style: { fontSize: 12, lineHeight: 1.8 } },
'① 信息收集 `recon` —— 只收集资产,把单位资产收集完整(含边缘与未备案资产)', h('br'),
'② 资产梳理 `assess` —— 一条一条过,评易打性并全部落库', h('br'),
'③ 漏洞发现 `vuln-scan` —— 先查库去重 → Nday/1day 优先 → 接口未授权探测', h('br'),
'④ 漏洞利用 `exploit` —— 先拿服务器权限(冰蝎/哥斯拉马)+ 建 suo5 隧道,再打其它得分项', h('br'),
'⑤ 内网渗透 `internal` —— 走隧道,依次拉起 ①②③④ 做内网', h('br'),
'⑥ 主会话 `plan` —— 只做计划、派活、汇总、汇报,不动手')),
h(ConcurrencyCard, { concurrency, onSaved: load }),
h('div', { className: 'rt-card' },
h('h4', null, '角色提示词(按靶标存,可在这里编辑)'),
h('div', { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-secondary)', marginBottom: 6 } },
'「智能体提示词」已并入本页;下面是完整编辑器(改完点保存,只影响当前靶标)。'),
h(PromptsTab, { engagement: eng, refreshKey: refreshKey }))))
}
function ConcurrencyCard(props) {
const info = props.concurrency || {}
const [draft, setDraft] = React.useState('')
const [busy, setBusy] = React.useState(false)
const [msg, setMsg] = React.useState(null)
React.useEffect(() => { if (info.max) setDraft(String(info.max)) }, [info.max])
const limit = info.limit || 10
const used = typeof info.used === 'number' ? info.used : null
const source = info.source === 'settings' ? '面板中设置'
: info.source === 'env' ? '环境变量 REDTEAM_MAX_AGENTS' : '默认值'
const n = Number(draft)
const invalid = !Number.isFinite(n) || Math.floor(n) !== n || n < 1 || n > limit
const save = () => {
if (invalid) return
setBusy(true); setMsg(null)
api({ op: 'setAgentsMax', max: n }).then((r) => {
setBusy(false)
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '保存失败' }); return }
setMsg({ ok: '已保存:并发上限 ' + r.max + '(立即生效,不用重启)' })
if (props.onSaved) props.onSaved()
}, (e) => { setBusy(false); setMsg({ err: String((e && e.message) || e) }) })
}
return h('div', { className: 'rt-card' },
h('h4', null, '并发数量'),
h('div', { style: { fontSize: 12, lineHeight: 1.8, marginBottom: 8 } },
'同一靶标**同时最多几个执行智能体**。当前生效:**' + (info.max || '?') + '**'
+ (used === null ? '' : '(在跑 ' + used + ',剩余 ' + Math.max((info.max || 0) - used, 0) + ')')
+ ',来源:' + source + '。改完**立即生效**,不用重启。'),
h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
h('input', {
className: 'rt-input', style: { width: 90 }, type: 'number', min: 1, max: limit,
value: draft, onChange: (e) => setDraft(e.target.value),
}),
h('span', { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-secondary)' } }, '范围 1 – ' + limit),
h('button', { className: 'rt-btn rt-btn-primary', disabled: busy || invalid, onClick: save },
busy ? '保存中…' : '保存'),
msg && msg.err ? h('span', { style: { fontSize: 12, color: 'var(--rt-danger, #d33)' } }, msg.err) : null,
msg && msg.ok ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, msg.ok) : null),
h('div', { className: 'rt-note', style: { marginTop: 8 } },
'⚠️ 调大不等于更快:**同一个模型 API Key 的并发/速率上限是共享的**,同时派太多会互相挤占,'
+ '表现为排队等待、超时、返回被截断甚至限流报错 —— 反而让测试结果不稳定(漏测、半途而废)。'
+ '建议从 2–3 开始,只有在确认 Key 的额度足够、任务之间确实互不依赖时才调大。'))
}
/**
* 版本显示 + 自动更新按钮。
*
* 行为:挂载时静默查一次最新版本(npm registry),有新版本就把按钮点亮成
* 「有新版本 vX」;点击弹出确认框(当前版本 / 最新版本 / 安装位置 / 阻塞项),
* 确认后由 host 侧执行 安装 → 重启 dsh web(页面会断开,重启后刷新即可)。
*/
function VersionBar(props) {
const [info, setInfo] = React.useState(null)
const [err, setErr] = React.useState(null)
const [busy, setBusy] = React.useState(false)
const [open, setOpen] = React.useState(false)
const [msg, setMsg] = React.useState(null)
const [countdown, setCountdown] = React.useState(0)
const check = (silent) => {
if (!silent) setBusy(true)
api({ op: 'updateCheck' }).then((r) => {
setBusy(false)
if (!r || r.ok === false) {
/* 开发态或查不到 registry:只显示当前版本,不报错打扰用户 */
if (!silent) setErr((r && r.error) || '检查更新失败')
api({ op: 'version' }).then((v) => { if (v && v.ok !== false) setInfo(v) }, () => {})
return
}
setErr(null)
setInfo(r)
}, (e) => {
setBusy(false)
if (!silent) setErr(String((e && e.message) || e))
api({ op: 'version' }).then((v) => { if (v && v.ok !== false) setInfo(v) }, () => {})
})
}
React.useEffect(() => { check(true) }, [])
const apply = () => {
setBusy(true)
setMsg(null)
api({ op: 'updateApply', target: (info && info.latest) || undefined }).then((r) => {
setBusy(false)
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '更新失败' }); return }
setMsg({ ok: '已安装 ' + (r.installed || '') + ',正在重启当前宿主…' })
/* 重启会断开这个页面:倒计时提示用户稍后刷新 */
setCountdown(6)
const t = setInterval(() => {
setCountdown((n) => {
if (n <= 1) { clearInterval(t); window.location.reload(); return 0 }
return n - 1
})
}, 1000)
}, (e) => { setBusy(false); setMsg({ err: String((e && e.message) || e) }) })
}
const current = info && info.current ? info.current : (info && info.plugin ? info.plugin.version : null)
const latest = info && info.latest ? info.latest : null
const hasNew = !!(info && info.updateAvailable)
const blockers = (info && info.blockers) || []
const notes = (info && info.notes) || []
const mode = info && info.install ? info.install.mode : null
return h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 6 } },
h('span', {
className: 'rt-tag' + (hasNew ? ' rt-tag-warn' : ''),
title: ('当前版本 ' + (current || '未知') + (latest ? '\n最新版本 ' + latest : '')
+ (mode === 'dev' ? '\n安装方式:开发态(源码软链)' : mode === 'package' ? '\n安装方式:包安装' : '')
+ (info && info.check_error ? '\n检查失败:' + info.check_error : '')),
}, 'v' + (current || '?')),
hasNew
? h('button', {
className: 'rt-btn rt-btn-primary', disabled: busy,
onClick: () => { setOpen(true); setMsg(null) },
title: '发现新版本 ' + latest + ':点击查看并一键更新(会重启当前宿主)',
}, busy ? '检查中…' : '有新版本 v' + latest)
: h('button', {
className: 'rt-btn', disabled: busy, onClick: () => check(false),
title: '重新检查 npm 上的最新版本',
}, busy ? '检查中…' : '检查更新'),
open
? h('div', { className: 'rt-modal', 'aria-hidden': 'true', onClick: () => setOpen(false) },
h('div', { className: 'rt-modal-box', onClick: (e) => e.stopPropagation() },
h('h4', { style: { marginTop: 0 } }, '更新 RedTeam 模式'),
h('div', { className: 'rt-kv' }, h('b', null, '当前版本'), h('span', null, current || '未知')),
h('div', { className: 'rt-kv' }, h('b', null, '最新版本'), h('span', null, latest || '未知')),
h('div', { className: 'rt-kv' }, h('b', null, '安装方式'),
h('span', null, mode === 'dev' ? '开发态(源码软链)' : mode === 'package' ? '包安装(' + ((info.install && info.install.packageManager) || 'npm') + ')' : '未知')),
info && info.install && info.install.dir
? h('div', { className: 'rt-kv' }, h('b', null, '安装位置'), h('span', { className: 'rt-mono' }, info.install.dir))
: null,
blockers.length
? h('div', { className: 'rt-hint' },
h('div', { style: { fontWeight: 600, marginBottom: 4 } }, '现在不能更新:'),
blockers.map((b, i) => h('div', { key: 'b' + i }, '· ' + b)))
: null,
notes.length
? h('div', { className: 'rt-foot', style: { padding: '4px 0' } }, notes.map((n, i) => h('div', { key: 'n' + i }, n)))
: null,
h('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', margin: '8px 0' } },
'更新过程:安装新版本 → 自动重启当前宿主(官方 exe / 社区版 / dsh web;当前页面会断开,约 5–10 秒后自动刷新)。'
+ '重启前会检查有没有智能体在跑,有就拦住不动。'),
msg ? h('div', { className: msg.err ? 'rt-err' : 'rt-foot' }, msg.err || msg.ok) : null,
countdown > 0
? h('div', { className: 'rt-foot' }, '宿主正在重启,' + countdown + ' 秒后自动刷新页面…')
: null,
h('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 10 } },
h('button', { className: 'rt-btn', onClick: () => setOpen(false) }, '关闭'),
h('button', {
className: 'rt-btn rt-btn-primary',
disabled: busy || blockers.length > 0 || mode === 'dev',
title: mode === 'dev' ? '开发态安装请用仓库流程升级' : blockers.length ? '先解决上面的阻塞项' : '安装并重启',
onClick: apply,
}, busy ? '更新中…' : '一键更新并重启'))))
: null)
}
/* ---------------------------------------------------------- 常驻面板主体 */
/**
* 「未读」状态:按靶标记在 localStorage 里,每 25 秒问一次 host 的 consoleDigest
* (每个页签的条数 + 最近更新时间)。比本地记住的快照新 → 该页签点红点;
* 用户点开那个页签就把当前值记成已读,红点消失。
*/
const UNREAD_KEY = 'rt-unread:'
const loadUnread = (eng) => {
try {
const raw = window.localStorage.getItem(UNREAD_KEY + eng)
const parsed = raw ? JSON.parse(raw) : null
return parsed && typeof parsed === 'object' ? parsed : null
} catch (e) { return null }
}
const saveUnread = (eng, value) => {
try { window.localStorage.setItem(UNREAD_KEY + eng, JSON.stringify(value)) } catch (e) { /* 隐私模式等:不持久化也能用 */ }
}
/** 这个页签相对上次查看有没有新内容(条数变多,或最新一条比上次查看还新)。 */
const digestHasNew = (prev, cur) => {
if (!cur) return false
if (!prev) return true
const pc = Number(prev.count || 0)
const cc = Number(cur.count || 0)
if (cc > pc) return true
const pa = prev.at || ''
const ca = cur.at || ''
return ca !== '' && ca !== pa && ca > pa
}
const RT_GEOM_KEY = 'rt-dock-geom-v1'
function clampRtGeom(g) {
const vw = (typeof window !== 'undefined' && window.innerWidth) || 1280
const vh = (typeof window !== 'undefined' && window.innerHeight) || 800
const w = Math.min(Math.max(320, Number(g && g.w) || 620), Math.max(320, vw))
const h = Math.min(Math.max(240, Number(g && g.h) || 520), Math.max(240, vh))
const x = Math.min(Math.max(0, Number(g && g.x) || 0), Math.max(0, vw - w))
const y = Math.min(Math.max(0, Number(g && g.y) || 0), Math.max(0, vh - h))
return { x: x, y: y, w: w, h: h }
}
function defaultRtGeom() {
const vw = (typeof window !== 'undefined' && window.innerWidth) || 1280
const vh = (typeof window !== 'undefined' && window.innerHeight) || 800
let w = 620
try {
const saved = Number(window.localStorage.getItem('rt-dock-width'))
if (Number.isFinite(saved) && saved >= 320 && saved <= 1200) w = saved
} catch (e) { /* 隐私模式 */ }
const h = Math.max(240, Math.min(640, vh - 24))
return clampRtGeom({ x: Math.max(0, vw - w - 12), y: 12, w: w, h: h })
}
function loadRtGeom() {
try {
const raw = JSON.parse(window.localStorage.getItem(RT_GEOM_KEY) || 'null')
if (raw && typeof raw === 'object') return clampRtGeom(raw)
} catch (e) { /* 隐私模式或坏数据 */ }
return defaultRtGeom()
}
function Panel(props) {
/* embedded:嵌在 dsh-purge 外层 dock 里时,不再自带收起/全面浏览/拖拽条,避免与外层按钮重叠 */
const embedded = !!(props && props.embedded)
const st = useUI()
const [engagements, setEngagements] = React.useState([])
const [eng, setEng] = React.useState(null)
const [snapshot, setSnapshot] = React.useState(null)
const [err, setErr] = React.useState(null)
const [newName, setNewName] = React.useState('')
const [geom, setGeom] = React.useState(loadRtGeom)
const [creating, setCreating] = React.useState(false)
const [refreshKey, setRefreshKey] = React.useState(0)
const [digest, setDigest] = React.useState(null)
const [seen, setSeen] = React.useState(null)
React.useEffect(() => {
if (embedded) return undefined
try { window.localStorage.setItem(RT_GEOM_KEY, JSON.stringify(geom)) } catch (e) { /* 隐私模式 */ }
}, [geom, embedded])
React.useEffect(() => {
if (embedded) return undefined
const onResize = () => setGeom((g) => clampRtGeom(g))
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [embedded])
const loadSnapshot = (id) => {
if (!id) { setSnapshot(null); return }
api({ op: 'snapshot', engagement: id }).then((r) => {
if (!r || r.ok === false) { setErr((r && r.error) || '加载失败'); return }
setErr(null)
setSnapshot(r)
}, (e) => setErr(String((e && e.message) || e)))
}
const refreshList = (selectId) => {
api({ op: 'bootstrap' }).then((b) => {
setEngagements((b && b.engagements) || [])
if (selectId) { setEng(selectId); loadSnapshot(selectId) }
}, () => {})
}
React.useEffect(() => {
api({ op: 'bootstrap' }).then((r) => {
if (!r || r.ok === false) { setErr((r && r.error) || '无法连接资产库'); return }
setEngagements(r.engagements || [])
const id = r.current || (r.engagements && r.engagements[0] && r.engagements[0].id) || null
if (id) { setEng(id); loadSnapshot(id) }
}, (e) => setErr(String((e && e.message) || e)))
}, [])
const openEngagement = (nameArg) => {
const name = String(nameArg || newName).trim()
if (!name) return
setCreating(true)
api({ op: 'openEngagement', name: name }).then((r) => {
setCreating(false)
if (!r || r.ok === false) { setErr((r && r.error) || '创建失败'); return }
setNewName('')
refreshList(r.engagement && r.engagement.id)
}, (e) => { setCreating(false); setErr(String((e && e.message) || e)) })
}
/** 刷新:重新拉取名册/快照,并让当前页签重新取数。 */
const refreshAll = () => {
setRefreshKey((k) => k + 1)
api({ op: 'bootstrap' }).then((b) => {
setEngagements((b && b.engagements) || [])
if (eng) loadSnapshot(eng)
}, () => { if (eng) loadSnapshot(eng) })
}
const trackPointer = (e, move) => {
if (e.button !== 0) return
e.preventDefault()
e.stopPropagation()
const up = () => {
window.removeEventListener('mousemove', move)
window.removeEventListener('mouseup', up)
}
window.addEventListener('mousemove', move)
window.addEventListener('mouseup', up)
}
const startDrag = (e) => {
if (e.target && e.target.closest && e.target.closest('button,a,input,select,textarea,label')) return
const sx = e.clientX
const sy = e.clientY
const ox = geom.x
const oy = geom.y
trackPointer(e, (ev) => setGeom((g) => clampRtGeom({ x: ox + (ev.clientX - sx), y: oy + (ev.clientY - sy), w: g.w, h: g.h })))
}
const startResizeLeft = (e) => {
const sx = e.clientX
const ox = geom.x
const ow = geom.w
trackPointer(e, (ev) => {
const dx = ev.clientX - sx
setGeom((g) => clampRtGeom({ x: ox + dx, y: g.y, w: ow - dx, h: g.h }))
})
}
const startResizeRight = (e) => {
const sx = e.clientX
const ow = geom.w
trackPointer(e, (ev) => setGeom((g) => clampRtGeom({ x: g.x, y: g.y, w: ow + (ev.clientX - sx), h: g.h })))
}
const startResizeBottom = (e) => {
const sy = e.clientY
const oh = geom.h
trackPointer(e, (ev) => setGeom((g) => clampRtGeom({ x: g.x, y: g.y, w: g.w, h: oh + (ev.clientY - sy) })))
}
const startResizeCorner = (e) => {
const sx = e.clientX
const sy = e.clientY
const ow = geom.w
const oh = geom.h
trackPointer(e, (ev) => setGeom((g) => clampRtGeom({ x: g.x, y: g.y, w: ow + (ev.clientX - sx), h: oh + (ev.clientY - sy) })))
}
/* 切靶标:重新读该靶标的未读快照,并立刻取一次摘要 */
React.useEffect(() => {
if (!eng) { setDigest(null); setSeen(null); return undefined }
setSeen(loadUnread(eng))
let alive = true
let digestInflight = false
const tick = () => {
if (digestInflight) return /* 上一轮还没回来,跳过这一轮,别把请求堆起来 */
digestInflight = true
api({ op: 'consoleDigest', engagement: eng }).then((r) => {
if (alive && r && r.ok !== false && r.sections) setDigest(r.sections)
}, () => { /* 网络异常:不打断,等下一轮 */ })
.finally(() => { digestInflight = false })
}
tick()
const timer = window.setInterval(tick, 25000)
return () => { alive = false; window.clearInterval(timer) }
}, [eng, refreshKey])
/**
* 打开某个页签 = 看过这个页签的内容:把当前摘要记成已读。
* 没读过(第一次打开面板)不算新内容 —— 否则一进来满屏红点,反而看不出"哪里有新东西"。
*/
const markTabSeen = React.useCallback((tab) => {
if (!eng) return
setSeen((prev) => {
const base = prev || (() => {
const fresh = {}
for (const [k, v] of Object.entries((digest || {}))) fresh[k] = { count: Number(v.count || 0), at: v.at || null }
return fresh
})()
const next = Object.assign({}, base)
const cur = digest && digest[tab]
if (cur) next[tab] = { count: Number(cur.count || 0), at: cur.at || null }
saveUnread(eng, next)
return next
})
}, [eng, digest])
const unreadOf = (tab) => {
if (!eng || !digest) return false
if (!seen) return false
return digestHasNew(seen[tab], digest[tab])
}
const stats = (snapshot && snapshot.stats) || {}
const tabs = [
['assets', '资产测绘'], ['env', '环境适配'], ['agents', '智能体'], ['sessions', '会话隧道'],
['findings', '漏洞战果'],
['chain', '攻击链'], ['scores', '得分目标'], ['report', '报告'],
['knowledge', '知识库'],
['skills', '技能库'],
]
const full = isFullWindow()
const openFull = () => {
try { window.open(window.location.href.split('#')[0] + '#redteam-full', '_blank', 'noopener') } catch (e) { setErr('无法打开新窗口:' + ((e && e.message) || e)) }
}
const exitFull = () => {
/* 优先关掉脚本打开的窗口;关不掉就退回带侧栏的普通界面 */
try { window.close() } catch (e) { /* 非脚本打开的窗口无法关闭 */ }
try {
if (window.location.hash) {
window.location.hash = ''
window.location.reload()
}
} catch (e) { /* ignore */ }
}
React.useEffect(() => {
if (!full) return undefined
const onKey = (e) => { if (e.key === 'Escape') exitFull() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [full])
let body
if (err) body = h('div', { className: 'rt-err' }, err)
/* 全局页:没有靶标也能看(知识库 / 环境适配 / 技能库 / 智能体概览) */
else if (st.tab === 'knowledge') body = h(KnowledgeTab, { refreshKey: refreshKey })
else if (st.tab === 'env') body = h(EnvTab, { refreshKey: refreshKey })
else if (st.tab === 'skills') body = h(SkillsTab, { refreshKey: refreshKey })
else if (st.tab === 'agents' || st.tab === 'prompts') body = h(AgentsTab, { engagement: eng, refreshKey: refreshKey })
else if (!eng) {
body = h('div', { className: 'rt-pane' },
h('div', { className: 'rt-card' },
h('h4', null, '还没有靶标'),
h('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginBottom: 8 } },
'输入攻防演练靶标单位名称,创建演练并开始资产测绘。'),
h('div', { style: { display: 'flex', gap: 8 } },
h('input', {
className: 'rt-input', style: { flex: 1 }, placeholder: '例如:示例科技有限公司',
value: newName, onChange: (e) => setNewName(e.target.value),
onKeyDown: (e) => { if (e.key === 'Enter') openEngagement() },
}),
h('button', { className: 'rt-btn rt-btn-primary', disabled: creating, onClick: () => openEngagement() },
creating ? '创建中…' : '创建靶标'))))
} else if (st.tab === 'assets') body = h(AssetsTab, { engagement: eng, snapshot: snapshot, refreshKey: refreshKey, onRefresh: refreshAll, onData: () => loadSnapshot(eng) })
else if (st.tab === 'testing') body = h(AssetsTab, { engagement: eng, snapshot: snapshot, refreshKey: refreshKey, onRefresh: refreshAll, onData: () => loadSnapshot(eng) })
else if (st.tab === 'sessions') body = h(SessionTab, { engagement: eng, refreshKey: refreshKey })
else if (st.tab === 'findings') body = h(FindingsTab, { engagement: eng, refreshKey: refreshKey })
else if (st.tab === 'chain') body = h(ChainTab, { engagement: eng, refreshKey: refreshKey })
else if (st.tab === 'report') body = h(ReportTab, { engagement: eng, refreshKey: refreshKey })
else if (st.tab === 'attackfiles') body = h(FindingsTab, { engagement: eng, refreshKey: refreshKey })
else if (st.tab === 'scores') body = h(ScoreTab, { engagement: eng, refreshKey: refreshKey })
else body = h('div', { className: 'rt-empty' }, '未知页签')
const shellProps = full
? { className: 'rt-full', style: { display: 'flex' } }
: embedded
? { className: 'rt-dock rt-embedded', 'data-open': '1', style: { width: '100%', display: 'flex' } }
: {
className: 'rt-dock',
'data-open': st.open ? '1' : '0',
style: {
left: geom.x + 'px',
top: geom.y + 'px',
right: 'auto',
bottom: 'auto',
width: geom.w + 'px',
height: geom.h + 'px',
display: st.open ? 'flex' : 'none',
},
}
return h('div', shellProps,
(full || embedded) ? null : h('div', { className: 'rt-grip', title: '拖动调整宽度', onMouseDown: startResizeLeft }),
(full || embedded) ? null : h('div', { className: 'rt-resize-r', title: '拖动调整宽度', onMouseDown: startResizeRight }),
(full || embedded) ? null : h('div', { className: 'rt-resize-b', title: '拖动调整高度', onMouseDown: startResizeBottom }),
(full || embedded) ? null : h('div', { className: 'rt-resize-c', title: '拖动调整大小', onMouseDown: startResizeCorner }),
h('div', { className: 'rt-head', onMouseDown: (full || embedded) ? undefined : startDrag, title: (full || embedded) ? undefined : '按住拖动面板' },
embedded ? null : h('div', { className: 'rt-title' }, h('span', { className: 'rt-dot' }),
full ? '演练台 · 全面浏览' : '演练台',
full ? h('span', { className: 'rt-tag', style: { marginLeft: 6 } }, '独立窗口') : null),
h('select', {
className: 'rt-input rt-eng-select',
style: { maxWidth: embedded ? '100%' : '170px', minWidth: embedded ? '160px' : undefined, flex: embedded ? '1 1 200px' : undefined },
value: eng || '',
onChange: (e) => {
setEng(e.target.value)
loadSnapshot(e.target.value)
/* 同步「当前靶标」:原生 skill 目录按它解析技能根 */
api({ op: 'activateEngagement', engagement: e.target.value }).catch(() => {})
},
},
h('option', { value: '', disabled: true }, engagements.length ? '选择靶标' : '暂无靶标'),
engagements.map((x) => h('option', { key: x.id, value: x.id }, x.name || x.id || '未命名')),
),
h('div', { className: 'rt-spacer' }),
embedded ? null : h(VersionBar, null),
full ? h('button', { className: 'rt-btn', title: '回到带侧栏的普通界面(或按 Esc)', onClick: exitFull }, '退出全面浏览') : null,
embedded ? null : h('button', { className: 'rt-btn', title: '刷新名册、快照与当前页面数据', onClick: refreshAll }, '刷新'),
(full || embedded) ? null : h('button', { className: 'rt-btn', title: '在新浏览器窗口打开完整控制台', onClick: openFull }, '全面浏览'),
(full || embedded) ? null : h('button', { className: 'rt-btn', title: '收起面板', onClick: () => setUI({ open: false }) }, '收起')),
/* 页签栏用标准 tablist/tab 角色:读屏软件据此播报「第几个页签、是否选中」。
键盘用户 Tab 进来后可用 Enter/Space 切换(由 clickable 提供)。 */
h('div', { className: 'rt-tabs', role: 'tablist' }, tabs.map((t) => {
const activate = () => {
markTabSeen(t[0])
setUI({ tab: t[0] })
/* 强制当前页重新取数,避免切页后仍显示上一页缓存视觉 */
setRefreshKey((k) => k + 1)
}
return h('div', Object.assign({}, clickable(activate, { label: t[1] }), {
key: t[0], className: 'rt-tab' + (st.tab === t[0] ? ' on' : ''),
role: 'tab',
'aria-selected': st.tab === t[0] ? 'true' : 'false',
title: unreadOf(t[0]) ? t[1] + ':有新内容,点开看过红点就会消失' : t[1],
}), t[1], unreadOf(t[0])
/* 红点是纯视觉信息,给读屏软件一个文字替代 */
? h('span', { className: 'rt-tab-dot', 'aria-label': '有新内容' })
: null)
})),
h('div', { className: 'rt-body', key: 'body-' + st.tab }, h(RtBoundary, { key: st.tab }, body)),
h('div', { className: 'rt-foot' },
h('span', null, 'C 段 ' + (stats.segments || 0)),
h('span', null, '资产 ' + (stats.assets || 0) + '(存活 ' + (stats.liveAssets || 0) + ')'),
h('span', null, '端口 ' + (stats.openPorts || 0)),
h('span', null, '指纹 ' + (stats.fingerprints || 0)),
h('span', null, '漏洞 ' + (stats.vulns || 0)),
h('span', null, '被动/主动 ' + (stats.passiveSignals || 0) + '/' + (stats.activeSignals || 0)),
h('div', { className: 'rt-spacer' }),
full ? h('span', null, '按 Esc 或点右上角「退出全面浏览」回到带侧栏的界面') : null,
h('span', null, 'SQLite · ' + (snapshot && snapshot.engagement ? snapshot.engagement.name : ''))))
}
/* ---------------------------------------------------------- 入口按钮 */
function SidebarButton(props) {
const st = useUI()
return h('button', {
className: 'rt-icon-btn' + (st.open ? ' on' : ''),
title: 'RedTeam 控制台(常驻右侧栏)',
onClick: () => setUI({ open: !st.open }),
}, h('span', { style: { fontSize: 14 } }, '⛨'), props.wide ? h('span', null, 'RedTeam') : null)
}
function HeaderButton() {
const st = useUI()
return h('button', {
className: 'rt-hbtn' + (st.open ? ' on' : ''),
title: 'RedTeam 控制台(常驻右侧栏)',
onClick: () => setUI({ open: !st.open }),
}, '⛨ RedTeam')
}
/** 侧栏「全面浏览」:在新浏览器窗口打开完整控制台(当前窗口不受影响)。 */
function FullButton(props) {
const open = () => {
try { window.open(window.location.href.split('#')[0] + '#redteam-full', '_blank', 'noopener') } catch { /* 被浏览器拦截 */ }
}
return h('button', {
className: 'rt-icon-btn',
title: '全面浏览:在新窗口打开完整控制台(当前窗口不受影响;新窗口内按 Esc 退出)',
onClick: open,
}, h('span', { style: { fontSize: 14 } }, '⛶'), props.wide ? h('span', null, '全面浏览') : null)
}
/* ---------------------------------------------------------- 插件入口 */
/** 唯一硬依赖:槽位注册表。 */
const inject = ['slots']
/**
* 注入样式 + 三处槽位。样式标签与宽度变量随 fiber 卸载一起移除。
* @param ctx - 客户端根上下文。
*/
function apply(ctx) {
const styleTag = document.createElement('style')
styleTag.setAttribute('data-redteam-ui', '1')
styleTag.textContent = CSS
document.head.append(styleTag)
ctx.effect(() => () => {
styleTag.remove()
dockWidthTag = null
})
ctx.slots.inject('shell.overlay', () => ctx.slots.register(
{ name: 'shell.overlay', id: 'redteam-console', order: 50 },
() => h(Panel),
))
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register(
{ name: 'sidebar.footer.action', id: 'redteam-toggle', order: 50, label: 'RedTeam' },
(props) => h(SidebarButton, props),
))
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register(
{ name: 'sidebar.footer.action', id: 'redteam-full', order: 51, label: '全面浏览' },
(props) => h(FullButton, props),
))
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register(
{ name: 'conversation.session.header.utilities', id: 'redteam-header-toggle', order: 50, label: 'RedTeam' },
() => h(HeaderButton),
))
}
exports.apply = apply
exports.inject = inject
return module.exports
},
})