已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、 数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、 刷新接口滥用防护与信息泄露控制。
172 lines
9.9 KiB
JavaScript
172 lines
9.9 KiB
JavaScript
/**
|
||
* 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);
|
||
});
|