diff --git a/lib/upload-diagnostics.js b/lib/upload-diagnostics.js index 019565f..df406ce 100644 --- a/lib/upload-diagnostics.js +++ b/lib/upload-diagnostics.js @@ -9,13 +9,28 @@ function cleanText(value, limit = 320) { return '[URL]'; } }); + text = text.replace(/\b(authorization|proxy-authorization|cookie|set-cookie)\s*[:=]\s*[^,]+/gi, '$1=[redacted]'); text = text.replace(/(["']?(?:api[_-]?key|token|password|cookie|authorization|session)["']?\s*[:=]\s*)[^,;\s}"']+/gi, '$1[redacted]'); if (/^(?:bearer|basic)\s+/i.test(text)) { text = '[redacted authorization]'; } + text = text.replace(/\b[A-Za-z0-9_-]{20,}\b/g, '[redacted]'); return text.slice(0, limit); } +function normalizeEndpointHost(value) { + const host = String(value || '').trim().toLowerCase(); + if (!host || host.length > 253 || !/^[a-z0-9.-]+$/.test(host)) return ''; + if (host.startsWith('.') || host.endsWith('.') || host.includes('..')) return ''; + return host; +} + +function normalizePhase(value) { + const phase = String(value || '').trim(); + if (/^[a-z0-9._:-]{1,80}$/i.test(phase)) return phase; + return cleanText(phase, 80); +} + function normalizeFailureDetails(diagnostic) { if (!diagnostic || typeof diagnostic !== 'object') return null; const httpStatus = Number.isInteger(Number(diagnostic.http)) && Number(diagnostic.http) >= 100 && Number(diagnostic.http) <= 599 @@ -23,9 +38,18 @@ function normalizeFailureDetails(diagnostic) { : null; const contentType = cleanText(diagnostic.contentType, 120); const responseSnippet = cleanText(diagnostic.payloadSnippet, 320); + const phase = normalizePhase(diagnostic.phase); + const endpointHost = normalizeEndpointHost(diagnostic.safeEndpointHost); + const responseKind = ['empty', 'json', 'html', 'text'].includes(diagnostic.responseKind) + ? diagnostic.responseKind + : ''; const details = {}; + if (phase) details.phase = phase; if (httpStatus !== null) details.httpStatus = httpStatus; if (contentType) details.contentType = contentType; + if (endpointHost) details.endpointHost = endpointHost; + if (responseKind) details.responseKind = responseKind; + if (typeof diagnostic.retryable === 'boolean') details.retryable = diagnostic.retryable; if (responseSnippet) details.responseSnippet = responseSnippet; return Object.keys(details).length > 0 ? details : null; } diff --git a/tests/upload-diagnostics.test.js b/tests/upload-diagnostics.test.js index 0cae648..17103ba 100644 --- a/tests/upload-diagnostics.test.js +++ b/tests/upload-diagnostics.test.js @@ -25,3 +25,40 @@ test('bereinigt Zugangsdaten und mehrere URLs aus Hosterantworten', () => { assert.equal(result.responseSnippet, 'token=[redacted] one.example/a password: [redacted] two.example/b'); }); + +test('behält sichere Transportdetails für eine konkrete Fehleranalyse', () => { + const result = normalizeFailureDetails({ + phase: 'web-upload-confirmation', + http: 502, + contentType: 'application/json', + safeEndpointHost: 'upload.doodstream.com', + responseKind: 'json', + retryable: true, + payloadSnippet: 'json response (148 bytes)' + }); + + assert.deepEqual(result, { + phase: 'web-upload-confirmation', + httpStatus: 502, + contentType: 'application/json', + endpointHost: 'upload.doodstream.com', + responseKind: 'json', + retryable: true, + responseSnippet: 'json response (148 bytes)' + }); +}); + +test('verwirft unsichere Transportfelder und unbenannte lange Geheimnisse', () => { + const secret = 'SYNTHETIC_UNNAMED_SECRET_1234567890'; + const result = normalizeFailureDetails({ + phase: `upload ${secret}`, + safeEndpointHost: `evil.example/${secret}`, + responseKind: `json-${secret}`, + payloadSnippet: `Authorization: Bearer ${secret}` + }); + + assert.equal(result.phase, 'upload [redacted]'); + assert.equal(result.endpointHost, undefined); + assert.equal(result.responseKind, undefined); + assert.doesNotMatch(JSON.stringify(result), /SYNTHETIC_UNNAMED_SECRET/); +});