diff --git a/lib/diagnostics-collectors.js b/lib/diagnostics-collectors.js index 41f890d..7c4a169 100644 --- a/lib/diagnostics-collectors.js +++ b/lib/diagnostics-collectors.js @@ -79,10 +79,10 @@ function createCollectors(deps) { readableNames.add(path.basename(fp)); try { const st = fs.statSync(fp); - variants.push({ backup, sizeBytes: st.size, mtime: st.mtime.toISOString() }); + variants.push({ id: backup === 0 ? name : `${name}:${backup}`, backup, fileName: path.basename(fp), sizeBytes: st.size, mtime: st.mtime.toISOString() }); } catch {} } - files.push({ name, path: base, readable: true, present: variants.length > 0, variants }); + files.push({ id: name, name, fileName: path.basename(base), readable: true, present: variants.length > 0, variants }); } let siblings = []; try { @@ -95,16 +95,16 @@ function createCollectors(deps) { return { name: f, readable: false, sizeBytes: size, mtime }; }); } catch {} - return { dir, files, otherLogs: siblings }; + return { files, otherLogs: siblings }; } function readLog(args) { const a = args || {}; const name = a.name; const p = _resolveLogPath(name, a.backup); - if (!p) return { ok: false, error: `unknown or non-readable log: ${name}` }; + if (!p) return { ok: false, error: 'unknown or non-readable log identifier' }; const tailKb = Math.min(Math.max(Number(a.tailKb) || 256, 1), 1024); - const raw = support.collectFile(p, name, tailKb * 1024); + const raw = support.collectFile(p, name, tailKb * 1024, { includePath: false }); let content = support.redactLogText(raw, _secrets()); let matchedLines; if (a.grep && typeof a.grep === 'string' && a.grep.length <= 200) { @@ -120,7 +120,7 @@ function createCollectors(deps) { } let sizeBytes = null; try { sizeBytes = fs.statSync(p).size; } catch {} - return { name, path: p, sizeBytes, returnedBytes: Buffer.byteLength(content), tailKb, matchedLines, content }; + return { id: name, name, fileName: path.basename(p), sizeBytes, returnedBytes: Buffer.byteLength(content), tailKb, matchedLines, content }; } function getAppEvents(args) { diff --git a/lib/support-bundle.js b/lib/support-bundle.js index 468b7f3..b646d26 100644 --- a/lib/support-bundle.js +++ b/lib/support-bundle.js @@ -26,47 +26,83 @@ function collectSecretValues(config) { if (typeof o !== 'object') return; for (const k of Object.keys(o)) { const v = o[k]; - if (CRED_KEYS.has(k) && typeof v === 'string' && v.length >= 6) out.add(v); + if (CRED_KEYS.has(k) && typeof v === 'string' && v.length > 0) out.add(v); else walk(v); } })(config); return Array.from(out); } -function redactLogText(text, secrets) { - if (typeof text !== 'string' || !text) return text; +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))) + .sort((a, b) => b.length - a.length); let out = text; - if (Array.isArray(secrets)) { - for (const s of secrets) { - if (typeof s === 'string' && s.length >= 6) out = out.split(s).join(REDACTED); + 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))) { + out = `${out.slice(0, index)}${REDACTED}${out.slice(index + value.length)}`; + offset = index + REDACTED.length; + } else { + offset = index + value.length; + } } } - out = out + return out; +} + +function redactHtmlCredentialFields(text) { + return text.replace(/]*>/gi, input => { + 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}`); + }); +} + +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(/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(/(authorization:\s*(?:bearer|basic)\s+)\S+/gi, '$1' + REDACTED) - .replace(/\bbearer\s+[A-Za-z0-9._\-/+]{16,}/gi, 'bearer ' + REDACTED) + .replace(/(\b(?:proxy-)?authorization\s*:\s*)[^\r\n]*/gi, '$1' + REDACTED) + .replace(/(\b(?:set-cookie|cookie)\s*:\s*)[^\r\n]*/gi, '$1' + REDACTED) + .replace(/(\b(?:bearer|basic)\s+)[A-Za-z0-9._~+\-/=]+/gi, '$1' + REDACTED) .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}/g, REDACTED) - .replace(/([?&](?:api_?key|key|token|access_token|password|pass)=)[^\s&"'`]+/gi, '$1' + REDACTED) - .replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|(?:access|refresh|auth|session)[_-]?token|token|sessionid|session)"?\s*[:=]\s*"?)[A-Za-z0-9._\-/+]{8,}/gi, '$1' + REDACTED) - .replace(/(\bset-cookie:|\bcookie:)\s*\S[^\n]*/gi, '$1 ' + REDACTED) - .replace(/(\bsess(?:_?id)?\b["'=:\s]+)[A-Za-z0-9._\-]{8,}/gi, '$1' + REDACTED); + .replace(/([?&](?:api[_-]?key|key|token|access[_-]?token|refresh[_-]?token|auth|authorization|password|pass|cookie|session(?:[_-]?id)?)=)[^\s&#"'`]+/gi, '$1' + REDACTED) + .replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|authorization|cookie|(?:access|refresh|auth|session)[_-]?token|token|session[_-]?id|sessionid|session|sess[_-]?id|sessid|sess)"?\s*[:=]\s*)(["'])(.*?)\2/gi, `$1$2${REDACTED}$2`) + .replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|authorization|cookie|(?:access|refresh|auth|session)[_-]?token|token|session[_-]?id|sessionid|session|sess[_-]?id|sessid|sess)"?\s*[:=]\s*)(?!["'])([^\s,;}\]\r\n]+)/gi, '$1' + REDACTED); return out; } function valueScrub(value, secrets) { if (value === null || value === undefined) return value; - const json = JSON.stringify(value); - let scrubbed = json; - if (Array.isArray(secrets)) { - for (const s of secrets) { - if (typeof s === 'string' && s.length >= 6) scrubbed = scrubbed.split(s).join(REDACTED); - } + if (typeof value === 'string') return redactLogText(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); + return out; } - return JSON.parse(scrubbed); + return value; } function collectFile(filePath, label, maxBytes, options) { @@ -107,12 +143,12 @@ function buildSupportBundleText({ header, sanitizedConfig, files, secrets }) { } parts.push('\n'); parts.push('=== Config (sanitized — password/apiKey/token/cookie/sessionId redacted) ===\n'); - parts.push(redactLogText(JSON.stringify(sanitizedConfig, null, 2), secrets)); + parts.push(JSON.stringify(sanitizedConfig, null, 2)); parts.push('\n\n'); for (const f of (files || [])) { - parts.push(redactLogText(collectFile(f.path, f.label || f.path, f.maxBytes, { includePath: false }), secrets)); + parts.push(collectFile(f.path, f.label || 'log', f.maxBytes, { includePath: false })); } - return parts.join(''); + return redactLogText(parts.join(''), secrets); } module.exports = { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, buildSupportBundleText, CRED_KEYS, REDACTED }; diff --git a/lib/upload-audit.js b/lib/upload-audit.js index 7d96d38..3819f46 100644 --- a/lib/upload-audit.js +++ b/lib/upload-audit.js @@ -1,4 +1,5 @@ const nodePath = require('path'); +const { formatUploadPlanLogLine } = require('./upload-log'); function getUploadAuditLogPath(uploadLogPath, pathApi = nodePath) { if (typeof uploadLogPath !== 'string' || !uploadLogPath.trim()) return null; @@ -12,7 +13,7 @@ function createUploadAuditWriter(options) { const resolveUploadLogTarget = source.resolveUploadLogTarget; const rotateLogFile = typeof source.rotateLogFile === 'function' ? source.rotateLogFile : () => {}; const invalidateUploadLogTarget = typeof source.invalidateUploadLogTarget === 'function' ? source.invalidateUploadLogTarget : () => {}; - const persistFallbackLogPath = typeof source.persistFallbackLogPath === 'function' ? source.persistFallbackLogPath : async () => {}; + const persistFallbackLogPath = typeof source.persistFallbackLogPath === 'function' ? source.persistFallbackLogPath : async () => false; const reportError = typeof source.reportError === 'function' ? source.reportError : () => {}; const retryDelays = Array.isArray(source.retryDelays) && source.retryDelays.length > 0 ? source.retryDelays : [0, 100, 250]; const maxBytes = Number.isFinite(source.maxBytes) ? source.maxBytes : 10 * 1024 * 1024; @@ -24,27 +25,35 @@ function createUploadAuditWriter(options) { } async function append(line, label) { - let excludedPath = null; + const excludedPaths = new Set(); for (const delay of retryDelays) { if (delay) await new Promise(resolve => setTimeout(resolve, delay)); - const uploadTarget = resolveUploadLogTarget(excludedPath); + const uploadTarget = resolveUploadLogTarget(excludedPaths); + if (!uploadTarget || excludedPaths.has(uploadTarget.path)) continue; const targetPath = uploadTarget && getUploadAuditLogPath(uploadTarget.path, path); if (!targetPath) continue; try { fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - rotateLogFile(targetPath, maxBytes, maxBackups); - await fs.promises.appendFile(targetPath, line, 'utf-8'); - activePath = targetPath; if (uploadTarget.isFallback) { + let persisted = false; try { - await persistFallbackLogPath(uploadTarget.path); + persisted = await persistFallbackLogPath(uploadTarget.path); } catch (error) { reportError('audit-fallback-persist', error); } + if (persisted !== true) { + excludedPaths.add(uploadTarget.path); + invalidateUploadLogTarget(); + reportError('audit-fallback-persist', new Error('Fallback log path could not be persisted')); + continue; + } } + rotateLogFile(targetPath, maxBytes, maxBackups); + await fs.promises.appendFile(targetPath, line, 'utf-8'); + activePath = targetPath; return true; } catch (error) { - excludedPath = uploadTarget.path; + excludedPaths.add(uploadTarget.path); invalidateUploadLogTarget(); reportError(label, error); } @@ -55,4 +64,27 @@ function createUploadAuditWriter(options) { return { append, getActivePath: () => activePath }; } -module.exports = { getUploadAuditLogPath, createUploadAuditWriter }; +function createUploadAuditEvents(writer, now = () => new Date()) { + if (!writer || typeof writer.append !== 'function') throw new TypeError('createUploadAuditEvents requires an audit writer'); + return { + appendSourceCleanup: event => writer.append(`# SOURCE-CLEANUP ${JSON.stringify(event)}\r\n`, 'source-cleanup'), + appendUploadPlan: (plan, mode) => writer.append(formatUploadPlanLogLine(now(), plan, mode), 'upload-plan') + }; +} + +async function runAfterDurableAudit(audit, action) { + let persisted = false; + try { + persisted = await audit(); + } catch {} + if (persisted !== true) return { ok: false }; + return { ok: true, value: await action() }; +} + +function getUploadAuditFailureMessage(language) { + return language === 'de' + ? 'Der Uploadplan konnte nicht dauerhaft protokolliert werden. Bitte prüfe den Log-Pfad und versuche es erneut.' + : 'The upload plan could not be recorded durably. Check the log path and try again.'; +} + +module.exports = { getUploadAuditLogPath, createUploadAuditWriter, createUploadAuditEvents, runAfterDurableAudit, getUploadAuditFailureMessage }; diff --git a/main.js b/main.js index c190f92..3f0c1e0 100644 --- a/main.js +++ b/main.js @@ -27,8 +27,8 @@ const { walkFolderAsync } = require('./lib/file-discovery'); const RemoteServer = require('./lib/remote-server'); const { maybeRotateLogFile } = require('./lib/log-rotation'); const { hosterLogToFileEnabled } = require('./lib/log-policy'); -const { formatUploadLogLine, parseUploadLogLine, summarizeBatchPlan, formatUploadPlanLogLine } = require('./lib/upload-log'); -const { getUploadAuditLogPath, createUploadAuditWriter } = require('./lib/upload-audit'); +const { formatUploadLogLine, parseUploadLogLine, summarizeBatchPlan } = require('./lib/upload-log'); +const { getUploadAuditLogPath, createUploadAuditWriter, createUploadAuditEvents, runAfterDurableAudit, getUploadAuditFailureMessage } = require('./lib/upload-audit'); const { selectOrphanTmps } = require('./lib/orphan-tmp'); const { sanitizeConfig, buildSupportBundleText, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED } = require('./lib/support-bundle'); const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify'); @@ -778,13 +778,14 @@ function _invalidateUploadLogTargetCache() { _cachedUploadLogKey = ''; } -function _resolveUploadLogTarget(excludedPath) { +function _resolveUploadLogTarget(excluded) { + const excludedPaths = excluded instanceof Set ? excluded : new Set(excluded ? [excluded] : []); const primary = getLogFilePath(); // The primary path already encodes the mode + date/session, so it changes // when the user toggles mode, daily rolls at midnight, or this is a new // process — cache invalidates naturally on path change. const key = primary; - if (_cachedUploadLogKey === key && _cachedUploadLogTarget && _cachedUploadLogTarget.path !== excludedPath) return _cachedUploadLogTarget; + if (_cachedUploadLogKey === key && _cachedUploadLogTarget && !excludedPaths.has(_cachedUploadLogTarget.path)) return _cachedUploadLogTarget; const commit = (t) => { _cachedUploadLogTarget = t; @@ -793,7 +794,7 @@ function _resolveUploadLogTarget(excludedPath) { }; // Try primary → desktop → userData, mirror the original fallback ladder. - if (primary !== excludedPath) { + if (!excludedPaths.has(primary)) { try { fs.mkdirSync(path.dirname(primary), { recursive: true }); return commit({ path: primary, isFallback: false }); @@ -805,7 +806,7 @@ function _resolveUploadLogTarget(excludedPath) { if (desktop) { try { const p = buildFallbackLogName(desktop); - if (p !== excludedPath) { + if (!excludedPaths.has(p)) { fs.mkdirSync(path.dirname(p), { recursive: true }); return commit({ path: p, isFallback: true }); } @@ -813,7 +814,7 @@ function _resolveUploadLogTarget(excludedPath) { } try { const p = buildFallbackLogName(app.getPath('userData')); - if (p === excludedPath) return null; + if (excludedPaths.has(p)) return null; fs.mkdirSync(path.dirname(p), { recursive: true }); return commit({ path: p, isFallback: true }); } catch (err) { @@ -836,6 +837,7 @@ const _uploadAuditWriter = createUploadAuditWriter({ persistFallbackLogPath: _persistFallbackLogPath, reportError: (label, error) => debugLog(`${label} audit append failed: ${error.message}`) }); +const _uploadAuditEvents = createUploadAuditEvents(_uploadAuditWriter); function _flushUploadLog() { if (_uploadLogWriting || _uploadLogBuffer.length === 0) return; @@ -936,19 +938,8 @@ function appendUploadLog(hoster, link, fileName) { } } -async function appendUploadAuditLine(line, label) { - return _uploadAuditWriter.append(line, label); -} - -async function appendSourceCleanupAudit(event) { - debugLog(`source-cleanup: ${event.outcome} ${event.file} trigger=${event.trigger || '-'}`); - return appendUploadAuditLine(`# SOURCE-CLEANUP ${JSON.stringify(event)}\r\n`, 'source-cleanup'); -} - -async function appendUploadPlanAudit(plan, mode) { - debugLog(`upload-plan: mode=${mode} files=${plan.fileCount} destinations=${plan.destinationCount} uploads=${plan.plannedUploadCount}`); - return appendUploadAuditLine(formatUploadPlanLogLine(new Date(), plan, mode), 'upload-plan'); -} +const appendSourceCleanupAudit = _uploadAuditEvents.appendSourceCleanup; +const appendUploadPlanAudit = _uploadAuditEvents.appendUploadPlan; function flattenHistoryForExport(history) { const rows = []; @@ -2032,7 +2023,6 @@ ipcMain.handle('start-upload', async (_event, payload) => { const tasks = jobs.length > 0 ? buildUploadTasksFromJobs(config, jobs, pick) : buildUploadTasks(config, files, hosters, pick); - persistRotation(pick); // Identify jobs that were skipped (no account/credentials) const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean)); @@ -2050,8 +2040,16 @@ ipcMain.handle('start-upload', async (_event, payload) => { debugLog(` tasks built: ${tasks.length}`); + const auditedStart = await runAfterDurableAudit( + () => appendUploadPlanAudit(batchPlan, 'start'), + () => tasks.length > 0 ? new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config)) : null + ); + if (!auditedStart.ok) { + return { error: getUploadAuditFailureMessage(getConfiguredLanguage()) }; + } + persistRotation(pick); + if (tasks.length === 0) { - await appendUploadPlanAudit(batchPlan, 'start'); const skippedSummary = stats.mergeSkippedIntoSummary({ id: `skipped-${Date.now()}`, timestamp: new Date().toISOString(), @@ -2068,12 +2066,10 @@ ipcMain.handle('start-upload', async (_event, payload) => { return { started: true, taskCount: 0, skippedJobs }; } - uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config)); + uploadManager = auditedStart.value; globalThis._mhuUploadManagerRef = uploadManager; const _thisManager = uploadManager; - await appendUploadPlanAudit(batchPlan, 'start'); - const recovery = { id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, startedAt: new Date().toISOString(), @@ -2376,11 +2372,18 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => { const sourceCleanupGroups = payload && Array.isArray(payload.sourceCleanupGroups) ? payload.sourceCleanupGroups : []; const pick = makeAccountPicker(config); const tasks = buildUploadTasksFromJobs(config, jobs, pick); - persistRotation(pick); const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean)); const skippedJobs = jobs .filter(j => j && j.id && !taskJobIds.has(j.id)) .map(j => ({ jobId: j.id, hoster: j.hoster, reason: 'Kein gültiger Account für diesen Hoster' })); + if (jobs.length > 0) { + const auditedAdd = await runAfterDurableAudit( + () => appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add'), + () => true + ); + if (!auditedAdd.ok) return { error: getUploadAuditFailureMessage(getConfiguredLanguage()) }; + } + persistRotation(pick); const sourceCleanupFingerprints = batchManager.sourceFileCleanup ? await batchManager.sourceFileCleanup.registerGroups(sourceCleanupGroups) : {}; @@ -2393,7 +2396,6 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => { if (tasks.length === 0) { debugLog(`add-jobs-to-batch: 0 tasks built (${skippedJobs.length} skipped: no account)`); - if (jobs.length > 0) await appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add'); return { added: 0, skippedJobs, alreadyInBatchJobIds: [], sourceCleanupFingerprints }; } @@ -2406,7 +2408,6 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => { debugLog( `add-jobs-to-batch: ${added} of ${tasks.length} tasks added (${alreadyInBatchJobIds.length} already in batch, ${skippedJobs.length} skipped)` ); - if (jobs.length > 0) await appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add'); return { added, skippedJobs, alreadyInBatchJobIds, sourceCleanupFingerprints }; }); diff --git a/tests/diagnostics-collectors.test.js b/tests/diagnostics-collectors.test.js index 38daaf8..8cd626f 100644 --- a/tests/diagnostics-collectors.test.js +++ b/tests/diagnostics-collectors.test.js @@ -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'); }); diff --git a/tests/support-bundle.test.js b/tests/support-bundle.test.js index f1d0ba4..c5d73ec 100644 --- a/tests/support-bundle.test.js +++ b/tests/support-bundle.test.js @@ -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}`, + ``, + ``, + `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(//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); + } +}); diff --git a/tests/upload-audit.test.js b/tests/upload-audit.test.js index 20a0874..2a12a50 100644 --- a/tests/upload-audit.test.js +++ b/tests/upload-audit.test.js @@ -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:/); +});