Adds the connect-by-code side of remote diagnostics and closes two real secret-leak vectors that an end-to-end gateway<->agent test surfaced. Gateway (gateway/, local stdio MCP, Claude connects once): - 14 read-only tools (server_health hub, read_log, list_logs, list_errors, get_queue_state, get_history, get_config_redacted, get_system_info, get_rotation_state, get_app_events + connect/disconnect/list/current). - The HOST is always supplied by the operator, never taken from the code. - TLS fingerprint pinning is enforced in the socket 'open' handler BEFORE the token is sent (wss opt-in); plain ws is loopback-only. - registry.json (holds bearer tokens) is gitignored; only an empty example ships. Security hardening (gates every off-box payload): - redactLogText now scrubs opaque bearer/token-family secrets that are NOT stored config credentials (e.g. a session token a hoster returns inside an error string): bare token/auth_token/refresh_token/session_token + standalone "Bearer <opaque>". Benign "token bucket" prose is left intact. - get_config_redacted deep-redacts every string leaf (JSON-safe, per-leaf, so the cookie/sess line patterns can't gobble across a compact-JSON field) and drops the history subtree (served by get_history with its own per-error redaction). This plugs leaks via globalSettings.pendingQueue[].error etc. Bind-address safety: - _safeDiagBindAddress() forces the diagnostic agent to 127.0.0.1/::1; the 0.0.0.0 UI option is removed. Direct LAN/Internet bind stays disabled until encrypted transport (wss) exists — remote access goes through an SSH/VPN tunnel to loopback. (Never plaintext ws:// on all interfaces.) Tests: end-to-end gateway<->agent gate (connect -> server_health/read_log/ get_config_redacted, asserts zero secret leakage, rejects doodstream log, path traversal and write ops); + redaction regression tests in the main suite. 385 app tests + 9 gateway tests pass; lint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
115 lines
4.6 KiB
JavaScript
115 lines
4.6 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { sanitizeConfig, collectFile, buildSupportBundleText, redactLogText, REDACTED } = require('../lib/support-bundle');
|
|
|
|
test('sanitizeConfig redacts known credential keys at any nesting depth', () => {
|
|
const input = {
|
|
hosters: {
|
|
'voe.sx': [{ username: 'u', password: 'p1', apiKey: 'k1', enabled: true }],
|
|
'byse.sx': [{ apiKey: 'k2' }, { apiKey: 'k3', token: 't1', label: 'main' }]
|
|
},
|
|
globalSettings: { remote: { token: 'remT' }, scramble: { active: false } }
|
|
};
|
|
const out = sanitizeConfig(input);
|
|
assert.strictEqual(out.hosters['voe.sx'][0].password, REDACTED);
|
|
assert.strictEqual(out.hosters['voe.sx'][0].apiKey, REDACTED);
|
|
assert.strictEqual(out.hosters['voe.sx'][0].username, 'u');
|
|
assert.strictEqual(out.hosters['voe.sx'][0].enabled, true);
|
|
assert.strictEqual(out.hosters['byse.sx'][1].apiKey, REDACTED);
|
|
assert.strictEqual(out.hosters['byse.sx'][1].token, REDACTED);
|
|
assert.strictEqual(out.hosters['byse.sx'][1].label, 'main');
|
|
assert.strictEqual(out.globalSettings.remote.token, REDACTED);
|
|
});
|
|
|
|
test('redactLogText scrubs opaque tokens that are NOT stored config secrets', () => {
|
|
const cases = [
|
|
'boom token=bearer_tok_qwerty12345',
|
|
'response auth_token: aGVsbG8td29ybGQtMTIz',
|
|
'refresh_token = abc123DEF456ghi789',
|
|
'using Bearer aaaabbbbccccddddeeeeffff',
|
|
'Authorization: Bearer deadbeefcafef00dba5e'
|
|
];
|
|
for (const line of cases) {
|
|
const out = redactLogText(line, []);
|
|
assert.ok(out.includes(REDACTED), `expected redaction in: ${line} -> ${out}`);
|
|
assert.ok(!/qwerty12345|aGVsbG8|abc123DEF456|aaaabbbbcccc|deadbeefcafe/.test(out), `secret survived: ${out}`);
|
|
}
|
|
});
|
|
|
|
test('redactLogText leaves benign "token" prose alone', () => {
|
|
const benign = 'token bucket refill rate is 5 per second';
|
|
assert.equal(redactLogText(benign, []), benign);
|
|
});
|
|
|
|
test('sanitizeConfig does not mutate input', () => {
|
|
const input = { hosters: { 'voe.sx': [{ password: 'secret' }] } };
|
|
const clone = JSON.parse(JSON.stringify(input));
|
|
sanitizeConfig(input);
|
|
assert.deepStrictEqual(input, clone);
|
|
});
|
|
|
|
test('sanitizeConfig leaves empty/missing credentials alone', () => {
|
|
const input = { hosters: { 'voe.sx': [{ password: '', apiKey: null }] } };
|
|
const out = sanitizeConfig(input);
|
|
assert.strictEqual(out.hosters['voe.sx'][0].password, '');
|
|
assert.strictEqual(out.hosters['voe.sx'][0].apiKey, null);
|
|
});
|
|
|
|
test('sanitizeConfig handles null/undefined input', () => {
|
|
assert.strictEqual(sanitizeConfig(null), null);
|
|
assert.strictEqual(sanitizeConfig(undefined), undefined);
|
|
});
|
|
|
|
test('collectFile tails when file exceeds maxBytes', () => {
|
|
const tmp = path.join(os.tmpdir(), `mhu-bundle-${Date.now()}.log`);
|
|
const bigLine = 'x'.repeat(1000) + '\n';
|
|
fs.writeFileSync(tmp, bigLine.repeat(100));
|
|
try {
|
|
const section = collectFile(tmp, 'big.log', 5000);
|
|
assert.match(section, /truncated: skipped first \d+ bytes/);
|
|
assert.ok(section.length < bigLine.length * 100, 'section should be truncated');
|
|
} finally {
|
|
fs.unlinkSync(tmp);
|
|
}
|
|
});
|
|
|
|
test('collectFile returns placeholder for missing file', () => {
|
|
const section = collectFile(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.log`), 'missing');
|
|
assert.match(section, /<file does not exist yet>/);
|
|
});
|
|
|
|
test('collectFile returns placeholder for null path', () => {
|
|
const section = collectFile(null, 'no-path');
|
|
assert.match(section, /<no path configured>/);
|
|
});
|
|
|
|
test('buildSupportBundleText produces structured output with header + config + file sections', () => {
|
|
const tmp = path.join(os.tmpdir(), `mhu-bundle-text-${Date.now()}.log`);
|
|
fs.writeFileSync(tmp, 'line one\nline two\n');
|
|
try {
|
|
const text = buildSupportBundleText({
|
|
header: { Version: '3.3.41', Platform: 'win32' },
|
|
sanitizedConfig: { hosters: { 'voe.sx': [{ apiKey: '<redacted>' }] } },
|
|
files: [{ label: 'debug.log', path: tmp }]
|
|
});
|
|
assert.match(text, /^=== Multi-Hoster-Upload Support Bundle ===/);
|
|
assert.match(text, /Version: 3\.3\.41/);
|
|
assert.match(text, /Platform: win32/);
|
|
assert.match(text, /=== Config \(sanitized/);
|
|
assert.match(text, /"apiKey": "<redacted>"/);
|
|
assert.match(text, /=== debug\.log/);
|
|
assert.match(text, /line one\nline two/);
|
|
} finally {
|
|
fs.unlinkSync(tmp);
|
|
}
|
|
});
|
|
|
|
test('buildSupportBundleText handles empty file list and missing header', () => {
|
|
const text = buildSupportBundleText({ sanitizedConfig: {}, files: [] });
|
|
assert.match(text, /=== Multi-Hoster-Upload Support Bundle ===/);
|
|
assert.match(text, /=== Config/);
|
|
});
|