diff --git a/lib/diagnostics-agent.js b/lib/diagnostics-agent.js index f5c2239..520fc52 100644 --- a/lib/diagnostics-agent.js +++ b/lib/diagnostics-agent.js @@ -1,3 +1,5 @@ +const { valueScrub } = require('./support-bundle'); + function createAgent(collectors) { const OPS = { get_system_info: (a) => collectors.getSystemInfo(a), @@ -14,15 +16,28 @@ function createAgent(collectors) { get_health: () => collectors.getHealth() }; + function redactResponse(value) { + try { + const redacted = typeof collectors.redactResponse === 'function' + ? collectors.redactResponse(value) + : valueScrub(value, []); + const response = valueScrub(redacted, []); + if (!response || typeof response !== 'object' || Array.isArray(response)) throw new Error('invalid redaction result'); + return response; + } catch { + return { ok: false, error: 'diagnostic response could not be safely returned' }; + } + } + function handle(op, args) { const fn = (typeof op === 'string' && Object.prototype.hasOwnProperty.call(OPS, op)) ? OPS[op] : null; - if (typeof fn !== 'function') return { ok: false, error: `unknown or non-readonly op: ${op}` }; + if (typeof fn !== 'function') return redactResponse({ ok: false, error: `unknown or non-readonly op: ${op}` }); try { const data = fn(args || {}); - if (data && data.ok === false) return data; - return { ok: true, data }; + if (data && data.ok === false) return redactResponse(data); + return redactResponse({ ok: true, data }); } catch (e) { - return { ok: false, error: String((e && e.message) || e) }; + return redactResponse({ ok: false, error: String((e && e.message) || e) }); } } diff --git a/lib/diagnostics-collectors.js b/lib/diagnostics-collectors.js index 7c4a169..bce9e38 100644 --- a/lib/diagnostics-collectors.js +++ b/lib/diagnostics-collectors.js @@ -20,18 +20,11 @@ function createCollectors(deps) { } function _deepRedact(value, secrets) { - const s = secrets || _secrets(); - const walk = (v) => { - if (typeof v === 'string') return support.redactLogText(v, s); - if (Array.isArray(v)) return v.map(walk); - if (v && typeof v === 'object') { - const o = {}; - for (const k of Object.keys(v)) o[k] = walk(v[k]); - return o; - } - return v; - }; - try { return walk(value); } catch { return value; } + return support.valueScrub(value, secrets || _secrets()); + } + + function redactResponse(value) { + return _deepRedact(value); } function _resolveLogPath(name, backup) { @@ -274,7 +267,7 @@ function createCollectors(deps) { return { getSystemInfo, getConfigRedacted, listLogs, readLog, getAppEvents, listErrors, getQueueState, getHistory, getRotationState, getHealth, serverHealth, - READABLE_LOGS + redactResponse, READABLE_LOGS }; } diff --git a/lib/support-bundle.js b/lib/support-bundle.js index b646d26..c1f0898 100644 --- a/lib/support-bundle.js +++ b/lib/support-bundle.js @@ -35,24 +35,28 @@ function collectSecretValues(config) { function redactConfiguredSecrets(text, secrets) { if (!Array.isArray(secrets)) return text; - const values = Array.from(new Set(secrets.filter(value => typeof value === 'string' && value.length > 0))) + const values = Array.from(new Set(secrets + .filter(value => typeof value === 'string' && value.length > 0) + .flatMap(value => { + const variants = [value]; + for (let index = 0; index < 3; index++) { + const escaped = JSON.stringify(variants[variants.length - 1]).slice(1, -1); + if (escaped === variants[variants.length - 1]) break; + variants.push(escaped); + } + return variants; + }))) .sort((a, b) => b.length - a.length); let out = text; for (const value of values) { - if (value.length >= 6) { - out = out.split(value).join(REDACTED); - continue; - } let offset = 0; while (offset < out.length) { const index = out.indexOf(value, offset); if (index < 0) break; - const first = value[0]; - const last = value[value.length - 1]; const before = index > 0 ? out[index - 1] : ''; const after = index + value.length < out.length ? out[index + value.length] : ''; - const identifier = character => /[A-Za-z0-9_]/.test(character); - if ((!identifier(first) || !identifier(before)) && (!identifier(last) || !identifier(after))) { + const continuation = character => /[A-Za-z0-9_.]/.test(character); + if (!continuation(before) && !continuation(after)) { out = `${out.slice(0, index)}${REDACTED}${out.slice(index + value.length)}`; offset = index + REDACTED.length; } else { @@ -64,23 +68,83 @@ function redactConfiguredSecrets(text, secrets) { } function redactHtmlCredentialFields(text) { - return text.replace(/]*>/gi, input => { + let out = ''; + let offset = 0; + const lower = text.toLowerCase(); + while (offset < text.length) { + const start = lower.indexOf('') { + end++; + break; + } + } + const input = text.slice(start, end); const sensitive = /\btype\s*=\s*["']?password\b/i.test(input) || /\b(?:name|id)\s*=\s*["']?(?:password|passwd|api[_-]?(?:key|token)|token|secret|authorization|cookie|session(?:[_-]?id)?)\b/i.test(input); - if (!sensitive) return input; - return input - .replace(/(\bvalue\s*=\s*)(["'])(.*?)\2/gi, `$1$2${REDACTED}$2`) - .replace(/(\bvalue\s*=\s*)(?!["'])([^\s>]+)/gi, `$1${REDACTED}`); - }); + out += sensitive + ? input + .replace(/(\bvalue\s*=\s*)(["'])([\s\S]*?)\2/gi, `$1$2${REDACTED}$2`) + .replace(/(\bvalue\s*=\s*)(?!["'])([^\s>]+)/gi, `$1${REDACTED}`) + : input; + offset = end; + } + return out; +} + +function redactAbsolutePaths(text) { + const isDriveStart = (value, index) => /[A-Za-z]/.test(value[index] || '') + && !/[A-Za-z0-9]/.test(value[index - 1] || '') + && value[index + 1] === ':' + && /[\\/]/.test(value[index + 2] || ''); + const isBackslashUncStart = (value, index) => { + if (value[index] !== '\\' || value[index + 1] !== '\\' || value[index - 1] === '\\') return false; + let cursor = index + 2; + while (value[cursor] === '\\') cursor++; + if (value[cursor] === '?') return true; + const separator = value.indexOf('\\', cursor); + return separator > cursor; + }; + const isSlashUncStart = (value, index) => value[index] === '/' + && value[index + 1] === '/' + && !/[:/]/.test(value[index - 1] || '') + && !/[\/]/.test(value[index + 2] || '') + && value.indexOf('/', index + 2) > index + 2; + let out = ''; + let index = 0; + while (index < text.length) { + if (!isDriveStart(text, index) && !isBackslashUncStart(text, index) && !isSlashUncStart(text, index)) { + out += text[index]; + index++; + continue; + } + let end = index; + while (end < text.length && !/[\r\n"'<>|]/.test(text[end])) end++; + const candidate = text.slice(index, end).replace(/\s+(?:trigger|error|outcome|hoster|attempt|status|code)=.*$/i, ''); + out += ''; + index += candidate.length; + } + return out; } function redactLogText(text, secrets) { if (typeof text !== 'string' || !text) return text; let out = redactConfiguredSecrets(text, secrets); out = redactHtmlCredentialFields(out) - .replace(/("(?:file|fileName|stagedFile|sourceFile|targetFile|path|[A-Za-z0-9_]*Path)"\s*:\s*")[^"]*(")/gi, '$1$2') - .replace(/\b[A-Za-z]:(?:\\+|\/+)[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/gi, '') - .replace(/\\{2,}[A-Za-z0-9._$-]+\\+[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/g, '') + .replace(/("(?:file|fileName|stagedFile|sourceFile|targetFile|path|[A-Za-z0-9_]*Path)"\s*:\s*")[^"]*(")/gi, '$1$2'); + out = redactAbsolutePaths(out) .replace(/https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+/gi, 'https://discord.com/api/webhooks/' + REDACTED) .replace(/(\/\/[^\s/:@]+:)[^\s/@]+(@)/g, '$1' + REDACTED + '$2') .replace(/(\b(?:proxy-)?authorization\s*:\s*)[^\r\n]*/gi, '$1' + REDACTED) @@ -99,7 +163,7 @@ function valueScrub(value, secrets) { if (Array.isArray(value)) return value.map(entry => valueScrub(entry, secrets)); if (typeof value === 'object') { const out = {}; - for (const [key, entry] of Object.entries(value)) out[key] = valueScrub(entry, secrets); + for (const [key, entry] of Object.entries(value)) out[redactLogText(key, secrets)] = valueScrub(entry, secrets); return out; } return value; diff --git a/tests/diagnostics-agent.test.js b/tests/diagnostics-agent.test.js index e728d6a..71e3908 100644 --- a/tests/diagnostics-agent.test.js +++ b/tests/diagnostics-agent.test.js @@ -1,6 +1,7 @@ const { test } = require('node:test'); const assert = require('node:assert'); const { createAgent } = require('../lib/diagnostics-agent'); +const { valueScrub } = require('../lib/support-bundle'); function stubCollectors() { const calls = []; @@ -28,6 +29,9 @@ test('agent rejects unknown ops and any write/exec-shaped op', () => { assert.equal(r.ok, false, `${bad} must be rejected`); assert.match(r.error, /unknown or non-readonly/); } + const pathShaped = agent.handle('C:\\Users\\PrivateProfile\\operation', {}); + assert.ok(!pathShaped.error.includes('PrivateProfile')); + assert.match(pathShaped.error, //); }); test('agent rejects inherited Object.prototype members (no whitelist bypass via the prototype chain)', () => { @@ -53,10 +57,43 @@ test('agent maps each whitelisted op to its collector and is read-only only', () for (const op of agent.ops) assert.ok(!/write|delete|set_|exec|restart|cancel|retry/.test(op), `${op} must be read-only`); }); -test('agent surfaces a collector ok:false verbatim and never throws', () => { - const agent = createAgent({ readLog: () => ({ ok: false, error: 'unknown or non-readable log: x' }), getSystemInfo: () => { throw new Error('boom'); } }); +test('agent redacts collector failures and thrown errors at the response boundary', () => { + const drivePath = 'C:\\Users\\PrivateProfile\\secret.log'; + const uncPath = '\\\\?\\UNC\\private-server\\secret-share\\secret.log'; + const agent = createAgent({ + readLog: () => ({ ok: false, error: `cannot read ${drivePath}` }), + getSystemInfo: () => { throw new Error(`boom at ${uncPath}`); } + }); assert.equal(agent.handle('read_log', { name: 'x' }).ok, false); + assert.ok(!JSON.stringify(agent.handle('read_log', { name: 'x' })).includes('PrivateProfile')); const thrown = agent.handle('get_system_info', {}); assert.equal(thrown.ok, false); assert.match(thrown.error, /boom/); + assert.ok(!thrown.error.includes('private-server')); + assert.match(thrown.error, //); +}); + +test('agent redacts every successful response with configured secrets at the boundary', () => { + const secret = 'configured-secret-123'; + const slashUnc = '//private-server/secret-share/secret.log'; + const collectors = stubCollectors(); + collectors.getSystemInfo = () => ({ nested: { message: `token ${secret}`, path: slashUnc } }); + collectors.redactResponse = value => valueScrub(value, [secret]); + const result = createAgent(collectors).handle('get_system_info', {}); + const json = JSON.stringify(result); + assert.equal(result.ok, true); + assert.ok(!json.includes(secret)); + assert.ok(!json.includes('private-server')); + assert.match(json, //); + assert.match(json, //); +}); + +test('agent fails closed when response redaction fails', () => { + const agent = createAgent({ + getSystemInfo: () => ({ token: 'must-not-leak' }), + redactResponse: () => { throw new Error('redactor unavailable'); } + }); + const result = agent.handle('get_system_info', {}); + assert.deepEqual(result, { ok: false, error: 'diagnostic response could not be safely returned' }); + assert.ok(!JSON.stringify(result).includes('must-not-leak')); }); diff --git a/tests/diagnostics-collectors.test.js b/tests/diagnostics-collectors.test.js index 8cd626f..b083da0 100644 --- a/tests/diagnostics-collectors.test.js +++ b/tests/diagnostics-collectors.test.js @@ -6,7 +6,6 @@ const path = require('path'); const support = require('../lib/support-bundle'); const stats = require('../lib/stats'); const { createCollectors } = require('../lib/diagnostics-collectors'); -const { createAgent } = require('../lib/diagnostics-agent'); function makeFixture() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-diag-')); @@ -192,3 +191,18 @@ test('serverHealth assembles the one-shot hub without leaking secrets', () => { assert.ok(!json.includes('HUNTER2SECRET') && !json.includes('SECRETTOKEN123456') && !json.includes('WBHOOKSECRETTOKEN'), 'no secret leaks in server_health'); assert.ok(!json.includes(dir) && !json.includes(paths.debug), 'server_health must not expose absolute log paths'); }); + +test('redactResponse scrubs configured secrets and absolute paths from arbitrary nested output', () => { + const { collectors, fixtureAlpha } = makeFixture(); + const value = { + error: `token ${fixtureAlpha} at C:\\Users\\PrivateProfile\\secret.log`, + nested: [{ source: '\\\\?\\UNC\\private-server\\secret-share\\secret.log' }] + }; + const out = collectors.redactResponse(value); + const json = JSON.stringify(out); + assert.ok(!json.includes(fixtureAlpha)); + assert.ok(!json.includes('PrivateProfile')); + assert.ok(!json.includes('private-server')); + assert.match(json, //); + assert.match(json, //); +}); diff --git a/tests/support-bundle.test.js b/tests/support-bundle.test.js index c5d73ec..3713be6 100644 --- a/tests/support-bundle.test.js +++ b/tests/support-bundle.test.js @@ -117,6 +117,42 @@ test('redactLogText masks a one-character configured secret only as a complete s assert.ok(out.includes(`marker=${REDACTED}`)); }); +test('redactLogText replaces configured secrets only as complete values', () => { + const out = redactLogText([ + 'password=orange', + 'configured token orange accepted', + 'file=orangejuice', + 'file=orange.mkv', + 'password=.', + 'version=2.1.20', + 'sentence finished.' + ].join('\n'), ['orange', '.']); + assert.ok(!out.includes('password=orange')); + assert.ok(!out.includes('token orange')); + assert.ok(!out.includes('password=.')); + assert.ok(out.includes('file=orangejuice')); + assert.ok(out.includes('file=orange.mkv')); + assert.ok(out.includes('version=2.1.20')); + assert.ok(out.includes('sentence finished.')); +}); + +test('redactLogText removes JSON-escaped configured secrets and quoted HTML credential values', () => { + const jsonSecret = 'alpha"beta\\gamma'; + const password = 'abc>secret'; + const token = 'token>quoted'; + const input = [ + JSON.stringify({ note: jsonSecret, token: jsonSecret }), + ``, + `` + ].join('\n'); + const out = redactLogText(input, [jsonSecret]); + assert.ok(!out.includes(jsonSecret)); + assert.ok(!out.includes('alpha\\"beta\\\\gamma')); + assert.ok(!out.includes(password)); + assert.ok(!out.includes(token)); + assert.ok((out.match(//g) || []).length >= 3); +}); + test('redactLogText removes complete local paths from structured and free-form log text', () => { const profilePath = ['C:', 'Users', 'ProfileFixture', 'Private Folder', 'episode.mkv'].join('\\'); const drivePath = ['D:', 'Archive', 'Private Folder', 'source.mkv'].join('\\'); @@ -135,6 +171,23 @@ test('redactLogText removes complete local paths from structured and free-form l assert.ok((out.match(//g) || []).length >= 4); }); +test('redactLogText removes extended UNC, extended drive, UNC and slash-UNC paths', () => { + const extendedUnc = '\\\\?\\UNC\\private-server\\secret-share\\hidden.log'; + const extendedDrive = '\\\\?\\C:\\Users\\PrivateProfile\\hidden.log'; + const unc = '\\\\private-server\\secret-share\\hidden.log'; + const slashUnc = '//private-server/secret-share/hidden.log'; + const out = redactLogText([ + `extended UNC failure: ${extendedUnc}`, + `extended drive failure: ${extendedDrive}`, + `UNC failure: ${unc}`, + `slash UNC failure: ${slashUnc}` + ].join('\n'), []); + for (const fragment of ['private-server', 'secret-share', 'PrivateProfile', 'hidden.log']) { + assert.ok(!out.includes(fragment), `private path fragment survived: ${fragment}`); + } + assert.equal((out.match(//g) || []).length, 4); +}); + test('sanitizeConfig does not mutate input', () => { const input = { hosters: { 'voe.sx': [{ password: 'secret' }] } }; const clone = JSON.parse(JSON.stringify(input)); @@ -285,3 +338,32 @@ test('buildSupportBundleText removes a one-character configured secret', () => { fs.unlinkSync(tmp); } }); + +test('buildSupportBundleText contains no escaped secrets, credential HTML or absolute path variants', () => { + const tmp = path.join(os.tmpdir(), `mhu-bundle-hard-redaction-${Date.now()}.log`); + const secret = 'alpha"beta\\gamma'; + const paths = [ + '\\\\?\\UNC\\private-server\\secret-share\\hidden.log', + '\\\\private-server\\secret-share\\hidden.log', + '//private-server/secret-share/hidden.log', + 'C:\\Users\\PrivateProfile\\hidden.log' + ]; + fs.writeFileSync(tmp, [ + JSON.stringify({ token: secret, path: paths[0] }), + '', + ...paths + ].join('\n')); + try { + const text = buildSupportBundleText({ + header: { Source: paths[3] }, + sanitizedConfig: { marker: JSON.stringify(secret), path: paths[1] }, + secrets: [secret], + files: [{ label: paths[2], path: tmp }] + }); + for (const value of ['alpha', 'beta', 'gamma', 'abc>secret', 'private-server', 'secret-share', 'PrivateProfile', 'hidden.log', tmp]) { + assert.ok(!text.includes(value), `support bundle leak survived: ${value}`); + } + } finally { + fs.unlinkSync(tmp); + } +});