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
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Transforms the original report UI without changing its stored source or RPC identity.
|
|
2
|
+
export function rptBuildWorkspaceClientSource(input) {
|
|
3
|
+
let source = input;
|
|
4
|
+
function once(old, value) {
|
|
5
|
+
if (source.split(old).length !== 2) throw new Error('报告客户端源码锚点不唯一:' + old.slice(0, 80));
|
|
6
|
+
source = source.replace(old, value);
|
|
7
|
+
}
|
|
8
|
+
once("['set', '设置'], ", '');
|
|
9
|
+
once(' const slots = ctx.slots', ` const slots = ctx.slots;
|
|
10
|
+
const reportHost = host;
|
|
11
|
+
let selection = { id:'', path:'', title:'', sessionId:'' };
|
|
12
|
+
let activationError = '';
|
|
13
|
+
let switchSequence = 0;
|
|
14
|
+
const subscribers = new Set();
|
|
15
|
+
const viewId = 'report-view-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
|
16
|
+
function publish(next) {
|
|
17
|
+
if (selection.id === next.id && selection.path === next.path && selection.title === next.title && selection.sessionId === next.sessionId) return;
|
|
18
|
+
selection = next;
|
|
19
|
+
activationError = '';
|
|
20
|
+
for (const notify of subscribers) notify();
|
|
21
|
+
}
|
|
22
|
+
function useWorkspace() {
|
|
23
|
+
const [value, setValue] = React.useState(() => selection);
|
|
24
|
+
React.useEffect(() => {
|
|
25
|
+
const notify = () => setValue(selection);
|
|
26
|
+
subscribers.add(notify); notify();
|
|
27
|
+
return () => subscribers.delete(notify);
|
|
28
|
+
}, []);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function WorkspaceObserver(props) {
|
|
32
|
+
const sessionId = props.useSessions(s => s.current || '');
|
|
33
|
+
const workspaceId = props.useWorkspaces(s => {
|
|
34
|
+
const w = s.items.find(item => item.sessionIds.includes(sessionId));
|
|
35
|
+
return w ? w.workspaceId : '';
|
|
36
|
+
});
|
|
37
|
+
const path = props.useWorkspaces(s => {
|
|
38
|
+
const w = s.items.find(item => item.workspaceId === workspaceId);
|
|
39
|
+
return w ? w.path : '';
|
|
40
|
+
});
|
|
41
|
+
const title = props.useWorkspaces(s => {
|
|
42
|
+
const w = s.items.find(item => item.workspaceId === workspaceId);
|
|
43
|
+
return w ? w.title : '';
|
|
44
|
+
});
|
|
45
|
+
React.useEffect(() => { publish({id:workspaceId,path:path,title:title,sessionId:sessionId}); }, [workspaceId,path,title,sessionId]);
|
|
46
|
+
React.useEffect(() => {
|
|
47
|
+
let live = true;
|
|
48
|
+
const sequence = ++switchSequence;
|
|
49
|
+
reportHost.call('activate', {workspaceId:'', viewId:viewId, sequence:sequence}).catch(() => {});
|
|
50
|
+
const cancel = ctx.timeout(() => {
|
|
51
|
+
reportHost.call('activate', {workspaceId:workspaceId, viewId:viewId, sequence:sequence}).then(result => {
|
|
52
|
+
if (live && result && result.ok === false) activationError = result.error || '自动报告启动失败';
|
|
53
|
+
}).catch(error => { if (live) activationError = String(error.message || error); });
|
|
54
|
+
}, 1200);
|
|
55
|
+
return () => { live = false; cancel(); };
|
|
56
|
+
}, [workspaceId]);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function ScopedPanel(props) {
|
|
60
|
+
const current = useWorkspace();
|
|
61
|
+
if (!current.id) return el('div', {className:'rtr-root'}, props && props.settingsOnly ? '报告设置:请先选择工作区' : '请先选择工作区;报告不会回退到其他工作区。');
|
|
62
|
+
return el(Panel, {key:current.id, workspaceId:current.id, workspacePath:current.path, workspaceTitle:current.title, settingsOnly:!!(props && props.settingsOnly)});
|
|
63
|
+
}
|
|
64
|
+
ctx.effect(() => slots.inject('shell.overlay', () => slots.register({name:'shell.overlay',id:'redteam-report-workspace-observer',order:0}, WorkspaceObserver)));
|
|
65
|
+
ctx.effect(() => settingsHub.register(function ReportSettings() { return el(ScopedPanel, {settingsOnly:true}); }));
|
|
66
|
+
ctx.effect(() => () => { subscribers.clear(); reportHost.call('activate', {workspaceId:'',viewId:viewId,sequence:++switchSequence}).catch(() => {}); });`);
|
|
67
|
+
once('function Panel() {', `function Panel(props) {
|
|
68
|
+
const settingsOnly = !!(props && props.settingsOnly);
|
|
69
|
+
const workspaceId = props.workspaceId;
|
|
70
|
+
const host = { call(method, args) { return reportHost.call(method, Object.assign({}, args || {}, {workspaceId:workspaceId})); } };`);
|
|
71
|
+
once(" const timer = ctx.get('timer')", " if (settingsOnly) return undefined;\n const timer = ctx.get('timer')");
|
|
72
|
+
once(" if (typeof ctx.interval !== 'function') return undefined", " if (settingsOnly || typeof ctx.interval !== 'function') return undefined");
|
|
73
|
+
once(' if (!liveRef.current.generating) return', ' // Always poll this scoped panel: automatic jobs can start while it is open.');
|
|
74
|
+
once(" snap && tab === 'set' ? setTab_() : null,", '');
|
|
75
|
+
once(" return el('div', { className: 'rtr-root' },", ` if (settingsOnly) return el('section', {className:'rtr-root', style:{borderTop:'1px solid var(--dsw-alias-border-l1)'}},
|
|
76
|
+
el('h2',{className:'rtr-brand'},'报告设置 · ' + (props.workspaceTitle || props.workspacePath)),
|
|
77
|
+
hint('以下配置仅用于当前工作区;报告库按工作区隔离。'),
|
|
78
|
+
error ? el('div',{className:'rtr-errbar'},error) : null,
|
|
79
|
+
toast ? el('div',{className:'rtr-ok'},toast) : null,
|
|
80
|
+
snap && draft ? setTab_() : el('div',{className:'rtr-dim'},'加载报告设置…'));
|
|
81
|
+
return el('div', { className: 'rtr-root' },`);
|
|
82
|
+
once(' head(),', ` head(),
|
|
83
|
+
hint('当前工作区:' + (props.workspaceTitle || '') + ' · ' + props.workspacePath),
|
|
84
|
+
hint('切换工作区自动采集并生成;相同证据复用已有报告,五分钟内不重复自动生成。配置入口:设置 → 红队设置。'),
|
|
85
|
+
activationError ? el('div',{className:'rtr-warn'},activationError) : null,
|
|
86
|
+
st && st.legacyUnassignedReports ? hint('旧版未归属工作区报告 ' + st.legacyUnassignedReports + ' 份仍保存在旧报告库,未自动迁移。') : null,
|
|
87
|
+
st && st.queued ? hint('等待自动报告任务…') : null,
|
|
88
|
+
st && st.lastError ? el('div',{className:'rtr-warn'},st.lastError) : null,`);
|
|
89
|
+
once("btn('保存设置'", "btn('保存报告设置'");
|
|
90
|
+
once(" card('撰写模型', '报告由它写', [", ` card('工作区自动报告', '切换触发', [
|
|
91
|
+
el('label',{className:'rtr-f'},
|
|
92
|
+
el('span',null,'启用此工作区的自动报告'),
|
|
93
|
+
el('input',{type:'checkbox',checked:draft.autoGenerate !== false,onChange:e=>setField('autoGenerate',e.target.checked)})),
|
|
94
|
+
hint('先保存配置。快速切换只保留最后一个待处理工作区;已开始的报告完成后只写回原工作区。'),
|
|
95
|
+
hint('文件枚举有数量、深度与摘录预算;跳过凭据、依赖、二进制和符号链接。采集边界写入报告。')
|
|
96
|
+
]),
|
|
97
|
+
card('撰写模型', '报告由它写', [`);
|
|
98
|
+
once("value: draft.storePath || '', onChange: function (e) { setField('storePath', e.target.value) }", "value: (st && st.storePath) || '', readOnly: true");
|
|
99
|
+
once('本机实测落在 /home/kali/桌面', '实际路径见下方');
|
|
100
|
+
once(" card('digest',", ` evidence.files ? card('工作区文件采集', '清单与限制', [
|
|
101
|
+
hint(JSON.stringify(evidence.files.stats)),
|
|
102
|
+
el('div',{className:'rtr-hint'},evidence.files.notes.join(';')),
|
|
103
|
+
el('pre',{className:'rtr-pre'},evidence.files.inventory.map(f=>f.path+' ['+f.status+']'+(f.reason?' '+f.reason:'')).join('\\n'))
|
|
104
|
+
]) : null,
|
|
105
|
+
card('digest',`);
|
|
106
|
+
once("}, Panel)", "}, ScopedPanel)");
|
|
107
|
+
return source;
|
|
108
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// Workspace evidence collection. Plain ESM; strip line-leading `export ` to inline
|
|
2
|
+
// into a dynamic Host. No imports, networking, commands, or business-file writes.
|
|
3
|
+
// The caller supplies the actual Harness fs service and an absolute workspace root.
|
|
4
|
+
// Fingerprints are deterministic change detectors, NOT cryptographic signatures.
|
|
5
|
+
|
|
6
|
+
export function rptRedactEvidence(text) {
|
|
7
|
+
let value = String(text == null ? '' : text)
|
|
8
|
+
value = value.replace(/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/gi, '[REDACTED PRIVATE KEY]')
|
|
9
|
+
value = value.replace(/\b(Bearer|Basic)([ \t]+)[A-Za-z0-9._~+\/=-]+/gi, '$1$2[REDACTED]')
|
|
10
|
+
value = value.replace(/\b(?:sk[-_][A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|github_pat_[A-Za-z0-9_]{12,}|npm_[A-Za-z0-9]{20,}|AKIA[A-Z0-9]{16})\b/g, '[REDACTED]')
|
|
11
|
+
value = value.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/@]+(?::[^\s/@]*)?@/gi, '$1[REDACTED]@')
|
|
12
|
+
// YAML literal/folded secrets: discard the indented body as well as its marker.
|
|
13
|
+
const key = '[A-Za-z0-9_-]*(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|authorization)[A-Za-z0-9_-]*'
|
|
14
|
+
value = value.replace(new RegExp('^([ \\t]*["\\\']?' + key + '["\\\']?[ \\t]*:[ \\t]*)[|>][-+]?[ \\t]*\\r?\\n(?:[ \\t]+[^\\r\\n]*(?:\\r?\\n|$))+', 'gim'), '$1[REDACTED]\n')
|
|
15
|
+
// JSON, YAML, .env, source assignments, headers, and URL query parameters.
|
|
16
|
+
// Keeping the key is useful evidence; never retain the matched value.
|
|
17
|
+
value = value.replace(new RegExp('((?:["\\\']?\\b' + key + '["\\\']?)[ \\t]*(?::|=)[ \\t]*)(\\[REDACTED(?: PRIVATE KEY)?\\]|"(?:\\\\.|[^"\\\\])*"|\\\'(?:\\\\.|[^\\\'\\\\])*\\\'|[^\\r\\n,;#}\\]&]+)', 'gi'), (match, prefix, secret) => prefix + (secret[0] === '"' ? '"[REDACTED]"' : secret[0] === "'" ? "'[REDACTED]'" : '[REDACTED]'))
|
|
18
|
+
return value
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function rptEvidenceFingerprint(text) {
|
|
22
|
+
const value = String(text == null ? '' : text)
|
|
23
|
+
let a = 0x811c9dc5
|
|
24
|
+
let b = 0x9e3779b9
|
|
25
|
+
for (let i = 0; i < value.length; i++) {
|
|
26
|
+
const c = value.charCodeAt(i)
|
|
27
|
+
a = Math.imul(a ^ c, 0x01000193) >>> 0
|
|
28
|
+
b = Math.imul(b ^ c, 0x85ebca6b) >>> 0
|
|
29
|
+
b = ((b << 13) | (b >>> 19)) >>> 0
|
|
30
|
+
}
|
|
31
|
+
return 'wev1-' + a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// opts (all limits are clamped; quotas can be zero except maxBytes):
|
|
35
|
+
// maxEntries=2000, maxDirectories=200 (includes root), maxDepth=8 (root=0),
|
|
36
|
+
// maxFiles=60 (bounds read attempts, including failures), maxFileChars=6000,
|
|
37
|
+
// maxTotalChars=100000, maxBytes=65536.
|
|
38
|
+
// generatedReportDirectories / generatedReportPaths: additional ROOT-RELATIVE
|
|
39
|
+
// paths to exclude. Defaults also exclude conventional generated-report folders.
|
|
40
|
+
// Inventory contains directory and file entries, including skipped entries, but
|
|
41
|
+
// never expands an excluded directory. Quotas bound both inventory and excerpts.
|
|
42
|
+
// listDir itself returns one whole directory in the Harness API; a large provider
|
|
43
|
+
// response cannot be paginated here. Only bounded names from it are retained.
|
|
44
|
+
export async function rptCollectWorkspaceFiles(fs, workspacePath, opts = {}) {
|
|
45
|
+
if (typeof workspacePath !== 'string' || !/^(?:\/|[A-Za-z]:[\\/])/.test(workspacePath) || /\0/.test(workspacePath)) {
|
|
46
|
+
throw new TypeError('workspacePath must be an absolute path without NUL')
|
|
47
|
+
}
|
|
48
|
+
for (const method of ['resolve', 'contains', 'processPath', 'stat', 'lstat', 'listDir']) {
|
|
49
|
+
if (!fs || typeof fs[method] !== 'function') throw new TypeError('Required fs boundary capability missing: ' + method)
|
|
50
|
+
}
|
|
51
|
+
const options = opts && typeof opts === 'object' ? opts : {}
|
|
52
|
+
function limit(name, fallback, maximum, minimum = 0) {
|
|
53
|
+
const n = options[name]
|
|
54
|
+
return typeof n === 'number' && Number.isFinite(n) ? Math.max(minimum, Math.min(maximum, Math.floor(n))) : fallback
|
|
55
|
+
}
|
|
56
|
+
const limits = {
|
|
57
|
+
entries: limit('maxEntries', 2000, 20000),
|
|
58
|
+
directories: limit('maxDirectories', 200, 2000),
|
|
59
|
+
depth: limit('maxDepth', 8, 32),
|
|
60
|
+
files: limit('maxFiles', 60, 500),
|
|
61
|
+
fileChars: limit('maxFileChars', 6000, 100000),
|
|
62
|
+
totalChars: limit('maxTotalChars', 100000, 2000000),
|
|
63
|
+
bytes: limit('maxBytes', 65536, 1048576, 1),
|
|
64
|
+
}
|
|
65
|
+
const files = []
|
|
66
|
+
const inventory = []
|
|
67
|
+
const skipped = []
|
|
68
|
+
const notes = []
|
|
69
|
+
const noteSet = new Set()
|
|
70
|
+
const stats = {
|
|
71
|
+
entriesEnumerated: 0, entriesOmitted: 0, directoriesVisited: 0,
|
|
72
|
+
directoriesNotTraversed: 0, filesSeen: 0, filesCollected: 0,
|
|
73
|
+
entriesSkipped: 0, readAttempts: 0, bytesRead: 0, charsCollected: 0, truncatedFiles: 0,
|
|
74
|
+
errors: 0, inventoryComplete: true,
|
|
75
|
+
limitHits: { entries: 0, directories: 0, depth: 0, files: 0, characters: 0 },
|
|
76
|
+
}
|
|
77
|
+
const dependencyDirs = new Set(['node_modules', 'vendor', 'bower_components', 'venv', 'env', '__pycache__', 'dist', 'build', 'target', 'out', 'coverage', 'cache', 'caches', 'tmp', 'temp'])
|
|
78
|
+
const reportDirs = new Set(['reports', 'generated-reports', 'generated_reports', 'redteam-reports', 'redteam_reports', 'report-output', 'report-outputs', 'report-exports', 'report_exports'])
|
|
79
|
+
const textExtensions = new Set(['md', 'markdown', 'txt', 'log', 'csv', 'tsv', 'json', 'jsonl', 'ndjson', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'xml', 'html', 'htm', 'css', 'scss', 'sass', 'less', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'h', 'cc', 'cpp', 'hpp', 'cs', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'sql', 'graphql', 'gql', 'proto', 'vue', 'svelte', 'r', 'kt', 'kts', 'swift', 'php', 'pl', 'ex', 'exs', 'erl', 'hrl', 'hs', 'lua'])
|
|
80
|
+
const textNames = new Set(['readme', 'license', 'licence', 'copying', 'notice', 'makefile', 'dockerfile', 'containerfile', 'gemfile', 'rakefile'])
|
|
81
|
+
function relativeExclusions(input) {
|
|
82
|
+
const result = new Set()
|
|
83
|
+
if (!Array.isArray(input)) return result
|
|
84
|
+
for (const item of input.slice(0, 200)) {
|
|
85
|
+
if (typeof item !== 'string' || item.startsWith('/') || item.includes('\\') || item.includes('\0')) continue
|
|
86
|
+
const path = item.replace(/\/+$/, '')
|
|
87
|
+
if (path && path.split('/').every(part => part && part !== '.' && part !== '..')) result.add(path)
|
|
88
|
+
}
|
|
89
|
+
return result
|
|
90
|
+
}
|
|
91
|
+
const extraReportDirs = relativeExclusions(options.generatedReportDirectories)
|
|
92
|
+
const extraReportPaths = relativeExclusions(options.generatedReportPaths)
|
|
93
|
+
function note(message) {
|
|
94
|
+
if (!noteSet.has(message) && notes.length < 64) { noteSet.add(message); notes.push(message) }
|
|
95
|
+
}
|
|
96
|
+
function quota(name) {
|
|
97
|
+
stats.limitHits[name]++
|
|
98
|
+
note('Quota reached: ' + name + '; inventory or excerpts are incomplete.')
|
|
99
|
+
}
|
|
100
|
+
function skip(record, reason, isDirectory = false) {
|
|
101
|
+
record.status = 'skipped'
|
|
102
|
+
record.reason = reason
|
|
103
|
+
skipped.push({ path: record.path, reason })
|
|
104
|
+
stats.entriesSkipped++
|
|
105
|
+
if (isDirectory) {
|
|
106
|
+
stats.directoriesNotTraversed++
|
|
107
|
+
stats.inventoryComplete = false
|
|
108
|
+
note('Excluded directories are listed but their contents are not enumerated.')
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function fail(record, reason, isDirectory = false) {
|
|
112
|
+
skip(record, reason, isDirectory)
|
|
113
|
+
record.status = 'error'
|
|
114
|
+
stats.errors++
|
|
115
|
+
stats.inventoryComplete = false
|
|
116
|
+
note('Some filesystem operations failed; no error payloads or outside-root paths are included.')
|
|
117
|
+
}
|
|
118
|
+
function credentialName(name) {
|
|
119
|
+
return /^(?:\.env(?:\.|$)|\.npmrc$|\.pypirc$|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.|$))/.test(name)
|
|
120
|
+
|| /(?:^|[._-])(?:secrets?|credentials?|passwords?|tokens?|api[-_]?keys?)(?:[._-]|$)/.test(name)
|
|
121
|
+
|| /\.(?:pem|key|p12|pfx|jks|keystore|gpg)$/.test(name)
|
|
122
|
+
}
|
|
123
|
+
function allowedText(name) {
|
|
124
|
+
if (textNames.has(name)) return true
|
|
125
|
+
const dot = name.lastIndexOf('.')
|
|
126
|
+
return dot >= 0 && textExtensions.has(name.slice(dot + 1))
|
|
127
|
+
}
|
|
128
|
+
function bytesOf(info) { return info && typeof info.size === 'number' && Number.isFinite(info.size) && info.size >= 0 ? Math.floor(info.size) : 0 }
|
|
129
|
+
function finish() {
|
|
130
|
+
stats.entriesEnumerated = inventory.length
|
|
131
|
+
stats.filesCollected = files.length
|
|
132
|
+
// These are all newly constructed records, not FsTargets or service objects.
|
|
133
|
+
const fingerprint = rptEvidenceFingerprint(JSON.stringify({ files, inventory, skipped, stats, notes }))
|
|
134
|
+
return { files, inventory, skipped, stats, fingerprint, notes }
|
|
135
|
+
}
|
|
136
|
+
note('Text is allowlisted and heuristically redacted; review evidence before sending it to a model. Unlabelled secrets may remain.')
|
|
137
|
+
let root
|
|
138
|
+
let rootPath
|
|
139
|
+
try {
|
|
140
|
+
const rootInfo = await fs.lstat(workspacePath)
|
|
141
|
+
if (!rootInfo || rootInfo.type !== 'directory') {
|
|
142
|
+
note(rootInfo && rootInfo.type === 'symlink' ? 'Workspace root is a symbolic link; collection refused.' : 'Workspace root is missing or not a directory; collection refused.')
|
|
143
|
+
stats.inventoryComplete = false
|
|
144
|
+
stats.errors++
|
|
145
|
+
return finish()
|
|
146
|
+
}
|
|
147
|
+
root = await fs.resolve(workspacePath)
|
|
148
|
+
if (!fs.contains(root, root)) throw new Error('Invalid root boundary')
|
|
149
|
+
rootPath = fs.processPath(root)
|
|
150
|
+
if (typeof rootPath !== 'string' || !rootPath) throw new Error('Missing canonical root path')
|
|
151
|
+
const info = await fs.stat(root)
|
|
152
|
+
if (!info || info.type !== 'directory') throw new Error('Invalid root type')
|
|
153
|
+
} catch (error) {
|
|
154
|
+
note('Workspace root could not be safely resolved; collection refused.')
|
|
155
|
+
stats.errors++
|
|
156
|
+
stats.inventoryComplete = false
|
|
157
|
+
return finish()
|
|
158
|
+
}
|
|
159
|
+
const visitedDirectories = new Set()
|
|
160
|
+
let enumerationStopped = false
|
|
161
|
+
async function walk(target, absolutePath, relativePath, depth, directoryRecord) {
|
|
162
|
+
if (enumerationStopped) return
|
|
163
|
+
if (stats.directoriesVisited >= limits.directories) {
|
|
164
|
+
quota('directories')
|
|
165
|
+
if (directoryRecord) skip(directoryRecord, 'directory-quota', true)
|
|
166
|
+
else { stats.directoriesNotTraversed++; stats.inventoryComplete = false }
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
if (visitedDirectories.has(absolutePath)) {
|
|
170
|
+
if (directoryRecord) skip(directoryRecord, 'directory-alias', true)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
visitedDirectories.add(absolutePath)
|
|
174
|
+
stats.directoriesVisited++
|
|
175
|
+
let entries
|
|
176
|
+
try {
|
|
177
|
+
if (!fs.contains(root, target)) throw new Error('Outside root')
|
|
178
|
+
entries = await fs.listDir(target)
|
|
179
|
+
if (!Array.isArray(entries)) throw new Error('Invalid directory listing')
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (directoryRecord) fail(directoryRecord, 'list-failed', true)
|
|
182
|
+
else { stats.errors++; stats.inventoryComplete = false; note('Workspace directory listing failed.') }
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
// Keep the lexicographically smallest bounded names even if the provider is
|
|
186
|
+
// unsorted. This retains deterministic output without copying live entries.
|
|
187
|
+
const remaining = limits.entries - inventory.length
|
|
188
|
+
const names = []
|
|
189
|
+
const seenNames = new Set()
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
const name = entry && typeof entry.name === 'string' ? entry.name : ''
|
|
192
|
+
if (!name || seenNames.has(name)) continue
|
|
193
|
+
// The bounded sorted prefix needs no unbounded Set of all returned names.
|
|
194
|
+
if (names.length >= remaining && name >= names[names.length - 1]) continue
|
|
195
|
+
let i = 0
|
|
196
|
+
while (i < names.length && names[i] < name) i++
|
|
197
|
+
names.splice(i, 0, name)
|
|
198
|
+
seenNames.add(name)
|
|
199
|
+
if (names.length > remaining) seenNames.delete(names.pop())
|
|
200
|
+
}
|
|
201
|
+
if (entries.length > names.length && remaining <= entries.length) {
|
|
202
|
+
stats.entriesOmitted += entries.length - names.length
|
|
203
|
+
stats.inventoryComplete = false
|
|
204
|
+
quota('entries')
|
|
205
|
+
}
|
|
206
|
+
for (let i = 0; i < names.length; i++) {
|
|
207
|
+
if (inventory.length >= limits.entries) {
|
|
208
|
+
stats.entriesOmitted += names.length - i
|
|
209
|
+
stats.inventoryComplete = false
|
|
210
|
+
quota('entries')
|
|
211
|
+
enumerationStopped = true
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
const name = names[i]
|
|
215
|
+
const safeName = name !== '.' && name !== '..' && !/[\\/\0]/.test(name)
|
|
216
|
+
const path = safeName ? (relativePath ? relativePath + '/' + name : name) : (relativePath ? relativePath + '/' : '') + '[invalid-entry]'
|
|
217
|
+
const record = { path, bytes: 0, status: 'pending', reason: '' }
|
|
218
|
+
inventory.push(record)
|
|
219
|
+
if (!safeName) { skip(record, 'invalid-entry-name'); stats.inventoryComplete = false; continue }
|
|
220
|
+
const lower = name.toLowerCase()
|
|
221
|
+
// Exclusions happen before resolution or content reads, so credentials are
|
|
222
|
+
// never opened merely to decide whether to omit them.
|
|
223
|
+
if (lower.startsWith('.redteam-report') || extraReportPaths.has(path) || credentialName(lower)) {
|
|
224
|
+
skip(record, lower.startsWith('.redteam-report') || extraReportPaths.has(path) ? 'generated-report' : 'credential-file')
|
|
225
|
+
stats.inventoryComplete = false
|
|
226
|
+
note('Credential/generated-report paths excluded before metadata checks use bytes=0 for unknown size and are never traversed.')
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
const candidatePath = absolutePath.replace(/[\\/]+$/, '') + '/' + name
|
|
230
|
+
let info
|
|
231
|
+
let child
|
|
232
|
+
let canonicalPath
|
|
233
|
+
try {
|
|
234
|
+
info = await fs.lstat(candidatePath)
|
|
235
|
+
if (!info) { fail(record, 'entry-disappeared'); continue }
|
|
236
|
+
record.bytes = bytesOf(info)
|
|
237
|
+
if (info.type === 'symlink') { skip(record, 'symbolic-link'); stats.inventoryComplete = false; note('Symbolic links are not followed; their target contents are not enumerated.'); continue }
|
|
238
|
+
child = await fs.resolve(candidatePath)
|
|
239
|
+
if (!fs.contains(root, child)) { skip(record, 'outside-workspace', info.type === 'directory'); stats.inventoryComplete = false; continue }
|
|
240
|
+
canonicalPath = fs.processPath(child)
|
|
241
|
+
if (typeof canonicalPath !== 'string' || !canonicalPath) throw new Error('Invalid process path')
|
|
242
|
+
const resolvedInfo = await fs.stat(child)
|
|
243
|
+
if (!resolvedInfo || resolvedInfo.type !== info.type) { fail(record, 'entry-type-changed', info.type === 'directory'); continue }
|
|
244
|
+
info = resolvedInfo
|
|
245
|
+
record.bytes = bytesOf(info)
|
|
246
|
+
} catch (error) { fail(record, 'metadata-failed'); continue }
|
|
247
|
+
const isDirectory = info.type === 'directory'
|
|
248
|
+
if (lower.startsWith('.')) { skip(record, 'hidden-entry', isDirectory); continue }
|
|
249
|
+
if (isDirectory) {
|
|
250
|
+
if (dependencyDirs.has(lower)) { skip(record, 'dependency-build-cache', true); continue }
|
|
251
|
+
if (reportDirs.has(lower) || extraReportDirs.has(path)) { skip(record, 'generated-report-directory', true); continue }
|
|
252
|
+
if (depth + 1 > limits.depth) { quota('depth'); skip(record, 'depth-quota', true); continue }
|
|
253
|
+
record.status = 'directory'
|
|
254
|
+
await walk(child, canonicalPath, path, depth + 1, record)
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
if (info.type !== 'file') { skip(record, 'non-regular-file'); continue }
|
|
258
|
+
stats.filesSeen++
|
|
259
|
+
if (!allowedText(lower)) { skip(record, 'not-text-allowlist'); continue }
|
|
260
|
+
if (stats.readAttempts >= limits.files) { quota('files'); skip(record, 'file-quota'); continue }
|
|
261
|
+
if (stats.charsCollected >= limits.totalChars || limits.fileChars === 0) { quota('characters'); skip(record, 'character-quota'); continue }
|
|
262
|
+
if (typeof TextDecoder !== 'function') { fail(record, 'text-decoder-unavailable'); continue }
|
|
263
|
+
let data
|
|
264
|
+
let byteTruncated = false
|
|
265
|
+
stats.readAttempts++
|
|
266
|
+
try {
|
|
267
|
+
// Recheck before reading: no path supplied by listDir is trusted.
|
|
268
|
+
if (!fs.contains(root, child)) { skip(record, 'outside-workspace'); stats.inventoryComplete = false; continue }
|
|
269
|
+
if (typeof fs.readByteRange === 'function') {
|
|
270
|
+
data = await fs.readByteRange(child, { offset: 0, length: limits.bytes })
|
|
271
|
+
} else if (record.bytes <= limits.bytes && typeof fs.readBytes === 'function') {
|
|
272
|
+
data = await fs.readBytes(child, undefined, limits.bytes)
|
|
273
|
+
} else {
|
|
274
|
+
fail(record, 'bounded-read-unavailable')
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
if (!data || typeof data.length !== 'number' || typeof data.subarray !== 'function') throw new Error('Invalid byte response')
|
|
278
|
+
stats.bytesRead += data.length
|
|
279
|
+
byteTruncated = record.bytes > data.length || data.length >= limits.bytes
|
|
280
|
+
if (data.length > limits.bytes) { data = data.subarray(0, limits.bytes); byteTruncated = true }
|
|
281
|
+
} catch (error) { fail(record, 'read-failed'); continue }
|
|
282
|
+
let text
|
|
283
|
+
try {
|
|
284
|
+
const decoder = new TextDecoder('utf-8', { fatal: true })
|
|
285
|
+
// Streaming decode accepts an incomplete final codepoint ONLY for a
|
|
286
|
+
// bounded prefix. Invalid UTF-8 (including 0xff at the end) still fails.
|
|
287
|
+
text = decoder.decode(data, { stream: byteTruncated })
|
|
288
|
+
} catch (error) { skip(record, 'non-utf8-text'); continue }
|
|
289
|
+
const controls = text.match(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g)
|
|
290
|
+
if (text.includes('\0') || (controls && controls.length > Math.max(1, text.length / 100))) { skip(record, 'binary-content'); continue }
|
|
291
|
+
text = rptRedactEvidence(text)
|
|
292
|
+
const room = Math.min(limits.fileChars, limits.totalChars - stats.charsCollected)
|
|
293
|
+
const charTruncated = text.length > room
|
|
294
|
+
if (charTruncated) {
|
|
295
|
+
text = text.slice(0, room)
|
|
296
|
+
// Avoid a dangling high surrogate at the character cutoff.
|
|
297
|
+
if (/[\ud800-\udbff]$/.test(text)) text = text.slice(0, -1)
|
|
298
|
+
}
|
|
299
|
+
const truncated = byteTruncated || charTruncated
|
|
300
|
+
if (truncated) { stats.truncatedFiles++; note('Some files contain only bounded, redacted excerpts; truncated=true does not represent the complete file.') }
|
|
301
|
+
if (charTruncated) quota('characters')
|
|
302
|
+
record.status = truncated ? 'excerpt' : 'collected'
|
|
303
|
+
record.reason = byteTruncated ? 'byte-limit-or-short-read' : (charTruncated ? 'character-limit' : '')
|
|
304
|
+
files.push({ path, bytes: record.bytes, text, truncated })
|
|
305
|
+
stats.charsCollected += text.length
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
await walk(root, rootPath, '', 0, null)
|
|
309
|
+
return finish()
|
|
310
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Production Host adapter. The legacy report engine remains an isolated closure
|
|
2
|
+
// inside workspace-runtime; this is the only global Tool/RPC registration layer.
|
|
3
|
+
import { rptCreateWorkspaceRuntime } from './workspace-runtime.js'
|
|
4
|
+
import { rptCollectWorkspaceFiles, rptRedactEvidence, rptEvidenceFingerprint } from './workspace-evidence.js'
|
|
5
|
+
|
|
6
|
+
export async function rptInstallWorkspaceReports(ctx, harness, sources) {
|
|
7
|
+
const runtime = await rptCreateWorkspaceRuntime(ctx, {
|
|
8
|
+
hostSource: sources.hostSource,
|
|
9
|
+
docxSource: sources.docxSource,
|
|
10
|
+
helpers: { rptCollectWorkspaceFiles, rptRedactEvidence, rptEvidenceFingerprint },
|
|
11
|
+
console,
|
|
12
|
+
})
|
|
13
|
+
ctx.effect(() => () => runtime.dispose())
|
|
14
|
+
const methods = ['activate', 'snapshot', 'saveSettings', 'collect', 'generate', 'saveDraft', 'select', 'create', 'remove', 'preview', 'export', 'importToMemory', 'logClear']
|
|
15
|
+
for (const method of methods) harness.handle(method, async args => {
|
|
16
|
+
try { return await runtime.call(method, args || {}) }
|
|
17
|
+
catch (error) { return { ok: false, error: String(error && error.message || error) } }
|
|
18
|
+
})
|
|
19
|
+
harness.handle('__health', () => runtime.activity())
|
|
20
|
+
const output = { schema: { type: 'json' }, render(args, value) { return [{ type: 'text', text: JSON.stringify(value) }] } }
|
|
21
|
+
const workspaceField = { type: 'string', description: '工作区 ID;省略时使用调用会话所属工作区,绝不回退到其他工作区。' }
|
|
22
|
+
harness.registerTool(ctx, harness.defineTool({
|
|
23
|
+
name: 'report_generate',
|
|
24
|
+
description: '收集当前工作区文件、会话、矩阵和参考知识并生成隔离报告;等待生成完成。',
|
|
25
|
+
parameters: { workspaceId: workspaceField, title: { type: 'string' }, instruction: { type: 'string' } }, output,
|
|
26
|
+
async execute(args, exec) {
|
|
27
|
+
try { return await runtime.generate(args || {}, exec) }
|
|
28
|
+
catch (error) { return { ok: false, error: String(error.message || error) } }
|
|
29
|
+
},
|
|
30
|
+
}))
|
|
31
|
+
harness.registerTool(ctx, harness.defineTool({
|
|
32
|
+
name: 'report_list', description: '只列出指定工作区的报告;默认使用调用会话所属工作区。',
|
|
33
|
+
parameters: { workspaceId: workspaceField }, output,
|
|
34
|
+
async execute(args, exec) {
|
|
35
|
+
try { const r = await runtime.call('snapshot', args || {}, exec); return { ok: true, workspace: r.snapshot.workspace, reports: r.snapshot.reports } }
|
|
36
|
+
catch (error) { return { ok: false, error: String(error.message || error) } }
|
|
37
|
+
},
|
|
38
|
+
}))
|
|
39
|
+
harness.registerTool(ctx, harness.defineTool({
|
|
40
|
+
name: 'report_export', description: '将当前工作区报告导出为 md/html/docx;拒绝其他工作区的报告 ID。',
|
|
41
|
+
parameters: { workspaceId: workspaceField, reportId: { type: 'string' }, format: { type: 'string', enum: ['md', 'html', 'docx'] } }, output,
|
|
42
|
+
async execute(args, exec) {
|
|
43
|
+
try {
|
|
44
|
+
const a = args || {}
|
|
45
|
+
const r = await runtime.call('export', { workspaceId: a.workspaceId, id: a.reportId || '', format: a.format || 'docx' }, exec)
|
|
46
|
+
return { ok: r.ok === true && !!r.path && !r.writeError, path: r.path || null, name: r.name || null, format: r.format || a.format || 'docx', bytes: r.bytes || 0, error: r.error || r.writeError || null }
|
|
47
|
+
} catch (error) { return { ok: false, error: String(error.message || error) } }
|
|
48
|
+
},
|
|
49
|
+
}))
|
|
50
|
+
harness.registerTool(ctx, harness.defineTool({
|
|
51
|
+
name: 'redteam_report_status', description: '检查当前工作区报告与自动任务;wait=true 只等待现有任务,不触发新生成。',
|
|
52
|
+
parameters: { workspaceId: workspaceField, wait: { type: 'boolean' } }, output,
|
|
53
|
+
async execute(args, exec) {
|
|
54
|
+
if (args && args.wait) await runtime.waitIdle()
|
|
55
|
+
try { return { ok: true, ...await runtime.call('status', args || {}, exec), activity: runtime.activity() } }
|
|
56
|
+
catch (error) { return { ok: false, error: String(error.message || error), activity: runtime.activity() } }
|
|
57
|
+
},
|
|
58
|
+
}))
|
|
59
|
+
return runtime
|
|
60
|
+
}
|