/** * shiroko-monitor 安全与探测能力验证(开发用) * * 覆盖: * A. banner 探测:SSH 标识串识别、expectBanner 策略匹配/不符、静默端口超时、默认不回显版本 * B. 信息泄露控制:detail 不含地址/版本、redactDetails 脱敏、公开配置不含真实地址 * C. 私有配置分层:local.monitors.json 覆盖 host/port 后探测才可连通 * D. /api/refresh 滥用防护:冷却限流 429 + Retry-After、allowRefresh=false 时 403 * E. 来源判定 isPrivateAddress 单元校验 * * 用法:node scripts/verify-security.cjs */ const fs = require('node:fs'); const os = require('node:os'); const net = require('node:net'); const path = require('node:path'); const { spawn } = require('node:child_process'); const ROOT = path.join(__dirname, '..'); const PROBER = path.join(ROOT, 'prober', 'node', 'index.js'); const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-sec-')); const SSH_PORT = 2222; const SILENT_PORT = 2223; const results = []; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function check(name, pass, detail) { results.push({ name, pass }); console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`); } async function waitForHttp(url, timeoutMs = 8000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { await fetch(url, { cache: 'no-store' }); return true; } catch { await sleep(200); } } return false; } function startProber(args, env = {}) { return spawn(process.execPath, [PROBER, ...args], { env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'], }); } function runOnce(args) { return new Promise((resolve) => { const child = startProber(args); let out = ''; child.stdout.on('data', (d) => (out += d)); child.stderr.on('data', (d) => (out += d)); child.on('exit', (code) => resolve({ code, out })); }); } async function main() { // ---------- 准备:本地假 SSH 服务 + 静默端口 ---------- const sshServer = net.createServer((sock) => { // 忠实模拟 OpenSSH:连接建立后立即发送标识串(RFC 4253) sock.write('SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13\r\n'); }); const silentServer = net.createServer(() => { /* 接受连接但从不发送数据 */ }); await new Promise((r) => sshServer.listen(SSH_PORT, '127.0.0.1', r)); await new Promise((r) => silentServer.listen(SILENT_PORT, '127.0.0.1', r)); console.log(`[准备] 假 SSH 服务 127.0.0.1:${SSH_PORT},静默端口 127.0.0.1:${SILENT_PORT},临时目录 ${TMP}`); // ---------- A/B/C:banner 探测 + 脱敏 + 私有配置分层 ---------- const pubConfig = path.join(TMP, 'monitors.json'); const localConfig = path.join(TMP, 'local.monitors.json'); const outJson = path.join(TMP, 'status.json'); fs.writeFileSync(pubConfig, JSON.stringify({ settings: { title: 'sec-test', redactDetails: false }, monitors: [ { id: 'ssh-banner', name: 'SSH 服务', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: 22, timeout: 5, expectBanner: '^SSH-2\\.0-' }, { id: 'ssh-reveal', name: 'SSH(回显)', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: 22, timeout: 5, expectBanner: '^SSH-2\\.0-', revealBanner: true }, { id: 'ssh-mismatch', name: 'SSH(策略不符)', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: 22, timeout: 5, expectBanner: '^SSH-1\\.99-' }, { id: 'silent-banner', name: '静默端口 banner', group: '基础设施', type: 'banner', host: 'placeholder.invalid', port: SILENT_PORT, timeout: 2 }, { id: 'silent-tcp', name: '静默端口 TCP', group: '基础设施', type: 'tcp', host: 'placeholder.invalid', port: SILENT_PORT, timeout: 2 }, ], }, null, 2)); // 真实地址只存在于私有覆盖中(模拟「公开仓库不含内网地址」) fs.writeFileSync(localConfig, JSON.stringify({ monitors: [ { id: 'ssh-banner', host: '127.0.0.1', port: SSH_PORT }, { id: 'ssh-reveal', host: '127.0.0.1', port: SSH_PORT }, { id: 'ssh-mismatch', host: '127.0.0.1', port: SSH_PORT }, { id: 'silent-banner', host: '127.0.0.1', port: SILENT_PORT }, { id: 'silent-tcp', host: '127.0.0.1', port: SILENT_PORT }, ], }, null, 2)); console.log('\n[A] banner 探测(私有配置覆盖地址后执行一轮探测)'); const once = await runOnce(['--once', `--config=${pubConfig}`, `--local-config=${localConfig}`, `--out=${outJson}`]); const snap = JSON.parse(fs.readFileSync(outJson, 'utf8')); const m = snap.monitors; check('私有配置分层生效(占位地址被覆盖为本地地址后探测成功)', m['ssh-banner'].status === 'up', `ssh-banner=${m['ssh-banner'].status} · ${m['ssh-banner'].detail}`); check('SSH 标识串识别为在线且只给协议族摘要', m['ssh-banner'].detail === 'SSH 响应正常', `detail="${m['ssh-banner'].detail}"`); check('默认不回显版本号(detail 不含 OpenSSH)', !/OpenSSH/.test(m['ssh-banner'].detail), m['ssh-banner'].detail); check('expectBanner 策略不符时判为离线且不回显实际标识串', m['ssh-mismatch'].status === 'down' && !/OpenSSH/.test(m['ssh-mismatch'].detail), `detail="${m['ssh-mismatch'].detail}"`); check('显式开启 revealBanner 后才回显标识串', /OpenSSH/.test(m['ssh-reveal'].detail), `detail="${m['ssh-reveal'].detail}"`); check('静默端口 banner 探测超时判离线', m['silent-banner'].status === 'down', `detail="${m['silent-banner'].detail}"`); check('同一静默端口 TCP 探测可达(证明两者判定逻辑不同)', m['silent-tcp'].status === 'up', `detail="${m['silent-tcp'].detail}"`); check('TCP detail 不再包含地址', !m['silent-tcp'].detail.includes('127.0.0.1') && !m['silent-tcp'].detail.includes(':'), m['silent-tcp'].detail); const rawSnapshot = fs.readFileSync(outJson, 'utf8'); check('快照中不出现公开配置的占位地址', !rawSnapshot.includes('placeholder.invalid'), '未泄露 host'); check('快照中不出现真实内网地址', !rawSnapshot.includes('127.0.0.1'), '未泄露 host'); console.log('\n[B] redactDetails 脱敏'); const redacted = JSON.parse(fs.readFileSync(pubConfig, 'utf8')); redacted.settings.redactDetails = true; fs.writeFileSync(pubConfig, JSON.stringify(redacted, null, 2)); const outRedacted = path.join(TMP, 'status-redacted.json'); await runOnce(['--once', `--config=${pubConfig}`, `--local-config=${localConfig}`, `--out=${outRedacted}`]); const rSnap = JSON.parse(fs.readFileSync(outRedacted, 'utf8')); check('redactDetails 开启后 detail 只保留正常/异常', rSnap.monitors['ssh-reveal'].detail === '正常' && rSnap.monitors['silent-tcp'].detail === '正常', JSON.stringify(Object.fromEntries(Object.entries(rSnap.monitors).map(([k, v]) => [k, v.detail])))); // ---------- D:刷新接口滥用防护 ---------- console.log('\n[D] /api/refresh 滥用防护'); const cooldownCfg = path.join(TMP, 'cooldown.json'); fs.writeFileSync(cooldownCfg, JSON.stringify({ settings: { title: 't', refreshCooldownSec: 3 }, monitors: [] }, null, 2)); const srvA = startProber([`--config=${cooldownCfg}`, `--local-config=${cooldownCfg}`, `--out=${path.join(TMP, 'a.json')}`], { PORT: '8897' }); await waitForHttp('http://127.0.0.1:8897/api/status'); const r1 = await fetch('http://127.0.0.1:8897/api/refresh'); const r2 = await fetch('http://127.0.0.1:8897/api/refresh'); check('首次刷新放行', r1.status === 200, `status=${r1.status}`); check('冷却期内再次刷新被限流', r2.status === 429, `status=${r2.status}, retry-after=${r2.headers.get('retry-after')}s`); check('限流响应带 Retry-After 头', Number(r2.headers.get('retry-after')) >= 1, `retry-after=${r2.headers.get('retry-after')}`); await sleep(3300); const r3 = await fetch('http://127.0.0.1:8897/api/refresh'); check('冷却结束后可再次刷新', r3.status === 200, `status=${r3.status}`); srvA.kill(); const disabledCfg = path.join(TMP, 'disabled.json'); fs.writeFileSync(disabledCfg, JSON.stringify({ settings: { title: 't', allowRefresh: false }, monitors: [] }, null, 2)); const srvB = startProber([`--config=${disabledCfg}`, `--local-config=${disabledCfg}`, `--out=${path.join(TMP, 'b.json')}`], { PORT: '8898' }); await waitForHttp('http://127.0.0.1:8898/api/status'); const r4 = await fetch('http://127.0.0.1:8898/api/refresh'); const body4 = await r4.json().catch(() => ({})); check('allowRefresh:false 时刷新被拒绝', r4.status === 403, `status=${r4.status}, error=${body4.error}`); check('拒绝响应不泄露目标信息', !JSON.stringify(body4).includes('host') && !JSON.stringify(body4).includes('/'), JSON.stringify(body4)); const r5 = await fetch('http://127.0.0.1:8898/api/status'); check('只读模式下面板数据接口仍可用', r5.status === 200, `status=${r5.status}`); srvB.kill(); // ---------- E:来源判定 ---------- console.log('\n[E] 来源判定'); const { isPrivateAddress } = await import(`file://${PROBER.replace(/\\/g, '/')}`); const priv = ['127.0.0.1', '::1', '10.1.2.3', '192.168.1.10', '172.16.0.1', '172.31.255.254', 'fc00::1', 'fe80::1', '::ffff:127.0.0.1']; const pub = ['8.8.8.8', '1.1.1.1', '172.32.0.1', '11.0.0.1', '2001:4860:4860::8888']; check('内网/回环地址被允许', priv.every(isPrivateAddress), priv.filter((i) => !isPrivateAddress(i)).join(',') || '全部通过'); check('公网地址被拒绝', pub.every((i) => !isPrivateAddress(i)), pub.filter(isPrivateAddress).join(',') || '全部通过'); // ---------- 收尾 ---------- sshServer.close(); silentServer.close(); fs.rmSync(TMP, { recursive: true, force: true }); const failed = results.filter((r) => !r.pass); console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`); if (failed.length) process.exit(1); } main().catch((err) => { console.error('FAILED:', err.stack || err.message); process.exit(1); });