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

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

1999 lines
121 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* RedTeam 智能体工具集(preset 平面)
*
* 这些行只向 `tools` 注册模型可见工具,自身不发布任何服务,因此不需要 isolate realm;
* 它们解析的是 host 平面的 `ctx.redteam`(dsh-redteam-store 发布的进程级实例)。
*
* 设计约定:
* · 写入一律走 redteam_asset_add / redteam_asset_link,智能体不直接碰 SQL;
* · 每次发现都带 provenance(passive|active)与 tool,保证界面上的来源标注可信;
* · **靶标按根会话隔离**:绑定键是会话的根祖先 id,子智能体顺着
* `session.header.parentSession` 继承父会话的靶标 —— 多会话并行时互不串写,
* 报告不会再被别的靶标的成果污染(v0.9.0 之前的全局「当前靶标」指针正是串写的根源);
* · 并发硬约束:`redteam_agent_slot` 用 `ctx.subagents.listChildren` 的真实运行数
* 卡住"同一靶标最多 3 个执行智能体",超了直接拒绝,不靠提示词自觉。
*/
import { createRequire } from 'node:module'
import { existsSync, realpathSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { ROLE_TITLES, PLANNER_ROLE, ROLE_ORDER } from './store-core.js'
/* 技能可用性判定只有一份实现:面板的「技能库」页签与这里共用(见该文件头注释) */
import { bundledSkillSummaries, checkSkill, summarizeSkills, expandSkillPath, redteamSummariesForCheck } from './skill-availability.js'
import {
loadPlatformConfig,
mergeConfigEnv,
platformSummary,
toolkitDirOf,
} from './platform-config.js'
import { maxAgentsOf, DEFAULT_MAX_AGENTS, MAX_AGENTS_LIMIT } from './settings.js'
/**
* link: 源码包不在 profile node_modules 树内,静态 import `@deepseek-ai/dsh-tools`
* 会解析失败 → 预设行 fiber 为 undefined(never started)→ 整份「红队模式」被下拉过滤。
* 这里从运行中的 dsh / profile 路径 createRequire。
*/
function addCandidate(list, pkg) {
if (!pkg) return
const norm = String(pkg).replace(/\\/g, '/')
if (!list.includes(norm)) list.push(norm)
}
function considerBin(list, bin) {
if (!bin) return
let text = String(bin).replace(/\\/g, '/')
try { text = realpathSync(bin).replace(/\\/g, '/') } catch { /* 解析不了就用原路径 */ }
const needle = '/node_modules/@deepseek-ai/dsh/'
const at = text.lastIndexOf(needle)
if (at >= 0) addCandidate(list, text.slice(0, at + needle.length - 1) + '/package.json')
const nm = text.lastIndexOf('/node_modules/')
if (nm >= 0) addCandidate(list, join(text.slice(0, nm), 'node_modules', '@deepseek-ai', 'dsh', 'package.json'))
const dir = dirname(text)
/* npm 全局的 argv 常常是 …/bin/dsh,真正的包在旁边的 lib/node_modules。 */
addCandidate(list, join(dir, '..', 'lib', 'node_modules', '@deepseek-ai', 'dsh', 'package.json'))
addCandidate(list, join(dir, '..', 'node_modules', '@deepseek-ai', 'dsh', 'package.json'))
}
function loadDefineTool() {
const candidates = []
try { considerBin(candidates, process.argv[1]) } catch { /* ignore */ }
const homes = [process.env.DSH_HOME, join(homedir(), '.dsh')].filter(Boolean)
for (const home of homes) {
addCandidate(candidates, join(home, 'package.json'))
for (const profile of ['web', 'desktop', 'default']) {
addCandidate(candidates, join(home, 'profiles', profile, 'package.json'))
addCandidate(candidates, join(home, 'profiles', profile, 'node_modules', '@deepseek-ai', 'dsh', 'package.json'))
}
}
addCandidate(candidates, join(process.cwd(), 'package.json'))
addCandidate(candidates, join('D:', 'DeepSeek Harness', 'npm-global', 'node_modules', '@deepseek-ai', 'dsh', 'package.json'))
for (const pkg of candidates) {
try {
if (!pkg || !existsSync(pkg)) continue
const mod = createRequire(pkg)('@deepseek-ai/dsh-tools')
const fn = mod && (mod.defineTool || (mod.default && mod.default.defineTool))
if (typeof fn === 'function') return fn
} catch { /* try next */ }
}
throw new Error('dsh-purge/redteam/tools: cannot resolve @deepseek-ai/dsh-tools from host')
}
const defineTool = loadDefineTool()
/** Cordis 插件名。 */
export const name = 'dsh-purge-redteam-tools'
/** 硬依赖工具注册表;`redteam` 在 host 平面由 store 发布,预设域内用软取。 */
export const inject = ['tools']
/** 根会话 id → engagementId 绑定(一次会话内稳定;进程重启后由 agent 重新绑定)。 */
const bindings = new Map()
/** 并发闸门:根会话 id → Map(子智能体键 → 预留时间戳)。子智能体结束时自动释放。 */
const reservations = new Map()
/** 并发上限:每次调用现读 settings.json → REDTEAM_MAX_AGENTS → 默认 3。 */
function maxAgentsNow(ctx) {
const root = (() => { try { return ctx.redteam.root } catch { return undefined } })()
return maxAgentsOf(root, process.env)
}
/** 一次调用的会话解析结果。 */
function sessionInfoOf(exec) {
const agent = exec && exec.agent
const session = agent && agent.session
if (session === undefined || session === null) return {}
const id = session.id === undefined ? undefined : String(session.id)
let parentId
try {
const header = session.header
parentId = header && header.parentSession !== undefined && header.parentSession !== null
? String(header.parentSession)
: undefined
} catch { parentId = undefined }
return { id: id, parentId: parentId, session: session }
}
/** 会话 id(兼容旧调用点)。 */
function sessionOf(exec) {
return sessionInfoOf(exec).id
}
/**
* 解析"根会话":一路沿 parentSession 走到最顶层的会话 id。
* 主会话派出的子智能体因此共享同一个绑定键,而**不同主会话之间天然隔离**。
* 顺手把沿途会话 id 都返回,便于把绑定缓存到子会话上。
*/
function resolveRootSession(session) {
const chain = []
let cur = session
let guard = 0
while (cur !== undefined && cur !== null && guard < 64) {
const info = sessionInfoOf({ agent: { session: cur } })
if (info.id === undefined) break
chain.push(info.id)
if (info.parentId === undefined || info.parentId === '') break
let parent
try {
const store = cur.store
parent = store && typeof store.get === 'function' ? store.get(info.parentId) : undefined
} catch { parent = undefined }
if (parent === undefined || parent === null) {
/* 父会话不在内存里(被回收 / 冷启动):父 id 本身就是稳定的根键 */
return { rootId: info.parentId, chain: chain.concat([info.parentId]) }
}
cur = parent
guard += 1
}
return { rootId: chain.length > 0 ? chain[chain.length - 1] : undefined, chain: chain }
}
/** 绑定键 + 会话链。 */
function bindingKeyOf(exec) {
const info = sessionInfoOf(exec)
if (info.id === undefined) return { rootId: undefined, sessionId: undefined, chain: [] }
const resolved = resolveRootSession(info.session)
const rootId = resolved.rootId === undefined ? info.id : resolved.rootId
return { rootId, sessionId: info.id, chain: resolved.chain }
}
/**
* 解析本次调用应作用在哪个靶标上(会话隔离版)。
*
* 顺序:① 显式传入 → ② 本会话/父链上的绑定 → ③ 全局「当前靶标」指针
* (仅当没有被别的会话占用;被占用时报错并提示显式绑定,**绝不静默串写**)。
*
* 抛错而不是"随便挑一个"是刻意的:多会话并行时挑错靶标 = 把 A 单位的成果写进
* B 单位的库、A 的报告里出现 B 的数据(这正是要多会话隔离的原因)。
*/
function resolveEngagement(store, exec, explicit) {
if (typeof explicit === 'string' && explicit.length > 0) return explicit
const { rootId, sessionId, chain } = bindingKeyOf(exec)
const keys = Array.from(new Set([rootId, sessionId, ...chain].filter(Boolean)))
for (const key of keys) {
const bound = bindings.get(key)
if (typeof bound === 'string' && bound.length > 0) return bound
}
if (typeof store.activeEngagementId === 'function') {
const active = store.activeEngagementId()
if (active !== undefined) {
const holder = Array.from(bindings.entries()).find(([k, v]) => v === active && !keys.includes(k))
if (holder !== undefined) {
throw new Error(
'本会话尚未绑定靶标,而全局「当前靶标」' + active + ' 已被另一个会话占用;'
+ '为避免多会话串写同一个靶标库,请显式绑定:redteam_engagement_open(新建/打开)'
+ ' 或 redteam_session_bind(绑定到已有靶标)。',
)
}
for (const key of keys) bindings.set(key, active)
return active
}
}
const list = store.listEngagements()
if (list.length === 1) {
for (const key of keys) bindings.set(key, list[0].id)
return list[0].id
}
throw new Error('尚未绑定靶标:请先调用 redteam_engagement_open 传入靶标单位名称')
}
/** 当前会话的绑定关系(redteam_session_info 与面板展示用)。 */
function bindingViewOf(exec) {
const info = sessionInfoOf(exec)
const { rootId, chain } = bindingKeyOf(exec)
return {
session_id: info.id ?? null,
parent_session: info.parentId ?? null,
root_session: rootId ?? null,
session_chain: chain,
is_subagent: info.parentId !== undefined,
bound_engagement: rootId !== undefined ? (bindings.get(rootId) ?? null) : null,
}
}
/** 逗号/空白分隔的清单 → 去重数组。 */
function splitList(value) {
return String(value || '').split(/[,,\s]+/).map((x) => x.trim()).filter(Boolean)
}
/** 把一行资产压成模型可读的紧凑结构(避免把整库原始数据塞进上下文)。 */
function compactAsset(a) {
return {
id: a.id,
ip: a.ip,
segment: a.segment_cidr,
state: a.state,
name: a.primary_name,
names: a.names.map((n) => n.name),
ports: a.ports.filter((p) => p.state === 'open')
.map((p) => p.port + '/' + p.proto + (p.service ? ' ' + [p.service, p.product, p.version].filter(Boolean).join(' ') : '') + ' [' + p.provenance + ']'),
fingerprints: a.fingerprints.map((f) => [f.category, f.vendor, f.product, f.version].filter(Boolean).join(' ') + ' [' + f.provenance + ']'),
passive: a.passive,
active: a.active,
/* 发现时间:这条资产第一次进入本库的时刻(面板与报告都按它排时间线) */
discovered_at: a.discovered_at,
first_seen: a.first_seen,
last_seen: a.last_seen,
test_status: a.test_status,
priority: a.priority,
}
}
const text = (value) => [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }]
/** 角色 code 的合法值说明(写进工具 description,模型不用猜)。 */
const ROLE_HINT = '角色 code:' + ROLE_ORDER.map((r) => '`' + r + '`(' + ROLE_TITLES[r] + ')').join('、')
function bindKeysOf(exec) {
const { rootId, sessionId, chain } = bindingKeyOf(exec)
return Array.from(new Set([rootId, sessionId, ...chain].filter(Boolean)))
}
/** 实时并发占用:读 subagents 注册表里"真正在跑"的直接子会话数。 */
async function liveRunningChildren(ctx, rootSessionId) {
const subagents = ctx.get('subagents')
if (subagents === undefined || subagents === null || typeof subagents.listChildren !== 'function') {
return { available: false, running: 0, error: 'subagents 注册表不可用(无法核对实际并发数,只能按预留计数)' }
}
try {
const entries = await subagents.listChildren(rootSessionId)
const rows = Array.isArray(entries) ? entries : []
const running = rows.filter((e) => e && e.kind === 'child' && e.activity === 'running')
return {
available: true,
running: running.length,
running_labels: running.map((e) => e.label || e.id).slice(0, 20),
total_children: rows.filter((e) => e && e.kind === 'child').length,
}
} catch (error) {
return { available: false, running: 0, error: '列子会话失败:' + (error && error.message ? error.message : String(error)) }
}
}
/** 预留(未被 subagents 注册表覆盖的那部分,例如刚 acquire 还没起来的)。 */
function reservationList(rootId) {
const map = reservations.get(rootId)
if (map === undefined) return []
return Array.from(map, ([key, at]) => ({ key, at }))
}
function addReservation(rootId, key) {
if (!reservations.has(rootId)) reservations.set(rootId, new Map())
reservations.get(rootId).set(key, Date.now())
}
function dropReservation(rootId, key) {
const map = reservations.get(rootId)
if (map === undefined) return false
if (key === undefined) {
/* 无 key 时只释放最早的一条(与工具描述一致)——清空整张表会把别的在途预留一起放掉,名额超卖 */
let oldestKey = null
let oldestAt = Infinity
for (const [k, at] of map) {
if (at < oldestAt) { oldestAt = at; oldestKey = k }
}
return oldestKey !== null ? map.delete(oldestKey) : false
}
return map.delete(key)
}
/**
* 产出这条记录的智能体角色:显式传的优先,否则默认按主会话记账。
* 这个值会写进库里的 `agent` 列,报告里"这一步是谁做的"就靠它。
*/
function agentOf(args) {
const raw = typeof args.agent === 'string' ? args.agent.trim() : ''
return raw !== '' ? raw : PLANNER_ROLE
}
/** 写库工具共用的 agent 参数(角色 code 白名单提示写进 description)。 */
const AGENT_PARAM = {
type: 'string',
description: '【建议填】产出这条记录的角色 —— ' + ROLE_HINT
+ '。报告里会按它标注"这一步是谁做的",不填按主会话记账。',
}
/**
* 把 store 层抛出的异常统一收敛成结构化的失败返回。
*
* 为什么需要:同一类错误(对象不存在 / 参数非法)此前有两种形态 ——
* 有的工具 `return { ok:false, error }`,有的直接抛。模型拿到前者能换参数重试,
* 拿到后者往往直接放弃这一次调用,行为不可预测。统一成前者,且**不吞原始信息**。
* @param tool - defineTool 的返回值。
* @returns 包了一层 try/catch 的同一个工具定义。
*/
function withStructuredErrors(tool) {
const inner = tool.execute
if (typeof inner !== 'function') return tool
return Object.assign({}, tool, {
async execute(args, exec) {
try {
return await inner(args, exec)
} catch (error) {
const message = error && error.message ? error.message : String(error)
/* 参数类错误(非法值/缺必填)与"找不到对象"分开提示,模型据此决定重试还是放弃 */
const kind = /非法|invalid|required|不能为空|not found|不存在/i.test(message) ? 'bad_input' : 'failed'
return JSON.stringify({
ok: false,
error: message,
error_kind: kind,
hint: kind === 'bad_input'
? '这是参数或对象标识的问题:核对参数名与取值(可用对应的 *_list / *_query 工具先看实际 id),修正后重试。'
: '工具执行失败,错误原文见 error 字段;不要静默跳过,先向用户或指挥者说明卡点。',
}, null, 2)
}
},
})
}
export function apply(ctx) {
let store
try {
store = ctx.redteam
} catch {
store = undefined
}
if (!store) {
try { store = ctx.get('redteam') } catch { store = undefined }
}
if (!store) {
ctx.logger?.warn?.('redteam-tools: host ctx.redteam unavailable — tools will report store_missing')
store = {
openEngagement: () => { throw new Error('redteam store unavailable') },
listEngagements: () => [],
stats: () => ({}),
}
}
/* ── 子智能体结束时自动释放并发名额(不依赖模型记得调 release)──────────── */
try {
ctx.on('subagent/end', (info, parent) => {
const parentSession = parent && parent.session
const { rootId } = bindingKeyOf({ agent: { session: parentSession } })
const childId = info && info.id !== undefined ? String(info.id) : undefined
if (rootId === undefined) return
if (childId !== undefined && dropReservation(rootId, childId)) return
/* 注册表里的键可能与 runId 不同:按时间兜底清掉最早的一个预留 */
const map = reservations.get(rootId)
if (map === undefined || map.size === 0) return
let oldestKey
let oldestAt = Number.POSITIVE_INFINITY
for (const [key, at] of map) { if (at < oldestAt) { oldestAt = at; oldestKey = key } }
if (oldestKey !== undefined) map.delete(oldestKey)
})
} catch { /* 事件不可用时只影响自动释放,手动 release 仍然可用 */ }
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_engagement_open',
description: '打开或创建一次攻防演练靶标(按单位名称),并把**当前会话**绑定到该靶标(子智能体会自动继承)。后续所有资产工具默认作用于它。若靶标已存在则复用其资产库。写入的是本会话的绑定,不会改动其它会话正在用的靶标。',
parameters: {
target: { type: 'string', required: true, description: '靶标单位名称,例如「示例科技有限公司」' },
scope: {
type: 'array',
description: '授权范围 CIDR 列表(可选:用户没给就不用问,按公开可见资产面自主推进)',
items: { type: 'string' },
},
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
/* bindCurrent: false —— 不动全局「当前靶标」指针:多会话并行时那个指针是串写的根源 */
const engagement = store.openEngagement(args.target, args.scope, { bindCurrent: false })
const keys = bindKeysOf(exec)
for (const key of keys) bindings.set(key, engagement.id)
const view = bindingViewOf(exec)
return JSON.stringify({
ok: true,
engagement,
stats: store.stats(engagement.id),
session: view,
note: '已绑定到本会话(会话隔离:其它会话的绑定不受影响);子智能体会继承本靶标。'
+ (view.is_subagent ? ' 注意:本会话是子智能体,通常不需要再 open,直接继承父会话靶标即可。' : ''),
}, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_session_bind',
description: '把一个**已经存在**的靶标绑定到当前会话(多会话并行时用:每个会话绑自己的靶标,互不串写)。可用靶标 id 或单位名称,省略则列出可绑定的靶标。',
parameters: {
engagement: { type: 'string', description: '靶标 id 或单位名称;省略只列出候选' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const list = store.listEngagements()
const wanted = typeof args.engagement === 'string' ? args.engagement.trim() : ''
if (wanted === '') {
return JSON.stringify({
ok: false,
candidates: list.map((e) => ({ id: e.id, name: e.name, created_at: e.created_at, stats: e.stats })),
note: '请传 engagement(靶标 id 或单位名称)完成绑定。',
}, null, 2)
}
const hit = list.find((e) => e.id === wanted)
|| list.find((e) => String(e.name) === wanted)
|| list.find((e) => String(e.name).includes(wanted))
if (hit === undefined) {
return JSON.stringify({ ok: false, error: '靶标不存在:' + wanted, candidates: list.map((e) => e.id) }, null, 2)
}
const keys = bindKeysOf(exec)
for (const key of keys) bindings.set(key, hit.id)
return JSON.stringify({
ok: true, engagement: { id: hit.id, name: hit.name },
session: bindingViewOf(exec),
note: '本会话已绑定到「' + hit.name + '」;后续工具与报告都只作用于它。',
}, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_session_info',
description: '查看**当前会话绑定的是哪个靶标**(会话 id、父会话、根会话、绑定链)。多会话同时开工时,先跑它确认自己没串到别人的靶标上。',
parameters: {},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(_args, exec) {
const view = bindingViewOf(exec)
const changed = view.bound_engagement !== null ? store.stats(view.bound_engagement) : null
return JSON.stringify({
ok: true, session: view, stats: changed,
bindings_now: Array.from(bindings, ([k, v]) => ({ session: k, engagement: v })),
note: '绑定是进程内状态:dsh 重启后各会话需要重新绑定一次(redteam_engagement_open / redteam_session_bind)。',
}, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_preflight',
description: '【开工前必须跑一次】平台技能与资源自检:逐个检查红队技能的必需环境变量(如 FOFA 测绘的 FOFA_KEY)、本机工具与二进制(suo5 / fscan / gogo / frp / 冰蝎 / 哥斯拉 / nmap / nuclei…)、外部基础设施(反弹 Shell 用的 VPS)。返回 available/broken 清单与每个坏掉的技能"缺什么、怎么补"。**缺 key / 缺 VPS 时直接向用户要,或改用替代方案,不要假装能跑。**',
parameters: {
include: { type: 'string', description: '只检查这些技能(逗号分隔);省略则检查全部红队技能' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args) {
let skills
try { skills = typeof ctx.get === 'function' ? ctx.get('skills') : null } catch { skills = null }
const plat = platformSummary()
const cfg = loadPlatformConfig()
/* 环境变量的真实来源:进程环境 + $DSH_HOME/.env + 平台 config.json env */
const envFile = expandSkillPath('$DSH_HOME/.env')
const effectiveEnv = mergeConfigEnv({ ...process.env }, cfg)
let envFileKeys = []
try {
if (existsSync(envFile)) {
const { readFileSync } = await import('node:fs')
for (const line of readFileSync(envFile, 'utf8').split('\n')) {
/* 兼容 shell 习惯写法:`export KEY=value`;未加引号的行内注释(`KEY=v # 备注`)剥掉 */
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line)
if (m === null) continue
const key = m[1]
let raw = m[2].trim()
if (!/^["']/.test(raw)) raw = raw.replace(/\s+#.*$/, '')
const value = raw.replace(/^["']|["']$/g, '').trim()
if (value === '') continue
envFileKeys.push(key)
if (effectiveEnv[key] === undefined || effectiveEnv[key] === '') effectiveEnv[key] = value
}
}
} catch { /* .env 读不到不影响主流程 */ }
const toolkit = toolkitDirOf(cfg)
let toolkitEntries = []
try {
if (existsSync(toolkit)) {
const { readdirSync } = await import('node:fs')
toolkitEntries = readdirSync(toolkit).slice(0, 60)
}
} catch { /* 忽略 */ }
const redteamDir = expandSkillPath('$DSH_HOME/redteam')
const markerFile = redteamDir + '/.setup-complete'
const setupScript = redteamDir + '/setup.sh'
const hasMarker = existsSync(markerFile)
const hasSetupScript = existsSync(setupScript)
const toolkitHint = '本插件不替用户下载或安装工具。Windows 与没有 setup.sh 时,到演练台「环境适配」填路径、FOFA_KEY 和 VPS;本机已有 `$DSH_HOME/redteam/setup.sh` 时,由用户自己执行 `--check`,不要由智能体跑 `--yes` 去下载二进制。'
let fofaKey = effectiveEnv.FOFA_KEY || ''
const vpsKey = effectiveEnv.REDTEAM_VPS_KEY || (toolkit + (plat.runtime.windows ? '\\vps\\id_rsa' : '/vps/id_rsa'))
const vpsHost = effectiveEnv.REDTEAM_VPS_HOST || ''
const hasVps = existsSync(vpsKey)
const missing = []
if (fofaKey === '') missing.push('FOFA_KEY(资产测绘:在「环境适配」页填写,或写进 $DSH_HOME/.env)')
if (!hasVps) {
missing.push(plat.runtime.windows
? 'VPS 私钥(在「环境适配」填写 REDTEAM_VPS_KEY 路径与 REDTEAM_VPS_HOST;Windows 不依赖 setup.sh)'
: 'VPS 登录方式——私钥放到 ' + vpsKey + ',并设 REDTEAM_VPS_HOST(步骤见技能 redteam-setup,或演练台「环境适配」)')
}
const fofaShown = fofaKey.length > 8 ? fofaKey.slice(0, 4) + '…' + fofaKey.slice(-4) : '(已配置)'
const runtimeHint = plat.runtime.windows
? '当前为 **Windows 适配**:请在演练台「环境适配」页配置工具路径/搜索目录与 FOFA/VPS;不要假设存在 Kali/`bash setup.sh`。仍建议用专用演练机,勿在日常办公机上落私钥与扫描器。'
: '本模式建议在**专供演练的 Kali 虚拟机**里运行,不要跑在日常办公电脑/宿主机上'
+ '(本机落 VPS 私钥与 WebShell 马,扫描流量也从常用出口 IP 出去;演练完虚拟机可直接丢弃)。'
let checked = []
let allBroken = []
let allAvailable = []
let broken = []
let available = []
let only = splitList(args.include)
let unknownIncludes = []
const bundledSkills = bundledSkillSummaries()
const skillsMissing = skills === undefined || skills === null || typeof skills.list !== 'function'
if (skillsMissing && bundledSkills.length === 0) {
/* 桌面端常无 skills inject:仍返回平台工具探测,避免整条 preflight 报废 */
const toolMissing = (plat.tools.items || []).filter((t) => !t.path).map((t) => t.id)
if (toolMissing.length) {
missing.push('未解析到的工具(在「环境适配」填路径或加搜索目录):' + toolMissing.slice(0, 12).join(', '))
}
const onboarding = {
complete: hasMarker && missing.length === 0,
runtime_hint: runtimeHint,
first_run: !hasMarker,
marker: { path: markerFile, exists: hasMarker },
setup_script: { path: setupScript, exists: hasSetupScript },
toolkit_hint: toolkitHint,
configured: {
fofa_key: fofaKey === '' ? 'missing' : 'configured(' + fofaShown + ')',
vps_key: hasVps ? 'configured(' + vpsKey + ')' : 'missing',
vps_host: vpsHost,
env_file: { path: envFile, exists: existsSync(envFile), keys: envFileKeys },
platform_config: { path: cfg.path, exists: !!cfg.exists },
},
missing,
}
return JSON.stringify({
ok: missing.length === 0 && toolMissing.length === 0,
onboarding,
platform: plat,
toolkit: { dir: toolkit, exists: existsSync(toolkit), entries: toolkitEntries, hint: toolkitHint },
skills_root_hint: '宿主 skills 服务不可用:已跳过技能正文体检,仅返回平台工具探测。请到「环境适配」配置路径。',
next: plat.runtime.windows
? '打开演练台「环境适配」:① 填 binDirs(你的工具文件夹)或逐个 tools 路径 ② 填 FOFA_KEY / VPS ③ 保存后重新 preflight。不要替用户下载工具。'
: (hasSetupScript
? '可由用户自己执行 bash "$DSH_HOME/redteam/setup.sh" --check,或到「环境适配」手填路径。不要由智能体下载工具。'
: '本机没有 setup.sh,这是正常情况。到演练台「环境适配」手填路径,或按技能 redteam-setup 逐项配置。'),
}, null, 2)
}
let summaries = []
let fromBundle = skillsMissing
let scoped = false
if (fromBundle) {
summaries = bundledSkills
} else {
try {
let scope = null
try {
const presets = typeof ctx.get === 'function' ? ctx.get('agentPresets') : null
if (presets && typeof presets.standingKeyFor === 'function') {
scope = await presets.standingKeyFor('redteam')
}
} catch { scope = null }
scoped = scope != null
const listed = scoped ? await skills.list({ scope }) : await skills.list()
summaries = redteamSummariesForCheck(listed, bundledSkills, scoped)
fromBundle = summaries.every((item) => item && item.library === 'redteam' && item.content)
} catch (error) {
if (bundledSkills.length === 0) {
return JSON.stringify({ ok: false, error: '读取技能目录失败:' + (error && error.message ? error.message : String(error)), platform: plat }, null, 2)
}
summaries = bundledSkills
fromBundle = true
}
}
summaries = redteamSummariesForCheck(summaries, bundledSkills, scoped)
const wanted = summaries.filter((s) => only.length === 0 || only.includes(s.name))
/* include 时只体检点名的技能:否则 ok 被无关 broken 拖成 false,模型会误以为目标技能坏了 */
const toCheck = only.length > 0 ? wanted : summaries
unknownIncludes = only.filter((n) => !summaries.some((s) => s.name === n))
const rootsWith = new Map()
for (const x of summaries) {
const dir = x.resourceBase && x.resourceBase.kind === 'directory' ? x.resourceBase.path : null
if (dir === null) continue
if (!rootsWith.has(x.name)) rootsWith.set(x.name, [])
if (!rootsWith.get(x.name).includes(dir)) rootsWith.get(x.name).push(dir)
}
for (const summary of toCheck) {
let def
if (!fromBundle && skills && typeof skills.get === 'function') {
try { def = await skills.get(summary.name) } catch { def = undefined }
}
const path = def && typeof def.path === 'string'
? def.path
: (typeof summary.path === 'string'
? summary.path
: (summary.resourceBase && summary.resourceBase.kind === 'directory' ? summary.resourceBase.path : null))
const root = summary.resourceBase && summary.resourceBase.kind === 'directory' ? summary.resourceBase.path : null
const content = def && typeof def.content === 'string' && def.content.trim() !== ''
? def.content
: summary.content
const verdict = checkSkill({ name: summary.name, content, path, root },
{ env: effectiveEnv, sameNameIn: rootsWith.get(summary.name) || [] })
checked.push(Object.assign({}, verdict, {
title: summary.description,
path,
when_to_use: summary.whenToUse || '',
needs_user: verdict.needs_user.map((n) => (n.startsWith('环境变量')
? n + (plat.runtime.windows ? '(可在「环境适配」页填写)' : '(拿到后 export 并重启 dsh web)')
: n === 'VPS 地址' || n === 'VPS 主机名'
? '反弹 Shell / 载荷投递用的 ' + n + '(技能里还是占位符)'
: n)),
}))
}
allBroken = checked.filter((s) => s.status === 'broken')
allAvailable = checked.filter((s) => s.status === 'available')
const shown = (x) => only.length === 0 || only.includes(x.name)
broken = allBroken.filter(shown)
available = allAvailable.filter(shown)
const toolMissing = (plat.tools.items || []).filter((t) => !t.path)
if (toolMissing.length) {
missing.push('未解析到的本机工具(「环境适配」填路径):' + toolMissing.slice(0, 10).map((t) => t.id).join(', '))
}
const needsUser = Array.from(new Set(broken.flatMap((s) => s.needs_user)))
const onboarding = {
complete: hasMarker && missing.length === 0 && allBroken.length === 0,
runtime_hint: runtimeHint,
first_run: !hasMarker,
marker: { path: markerFile, exists: hasMarker },
setup_script: { path: setupScript, exists: hasSetupScript },
toolkit_hint: toolkitHint,
configured: {
fofa_key: fofaKey === '' ? 'missing' : 'configured(' + fofaShown + ')',
vps_key: hasVps ? 'configured(' + vpsKey + ')' : 'missing',
vps_host: vpsHost,
env_file: { path: envFile, exists: existsSync(envFile), keys: envFileKeys },
platform_config: { path: cfg.path, exists: !!cfg.exists },
},
missing,
}
let next
if (plat.runtime.windows && (!hasMarker || missing.length > 0 || allBroken.length > 0)) {
next = 'Windows:打开演练台「环境适配」配置 toolkit/binDirs/tools 路径与 FOFA/VPS,保存后重跑 redteam_preflight;技能正文缺路径时优先用已解析的工具绝对路径。'
} else if (!hasMarker && !hasSetupScript) {
next = '首次使用,且本机没有安装脚本 ' + setupScript + '(这是正常情况,不是故障)。'
+ toolkitHint + ' 把 onboarding.missing 一次性列给用户;补不齐就说明降级:没有 FOFA 用 crt.sh / 被动 DNS,没有 VPS 只做不落地的成果。'
} else if (!hasMarker) {
next = '**首次使用**:让用户自己执行 `bash ' + setupScript + ' --check`,或打开演练台「环境适配」。不要由智能体执行 --yes 去下载工具。'
} else if (missing.length > 0) {
next = '有缺口:把 onboarding.missing 一次性列给用户。Windows 或没有 setup.sh 时走「环境适配」;有脚本时也只让用户自己跑,不要替用户下载工具。'
} else if (allBroken.length > 0) {
next = '把 broken 里 needs_user 一次性列给用户;补不齐则说明降级方案。'
} else {
next = '全部可用,可以开工。'
}
const summary = summarizeSkills(checked)
return JSON.stringify({
/* include 模式:ok 只反映点名技能的体检结果;测绘 key/VPS 等 missing 与此无关,降为参考不拖 ok */
ok: allBroken.length === 0 && (only.length > 0 || missing.length === 0),
include: only.length > 0 ? { requested: only, unknown: unknownIncludes, note: 'ok 只覆盖点名的技能;onboarding.missing 是全局参考,不影响本次 ok' } : undefined,
all_checked: { registry_total: summaries.length, checked: checked.length, broken: allBroken.length, available: allAvailable.length },
onboarding,
platform: plat,
summary,
checked: checked.length,
available: available.map((s) => s.name),
broken: broken.map((s) => ({ name: s.name, problems: s.problems, needs_user: s.needs_user,
fixes: (s.issues || []).map((i) => i && i.fix).filter(Boolean),
fix_note: '修复步骤由用户执行(或经「环境适配」页);不要由你下载/安装工具。' })),
unknown: checked.filter((s) => s.status === 'unknown').map((s) => s.name),
toolkit: { dir: toolkit, exists: existsSync(toolkit), entries: toolkitEntries, hint: toolkitHint },
skills_root_hint: '这里只检查红队技能。官方技能在 $DSH_HOME/skills,不按红队工具路径判不可用。',
next,
needs_user_flat: needsUser,
wanted_count: wanted.length,
}, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_agent_slot',
description: '【主会话派活前后用】执行智能体的并发闸门:同一靶标**最多同时 3 个**在跑(可用 REDTEAM_MAX_AGENTS 配置)。action=status 看还剩几个名额与谁在跑;action=acquire 占一个名额(满了会直接拒绝,**不要重试硬塞**);action=release 释放(子智能体结束时服务端也会自动释放)。',
parameters: {
action: { type: 'string', required: true, enum: ['status', 'acquire', 'release'], description: 'status 查看 / acquire 占用 / release 释放' },
label: { type: 'string', description: 'acquire 时的任务标签,例如「信息收集:主域与 C 段」' },
key: { type: 'string', description: 'release 时指定要释放的键(省略则释放最早的一个预留)' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const { rootId, sessionId } = bindingKeyOf(exec)
const owner = rootId ?? sessionId
if (owner === undefined) {
return JSON.stringify({ ok: false, error: '取不到会话 id:无法统计并发(请确认工具调用带着 agent 上下文)。' }, null, 2)
}
const live = await liveRunningChildren(ctx, owner)
const reserved = reservationList(owner)
/* 注册表里 running 的子会话已经包含正在跑的;预留是"刚占位还没出现在注册表"的那部分,
两者取较大值,避免重复计数又不会漏计 */
const used = Math.max(live.running, reserved.length)
const cap = maxAgentsNow(ctx)
const free = Math.max(cap - used, 0)
const base = {
ok: true, max: cap, used, free,
running_from_registry: live.running,
running_labels: live.running_labels ?? [],
reservations: reserved,
registry_available: live.available,
registry_note: live.available ? undefined : live.error,
}
if (args.action === 'status') {
return JSON.stringify(Object.assign(base, {
hint: free === 0
? '名额已满:等现有智能体回报后再派(每次只派一个、按顺序推进是最稳的节奏)。'
: '还有 ' + free + ' 个名额。默认一个一个派;只有确实互不依赖的活才并行。',
}), null, 2)
}
if (args.action === 'acquire') {
if (free <= 0) {
return JSON.stringify(Object.assign(base, {
ok: false,
error: '并发已满(最多 ' + cap + ' 个):现在不能派新智能体。先等当前的在跑智能体回报,或先 release 掉已经结束的。',
}), null, 2)
}
const key = (typeof args.label === 'string' && args.label.trim() !== '' ? args.label.trim() : 'slot') + '#' + Date.now()
addReservation(owner, key)
return JSON.stringify(Object.assign(base, { slot: key, used: used + 1, free: Math.max(cap - used - 1, 0) }, {
hint: '已占位(' + key + ')。**派完之后要记得**:子智能体结束会自动释放;若你派活失败(比如工具报错),手动 redteam_agent_slot action=release key=' + key + ' 把它放掉。',
}), null, 2)
}
const dropped = dropReservation(owner, typeof args.key === 'string' && args.key.trim() !== '' ? args.key.trim() : undefined)
return JSON.stringify(Object.assign(base, { ok: true, released: dropped, used: Math.max(used - (dropped ? 1 : 0), 0) }), null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_asset_add',
description: '把一个资产及其端口/服务/指纹写入资产库(幂等 upsert)。每条发现必须标注 provenance:被动收集填 passive,主动探测填 active,并填写 tool(数据源或工具名)。',
parameters: {
engagement: { type: 'string', description: '靶标 id;省略则用当前会话绑定的靶标' },
ip: { type: 'string', required: true, description: '资产 IP(IPv4)' },
state: { type: 'string', description: 'live | dead | unknown,默认 unknown' },
primary_name: { type: 'string', description: '主域名/主机名' },
provenance: { type: 'string', required: true, enum: ['passive', 'active'], description: '本条发现的来源:被动或主动' },
tool: { type: 'string', description: '数据源或工具名,例如 crt.sh / nmap / nuclei / curl' },
discovered_at: { type: 'string', description: '【可选】这条资产的发现时间(ISO,如 2026-09-17T09:30:00Z)。不填则按"第一次入库的时刻"自动记录;数据源给出历史首次出现时间时传进来更准。**重复采集不会覆盖它**。' },
first_seen: { type: 'string', description: '【可选】数据源/工具报告的首次出现时间(与发现时间分开记)' },
names: {
type: 'array',
description: '该资产关联的域名/证书名',
items: {
type: 'object',
additionalProperties: false,
properties: {
name: { type: 'string', required: true },
kind: { type: 'string', description: 'domain | hostname | cert_cn,默认 domain' },
provenance: { type: 'string', enum: ['passive', 'active'] },
},
},
},
ports: {
type: 'array',
description: '开放端口及其服务/指纹',
items: {
type: 'object',
additionalProperties: false,
properties: {
port: { type: 'number', required: true },
proto: { type: 'string', description: 'tcp(默认)| udp' },
service: { type: 'string', description: '服务名,例如 http / mysql' },
product: { type: 'string', description: '产品名,例如 nginx' },
version: { type: 'string', description: '版本,例如 1.24.0' },
banner: { type: 'string', description: '原始 banner 片段' },
url: { type: 'string', description: 'Web 服务可直接访问的完整 URL,例如 https://oa.example.com:8443/portal' },
title: { type: 'string', description: 'Web 页面标题(HTTP 探测获取),例如「致远OA 登录」' },
provenance: { type: 'string', enum: ['passive', 'active'] },
tool: { type: 'string' },
fingerprints: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
category: { type: 'string', description: 'Web服务器 | 中间件 | 框架 | CMS | 数据库 | VPN网关 …' },
vendor: { type: 'string' },
product: { type: 'string' },
version: { type: 'string' },
evidence: { type: 'string', description: '判定依据,例如响应头 / 路径 / banner' },
confidence: { type: 'number', description: '0–1' },
},
},
},
},
},
},
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const asset = {
ip: args.ip, state: args.state, primary_name: args.primary_name,
provenance: args.provenance, tool: args.tool,
names: args.names, ports: args.ports,
/* 发现时间:不传则由服务端按"第一次入库的时刻"自动记录;
数据源能给历史时间(FOFA 的 first_seen 等)就传进来,报告时间线更准 */
discovered_at: args.discovered_at,
first_seen: args.first_seen,
}
const result = store.importBundle(id, { scan: { tool: args.tool, argv: ['redteam_asset_add'] }, assets: [asset] })
const written = store.listAssets(id, { ip: args.ip, limit: 1 }).items[0]
return JSON.stringify({
ok: true, engagement: id, counts: result.counts,
asset: written ? compactAsset(written) : null,
discovered_at: written ? written.discovered_at : null,
hint: '本条资产的发现时间已记为 ' + (written && written.discovered_at ? written.discovered_at : '(未知)')
+ ';重复采集只刷新 last_seen,不会改动发现时间。',
}, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_asset_link',
description: '在资产库中写入一条关系边(图谱与后续横向分析共用)。relation 例如 resolves / exposes / contains / trusts / shares_cert。',
parameters: {
engagement: { type: 'string' },
src_kind: { type: 'string', required: true, description: 'segment | asset | domain | port …' },
src_id: { type: 'string', required: true },
dst_kind: { type: 'string', required: true },
dst_id: { type: 'string', required: true },
relation: { type: 'string', required: true },
confidence: { type: 'number' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.importBundle(id, {
edges: [{
src_kind: args.src_kind, src_id: args.src_id,
dst_kind: args.dst_kind, dst_id: args.dst_id,
relation: args.relation, confidence: args.confidence,
}],
})
return JSON.stringify({ ok: true, edges: result.counts.edges }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_asset_query',
description: '检索资产库。支持按 C 段、端口、服务、指纹、来源、内外网维度(scope)、排序(sort)、关键词(全文,覆盖 IP/域名/banner/服务/指纹)过滤。返回紧凑结果,含每个资产的开放端口与指纹及内外网归属。',
parameters: {
engagement: { type: 'string' },
cidr: { type: 'string', description: 'C 段,例如 203.0.113.0/24' },
port: { type: 'number', description: '开放端口' },
service: { type: 'string', description: '服务/产品名关键词,例如 nginx' },
fingerprint: { type: 'string', description: '指纹关键词,例如 Tomcat / Spring' },
provenance: { type: 'string', enum: ['passive', 'active'], description: '只看被动或主动来源' },
q: { type: 'string', description: '全文检索词(空格分隔多词为 AND)' },
state: { type: 'string', enum: ['live', 'dead', 'unknown'] },
scope: { type: 'string', enum: ['internal', 'external'], description: '内外网维度:internal=内网/私网地址,external=互联网可达' },
sort: { type: 'string', enum: ['priority', 'todo', 'ports', 'ip'], description: '排序:priority=易打性优先(默认),todo=待测优先,ports=端口多优先,ip=按 IP' },
limit: { type: 'number', description: '返回条数上限,默认 50,最大 200' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const limit = Math.min(Number(args.limit) || 50, 200)
const result = store.listAssets(id, {
cidr: args.cidr, port: args.port, service: args.service,
fingerprint: args.fingerprint, provenance: args.provenance,
q: args.q, state: args.state, scope: args.scope, sort: args.sort, limit,
})
return JSON.stringify({
ok: true, engagement: id, total: result.total, returned: result.items.length,
items: result.items.map(compactAsset),
}, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_asset_get',
description: '取单个资产的完整详情:全部端口与服务、指纹、采集溯源时间线、关系边。用于深入分析某个目标。',
parameters: {
engagement: { type: 'string' },
id: { type: 'number', required: true, description: '资产 id(来自 redteam_asset_query 结果)' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const asset = store.getAsset(id, args.id)
if (asset === undefined) return JSON.stringify({ ok: false, error: 'asset not found' })
return JSON.stringify({ ok: true, asset }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_asset_stats',
description: '资产测绘概览:C 段数、资产数(存活)、开放端口、服务、指纹、被动/主动溯源条数,以及各 C 段的明细。',
parameters: { engagement: { type: 'string' } },
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
return JSON.stringify({ ok: true, engagement: id, stats: store.stats(id), segments: store.listSegments(id) }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_asset_graph',
description: '导出资产图谱(C 段 → 资产 → 开放端口,含域名解析边),用于拓扑推理与横向移动路径规划。节点数超过上限时按 C 段缩小范围。',
parameters: {
engagement: { type: 'string' },
cidr: { type: 'string', description: '只取某个 C 段的子图' },
maxNodes: { type: 'number', description: '节点上限,默认 300' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const maxNodes = Math.min(Number(args.maxNodes) || 300, 1000)
const graph = store.graph(id, { cidr: args.cidr })
if (graph.nodes.length > maxNodes) {
return JSON.stringify({
ok: false,
error: '图谱节点过多(' + graph.nodes.length + ' > ' + maxNodes + '),请指定 cidr 缩小范围',
segments: store.listSegments(id).map((s) => ({ cidr: s.cidr, assets: s.assets })),
}, null, 2)
}
return JSON.stringify({ ok: true, engagement: id, nodes: graph.nodes, edges: graph.edges }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_role_prompt',
description: '取出某个红队角色当前生效的系统提示词,只供查看。派活时不要把全文抄进任务;在 subagent 的 prompt 第一行写 redteamRole: <code>,角色稿会进入子会话系统提示词。',
parameters: {
engagement: { type: 'string' },
role: { type: 'string', required: true, /* 角色白名单**从 ROLE_ORDER 派生**:写死 4 个值曾漏掉 assess,
而预设明确要求主会话用本工具取资产梳理角色的提示词 —— 结果 enum 直接拒绝,
资产梳理子智能体永远拿不到角色约束。派生后新增角色不会再漏。 */
enum: ROLE_ORDER },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const found = store.listPrompts(id).find((p) => p.role === args.role)
if (found === undefined) return JSON.stringify({ ok: false, error: 'unknown role' })
return JSON.stringify({
ok: true, role: found.role, title: found.title,
updated_at: found.updated_at, prompt: found.content,
}, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_roles',
description: '列出红队角色及其职责标题(信息收集 / 漏洞检测 / 漏洞利用 / 内网渗透)。',
parameters: {},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute() {
return JSON.stringify({
ok: true,
roles: Object.entries(ROLE_TITLES).map(([role, title]) => ({ role, title })),
}, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_asset_test',
description: '记录对某个资产的测试情况:本次做了什么测试(`test`,追加式)、还剩什么攻击面(`surface`,覆盖式)、当前测试状态、是否被 WAF 封禁。**每个资产开始测之前先调一次(status=testing),测完/放弃再调一次**——资产测绘页面据此显示「未测试 / 测试中 / 已测试 / 被封禁 / 已放弃 / 无攻击面」。排除结论、登录失败原因、重测理由都写在 `test` 里。',
parameters: {
engagement: { type: 'string' },
asset_id: { type: 'number', description: '资产 id(与 ip 二选一,优先 asset_id)' },
ip: { type: 'string', description: '资产 IP' },
status: { type: 'string', enum: ['untested', 'testing', 'tested', 'blocked', 'abandoned', 'no_surface'], description: '测试状态:未测试/测试中/已测试/被封禁/已放弃/无攻击面' },
test: { type: 'string', description: '本次做了什么测试(**追加**到测试记录,例如「nmap 全端口 + nuclei cve 模板 + 接口越权」)。排除结论、登录失败原因、重测理由都写这里。' },
notes: { type: 'string', description: 'test 的兼容别名(老提示词里的写法),效果与 test 相同,会一并追加到测试记录。' },
surface: { type: 'string', description: '还剩什么攻击面可测(覆盖式,例如「SMB 445 未测;Web /api 未做越权」)' },
blocked: { type: 'boolean', description: '本次是否被 WAF/防护封禁(true 时封禁计数 +1)' },
updated_by: { type: 'string', description: '记录角色,例如 recon / vuln-scan' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.updateAssetTest(id, { ...args, engagement: undefined })
return JSON.stringify({ ok: true, engagement: id, ...result }, null, 2)
},
})))
/* ================================================================== 漏洞检测 */
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_vuln_add',
description: '记录一条漏洞发现(漏洞检测角色)。带 cve 时按 (asset_id, cve, target) 幂等更新。severity:critical|high|medium|low|info;status:candidate(待验证)|confirmed(已验证)|false-positive|exploited|fixed。必须附证据(请求/响应片段、复现命令、证据文件路径)。',
parameters: {
engagement: { type: 'string' },
agent: AGENT_PARAM,
asset_id: { type: 'number', description: '资产 id(来自 redteam_asset_query)' },
cve: { type: 'string', description: 'CVE / CNVD 编号(无编号可省略)' },
title: { type: 'string', required: true, description: '漏洞标题' },
severity: { type: 'string', required: true, enum: ['critical', 'high', 'medium', 'low', 'info'] },
status: { type: 'string', enum: ['candidate', 'confirmed', 'false-positive', 'exploited', 'fixed'] },
target: { type: 'string', description: '受影响的目标,如 https://host:443/path 或 ip:port' },
source: { type: 'string', description: '发现方式:nuclei / sqlmap / curl / manual …' },
confidence: { type: 'number', description: '0–1' },
evidence: { type: 'string', description: '证据:请求/响应摘要、命令、证据文件路径' },
found_by_agent: { type: 'string', description: '发现角色,默认 vuln-scan' },
gained: { type: 'string', description: '【重要】通过这个漏洞拿到了什么权限/成果,写得分口径的短标签,多项用顿号或逗号分隔,例如「服务器权限、内网隧道」「后台管理员账号」「数据库权限」' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.addVuln(id, { ...args, engagement: undefined, agent: agentOf(args), found_by_agent: args.found_by_agent || args.agent || 'vuln-scan' })
return JSON.stringify({ ok: true, engagement: id, ...result, stats: store.vulnStats(id) }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_vuln_query',
description: '检索漏洞库:按严重级、状态、CVE、资产、C 段或关键词过滤。用于挑选待利用目标或核对误报。',
parameters: {
engagement: { type: 'string' },
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'] },
status: { type: 'string', enum: ['candidate', 'confirmed', 'false-positive', 'exploited', 'fixed'] },
cve: { type: 'string' },
asset_id: { type: 'number' },
cidr: { type: 'string' },
q: { type: 'string', description: '标题/CVE/目标/证据关键词' },
limit: { type: 'number', description: '默认 50,最大 200' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.listVulns(id, { ...args, engagement: undefined, limit: Math.min(Number(args.limit) || 50, 200) })
return JSON.stringify({ ok: true, engagement: id, total: result.total, items: result.items, stats: store.vulnStats(id) }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_vuln_update',
description: '更新漏洞状态/严重级/证据(例如验证后置为 confirmed、利用成功后置为 exploited、误报置为 false-positive)。',
parameters: {
engagement: { type: 'string' },
id: { type: 'number', required: true, description: '漏洞 id' },
status: { type: 'string', enum: ['candidate', 'confirmed', 'false-positive', 'exploited', 'fixed'] },
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'] },
evidence: { type: 'string' },
confidence: { type: 'number' },
gained: { type: 'string', description: '通过这个漏洞拿到了什么权限/成果(得分口径短标签,多项用顿号分隔)' },
title: { type: 'string' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.updateVuln(id, args.id, { status: args.status, severity: args.severity, evidence: args.evidence, confidence: args.confidence, gained: args.gained, title: args.title })
return JSON.stringify({ ok: result.updated, engagement: id, ...result, stats: store.vulnStats(id) }, null, 2)
},
})))
/* ================================================================== 漏洞利用 / 内网渗透 */
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_credential_add',
description: '记录一条凭据(漏洞利用/内网渗透角色)。**必须把口令/密钥明文写进 secret_value**——面板要直接显示明文供随时复用;同时用 secret_ref 指向 runs/ 下的证据文件。注意:本库只在本机,禁止把库文件或导出内容提交到任何仓库。',
parameters: {
engagement: { type: 'string' },
agent: AGENT_PARAM,
host: { type: 'string', required: true, description: '所属主机(IP 或域名)' },
username: { type: 'string' },
secret_type: { type: 'string', description: 'password | hash | key | token | connection-string,默认 password' },
secret_value: { type: 'string', description: '【必填】凭据明文:口令 / Hash / 私钥 / Token / 连接串' },
secret_ref: { type: 'string', description: '证据引用路径,例如 runs/cred-vnc-10.0.0.5.txt' },
privilege: { type: 'string', description: '该凭据的权限级别,如 admin / user / db-read' },
asset_id: { type: 'number' },
source: { type: 'string', description: '来源:exploit / dump / config-leak …' },
tool: { type: 'string' },
note: { type: 'string' },
found_by_agent: { type: 'string' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.addCredential(id, { ...args, engagement: undefined, agent: agentOf(args) })
return JSON.stringify({ ok: true, engagement: id, ...result }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_shot_add',
description: '登记一张证据截图:把图片拷进靶标目录并关联到得分点/命中(面板展开得分点即可看图,报告里也会嵌入)。批量得分点(如终端权限)每个得分点最多展示 3 张代表图,不必逐台截图。',
parameters: {
engagement: { type: 'string' },
agent: AGENT_PARAM,
path: { type: 'string', description: '图片在本机的路径(相对靶标目录或绝对路径);与 data_base64 二选一' },
data_base64: { type: 'string', description: '图片内容(base64,可带 data: 前缀);截图工具没落盘时用这个' },
label: { type: 'string', description: '图片说明,例如「域控 WEB-SHDC1 atexec 回显 nt authority\\system」' },
kind: { type: 'string', description: 'screenshot(现场截图,默认)| card(由记录生成的证据摘录卡,报告里会标注)' },
point_code: { type: 'string', description: '关联的得分点 code,如 terminal-access / central-system' },
hit_id: { type: 'number', description: '关联到具体某条得分命中(score_hit id);给了它这张图就只挂在那一条上' },
asset_id: { type: 'number' },
target: { type: 'string', description: '这张图对应的目标(IP / URL)' },
note: { type: 'string', description: '补充说明:这张图证明了什么' },
source_ref: { type: 'string', description: '原始来源:截图的落盘路径或 runs/ 下的证据文件' },
captured_at: { type: 'string', description: '截图时间(ISO),缺省用当前时间' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.addShot(id, { ...args, engagement: undefined, agent: agentOf(args) })
return JSON.stringify({ ok: true, engagement: id, ...result }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_shot_list',
description: '列出已登记的证据截图(含所属得分点、目标、类型、落盘文件)。',
parameters: {
engagement: { type: 'string' },
point_code: { type: 'string' },
hit_id: { type: 'number' },
asset_id: { type: 'number' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
return JSON.stringify({ ok: true, engagement: id, ...store.listShots(id, {
point_code: args.point_code, hit_id: args.hit_id, asset_id: args.asset_id,
}) }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_shot_delete',
description: '删除一张证据截图(连同磁盘文件)。',
parameters: {
engagement: { type: 'string' },
id: { type: 'number', required: true, description: 'shot id(redteam_shot_list 里返回的 id)' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const eng = resolveEngagement(store, exec, args.engagement)
return JSON.stringify({ ok: true, engagement: eng, ...store.deleteShot(eng, args.id) }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_credential_list',
description: '列出已收集的凭据(含明文 secret_value),用于凭据复用与横向移动。',
parameters: {
engagement: { type: 'string' },
host: { type: 'string' },
username: { type: 'string' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
return JSON.stringify({ ok: true, engagement: id, items: store.listCredentials(id, { host: args.host, username: args.username }) }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_access_add',
description: '记录一次成功获得的访问会话(横向移动起点)。method 例如 vnc/rdp/ssh/web-shell/webshell/db;privilege 例如 admin/user/system。',
parameters: {
engagement: { type: 'string' },
host: { type: 'string', required: true },
username: { type: 'string' },
method: { type: 'string', required: true, description: '获得访问的方式' },
privilege: { type: 'string' },
asset_id: { type: 'number' },
session_ref: { type: 'string', description: '会话/证据引用,例如 runs/session-vnc-10.0.0.5.md' },
note: { type: 'string' },
found_by_agent: { type: 'string' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.addAccess(id, { ...args, engagement: undefined })
return JSON.stringify({ ok: true, engagement: id, ...result }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_access_list',
description: '列出已获得的访问会话。',
parameters: {
engagement: { type: 'string' },
host: { type: 'string' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
return JSON.stringify({ ok: true, engagement: id, items: store.listAccess(id, { host: args.host }) }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_role_prompt_reset',
description: '把角色系统提示词恢复为内置默认(新版模板)。老靶标想用上最新版角色提示词时用它;省略 role 则四个角色全部重置。',
parameters: {
engagement: { type: 'string' },
role: { type: 'string', /* 角色白名单**从 ROLE_ORDER 派生**:写死 4 个值曾漏掉 assess,
而预设明确要求主会话用本工具取资产梳理角色的提示词 —— 结果 enum 直接拒绝,
资产梳理子智能体永远拿不到角色约束。派生后新增角色不会再漏。 */
enum: ROLE_ORDER },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.resetPrompts(id, args.role)
return JSON.stringify({ ok: true, engagement: id, ...result }, null, 2)
},
})))
/* ---------- WebShell 与内网隧道:打的过程中随时复用,避免"打到后面忘了还有入口" ---------- */
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_sessions',
description: '【每次决策前后都要看】一屏总览当前所有可复用入口:已上线的 WebShell、可用的内网隧道(含监听地址与可达网段)、凭据、访问会话,并给出在线/离线统计。打内网前先看这里,不要重复造轮子,也不要忘记已有的隧道。',
parameters: { engagement: { type: 'string' } },
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const summary = store.sessionSummary(id)
return JSON.stringify({
ok: true, engagement: id, totals: summary.totals,
webshells: summary.webshells.map((w) => ({
id: w.id, url: w.url, type: w.shell_type, pass_key: w.pass_key, privilege: w.privilege,
status: w.status, asset_ip: w.asset_ip, last_check: w.last_check, note: w.note,
})),
tunnels: summary.tunnels.map((t) => ({
id: t.id, kind: t.kind, listen: t.listen, entry: t.entry, reach: t.reach,
entry_kind: t.entry_kind, legit: t.legit,
status: t.status, asset_ip: t.asset_ip, command: t.command, last_check: t.last_check, note: t.note,
})),
hint: '隧道 status=active 时可直接给扫描器用:-socks5 <listen> 或 --proxy socks5://<listen>;webshell status=online 时用对应客户端连接。'
+ ' 注意 legit=false(entry_kind=self-only,只在自己的 VPS/自建服务器上)**不算跨越靶标边界、不算突破**;legit=null 表示未声明 entry_kind,用 redteam_tunnel_update 补上目标侧那一端。',
}, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_webshell_add',
description: '登记一个已上线的 WebShell。**必须上传冰蝎马(behinder)或哥斯拉马(godzilla)的加密马**——用户在控制台要用对应客户端直连,一句话马/自研马/MemShell 用户连不上,不算可交付的入口。shell_type 只能填 godzilla | behinder;确实只能用其它形式时填 other 并在 note 里写清为什么。同一 url+pass_key 重复登记会合并刷新。登记后所有角色智能体都能复用它,不要重复打点。',
parameters: {
engagement: { type: 'string' },
agent: AGENT_PARAM,
url: { type: 'string', required: true, description: 'WebShell 完整 URL' },
shell_type: { type: 'string', description: 'godzilla(哥斯拉)| behinder(冰蝎);其它形式才用 antsword/other 并说明原因' },
pass_key: { type: 'string', description: '连接密码 / 密钥(冰蝎马写 pass,哥斯拉写 key)' },
privilege: { type: 'string', description: '当前权限,例如 www-data / root / iis' },
secret_ref: { type: 'string', description: '凭据/证据引用,例如 runs/ws-10.0.0.5.txt(不要把明文口令写进库)' },
asset_id: { type: 'number' },
note: { type: 'string' },
found_by_agent: { type: 'string' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.addWebshell(id, { ...args, engagement: undefined, status: 'online', agent: agentOf(args) })
return JSON.stringify({ ok: true, engagement: id, ...result }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_webshell_list',
description: '列出已登记的 WebShell(含在线状态与最后检查时间)。',
parameters: {
engagement: { type: 'string' },
status: { type: 'string', description: 'online | offline | unknown' },
asset_id: { type: 'number' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
return JSON.stringify({ ok: true, engagement: id, items: store.listWebshells(id, args) }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_webshell_update',
description: '更新 WebShell 状态(被删/掉线/权限变化)或补充说明。',
parameters: {
engagement: { type: 'string' },
id: { type: 'number', required: true, description: 'WebShell 记录 id' },
status: { type: 'string', description: 'online | offline | dead | unknown' },
privilege: { type: 'string' },
note: { type: 'string' },
check_note: { type: 'string' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const eng = resolveEngagement(store, exec, args.engagement)
const result = store.updateWebshell(eng, args.id, args)
return JSON.stringify({ ok: true, engagement: eng, ...result }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_tunnel_add',
description: '登记一条内网隧道。**打进内网必须先用技能 suo5-tunnel 通过 WebShell/HTTP 建 socks5(kind=suo5)**;没有隧道就不要手搓内网探测脚本。listen 写本机可用地址(如 127.0.0.1:1080),reach 写它能到达的网段。登记后扫描器可直接 -socks5 <listen>。\n\n**红线:自己的 VPS / 自己配置的服务器不算隧道。** 只在你自己服务器上开的 socks5、frp 服务端、代理,没有碰到目标,**不算跨越靶标边界、不算边界突破或内网突破**(这类填 entry_kind=self-only,会被标成"不算突破")。**必须说清目标侧的那一端**:\n· target-outbound — 目标主动连出到我方(**自己服务器收目标反弹 shell**、目标上跑 frp/Stowaway 客户端);\n· target-http — 经目标 WebShell/HTTP 通道(suo5、Neo-ReGeorg、reGeorg);\n· target-agent — 经目标已控进程/会话转发(SSH -R 由目标发起等)。',
parameters: {
engagement: { type: 'string' },
agent: AGENT_PARAM,
kind: { type: 'string', required: true, description: 'suo5(首选,走 WebShell/HTTP)| socks5 | ssh-r | frp | chisel | other' },
listen: { type: 'string', required: true, description: '本地监听地址 host:port' },
entry: { type: 'string', description: '入口:WebShell URL / 跳板机 / 命令' },
reach: { type: 'string', description: '可达网段,例如 10.0.0.0/8' },
entry_kind: { type: 'string', description: '【重要】通道的目标侧那一端是什么:target-outbound(目标反弹 shell 到我方/目标上跑 frp 客户端)| target-http(经目标 WebShell 的 suo5/Neo-ReGeorg)| target-agent(经目标已控进程转发)| self-only(只在自己 VPS/自建服务器上,**不算突破**)' },
webshell_id: { type: 'number', description: '由哪个 WebShell 建立' },
asset_id: { type: 'number' },
command: { type: 'string', description: '建立命令,便于重建' },
pid: { type: 'string' },
note: { type: 'string' },
found_by_agent: { type: 'string' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.addTunnel(id, { ...args, engagement: undefined, status: 'active', agent: agentOf(args) })
return JSON.stringify({ ok: true, engagement: id, ...result }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_tunnel_list',
description: '列出已登记的内网隧道(含状态、监听地址、可达网段、entry_kind 与 legit 判定)。**legit=false 表示只在自己 VPS/自建服务器上开的通道,不算跨越靶标边界、不算突破**;legit=null 表示未声明 entry_kind。',
parameters: {
engagement: { type: 'string' },
status: { type: 'string', description: 'active | down | unknown' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
return JSON.stringify({ ok: true, engagement: id, items: store.listTunnels(id, args) }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_tunnel_update',
description: '更新隧道状态(关闭/失效/更换监听地址)或补 entry_kind 判定。用完隧道务必标为 down 并说明,避免后续误用。**老记录没声明入口归属的(legit=null,界面显示"待确认"),用 entry_kind 补上目标侧那一端**,补完才算突破凭证。',
parameters: {
engagement: { type: 'string' },
id: { type: 'number', required: true },
status: { type: 'string', description: 'active | down | closed | unknown' },
listen: { type: 'string' },
reach: { type: 'string' },
entry: { type: 'string', description: '入口:WebShell URL / 跳板机 / 命令' },
entry_kind: { type: 'string', description: '补声明目标侧那一端:target-outbound | target-http | target-agent | self-only(只在自己服务器上,不算突破)' },
note: { type: 'string' },
check_note: { type: 'string' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const eng = resolveEngagement(store, exec, args.engagement)
const result = store.updateTunnel(eng, args.id, args)
return JSON.stringify({ ok: true, engagement: eng, ...result }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_session_check',
description: '实测所有 WebShell 与隧道的连通性(由 host 侧真实发起 HTTP / TCP 连接),并把在线/离线状态回写数据库。开工前和长时间任务后各跑一次。',
parameters: {
engagement: { type: 'string' },
timeoutMs: { type: 'number', description: '单次探测超时,默认 6000ms' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = await store.probeSessions(id, { timeoutMs: args.timeoutMs })
const online = result.webshells.filter((w) => w.status === 'online').length
const active = result.tunnels.filter((t) => t.status === 'active').length
return JSON.stringify({
ok: true, engagement: id, checked_at: result.checkedAt,
summary: `WebShell 在线 ${online}/${result.webshells.length},隧道可用 ${active}/${result.tunnels.length}`,
webshells: result.webshells, tunnels: result.tunnels,
}, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_attack_path',
description: '导出攻击图谱:资产拓扑 + 漏洞节点 + 已控制资产(meta.owned)。用于横向移动路径规划与战果汇报。节点过多时用 cidr 缩小范围。',
parameters: {
engagement: { type: 'string' },
cidr: { type: 'string' },
maxNodes: { type: 'number', description: '默认 400' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const maxNodes = Math.min(Number(args.maxNodes) || 400, 1500)
const graph = store.attackGraph(id, { cidr: args.cidr })
if (graph.nodes.length > maxNodes) {
return JSON.stringify({
ok: false,
error: '图谱节点过多(' + graph.nodes.length + ' > ' + maxNodes + '),请指定 cidr 缩小范围',
summary: graph.summary,
}, null, 2)
}
return JSON.stringify({
ok: true, engagement: id, summary: graph.summary,
owned: graph.nodes.filter((n) => n.meta && n.meta.owned).map((n) => n.label),
nodes: graph.nodes, edges: graph.edges,
}, null, 2)
},
})))
/* ================================================================== 域名 / Web 资产 */
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_web_list',
description: '列出 Web 资产(含可直接访问的 URL 与页面标题)。做 Web 渗透前先看这里,优先从接口入手。',
parameters: {
engagement: { type: 'string' },
cidr: { type: 'string' },
limit: { type: 'number' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.listWeb(id, { cidr: args.cidr, limit: args.limit })
return JSON.stringify({
ok: true, engagement: id, total: result.total,
items: result.items.map((w) => ({
url: w.url || ('http' + (w.port === 443 || w.port === 8443 || w.port === 9443 ? 's' : '') + '://' + w.ip + (w.port === 80 || w.port === 443 ? '' : ':' + w.port)),
title: w.title, ip: w.ip, segment: w.segment_cidr, port: w.port,
service: [w.service, w.product, w.version].filter(Boolean).join(' '),
provenance: w.provenance, asset_id: w.asset_id,
})),
}, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_domain_index',
description: '按域名维度聚合资产:每个域名关联了哪些 IP/资产。用于梳理主域名、子域与 C 段的关系。',
parameters: { engagement: { type: 'string' } },
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
return JSON.stringify({ ok: true, engagement: id, items: store.domainIndex(id) }, null, 2)
},
})))
/* ================================================================== 得分目标 */
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_score_list',
description: '查看得分目标面板:所有得分点(名称/分类/分值/是否已拿下/命中证据)与总分进度。**每次规划下一步之前先看这里**,按分值高低决定先打什么。',
parameters: { engagement: { type: 'string' } },
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.listScorePoints(id, {})
/* 按合并版的 8 个类别分组返回:智能体一眼看到"哪些类别还没打、每项上限用了多少" */
const groups = (result.ruleGroups || []).map((g) => ({
category: g.name,
items: g.tiers.length,
points: g.points,
cap_sum: g.capSum,
}))
return JSON.stringify({
ok: true, engagement: id, summary: result.summary,
groups,
items: result.items.map((p) => ({
id: p.id, code: p.code, name: p.name, category: p.category, points: p.points,
/* 合并版的档位说明(一条含多档时,记分要用 points 参数指定本档分值) */
tier: p.tier, cap: p.cap, cap_used: p.cap_used, dedup_scope: p.dedup_scope,
scope_label: p.scope_label,
enabled: p.enabled, achieved: p.hits.length > 0, hits: p.hits.length,
counted: p.counted, earned: p.earned,
/* 该条因计分口径 / 已达上限而不计分的条数(把它当停止信号,别在同一条上刷) */
capped: p.capped,
/* 两个字段各司其职:service_summary 是"这条为什么有命中不计分"的整段说明;
capped_reason 留给**逐条命中**的具体原因(在 hits[] 里),不要拿摘要冒充它。 */
capped_summary: p.service_summary,
evidence: p.hits.map((h) => (h.target ? h.target + ':' : '') + h.evidence).slice(0, 3),
})),
}, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_score_hit',
description: '记录一次得分。得分规则已按《突破入侵类得分规则(合并版)》重构为 **25 项**(一、获取权限 21 项 + 二、突破网络边界 4 项,共 8 个类别),'
+ '另有 8 条通用规则 G1–G8 横切全部条目。**记分前先用 `redteam_score_list` 读实际 code 与该条的上限/口径**,不要凭记忆写。\n\n'
+ '**证据只写结果**:目标资产 + 拿到了什么(账号/密码/权限/数据量),不要写取得过程与路径——过程由攻击得分链路的步骤负责。'
+ '能指向"用哪个漏洞拿到的"时带上 vuln_id,报告会自动附上该漏洞的原始请求。\n\n'
+ '**多档条目用 `points` 指定本档分值**:《合并版》把同一项的多个档位并成一条(如「服务器主机权限」普通 10 / 管理员 50、'
+ '「域名控制权限」一级 50 / 二级 20、「网络设备权限」普通 100 / 管理员 200)。记分时把 `points` 填成本次实际档位的分值;'
+ '不填就用该条的主档默认值。**同一系统只按最高权限计一次**(G1)——先记了普通档、后来提权到管理员档,'
+ '再记一条管理员档(points 填高档值),系统会自动顶掉普通档那条。\n\n'
+ '**红线一:账号类得分必须先实测能登录。** 拿到账号/口令后要用浏览器(browser-automation / kimi-webbridge)或等价会话'
+ '实测登录成功、能交互访问页面,才记账号权限分——**只有凭据不算拿到账号**;登不进去的写进 redteam_asset_test 的 `test` 参数。\n\n'
+ '**红线二:自己注册的账号不算得分权限。** 自助注册、自己新建的用户/角色/后台账号、自己给自己开的权限都不算——'
+ '演练得分针对**拿到别人已有的**账号与权限。这类用 self_created=true 记录(留过程),不计分、不计数、不进报告。\n\n'
+ '**红线三:注意该条的计分口径(G1/G3)。** 每条得分点自带口径:'
+ '`同一系统只算最高权限一次`/`同一服务只算最高一条`/`整个目标只算一次`(突破网络边界)/`按台·卡·个累加`(终端、云节点、算力卡)。'
+ '且每条有自己的**上限**(如服务器主机 600 分、Web 应用 2000 分、集权系统 4000 分),到上限后同条不再累计(G3)——'
+ '返回里会用 warning 告诉你"已达上限/已被顶掉",把它当**停止信号**,换别的条目或别的资产推进,不要在同一条上刷。\n\n'
+ '**数据成果单独计分(G2)**:邮件数据、业务数据、数据资产等按重要程度另行记录,不与权限分混算;'
+ 'evidence 必须写出**实际数据量**(如「导出 1,320,000 条用户数据」)。',
parameters: {
engagement: { type: 'string' },
code: { type: 'string', description: '得分点 code(或 point_id / point_name 任选其一);先用 redteam_score_list 核对实际 code' },
point_id: { type: 'number' },
point_name: { type: 'string' },
points: { type: 'number', description: '【多档条目必填】本次命中按哪一档计分(如服务器主机权限:普通权限填 10、管理员填 50;域名控制:一级 50、二级 20)。不填则用该条主档默认值。' },
target: { type: 'string', description: '目标资产:URL / ip:port / 主机名(**带上端口**,服务/系统口径按它判定)' },
asset_id: { type: 'number', description: '目标资产在库里的 id' },
port: { type: 'number', description: '【建议填】这条得分落在哪个端口(口径判定按「资产 + 端口」;target 里已带端口时可不填)' },
vuln_id: { type: 'number', description: '【建议填】用哪个漏洞拿到的分(报告据此附原始请求)' },
step_id: { type: 'number', description: '对应的攻击链步骤 id(可选)' },
evidence: { type: 'string', required: true, description: '【必填】结果:拿到的东西,例如「后台管理员 tomcat/Tomcat@2024(已实测浏览器可登录)」「数据库账号 root/xxx」「导出 132 万条用户数据」。多档条目请写明档位(如"管理员权限")。' },
self_created: { type: 'boolean', description: '【重要】这个账号/权限是不是**自己注册、自己创建**的?是则填 true —— 只作过程记录,不计分、不进报告。拿到别人已有的账号/权限不要填(默认 false)。' },
note: { type: 'string' },
recorded_by: { type: 'string' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.addScoreHit(id, { ...args, engagement: undefined })
return JSON.stringify({ ok: true, engagement: id, ...result }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_score_point_save',
description: '新增或修改得分点(分值/名称/分类/说明/启用)。用户也会在界面上编辑;智能体只在必要时用(例如发现规则里还有未被记录的得分项)。带 id 为修改,不带 id 为新增。',
parameters: {
engagement: { type: 'string' },
id: { type: 'number', description: '已有得分点 id(修改时传)' },
name: { type: 'string', required: true },
code: { type: 'string', description: '短代码(新增时建议给,便于记录得分)' },
category: { type: 'string', description: '分类,如 账号权限 / 服务器权限 / 数据库 / 网络突破 / 数据 / 核心目标' },
points: { type: 'number', description: '分值' },
description: { type: 'string', description: '得分条件说明' },
enabled: { type: 'boolean' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.saveScorePoint(id, { ...args, engagement: undefined })
return JSON.stringify({ ok: true, engagement: id, ...result }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_asset_assess',
description: '给资产做「易打性评估」:预期能拿下哪些成果(账号权限/RCE/服务器权限/数据库权限/敏感数据/边界突破)、优先级多高、判断理由。信息收集收口时对每个资产调用一次,供指挥者按性价比排序。',
parameters: {
engagement: { type: 'string' },
asset_id: { type: 'number' },
ip: { type: 'string' },
priority: { type: 'string', enum: ['high', 'medium', 'low'], description: 'high 容易出成果 / medium 一般 / low 难打或价值低' },
potential: { type: 'string', description: '预期成果,如「账号权限+RCE」「数据库权限」「大量敏感信息」' },
reason: { type: 'string', description: '判断理由:指纹版本命中 Nday、接口未鉴权、口令弱、暴露数据库、WAF 强弱等' },
assessed_by: { type: 'string' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.assessAsset(id, { ...args, engagement: undefined })
return JSON.stringify({ ok: true, engagement: id, ...result }, null, 2)
},
})))
/* ================================================================== 证据 / 攻击链 / 报告 */
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_http_evidence_add',
description: '保存一条 HTTP 证据:原始请求与响应。报告会把它渲染成可粘贴进 Burp Suite / Yakit 的原始报文,因此 request 必须是完整可重放的原始请求(含请求行、Host 等头部、必要时 body)。',
parameters: {
engagement: { type: 'string' },
vuln_id: { type: 'number', description: '关联的漏洞 id(建议填)' },
asset_id: { type: 'number' },
label: { type: 'string', description: '这条证据的用途,例如「越权读取用户列表」' },
method: { type: 'string', description: 'GET/POST/…' },
url: { type: 'string' },
status: { type: 'number', description: '响应状态码' },
request: { type: 'string', required: true, description: '原始请求全文(Burp 可直接粘贴)' },
response: { type: 'string', description: '响应全文或关键片段' },
note: { type: 'string' },
captured_by: { type: 'string', description: '采集角色' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.addHttpEvidence(id, { ...args, engagement: undefined })
return JSON.stringify({ ok: true, engagement: id, ...result }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_chain_add',
description: '记录一个攻击链步骤。**报告里「这一步怎么来的」完全靠这条记录**:账号密码怎么来的、冰蝎马怎么上的、隧道怎么搭的,都要在这里用 `tool`(实际命令原文)+ `detail`(为什么这么做、线索从哪来)+ `result`(实际回显/结果)写清楚。攻击链页面与报告按 seq 排序展示。',
parameters: {
engagement: { type: 'string' },
agent: AGENT_PARAM,
stage: { type: 'string', required: true, enum: ['recon', 'vuln', 'exploit', 'access', 'pivot', 'data', 'other'], description: '动作类型(兼容字段):recon 信息收集 | vuln 漏洞发现 | exploit 利用 | access 获得权限 | pivot 内网突破 | data 敏感数据 | other。**阶段归属用 stage_code**,这个字段只用于老数据兜底。' },
title: { type: 'string', required: true, description: '一句话描述这一步做了什么,例如「通过后台模板上传点上传冰蝎马」「用 suo5 建 socks5 隧道」' },
detail: { type: 'string', description: '为什么这么做、线索从哪来,例如「登录页泄露版本 Coremail XT 5.0 → 匹配 CVE-2023-xxxx → 后台模板管理可上传」' },
tool: { type: 'string', description: '【报告复现的关键】**实际执行的命令原文**,例如 `nuclei -t CVE-2021-xxxx.yaml -u http://x`、`suo5-linux-amd64 -t http://x/shell.jsp -l 1080`、`sqlmap -u "http://x?id=1" --dump`。不写这条,报告里这一步就没法复现。' },
result: { type: 'string', description: '【报告复现的关键】实际结果/回显摘要,例如 `uid=0(root) gid=0(root)`、「返回 200,含 1.2 万条用户数据」、「后台管理员 tomcat 登录成功」' },
asset_id: { type: 'number' },
vuln_id: { type: 'number' },
access_id: { type: 'number' },
evidence_ref: { type: 'string', description: '证据文件/会话引用,例如 runs/session-vnc.md' },
recorded_by: { type: 'string' },
point_code: { type: 'string', description: '【这一步拿了分就填】得分点 code(如 rce / webshell / boundary),服务端会自动记一次分并把步骤与得分互相挂上。**必须同时给 evidence,否则不会记分**(步骤照常入库)。' },
stage_code: { type: 'string', enum: ['recon', 'internet', 'boundary', 'internal', 'target'], description: '所属作战阶段(只有这 5 个合法值):recon(信息收集) | internet(互联网资产权限) | boundary(边界突破·搭隧道) | internal(内网资产权限) | target(靶标系统权限)。写别的值(如外部工具常见的 external/foothold/tunnel/privilege)会被忽略并退回按 stage 兜底,步骤就不落在任何阶段。' },
evidence: { type: 'string', description: '配合 point_code 使用:这一分拿到了什么(目标资产 + 账号/权限/数据量)——**不填就不记分**。' },
self_created: { type: 'boolean', description: '配合 point_code 使用:这一步拿到的账号/权限是**自己注册/自建**的吗?是则 true(只留过程,不计分)' },
target: { type: 'string', description: '配合 point_code 使用:目标资产' },
seq: { type: 'number', description: '不填则自动追加到链尾' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.addChainStep(id, { ...args, engagement: undefined, agent: agentOf(args), recorded_by: args.recorded_by || args.agent })
/* 步骤落库后提醒两件事:报告要用的 tool/result 有没有写、这一步的得分有没有记 */
const hints = []
if (!args.tool) hints.push('这一步没写 `tool`(实际命令):报告里"怎么做的"会变成空白,建议补一条步骤把命令写上。')
if (!args.result) hints.push('这一步没写 `result`(实际回显/结果):报告复现时会缺"打没打通"的证据。')
if (result && result.score_hint) hints.push(result.score_hint)
return JSON.stringify({ ok: true, engagement: id, ...result, hints: hints.length > 0 ? hints : undefined }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_chain',
description: '读取完整攻击链(按步骤顺序),用于汇报与检查链路是否闭合(入口 → 权限 → 内网突破)。',
parameters: { engagement: { type: 'string' } },
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
return JSON.stringify({ ok: true, engagement: id, items: store.listChain(id) }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_attack_file_add',
description: '保存一个**实际生效**的攻击文件(脚本/POC/EXP/字典)到目标文件夹,供复用与交付。目录结构:attack-files/<IP|URL主机|C段>/<文件名>。evidence 必填且必须写清验证效果(例如"回显 uid=0"、"未授权返回 200 并含数据")——**没打通的、只是尝试过的脚本不要放进来**。',
parameters: {
engagement: { type: 'string' },
target: { type: 'string', required: true, description: '目标:IP、URL 或 C 段(同一 IP 的多个端口会归到该 IP 文件夹)' },
name: { type: 'string', required: true, description: '文件名,例如 cve-2021-22893.sh / vnc_brute.py' },
kind: { type: 'string', enum: ['poc', 'exp', 'script', 'wordlist', 'other'], description: '类型:poc 验证 / exp 利用 / script 脚本 / wordlist 字典' },
description: { type: 'string', description: '这个文件做什么(一句话)' },
evidence: { type: 'string', required: true, description: '有效性证据:实际打通的输出/回显/影响,或对应漏洞/证据 id' },
content: { type: 'string', description: '文件内容(与 path 二选一)' },
path: { type: 'string', description: '已存在的文件路径(相对靶标目录或绝对路径),会复制进目标文件夹(与 content 二选一)' },
asset_id: { type: 'number' },
vuln_id: { type: 'number' },
created_by: { type: 'string' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.addAttackFile(id, { ...args, engagement: undefined })
return JSON.stringify({ ok: true, engagement: id, ...result }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_attack_file_list',
description: '列出已保存的攻击文件(按目标文件夹分组)。开始打某个目标前先看这里,避免重复造轮子。',
parameters: {
engagement: { type: 'string' },
target: { type: 'string', description: '只看某个目标' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
if (args.target) return JSON.stringify({ ok: true, engagement: id, items: store.listAttackFiles(id, { target: args.target }) }, null, 2)
return JSON.stringify({ ok: true, engagement: id, folders: store.attackFileTree(id) }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_report_targets',
description: '【已弃用,改用 redteam_score_report】按目标查看成果报告(每个 IP / URL / C 段一份):成果漏洞(含可粘贴进 Burp/Yakit 的原始请求)、已获权限、凭据、攻击文件、攻击链。用于按目标汇报或检查某个目标还缺什么。',
parameters: {
engagement: { type: 'string' },
target: { type: 'string', description: '只看某个目标的报告' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.reportTargets(id, {})
const picked = args.target ? result.targets.filter((t) => t.key === args.target || t.label === args.target) : result.targets
return JSON.stringify({
ok: true, engagement: id, generated_at: result.generated_at, totals: result.totals,
targets: picked.map((t) => ({ key: t.key, label: t.label, segment: t.segment, stats: t.stats })),
}, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_attack_chain',
description: '攻击链:按攻击面位置串起来的五个阶段 —— ① 信息收集(互联网侧)→ ② 互联网资产权限 → ③ 边界突破(搭隧道)→ ④ 内网资产权限 → ⑤ 靶标权限。返回每阶段的阶段目标、**实际拿到多少分与累计分**、涉及资产、以及边界突破阶段的真实隧道。开工时看它明确"现在在第几阶段、下一步该打哪一阶段",汇报时按阶段给结论。',
parameters: { engagement: { type: 'string' } },
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const chain = store.scoreChain(id)
return JSON.stringify({
ok: true, engagement: id,
totals: { points: chain.summary.points, totalPoints: chain.summary.totalPoints, hits: chain.summary.hits,
/* 按服务封顶(同资产同端口只算一次)而没计分的条数:账号类/数据库权限 */
serviceCappedHits: chain.summary.serviceCappedHits || 0 },
stages: (chain.stages || []).map((st) => ({
code: st.code, name: st.name, goal: st.goal,
points: st.points, cumulative: st.cumulative, counted: st.counted, hits: st.hits,
steps: st.steps, tools: st.tools,
methods: (st.sections || []).map((x) => x.label + ':' + (x.items || []).join('、')),
scored: st.items.map((x) => ({ point: x.point_name, points: x.points, counted: x.counted, target: x.target,
service: x.service || null, capped: x.capped === true, capped_reason: x.capped_reason || null })),
assets: (st.assets || []).map((a) => a.ip + (a.scope === 'internal' ? '(内网)' : '(外网)') + ' 贡献' + a.points + '分'),
tunnels: (st.tunnels || []).map((t) => t.kind + ' ' + t.listen + ' [' + t.status + '] 可达 ' + (t.reach || '—')),
})),
hint: '得分阶段是自动推导的(core-system→靶标、boundary→边界突破,其余按资产内外网归属);写攻击链步骤时带 stage_code(只接受 recon/internet/boundary/internal/target)步骤计数才会落到正确阶段,写别的值会被忽略。账号权限与数据库权限按「同资产同端口」封顶:一个服务拿到最高权限即拿满,同服务重复命中标 capped=true 且不计分。',
}, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_score_report',
description: '攻击得分链路复现报告:只收录"拿到了分"的成果(没得分的漏洞不进报告),每一项都尽量附上可直接粘贴进 Yakit Repeater 复现的原始请求。需要交付报告时用这个,而不是 redteam_report。',
parameters: {
engagement: { type: 'string' },
limit: { type: 'number', description: '最多多少项,默认 500' },
markdown: { type: 'boolean', description: 'true=返回 markdown 全文(默认 true);false=只返回条目摘要' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const r = store.scoreReport(id, { limit: args.limit })
if (args.markdown === false) {
return JSON.stringify({ ok: true, engagement: id, summary: r.summary,
items: r.items.map((x) => ({ seq: x.seq, point: x.point_name, points: x.points, counted: x.counted,
target: x.target, gained: x.gained, requests: x.requests.length, missing_evidence: x.missing_evidence })) }, null, 2)
}
return JSON.stringify({ ok: true, engagement: id, summary: r.summary, markdown: r.markdown }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_report',
description: '【已弃用,改用 redteam_score_report】生成成果报告(Markdown):只收录**已验证/已利用且中危以上**的成果漏洞(每条附可粘贴进 Burp/Yakit 的原始请求)、攻击链、已获权限与凭据、修复建议。信息收集的资产清单、待验证/误报、低危与信息级水洞都不会出现在报告里。想让发现进报告:先 redteam_vuln_update 置为 confirmed(验证通过)或 exploited(利用成功),再 redteam_http_evidence_add 补原始请求。',
parameters: { engagement: { type: 'string' } },
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const result = store.report(id)
return JSON.stringify({ ok: true, engagement: id, generated_at: result.generated_at, stats: result.stats, markdown: result.markdown }, null, 2)
},
})))
/* ── 知识库(POC/EXP):全局共享,跨靶标复用 ───────────────────────────────
为什么单列一组工具:通用 POC/EXP 是一次性投入、长期复用的资产。打 Nday/1day
之前先查这里,能省掉整轮"去互联网找 + 手搓 + 调试"的时间;验证有效的通用
POC/EXP 必须回填,后面的靶标和智能体直接就能用。 */
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_poc_search',
description: '【打 Nday/1day 前的第一步】先查"现成的"两层:① **知识库**(本机沉淀的通用 POC/EXP,跨靶标共享,按"已验证 → 复用次数"排序)② **本机 nuclei 模板库**(上万条模板,CVE/组件名直接命中)。命中就取用:知识库用 redteam_poc_get 拿全文,模板直接 `nuclei -t <相对路径>`。**不要重复去互联网找或重新手搓**。两层都没有,再去互联网搜索或自己手搓,验证有效后用 redteam_poc_add 回填知识库。',
parameters: {
q: { type: 'string', description: '关键字:组件+版本、漏洞名、路径片段、正文里的特征串都行' },
cve: { type: 'string', description: 'CVE / CNVD 编号,例如 CVE-2023-21839' },
component: { type: 'string', description: '组件/产品名,例如 Weblogic、Shiro、泛微 OA、Nacos' },
kind: { type: 'string', description: 'poc | exp | script | template | payload' },
category: { type: 'string', description: '按**归类**筛(可多值,逗号分隔):rce | deserialization | file-upload | sqli | unauthorized | auth-bypass | weak-password | ssrf | xxe | path-traversal | info-leak | privesc | tunnel | other' },
engagement: { type: 'string', description: '按**来源靶标**筛(靶标 id 或名称关键词):只看"在某个单位上验证过的"经验' },
asset_target: { type: 'string', description: '按**发现资产**筛(IP / 域名 / URL 片段)' },
language: { type: 'string', description: 'python | go | java | bash | http | nuclei | js | php' },
source: { type: 'string', description: 'web(互联网)| self(手搓)| manual(人工)| nuclei-template' },
verified: { type: 'boolean', description: 'true 只看实测验证过的(优先用这些)' },
templateLimit: { type: 'number', description: '本机 nuclei 模板最多返回几条,默认 20' },
limit: { type: 'number', description: '知识库条目上限,默认 200' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args) {
const items = store.searchPocs(args)
const stats = store.pocStats()
const q = args.q || args.cve || args.component || ''
/* templateStats() / nucleiTemplatesDir() 都要扫模板目录:**一次调用只算一次**。
原来这一小段里 templateStats() 被连着算了三遍(13k 模板的目录每次都要走一遍)。 */
const tplStats = store.templateStats()
const tpl = (q || args.category)
? store.searchTemplates(q, Math.min(Number(args.templateLimit) || 20, 100), 0, args.category || '')
: { dir: tplStats.dir || store.nucleiTemplatesDir() || null, total: tplStats.total, items: [], byCategory: tplStats.byCategory }
const hit = items.length > 0 || (tpl.items || []).length > 0
return JSON.stringify({
ok: true,
knowledge_base: {
total: stats.total, verified: stats.verified, reused: stats.reused,
/* 归类概览:让智能体知道"哪类武器已经攒了多少",缺哪类心里有数 */
by_category: (stats.byCategory || []).filter((c) => c.n > 0).map((c) => c.name + ' ' + c.n + '(已验证 ' + c.verified + ')'),
by_engagement: (stats.byEngagement || []).slice(0, 10).map((e) => e.engagement + ' ' + e.n),
},
local_templates: {
dir: tplStats.dir, total: tplStats.total, cve_templates: tplStats.cve, matched: (tpl.items || []).length,
by_category: (tplStats.byCategory || []).filter((c) => c.n > 0).map((c) => c.name + ' ' + c.n),
},
count: items.length,
hint: hit
? '命中现成的:知识库条目用 redteam_poc_get 取全文;nuclei 模板直接用 `nuclei -t <模板相对路径>`。用完 redteam_poc_use 记一次复用。'
: '知识库与本机模板库都没有:去互联网搜索(web_search / GitHub / ExploitDB / 厂商公告)或自己手搓,验证有效后务必 redteam_poc_add 回填知识库(带 category + engagement + asset_target + verified_note)。',
templates: (tpl.items || []).map((t) => ({ path: t.path, name: t.name, severity: t.severity, tags: t.tags })),
items: items.map((x) => ({
id: x.id, code: x.code, title: x.title, kind: x.kind, category: x.category, cve: x.cve, component: x.component,
versions: x.versions, language: x.language, source: x.source, source_url: x.source_url,
verified: x.verified === 1, verified_note: x.verified_note, hit_count: x.hit_count,
/* 来源溯源:哪条经验、哪个靶标、哪台资产、什么时候建的 */
engagement: x.engagement_name || x.engagement_id || null, asset_target: x.asset_target || null,
found_by_agent: x.found_by_agent || null, created_at: x.created_at,
tags: x.tags, usage: x.usage, path: x.path, has_content: x.has_content === 1,
})),
}, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_poc_get',
description: '取一条知识库 POC/EXP 的**完整内容**(正文 + 用法 + 验证记录 + 落盘路径),可直接照用或改造成目标专用脚本。不传 id 时可用 code(redteam_poc_search 返回里的 code 字段)。',
parameters: {
id: { type: 'number', description: '知识库条目 id' },
code: { type: 'string', description: '知识库条目的稳定标识(search 结果里的 code)' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args) {
const row = store.getPoc(args.id !== undefined ? args.id : args.code)
if (row === undefined) return JSON.stringify({ ok: false, error: '知识库没有这一条(先用 redteam_poc_search 检索)' }, null, 2)
return JSON.stringify({ ok: true, ...row }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_poc_add',
description: '把**通用可复用**的 POC/EXP 落进知识库(跨靶标共享)。从互联网拿到的、自己手搓的、或已经调通验证过的都往这里放——只收录真正有效的,并写清来源与验证证据。判断标准:**换个目标还能用**的进知识库;只对本次靶标有效的脚本走 redteam_attack_file_add(攻击文件页)。同名(同一 code)会合并刷新,可用于更新版本。\n\n**回填三件套(缺了面板里就没有归类、追溯不到来源)**:① `category` 归类;② `engagement` + `asset_target` —— 这条知识是在哪个靶标、哪台资产上发现并验证的;③ `verified` + `verified_note` 验证证据。',
parameters: {
title: { type: 'string', required: true, description: '标题:组件 + 漏洞名/编号,例如「Weblogic T3 反序列化 CVE-2023-21839」' },
kind: { type: 'string', description: 'poc(验证)| exp(利用)| script | template | payload' },
category: { type: 'string', description: '【归类,建议必填】rce 远程命令执行 | deserialization 反序列化 | file-upload 文件上传 getshell | sqli SQL 注入 | unauthorized 未授权访问 | auth-bypass 认证绕过/越权 | weak-password 弱口令 | ssrf | xxe | path-traversal 目录穿越/任意文件读 | info-leak 信息泄露 | privesc 提权/横向 | tunnel 隧道/代理 | other 其它' },
cve: { type: 'string', description: 'CVE / CNVD 编号' },
component: { type: 'string', description: '组件/产品名(便于按组件检索)' },
versions: { type: 'string', description: '影响版本范围' },
severity: { type: 'string', description: 'critical | high | medium | low' },
language: { type: 'string', description: 'python | go | java | bash | http | nuclei | js | php' },
source: { type: 'string', description: 'web(互联网扒的)| self(自己手搓)| manual(人工)| nuclei-template' },
source_url: { type: 'string', description: '来源链接(互联网来源必填,便于复核)' },
description: { type: 'string', description: '这个 POC 干什么、原理要点、前提条件' },
usage: { type: 'string', description: '用法:完整命令行示例 + 需要替换的参数' },
content: { type: 'string', description: '正文:脚本/POC 源码、原始请求包、nuclei 模板、调用步骤' },
path: { type: 'string', description: '也可以给本机已有文件路径(相对 pocs/ 或绝对路径),由知识库读取正文' },
filename: { type: 'string', description: '正文落盘文件名,默认按语言给(poc.py / poc.sh / poc.yaml…)' },
verified: { type: 'boolean', description: '是否已实测验证(**只有在真实目标上验证过的才填 true**)' },
verified_note: { type: 'string', description: '验证证据:在哪台目标、什么回显/结果、是否需要认证' },
engagement: { type: 'string', description: '【来源靶标】这条知识是在哪个靶标上发现/验证的(填靶标 id 或单位名称)' },
asset_target: { type: 'string', description: '【发现资产】具体是在哪台资产/哪个目标上验证成功的,例如 10.1.2.3:8080 或 http://oa.demo.com' },
found_by_agent: { type: 'string', description: '发现它的角色 code(recon / assess / vuln-scan / exploit / internal)' },
tags: { type: 'string', description: '逗号分隔标签,例如「java,反序列化,rce」' },
created_by: { type: 'string', description: '哪个角色/智能体沉淀的' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args) {
/* engagement 这个参数名在本工具里表示"来源靶标",不是"作用在哪个靶标",
所以不能走 resolveEngagement;只做名称补全,把 id 与中文名都记下来便于面板归类。 */
const payload = Object.assign({}, args)
if (typeof args.engagement === 'string' && args.engagement.trim() !== '') {
const wanted = args.engagement.trim()
const hit = store.listEngagements().find((e) => e.id === wanted || String(e.name) === wanted || String(e.name).includes(wanted))
payload.engagement_id = hit ? hit.id : wanted
payload.engagement_name = hit ? hit.name : wanted
}
const r = store.savePoc(payload)
const stats = store.pocStats()
return JSON.stringify({
ok: true, created: r.created, code: r.poc.code, path: r.path,
category: r.poc.category, engagement: r.poc.engagement_name || r.poc.engagement_id || null,
asset_target: r.poc.asset_target || null, created_at: r.poc.created_at,
verified: r.poc.verified === 1,
hint: '已进知识库(归类:' + (r.poc.category || 'other') + ',建立时间:' + r.poc.created_at + '),'
+ '后续任何靶标的智能体 redteam_poc_search 都能直接命中;知识库共 ' + stats.total + ' 条。',
}, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_poc_list',
description: '列出知识库里的 POC/EXP(可按 kind/source/component/verified 过滤,不带条件就是全部)。用于盘点"我们手上已经有哪些现成武器",避免重复搜集。',
parameters: {
kind: { type: 'string' }, source: { type: 'string' }, component: { type: 'string' },
verified: { type: 'boolean' }, limit: { type: 'number' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args) {
const items = store.searchPocs(args)
const stats = store.pocStats()
return JSON.stringify({ ok: true, stats: { total: stats.total, verified: stats.verified, by_kind: stats.byKind, by_source: stats.bySource },
count: items.length,
items: items.map((x) => ({ id: x.id, code: x.code, title: x.title, kind: x.kind, cve: x.cve, component: x.component,
language: x.language, source: x.source, verified: x.verified === 1, hit_count: x.hit_count, tags: x.tags })) }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_poc_update',
description: '更新知识库条目:补验证结论(verified + verified_note)、修正影响版本、补用法或正文、改标签。**在真实目标上验证通过后,一定要回来把 verified 置 true 并写清证据**——后续智能体会优先用已验证的。',
parameters: {
id: { type: 'number', description: '知识库条目 id' },
code: { type: 'string', description: '或用 code 指定条目' },
patch: {
type: 'object', additionalProperties: true,
description: '{ verified, verified_note, versions, usage, description, content, tags, severity, component, cve, source, source_url, language }',
},
verified: { type: 'boolean', description: '便捷写法:直接传 verified' },
verified_note: { type: 'string', description: '便捷写法:直接传验证证据' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args) {
const patch = Object.assign({}, args.patch || {})
if (args.verified !== undefined) patch.verified = args.verified
if (args.verified_note !== undefined) patch.verified_note = args.verified_note
const row = store.updatePoc(args.id !== undefined ? args.id : args.code, patch)
return JSON.stringify({ ok: true, id: row.id, code: row.code, verified: row.verified === 1, verified_note: row.verified_note, updated_at: row.updated_at }, null, 2)
},
})))
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_poc_use',
description: '记一次知识库 POC/EXP 的复用(用在哪个靶标/目标)。复用次数高的条目会排前面,方便后来者优先选经过实战的武器。',
parameters: {
id: { type: 'number' },
code: { type: 'string' },
used_on: { type: 'string', description: '用在哪:靶标名 / 目标 IP / URL' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args) {
const r = store.markPocUsed(args.id !== undefined ? args.id : args.code, args.used_on)
return JSON.stringify(r, null, 2)
},
})))
/* ================================================================== 资产发现时间线 */
ctx.tools.register(withStructuredErrors(defineTool({
name: 'redteam_asset_timeline',
description: '资产「发现时间」视图:按天聚合"哪天收了多少资产"(区分内网/外网),并列出最近发现的资产。用于回答"这个资产是什么时候发现的"、检查信息收集有没有断层,也用于本轮收集的收口核对。',
parameters: {
engagement: { type: 'string' },
limit: { type: 'number', description: '最近资产条数,默认 50' },
},
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
async execute(args, exec) {
const id = resolveEngagement(store, exec, args.engagement)
const data = store.discoveryTimeline(id, { limit: args.limit })
return JSON.stringify({
ok: true, engagement: id,
span: data.span,
days: data.days,
recent: data.recent.map((a) => ({
id: a.id, ip: a.ip, scope: a.scope, state: a.state, name: a.primary_name,
discovered_at: a.discovered_at, last_seen: a.last_seen, open_ports: a.open_ports, priority: a.priority,
})),
hint: '发现时间 = 这条资产**第一次进入本库**的时刻;重复采集只刷新 last_seen。'
+ '报告附录与资产测绘页都按它排序。',
}, null, 2)
},
})))
}