Harden audit durability and diagnostic redaction

Persist fallback audit targets before use and fail upload starts or active-batch additions closed when the plan cannot be recorded. Keep lifecycle audits out of session and debug logs, expose diagnostics through opaque metadata, localize audit failures, and redact paths plus complete credential values without corrupting benign text.
This commit is contained in:
Sucukdeluxe
2026-08-13 20:34:26 +02:00
parent a98b63618d
commit e63214cae8
7 changed files with 372 additions and 77 deletions
+20 -5
View File
@@ -25,7 +25,7 @@ function makeFixture() {
crashLog: path.join(dir, 'crash.log'),
logDir: dir
};
fs.writeFileSync(paths.debug, `boot ok\nuploading file with token ${fixtureAlpha} inline\nAuthorization: Bearer ${fixtureBeta}\n`);
fs.writeFileSync(paths.debug, `boot ok\nsource ${path.join(dir, 'private-source.mkv')}\nuploading file with token ${fixtureAlpha} inline\nAuthorization: Bearer ${fixtureBeta}\n`);
fs.writeFileSync(paths.uploadAudit, `# SOURCE-CLEANUP {"token":"${fixtureAlpha}"}\n`);
fs.writeFileSync(paths.doodstreamDebug, `api_key=${fixtureGamma} sess=abc\n`);
fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n');
@@ -88,7 +88,7 @@ test('getHistory falls back to loadConfig().history when loadHistory is absent (
});
test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => {
const { collectors } = makeFixture();
const { collectors, dir, paths } = makeFixture();
const dbg = collectors.readLog({ name: 'debug', tailKb: 64 });
const audit = collectors.readLog({ name: 'uploadAudit', tailKb: 64 });
assert.ok(!dbg.content.includes('SECRETTOKEN123456'), 'value-scrub removes the live diag token from logs');
@@ -97,6 +97,10 @@ test('readLog redacts a planted token and a Bearer line; doodstream is NOT reada
assert.ok(!audit.content.includes('SECRETTOKEN123456'), 'source cleanup audit is readable only through the redacted diagnostics path');
assert.equal(collectors.readLog({ name: 'doodstreamDebug' }).ok, false, 'doodstream-debug.log is not in the readable allowlist');
assert.equal(collectors.readLog({ name: '../../etc/passwd' }).ok, false, 'arbitrary names are rejected (no path traversal)');
assert.ok(!JSON.stringify(dbg).includes(dir), 'read log content and metadata must not expose its absolute directory');
const rejectedAbsolutePath = collectors.readLog({ name: paths.debug });
assert.ok(!rejectedAbsolutePath.error.includes(paths.debug), 'rejected log identifiers must not be echoed as absolute paths');
assert.ok(!rejectedAbsolutePath.error.includes(path.basename(paths.debug)), 'rejected log identifiers must not echo path components');
assert.equal(collectors.readLog({ name: 'crash' }).name, 'crash');
});
@@ -104,11 +108,21 @@ test('rotated audit backups are listed and readable with the rotation naming con
const { collectors, paths, fixtureAlpha } = makeFixture();
const backupPath = path.join(path.dirname(paths.uploadAudit), 'upload-audit.1.log');
fs.writeFileSync(backupPath, `# SOURCE-CLEANUP {"token":"${fixtureAlpha}"}\n`);
const listed = collectors.listLogs().files.find(file => file.name === 'uploadAudit');
const logList = collectors.listLogs();
const listed = logList.files.find(file => file.name === 'uploadAudit');
assert.equal(logList.dir, undefined);
assert.equal(listed.id, 'uploadAudit');
assert.equal(listed.fileName, 'upload-audit.log');
assert.equal(listed.path, undefined);
assert.ok(listed.variants.some(variant => variant.backup === 1));
assert.ok(listed.variants.every(variant => variant.fileName && !Object.hasOwn(variant, 'path')));
assert.ok(!collectors.listLogs().otherLogs.some(file => file.name === 'upload-audit.1.log'));
const backup = collectors.readLog({ name: 'uploadAudit', backup: 1, tailKb: 64 });
assert.equal(backup.path, backupPath);
assert.equal(backup.id, 'uploadAudit');
assert.equal(backup.name, 'uploadAudit');
assert.equal(backup.fileName, 'upload-audit.1.log');
assert.equal(backup.path, undefined);
assert.ok(!JSON.stringify({ logList, backup }).includes(path.dirname(paths.uploadAudit)));
assert.ok(!backup.content.includes(fixtureAlpha));
});
@@ -171,9 +185,10 @@ test('listErrors classifies via stats.classifyErrorCategory and redacts error te
});
test('serverHealth assembles the one-shot hub without leaking secrets', () => {
const { collectors } = makeFixture();
const { collectors, dir, paths } = makeFixture();
const h = collectors.serverHealth({});
const json = JSON.stringify(h);
assert.ok(h.server && h.queue && h.errors && h.logs, 'hub has all sections');
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');
});
+86 -1
View File
@@ -3,7 +3,7 @@ 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');
const { sanitizeConfig, collectSecretValues, collectFile, buildSupportBundleText, redactLogText, REDACTED } = require('../lib/support-bundle');
test('sanitizeConfig redacts known credential keys at any nesting depth', () => {
const input = {
@@ -85,6 +85,38 @@ test('redactLogText leaves a normal "session" word in prose alone', () => {
assert.equal(redactLogText(benign, []), benign);
});
test('redactLogText removes complete authorization, cookie, session, HTML credential, and query values', () => {
const authorization = 'Digest username="private-user", realm="private-realm", response="private-response"';
const cookie = 'sid=private-cookie; preferences=private-preferences';
const sessionId = 's3';
const htmlPassword = 'private-html-password';
const htmlToken = 'private-html-token';
const queryToken = 'q1';
const input = [
`Authorization: ${authorization}`,
`Cookie: ${cookie}`,
`session_id=${sessionId}`,
`<input type="password" name="password" value="${htmlPassword}">`,
`<input value="${htmlToken}" name="api_token" type="text">`,
`https://example.invalid/upload?token=${queryToken}&next=ok`
].join('\n');
const out = redactLogText(input, []);
for (const value of [authorization, 'private-user', 'private-realm', 'private-response', cookie, 'private-cookie', 'private-preferences', sessionId, htmlPassword, htmlToken, queryToken]) {
assert.ok(!out.includes(value), `sensitive value survived: ${value}`);
}
assert.ok((out.match(/<redacted>/g) || []).length >= 6);
});
test('redactLogText masks a one-character configured secret only as a complete sensitive value', () => {
const out = redactLogText('status=diagnostics available\npassword=x\nfile=xylophone.mkv\nmarker=x', ['x']);
assert.ok(out.includes('status=diagnostics available'));
assert.ok(out.includes('file=xylophone.mkv'));
assert.ok(!out.includes('password=x'));
assert.ok(!out.includes('marker=x'));
assert.ok(out.includes(`password=${REDACTED}`));
assert.ok(out.includes(`marker=${REDACTED}`));
});
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('\\');
@@ -172,6 +204,18 @@ test('buildSupportBundleText handles empty file list and missing header', () =>
assert.match(text, /=== Config/);
});
test('buildSupportBundleText never uses an absolute source path as a section label', () => {
const tmp = path.join(os.tmpdir(), `mhu-bundle-unlabeled-${Date.now()}.log`);
fs.writeFileSync(tmp, 'safe content\n');
try {
const text = buildSupportBundleText({ sanitizedConfig: {}, files: [{ path: tmp }], secrets: [] });
assert.ok(!text.includes(tmp));
assert.ok(text.includes('=== log (size='));
} finally {
fs.unlinkSync(tmp);
}
});
test('buildSupportBundleText redacts configured and pattern-detected secrets from included logs', () => {
const tmp = path.join(os.tmpdir(), `mhu-bundle-secrets-${Date.now()}.log`);
const configuredSecret = ['configured', 'Secret', '123456'].join('');
@@ -200,3 +244,44 @@ test('buildSupportBundleText redacts configured and pattern-detected secrets fro
fs.unlinkSync(tmp);
}
});
test('buildSupportBundleText removes configured secrets of every non-empty length', () => {
const tmp = path.join(os.tmpdir(), `mhu-bundle-short-secrets-${Date.now()}.log`);
const config = {
hosters: { 'voe.sx': [{ password: 'p1', apiKey: 'k2' }] },
globalSettings: { diagnostics: { token: 't3' }, cookie: '', sessionId: null }
};
fs.writeFileSync(tmp, 'password=p1\napiKey=k2\ntoken=t3\n');
try {
const secrets = collectSecretValues(config);
assert.deepEqual(new Set(secrets), new Set(['p1', 'k2', 't3']));
const text = buildSupportBundleText({
header: { Marker: 'p1-k2-t3' },
sanitizedConfig: sanitizeConfig(config),
secrets,
files: [{ label: 'short-secrets.log', path: tmp }]
});
for (const secret of ['p1', 'k2', 't3']) assert.ok(!text.includes(secret), `configured secret survived: ${secret}`);
} finally {
fs.unlinkSync(tmp);
}
});
test('buildSupportBundleText removes a one-character configured secret', () => {
const tmp = path.join(os.tmpdir(), `mhu-bundle-one-character-secret-${Date.now()}.log`);
const config = { hosters: { 'voe.sx': [{ password: 'x' }] } };
fs.writeFileSync(tmp, 'password=x\n');
try {
const text = buildSupportBundleText({
header: { Marker: 'secret:x' },
sanitizedConfig: sanitizeConfig(config),
secrets: collectSecretValues(config),
files: [{ label: 'one-character.log', path: tmp }]
});
assert.ok(!text.includes('secret:x'));
assert.ok(!text.includes('password=x'));
assert.ok(text.includes('one-character.log'));
} finally {
fs.unlinkSync(tmp);
}
});
+131 -5
View File
@@ -6,13 +6,17 @@ const path = require('path');
test('internal audit records never contaminate the MDU session link log', async () => {
let createUploadAuditWriter;
let createUploadAuditEvents;
try {
({ createUploadAuditWriter } = require('../lib/upload-audit'));
({ createUploadAuditWriter, createUploadAuditEvents } = require('../lib/upload-audit'));
} catch {}
assert.equal(typeof createUploadAuditWriter, 'function');
assert.equal(typeof createUploadAuditEvents, 'function');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-upload-audit-'));
const sessionLog = path.join(directory, '13-08-2026-mdu-session-14-20-123456.log');
const debugLog = path.join(directory, 'upload-debug.log');
fs.writeFileSync(debugLog, 'debug-before\r\n');
const writer = createUploadAuditWriter({
fs,
path,
@@ -22,13 +26,15 @@ test('internal audit records never contaminate the MDU session link log', async
reportError: () => {},
retryDelays: [0]
});
const events = createUploadAuditEvents(writer, () => new Date('2026-08-13T12:00:00.000Z'));
await writer.append('# SOURCE-CLEANUP {"outcome":"deleted"}\r\n', 'source-cleanup');
await writer.append('# UPLOAD-PLAN {"plannedUploadCount":4}\r\n', 'upload-plan');
await events.appendSourceCleanup({ outcome: 'deleted' });
await events.appendUploadPlan({ fileCount: 2, destinationCount: 2, plannedUploadCount: 4 }, 'start');
const auditLog = path.join(directory, 'upload-audit.log');
assert.equal(fs.existsSync(sessionLog), false);
assert.equal(fs.readFileSync(auditLog, 'utf8'), '# SOURCE-CLEANUP {"outcome":"deleted"}\r\n# UPLOAD-PLAN {"plannedUploadCount":4}\r\n');
assert.equal(fs.readFileSync(debugLog, 'utf8'), 'debug-before\r\n');
assert.equal(fs.readFileSync(auditLog, 'utf8'), '# SOURCE-CLEANUP {"outcome":"deleted"}\r\n# UPLOAD-PLAN {"timestamp":"2026-08-13T12:00:00.000Z","mode":"start","fileCount":2,"destinationCount":2,"plannedUploadCount":4}\r\n');
fs.rmSync(directory, { recursive: true, force: true });
});
@@ -51,7 +57,7 @@ test('audit writer reports the actual fallback file after a failed primary write
}),
rotateLogFile: () => {},
invalidateUploadLogTarget: () => {},
persistFallbackLogPath: async targetPath => { persistedFallbacks.push(targetPath); },
persistFallbackLogPath: async targetPath => { persistedFallbacks.push(targetPath); return true; },
reportError: () => {},
retryDelays: [0, 0]
});
@@ -62,3 +68,123 @@ test('audit writer reports the actual fallback file after a failed primary write
assert.equal(fs.readFileSync(writer.getActivePath(), 'utf8'), '# UPLOAD-PLAN {}\r\n');
fs.rmSync(directory, { recursive: true, force: true });
});
test('audit writer rejects false and thrown fallback persistence before trying the next safe target', async (t) => {
const { createUploadAuditWriter } = require('../lib/upload-audit');
for (const rejection of ['false', 'throw']) {
await t.test(rejection, async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), `mhu-upload-audit-${rejection}-`));
const first = path.join(directory, 'first', 'fileuploader.log');
const second = path.join(directory, 'second', 'fileuploader.log');
const targets = [first, second].map(targetPath => ({ path: targetPath, isFallback: true }));
const persistedFallbacks = [];
const writer = createUploadAuditWriter({
fs,
path,
resolveUploadLogTarget: excluded => {
const excludedPaths = excluded instanceof Set ? excluded : new Set(excluded ? [excluded] : []);
return targets.find(target => !excludedPaths.has(target.path)) || null;
},
rotateLogFile: () => {},
invalidateUploadLogTarget: () => {},
persistFallbackLogPath: async targetPath => {
persistedFallbacks.push(targetPath);
if (targetPath === first) {
if (rejection === 'throw') throw new Error('settings write failed');
return false;
}
return true;
},
reportError: () => {},
retryDelays: [0, 0, 0]
});
assert.equal(await writer.append('# UPLOAD-PLAN {}\r\n', 'upload-plan'), true);
assert.equal(writer.getActivePath(), path.join(directory, 'second', 'upload-audit.log'));
assert.deepEqual(persistedFallbacks, [first, second]);
assert.equal(fs.existsSync(path.join(directory, 'first', 'upload-audit.log')), false);
assert.equal(fs.readFileSync(writer.getActivePath(), 'utf8'), '# UPLOAD-PLAN {}\r\n');
fs.rmSync(directory, { recursive: true, force: true });
});
}
});
test('audit writer returns false only after every allowed fallback target is rejected', async () => {
const { createUploadAuditWriter } = require('../lib/upload-audit');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-upload-audit-exhausted-'));
const targets = ['first', 'second'].map(name => ({ path: path.join(directory, name, 'fileuploader.log'), isFallback: true }));
const persistedFallbacks = [];
const writer = createUploadAuditWriter({
fs,
path,
resolveUploadLogTarget: excluded => {
const excludedPaths = excluded instanceof Set ? excluded : new Set(excluded ? [excluded] : []);
return targets.find(target => !excludedPaths.has(target.path)) || null;
},
rotateLogFile: () => {},
invalidateUploadLogTarget: () => {},
persistFallbackLogPath: async targetPath => {
persistedFallbacks.push(targetPath);
if (persistedFallbacks.length === 2) throw new Error('settings write failed');
return false;
},
reportError: () => {},
retryDelays: [0, 0, 0]
});
assert.equal(await writer.append('# UPLOAD-PLAN {}\r\n', 'upload-plan'), false);
assert.deepEqual(persistedFallbacks, targets.map(target => target.path));
assert.equal(writer.getActivePath(), null);
assert.equal(fs.existsSync(path.join(directory, 'first', 'upload-audit.log')), false);
assert.equal(fs.existsSync(path.join(directory, 'second', 'upload-audit.log')), false);
fs.rmSync(directory, { recursive: true, force: true });
});
test('durable audit gate never creates a manager or adds jobs after a false or thrown audit', async () => {
const { runAfterDurableAudit } = require('../lib/upload-audit');
assert.equal(typeof runAfterDurableAudit, 'function');
for (const actionName of ['manager', 'addJobs']) {
for (const audit of [async () => false, async () => { throw new Error('audit failed'); }]) {
let actions = 0;
const result = await runAfterDurableAudit(audit, () => { actions += 1; return actionName; });
assert.equal(result.ok, false);
assert.equal(actions, 0);
}
}
let actions = 0;
const result = await runAfterDurableAudit(async () => true, () => { actions += 1; return 'started'; });
assert.deepEqual(result, { ok: true, value: 'started' });
assert.equal(actions, 1);
});
test('audit failure message is localized and tells the user how to retry', () => {
const { getUploadAuditFailureMessage } = require('../lib/upload-audit');
const german = getUploadAuditFailureMessage('de');
const english = getUploadAuditFailureMessage('en');
assert.match(german, /Log-Pfad/);
assert.match(german, /erneut/);
assert.match(english, /log path/i);
assert.match(english, /try again/i);
assert.notEqual(german, english);
});
test('main process audits batch plans before creating or mutating upload work', () => {
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
const startHandler = mainSource.slice(
mainSource.indexOf("ipcMain.handle('start-upload'"),
mainSource.indexOf("ipcMain.handle('cancel-upload'")
);
const addHandler = mainSource.slice(
mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'"),
mainSource.indexOf("ipcMain.handle('finish-after-active'")
);
assert.ok(startHandler.indexOf("appendUploadPlanAudit(batchPlan, 'start')") < startHandler.indexOf('new UploadManager('));
assert.ok(startHandler.indexOf("appendUploadPlanAudit(batchPlan, 'start')") < startHandler.indexOf('persistRotation(pick)'));
assert.ok(addHandler.indexOf("appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add')") < addHandler.indexOf('persistRotation(pick)'));
assert.ok(addHandler.indexOf("appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add')") < addHandler.indexOf('registerGroups(sourceCleanupGroups)'));
assert.ok(addHandler.indexOf("appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add')") < addHandler.indexOf('batchManager.addJobs(tasks)'));
assert.doesNotMatch(mainSource, /debugLog\(`source-cleanup:/);
assert.doesNotMatch(mainSource, /debugLog\(`upload-plan:/);
});