Files
shiroko-monitor/scripts/verify-security.cjs
T
tamakyi 3db3ce570a chore: v0.6.0 baseline — 主题美化前存档
已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、
数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、
刷新接口滥用防护与信息泄露控制。
2026-10-13 18:31:58 +08:00

172 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});