Repo-side hardening and tooling from the intensive test round (none of this ships in the app installer). - gateway: registry.json (holds bearer tokens) now gets a best-effort owner-only NTFS ACL on Windows via `icacls /inheritance:r /grant:r <user>:F` (the chmod 0600 is a no-op on NTFS); verified the file ends up <user>:(F) only. - gateway: connect_server now reads the app version from the real get_system_info shape (data.app.version / data.agent.version), so "connected to vX.Y.Z" works. - gateway: read_log tool description documents grep as a case-insensitive substring filter with "|" alternation (not a regex), matching the agent-side change. - gateway: standalone verification harnesses moved to gateway/verify/ (so `node --test` only sweeps real unit tests) and exposed via `npm run verify`: e2e-verify, integration-mcp (live gateway-MCP <-> agent, all 14 tools), and adversarial-probe (redaction fuzz + ReDoS + lockout). `npm test` runs the units. - eslint: gateway/** now lints as ESM (sourceType module) via a dedicated block; global ignores fixed so `eslint .` is clean across the whole project (0 errors). - docs/remote-diagnostics-setup.md: made the transport story honest — the agent speaks plaintext ws:// over enforced loopback; the SSH/WireGuard tunnel is the ONLY confidentiality layer (wss/TLS + cert-pin is a documented future mode, not active). Removed the stale "bind to a LAN/VPN IP" guidance (loopback is enforced). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
138 lines
9.6 KiB
JavaScript
138 lines
9.6 KiB
JavaScript
import { createRequire } from 'node:module';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
import { writeFileSync, mkdtempSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import WebSocket from 'ws';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const appRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
const support = require(join(appRoot, 'lib', 'support-bundle.js'));
|
|
const stats = require(join(appRoot, 'lib', 'stats.js'));
|
|
const RemoteServer = require(join(appRoot, 'lib', 'remote-server.js'));
|
|
const { createCollectors } = require(join(appRoot, 'lib', 'diagnostics-collectors.js'));
|
|
const { createAgent } = require(join(appRoot, 'lib', 'diagnostics-agent.js'));
|
|
|
|
const findings = [];
|
|
function leakCheck(label, text, needle, { realistic }) {
|
|
if (text.includes(needle)) findings.push({ label, needle: needle.slice(0, 24), realistic });
|
|
}
|
|
|
|
console.log('=== A. redactLogText pattern-scrub battery (no config secrets; pure shape detection) ===');
|
|
const battery = [
|
|
{ line: 'Authorization: Bearer abcDEF123456ghiJKL789', needle: 'abcDEF123456ghiJKL789', realistic: true },
|
|
{ line: 'using Bearer eyJhbGciOiJIUzI1Nib-longtokenvalue-0099', needle: 'eyJhbGciOiJIUzI1Nib-longtokenvalue-0099', realistic: true },
|
|
{ line: 'resp token=sess_9f8e7d6c5b4a3210ffee', needle: 'sess_9f8e7d6c5b4a3210ffee', realistic: true },
|
|
{ line: 'access_token: ya29.A0ARrdaM-longgoogletoken-123', needle: 'ya29.A0ARrdaM-longgoogletoken-123', realistic: true },
|
|
{ line: 'refresh_token = 1//0ggLongRefreshToken_abcdef', needle: '1//0ggLongRefreshToken_abcdef', realistic: true },
|
|
{ line: 'x-api-key: SuperSecretApiKeyValue99', needle: 'SuperSecretApiKeyValue99', realistic: true },
|
|
{ line: 'api_key=AKIAIOSFODNN7EXAMPLEKEY', needle: 'AKIAIOSFODNN7EXAMPLEKEY', realistic: true },
|
|
{ line: 'GET /up?key=querykeysecret12345 HTTP/1.1', needle: 'querykeysecret12345', realistic: true },
|
|
{ line: 'POST https://discord.com/api/webhooks/123456789012345678/WEBHOOKTOKENsecretvalue', needle: 'WEBHOOKTOKENsecretvalue', realistic: true },
|
|
{ line: 'set-cookie: session=COOKIEsecretvalue9988; HttpOnly', needle: 'COOKIEsecretvalue9988', realistic: true },
|
|
{ line: 'Cookie: sess_id=ABCcookievalue12345', needle: 'ABCcookievalue12345', realistic: true },
|
|
{ line: 'sessionId: SESSIONsecret009988aa', needle: 'SESSIONsecret009988aa', realistic: true },
|
|
{ line: 'two leaks: token=firsttok12345678 and api_key=secondkey87654321', needle: 'secondkey87654321', realistic: true },
|
|
{ line: 'proxy https://admin:Sup3rProxyPass@proxy.internal:8080/path', needle: 'Sup3rProxyPass', realistic: true },
|
|
{ line: 'Authorization: Basic dXNlcjpwYXNzd29yZF9zZWNyZXQ=', needle: 'dXNlcjpwYXNzd29yZF9zZWNyZXQ', realistic: true },
|
|
{ line: 'jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhYmMxMjMifQ.SflKxwRJSMeKKF2QT4fwpMabc', needle: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhYmMxMjMifQ', realistic: true },
|
|
{ line: 'session=BareSessionSecret998877', needle: 'BareSessionSecret998877', realistic: true },
|
|
{ line: 'password: PlainTextPassword123 was used', needle: 'PlainTextPassword123', realistic: true },
|
|
{ line: 'random high-entropy blob 9f8e7d6c5b4a3210ffeeddccbbaa with no key context', needle: '9f8e7d6c5b4a3210ffeeddccbbaa', realistic: false },
|
|
];
|
|
for (const t of battery) {
|
|
const out = support.redactLogText(t.line, []);
|
|
leakCheck('redactLogText: ' + t.line.slice(0, 40), out, t.needle, t);
|
|
console.log(` ${out.includes(t.needle) ? 'LEAK ' : 'scrub'} ${t.line.slice(0, 52)}`);
|
|
}
|
|
|
|
console.log('\n=== B. value-scrub: config secret in odd encodings (deepRedact via collectors) ===');
|
|
const SECRET = 'CFGsecret_aabbccddeeff';
|
|
const tmp = mkdtempSync(join(tmpdir(), 'mhu-adv-'));
|
|
writeFileSync(join(tmp, 'debug.log'), `plain ${SECRET}\nurlenc CFGsecret_aabbccddeeff also\n`);
|
|
const cfg = {
|
|
hosters: { doodstream: [{ accountId: 'a', apiKey: SECRET }] }, hosterSettings: {},
|
|
globalSettings: { diagnostics: { enabled: true, token: 'd'.repeat(64) },
|
|
pendingQueue: { savedAt: 1, selectedUploadHosters: [], selectedFiles: [], queueJobs: [
|
|
{ file: 'x', fileName: 'x', hoster: 'doodstream', status: 'error', error: `failed ${SECRET}` }] } },
|
|
history: [{ timestamp: 't', files: [{ name: 'x', results: [{ hoster: 'doodstream', status: 'error', error: `e ${SECRET}` }] }] }],
|
|
rotationCursors: {},
|
|
};
|
|
const cols = createCollectors({
|
|
loadConfig: () => JSON.parse(JSON.stringify(cfg)),
|
|
getAllLogPaths: () => ({ fileuploader: join(tmp, 'f.log'), debug: join(tmp, 'debug.log'), accountRotation: join(tmp, 'r.log'), doodstreamDebug: join(tmp, 'doodstream-debug.log'), crashLog: join(tmp, 'c.log'), logDir: tmp }),
|
|
support, stats, appInfo: () => ({ version: '3.3.84' }), systemInfo: () => ({}), agentInfo: () => ({}),
|
|
});
|
|
for (const [name, fn] of [
|
|
['getConfigRedacted(all)', () => cols.getConfigRedacted({ section: 'all' })],
|
|
['getQueueState(includeJobs)', () => cols.getQueueState({ includeJobs: true })],
|
|
['getQueueState(default)', () => cols.getQueueState({})],
|
|
['listErrors', () => cols.listErrors({})],
|
|
['getHistory(files)', () => cols.getHistory({ includeFiles: true })],
|
|
['serverHealth', () => cols.serverHealth({})],
|
|
['readLog(debug)', () => cols.readLog({ name: 'debug' })],
|
|
]) {
|
|
const text = JSON.stringify(fn());
|
|
leakCheck('collector:' + name, text, SECRET, { realistic: true });
|
|
console.log(` ${text.includes(SECRET) ? 'LEAK ' : 'scrub'} ${name}`);
|
|
}
|
|
|
|
console.log('\n=== C. abuse / DoS: ReDoS grep, oversized tailKb, malformed args (must not hang/crash) ===');
|
|
const t0 = Date.now();
|
|
writeFileSync(join(tmp, 'debug.log'), 'a'.repeat(80) + '! catastrophic-bait line\n' + `plain ${SECRET}\nurlenc CFGsecret_aabbccddeeff also\n`);
|
|
const redos = cols.readLog({ name: 'debug', grep: '(a+)+$', tailKb: 1 });
|
|
const redosMs = Date.now() - t0;
|
|
if (redosMs > 1500) findings.push({ label: 'grep ReDoS hang (' + redosMs + 'ms) on 80-a line', needle: '(a+)+$', realistic: true });
|
|
console.log(` grep "(a+)+$" vs 80-a line returned in ${redosMs}ms (must be <1500: ${redosMs < 1500})`);
|
|
const longGrep = cols.readLog({ name: 'debug', grep: 'a'.repeat(5000) });
|
|
console.log(` grep 5000-char pattern: ${longGrep && (longGrep.matchedLines !== undefined || longGrep.content !== undefined) ? 'handled' : 'handled'}`);
|
|
const bigTail = cols.readLog({ name: 'debug', tailKb: 9999999 });
|
|
console.log(` tailKb 9999999 clamped to: ${bigTail.tailKb} (<=1024:${bigTail.tailKb <= 1024})`);
|
|
let crashed = false;
|
|
for (const bad of [null, undefined, 42, [], { name: 123 }, { name: ['debug'] }, { name: 'debug', backup: 'evil' }, { name: 'debug', tailKb: -5 }, { limit: 'NaN' }]) {
|
|
try { cols.readLog(bad); cols.listErrors(bad); cols.getQueueState(bad); cols.getHistory(bad); cols.getAppEvents(bad); }
|
|
catch (e) { crashed = true; findings.push({ label: 'collector THREW on malformed args: ' + JSON.stringify(bad), needle: String(e.message), realistic: true }); }
|
|
}
|
|
console.log(` malformed-args battery: ${crashed ? 'THREW (bad)' : 'no throw (good)'}`);
|
|
|
|
console.log('\n=== D. agent whitelist: write/exec/unknown ops rejected, never throws ===');
|
|
const agent = createAgent(cols);
|
|
let agentThrew = false;
|
|
for (const op of ['save_config', 'run_health_check', 'exec', 'eval', 'delete_log', '__proto__', 'constructor', 'getConfigRedacted', '', null, 'get_config_redacted; drop']) {
|
|
try { const r = agent.handle(op, {}); if (r && r.ok === true && !['get_config_redacted'].includes(op)) findings.push({ label: 'agent ACCEPTED non-whitelisted op: ' + op, needle: op, realistic: true }); }
|
|
catch (e) { agentThrew = true; findings.push({ label: 'agent THREW on op ' + op, needle: String(e.message), realistic: true }); }
|
|
}
|
|
console.log(` non-whitelisted ops: ${agentThrew ? 'THREW (bad)' : 'all returned {ok:false} (good)'}`);
|
|
|
|
console.log('\n=== E. transport abuse: brute-force lockout + concurrent clients (live RemoteServer) ===');
|
|
const TOKEN = 'z'.repeat(64);
|
|
const srv = new RemoteServer();
|
|
await srv.start({ port: 0, host: '127.0.0.1', token: TOKEN, diagnosticMode: true, onDiagnosticRequest: (m, _c, reply) => reply(agent.handle(m.op, m.args)) });
|
|
const port = srv.getPort();
|
|
function wsOnce(sendToken) {
|
|
return new Promise((resolve) => {
|
|
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
let authed = false;
|
|
ws.on('open', () => ws.send(JSON.stringify({ type: 'auth', token: sendToken, role: 'diagnostic' })));
|
|
ws.on('message', (raw) => { try { const m = JSON.parse(raw); if (m.type === 'auth-ok') { authed = true; ws.close(); resolve({ authed: true }); } } catch {} });
|
|
ws.on('close', (code) => resolve({ authed, code }));
|
|
ws.on('error', () => {});
|
|
});
|
|
}
|
|
const okClients = await Promise.all([wsOnce(TOKEN), wsOnce(TOKEN), wsOnce(TOKEN)]);
|
|
console.log(` 3 concurrent valid clients all authed: ${okClients.every(c => c.authed)}`);
|
|
let lastCode = null;
|
|
for (let i = 0; i < 6; i++) lastCode = (await wsOnce('wrongtoken')).code;
|
|
console.log(` after 6 bad-token attempts, close code = ${lastCode} (4003 lockout expected: ${lastCode === 4003})`);
|
|
const afterLock = await wsOnce(TOKEN);
|
|
console.log(` valid token DURING lockout window: authed=${afterLock.authed} closeCode=${afterLock.code} (locked out even with right token: ${!afterLock.authed})`);
|
|
srv.stop();
|
|
|
|
console.log('\n=== SUMMARY ===');
|
|
const real = findings.filter(f => f.realistic);
|
|
const theo = findings.filter(f => !f.realistic);
|
|
if (theo.length) console.log(` ${theo.length} THEORETICAL (acknowledged denylist limit): ${theo.map(f => f.label).join(' | ')}`);
|
|
if (real.length) { console.log(` ${real.length} REAL finding(s):`); for (const f of real) console.log(` - ${f.label} :: ${f.needle}`); process.exit(2); }
|
|
console.log(' No REAL leaks/crashes found. (Theoretical = standalone secret with zero key/Bearer/URL context — inherent to denylist.)');
|
|
process.exit(0);
|