Files
easynode/server/test/test-ai-data.js
T

217 lines
7.3 KiB
JavaScript

/**
* Agent 敏感数据与文件审批测试
*
* 运行:node test/test-ai-data.js
*/
import { EventEmitter } from 'node:events'
import {
classifyReadPath,
containsCoreCredentialPath,
DataRisk,
stricterDataRisk
} from '../app/ai/data-policy.js'
import { redact } from '../app/ai/redact.js'
import { fit, read, clearBySession } from '../app/ai/output-store.js'
import {
buildFullReplacementDiff,
buildWriteFilePreviewWithSftp,
MAX_WRITE_FILE_BYTES,
validateWriteFileInput
} from '../app/ai/write-preview.js'
import {
backupPathForAttempt,
createUniqueBackup,
formatBackupTimestamp
} from '../app/ai/file-backup.js'
let passed = 0
let failed = 0
const failures = []
function expect(label, actual, want) {
if (JSON.stringify(actual) === JSON.stringify(want)) {
passed += 1
return
}
failed += 1
failures.push(` ${ label }\n 期望: ${ JSON.stringify(want) }\n 实际: ${ JSON.stringify(actual) }`)
}
function assert(label, condition) {
if (condition) {
passed += 1
return
}
failed += 1
failures.push(` ${ label }\n 断言失败`)
}
function expectThrow(label, fn, matcher) {
try {
fn()
failed += 1
failures.push(` ${ label }\n 期望抛出但成功返回了`)
} catch (error) {
if (matcher.test(error.message)) {
passed += 1
return
}
failed += 1
failures.push(` ${ label }\n 错误不符预期: ${ error.message }`)
}
}
console.log('\n========== 敏感路径分级 ==========')
expect('/etc/shadow 高危读取', classifyReadPath('/etc/shadow').risk, DataRisk.HIGH)
expect('SSH 私钥高危读取', classifyReadPath('/root/.ssh/id_rsa').risk, DataRisk.HIGH)
expect('AWS credentials 高危读取', classifyReadPath('/root/.aws/credentials').risk, DataRisk.HIGH)
expect('大写 SSH 私钥名高危读取', classifyReadPath('/root/.SSH/ID_RSA').risk, DataRisk.HIGH)
expect('解释器表达式中的 shadow 路径可识别', containsCoreCredentialPath('open("/etc/shadow").read()'), true)
expect('普通绝对路径不误判', containsCoreCredentialPath('open("/etc/nginx/nginx.conf").read()'), false)
expect('.env 需要确认', classifyReadPath('/opt/app/.env').risk, DataRisk.HIGH)
expect('shell history 需要确认', classifyReadPath('/root/.bash_history').risk, DataRisk.HIGH)
expect('普通配置正常读取', classifyReadPath('/etc/nginx/nginx.conf').risk, DataRisk.NORMAL)
expect(
'符号链接真实路径采用更严格分级',
stricterDataRisk(
classifyReadPath('/tmp/current-config'),
classifyReadPath('/etc/shadow')
).risk,
DataRisk.HIGH
)
console.log('\n========== 脱敏与输出隔离 ==========')
{
const shadow = 'root:$6$salt$hash:19793:0:99999:7:::'
assert('shadow 哈希被脱敏', !redact(shadow).text.includes('$6$salt$hash'))
const quoted = 'AWS_SECRET_ACCESS_KEY = \'abc def ghi jkl\''
assert('带空格的引号 secret 被脱敏', !redact(quoted).text.includes('abc def ghi jkl'))
const sessionId = 'data-session'
const secret = 'token=super-secret-value'
const stored = fit(`${ secret }\n${ 'x'.repeat(9 * 1024) }`, { sessionId })
assert('长输出生成回读 handle', Boolean(stored.handle))
expect('错误会话不能回读', read(stored.handle, { sessionId: 'other' }).ok, false)
const result = read(stored.handle, { sessionId })
expect('当前会话可以回读', result.ok, true)
assert('暂存内容本身已经脱敏', !result.content.includes('super-secret-value'))
const approved = fit('token=approved-secret-value', { sessionId }, { allowSensitive: true })
assert('批准后的敏感读取保留原始内容', approved.text.includes('approved-secret-value'))
clearBySession(sessionId)
}
console.log('\n========== 文件写入预览 ==========')
{
const diff = buildFullReplacementDiff('/etc/app.conf', 'a=1\nb=2\n', 'a=1\nb=3\nc=4\n', false)
assert('diff 包含完整旧内容', diff.includes('-b=2'))
assert('diff 包含完整新内容', diff.includes('+b=3') && diff.includes('+c=4'))
assert('diff 标记目标路径', diff.includes('+++ /etc/app.conf (proposed)'))
expect('合法写入参数通过', validateWriteFileInput({
path: '/etc/app.conf',
content: 'ok',
mode: '0644'
}).bytes, 2)
expectThrow('相对路径被拒绝', () => validateWriteFileInput({
path: 'app.conf',
content: 'ok'
}), /绝对路径/)
expect('核心系统文件可进入高危审批', validateWriteFileInput({
path: '/etc/../etc/passwd',
content: 'root:x:0:0:root:/root:/bin/bash\n'
}).pathname, '/etc/../etc/passwd')
const protectedPreview = await buildWriteFilePreviewWithSftp({
stat(_path, callback) {
callback(null, { isDirectory: () => false, size: 0, mode: 0o100644 })
},
realpath(_path, callback) {
callback(null, '/etc/passwd')
},
createReadStream() {
const stream = new EventEmitter()
queueMicrotask(() => stream.emit('end'))
return stream
}
}, 'host-1', {
path: '/tmp/config-link',
content: 'replacement'
})
expect('符号链接真实路径进入预览', protectedPreview.realPath, '/etc/passwd')
expectThrow('非法权限被拒绝', () => validateWriteFileInput({
path: '/etc/app.conf',
content: 'ok',
mode: '999'
}), /八进制/)
expectThrow('超大内容被拒绝', () => validateWriteFileInput({
path: '/etc/app.conf',
content: 'x'.repeat(MAX_WRITE_FILE_BYTES + 1)
}), /预览上限/)
expectThrow('二进制内容被拒绝', () => validateWriteFileInput({
path: '/etc/app.conf',
content: 'text\0binary'
}), /二进制/)
}
console.log('\n========== 唯一备份文件 ==========')
{
const fixedTime = Date.parse('2026-07-31T12:34:56.789Z')
const timestamp = formatBackupTimestamp(fixedTime)
expect('备份时间戳不含路径非法字符', timestamp, '20260731T123456789Z')
expect(
'碰撞序号追加在时间戳后',
backupPathForAttempt('/etc/app.conf', timestamp, 2),
'/etc/app.conf.bak.20260731T123456789Z.2'
)
const occupied = new Set([
'/etc/app.conf.bak.20260731T123456789Z',
'/etc/app.conf.bak.20260731T123456789Z.1'
])
const copied = []
const backupPath = await createUniqueBackup({}, '/etc/app.conf', {
now: () => fixedTime,
pathExists: async (_sftp, pathname) => occupied.has(pathname),
copy: async (_sftp, from, to) => copied.push({ from, to })
})
expect('既有备份不会被覆盖', backupPath, '/etc/app.conf.bak.20260731T123456789Z.2')
expect('实际复制到选中的唯一路径', copied, [{
from: '/etc/app.conf',
to: '/etc/app.conf.bak.20260731T123456789Z.2'
}])
const raced = new Set()
let copyAttempts = 0
const racedPath = await createUniqueBackup({}, '/etc/race.conf', {
now: () => fixedTime,
pathExists: async (_sftp, pathname) => raced.has(pathname),
copy: async (_sftp, _from, to) => {
copyAttempts += 1
if (copyAttempts === 1) {
raced.add(to)
throw Object.assign(new Error('Failure'), {
backupOrigin: 'target',
backupTargetOpened: false
})
}
}
})
expect('并发创建碰撞后自动换序号', racedPath, '/etc/race.conf.bak.20260731T123456789Z.1')
}
console.log('\n==================================')
if (failed === 0) {
console.log(`✅ 全部通过 (${ passed } 项)`)
process.exit(0)
}
console.log(`❌ ${ failed } 项失败 / 共 ${ passed + failed } 项\n`)
console.log(failures.join('\n\n'))
process.exit(1)