chore: v0.6.0 baseline — 主题美化前存档

已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、
数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、
刷新接口滥用防护与信息泄露控制。
This commit is contained in:
tamakyi
2026-10-13 18:31:58 +08:00
commit 3db3ce570a
31 changed files with 5136 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
/**
* 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);
});