/ | 只收录通用可复用的 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: 'var(--dsw-alias-state-error-primary,#ef4444)', borderColor: 'color-mix(in srgb,var(--dsw-alias-state-error-primary,var(--dsw-alias-state-error-primary,#ef4444)) 53%,transparent)' } }, '被封 ' + 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: 'var(--dsw-alias-state-success-primary,#22c55e)' } }, '已复制')
: 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 applyDrillParts() {
return { CSS: CSS, Panel: Panel, setUI: setUI, useUI: useUI, isFullWindow: isFullWindow, setDockWidth: setDockWidth };
}
exports.inject = inject
return applyDrillParts();
})();
/* __DSH_PURGE_DRILL_END__ */
const AUTH_KEY = "dsh-purge-drill-auth";
const WELCOME_KEY = "dsh-purge-user-notice-v1";
let drillGrantGen = 0;
let drillGrantPosting = 0;
let drillGrantState = { ready: false, granted: false };
let drillSyncRetryTimer = null;
const AUTH_LEGAL_HTML = "严正法律免责与合规使用声明
\r\n 本声明在进入演练台前必须全文阅读。本项目是非营利开源项目,遵守国家法律法规及所在平台的规范,仅供学习与研究、技术参考。严禁任何主体把本项目用于商业售卖、付费倒卖或黑灰产牟利。
\r\n 开发者坚决反对并严禁任何形式的违法犯罪,绝不支持、不鼓励、不协助未授权网络攻击、漏洞利用、数据窃取、非法侵入计算机信息系统,或生成违法违禁内容。任何将本项目用于违法犯罪的行为,均与开发者无关,由行为人依法独立承担全部法律责任。不得以科研、教学、测试、演示或内部学习为由规避下列条款。
\r\n 1. 本仓库不含违法内容
\r\n dsh-purge 发布的代码、文档、补丁与默认提示词不是木马、后门、未授权渗透工具、勒索软件、撞库脚本,也不是针对公网或第三方系统的攻击载荷。项目不提供违法内容,也不教唆、组织、协助实施违法犯罪。
\r\n 2. 本机操作不构成对外授权
\r\n 应用补丁、写入、回滚、卸载,都在本机文件和本机进程内完成。这些操作不对任何公网主机或未授权系统进行扫描、探测、入侵或攻击发包,也不得把本机当作跳板。
\r\n 插件若开启检测更新,仅可能访问本插件自己的 GitHub 仓库以核对版本。该访问与对第三方系统的网络攻击无关,不能被解释为对外渗透的授权。
\r\n 3. 允许使用的范围
\r\n 只允许用在你能证明有权处理的环境:本人有权管理的本机官方 Harness;离线的本地合成靶标;所有者已经出具合法书面授权的网络安全演练靶场;合规实验室里的受控环境。书面授权要能对应具体目标、时间和范围。只填写一个单位名称,或只有口头说法,不构成授权。
\r\n 未经所有者合法书面授权的目标、公网在线系统、生产业务,以及能源、交通、水利、金融、公共服务、电子政务等关键信息基础设施,都不得作为演练对象。
\r\n 4. 必须遵守的法律,按条、款
\r\n 《中华人民共和国刑法》第二百八十五条第一款:禁止违反国家规定,侵入国家事务、国防建设、尖端科学技术领域的计算机信息系统。
\r\n 同条第二款:禁止侵入前款规定以外的计算机信息系统,或者用其他技术手段,获取该系统中存储、处理或者传输的数据,或者对该系统实施非法控制。
\r\n 同条第三款:禁止提供专门用于侵入、非法控制计算机信息系统的程序、工具;明知他人实施侵入、非法控制而为其提供程序、工具,同样禁止。
\r\n 第二百八十六条第一款:禁止对计算机信息系统功能进行删除、修改、增加、干扰,造成系统不能正常运行。
\r\n 同条第二款:禁止对系统中存储、处理或者传输的数据和应用程序进行删除、修改、增加。
\r\n 同条第三款:禁止故意制作、传播计算机病毒等破坏性程序,影响计算机系统正常运行。
\r\n 第二百八十七条:禁止利用计算机实施金融诈骗、盗窃、贪污、挪用公款、窃取国家秘密或者其他犯罪。
\r\n 第二百八十七条之一第一款第(一)项:禁止设立用于实施诈骗、传授犯罪方法、制作或者销售违禁物品、管制物品等违法犯罪活动的网站、通讯群组。
\r\n 同款第(二)项:禁止发布有关制作或者销售毒品、枪支、淫秽物品等违禁物品、管制物品或者其他违法犯罪的信息。
\r\n 同款第(三)项:禁止为实施诈骗等违法犯罪活动发布信息。
\r\n 第二百八十七条之二:禁止明知他人利用信息网络实施犯罪,仍为其提供技术支持,或者提供广告推广、支付结算等帮助。
\r\n 第二百五十三条之一第一款、第三款:禁止向他人出售或者提供公民个人信息;禁止窃取或者以其他方法非法获取公民个人信息。
\r\n 《中华人民共和国网络安全法》第十三条第二款:使用网络不得危害网络安全,不得利用网络从事危害国家安全、荣誉和利益,煽动颠覆国家政权、推翻社会主义制度,煽动分裂国家、破坏国家统一,宣扬恐怖主义、极端主义,宣扬民族仇恨、民族歧视,传播暴力、淫秽色情信息,编造、传播虚假信息扰乱经济秩序和社会秩序,以及侵害他人名誉、隐私、知识产权和其他合法权益等活动。
\r\n 第十四条:禁止利用网络从事危害未成年人身心健康的活动。
\r\n 第二十九条:禁止非法侵入他人网络、干扰他人网络正常功能、窃取网络数据;禁止提供专门用于侵入网络、干扰网络正常功能及防护措施、窃取网络数据的程序、工具;明知他人从事危害网络安全的活动,禁止为其提供技术支持、广告推广、支付结算等帮助。
\r\n 第三十三条:公共通信和信息服务、能源、交通、水利、金融、公共服务、电子政务等关键信息基础设施实行重点保护。未获合法授权,不得侵入、干扰或破坏。
\r\n 第四十六条:禁止窃取或者以其他非法方式获取个人信息,禁止非法出售或者非法向他人提供个人信息。
\r\n 第四十八条:禁止设立用于实施诈骗,传授犯罪方法,制作或者销售违禁物品、管制物品等违法犯罪活动的网站、通讯群组;禁止利用网络发布涉及上述违法犯罪活动的信息。
\r\n 第五十条:发送的电子信息、提供的应用软件不得设置恶意程序,不得含有法律、行政法规禁止发布或者传输的信息。
\r\n 《中华人民共和国数据安全法》第八条:开展数据处理活动,不得危害国家安全、公共利益,不得损害个人、组织的合法权益。
\r\n 第三十二条第一款:收集数据应当采取合法、正当的方式,不得窃取或者以其他非法方式获取数据。
\r\n 同条第二款:法律、行政法规对收集、使用数据的目的和范围有规定的,只能在该目的和范围内收集、使用。
\r\n 第五十一条:窃取或者以其他非法方式获取数据,或者因此损害个人、组织合法权益的,依照有关法律、行政法规处罚。
\r\n 《中华人民共和国个人信息保护法》第五条:处理个人信息应当合法、正当、必要、诚信,不得通过误导、欺诈、胁迫等方式处理。
\r\n 第六条第一款、第二款:处理个人信息应当具有明确、合理的目的,并与该目的直接相关,采取对个人权益影响最小的方式;收集限于实现处理目的的最小范围,不得过度收集。
\r\n 第十条:禁止非法收集、使用、加工、传输他人个人信息,禁止非法买卖、提供或者公开他人个人信息;禁止从事危害国家安全、公共利益的个人信息处理活动。
\r\n 第十三条:没有取得个人同意,也不属于订立履行合同所必需、履行法定职责、应对突发、公共利益新闻舆论、处理本人已经合法公开的信息,以及法律行政法规规定的其他情形之一的,不得处理个人信息。
\r\n 第二十八条、第二十九条:生物识别、医疗健康、金融账户、行踪轨迹,以及不满十四周岁未成年人的个人信息,属于敏感个人信息。没有特定目的、充分必要性和严格保护措施,并且没有取得个人单独同意的,不得处理。
\r\n 此外还须遵守其他现行有效的法律、行政法规、监管规定,以及所使用模型的服务条款与滥用政策。
\r\n 5. 据此禁止的方向
\r\n 未授权进入。禁止把没有书面授权的单位、域名、地址、公网系统、生产业务或关键信息基础设施登记为靶标,禁止对其扫描、探测、侵入、控制或发包。对应刑法第二百八十五条第一款、第二款,网络安全法第二十九条、第三十三条。
\r\n 破坏与恶意程序。禁止删除、修改、增加、干扰他人系统功能或其中的数据、应用程序;禁止制作、传播病毒、勒索程序或其他破坏性程序;禁止在信息或软件中设置恶意程序。对应刑法第二百八十六条第一款至第三款,网络安全法第五十条。
\r\n 工具与帮助。禁止把本项目或本机提供给他人,用于侵入、非法控制或窃取数据;禁止明知对方在实施网络犯罪,仍提供程序、工具、技术支持、广告推广或支付结算。对应刑法第二百八十五条第三款、第二百八十七条之二,网络安全法第二十九条。
\r\n 数据。禁止窃取、非法收集、超范围使用、泄露、出售或向他人提供业务数据、账号、口令和其他受保护数据。对应数据安全法第八条、第三十二条、第五十一条。
\r\n 个人信息。禁止非法收集、使用、加工、传输、买卖、提供或公开他人个人信息;禁止过度收集;禁止以误导、欺诈、胁迫方式处理;禁止擅自处理生物识别、医疗健康、金融账户、行踪轨迹和儿童个人信息。对应刑法第二百五十三条之一第一款、第三款,网络安全法第四十六条,个人信息保护法第五条、第六条、第十条、第十三条、第二十八条、第二十九条。
\r\n 诈骗与违法信息。禁止设立或利用网站、群组实施诈骗、传授犯罪方法、制作或销售违禁物品、管制物品;禁止发布此类信息;禁止利用计算机实施诈骗、盗窃、贪污、挪用公款、窃取国家秘密。对应刑法第二百八十七条、第二百八十七条之一第一款第(一)项至第(三)项,网络安全法第四十八条。
\r\n 内容。禁止生成或传播危害国家安全、荣誉和利益的内容,禁止煽动颠覆、分裂,禁止恐怖主义、极端主义、民族仇恨,禁止暴力、淫秽色情、赌博,禁止编造虚假信息扰乱经济秩序和社会秩序,禁止侵害名誉、隐私、知识产权,禁止危害未成年人身心健康的内容。对应网络安全法第十三条第二款、第十四条。
\r\n 结果扩散。演练中形成的记录、资产信息和文件,只留在书面授权写明的目标和期限里,不得交给无权获知的人,也不得改作授权以外的用途。
\r\n 6. 责任由使用者承担
\r\n 本项目依据 MIT 协议按现状提供。开发者不就完整性、安全性与适用性作保证。使用者对自己的下载、部署、运行、修改、传播,以及全部输入与输出,承担独立、完全的民事、行政及刑事法律责任。作者与贡献者不承担因滥用产生的直接、间接或连带责任。
\r\n 勾选确认的是操作者本人,不能代替没有阅读本声明的人,也不能把别人的系统说成已经授权。
\r\n 7. 违约即终止授权
\r\n 一旦用于非法攻击、恶意活动或上述任一禁止方向,使用许可自该行为发生之日起自动终止,且不可撤销。必须立即停止使用,并销毁本项目的代码、脚本与衍生数据,依法承担责任。授权终止后,演练台不得继续打开或继续使用。
\r\n 8. 与 DeepSeek 官方的关系
\r\n 本项目是独立的开源项目,与 DeepSeek 官方或其关联主体没有隶属、商业合作、授权或官方背书。文中的「官方」只表示评测对象是使用者本机安装的官方 DeepSeek Harness 软件包,不代表 DeepSeek 官方开发、认可或担保本插件。
\r\n 文末。倒计时结束、滚到这里并勾选全部三项,才表示你以本人身份认可本声明,并完成本次演练台授权。
";
function readWelcomeAck() {
try { return window.localStorage.getItem(WELCOME_KEY) === "1"; } catch { return false; }
}
function writeWelcomeAck() {
try { window.localStorage.setItem(WELCOME_KEY, "1"); } catch { /* ignore */ }
}
function readDrillAuth() {
if (drillGrantState.ready) return drillGrantState.granted;
try { return window.localStorage.getItem(AUTH_KEY) === "1"; } catch { return false; }
}
function setDrillAuthLocal(ok) {
try {
if (ok) window.localStorage.setItem(AUTH_KEY, "1");
else window.localStorage.removeItem(AUTH_KEY);
} catch { /* ignore */ }
drillGrantState = { ready: true, granted: !!ok };
}
function writeDrillAuth(ok) {
drillGrantGen += 1;
const gen = drillGrantGen;
setDrillAuthLocal(ok);
if (!ok) return;
drillGrantPosting += 1;
fetch("/dsh-purge/drill-grant", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
}).then((r) => r.json()).then((saved) => {
if (gen !== drillGrantGen) return;
setDrillAuthLocal(!!(saved && saved.granted));
}).catch(() => { /* 页面先记住,下次同步再写入当前安装目录 */ })
.finally(() => { drillGrantPosting = Math.max(0, drillGrantPosting - 1); });
}
function syncDrillGrant(attempt) {
const n = attempt || 0;
const gen = drillGrantGen;
/* 复位上一次调度的重试 timer,避免多次 writeDrillAuth 叠加链条 */
if (drillSyncRetryTimer) { try { clearTimeout(drillSyncRetryTimer); } catch { /* ignore */ } drillSyncRetryTimer = null; }
fetch("/dsh-purge/drill-grant", { method: "GET" })
.then((r) => r.json())
.then((r) => {
if (gen !== drillGrantGen) return;
if (!r || r.ok === false) throw new Error("grant");
if (r.granted) {
setDrillAuthLocal(true);
if (dockBus.authOpen) setDock({ authOpen: false });
return;
}
if (drillGrantPosting > 0) return;
setDrillAuthLocal(false);
if (dockBus.authOpen || dockBus.tab === "drill") {
setDock({ open: true, tab: "drill", authOpen: true, pending: dockBus.pending || "tab" });
}
})
.catch(() => {
if (gen !== drillGrantGen) return;
if (n < 5) {
drillSyncRetryTimer = window.setTimeout(() => {
drillSyncRetryTimer = null;
syncDrillGrant(n + 1);
}, 400);
}
});
}
let dockBus = { open: false, tab: "clean", authOpen: false, pending: "tab", listeners: new Set() };
function getDock() { return dockBus; }
function setDock(patch) {
dockBus = Object.assign({}, dockBus, patch);
dockBus.listeners.forEach((fn) => { try { fn(dockBus); } catch { /* ignore */ } });
}
function useDock() {
const [st, setSt] = useState(dockBus);
useEffect(() => {
const fn = (next) => setSt(next);
dockBus.listeners.add(fn);
return () => { dockBus.listeners.delete(fn); };
}, []);
return st;
}
function requestDrill(kind) {
const pending = kind || "tab";
const openEnv = () => {
try {
if (__dshPurgeDrill && __dshPurgeDrill.setUI) {
__dshPurgeDrill.setUI({ open: true, tab: "env" });
}
} catch { /* ignore */ }
};
if (readDrillAuth()) {
setDock({ open: true, tab: "drill", authOpen: false, pending: "tab" });
try { if (__dshPurgeDrill && __dshPurgeDrill.setUI) __dshPurgeDrill.setUI({ open: true }); } catch { /* ignore */ }
if (pending === "env") openEnv();
if (pending === "full") {
try { window.open(window.location.href.split("#")[0] + "#redteam-full", "_blank", "noopener"); } catch { /* ignore */ }
}
return;
}
/* 未授权:直接切到演练台页签并弹出授权窗(不再停在清洗页,避免像「没触发」) */
setDock({ open: true, tab: "drill", authOpen: true, pending: pending });
}
function requestDrillEnv() {
requestDrill("env");
}
function openDockClean() {
setDock({ open: true, tab: "clean", authOpen: false, pending: "tab" });
}
/* 主入口:所有「打开 dsh-purge」的按钮都先跳到设置页(1.3.0 的正式界面)。
* 引擎没有暴露 settings 命令时退回浮窗,保证任何宿主版本上点了都有反应。 */
function openPurgePrimary() {
if (openSettingsSection()) return;
openDockClean();
}
/* 从浮窗跳到设置页。
* 官方设置外壳只对外暴露快捷键命令 settings.open(command.resolve 读 context.modal,
* 传 undefined 会被当成"未知 modal"直接 return,所以必须显式传 modal: null)。
* 拿不到快捷键服务时退到 DOM:点侧栏那个 aria-haspopup="dialog" 的设置按钮。
* 面板打开后落到哪一段由外壳决定,所以再按 label 点中我们那一行。 */
function findSettingsNavRow() {
if (typeof document === "undefined") return null;
const label = String(translate("page.nav") || "").trim();
if (!label) return null;
for (const row of document.querySelectorAll("[role=\"dialog\"] nav button")) {
if (String(row.textContent || "").trim() === label) return row;
}
return null;
}
function clickSettingsTrigger() {
if (typeof document === "undefined") return false;
const trigger = document.querySelector('button[aria-haspopup="dialog"][aria-expanded="false"]')
|| document.querySelector('button[aria-haspopup="dialog"]');
if (!trigger) return false;
try { trigger.click(); return true; } catch { return false; }
}
function openSettingsSection(attempt = 0) {
const existing = findSettingsNavRow();
if (existing) {
try { existing.click(); } catch { /* ignore */ }
setDock({ open: false, authOpen: false, pending: "tab" });
return true;
}
if (attempt === 0) {
let opened = false;
try {
const shortcuts = (purgeHost && (purgeHost.shortcuts || (typeof purgeHost.get === "function" ? purgeHost.get("shortcuts") : null))) || null;
if (shortcuts && typeof shortcuts.invoke === "function") {
shortcuts.invoke("settings.open", { modal: null });
opened = true;
}
} catch { /* ignore */ }
if (!opened) opened = clickSettingsTrigger();
if (!opened) return false;
}
// 面板是异步挂载的:最多等 ~600ms,找到就点。
if (attempt < 12) {
window.setTimeout(() => openSettingsSection(attempt + 1), 50);
}
return true;
}
function toggleDock() {
if (dockBus.open) setDock({ open: false, authOpen: false, pending: "tab" });
else openDockClean();
}
syncDrillGrant();
const DOCK_GEOM_KEY = "dsh-purge-dock-geom-v4";
/* 默认停在右上,底边留在输入框上面。拖动之后停在用户松开的位置,只保证不拖出窗口。 */
const DOCK_COMPOSER_CLEAR = 172;
function defaultDockGeom() {
const vw = typeof window !== "undefined" ? window.innerWidth : 1280;
const vh = typeof window !== "undefined" ? window.innerHeight : 800;
const w = Math.min(620, Math.max(380, vw - 16));
const y = 48;
const h = Math.max(240, Math.min(640, vh - y - DOCK_COMPOSER_CLEAR));
return { x: Math.max(8, vw - w - 8), y: y, w: w, h: h };
}
function clampDockGeom(g) {
const vw = typeof window !== "undefined" ? window.innerWidth : 1280;
const vh = typeof window !== "undefined" ? window.innerHeight : 800;
const w = Math.min(Math.max(380, Number(g && g.w) || 620), Math.max(380, vw - 16));
const h = Math.min(Math.max(240, Number(g && g.h) || 520), Math.max(240, vh - 16));
const x = Math.min(Math.max(8, Number(g && g.x) || 8), Math.max(8, vw - w - 8));
const y = Math.min(Math.max(8, Number(g && g.y) || 8), Math.max(8, vh - Math.min(h, vh - 16) - 8));
return { x: x, y: y, w: w, h: h };
}
function loadDockGeom() {
try {
const raw = JSON.parse(window.localStorage.getItem(DOCK_GEOM_KEY) || "null");
if (raw && typeof raw === "object") return clampDockGeom(raw);
} catch { /* ignore */ }
return clampDockGeom(defaultDockGeom());
}
function saveDockGeom(g) {
try { window.localStorage.setItem(DOCK_GEOM_KEY, JSON.stringify(clampDockGeom(g))); } catch { /* ignore */ }
}
function WelcomeNotice() {
const t = useT();
const [open, setOpen] = useState(() => !readWelcomeAck());
const [checked, setChecked] = useState(false);
if (!open) return null;
const repo = t("welcome.repo");
const node = h("div", { className: "dshp-auth-mask", role: "dialog", "aria-modal": "true" },
h("div", { className: "dshp-auth-modal", style: { width: "min(560px,100%)" } },
h("h2", null, t("welcome.title")),
h("div", { className: "muted", style: { color: "var(--dsw-alias-label-secondary,#999)", fontSize: 12 } }, t("welcome.lead")),
h("div", { className: "dshp-auth-warn" }, t("welcome.warn")),
h("div", { className: "dshp-auth-legal", style: { height: "auto", maxHeight: 280 } },
h("p", null, t("welcome.body")),
h("p", null,
h("a", {
href: repo,
target: "_blank",
rel: "noopener noreferrer",
style: { color: "var(--dsw-alias-label-brand, #8fbf9a)", wordBreak: "break-all" },
}, repo),
),
h("h3", null, t("welcome.bans")),
h("p", null, t("welcome.ban1")),
h("p", null, t("welcome.ban2")),
h("p", null, t("welcome.ban3")),
),
h("div", { className: "dshp-auth-checks" },
h("label", null,
h("input", {
type: "checkbox",
checked: checked,
onChange: (e) => setChecked(!!(e && e.target && e.target.checked)),
}),
h("span", null, t("welcome.check")),
),
),
h("div", { className: "dshp-auth-ops" },
h("button", {
type: "button",
className: "primary",
disabled: !checked,
onClick: () => {
if (!checked) return;
writeWelcomeAck();
setOpen(false);
},
}, t("welcome.ok")),
),
),
);
try {
const rd = require("react-dom");
if (rd && typeof rd.createPortal === "function" && typeof document !== "undefined" && document.body) {
return rd.createPortal(node, document.body);
}
} catch { /* ignore */ }
return node;
}
function AuthGate(props) {
const t = useT();
const [left, setLeft] = useState(10);
const [readEnd, setReadEnd] = useState(false);
const [checks, setChecks] = useState({ read: false, scope: false, ban: false });
const legalRef = useRef(null);
useEffect(() => {
const id = window.setInterval(() => {
setLeft((n) => {
if (n <= 1) { window.clearInterval(id); return 0; }
return n - 1;
});
}, 1000);
return () => window.clearInterval(id);
}, []);
useEffect(() => {
const el = legalRef.current;
if (!el) return;
if (el.scrollHeight <= el.clientHeight + 4) setReadEnd(true);
}, []);
const locked = left > 0 || !readEnd;
const ready = !locked && checks.read && checks.scope && checks.ban;
const hint = left > 0
? t("auth.hint.wait").replace("{n}", String(left))
: (readEnd ? t("auth.hint.check") : t("auth.hint.scroll"));
const onScroll = () => {
const el = legalRef.current;
if (!el) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 8) setReadEnd(true);
};
const toggle = (id) => {
if (locked) return;
setChecks((prev) => Object.assign({}, prev, { [id]: !prev[id] }));
};
const okLabel = !ready
? t("auth.ok")
: (props.pending === "full"
? t("auth.ok.full")
: (props.pending === "env" ? t("auth.ok.env") : t("auth.ok.tab")));
return h("div", { className: "dshp-auth-mask", role: "dialog", "aria-modal": "true" },
h("div", { className: "dshp-auth-modal" },
h("h2", null, t("auth.title")),
h("div", { className: "muted", style: { color: "var(--dsw-alias-label-secondary,#999)", fontSize: 12 } }, t("auth.lead")),
h("div", { className: "dshp-auth-warn" }, t("auth.warn")),
h("div", {
className: "dshp-auth-legal",
ref: legalRef,
onScroll,
dangerouslySetInnerHTML: { __html: AUTH_LEGAL_HTML },
}),
h("div", { style: { color: "var(--dsw-alias-label-secondary,#999)", fontSize: 12 } }, hint),
h("div", { className: "dshp-auth-checks" },
[["read", "auth.check.read"], ["scope", "auth.check.scope"], ["ban", "auth.check.ban"]].map((row) =>
h("label", { key: row[0], className: locked ? "locked" : "" },
h("input", {
type: "checkbox",
checked: !!checks[row[0]],
disabled: locked,
onChange: () => toggle(row[0]),
}),
h("span", null, t(row[1])),
),
),
),
h("div", { className: "dshp-auth-ops" },
h("button", { type: "button", onClick: () => setDock({ authOpen: false, tab: "clean", pending: "tab" }) }, t("auth.no")),
h("button", {
type: "button",
className: "primary",
disabled: !ready,
onClick: () => {
if (!ready) return;
writeDrillAuth(true);
const kind = props.pending || "tab";
setDock({ authOpen: false, open: true, tab: "drill", pending: "tab" });
try { if (__dshPurgeDrill && __dshPurgeDrill.setUI) __dshPurgeDrill.setUI({ open: true }); } catch { /* ignore */ }
if (kind === "env") {
try { if (__dshPurgeDrill && __dshPurgeDrill.setUI) __dshPurgeDrill.setUI({ open: true, tab: "env" }); } catch { /* ignore */ }
}
if (kind === "full") {
try { window.open(window.location.href.split("#")[0] + "#redteam-full", "_blank", "noopener"); } catch { /* ignore */ }
}
},
}, okLabel),
),
),
);
}
function isRedteamPresetId(value) {
const text = String(value || "").trim();
if (!text) return false;
if (text === "redteam") return true;
if (/^red[\s_-]*team$/i.test(text)) return true;
const label = text.replace(/不是红队/g, "").replace(/非红队/g, "");
if (/红队/.test(label)) return true;
return false;
}
function readSessionAgentPreset(sess) {
if (!sess || typeof sess !== "object") return "";
try {
const pv = sess.projectionValues;
if (pv && typeof pv.agentPreset === "string") return pv.agentPreset;
} catch { /* ignore */ }
try {
if (typeof sess.agentPreset === "string") return sess.agentPreset;
} catch { /* ignore */ }
try {
const header = sess.header;
if (header && typeof header.agentPreset === "string") return header.agentPreset;
} catch { /* ignore */ }
return "";
}
function readSessionCwd(sess) {
if (!sess || typeof sess !== "object") return "";
try {
const pv = sess.projectionValues;
if (pv && typeof pv.cwd === "string" && pv.cwd.trim()) return pv.cwd.trim();
} catch { /* ignore */ }
try {
if (typeof sess.cwd === "string" && sess.cwd.trim()) return sess.cwd.trim();
} catch { /* ignore */ }
try {
const header = sess.header;
if (header && typeof header.cwd === "string" && header.cwd.trim()) return header.cwd.trim();
} catch { /* ignore */ }
return "";
}
function clientCurrentCwd() {
try {
const sessions = rewindSessions || (rewindHost && rewindHost.sessions);
if (!sessions) return "";
const sid = currentSessionId(sessions);
if (!sid) return "";
try {
const snap = typeof sessions.list?.getSnapshot === "function" ? sessions.list.getSnapshot() : null;
const sess = snap && snap.byId ? snap.byId[sid] : null;
const found = readSessionCwd(sess);
if (found) return found;
} catch { /* ignore */ }
try {
const actx = typeof sessions.scope === "function" ? sessions.scope(sid) : undefined;
const face = typeof sessions.sessionOf === "function" ? sessions.sessionOf(actx) : undefined;
const faceSnap = face && typeof face.getSnapshot === "function" ? face.getSnapshot() : face;
const found = readSessionCwd(faceSnap);
if (found) return found;
} catch { /* ignore */ }
} catch { /* ignore */ }
return "";
}
function isRedteamModeActive() {
try {
const sessions = rewindSessions || (rewindHost && rewindHost.sessions);
const sid = currentSessionId(sessions);
if (sid && sessions) {
try {
const snap = typeof sessions.list?.getSnapshot === "function" ? sessions.list.getSnapshot() : null;
const sess = snap && snap.byId ? snap.byId[sid] : null;
if (isRedteamPresetId(readSessionAgentPreset(sess))) return true;
} catch { /* ignore */ }
try {
const actx = typeof sessions.scope === "function" ? sessions.scope(sid) : undefined;
const face = typeof sessions.sessionOf === "function" ? sessions.sessionOf(actx) : undefined;
const faceSnap = face && typeof face.getSnapshot === "function" ? face.getSnapshot() : face;
if (isRedteamPresetId(readSessionAgentPreset(faceSnap))) return true;
} catch { /* ignore */ }
}
} catch { /* ignore */ }
/* 新会话:模式下拉里的「红队模式」chip(选好但还没写入 session)
* 优先走语义化 data-* 挂点,其次 class 子串模糊匹配 + 文本兜底;
* 不写死 CSS Module 哈希(HKgFRW_seat 这种每次构建都会变)。 */
try {
const semantic = document.querySelector(
"[data-agent-preset='redteam'], [data-preset-id='redteam'], [data-mode='redteam']"
);
if (semantic) return true;
const nodes = document.querySelectorAll(
"[class*='seat' i][class*='label' i], [class*='seatLabel' i], button[class*='seat' i], [role='button'][class*='seat' i]"
);
for (const el of nodes) {
const text = String(el.textContent || "").replace(/\s+/g, " ").trim();
if (/红队模式/.test(text) || /^RedTeam\b/i.test(text)) return true;
}
} catch { /* ignore */ }
try {
const labels = document.querySelectorAll(
"[class*='label' i][class*='preset' i], [class*='presetLabel' i], [title*='红队'], [title*='RedTeam']"
);
for (const el of labels) {
const text = String(el.textContent || "").trim();
if (/红队模式/.test(text) || /^RedTeam\b/i.test(text)) return true;
if (isRedteamPresetId(text)) return true;
}
} catch { /* ignore */ }
return false;
}
function looksLikeSendControl(el) {
if (!el || el.nodeType !== 1) return false;
const btn = el.closest("button, [role='button']");
if (!btn) return false;
if (btn.closest(".dshp-dock, .dshp-auth-mask, .rt-dock, .dshp-env-gate")) return false;
const label = [
btn.getAttribute("aria-label"),
btn.getAttribute("title"),
btn.getAttribute("data-testid"),
btn.textContent,
].filter(Boolean).join(" ");
if (/停止|Stop/i.test(label) && !/发送|Send/i.test(label)) return false;
if (/发送|Submit|Send(?!\s*feedback)/i.test(label)) return true;
/* composer 主按钮:data-composer-card 内的 primary */
try {
const card = btn.closest("[data-composer-card], [data-composer-seat]");
if (card) {
const cls = String(btn.className || "");
if (/primary/i.test(cls) && btn.querySelector("svg")) return true;
}
} catch { /* ignore */ }
if (btn.type === "submit") {
const form = btn.closest("form");
if (form && form.querySelector("textarea, [contenteditable='true']")) return true;
}
return false;
}
function fetchEnvAdaptStatus() {
return fetch("/redteam/api", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ op: "platformEnvAdaptStatus" }),
}).then((r) => r.json()).catch(() => null);
}
/** 同步探测的占位:不再做同步 XHR(会卡 UI 主线程、Chrome 已逐步弃用)。
* 调用方 mustBlockSubmit 在 cache 过期/缺失时按"未知→保守 block"处理,
* 同时 EnvAdaptSendGate 的 useEffect 轮询会异步 refresh,下次调用即可放行。 */
function fetchEnvAdaptStatusSync() { return null; }
/** 红队发送前门禁:未就绪 → 授权并打开环境适配;可确认跳过。 */
function EnvAdaptSendGate() {
const t = useT();
const [open, setOpen] = useState(false);
const [confirmSkip, setConfirmSkip] = useState(false);
const [status, setStatus] = useState(null);
const [busy, setBusy] = useState(false);
const cacheRef = useRef({ at: 0, ready: false });
const openRef = useRef(false);
openRef.current = open;
const refresh = useCallback(async () => {
const r = await fetchEnvAdaptStatus();
const ready = !!(r && r.ready);
cacheRef.current = { at: Date.now(), ready: ready, skipped: !!(r && r.skipped) };
setStatus(r);
return r;
}, []);
const blockAndOpen = useCallback(() => {
setConfirmSkip(false);
refresh();
/* 第一次:只走完整授权窗 → 授权后进环境适配页 */
if (!readDrillAuth()) {
setOpen(false);
requestDrillEnv();
return true;
}
/* 已授权过:只弹精简环境适配窗,不再自动拉开整块演练台 */
setOpen(true);
return true;
}, [refresh]);
const mustBlockSubmit = useCallback(() => {
if (!isRedteamModeActive()) return false;
const cached = cacheRef.current;
/* cache 过期时触发异步刷新;本次调用按最后一次 cache 判断,未知保守 block */
if (!cached.at || Date.now() - cached.at > 2500) {
refresh().catch(() => {});
}
if (cached.ready) return false;
blockAndOpen();
return true;
}, [blockAndOpen, refresh]);
/* 挂住宿主 SessionInputShell.submit —— 比点按钮启发式可靠。
* patch 必须可逆:存 origSubmit 到 WeakMap,unmount / 依赖变更时恢复,
* 否则插件禁用后 shell.submit 还是我们的 wrapper,持死 mustBlockSubmit 闭包。 */
useEffect(() => {
const patched = new WeakMap();
const patchedShells = [];
const patchShell = (shell) => {
if (!shell || patched.has(shell)) return;
if (typeof shell.submit !== "function") return;
const origSubmit = shell.submit;
const origActionsSubmit = (shell.actions && typeof shell.actions.submit === "function")
? shell.actions.submit
: null;
patched.set(shell, { origSubmit, origActionsSubmit });
patchedShells.push(shell);
const bound = origSubmit.bind(shell);
shell.submit = function gatedSubmit(mode) {
try {
if (mustBlockSubmit()) return;
} catch { /* 门禁异常时不吞掉发送 */ }
return bound(mode);
};
if (shell.actions && typeof shell.actions === "object") {
shell.actions.submit = () => shell.submit("queue");
}
};
const tick = () => {
try {
const sessions = rewindSessions || (rewindHost && rewindHost.sessions);
const sid = currentSessionId(sessions);
if (!sid) return;
patchShell(conversationInput(sid));
} catch { /* ignore */ }
};
tick();
const iv = window.setInterval(tick, 400);
return () => {
window.clearInterval(iv);
for (const shell of patchedShells) {
const info = patched.get(shell);
if (!info) continue;
try { shell.submit = info.origSubmit; } catch { /* ignore */ }
if (info.origActionsSubmit && shell.actions) {
try { shell.actions.submit = info.origActionsSubmit; } catch { /* ignore */ }
}
}
patchedShells.length = 0;
};
}, [mustBlockSubmit]);
useEffect(() => {
let alive = true;
const tick = () => {
if (!alive) return;
if (!isRedteamModeActive()) return;
refresh();
};
tick();
const iv = window.setInterval(tick, 5000);
return () => { alive = false; window.clearInterval(iv); };
}, [refresh]);
useEffect(() => {
const onPointer = (e) => {
if (!looksLikeSendControl(e.target)) return;
if (!mustBlockSubmit()) return;
e.preventDefault();
e.stopPropagation();
if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
};
const onKey = (e) => {
if (e.key !== "Enter" || e.shiftKey || e.isComposing) return;
const inComposer = e.target && e.target.closest
&& e.target.closest("[data-composer-card], [data-composer-seat], [contenteditable='true'], textarea");
if (!inComposer) return;
if (e.target.closest && e.target.closest(".dshp-dock, .dshp-auth-mask, .rt-dock, .dshp-env-gate")) return;
if (!mustBlockSubmit()) return;
e.preventDefault();
e.stopPropagation();
if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
};
document.addEventListener("pointerdown", onPointer, true);
document.addEventListener("click", onPointer, true);
document.addEventListener("keydown", onKey, true);
return () => {
document.removeEventListener("pointerdown", onPointer, true);
document.removeEventListener("click", onPointer, true);
document.removeEventListener("keydown", onKey, true);
};
}, [mustBlockSubmit]);
if (!open) return null;
const message = (status && status.message) || t("envGate.lead");
return h("div", { className: "dshp-auth-mask dshp-env-gate", role: "dialog", "aria-modal": "true" },
h("div", { className: "dshp-auth-modal", style: { width: "min(480px,100%)" } },
h("h2", null, t("envGate.title")),
h("div", { style: { fontSize: 13, lineHeight: 1.55 } }, message),
h("div", { style: { fontSize: 12, color: "var(--dsw-alias-label-secondary,#999)", lineHeight: 1.5 } }, t("envGate.kaliHint")),
confirmSkip
? h("div", { className: "dshp-auth-warn" }, t("envGate.skipConfirm"))
: null,
h("div", { className: "dshp-auth-ops" },
confirmSkip
? [
h("button", {
key: "no",
type: "button",
onClick: () => setConfirmSkip(false),
}, t("envGate.skipNo")),
h("button", {
key: "yes",
type: "button",
className: "primary",
disabled: busy,
onClick: () => {
setBusy(true);
fetch("/redteam/api", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ op: "platformEnvAdaptSkip", skip: true }),
}).then((r) => r.json()).then((r) => {
setBusy(false);
cacheRef.current = { at: Date.now(), ready: true, skipped: true };
setStatus(r);
setOpen(false);
setConfirmSkip(false);
}).catch(() => {
setBusy(false);
cacheRef.current = { at: Date.now(), ready: true, skipped: true };
setOpen(false);
});
},
}, t("envGate.skipYes")),
]
: [
h("button", {
key: "skip",
type: "button",
onClick: () => setConfirmSkip(true),
}, t("envGate.skip")),
h("button", {
key: "go",
type: "button",
className: "primary",
onClick: () => {
setOpen(false);
requestDrillEnv();
},
}, t("envGate.go")),
],
),
),
);
}
function PurgeDock() {
const t = useT();
const st = useDock();
const [geom, setGeom] = useState(() => loadDockGeom());
const [dockTheme, setDockTheme] = useState(detectHostTheme);
useEffect(() => {
const sync = () => setDockTheme(detectHostTheme());
sync();
const obs = new MutationObserver(sync);
if (document.body) obs.observe(document.body, { attributes: true, attributeFilter: ["data-ds-dark-theme", "class", "style"] });
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["data-ds-dark-theme", "class", "style"] });
const timer = window.setInterval(sync, 1500);
return () => { obs.disconnect(); window.clearInterval(timer); };
}, []);
useEffect(() => {
let uiTag = document.querySelector('style[data-dsh-purge-ui="1"]');
if (!uiTag) {
uiTag = document.createElement("style");
uiTag.setAttribute("data-dsh-purge-ui", "1");
uiTag.textContent = PURGE_CSS;
document.head.append(uiTag);
}
if (!__dshPurgeDrill || !__dshPurgeDrill.CSS) return undefined;
const styleTag = document.createElement("style");
styleTag.setAttribute("data-dsh-purge-drill", "1");
styleTag.textContent = __dshPurgeDrill.CSS;
document.head.append(styleTag);
const widthTag = document.createElement("style");
widthTag.setAttribute("data-dsh-purge-dock-w", "1");
widthTag.textContent = ":root{--dshp-dock-w:" + geom.w + "px;--dshp-dock-h:" + geom.h + "px}";
document.head.append(widthTag);
return () => { styleTag.remove(); widthTag.remove(); };
}, []);
useEffect(() => {
saveDockGeom(geom);
const tag = document.querySelector('style[data-dsh-purge-dock-w="1"]');
if (tag) tag.textContent = ":root{--dshp-dock-w:" + geom.w + "px;--dshp-dock-h:" + geom.h + "px}";
}, [geom]);
useEffect(() => {
const onResize = () => setGeom((g) => clampDockGeom(g));
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
useEffect(() => {
if (st.tab === "drill" && readDrillAuth() && __dshPurgeDrill && __dshPurgeDrill.setUI) {
try { __dshPurgeDrill.setUI({ open: true }); } catch { /* ignore */ }
}
}, [st.tab, st.open]);
const onTab = (id) => {
if (id === "drill") { requestDrill("tab"); return; }
setDock({ tab: "clean", authOpen: false, pending: "tab" });
};
const startDrag = (e) => {
if (e.button !== 0) return;
if (e.target && e.target.closest && e.target.closest("button,a,input,select,textarea,label")) return;
e.preventDefault();
const sx = e.clientX;
const sy = e.clientY;
const ox = geom.x;
const oy = geom.y;
const move = (ev) => setGeom((g) => clampDockGeom({ x: ox + (ev.clientX - sx), y: oy + (ev.clientY - sy), w: g.w, h: g.h }));
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
};
const startResize = (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const sx = e.clientX;
const sy = e.clientY;
const ow = geom.w;
const oh = geom.h;
const move = (ev) => setGeom((g) => clampDockGeom({ x: g.x, y: g.y, w: ow + (ev.clientX - sx), h: oh + (ev.clientY - sy) }));
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
};
const startResizeLeft = (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const sx = e.clientX;
const ox = geom.x;
const ow = geom.w;
const move = (ev) => {
const dx = ev.clientX - sx;
setGeom((g) => clampDockGeom({ x: ox + dx, y: g.y, w: ow - dx, h: g.h }));
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
};
const startResizeRight = (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const sx = e.clientX;
const ow = geom.w;
const move = (ev) => setGeom((g) => clampDockGeom({ x: g.x, y: g.y, w: ow + (ev.clientX - sx), h: g.h }));
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
};
const startResizeBottom = (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const sy = e.clientY;
const oh = geom.h;
const move = (ev) => setGeom((g) => clampDockGeom({ x: g.x, y: g.y, w: g.w, h: oh + (ev.clientY - sy) }));
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
};
const body = st.tab === "clean"
? h(SettingsRoot, { t })
: (readDrillAuth() && __dshPurgeDrill.Panel
? h(__dshPurgeDrill.Panel, { embedded: true })
: h("div", { style: { padding: 16, color: "var(--dsw-alias-label-secondary)" } }, t("dock.unauthorized")));
const panel = h("div", {
className: "dshp-dock",
"data-theme": dockTheme === "white" ? "white" : "dusk",
"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",
},
},
h("div", { className: "dshp-dock-head", onMouseDown: startDrag, title: "按住拖动面板" },
h("b", null, "dsh-purge"),
h("span", { style: { flex: 1 } }),
st.tab === "drill" && readDrillAuth()
? h("button", { type: "button", onClick: () => requestDrill("full") }, t("dock.full"))
: null,
h("button", { type: "button", title: t("dock.openSettings"), onClick: () => openSettingsSection() }, t("dock.openSettings")),
h("button", { type: "button", onClick: () => setDock({ open: false, authOpen: false, tab: "clean", pending: "tab" }) }, t("dock.close")),
),
h("div", { className: "dshp-dock-tabs", role: "tablist" },
h("button", {
type: "button",
className: "dshp-dock-tab" + (st.tab === "clean" ? " on" : ""),
role: "tab",
"aria-selected": st.tab === "clean" ? "true" : "false",
onClick: () => onTab("clean"),
}, t("dock.clean")),
h("button", {
type: "button",
className: "dshp-dock-tab" + (st.tab === "drill" ? " on" : ""),
role: "tab",
"aria-selected": st.tab === "drill" ? "true" : "false",
onClick: () => onTab("drill"),
}, t("dock.drill") + (readDrillAuth() ? "" : " · " + t("dock.unauthorized"))),
),
h("div", { className: "dshp-dock-body" }, body),
h("div", { className: "dshp-dock-resize-l", title: "拖动调整宽度", onMouseDown: startResizeLeft }),
h("div", { className: "dshp-dock-resize-r", title: "拖动调整宽度", onMouseDown: startResizeRight }),
h("div", { className: "dshp-dock-resize-b", title: "拖动调整高度", onMouseDown: startResizeBottom }),
h("div", { className: "dshp-dock-resize", title: "拖动调整大小", onMouseDown: startResize }),
);
let portal = null;
let authPortal = null;
const authNode = st.authOpen ? h(AuthGate, { pending: st.pending }) : null;
try {
const rd = require("react-dom");
if (rd && typeof rd.createPortal === "function" && typeof document !== "undefined" && document.body) {
portal = rd.createPortal(panel, document.body);
if (authNode) authPortal = rd.createPortal(authNode, document.body);
}
} catch { /* host 可能没暴露 react-dom */ }
return h(react.Fragment, null,
portal || panel,
authPortal || authNode,
);
}
/** 首页:模式旁挂「dsh-purge」→ 打开插件。红队模式走模式下拉(registry 声明)。 */
/** 挂在「对话 / 轨迹 / 上下文」这一排,紧挨上下文。 */
function installRewindUi(ctx) {
rewindHost = ctx;
rewindSessions = ctx.sessions;
rewindWorkspace = ctx.uiWorkspace || null;
rewindConversation = ctx.conversation || null;
ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
name: "conversation.input.right",
id: "dsh-purge-rewind",
order: 20,
inject: (sessionId) => ({
sessionId,
t: ctx.locale.bind(NS),
}),
}, RewindSafe));
// ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
// name: "conversation.input.right",
// id: "dsh-purge-continue",
// order: 21,
// inject: (sessionId) => ({
// sessionId,
// t: ctx.locale.bind(NS),
// }),
// }, ContinueSafe));
ctx.effect(() => installRewindWatch(ctx), "dsh-purge: rewind watch");
}
function apply(ctx) {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-purge: dictionaries");
const t = ctx.locale.bind(NS);
translate = t;
purgeHost = ctx;
/* 主入口:设置 → DeepSeek Harness 破甲。
* 官方 settings.section 契约只投影 id / order / label(没有 icon 字段),
* 所以这里不给图标;导航行由设置外壳按 id 画,未知 id 落齿轮。
* order 排在 xdpool(440) 之后,紧贴它的下一行。 */
ctx.slots.inject("settings.section", () => ctx.slots.register({
name: "settings.section",
id: "dsh-purge",
order: 450,
label: () => t("page.nav"),
locale: NS,
inject: () => ({ t }),
}, SettingsPage));
installSettingsNavIcon(ctx, () => t("page.nav"));
/* 会话内只留三个必要件:首启提示、演练台浮窗(授权流程还要用)、发送前的环境门禁。
* 右下角启动按钮 / 首页模式旁 / 会话标题旁那三个「dsh-purge」入口全部撤掉——
* 界面只有设置里那一处,不再往对话界面塞按钮。 */
ctx.slots.inject("shell.overlay", () => ctx.slots.register({
name: "shell.overlay",
id: "dsh-purge-dock",
order: 50,
}, () => h(react.Fragment, null, h(WelcomeNotice), h(PurgeDock), h(EnvAdaptSendGate))));
try {
if (typeof ctx.inject === "function") {
ctx.inject(["sessions", "uiWorkspace", "workspaces", "conversation"], (host) => installRewindUi(host));
} else if (ctx.sessions) {
installRewindUi(ctx);
}
} catch (e) {
try {
if (typeof ctx.inject === "function") {
ctx.inject(["sessions", "uiWorkspace", "conversation"], (host) => installRewindUi(host));
}
} catch (e2) {
try { console.warn("[dsh-purge] rewind ui skipped:", e2 || e); } catch { /* ignore */ }
}
}
}
exports.name = name;
exports.inject = inject;
exports.apply = apply;
return module.exports;
}
});