dsh-redteam-report 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/WORKSPACE-REPORTS.md +48 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +747 -0
- package/lib/host.js +119 -0
- package/lib/parts/client.head.js +15 -0
- package/lib/parts/client.shim.js +45 -0
- package/lib/parts/client.tail.js +5 -0
- package/lib/parts/host.head.js +80 -0
- package/lib/parts/host.tail.js +37 -0
- package/package.json +68 -0
- package/src/client.js +602 -0
- package/src/docx.js +776 -0
- package/src/host.js +1176 -0
- package/src/workspace-client.js +108 -0
- package/src/workspace-evidence.js +310 -0
- package/src/workspace-install.js +60 -0
- package/src/workspace-runtime.js +409 -0
- package/tools/build-lib.mjs +120 -0
- package/tools/prepare-gh-packages.mjs +66 -0
package/src/host.js
ADDED
|
@@ -0,0 +1,1176 @@
|
|
|
1
|
+
// 红队报告 · Host 半边主体
|
|
2
|
+
//
|
|
3
|
+
// 本文件是 applyHost 的【函数体】——函数头、harness 垫片、收尾与导出都由
|
|
4
|
+
// lib/parts/host.head.js 与 host.tail.js 提供,所以这里不要写 import、function 头或 return 块。
|
|
5
|
+
// lib/host.js 由 `npm run build:lib` 生成,不要手改 lib/。
|
|
6
|
+
//
|
|
7
|
+
// ── 这个插件干什么 ────────────────────────────────────────────────────────────
|
|
8
|
+
// 把一次测试的**证据**汇成一份能交付的报告:
|
|
9
|
+
// 1. 工作区对话(用户要求 / 关键操作 / 结果与结论)
|
|
10
|
+
// 2. 攻击矩阵的命中(已确认与疑似分开,带技术点名字、判据、打过的目标、证据片段)
|
|
11
|
+
// 3. 记忆库里与本次测试相关的知识条目
|
|
12
|
+
// 然后调**当前会话正在用的那个模型**(llm 服务)自动撰写,人在面板上改与预览,
|
|
13
|
+
// 最后导出成 Markdown / HTML / Word(.docx),或者反手导入记忆库。
|
|
14
|
+
//
|
|
15
|
+
// ── 三个关键设计 ──────────────────────────────────────────────────────────────
|
|
16
|
+
// 1. **不自己攒模型凭据**:报告由 `llm.stream({provider, model})` 生成,provider/model
|
|
17
|
+
// 默认取 agentDefaultModel.currentSelection(),也就是你正在对话的那个模型。
|
|
18
|
+
// 于是「AI 自动撰写」这件事不需要用户再配一个 Key。
|
|
19
|
+
// 2. **证据与撰写分离**:collectEvidence() 只管把事实收齐、按预算裁剪成 digest;
|
|
20
|
+
// 撰写只是一次带 digest 的补全。想换提示词不用动采集;想看 AI 到底看到了什么,
|
|
21
|
+
// 面板上的「试算证据」把 digest 直接打出来。
|
|
22
|
+
// 3. **生成是后台任务**:报告要写几千字,RPC 不能一直挂着。generate 立刻返回,
|
|
23
|
+
// 正文边流边写进报告对象,面板轮询 snapshot 就能看到进度与半成品。
|
|
24
|
+
//
|
|
25
|
+
// 证据来源的两种情形(都要能跑):
|
|
26
|
+
// - 攻击矩阵:优先用 `redteamAttackMatrix` 服务(有技术点名字);没有服务就**直接读**
|
|
27
|
+
// 工作区里的 .redteam-attack-matrix.json(读任何路径都允许,只是拿不到名字)。
|
|
28
|
+
// - 记忆库:用 `redteamMemory` 服务检索;没在跑就跳过,并在报告来源里说明。
|
|
29
|
+
//
|
|
30
|
+
// 动态半边写文件的沙箱边界(实测,见 ../../docs/DEVELOPMENT.md §5.1):
|
|
31
|
+
// 相对路径落在插件自己的工作区;写它之外的绝对路径会被拒。导出路径因此默认用
|
|
32
|
+
// 相对名并把解析出的宿主路径回显给用户,写不进去时面板上能看见原因。
|
|
33
|
+
//
|
|
34
|
+
// markdown → 块 / HTML / docx 的纯函数在 src/docx.js,由生成器内联到本文件末尾的
|
|
35
|
+
// 占位注释处(见 tools/build-lib.mjs 的 DOCX_MARKER;运行期不需要额外文件)。
|
|
36
|
+
|
|
37
|
+
// ── 常量 ──────────────────────────────────────────────────────────────────
|
|
38
|
+
const STORE_NAME = '.redteam-report.json'
|
|
39
|
+
const STORE_VERSION = 1
|
|
40
|
+
const LOG_MAX = 120
|
|
41
|
+
const SHELL_TIMEOUT_MS = 20000
|
|
42
|
+
const DEFAULT_MATRIX_STORE = '.redteam-attack-matrix.json'
|
|
43
|
+
const SESSION_MAX_USERS = 6
|
|
44
|
+
const SESSION_MAX_OPS = 24
|
|
45
|
+
const SESSION_MAX_RESULTS = 12
|
|
46
|
+
const OUTLINE = [
|
|
47
|
+
'## 1. 概述(测试目标、时间范围、授权与范围假设)',
|
|
48
|
+
'## 2. 测试方法与过程(按阶段写:侦察 / 进入 / 利用 / 影响验证)',
|
|
49
|
+
'## 3. 已确认的发现(每条写:现象 → 证据 → 影响 → 复现步骤 → 修复建议)',
|
|
50
|
+
'## 4. 疑似与待验证(说明为什么没确认、下一步怎么验证)',
|
|
51
|
+
'## 5. 攻击面覆盖(对照攻击矩阵,说明已覆盖与明显缺口)',
|
|
52
|
+
'## 6. 风险评级与优先级(高/中/低,给理由)',
|
|
53
|
+
'## 7. 清理与合规(清掉了什么、留下了什么、哪些动作有副作用)',
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
function blankSettings() {
|
|
57
|
+
return {
|
|
58
|
+
// 留空 = 用当前会话的默认模型(agentDefaultModel)。
|
|
59
|
+
model: { provider: '', model: '' },
|
|
60
|
+
instruction: '',
|
|
61
|
+
sessionLimit: 8,
|
|
62
|
+
sessionChars: 5000,
|
|
63
|
+
maxConfirmed: 40,
|
|
64
|
+
maxSuspected: 25,
|
|
65
|
+
memoryTopK: 5,
|
|
66
|
+
memoryQueries: 6,
|
|
67
|
+
digestMax: 48000,
|
|
68
|
+
matrixStore: '',
|
|
69
|
+
exportDir: '',
|
|
70
|
+
maxTokens: 8000,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function blankStore() {
|
|
75
|
+
return {
|
|
76
|
+
version: STORE_VERSION,
|
|
77
|
+
updatedAt: 0,
|
|
78
|
+
settings: blankSettings(),
|
|
79
|
+
reports: [],
|
|
80
|
+
currentId: '',
|
|
81
|
+
log: [],
|
|
82
|
+
logSeq: 0,
|
|
83
|
+
meta: {
|
|
84
|
+
persistence: 'unknown', storePath: '', lastError: null, progress: null,
|
|
85
|
+
lastOp: null, generating: false, evidence: null,
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── 工具函数 ──────────────────────────────────────────────────────────────
|
|
91
|
+
function msgOf(e) { return e && e.message ? String(e.message) : String(e) }
|
|
92
|
+
function nowMs() { return Date.now() }
|
|
93
|
+
function clip(s, n) {
|
|
94
|
+
const v = String(s === undefined || s === null ? '' : s).replace(/\s+/g, ' ').trim()
|
|
95
|
+
return v.length > n ? v.slice(0, n - 1) + '…' : v
|
|
96
|
+
}
|
|
97
|
+
function intOf(v, d) { const n = Number(v); return Number.isFinite(n) ? Math.round(n) : d }
|
|
98
|
+
function shQuote(s) { return "'" + String(s === undefined || s === null ? '' : s).replace(/'/g, "'\\''") + "'" }
|
|
99
|
+
function stamp() { return new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-') }
|
|
100
|
+
function fmtTime(ms) {
|
|
101
|
+
try {
|
|
102
|
+
const d = new Date(Number(ms) || 0)
|
|
103
|
+
const p = function (n) { return String(n).padStart(2, '0') }
|
|
104
|
+
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes())
|
|
105
|
+
} catch (e) { return '' }
|
|
106
|
+
}
|
|
107
|
+
function newId() { return 'r' + nowMs().toString(36) + '-' + String(Math.floor(Math.random() * 1e6)).toString(36) }
|
|
108
|
+
|
|
109
|
+
function log(level, text) {
|
|
110
|
+
store.logSeq = (store.logSeq || 0) + 1
|
|
111
|
+
store.log.push({ seq: store.logSeq, at: nowMs(), level: level, text: String(text).slice(0, 1200) })
|
|
112
|
+
if (store.log.length > LOG_MAX) store.log = store.log.slice(store.log.length - LOG_MAX)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 内容块 -> 纯文本。tool-result 的 content 是嵌套的,要递归下去,
|
|
116
|
+
// 否则工具输出(报告里最硬的那部分证据)会全丢。
|
|
117
|
+
function plainText(content) {
|
|
118
|
+
if (typeof content === 'string') return content
|
|
119
|
+
if (!Array.isArray(content)) return ''
|
|
120
|
+
const out = []
|
|
121
|
+
for (const b of content) {
|
|
122
|
+
if (!b || typeof b !== 'object') continue
|
|
123
|
+
if (b.type === 'text' && typeof b.text === 'string') out.push(b.text)
|
|
124
|
+
else if (b.type === 'tool-result') out.push(plainText(b.content))
|
|
125
|
+
}
|
|
126
|
+
return out.join('\n')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── 落盘 ──────────────────────────────────────────────────────────────────
|
|
130
|
+
const store = blankStore()
|
|
131
|
+
let loaded = false
|
|
132
|
+
|
|
133
|
+
function settings() { return store.settings }
|
|
134
|
+
|
|
135
|
+
async function resolveTarget() {
|
|
136
|
+
const fs = ctx.get('fs')
|
|
137
|
+
if (fs === undefined || fs === null) return null
|
|
138
|
+
const target = await fs.resolve(store.settings.storePath || STORE_NAME)
|
|
139
|
+
return { fs: fs, target: target }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function hostPathOf(fs, target) {
|
|
143
|
+
try {
|
|
144
|
+
if (typeof fs.processPath === 'function') return String(fs.processPath(target) || '')
|
|
145
|
+
} catch (e) { /* 沙箱实现没有 processPath 时留空 */ }
|
|
146
|
+
return ''
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function mergeSettings(src) {
|
|
150
|
+
const d = blankSettings()
|
|
151
|
+
const s = store.settings
|
|
152
|
+
const g = src.model
|
|
153
|
+
if (g && typeof g === 'object') {
|
|
154
|
+
for (const k of Object.keys(d.model)) if (g[k] !== undefined && g[k] !== null) s.model[k] = String(g[k])
|
|
155
|
+
}
|
|
156
|
+
if (src.instruction !== undefined) s.instruction = String(src.instruction || '').slice(0, 4000)
|
|
157
|
+
if (src.matrixStore !== undefined) s.matrixStore = String(src.matrixStore || '').slice(0, 400)
|
|
158
|
+
if (src.exportDir !== undefined) s.exportDir = String(src.exportDir || '').slice(0, 400)
|
|
159
|
+
s.sessionLimit = Math.max(1, Math.min(60, intOf(src.sessionLimit, s.sessionLimit)))
|
|
160
|
+
s.sessionChars = Math.max(500, Math.min(40000, intOf(src.sessionChars, s.sessionChars)))
|
|
161
|
+
s.maxConfirmed = Math.max(1, Math.min(200, intOf(src.maxConfirmed, s.maxConfirmed)))
|
|
162
|
+
s.maxSuspected = Math.max(0, Math.min(200, intOf(src.maxSuspected, s.maxSuspected)))
|
|
163
|
+
s.memoryTopK = Math.max(1, Math.min(20, intOf(src.memoryTopK, s.memoryTopK)))
|
|
164
|
+
s.memoryQueries = Math.max(0, Math.min(20, intOf(src.memoryQueries, s.memoryQueries)))
|
|
165
|
+
s.digestMax = Math.max(4000, Math.min(200000, intOf(src.digestMax, s.digestMax)))
|
|
166
|
+
s.maxTokens = Math.max(500, Math.min(64000, intOf(src.maxTokens, s.maxTokens)))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function doLoad() {
|
|
170
|
+
try {
|
|
171
|
+
const r = await resolveTarget()
|
|
172
|
+
if (!r) { store.meta.persistence = 'memory'; log('warn', 'fs 服务不可用,报告只在内存里,重启会丢'); return 0 }
|
|
173
|
+
store.meta.storePath = hostPathOf(r.fs, r.target)
|
|
174
|
+
const info = await r.fs.stat(r.target)
|
|
175
|
+
const parsed = info ? JSON.parse(await r.fs.readText(r.target)) : null
|
|
176
|
+
if (parsed && typeof parsed === 'object') {
|
|
177
|
+
if (Number(parsed.version) !== STORE_VERSION) log('warn', '报告存储版本 ' + parsed.version + ' -> ' + STORE_VERSION + ',按字段合并')
|
|
178
|
+
if (parsed.settings && typeof parsed.settings === 'object') mergeSettings(parsed.settings)
|
|
179
|
+
if (Array.isArray(parsed.reports)) {
|
|
180
|
+
for (const x of parsed.reports) {
|
|
181
|
+
if (!x || typeof x !== 'object') continue
|
|
182
|
+
store.reports.push({
|
|
183
|
+
id: String(x.id || newId()),
|
|
184
|
+
title: clip(x.title || '未命名报告', 200),
|
|
185
|
+
markdown: String(x.markdown || ''),
|
|
186
|
+
createdAt: intOf(x.createdAt, 0) || nowMs(),
|
|
187
|
+
updatedAt: intOf(x.updatedAt, 0) || nowMs(),
|
|
188
|
+
meta: x.meta && typeof x.meta === 'object' ? x.meta : {},
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (typeof parsed.currentId === 'string') store.currentId = parsed.currentId
|
|
193
|
+
if (Array.isArray(parsed.log)) store.log = parsed.log.slice(-LOG_MAX)
|
|
194
|
+
if (typeof parsed.logSeq === 'number') store.logSeq = parsed.logSeq
|
|
195
|
+
if (parsed.meta && typeof parsed.meta === 'object' && parsed.meta.evidence) store.meta.evidence = parsed.meta.evidence
|
|
196
|
+
}
|
|
197
|
+
if (!currentReport() && store.reports.length) store.currentId = store.reports[store.reports.length - 1].id
|
|
198
|
+
store.meta.persistence = 'ready'
|
|
199
|
+
return store.reports.length
|
|
200
|
+
} catch (e) {
|
|
201
|
+
const m = msgOf(e)
|
|
202
|
+
if (!/ENOENT|not found|不存在|null/i.test(m)) {
|
|
203
|
+
store.meta.persistence = 'error'
|
|
204
|
+
store.meta.lastError = '读取报告库失败:' + m
|
|
205
|
+
log('err', store.meta.lastError)
|
|
206
|
+
} else {
|
|
207
|
+
store.meta.persistence = 'ready'
|
|
208
|
+
}
|
|
209
|
+
return 0
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function persist() {
|
|
214
|
+
store.updatedAt = nowMs()
|
|
215
|
+
const r = await resolveTarget()
|
|
216
|
+
if (!r) return false
|
|
217
|
+
try {
|
|
218
|
+
const payload = {
|
|
219
|
+
version: STORE_VERSION,
|
|
220
|
+
updatedAt: store.updatedAt,
|
|
221
|
+
settings: store.settings,
|
|
222
|
+
reports: store.reports,
|
|
223
|
+
currentId: store.currentId,
|
|
224
|
+
log: store.log.slice(-LOG_MAX),
|
|
225
|
+
logSeq: store.logSeq,
|
|
226
|
+
meta: { evidence: store.meta.evidence || null },
|
|
227
|
+
}
|
|
228
|
+
await r.fs.writeText(r.target, JSON.stringify(payload, null, 2))
|
|
229
|
+
store.meta.storePath = hostPathOf(r.fs, r.target)
|
|
230
|
+
store.meta.persistence = 'ready'
|
|
231
|
+
return true
|
|
232
|
+
} catch (e) {
|
|
233
|
+
store.meta.persistence = 'error'
|
|
234
|
+
store.meta.lastError = '写入失败:' + msgOf(e)
|
|
235
|
+
log('err', store.meta.lastError)
|
|
236
|
+
return false
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function ensureLoaded() { if (loaded) return 0; loaded = true; return await doLoad() }
|
|
241
|
+
|
|
242
|
+
function currentReport() {
|
|
243
|
+
for (const r of store.reports) if (r.id === store.currentId) return r
|
|
244
|
+
return null
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function newReport(title) {
|
|
248
|
+
const at = nowMs()
|
|
249
|
+
return { id: newId(), title: clip(title || '未命名报告', 200), markdown: '', createdAt: at, updatedAt: at, meta: {} }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── 工作区与会话 ──────────────────────────────────────────────────────────
|
|
253
|
+
function workspaceRegistry() {
|
|
254
|
+
const reg = ctx.get('workspaceRegistry')
|
|
255
|
+
if (!reg || typeof reg.list !== 'function') return null
|
|
256
|
+
return reg
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function listWorkspaces() {
|
|
260
|
+
const reg = workspaceRegistry()
|
|
261
|
+
if (!reg) return []
|
|
262
|
+
let raw = null
|
|
263
|
+
try { raw = reg.list() } catch (e) { return [] }
|
|
264
|
+
if (!Array.isArray(raw)) return []
|
|
265
|
+
const out = []
|
|
266
|
+
for (const w of raw) {
|
|
267
|
+
if (!w) continue
|
|
268
|
+
const ids = Array.isArray(w.sessionIds) ? w.sessionIds : []
|
|
269
|
+
out.push({ id: String(w.id || ''), path: String(w.path || ''), title: String(w.title || ''), sessionCount: ids.length })
|
|
270
|
+
}
|
|
271
|
+
return out
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 当前会话所在的工作区;取不到就退到第一个工作区。
|
|
275
|
+
function currentWorkspace() {
|
|
276
|
+
const reg = workspaceRegistry()
|
|
277
|
+
if (!reg) return null
|
|
278
|
+
try {
|
|
279
|
+
const agents = ctx.get('agents')
|
|
280
|
+
let agent = null
|
|
281
|
+
if (agents && typeof agents.currentInitiator === 'function') agent = agents.currentInitiator()
|
|
282
|
+
if (!agent && agents && typeof agents.roots === 'function') {
|
|
283
|
+
const roots = agents.roots()
|
|
284
|
+
if (Array.isArray(roots) && roots.length) agent = roots[0]
|
|
285
|
+
}
|
|
286
|
+
if (agent) {
|
|
287
|
+
const sid = String(agent.id)
|
|
288
|
+
for (const w of listWorkspaces()) {
|
|
289
|
+
let full = null
|
|
290
|
+
try { full = typeof reg.get === 'function' ? reg.get(w.id) : null } catch (e) { full = null }
|
|
291
|
+
const ids = full && Array.isArray(full.sessionIds) ? full.sessionIds : []
|
|
292
|
+
for (const x of ids) if (String(x) === sid) return w
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
} catch (e) { /* 退到第一个工作区 */ }
|
|
296
|
+
const all = listWorkspaces()
|
|
297
|
+
return all.length > 0 ? all[0] : null
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function sessionsOf(workspaceId) {
|
|
301
|
+
const reg = workspaceRegistry()
|
|
302
|
+
const sessions = ctx.get('sessions')
|
|
303
|
+
if (!reg || !sessions || typeof sessions.get !== 'function') return []
|
|
304
|
+
let w = null
|
|
305
|
+
try { w = typeof reg.get === 'function' ? reg.get(workspaceId) : null } catch (e) { return [] }
|
|
306
|
+
if (!w || !Array.isArray(w.sessionIds)) return []
|
|
307
|
+
const out = []
|
|
308
|
+
for (const id of w.sessionIds) {
|
|
309
|
+
let s = null
|
|
310
|
+
try { s = sessions.get(id) } catch (e) { continue }
|
|
311
|
+
if (s) out.push(s)
|
|
312
|
+
}
|
|
313
|
+
return out
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function sessionTitleOf(session) {
|
|
317
|
+
const svc = ctx.get('sessionTitle')
|
|
318
|
+
try {
|
|
319
|
+
if (svc && typeof svc.get === 'function') {
|
|
320
|
+
const t = svc.get(session)
|
|
321
|
+
const v = t && (t.title || t.value || t.text)
|
|
322
|
+
if (v) return clip(v, 80)
|
|
323
|
+
}
|
|
324
|
+
} catch (e) { /* 标题只为人看着方便 */ }
|
|
325
|
+
return ''
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 一个会话压成三块:用户要求 / 关键操作 / 结果与结论。
|
|
329
|
+
// 这是「报告能引用的最小可读单位」,也是 digest 预算的分配单位。
|
|
330
|
+
function sessionDigest(session, chars) {
|
|
331
|
+
let events = []
|
|
332
|
+
try { events = session.snapshotEvents() } catch (e) { return null }
|
|
333
|
+
if (!Array.isArray(events) || !events.length) return null
|
|
334
|
+
const users = [], ops = [], results = []
|
|
335
|
+
let firstAt = 0, lastAt = 0
|
|
336
|
+
for (const ev of events) {
|
|
337
|
+
if (!ev || !ev.data) continue
|
|
338
|
+
const at = Number(ev.time) || 0
|
|
339
|
+
if (at) { if (!firstAt || at < firstAt) firstAt = at; if (at > lastAt) lastAt = at }
|
|
340
|
+
if (ev.type === 'user/message') {
|
|
341
|
+
const t = plainText(ev.data.content)
|
|
342
|
+
if (t.trim() && users.length < SESSION_MAX_USERS) users.push(clip(t, 400))
|
|
343
|
+
} else if (ev.type === 'tool/call') {
|
|
344
|
+
if (ops.length < SESSION_MAX_OPS) ops.push(clip(String(ev.data.name || '') + ' ' + String(ev.data.arguments || ''), 200))
|
|
345
|
+
} else if (ev.type === 'tool/result') {
|
|
346
|
+
const t = plainText(ev.data.message && ev.data.message.content)
|
|
347
|
+
if (t.trim() && results.length < SESSION_MAX_RESULTS) results.push(clip(t, 300))
|
|
348
|
+
} else if (ev.type === 'assistant/message') {
|
|
349
|
+
const t = plainText(ev.data.message && ev.data.message.content)
|
|
350
|
+
if (t.trim() && results.length < SESSION_MAX_RESULTS) results.push('(模型结论)' + clip(t, 240))
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const L = []
|
|
354
|
+
L.push('### 会话:' + (sessionTitleOf(session) || String(session.id).slice(0, 16)))
|
|
355
|
+
if (firstAt) L.push('时间:' + fmtTime(firstAt) + ' → ' + fmtTime(lastAt))
|
|
356
|
+
if (users.length) { L.push('用户要求:'); for (const u of users) L.push('- ' + u) }
|
|
357
|
+
if (ops.length) { L.push('关键操作(工具调用):'); for (const o of ops) L.push('- ' + o) }
|
|
358
|
+
if (results.length) { L.push('结果与结论:'); for (const r of results) L.push('- ' + r) }
|
|
359
|
+
const text = L.join('\n')
|
|
360
|
+
return {
|
|
361
|
+
id: String(session.id), title: sessionTitleOf(session), firstAt: firstAt, lastAt: lastAt,
|
|
362
|
+
users: users, ops: ops.length, results: results.length,
|
|
363
|
+
text: text.length > chars ? text.slice(0, chars) + '\n(本会话已截断)' : text,
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ── 证据:攻击矩阵 ────────────────────────────────────────────────────────
|
|
368
|
+
function matrixPath() {
|
|
369
|
+
const p = String(settings().matrixStore || '').trim()
|
|
370
|
+
if (p) return p
|
|
371
|
+
const w = currentWorkspace()
|
|
372
|
+
return w && w.path ? String(w.path).replace(/\/+$/, '') + '/' + DEFAULT_MATRIX_STORE : DEFAULT_MATRIX_STORE
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 没有攻击矩阵插件在跑时的退路:直接读它的存储文件(读任何路径都允许)。
|
|
376
|
+
// 代价是拿不到技术点名字 —— 所以只当退路,不当主路。
|
|
377
|
+
async function matrixFromFile(path) {
|
|
378
|
+
const fs = ctx.get('fs')
|
|
379
|
+
if (!fs || typeof fs.resolve !== 'function') return null
|
|
380
|
+
const target = await fs.resolve(path)
|
|
381
|
+
const info = await fs.stat(target)
|
|
382
|
+
if (!info) return { from: 'file', items: [], confirmed: 0, suspected: 0, storePath: path, missing: true }
|
|
383
|
+
const parsed = JSON.parse(await fs.readText(target))
|
|
384
|
+
const items = []
|
|
385
|
+
const matrix = parsed && parsed.matrix && typeof parsed.matrix === 'object' ? parsed.matrix : {}
|
|
386
|
+
for (const fwId of Object.keys(matrix)) {
|
|
387
|
+
const techs = matrix[fwId] || {}
|
|
388
|
+
for (const tid of Object.keys(techs)) {
|
|
389
|
+
const hits = techs[tid] || {}
|
|
390
|
+
for (const sid of Object.keys(hits)) {
|
|
391
|
+
const h = hits[sid]
|
|
392
|
+
if (!h || h.confidence === 'rejected') continue
|
|
393
|
+
items.push({
|
|
394
|
+
frameworkId: fwId, frameworkLabel: fwId, techniqueId: tid, techniqueName: '',
|
|
395
|
+
sessionId: sid, sessionTitle: String(h.sessionTitle || ''),
|
|
396
|
+
confidence: h.confidence === 'confirmed' ? 'confirmed' : 'suspected',
|
|
397
|
+
decidedBy: String(h.decidedBy || ''), reason: String(h.reason || ''),
|
|
398
|
+
occurrences: intOf(h.occurrences, 0), firstAt: intOf(h.firstAt, 0), lastAt: intOf(h.lastAt, 0),
|
|
399
|
+
kind: String(h.kind || ''), matched: (h.matched || []).slice(0, 10), targets: (h.targets || []).slice(0, 10),
|
|
400
|
+
snippets: (h.snippets || []).slice(-3).map(function (x) { return { at: x.at, label: x.label, text: clip(x.text, 400) } }),
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const out = { from: 'file', items: items, storePath: path, updatedAt: intOf(parsed.updatedAt, 0) }
|
|
406
|
+
out.confirmed = items.filter(function (x) { return x.confidence === 'confirmed' }).length
|
|
407
|
+
out.suspected = items.length - out.confirmed
|
|
408
|
+
return out
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function collectMatrix() {
|
|
412
|
+
const svc = ctx.get('redteamAttackMatrix')
|
|
413
|
+
if (svc && typeof svc.digest === 'function') {
|
|
414
|
+
try {
|
|
415
|
+
const d = await svc.digest()
|
|
416
|
+
if (d && Array.isArray(d.items)) return Object.assign({ from: 'service' }, d)
|
|
417
|
+
} catch (e) {
|
|
418
|
+
log('warn', '从攻击矩阵服务取数失败,改读存储文件:' + msgOf(e))
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const path = matrixPath()
|
|
422
|
+
try {
|
|
423
|
+
const d = await matrixFromFile(path)
|
|
424
|
+
if (!d) return { from: 'none', items: [], confirmed: 0, suspected: 0, storePath: path }
|
|
425
|
+
// 服务在跑但 digest 失败时,至少把技术点名字补上。
|
|
426
|
+
if (svc && typeof svc.names === 'function') {
|
|
427
|
+
try {
|
|
428
|
+
const names = svc.names()
|
|
429
|
+
for (const it of d.items) {
|
|
430
|
+
const fw = names[it.frameworkId]
|
|
431
|
+
if (!fw) continue
|
|
432
|
+
it.frameworkLabel = fw.label || it.frameworkId
|
|
433
|
+
it.frameworkShort = fw.short || it.frameworkLabel
|
|
434
|
+
if (fw.techniques && fw.techniques[it.techniqueId]) it.techniqueName = fw.techniques[it.techniqueId]
|
|
435
|
+
}
|
|
436
|
+
} catch (e) { /* 名字是加分项,拿不到就算了 */ }
|
|
437
|
+
}
|
|
438
|
+
d.items.sort(function (a, b) {
|
|
439
|
+
return (b.confidence === 'confirmed' ? 1 : 0) - (a.confidence === 'confirmed' ? 1 : 0) || (b.lastAt - a.lastAt)
|
|
440
|
+
})
|
|
441
|
+
return d
|
|
442
|
+
} catch (e) {
|
|
443
|
+
return { from: 'none', items: [], confirmed: 0, suspected: 0, storePath: path, error: msgOf(e) }
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ── 证据:记忆库 ──────────────────────────────────────────────────────────
|
|
448
|
+
async function collectMemory(queries) {
|
|
449
|
+
const svc = ctx.get('redteamMemory')
|
|
450
|
+
if (!svc || typeof svc.search !== 'function') {
|
|
451
|
+
return { available: false, hits: [], note: '记忆插件没在运行(redteamMemory 服务不可用),本次报告不含记忆条目' }
|
|
452
|
+
}
|
|
453
|
+
const seen = {}
|
|
454
|
+
const hits = []
|
|
455
|
+
const budget = Math.max(0, intOf(settings().memoryQueries, 6))
|
|
456
|
+
const used = queries.slice(0, budget)
|
|
457
|
+
for (const q of used) {
|
|
458
|
+
if (!q || !String(q).trim()) continue
|
|
459
|
+
try {
|
|
460
|
+
const r = await svc.search(String(q), settings().memoryTopK, { rerank: false })
|
|
461
|
+
for (const h of (r && r.hits) || []) {
|
|
462
|
+
const id = String(h.id || h.entryId || h.title)
|
|
463
|
+
if (seen[id]) continue
|
|
464
|
+
seen[id] = true
|
|
465
|
+
hits.push({
|
|
466
|
+
id: id, title: String(h.title || ''), kind: String(h.kind || ''), tags: String(h.tags || ''),
|
|
467
|
+
text: clip(h.text, 500), source: String(h.source || ''), query: String(q), mode: String(h.mode || ''),
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
} catch (e) {
|
|
471
|
+
log('warn', '记忆检索失败(' + clip(q, 40) + '):' + msgOf(e))
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return { available: true, hits: hits, queries: used }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ── 证据采集与 digest ─────────────────────────────────────────────────────
|
|
478
|
+
async function collectEvidence() {
|
|
479
|
+
const s = settings()
|
|
480
|
+
const w = currentWorkspace()
|
|
481
|
+
const sessions = []
|
|
482
|
+
if (w) {
|
|
483
|
+
// 最近的会话优先:报告写的是这次测试,不是三个月前的。
|
|
484
|
+
const withTime = []
|
|
485
|
+
for (const session of sessionsOf(w.id)) {
|
|
486
|
+
let last = 0
|
|
487
|
+
try {
|
|
488
|
+
const evs = session.snapshotEvents()
|
|
489
|
+
if (Array.isArray(evs) && evs.length) last = Number(evs[evs.length - 1].time) || 0
|
|
490
|
+
} catch (e) { /* 拿不到时间就排最后 */ }
|
|
491
|
+
withTime.push({ session: session, last: last })
|
|
492
|
+
}
|
|
493
|
+
withTime.sort(function (a, b) { return b.last - a.last })
|
|
494
|
+
for (const x of withTime.slice(0, s.sessionLimit)) {
|
|
495
|
+
const d = sessionDigest(x.session, s.sessionChars)
|
|
496
|
+
if (d) sessions.push(d)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const matrix = await collectMatrix()
|
|
501
|
+
const confirmed = matrix.items.filter(function (x) { return x.confidence === 'confirmed' }).slice(0, s.maxConfirmed)
|
|
502
|
+
const suspected = matrix.items.filter(function (x) { return x.confidence !== 'confirmed' }).slice(0, s.maxSuspected)
|
|
503
|
+
|
|
504
|
+
// 记忆检索的 query:先拿矩阵里确认的技术点名字(那是本次测试「发生了什么」),
|
|
505
|
+
// 再补每个会话的第一条用户要求(那是「想做什么」)。
|
|
506
|
+
const queries = []
|
|
507
|
+
for (const it of confirmed.concat(suspected)) {
|
|
508
|
+
const name = it.techniqueName || it.techniqueId
|
|
509
|
+
if (name && queries.indexOf(name) < 0) queries.push(name)
|
|
510
|
+
}
|
|
511
|
+
for (const d of sessions) for (const u of (d.users || []).slice(0, 1)) if (queries.indexOf(u) < 0) queries.push(clip(u, 60))
|
|
512
|
+
const memory = await collectMemory(queries)
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
at: nowMs(),
|
|
516
|
+
workspace: w ? { id: w.id, title: w.title, path: w.path } : null,
|
|
517
|
+
sessions: sessions,
|
|
518
|
+
matrix: {
|
|
519
|
+
from: matrix.from, storePath: matrix.storePath || '', error: matrix.error || null,
|
|
520
|
+
updatedAt: matrix.updatedAt || 0, total: matrix.items.length,
|
|
521
|
+
confirmed: confirmed, suspected: suspected,
|
|
522
|
+
confirmedAll: intOf(matrix.confirmed, confirmed.length), suspectedAll: intOf(matrix.suspected, suspected.length),
|
|
523
|
+
},
|
|
524
|
+
memory: memory,
|
|
525
|
+
queries: queries,
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function matrixLines(items, kind) {
|
|
530
|
+
const out = []
|
|
531
|
+
for (const it of items) {
|
|
532
|
+
const fw = it.frameworkShort || it.frameworkLabel || it.frameworkId
|
|
533
|
+
const name = it.techniqueName ? '(' + it.techniqueName + ')' : ''
|
|
534
|
+
const bits = []
|
|
535
|
+
if (it.occurrences) bits.push('出现 ' + it.occurrences + ' 次')
|
|
536
|
+
if (it.firstAt) bits.push(fmtTime(it.firstAt) + (it.lastAt && it.lastAt !== it.firstAt ? ' → ' + fmtTime(it.lastAt) : ''))
|
|
537
|
+
if (it.sessionTitle) bits.push('会话「' + clip(it.sessionTitle, 40) + '」')
|
|
538
|
+
if (it.decidedBy) bits.push('判定方 ' + it.decidedBy)
|
|
539
|
+
out.push('- ' + fw + ' / ' + it.techniqueId + name + (bits.length ? '|' + bits.join('|') : ''))
|
|
540
|
+
if (it.reason) out.push(' 判据:' + clip(it.reason, 300))
|
|
541
|
+
if (it.targets && it.targets.length) out.push(' 目标:' + clip(it.targets.join('、'), 200))
|
|
542
|
+
if (it.matched && it.matched.length) out.push(' 命中线索:' + clip(it.matched.join('、'), 160))
|
|
543
|
+
// 疑似条目的片段不进 digest:那些多半是关键词撞上的原文,放进去只会把报告带偏。
|
|
544
|
+
if (kind === 'confirmed') {
|
|
545
|
+
for (const sn of (it.snippets || []).slice(-2)) out.push(' 证据片段[' + (sn.label || '') + ' ' + fmtTime(sn.at) + ']:' + clip(sn.text, 300))
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return out
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function buildDigest(ev) {
|
|
552
|
+
const s = settings()
|
|
553
|
+
const L = []
|
|
554
|
+
L.push('# 证据材料(自动采集;报告只能引用这里出现过的事实)')
|
|
555
|
+
L.push('')
|
|
556
|
+
L.push('## 一、工作区与会话')
|
|
557
|
+
L.push('- 工作区:' + (ev.workspace ? (ev.workspace.title || '') + '(' + ev.workspace.path + ')' : '(拿不到工作区)'))
|
|
558
|
+
L.push('- 采集时间:' + fmtTime(ev.at))
|
|
559
|
+
L.push('- 会话数:' + ev.sessions.length)
|
|
560
|
+
for (const d of ev.sessions) { L.push(''); L.push(d.text) }
|
|
561
|
+
|
|
562
|
+
L.push('')
|
|
563
|
+
L.push('## 二、攻击矩阵命中')
|
|
564
|
+
L.push('- 来源:' + (ev.matrix.from === 'service' ? '攻击矩阵插件(含技术点名字)'
|
|
565
|
+
: ev.matrix.from === 'file' ? '直接读 ' + ev.matrix.storePath + '(拿不到技术点名字,只有 id)' : '没有可用数据'))
|
|
566
|
+
if (ev.matrix.error) L.push('- 读取问题:' + ev.matrix.error)
|
|
567
|
+
L.push('- 已确认 ' + ev.matrix.confirmed.length + ' 条(全部 ' + ev.matrix.confirmedAll + ' 条)· 疑似 '
|
|
568
|
+
+ ev.matrix.suspected.length + ' 条(全部 ' + ev.matrix.suspectedAll + ' 条)')
|
|
569
|
+
L.push('')
|
|
570
|
+
L.push('### 已确认(有做成的证据)')
|
|
571
|
+
const cf = matrixLines(ev.matrix.confirmed, 'confirmed')
|
|
572
|
+
if (cf.length) for (const x of cf) L.push(x); else L.push('- (无)')
|
|
573
|
+
L.push('')
|
|
574
|
+
L.push('### 疑似(提及或尝试过,但没证明成功)')
|
|
575
|
+
const sp = matrixLines(ev.matrix.suspected, 'suspected')
|
|
576
|
+
if (sp.length) for (const x of sp) L.push(x); else L.push('- (无)')
|
|
577
|
+
|
|
578
|
+
L.push('')
|
|
579
|
+
L.push('## 三、记忆库里与本次测试相关的知识')
|
|
580
|
+
if (!ev.memory.available) L.push('- ' + ev.memory.note)
|
|
581
|
+
else if (!ev.memory.hits.length) L.push('- (没有检索到相关条目)')
|
|
582
|
+
else for (const h of ev.memory.hits) L.push('- [' + (h.kind || 'knowledge') + '] ' + h.title + ':' + clip(h.text, 400))
|
|
583
|
+
|
|
584
|
+
let text = L.join('\n')
|
|
585
|
+
if (text.length > s.digestMax) {
|
|
586
|
+
text = text.slice(0, s.digestMax) + '\n\n(证据材料超过 ' + s.digestMax + ' 字,已截断;可在设置里调大上限或减少会话数)'
|
|
587
|
+
}
|
|
588
|
+
return text
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function buildSystemPrompt() {
|
|
592
|
+
return [
|
|
593
|
+
'你是红队报告撰写助手。你会拿到一份自动采集的证据材料,然后写一份中文技术报告(Markdown)。',
|
|
594
|
+
'',
|
|
595
|
+
'纪律(这些比文采重要):',
|
|
596
|
+
'1. 只能写证据材料里出现过的事实。不要编造 IP、端口、URL、命令回显、时间、数量。',
|
|
597
|
+
'2. 攻击矩阵的「已确认」才能写成已确认的发现;「疑似」必须放在疑似与待验证一节,并写清为什么没确认。',
|
|
598
|
+
'3. 每条发现都要能指回证据:写清出现在哪个会话、哪个技术点、什么目标。',
|
|
599
|
+
'4. 数字(出现次数、时间区间)必须与证据材料一致,不要四舍五入成好看的数字。',
|
|
600
|
+
'5. 证据不足就写「证据不足」——一份诚实的中等报告比一份编造的优秀报告有用得多。',
|
|
601
|
+
'6. 不要用代码围栏包住整篇报告,直接给 Markdown 正文。',
|
|
602
|
+
].join('\n')
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function buildUserPrompt(digest, instruction) {
|
|
606
|
+
const L = []
|
|
607
|
+
L.push('请根据下面的证据材料撰写红队测试报告。')
|
|
608
|
+
L.push('')
|
|
609
|
+
L.push('按这个大纲组织(标题层级用 ##):')
|
|
610
|
+
for (const x of OUTLINE) L.push(x)
|
|
611
|
+
L.push('')
|
|
612
|
+
L.push('开头用一行 `# 标题` 给出报告标题(包含测试对象与时间范围,不要只写「红队报告」)。')
|
|
613
|
+
if (String(instruction || '').trim()) {
|
|
614
|
+
L.push('')
|
|
615
|
+
L.push('额外要求(必须满足):')
|
|
616
|
+
L.push(String(instruction).trim())
|
|
617
|
+
}
|
|
618
|
+
L.push('')
|
|
619
|
+
L.push('---')
|
|
620
|
+
L.push('')
|
|
621
|
+
L.push(digest)
|
|
622
|
+
return L.join('\n')
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ── 生成(后台任务,边流边写)────────────────────────────────────────────
|
|
626
|
+
const gen = { active: false, reportId: '', chars: 0, startedAt: 0 }
|
|
627
|
+
|
|
628
|
+
function pickModel() {
|
|
629
|
+
const s = settings()
|
|
630
|
+
const llm = ctx.get('llm')
|
|
631
|
+
const out = { provider: String(s.model.provider || '').trim(), model: String(s.model.model || '').trim(), from: 'settings' }
|
|
632
|
+
if (!out.provider || !out.model) {
|
|
633
|
+
const svc = ctx.get('agentDefaultModel')
|
|
634
|
+
let sel = null
|
|
635
|
+
try { if (svc && typeof svc.currentSelection === 'function') sel = svc.currentSelection() } catch (e) { sel = null }
|
|
636
|
+
if (sel && sel.provider && sel.model) {
|
|
637
|
+
out.provider = String(sel.provider)
|
|
638
|
+
out.model = String(sel.model)
|
|
639
|
+
out.from = 'default'
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (!out.provider) {
|
|
643
|
+
// 最后退到 llm 注册的第一个 provider(列不出模型时至少给个能用的路由)
|
|
644
|
+
try {
|
|
645
|
+
const provs = llm && typeof llm.listProviders === 'function' ? llm.listProviders() : null
|
|
646
|
+
if (Array.isArray(provs) && provs.length && provs[0] && provs[0].id) {
|
|
647
|
+
out.provider = String(provs[0].id)
|
|
648
|
+
out.from = 'provider'
|
|
649
|
+
}
|
|
650
|
+
} catch (e) { /* 下面统一报错 */ }
|
|
651
|
+
}
|
|
652
|
+
return out
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function runGenerate(report, instruction) {
|
|
656
|
+
const llm = ctx.get('llm')
|
|
657
|
+
if (!llm || typeof llm.stream !== 'function') throw new Error('llm 服务不可用,没法自动撰写(宿主里要有 llm 插件)')
|
|
658
|
+
const sel = pickModel()
|
|
659
|
+
if (!sel.provider || !sel.model) throw new Error('拿不到可用的模型:设置里没填,当前会话也没有默认模型')
|
|
660
|
+
|
|
661
|
+
const ev = await collectEvidence()
|
|
662
|
+
const digest = buildDigest(ev)
|
|
663
|
+
const messages = [{ id: 'rpt-' + nowMs(), role: 'user', content: [{ type: 'text', text: buildUserPrompt(digest, instruction) }], source: { kind: 'user' } }]
|
|
664
|
+
|
|
665
|
+
log('info', '开始撰写报告(' + sel.provider + '/' + sel.model + ',证据 ' + digest.length + ' 字:' + ev.sessions.length + ' 个会话,已确认 '
|
|
666
|
+
+ ev.matrix.confirmed.length + ' 条,疑似 ' + ev.matrix.suspected.length + ' 条,记忆 ' + ev.memory.hits.length + ' 条)')
|
|
667
|
+
|
|
668
|
+
const parts = []
|
|
669
|
+
let usage = null
|
|
670
|
+
let finish = null
|
|
671
|
+
let lastFlush = 0
|
|
672
|
+
const stream = llm.stream({
|
|
673
|
+
provider: sel.provider, model: sel.model, system: buildSystemPrompt(),
|
|
674
|
+
messages: messages, maxTokens: settings().maxTokens,
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
for await (const chunk of stream) {
|
|
678
|
+
if (!chunk || typeof chunk !== 'object') continue
|
|
679
|
+
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') {
|
|
680
|
+
parts.push(chunk.text)
|
|
681
|
+
// 边流边写回报告对象:面板轮询 snapshot 就能看到正在长出来的正文。
|
|
682
|
+
gen.chars = parts.join('').length
|
|
683
|
+
store.meta.progress = { text: '正在撰写… ' + gen.chars + ' 字', chars: gen.chars, total: 0 }
|
|
684
|
+
const now = nowMs()
|
|
685
|
+
if (now - lastFlush > 800) {
|
|
686
|
+
lastFlush = now
|
|
687
|
+
report.markdown = parts.join('')
|
|
688
|
+
report.updatedAt = now
|
|
689
|
+
}
|
|
690
|
+
} else if (chunk.type === 'usage' && chunk.usage) {
|
|
691
|
+
usage = chunk.usage
|
|
692
|
+
} else if (chunk.type === 'finish') {
|
|
693
|
+
finish = chunk.reason
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const text = parts.join('')
|
|
698
|
+
report.markdown = text
|
|
699
|
+
report.updatedAt = nowMs()
|
|
700
|
+
if (!report.title || report.title === '未命名报告') {
|
|
701
|
+
const m = /^\s*#\s+(.+)$/m.exec(text)
|
|
702
|
+
report.title = clip(m ? m[1] : ('红队测试报告 ' + fmtTime(report.createdAt)), 200)
|
|
703
|
+
}
|
|
704
|
+
report.meta = Object.assign({}, report.meta, {
|
|
705
|
+
provider: sel.provider, model: sel.model, modelFrom: sel.from,
|
|
706
|
+
chars: text.length, usage: usage || null, generatedAt: report.updatedAt,
|
|
707
|
+
evidence: {
|
|
708
|
+
sessions: ev.sessions.length,
|
|
709
|
+
matrixConfirmed: ev.matrix.confirmed.length,
|
|
710
|
+
matrixSuspected: ev.matrix.suspected.length,
|
|
711
|
+
matrixFrom: ev.matrix.from,
|
|
712
|
+
memoryHits: ev.memory.hits.length,
|
|
713
|
+
memoryAvailable: ev.memory.available,
|
|
714
|
+
digestChars: digest.length,
|
|
715
|
+
},
|
|
716
|
+
instruction: String(instruction || '').slice(0, 2000),
|
|
717
|
+
})
|
|
718
|
+
store.meta.evidence = report.meta.evidence
|
|
719
|
+
|
|
720
|
+
if (finish && finish.kind === 'error') throw new Error('模型返回错误:' + ((finish.failure && finish.failure.message) || '未知'))
|
|
721
|
+
if (finish && finish.kind === 'aborted') throw new Error('生成被中止:' + ((finish.failure && finish.failure.message) || ''))
|
|
722
|
+
if (!text.trim()) throw new Error('模型没有返回任何正文(finish = ' + (finish ? finish.kind : '?') + ')')
|
|
723
|
+
return { chars: text.length, evidence: report.meta.evidence }
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// 生成失败且一个字都没写出来时,把这份空报告撤掉:面板里留一堆空壳只会让人困惑。
|
|
727
|
+
function dropIfEmpty(report) {
|
|
728
|
+
if (String(report.markdown || '').trim()) return false
|
|
729
|
+
store.reports = store.reports.filter(function (r) { return r.id !== report.id })
|
|
730
|
+
if (store.currentId === report.id) store.currentId = store.reports.length ? store.reports[store.reports.length - 1].id : ''
|
|
731
|
+
return true
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function startGenerate(report, instruction) {
|
|
735
|
+
if (gen.active) return { ok: false, error: '已经有一次生成在跑,先等它结束' }
|
|
736
|
+
gen.active = true
|
|
737
|
+
gen.reportId = report.id
|
|
738
|
+
gen.chars = 0
|
|
739
|
+
gen.startedAt = nowMs()
|
|
740
|
+
store.meta.generating = true
|
|
741
|
+
store.meta.progress = { text: '正在采集证据…', chars: 0, total: 0 }
|
|
742
|
+
store.meta.lastOp = { at: nowMs(), op: 'generate', text: report.id }
|
|
743
|
+
Promise.resolve()
|
|
744
|
+
.then(function () { return runGenerate(report, instruction) })
|
|
745
|
+
.then(function (r) {
|
|
746
|
+
store.meta.progress = null
|
|
747
|
+
store.meta.generating = false
|
|
748
|
+
log('ok', '报告撰写完成:' + r.chars + ' 字(' + report.title + ')')
|
|
749
|
+
})
|
|
750
|
+
.catch(function (e) {
|
|
751
|
+
store.meta.progress = null
|
|
752
|
+
store.meta.generating = false
|
|
753
|
+
store.meta.lastError = '撰写失败:' + msgOf(e)
|
|
754
|
+
log('err', store.meta.lastError)
|
|
755
|
+
dropIfEmpty(report)
|
|
756
|
+
})
|
|
757
|
+
.then(function () {
|
|
758
|
+
gen.active = false
|
|
759
|
+
return persist()
|
|
760
|
+
})
|
|
761
|
+
.catch(function () {})
|
|
762
|
+
return { ok: true, started: true, reportId: report.id }
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ── 导出 ──────────────────────────────────────────────────────────────────
|
|
766
|
+
// 交付方式是**写到磁盘 + 把绝对路径回显给用户**,不做浏览器下载:
|
|
767
|
+
// 动态形态的客户端沙箱只给了 ctx / React / host / styles / console,
|
|
768
|
+
// 没有 document / Blob / URL,拼下载链接那条路在动态半边直接不可用。
|
|
769
|
+
function safeName(title) {
|
|
770
|
+
const base = String(title || 'report').replace(/[\\/:*?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '')
|
|
771
|
+
return (base || 'report').slice(0, 80)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// 文本走 fs.writeText;docx 是二进制,借 shell + base64 落盘。
|
|
775
|
+
// 沙箱里 shell 与插件 fs 的工作区未必相同,所以先把绝对路径解析出来再交给 shell。
|
|
776
|
+
async function writeExport(name, content, isBinary) {
|
|
777
|
+
const dir = String(settings().exportDir || '').trim().replace(/\/+$/, '')
|
|
778
|
+
const rel = dir ? dir + '/' + name : name
|
|
779
|
+
const fs = ctx.get('fs')
|
|
780
|
+
if (!isBinary) {
|
|
781
|
+
if (!fs || typeof fs.resolve !== 'function') return { ok: false, error: 'fs 服务不可用' }
|
|
782
|
+
const t = await fs.resolve(rel)
|
|
783
|
+
await fs.writeText(t, content)
|
|
784
|
+
return { ok: true, path: hostPathOf(fs, t) || rel }
|
|
785
|
+
}
|
|
786
|
+
const shell = ctx.get('shell')
|
|
787
|
+
if (!shell || typeof shell.resolve !== 'function') return { ok: false, error: 'shell 服务不可用(Word 是二进制,落盘要借 shell + base64)' }
|
|
788
|
+
let abs = rel
|
|
789
|
+
if (fs && typeof fs.resolve === 'function') {
|
|
790
|
+
try { abs = hostPathOf(fs, await fs.resolve(rel)) || rel } catch (e) { abs = rel }
|
|
791
|
+
}
|
|
792
|
+
const spec = shell.resolve({
|
|
793
|
+
command: "printf '%s' " + shQuote(content) + ' | base64 -d > ' + shQuote(abs),
|
|
794
|
+
timeoutMs: SHELL_TIMEOUT_MS, stdoutMaxBytes: 4096,
|
|
795
|
+
})
|
|
796
|
+
const r = await shell.run(spec)
|
|
797
|
+
if (r && r.exitCode === 0) return { ok: true, path: abs }
|
|
798
|
+
return { ok: false, error: '写文件失败:' + clip((r && r.stderr && r.stderr.text) || '未知', 200), path: abs }
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function reportMetaTable(report) {
|
|
802
|
+
const m = report.meta || {}
|
|
803
|
+
const ev = m.evidence || {}
|
|
804
|
+
return {
|
|
805
|
+
'生成时间': fmtTime(m.generatedAt || report.updatedAt),
|
|
806
|
+
'生成模型': (m.provider || '?') + ' / ' + (m.model || '?'),
|
|
807
|
+
'证据:会话': String(ev.sessions === undefined ? '?' : ev.sessions),
|
|
808
|
+
'证据:矩阵命中': '已确认 ' + (ev.matrixConfirmed || 0) + ' · 疑似 ' + (ev.matrixSuspected || 0),
|
|
809
|
+
'证据:记忆条目': String(ev.memoryHits || 0) + (ev.memoryAvailable === false ? '(记忆插件未运行)' : ''),
|
|
810
|
+
'字数': String(String(report.markdown || '').length),
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async function exportReport(id, format) {
|
|
815
|
+
const report = store.reports.filter(function (r) { return r.id === String(id) })[0] || currentReport()
|
|
816
|
+
if (!report) return { ok: false, error: '没有可导出的报告' }
|
|
817
|
+
const fmt = String(format || 'md').toLowerCase()
|
|
818
|
+
const name = safeName(report.title) + '-' + stamp()
|
|
819
|
+
const body = String(report.markdown || '').replace(/^\s*#\s+.+\n/, '')
|
|
820
|
+
if (fmt === 'md') {
|
|
821
|
+
const text = '# ' + report.title + '\n\n' + body
|
|
822
|
+
const w = await writeExport(name + '.md', text, false)
|
|
823
|
+
return { ok: true, format: 'md', name: name + '.md', bytes: text.length, path: w.path || '', writeError: w.ok ? null : w.error }
|
|
824
|
+
}
|
|
825
|
+
if (fmt === 'html') {
|
|
826
|
+
const html = rptBuildHtml({ title: report.title, markdown: report.markdown, meta: reportMetaTable(report) })
|
|
827
|
+
const w = await writeExport(name + '.html', html, false)
|
|
828
|
+
return { ok: true, format: 'html', name: name + '.html', bytes: html.length, path: w.path || '', writeError: w.ok ? null : w.error }
|
|
829
|
+
}
|
|
830
|
+
if (fmt === 'docx') {
|
|
831
|
+
const bytes = rptBuildDocx({ title: report.title, markdown: report.markdown, meta: reportMetaTable(report) })
|
|
832
|
+
const b64 = rptBytesToBase64(bytes)
|
|
833
|
+
const w = await writeExport(name + '.docx', b64, true)
|
|
834
|
+
log('ok', '导出 Word:' + (w.ok ? w.path : '落盘失败(' + w.error + '),已准备浏览器下载'))
|
|
835
|
+
return { ok: true, format: 'docx', name: name + '.docx', bytes: bytes.length, path: w.path || '', writeError: w.ok ? null : w.error }
|
|
836
|
+
}
|
|
837
|
+
return { ok: false, error: '不支持的格式:' + fmt + '(支持 md / html / docx)' }
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ── 导入记忆 ──────────────────────────────────────────────────────────────
|
|
841
|
+
function reportToEntries(report) {
|
|
842
|
+
const title = report.title || '红队报告'
|
|
843
|
+
const source = '报告 · ' + clip(title, 60)
|
|
844
|
+
const out = []
|
|
845
|
+
for (const b of String(report.markdown || '').split(/\n(?=##\s)/)) {
|
|
846
|
+
const t = b.trim()
|
|
847
|
+
if (!t) continue
|
|
848
|
+
const m = /^##\s+(.+)$/m.exec(t)
|
|
849
|
+
// 开头那段往往只有一行 H1 标题:当独立条目是噪声,跳过(但整篇没有 ## 时下面的兜底会保住全文)。
|
|
850
|
+
if (!m && t.replace(/^#\s+.+$/m, '').trim().length < 40) continue
|
|
851
|
+
out.push({ title: clip(m ? m[1] : title, 120), text: t, kind: 'note', tags: ['报告'], source: source })
|
|
852
|
+
}
|
|
853
|
+
if (!out.length) out.push({ title: clip(title, 120), text: String(report.markdown || ''), kind: 'note', tags: ['报告'], source: source })
|
|
854
|
+
return out
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async function importToMemory(id) {
|
|
858
|
+
const svc = ctx.get('redteamMemory')
|
|
859
|
+
if (!svc || typeof svc.add !== 'function') {
|
|
860
|
+
return { ok: false, error: '记忆插件没在运行(redteamMemory 服务不可用):先让红队记忆插件跑起来' }
|
|
861
|
+
}
|
|
862
|
+
const report = store.reports.filter(function (r) { return r.id === String(id) })[0] || currentReport()
|
|
863
|
+
if (!report) return { ok: false, error: '没有可导入的报告' }
|
|
864
|
+
if (!String(report.markdown || '').trim()) return { ok: false, error: '报告还是空的,先生成或写点内容' }
|
|
865
|
+
const entries = reportToEntries(report)
|
|
866
|
+
const r = await svc.add(entries, '报告 · ' + clip(report.title, 60))
|
|
867
|
+
log('ok', '报告导入记忆:' + r.added + ' 条新增 / ' + r.updated + ' 条覆盖(本地共 ' + (r.localCount || 0) + ' 条' + (r.indexError ? ',索引未同步' : '') + ')')
|
|
868
|
+
store.meta.lastOp = { at: nowMs(), op: 'importToMemory', text: report.title }
|
|
869
|
+
return { ok: true, added: r.added, updated: r.updated, entries: entries.length, indexed: r.indexed || 0, indexError: r.indexError || null, localCount: r.localCount || 0 }
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ── 状态快照 ──────────────────────────────────────────────────────────────
|
|
873
|
+
function reportSummary(r) {
|
|
874
|
+
return {
|
|
875
|
+
id: r.id, title: r.title, chars: String(r.markdown || '').length,
|
|
876
|
+
createdAt: r.createdAt, updatedAt: r.updatedAt,
|
|
877
|
+
provider: (r.meta && r.meta.provider) || '', model: (r.meta && r.meta.model) || '',
|
|
878
|
+
evidence: (r.meta && r.meta.evidence) || null,
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function snapshot() {
|
|
883
|
+
const cur = currentReport()
|
|
884
|
+
const sel = pickModel()
|
|
885
|
+
return {
|
|
886
|
+
updatedAt: store.updatedAt,
|
|
887
|
+
settings: settings(),
|
|
888
|
+
outline: OUTLINE,
|
|
889
|
+
model: { provider: sel.provider, model: sel.model, from: sel.from },
|
|
890
|
+
reports: store.reports.map(reportSummary),
|
|
891
|
+
currentId: cur ? cur.id : '',
|
|
892
|
+
current: cur ? { id: cur.id, title: cur.title, markdown: cur.markdown, meta: cur.meta || {}, updatedAt: cur.updatedAt } : null,
|
|
893
|
+
status: {
|
|
894
|
+
persistence: store.meta.persistence || 'unknown',
|
|
895
|
+
storePath: store.meta.storePath || '',
|
|
896
|
+
lastError: store.meta.lastError || null,
|
|
897
|
+
progress: store.meta.progress || null,
|
|
898
|
+
generating: store.meta.generating === true,
|
|
899
|
+
genChars: gen.chars,
|
|
900
|
+
lastOp: store.meta.lastOp || null,
|
|
901
|
+
evidence: store.meta.evidence || null,
|
|
902
|
+
matrixPath: matrixPath(),
|
|
903
|
+
},
|
|
904
|
+
log: store.log.slice(-60),
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// ── 模型工具 ──────────────────────────────────────────────────────────────
|
|
909
|
+
const genTool = harness.defineTool({
|
|
910
|
+
name: 'report_generate',
|
|
911
|
+
description: '基于当前工作区的对话、攻击矩阵命中与红队记忆库,自动撰写一份红队测试报告(Markdown)。会等写完再返回;正文同时出现在红队报告面板里,可以在那里编辑、预览、导出成 Word 或导入记忆。',
|
|
912
|
+
parameters: {
|
|
913
|
+
type: 'object',
|
|
914
|
+
properties: {
|
|
915
|
+
title: { type: 'string', description: '报告标题(省略时由正文里的 # 标题决定)' },
|
|
916
|
+
instruction: { type: 'string', description: '额外要求,例如「重点写未授权访问,给出修复优先级」' },
|
|
917
|
+
},
|
|
918
|
+
},
|
|
919
|
+
output: {
|
|
920
|
+
schema: { type: 'json' },
|
|
921
|
+
render: function (args, value) {
|
|
922
|
+
if (!value || value.ok !== true) return [{ type: 'text', text: '撰写失败:' + ((value && value.error) || '未知错误') }]
|
|
923
|
+
const e = value.evidence || {}
|
|
924
|
+
const head = '报告已生成:' + value.title + '(' + value.chars + ' 字,reportId ' + value.reportId + ')'
|
|
925
|
+
const ev = '证据来源:' + (e.sessions || 0) + ' 个会话 · 矩阵已确认 ' + (e.matrixConfirmed || 0) + ' 条 / 疑似 '
|
|
926
|
+
+ (e.matrixSuspected || 0) + ' 条(' + (e.matrixFrom === 'service' ? '来自攻击矩阵插件' : e.matrixFrom === 'file' ? '直接读矩阵存储' : '无矩阵数据') + ')· 记忆 '
|
|
927
|
+
+ (e.memoryHits || 0) + ' 条' + (e.memoryAvailable === false ? '(记忆插件未运行)' : '')
|
|
928
|
+
const outline = (value.headings || []).length ? '章节:' + value.headings.join(' / ') : ''
|
|
929
|
+
return [{ type: 'text', text: [head, ev, outline, '正文见红队报告面板(可编辑 / 预览 / 导出 Word / 导入记忆)。'].filter(Boolean).join('\n') }]
|
|
930
|
+
},
|
|
931
|
+
},
|
|
932
|
+
execute: async function (args) {
|
|
933
|
+
await ensureLoaded()
|
|
934
|
+
const a = args && typeof args === 'object' ? args : {}
|
|
935
|
+
const report = newReport(clip(a.title || '', 200) || '未命名报告')
|
|
936
|
+
store.reports.push(report)
|
|
937
|
+
store.currentId = report.id
|
|
938
|
+
try {
|
|
939
|
+
const r = await runGenerate(report, a.instruction)
|
|
940
|
+
await persist()
|
|
941
|
+
const headings = []
|
|
942
|
+
const re = /^\s*##\s+(.+)$/gm
|
|
943
|
+
let m = re.exec(report.markdown)
|
|
944
|
+
while (m) { headings.push(clip(m[1], 60)); m = re.exec(report.markdown) }
|
|
945
|
+
return { ok: true, reportId: report.id, title: report.title, chars: r.chars, evidence: r.evidence, headings: headings.slice(0, 12) }
|
|
946
|
+
} catch (e) {
|
|
947
|
+
// 失败也别丢掉半成品:面板里能看到写到哪儿了;但一个字都没写出来时就别留空壳。
|
|
948
|
+
store.meta.generating = false
|
|
949
|
+
store.meta.progress = null
|
|
950
|
+
log('err', '工具撰写报告失败:' + msgOf(e))
|
|
951
|
+
if (!String(report.markdown || '').trim()) {
|
|
952
|
+
dropIfEmpty(report)
|
|
953
|
+
await persist()
|
|
954
|
+
return { ok: false, error: msgOf(e), reportId: '', chars: 0 }
|
|
955
|
+
}
|
|
956
|
+
report.updatedAt = nowMs()
|
|
957
|
+
await persist()
|
|
958
|
+
return { ok: false, error: msgOf(e), reportId: report.id, chars: String(report.markdown || '').length }
|
|
959
|
+
}
|
|
960
|
+
},
|
|
961
|
+
})
|
|
962
|
+
|
|
963
|
+
const listTool = harness.defineTool({
|
|
964
|
+
name: 'report_list',
|
|
965
|
+
description: '列出红队报告面板里已有的报告(id、标题、字数、生成时间、证据规模)。要引用或导出某一份时先用它拿 id。',
|
|
966
|
+
parameters: { type: 'object', properties: {} },
|
|
967
|
+
output: {
|
|
968
|
+
schema: { type: 'json' },
|
|
969
|
+
render: function (args, value) {
|
|
970
|
+
if (!value || value.ok !== true) return [{ type: 'text', text: '读取失败:' + ((value && value.error) || '未知错误') }]
|
|
971
|
+
if (!value.reports.length) return [{ type: 'text', text: '还没有报告。可以用 report_generate 生成一份。' }]
|
|
972
|
+
const lines = ['共 ' + value.reports.length + ' 份报告:']
|
|
973
|
+
for (const r of value.reports) lines.push('· ' + r.title + '(' + r.chars + ' 字,id ' + r.id + ',更新于 ' + fmtTime(r.updatedAt) + ')')
|
|
974
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
975
|
+
},
|
|
976
|
+
},
|
|
977
|
+
execute: async function () {
|
|
978
|
+
await ensureLoaded()
|
|
979
|
+
return { ok: true, reports: store.reports.map(reportSummary).sort(function (a, b) { return b.updatedAt - a.updatedAt }) }
|
|
980
|
+
},
|
|
981
|
+
})
|
|
982
|
+
|
|
983
|
+
const exportTool = harness.defineTool({
|
|
984
|
+
name: 'report_export',
|
|
985
|
+
description: '把红队报告导出成文件(md / html / docx 三选一),写到磁盘并返回绝对路径。要 Word 就传 format=docx。省略 reportId 时导出面板里当前那一份。',
|
|
986
|
+
parameters: {
|
|
987
|
+
type: 'object',
|
|
988
|
+
properties: {
|
|
989
|
+
reportId: { type: 'string', description: '报告 id(从 report_list 拿;省略时用当前那一份)' },
|
|
990
|
+
format: { type: 'string', enum: ['md', 'html', 'docx'], description: '导出格式,默认 docx' },
|
|
991
|
+
},
|
|
992
|
+
},
|
|
993
|
+
output: {
|
|
994
|
+
schema: { type: 'json' },
|
|
995
|
+
render: function (args, value) {
|
|
996
|
+
if (!value || value.ok !== true) return [{ type: 'text', text: '导出失败:' + ((value && value.error) || '未知错误') }]
|
|
997
|
+
const lines = ['已导出 ' + value.format + ':' + value.name + '(' + value.bytes + ' 字节)']
|
|
998
|
+
lines.push(value.path ? '落盘路径:' + value.path : '(没有落盘:' + (value.writeError || '未知原因') + ')')
|
|
999
|
+
if (value.writeError && value.path) lines.push('落盘失败:' + value.writeError)
|
|
1000
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
1001
|
+
},
|
|
1002
|
+
},
|
|
1003
|
+
execute: async function (args) {
|
|
1004
|
+
await ensureLoaded()
|
|
1005
|
+
const a = args && typeof args === 'object' ? args : {}
|
|
1006
|
+
const r = await exportReport(a.reportId, a.format || 'docx')
|
|
1007
|
+
if (r.ok) log('ok', '工具导出 ' + r.format + ':' + r.name + (r.path ? ' → ' + r.path : '(未落盘)'))
|
|
1008
|
+
else log('err', '工具导出失败:' + r.error)
|
|
1009
|
+
await persist()
|
|
1010
|
+
return r
|
|
1011
|
+
},
|
|
1012
|
+
})
|
|
1013
|
+
|
|
1014
|
+
for (const t of [genTool, listTool, exportTool]) harness.registerTool(ctx, t)
|
|
1015
|
+
|
|
1016
|
+
// ── RPC 句柄(客户端 host.call 调)────────────────────────────────────────
|
|
1017
|
+
harness.handle('snapshot', async function () {
|
|
1018
|
+
await ensureLoaded()
|
|
1019
|
+
return { ok: true, snapshot: snapshot() }
|
|
1020
|
+
})
|
|
1021
|
+
|
|
1022
|
+
harness.handle('saveSettings', async function (args) {
|
|
1023
|
+
await ensureLoaded()
|
|
1024
|
+
mergeSettings(args && typeof args === 'object' ? args : {})
|
|
1025
|
+
const sel = pickModel()
|
|
1026
|
+
log('info', '设置已保存(模型 ' + (sel.provider || '?') + '/' + (sel.model || '?') + ',来源 ' + sel.from + ';证据上限 ' + settings().digestMax + ' 字)')
|
|
1027
|
+
await persist()
|
|
1028
|
+
return { ok: true, snapshot: snapshot() }
|
|
1029
|
+
})
|
|
1030
|
+
|
|
1031
|
+
// 试算证据:把采集结果与真正喂给模型的 digest 打出来。
|
|
1032
|
+
// 「AI 写的报告不对」九成能从这一屏看出来:是证据没采到,还是提示词没说清。
|
|
1033
|
+
harness.handle('collect', async function (args) {
|
|
1034
|
+
await ensureLoaded()
|
|
1035
|
+
const ev = await collectEvidence()
|
|
1036
|
+
const digest = buildDigest(ev)
|
|
1037
|
+
const preview = Math.max(1000, Math.min(40000, intOf(args && args.preview, 8000)))
|
|
1038
|
+
store.meta.evidence = {
|
|
1039
|
+
sessions: ev.sessions.length, matrixConfirmed: ev.matrix.confirmed.length,
|
|
1040
|
+
matrixSuspected: ev.matrix.suspected.length, matrixFrom: ev.matrix.from,
|
|
1041
|
+
memoryHits: ev.memory.hits.length, memoryAvailable: ev.memory.available, digestChars: digest.length,
|
|
1042
|
+
}
|
|
1043
|
+
log('info', '试算证据:' + ev.sessions.length + ' 个会话,矩阵 ' + ev.matrix.confirmed.length + '/' + ev.matrix.suspected.length
|
|
1044
|
+
+ ',记忆 ' + ev.memory.hits.length + ' 条,digest ' + digest.length + ' 字')
|
|
1045
|
+
await persist()
|
|
1046
|
+
return {
|
|
1047
|
+
ok: true, snapshot: snapshot(),
|
|
1048
|
+
evidence: {
|
|
1049
|
+
workspace: ev.workspace,
|
|
1050
|
+
sessions: ev.sessions.map(function (d) { return { id: d.id, title: d.title, users: d.users.length, ops: d.ops, results: d.results, firstAt: d.firstAt, lastAt: d.lastAt } }),
|
|
1051
|
+
matrix: { from: ev.matrix.from, storePath: ev.matrix.storePath, total: ev.matrix.total, confirmed: ev.matrix.confirmed.length, suspected: ev.matrix.suspected.length, error: ev.matrix.error || null },
|
|
1052
|
+
memory: { available: ev.memory.available, count: ev.memory.hits.length, note: ev.memory.note || '' },
|
|
1053
|
+
queries: ev.queries,
|
|
1054
|
+
digestChars: digest.length,
|
|
1055
|
+
digest: digest.slice(0, preview),
|
|
1056
|
+
truncated: digest.length > preview,
|
|
1057
|
+
},
|
|
1058
|
+
}
|
|
1059
|
+
})
|
|
1060
|
+
|
|
1061
|
+
harness.handle('generate', async function (args) {
|
|
1062
|
+
await ensureLoaded()
|
|
1063
|
+
const a = args && typeof args === 'object' ? args : {}
|
|
1064
|
+
let report = a.reportId ? (store.reports.filter(function (r) { return r.id === String(a.reportId) })[0] || null) : currentReport()
|
|
1065
|
+
if (!report || a.createNew === true) {
|
|
1066
|
+
report = newReport(clip(a.title || '', 200) || '未命名报告')
|
|
1067
|
+
store.reports.push(report)
|
|
1068
|
+
}
|
|
1069
|
+
store.currentId = report.id
|
|
1070
|
+
const r = startGenerate(report, a.instruction !== undefined ? a.instruction : settings().instruction)
|
|
1071
|
+
if (r.ok !== true) return { ok: false, error: r.error, snapshot: snapshot() }
|
|
1072
|
+
await persist()
|
|
1073
|
+
return { ok: true, started: true, reportId: report.id, snapshot: snapshot() }
|
|
1074
|
+
})
|
|
1075
|
+
|
|
1076
|
+
harness.handle('saveDraft', async function (args) {
|
|
1077
|
+
await ensureLoaded()
|
|
1078
|
+
const a = args && typeof args === 'object' ? args : {}
|
|
1079
|
+
const report = store.reports.filter(function (r) { return r.id === String(a.id || '') })[0] || currentReport()
|
|
1080
|
+
if (!report) return { ok: false, error: '没有可保存的报告' }
|
|
1081
|
+
if (a.title !== undefined) report.title = clip(a.title || '未命名报告', 200)
|
|
1082
|
+
if (a.markdown !== undefined) report.markdown = String(a.markdown || '')
|
|
1083
|
+
report.updatedAt = nowMs()
|
|
1084
|
+
store.currentId = report.id
|
|
1085
|
+
store.meta.lastOp = { at: nowMs(), op: 'saveDraft', text: report.title + '(' + String(report.markdown).length + ' 字)' }
|
|
1086
|
+
await persist()
|
|
1087
|
+
return { ok: true, snapshot: snapshot() }
|
|
1088
|
+
})
|
|
1089
|
+
|
|
1090
|
+
harness.handle('select', async function (args) {
|
|
1091
|
+
await ensureLoaded()
|
|
1092
|
+
const id = String((args && args.id) || '')
|
|
1093
|
+
if (!store.reports.filter(function (r) { return r.id === id })[0]) return { ok: false, error: '找不到这份报告' }
|
|
1094
|
+
store.currentId = id
|
|
1095
|
+
await persist()
|
|
1096
|
+
return { ok: true, snapshot: snapshot() }
|
|
1097
|
+
})
|
|
1098
|
+
|
|
1099
|
+
harness.handle('create', async function (args) {
|
|
1100
|
+
await ensureLoaded()
|
|
1101
|
+
const report = newReport(clip((args && args.title) || '', 200) || '未命名报告')
|
|
1102
|
+
store.reports.push(report)
|
|
1103
|
+
store.currentId = report.id
|
|
1104
|
+
log('info', '新建报告:' + report.title)
|
|
1105
|
+
await persist()
|
|
1106
|
+
return { ok: true, id: report.id, snapshot: snapshot() }
|
|
1107
|
+
})
|
|
1108
|
+
|
|
1109
|
+
harness.handle('remove', async function (args) {
|
|
1110
|
+
await ensureLoaded()
|
|
1111
|
+
const ids = Array.isArray(args && args.ids) ? args.ids.map(String).filter(Boolean) : []
|
|
1112
|
+
if (!ids.length) return { ok: false, error: '没有选中要删除的报告' }
|
|
1113
|
+
const kept = []
|
|
1114
|
+
let n = 0
|
|
1115
|
+
for (const r of store.reports) {
|
|
1116
|
+
if (ids.indexOf(r.id) >= 0) { n++; continue }
|
|
1117
|
+
kept.push(r)
|
|
1118
|
+
}
|
|
1119
|
+
store.reports = kept
|
|
1120
|
+
if (ids.indexOf(store.currentId) >= 0) store.currentId = kept.length ? kept[kept.length - 1].id : ''
|
|
1121
|
+
log('warn', '删除报告 ' + n + ' 份')
|
|
1122
|
+
await persist()
|
|
1123
|
+
return { ok: true, deleted: n, snapshot: snapshot() }
|
|
1124
|
+
})
|
|
1125
|
+
|
|
1126
|
+
// 预览:返回**完整 HTML 文档**,客户端塞进 iframe.srcdoc —— 与导出的 HTML 是同一份实现,
|
|
1127
|
+
// 所以「预览看到的」就是「导出的」。不用在前端再写一遍 markdown 渲染。
|
|
1128
|
+
harness.handle('preview', async function (args) {
|
|
1129
|
+
await ensureLoaded()
|
|
1130
|
+
const a = args && typeof args === 'object' ? args : {}
|
|
1131
|
+
const report = store.reports.filter(function (r) { return r.id === String(a.id || '') })[0] || currentReport()
|
|
1132
|
+
const markdown = a.markdown !== undefined ? String(a.markdown || '') : String((report && report.markdown) || '')
|
|
1133
|
+
const title = (a.title !== undefined ? String(a.title || '') : String((report && report.title) || '')) || '红队报告'
|
|
1134
|
+
return { ok: true, html: rptBuildHtml({ title: title, markdown: markdown, meta: report ? reportMetaTable(report) : {} }), chars: markdown.length }
|
|
1135
|
+
})
|
|
1136
|
+
|
|
1137
|
+
harness.handle('export', async function (args) {
|
|
1138
|
+
await ensureLoaded()
|
|
1139
|
+
const a = args && typeof args === 'object' ? args : {}
|
|
1140
|
+
const r = await exportReport(a.id, a.format)
|
|
1141
|
+
if (r.ok) log('ok', '导出 ' + r.format + ':' + r.name + (r.path ? ' → ' + r.path : '(未落盘,走浏览器下载)'))
|
|
1142
|
+
else log('err', '导出失败:' + r.error)
|
|
1143
|
+
await persist()
|
|
1144
|
+
return r
|
|
1145
|
+
})
|
|
1146
|
+
|
|
1147
|
+
harness.handle('importToMemory', async function (args) {
|
|
1148
|
+
await ensureLoaded()
|
|
1149
|
+
const a = args && typeof args === 'object' ? args : {}
|
|
1150
|
+
try {
|
|
1151
|
+
const r = await importToMemory(a.id)
|
|
1152
|
+
await persist()
|
|
1153
|
+
return Object.assign({ snapshot: snapshot() }, r)
|
|
1154
|
+
} catch (e) {
|
|
1155
|
+
log('err', '导入记忆失败:' + msgOf(e))
|
|
1156
|
+
await persist()
|
|
1157
|
+
return { ok: false, error: msgOf(e), snapshot: snapshot() }
|
|
1158
|
+
}
|
|
1159
|
+
})
|
|
1160
|
+
|
|
1161
|
+
harness.handle('logClear', async function () {
|
|
1162
|
+
store.log = []
|
|
1163
|
+
await persist()
|
|
1164
|
+
return { ok: true, snapshot: snapshot() }
|
|
1165
|
+
})
|
|
1166
|
+
|
|
1167
|
+
ensureLoaded()
|
|
1168
|
+
.then(function () {
|
|
1169
|
+
console.log('[rtreport] 报告库 ' + store.reports.length + ' 份,落盘 ' + (store.meta.persistence || '?') + ':' + (store.meta.storePath || '(未解析)'))
|
|
1170
|
+
})
|
|
1171
|
+
.catch(function (e) { console.error('[rtreport] load failed:', msgOf(e)) })
|
|
1172
|
+
.then(function () { loaded = true })
|
|
1173
|
+
|
|
1174
|
+
console.log('[rtreport] redteam-report host half ready; tools = 3, outline =', OUTLINE.length, '节,导出格式 md/html/docx')
|
|
1175
|
+
|
|
1176
|
+
/* @DOCX@ */
|