Multi-Hoster-Upload/tests/support-bundle.test.js
Administrator d69e5c39bf feat(diagnostics): MCP gateway + harden redaction so no secret ever leaves the box
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>
2026-06-19 17:39:31 +02:00

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/);
});