software-design-test 1.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/CHANGELOG.md +63 -0
- package/LICENSE +21 -0
- package/README.md +275 -0
- package/cordis.patch.yml +23 -0
- package/docs/FRAMEWORK.zh-en.md +149 -0
- package/docs/INSTALL.zh-en.md +226 -0
- package/docs/USAGE.zh-en.md +295 -0
- package/icon.svg +21 -0
- package/lib/index.js +197 -0
- package/lib/self-check.js +313 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +80 -0
- package/scripts/capture.mjs +318 -0
- package/scripts/guard.mjs +311 -0
- package/scripts/report.mjs +484 -0
- package/scripts/session.mjs +709 -0
- package/scripts/verify.mjs +205 -0
- package/skills/observed-test-plan/MATRIX.md +99 -0
- package/skills/observed-test-plan/PERMISSIONS.md +109 -0
- package/skills/observed-test-plan/PLAN-TEMPLATE.md +93 -0
- package/skills/observed-test-plan/SKILL.md +135 -0
- package/skills/observed-ui-test/BANNED-INPUTS.md +161 -0
- package/skills/observed-ui-test/EVIDENCE.md +113 -0
- package/skills/observed-ui-test/FRAMEWORK.md +181 -0
- package/skills/observed-ui-test/LEVELS.md +137 -0
- package/skills/observed-ui-test/REPORT-TEMPLATE.md +111 -0
- package/skills/observed-ui-test/SKILL.md +210 -0
- package/skills/software-design-test/DEFECTS.md +205 -0
- package/skills/software-design-test/HEURISTICS.md +236 -0
- package/skills/software-design-test/PERSONAS-SCENARIOS.md +159 -0
- package/skills/software-design-test/SKILL.md +211 -0
- package/skills/software-design-test/SOURCES.md +118 -0
- package/skills/software-design-test/TEST-CONTENT.md +257 -0
- package/skills/software-design-test/WORKFLOW.md +293 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* capture.mjs — read-only screen capture for observed-UI-test sessions.
|
|
4
|
+
*
|
|
5
|
+
* READ-ONLY BY CONSTRUCTION. This file contains no pointer, touch or key events, no
|
|
6
|
+
* accessibility control, no automation driver, and no "input" subcommand. It only
|
|
7
|
+
* records what is already on the screen, so the evidence chain stays legitimate and
|
|
8
|
+
* the tester remains the only hand on the machine.
|
|
9
|
+
*
|
|
10
|
+
* 只读捕获:本文件不含任何输入注入能力,也不提供任何"输入"子命令。
|
|
11
|
+
*
|
|
12
|
+
* Usage / 用法:
|
|
13
|
+
* node scripts/capture.mjs check
|
|
14
|
+
* node scripts/capture.mjs shot <session> --label L1-07-after [--target macos|ios-sim|android|windows]
|
|
15
|
+
* [--window] [--note "..."]
|
|
16
|
+
* node scripts/capture.mjs record <session> --label L1 --seconds 60 [--target ...] [--note "..."]
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
20
|
+
import { existsSync } from 'node:fs'
|
|
21
|
+
import { appendFile, mkdir, open, readFile } from 'node:fs/promises'
|
|
22
|
+
import { join, resolve } from 'node:path'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
24
|
+
import { parseArgs } from 'node:util'
|
|
25
|
+
|
|
26
|
+
const TARGETS = ['macos', 'windows', 'ios-sim', 'android']
|
|
27
|
+
|
|
28
|
+
const stamp = () => new Date().toISOString().replace(/[:.]/g, '-')
|
|
29
|
+
const isMain = () =>
|
|
30
|
+
process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
31
|
+
|
|
32
|
+
function usage() {
|
|
33
|
+
return `observed-ui-test capture tool (read-only; never injects input)
|
|
34
|
+
|
|
35
|
+
check
|
|
36
|
+
shot <session> --label <label> [--target ${TARGETS.join('|')}] [--window] [--note "..."]
|
|
37
|
+
record <session> --label <label> --seconds <n> [--target ${TARGETS.join('|')}] [--note "..."]
|
|
38
|
+
|
|
39
|
+
Targets default to the host: ${process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'macos'}.
|
|
40
|
+
|
|
41
|
+
This tool captures only. For the banned list of input-injecting directives see the
|
|
42
|
+
observed-ui-test skill: BANNED-INPUTS.md.
|
|
43
|
+
`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function which(command) {
|
|
47
|
+
const probe = process.platform === 'win32' ? 'where' : 'which'
|
|
48
|
+
const result = spawnSync(probe, [command], { encoding: 'utf8' })
|
|
49
|
+
return result.status === 0 ? String(result.stdout).trim().split('\n')[0].trim() : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function defaultTarget() {
|
|
53
|
+
if (process.platform === 'darwin') return 'macos'
|
|
54
|
+
if (process.platform === 'win32') return 'windows'
|
|
55
|
+
return 'macos'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function requireSession(dir) {
|
|
59
|
+
const file = join(dir, 'session.json')
|
|
60
|
+
if (!existsSync(file)) {
|
|
61
|
+
throw new Error(`not an observed-UI-test session (no session.json): ${dir}`)
|
|
62
|
+
}
|
|
63
|
+
return JSON.parse(await readFile(file, 'utf8'))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function noteEvidence(dir, record) {
|
|
67
|
+
await mkdir(join(dir, 'evidence'), { recursive: true })
|
|
68
|
+
await appendFile(join(dir, 'evidence', 'index.jsonl'), JSON.stringify(record) + '\n', 'utf8')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function describeTools() {
|
|
72
|
+
const rows = [
|
|
73
|
+
['screencapture', which('screencapture'), 'macOS 截屏/录屏 (screenshot + video)'],
|
|
74
|
+
['xcrun', which('xcrun'), 'iOS/iPadOS 模拟器捕获 (simctl screenshot/recordVideo)'],
|
|
75
|
+
['adb', which('adb'), 'Android 只读捕获 (screencap/screenrecord)'],
|
|
76
|
+
[
|
|
77
|
+
'powershell',
|
|
78
|
+
which('powershell') ?? which('pwsh'),
|
|
79
|
+
'Windows 只读截屏 (System.Drawing screen capture)',
|
|
80
|
+
],
|
|
81
|
+
]
|
|
82
|
+
return rows
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function cmdCheck() {
|
|
86
|
+
const lines = [
|
|
87
|
+
'只读捕获自检 / read-only capture self-check',
|
|
88
|
+
`主机 platform: ${process.platform} 默认目标 default target: ${defaultTarget()}`,
|
|
89
|
+
'',
|
|
90
|
+
'工具 tool 路径 path 用途 purpose',
|
|
91
|
+
]
|
|
92
|
+
for (const [name, path, purpose] of describeTools()) {
|
|
93
|
+
lines.push(` ${name.padEnd(24)} ${(path ?? '(unavailable)').padEnd(32)} ${purpose}`)
|
|
94
|
+
}
|
|
95
|
+
lines.push('')
|
|
96
|
+
lines.push('合规 / compliance:')
|
|
97
|
+
lines.push(' - 本工具只读屏,不注入鼠标/触摸/按键 / capture only, no pointer, touch or key events')
|
|
98
|
+
lines.push(' - macOS 首次截屏会触发「屏幕录制」权限提示;授予后需重启应用')
|
|
99
|
+
lines.push(' macOS triggers the Screen Recording prompt; restart the app after granting')
|
|
100
|
+
lines.push(' - iPhone/iPad 真机请用控制中心录屏或 QuickTime 连续互通')
|
|
101
|
+
lines.push(' - Windows 录屏请人工按 Win+G(Game Bar);截屏由本工具完成')
|
|
102
|
+
lines.push('')
|
|
103
|
+
lines.push('下一步 / next: node scripts/capture.mjs shot <session> --label permission-probe')
|
|
104
|
+
process.stdout.write(lines.join('\n') + '\n')
|
|
105
|
+
return 0
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function runWithTimeout(command, args, seconds, options = {}) {
|
|
109
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
110
|
+
const child = spawn(command, args, { stdio: options.stdio ?? 'inherit' })
|
|
111
|
+
const timer = setTimeout(() => {
|
|
112
|
+
child.kill('SIGINT')
|
|
113
|
+
}, Math.max(1, seconds) * 1000)
|
|
114
|
+
child.on('error', (error) => {
|
|
115
|
+
clearTimeout(timer)
|
|
116
|
+
rejectPromise(error)
|
|
117
|
+
})
|
|
118
|
+
child.on('exit', (code, signal) => {
|
|
119
|
+
clearTimeout(timer)
|
|
120
|
+
resolvePromise({ code, signal })
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function runSync(command, args) {
|
|
126
|
+
return spawnSync(command, args, { encoding: 'utf8' })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function captureStill(target, file, options) {
|
|
130
|
+
if (target === 'macos') {
|
|
131
|
+
const args = options.window ? ['-x', '-w', file] : ['-x', file]
|
|
132
|
+
if (options.window) {
|
|
133
|
+
process.stdout.write('请用鼠标点击要截取的窗口 / click the window to capture…\n')
|
|
134
|
+
}
|
|
135
|
+
const result = runSync('screencapture', args)
|
|
136
|
+
if (result.status !== 0) {
|
|
137
|
+
throw new Error(`screencapture failed: ${result.stderr || result.status}`)
|
|
138
|
+
}
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
if (target === 'ios-sim') {
|
|
142
|
+
const result = runSync('xcrun', ['simctl', 'io', 'booted', 'screenshot', file])
|
|
143
|
+
if (result.status !== 0) {
|
|
144
|
+
throw new Error(`simctl screenshot failed: ${result.stderr || result.status}`)
|
|
145
|
+
}
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if (target === 'android') {
|
|
149
|
+
const handle = await open(file, 'w')
|
|
150
|
+
try {
|
|
151
|
+
const result = await new Promise((resolvePromise) => {
|
|
152
|
+
const child = spawn('adb', ['exec-out', 'screencap', '-p'], { stdio: ['ignore', handle.fd, 'inherit'] })
|
|
153
|
+
child.on('error', () => resolvePromise({ status: 1 }))
|
|
154
|
+
child.on('exit', (code) => resolvePromise({ status: code }))
|
|
155
|
+
})
|
|
156
|
+
if (result.status !== 0) throw new Error('adb screencap failed')
|
|
157
|
+
} finally {
|
|
158
|
+
await handle.close()
|
|
159
|
+
}
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
if (target === 'windows') {
|
|
163
|
+
const script = [
|
|
164
|
+
'Add-Type -AssemblyName System.Drawing,System.Windows.Forms;',
|
|
165
|
+
'$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds;',
|
|
166
|
+
'$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height;',
|
|
167
|
+
'$g = [System.Drawing.Graphics]::FromImage($bmp);',
|
|
168
|
+
'$g.CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size);',
|
|
169
|
+
`$bmp.Save('${file.replace(/'/g, "''")}');`,
|
|
170
|
+
].join(' ')
|
|
171
|
+
const shell = which('pwsh') ?? which('powershell')
|
|
172
|
+
if (shell === null) throw new Error('no PowerShell available for Windows capture')
|
|
173
|
+
const result = runSync(shell, ['-NoProfile', '-Command', script])
|
|
174
|
+
if (result.status !== 0) throw new Error(`PowerShell capture failed: ${result.stderr || result.status}`)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`unsupported target: ${target}`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function captureVideo(target, file, seconds) {
|
|
181
|
+
if (target === 'macos') {
|
|
182
|
+
const result = await runWithTimeout('screencapture', ['-v', file], seconds)
|
|
183
|
+
if (result.code !== 0 && result.code !== null) {
|
|
184
|
+
throw new Error(`screencapture -v exited with ${result.code}`)
|
|
185
|
+
}
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
if (target === 'ios-sim') {
|
|
189
|
+
const result = await runWithTimeout(
|
|
190
|
+
'xcrun',
|
|
191
|
+
['simctl', 'io', 'booted', 'recordVideo', '--codec', 'h264', file],
|
|
192
|
+
seconds,
|
|
193
|
+
)
|
|
194
|
+
if (result.code !== 0 && result.code !== null) {
|
|
195
|
+
throw new Error(`simctl recordVideo exited with ${result.code}`)
|
|
196
|
+
}
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
if (target === 'android') {
|
|
200
|
+
const remote = `/sdcard/observed-ui-test-${Date.now()}.mp4`
|
|
201
|
+
const result = runSync('adb', [
|
|
202
|
+
'shell',
|
|
203
|
+
'screenrecord',
|
|
204
|
+
'--time-limit',
|
|
205
|
+
String(seconds),
|
|
206
|
+
remote,
|
|
207
|
+
])
|
|
208
|
+
if (result.status !== 0) throw new Error(`adb screenrecord failed: ${result.stderr}`)
|
|
209
|
+
const pull = runSync('adb', ['pull', remote, file])
|
|
210
|
+
if (pull.status !== 0) throw new Error(`adb pull failed: ${pull.stderr}`)
|
|
211
|
+
runSync('adb', ['shell', 'rm', '-f', remote])
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
if (target === 'windows') {
|
|
215
|
+
throw new Error(
|
|
216
|
+
'Windows 录屏需人工触发 / record Windows video by hand: press Win+G (Xbox Game Bar) and save to the session evidence folder.',
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
throw new Error(`unsupported target: ${target}`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function cmdShot(positionals, values) {
|
|
223
|
+
const dir = resolve(positionals[0] ?? '')
|
|
224
|
+
const session = await requireSession(dir)
|
|
225
|
+
const label = values.label ?? 'shot'
|
|
226
|
+
if (!values.label) throw new Error('shot needs --label (use the case id, e.g. L1-07-after)')
|
|
227
|
+
const target = values.target ?? defaultTarget()
|
|
228
|
+
if (!TARGETS.includes(target)) throw new Error(`--target must be one of ${TARGETS.join(', ')}`)
|
|
229
|
+
const file = join(dir, 'evidence', `${stamp()}-${sanitize(label)}.png`)
|
|
230
|
+
|
|
231
|
+
await mkdir(join(dir, 'evidence'), { recursive: true })
|
|
232
|
+
await captureStill(target, file, { window: values.window === true })
|
|
233
|
+
|
|
234
|
+
await noteEvidence(dir, {
|
|
235
|
+
kind: 'screenshot',
|
|
236
|
+
file: relativeTo(dir, file),
|
|
237
|
+
label,
|
|
238
|
+
at: new Date().toISOString(),
|
|
239
|
+
target,
|
|
240
|
+
platform: session.platform,
|
|
241
|
+
note: values.note ?? '',
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
process.stdout.write(`截图已保存 / screenshot saved: ${file}\n`)
|
|
245
|
+
process.stdout.write('用观察者的眼睛读图 / read it with the observer:\n')
|
|
246
|
+
process.stdout.write(` read_image ${file}\n`)
|
|
247
|
+
process.stdout.write('然后逐元素描述画面,不要凭记忆 / describe the pixels, not your memory.\n')
|
|
248
|
+
return 0
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function cmdRecord(positionals, values) {
|
|
252
|
+
const dir = resolve(positionals[0] ?? '')
|
|
253
|
+
const session = await requireSession(dir)
|
|
254
|
+
const label = values.label ?? 'record'
|
|
255
|
+
const seconds = Number(values.seconds ?? 30)
|
|
256
|
+
if (!Number.isFinite(seconds) || seconds < 1) throw new Error('--seconds must be a positive number')
|
|
257
|
+
const target = values.target ?? defaultTarget()
|
|
258
|
+
if (!TARGETS.includes(target)) throw new Error(`--target must be one of ${TARGETS.join(', ')}`)
|
|
259
|
+
const extension = target === 'macos' ? 'mov' : 'mp4'
|
|
260
|
+
const file = join(dir, 'evidence', `${stamp()}-${sanitize(label)}.${extension}`)
|
|
261
|
+
|
|
262
|
+
await mkdir(join(dir, 'evidence'), { recursive: true })
|
|
263
|
+
process.stdout.write(`开始录制 / recording ${seconds}s … 现在开始操作并口述动作。/ narrate as you act.\n`)
|
|
264
|
+
await captureVideo(target, file, seconds)
|
|
265
|
+
|
|
266
|
+
await noteEvidence(dir, {
|
|
267
|
+
kind: 'recording',
|
|
268
|
+
file: relativeTo(dir, file),
|
|
269
|
+
label,
|
|
270
|
+
at: new Date().toISOString(),
|
|
271
|
+
seconds,
|
|
272
|
+
target,
|
|
273
|
+
platform: session.platform,
|
|
274
|
+
note: values.note ?? '',
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
process.stdout.write(`录屏已保存 / recording saved: ${file}\n`)
|
|
278
|
+
process.stdout.write('记下关键时间点(mm:ss)写进缺陷证据 / note mm:ss timestamps in each finding.\n')
|
|
279
|
+
return 0
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const sanitize = (label) => String(label).replace(/[^\w.-]+/g, '_').slice(0, 60)
|
|
283
|
+
const relativeTo = (dir, file) => file.slice(dir.length + 1)
|
|
284
|
+
|
|
285
|
+
async function main() {
|
|
286
|
+
const { positionals, values } = parseArgs({
|
|
287
|
+
args: process.argv.slice(2),
|
|
288
|
+
allowPositionals: true,
|
|
289
|
+
options: {
|
|
290
|
+
label: { type: 'string' },
|
|
291
|
+
target: { type: 'string' },
|
|
292
|
+
seconds: { type: 'string' },
|
|
293
|
+
note: { type: 'string' },
|
|
294
|
+
window: { type: 'boolean', default: false },
|
|
295
|
+
},
|
|
296
|
+
})
|
|
297
|
+
const command = positionals.shift() ?? 'check'
|
|
298
|
+
try {
|
|
299
|
+
if (command === 'check') return await cmdCheck()
|
|
300
|
+
if (command === 'shot') return await cmdShot(positionals, values)
|
|
301
|
+
if (command === 'record') return await cmdRecord(positionals, values)
|
|
302
|
+
if (command === 'help' || command === '--help' || command === '-h') {
|
|
303
|
+
process.stdout.write(usage())
|
|
304
|
+
return 0
|
|
305
|
+
}
|
|
306
|
+
process.stderr.write(`unknown command: ${command}\n\n${usage()}`)
|
|
307
|
+
return 2
|
|
308
|
+
} catch (error) {
|
|
309
|
+
process.stderr.write(`error: ${error.message}\n`)
|
|
310
|
+
return 2
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (isMain()) {
|
|
315
|
+
process.exitCode = await main()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export { cmdCheck, cmdRecord, cmdShot, sanitize }
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* guard.mjs — injection WATCHDOG for observed-UI-test sessions.
|
|
4
|
+
*
|
|
5
|
+
* It observes input-injection directives; it never performs one. Everything here is a read:
|
|
6
|
+
* process listing, and text scanning of the session's own plan and findings. There is no
|
|
7
|
+
* pointer, touch or key event, no accessibility control, no automation driver, and no
|
|
8
|
+
* subcommand that could produce one.
|
|
9
|
+
*
|
|
10
|
+
* 只"看"注入,不"做"注入:本工具扫描进程与计划文本,把违规线索记成合规证据。
|
|
11
|
+
*
|
|
12
|
+
* Why it exists / 为什么需要它:
|
|
13
|
+
* 测试纪律要求"机器上不允许有自动化工具在跑"。这个守门器把这条纪律变成可检查的事实:
|
|
14
|
+
* 跑测试之前扫一遍,测试期间每隔几秒盯一遍,任何疑似注入工具出现就写进 evidence/compliance.jsonl,
|
|
15
|
+
* 最终出现在报告里。它不能证明"绝对没有注入",但能让偷用注入留下痕迹。
|
|
16
|
+
*
|
|
17
|
+
* Usage / 用法:
|
|
18
|
+
* node scripts/guard.mjs scan [<session>] [--json]
|
|
19
|
+
* node scripts/guard.mjs watch <session> --seconds 300 [--interval 5]
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from 'node:child_process'
|
|
23
|
+
import { existsSync } from 'node:fs'
|
|
24
|
+
import { appendFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
|
25
|
+
import { join, resolve } from 'node:path'
|
|
26
|
+
import { fileURLToPath } from 'node:url'
|
|
27
|
+
import { parseArgs } from 'node:util'
|
|
28
|
+
|
|
29
|
+
/** Tokens that betray an input-injection or UI-automation surface. */
|
|
30
|
+
const PROCESS_TOKENS = [
|
|
31
|
+
['cliclick', 'macOS 命令行合成鼠标/按键 / CLI pointer and key injection'],
|
|
32
|
+
['pyautogui', 'Python 合成输入 / synthetic input'],
|
|
33
|
+
['autohotkey', 'Windows 宏与合成输入 / macro and synthetic input'],
|
|
34
|
+
['ahk_', 'AutoHotkey 运行进程 / AutoHotkey host'],
|
|
35
|
+
['appium', 'UI 自动化框架 / UI automation framework'],
|
|
36
|
+
['webdriveragent', 'iOS 自动化驱动 / iOS automation driver'],
|
|
37
|
+
['wda-runner', 'iOS 自动化驱动 / iOS automation driver'],
|
|
38
|
+
['idb', 'iOS 注入式交互 / iOS injected interaction'],
|
|
39
|
+
['scrcpy', 'Android 投屏控制 / Android mirroring with control'],
|
|
40
|
+
['xdotool', 'X11 合成输入 / X11 synthetic input'],
|
|
41
|
+
['wtype', 'Wayland 合成输入 / Wayland synthetic input'],
|
|
42
|
+
['ydotool', 'Wayland 合成输入 / Wayland synthetic input'],
|
|
43
|
+
['playwright', '浏览器自动化 / browser automation'],
|
|
44
|
+
['puppeteer', '浏览器自动化 / browser automation'],
|
|
45
|
+
['selenium', '浏览器自动化 / browser automation'],
|
|
46
|
+
['chromedriver', '浏览器自动化 / browser automation'],
|
|
47
|
+
['geckodriver', '浏览器自动化 / browser automation'],
|
|
48
|
+
['wda', 'iOS 自动化驱动 / iOS automation driver'],
|
|
49
|
+
['robotframework', '自动化测试框架 / automation framework'],
|
|
50
|
+
['sikuli', '图像识别点击 / image-driven clicking'],
|
|
51
|
+
['macrodroid', 'Android 宏 / Android macro'],
|
|
52
|
+
['nircmd', 'Windows 合成输入 / Windows synthetic input'],
|
|
53
|
+
['xcodebuild', '可能是 XCUITest 自动化 / possibly XCUITest automation'],
|
|
54
|
+
['remote-debugging-port', '浏览器 CDP 自动化入口 / browser CDP automation endpoint'],
|
|
55
|
+
['--headless', '无头浏览器(无法作为真实界面证据)/ headless browser (no real UI evidence)'],
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
/** Directive names banned inside a written test plan. */
|
|
59
|
+
const PLAN_TOKENS = [
|
|
60
|
+
'CGEventPost',
|
|
61
|
+
'CGEventCreateMouseEvent',
|
|
62
|
+
'CGWarpMouseCursorPosition',
|
|
63
|
+
'IOHIDPostEvent',
|
|
64
|
+
'cliclick',
|
|
65
|
+
'AXUIElementPerformAction',
|
|
66
|
+
'kAXPressAction',
|
|
67
|
+
'XCUIElement',
|
|
68
|
+
'SendInput',
|
|
69
|
+
'mouse_event',
|
|
70
|
+
'keybd_event',
|
|
71
|
+
'SetCursorPos',
|
|
72
|
+
'WM_LBUTTONDOWN',
|
|
73
|
+
'SendKeys',
|
|
74
|
+
'InvokePattern',
|
|
75
|
+
'adb shell input',
|
|
76
|
+
'input tap',
|
|
77
|
+
'dispatchEvent',
|
|
78
|
+
'dispatchMouseEvent',
|
|
79
|
+
'element.click()',
|
|
80
|
+
'page.click',
|
|
81
|
+
'pyautogui',
|
|
82
|
+
'AutoHotkey',
|
|
83
|
+
'osascript -e',
|
|
84
|
+
"System Events",
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
const isMain = () =>
|
|
88
|
+
process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
89
|
+
|
|
90
|
+
const nowIso = () => new Date().toISOString()
|
|
91
|
+
|
|
92
|
+
function listProcesses() {
|
|
93
|
+
if (process.platform === 'win32') {
|
|
94
|
+
const shell = findOnPath('pwsh') ?? findOnPath('powershell')
|
|
95
|
+
if (shell === null) return []
|
|
96
|
+
const result = spawnSync(shell, [
|
|
97
|
+
'-NoProfile',
|
|
98
|
+
'-Command',
|
|
99
|
+
'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.CommandLine)" }',
|
|
100
|
+
], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 })
|
|
101
|
+
if (result.status !== 0 || !result.stdout) return []
|
|
102
|
+
return result.stdout
|
|
103
|
+
.split('\n')
|
|
104
|
+
.map((line) => line.trim())
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.map((line) => {
|
|
107
|
+
const space = line.indexOf(' ')
|
|
108
|
+
return { pid: Number(line.slice(0, space)), command: space > 0 ? line.slice(space + 1) : line }
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
const result = spawnSync('ps', ['-ax', '-o', 'pid=,args='], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 })
|
|
112
|
+
if (result.status !== 0 || !result.stdout) return []
|
|
113
|
+
return result.stdout
|
|
114
|
+
.split('\n')
|
|
115
|
+
.map((line) => line.trim())
|
|
116
|
+
.filter(Boolean)
|
|
117
|
+
.map((line) => {
|
|
118
|
+
const space = line.indexOf(' ')
|
|
119
|
+
return { pid: Number(line.slice(0, space)), command: space > 0 ? line.slice(space + 1) : line }
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function findOnPath(command) {
|
|
124
|
+
const probe = process.platform === 'win32' ? 'where' : 'which'
|
|
125
|
+
const result = spawnSync(probe, [command], { encoding: 'utf8' })
|
|
126
|
+
return result.status === 0 ? String(result.stdout).trim().split('\n')[0].trim() : null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Read-only scan: which running processes look like input-injection surfaces. */
|
|
130
|
+
function scanProcesses() {
|
|
131
|
+
const hits = []
|
|
132
|
+
for (const process_ of listProcesses()) {
|
|
133
|
+
const haystack = process_.command.toLowerCase()
|
|
134
|
+
for (const [token, reason] of PROCESS_TOKENS) {
|
|
135
|
+
if (haystack.includes(token.toLowerCase())) {
|
|
136
|
+
hits.push({ kind: 'process', pid: process_.pid, command: process_.command.slice(0, 240), token, reason })
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return hits
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Read-only scan: does the session's own written plan name a banned directive? */
|
|
144
|
+
async function scanSessionText(dir) {
|
|
145
|
+
const hits = []
|
|
146
|
+
let entries = []
|
|
147
|
+
try {
|
|
148
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
149
|
+
} catch {
|
|
150
|
+
return hits
|
|
151
|
+
}
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
|
|
154
|
+
const file = join(dir, entry.name)
|
|
155
|
+
const lines = (await readFile(file, 'utf8')).split('\n')
|
|
156
|
+
for (const [index, line] of lines.entries()) {
|
|
157
|
+
for (const token of PLAN_TOKENS) {
|
|
158
|
+
if (line.includes(token)) {
|
|
159
|
+
hits.push({ kind: 'plan-text', file: entry.name, line: index + 1, token, text: line.trim().slice(0, 200) })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (existsSync(join(dir, 'findings.jsonl'))) {
|
|
165
|
+
const lines = (await readFile(join(dir, 'findings.jsonl'), 'utf8')).split('\n')
|
|
166
|
+
for (const [index, line] of lines.entries()) {
|
|
167
|
+
for (const token of PLAN_TOKENS) {
|
|
168
|
+
if (line.includes(token)) {
|
|
169
|
+
hits.push({ kind: 'finding-text', file: 'findings.jsonl', line: index + 1, token, text: line.trim().slice(0, 200) })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return hits
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function appendCompliance(dir, records) {
|
|
178
|
+
if (records.length === 0) return
|
|
179
|
+
await mkdir(join(dir, 'evidence'), { recursive: true })
|
|
180
|
+
await appendFile(
|
|
181
|
+
join(dir, 'evidence', 'compliance.jsonl'),
|
|
182
|
+
records.map((record) => JSON.stringify(record)).join('\n') + '\n',
|
|
183
|
+
'utf8',
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function printHits(hits, label) {
|
|
188
|
+
if (hits.length === 0) {
|
|
189
|
+
process.stdout.write(` ${label}: 未发现 / none found\n`)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
for (const hit of hits) {
|
|
193
|
+
if (hit.kind === 'process') {
|
|
194
|
+
process.stdout.write(` [疑似注入进程 / suspected injection process] pid=${hit.pid} token=${hit.token} — ${hit.reason}\n`)
|
|
195
|
+
process.stdout.write(` ${hit.command}\n`)
|
|
196
|
+
} else {
|
|
197
|
+
process.stdout.write(` [计划里出现被禁指令 / banned directive in text] ${hit.file}:${hit.line} token=${hit.token}\n`)
|
|
198
|
+
process.stdout.write(` ${hit.text}\n`)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function cmdScan(positionals) {
|
|
204
|
+
process.stdout.write('输入注入观察 / injection watch (read-only; observes, never performs)\n')
|
|
205
|
+
const hits = scanProcesses()
|
|
206
|
+
printHits(hits, '运行中的注入类工具 injection tooling in processes')
|
|
207
|
+
|
|
208
|
+
let sessionHits = []
|
|
209
|
+
const target = positionals[0]
|
|
210
|
+
if (target) {
|
|
211
|
+
const dir = resolve(target)
|
|
212
|
+
if (!existsSync(join(dir, 'session.json'))) {
|
|
213
|
+
throw new Error(`not an observed-UI-test session (no session.json): ${dir}`)
|
|
214
|
+
}
|
|
215
|
+
sessionHits = await scanSessionText(dir)
|
|
216
|
+
printHits(sessionHits, '测试文本里的被禁指令 banned directives in session text')
|
|
217
|
+
const record = {
|
|
218
|
+
at: nowIso(),
|
|
219
|
+
kind: 'scan',
|
|
220
|
+
processHits: hits,
|
|
221
|
+
textHits: sessionHits,
|
|
222
|
+
clean: hits.length === 0 && sessionHits.length === 0,
|
|
223
|
+
}
|
|
224
|
+
await appendCompliance(dir, [record])
|
|
225
|
+
await writeFile(join(dir, 'evidence', 'compliance-scan.json'), JSON.stringify(record, null, 2) + '\n', 'utf8')
|
|
226
|
+
process.stdout.write(` 已写入 / written: ${join(dir, 'evidence', 'compliance.jsonl')}\n`)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
process.stdout.write('\n人工核对 / manual checks:\n')
|
|
230
|
+
process.stdout.write(' - macOS 辅助功能/输入监控权限是否授予了非必要应用(本方法不需要控制权限)\n')
|
|
231
|
+
process.stdout.write(' - 键盘宏、按键精灵、RPA、浏览器自动化扩展是否已退出\n')
|
|
232
|
+
process.stdout.write(' - 被测机器上的远程控制软件是否已关闭\n')
|
|
233
|
+
process.stdout.write(
|
|
234
|
+
`\n结论 / result: ${hits.length + sessionHits.length === 0 ? '未见注入线索 (not proof of absence)' : '发现疑似注入线索,需人工确认并记录 / suspected injection found, confirm by hand'}\n`,
|
|
235
|
+
)
|
|
236
|
+
return hits.length + sessionHits.length === 0 ? 0 : 1
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function cmdWatch(positionals, values) {
|
|
240
|
+
const dir = resolve(positionals[0] ?? '')
|
|
241
|
+
if (!existsSync(join(dir, 'session.json'))) {
|
|
242
|
+
throw new Error(`not an observed-UI-test session (no session.json): ${dir}`)
|
|
243
|
+
}
|
|
244
|
+
const seconds = Number(values.seconds ?? 300)
|
|
245
|
+
const interval = Math.max(1, Number(values.interval ?? 5))
|
|
246
|
+
if (!Number.isFinite(seconds) || seconds < 1) throw new Error('--seconds must be a positive number')
|
|
247
|
+
|
|
248
|
+
const seen = new Set()
|
|
249
|
+
const startedAt = Date.now()
|
|
250
|
+
process.stdout.write(
|
|
251
|
+
`开始监视注入线索 / watching for injection for ${seconds}s (every ${interval}s). ` +
|
|
252
|
+
`本进程只读进程表,不注入任何输入。\n`,
|
|
253
|
+
)
|
|
254
|
+
let total = 0
|
|
255
|
+
while ((Date.now() - startedAt) / 1000 < seconds) {
|
|
256
|
+
const hits = scanProcesses()
|
|
257
|
+
const fresh = hits.filter((hit) => {
|
|
258
|
+
const key = `${hit.pid}|${hit.token}`
|
|
259
|
+
if (seen.has(key)) return false
|
|
260
|
+
seen.add(key)
|
|
261
|
+
return true
|
|
262
|
+
})
|
|
263
|
+
if (fresh.length > 0) {
|
|
264
|
+
total += fresh.length
|
|
265
|
+
printHits(fresh, '新发现 / newly seen')
|
|
266
|
+
await appendCompliance(dir, fresh.map((hit) => ({ at: nowIso(), kind: 'process', ...hit })))
|
|
267
|
+
}
|
|
268
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, interval * 1000))
|
|
269
|
+
}
|
|
270
|
+
if (total === 0) {
|
|
271
|
+
await appendCompliance(dir, [{ at: nowIso(), kind: 'watch', seconds, interval, clean: true }])
|
|
272
|
+
}
|
|
273
|
+
process.stdout.write(
|
|
274
|
+
`监视结束 / watch finished. 新线索 new hits: ${total}. 记录 / log: evidence/compliance.jsonl\n`,
|
|
275
|
+
)
|
|
276
|
+
return total === 0 ? 0 : 1
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function main() {
|
|
280
|
+
const { positionals, values } = parseArgs({
|
|
281
|
+
args: process.argv.slice(2),
|
|
282
|
+
allowPositionals: true,
|
|
283
|
+
options: {
|
|
284
|
+
seconds: { type: 'string' },
|
|
285
|
+
interval: { type: 'string' },
|
|
286
|
+
},
|
|
287
|
+
})
|
|
288
|
+
const command = positionals.shift() ?? 'scan'
|
|
289
|
+
try {
|
|
290
|
+
if (command === 'scan') return await cmdScan(positionals)
|
|
291
|
+
if (command === 'watch') return await cmdWatch(positionals, values)
|
|
292
|
+
if (command === 'help' || command === '--help' || command === '-h') {
|
|
293
|
+
process.stdout.write(
|
|
294
|
+
'guard.mjs — observe injection directives (read-only)\n\n' +
|
|
295
|
+
' scan [<session>]\n watch <session> --seconds 300 [--interval 5]\n',
|
|
296
|
+
)
|
|
297
|
+
return 0
|
|
298
|
+
}
|
|
299
|
+
process.stderr.write(`unknown command: ${command}\n`)
|
|
300
|
+
return 2
|
|
301
|
+
} catch (error) {
|
|
302
|
+
process.stderr.write(`error: ${error.message}\n`)
|
|
303
|
+
return 2
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (isMain()) {
|
|
308
|
+
process.exitCode = await main()
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export { PLAN_TOKENS, PROCESS_TOKENS, scanProcesses, scanSessionText }
|