接入上游截图登记和六段报告,默认提示词按当前文稿重新加密。缺外部工具或密钥时技能仍可加载。 Co-authored-by: Cursor <cursoragent@cursor.com>
4219 lines
271 KiB
JavaScript
4219 lines
271 KiB
JavaScript
/**
|
||
* 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
|
||
},
|
||
})
|