|
| 1 | +import { |
| 2 | + isSensitiveKey, |
| 3 | + REDACTED_MARKER, |
| 4 | + redactExactSensitiveValues, |
| 5 | +} from '@/lib/core/security/redaction' |
| 6 | + |
1 | 7 | const MAX_OCI_ERROR_FIELD_LENGTH = 1024 |
2 | | -const MAX_OCI_ERROR_INPUT_LENGTH = 8192 |
| 8 | +const MAX_OCI_ERROR_INPUT_LENGTH = 65_536 |
3 | 9 | const MAX_NESTED_JSON_DEPTH = 3 |
4 | | -const SENSITIVE_JSON_FIELDS = new Set([ |
5 | | - 'authorization', |
6 | | - 'passphrase', |
7 | | - 'privatekey', |
8 | | - 'proxyauthorization', |
9 | | - 'signingstring', |
10 | | -]) |
| 10 | +const OCI_SENSITIVE_JSON_FIELDS = new Set(['signingstring']) |
| 11 | +const ENCODED_DIAGNOSTIC_SENTINELS = ['-----BEGIN', 'https://'] |
| 12 | + |
| 13 | +function normalizeJsonDiagnosticKey(key: string): string | undefined { |
| 14 | + let normalized = key |
| 15 | + for (let depth = 0; depth < MAX_NESTED_JSON_DEPTH; depth += 1) { |
| 16 | + if (!normalized.includes('%')) return normalized |
| 17 | + if (!/%[0-9a-f]{2}/i.test(normalized)) return undefined |
| 18 | + try { |
| 19 | + normalized = decodeURIComponent(normalized) |
| 20 | + } catch { |
| 21 | + return undefined |
| 22 | + } |
| 23 | + } |
| 24 | + return normalized.includes('%') ? undefined : normalized |
| 25 | +} |
| 26 | + |
| 27 | +function looksLikeStructuredJson(value: string): boolean { |
| 28 | + const first = value.trimStart()[0] |
| 29 | + return first === '{' || first === '[' || first === '"' |
| 30 | +} |
| 31 | + |
| 32 | +function isSensitiveOciJsonKey(key: string): boolean { |
| 33 | + const compactKey = key.replace(/[^a-z]/gi, '').toLowerCase() |
| 34 | + return OCI_SENSITIVE_JSON_FIELDS.has(compactKey) || isSensitiveKey(key) |
| 35 | +} |
| 36 | + |
| 37 | +function containsEncodedDiagnosticSentinel(value: string): boolean { |
| 38 | + const lowerValue = value.toLowerCase() |
| 39 | + return ENCODED_DIAGNOSTIC_SENTINELS.some((sentinel) => { |
| 40 | + let encoded = sentinel |
| 41 | + for (let depth = 0; depth < MAX_NESTED_JSON_DEPTH; depth += 1) { |
| 42 | + encoded = encodeURIComponent(encoded) |
| 43 | + if (encoded !== sentinel && lowerValue.includes(encoded.toLowerCase())) return true |
| 44 | + } |
| 45 | + return false |
| 46 | + }) |
| 47 | +} |
11 | 48 |
|
12 | 49 | function flattenJsonDiagnostic(value: unknown, depth = 0): string | undefined { |
13 | | - if (depth > MAX_NESTED_JSON_DEPTH || value === null) return undefined |
14 | | - if (typeof value === 'string') return value |
| 50 | + if (depth > MAX_NESTED_JSON_DEPTH) return undefined |
| 51 | + if (value === null) return 'null' |
| 52 | + if (typeof value === 'string') { |
| 53 | + if (!looksLikeStructuredJson(value)) return value |
| 54 | + if (depth === MAX_NESTED_JSON_DEPTH) return undefined |
| 55 | + try { |
| 56 | + return flattenJsonDiagnostic(JSON.parse(value), depth + 1) |
| 57 | + } catch { |
| 58 | + return undefined |
| 59 | + } |
| 60 | + } |
15 | 61 | if (typeof value === 'number' || typeof value === 'boolean') return String(value) |
16 | 62 | if (Array.isArray(value)) { |
17 | | - return value |
18 | | - .map((entry) => flattenJsonDiagnostic(entry, depth + 1)) |
19 | | - .filter((entry): entry is string => entry !== undefined) |
20 | | - .join(' ') |
| 63 | + if (depth === MAX_NESTED_JSON_DEPTH) return undefined |
| 64 | + const flattened = value.map((entry) => flattenJsonDiagnostic(entry, depth + 1)) |
| 65 | + if (flattened.some((entry) => entry === undefined)) return undefined |
| 66 | + return flattened.join(' ') |
21 | 67 | } |
22 | 68 | if (typeof value !== 'object') return undefined |
23 | | - return Object.entries(value) |
24 | | - .map(([key, entry]) => { |
25 | | - const normalizedKey = key.replace(/[^a-z]/gi, '').toLowerCase() |
26 | | - if (SENSITIVE_JSON_FIELDS.has(normalizedKey)) return `${key}: [redacted]` |
27 | | - const flattened = flattenJsonDiagnostic(entry, depth + 1) |
28 | | - return flattened === undefined ? undefined : `${key}: ${flattened}` |
29 | | - }) |
30 | | - .filter((entry): entry is string => entry !== undefined) |
31 | | - .join(' ') |
| 69 | + if (depth === MAX_NESTED_JSON_DEPTH) return undefined |
| 70 | + const flattened = Object.entries(value).map(([key, entry]) => { |
| 71 | + const normalizedKey = normalizeJsonDiagnosticKey(key) |
| 72 | + if (normalizedKey === undefined) return undefined |
| 73 | + if (isSensitiveOciJsonKey(normalizedKey)) return `${key}: ${REDACTED_MARKER}` |
| 74 | + const nested = flattenJsonDiagnostic(entry, depth + 1) |
| 75 | + return nested === undefined ? undefined : `${key}: ${nested}` |
| 76 | + }) |
| 77 | + if (flattened.some((entry) => entry === undefined)) return undefined |
| 78 | + return flattened.join(' ') |
32 | 79 | } |
33 | 80 |
|
34 | | -function decodeNestedJsonDiagnostic(value: string): string { |
35 | | - let decoded = value.slice(0, MAX_OCI_ERROR_INPUT_LENGTH) |
36 | | - for (let depth = 0; depth < MAX_NESTED_JSON_DEPTH; depth += 1) { |
37 | | - let parsed: unknown |
38 | | - try { |
39 | | - parsed = JSON.parse(decoded) |
40 | | - } catch { |
41 | | - break |
42 | | - } |
43 | | - const flattened = flattenJsonDiagnostic(parsed) |
44 | | - if (flattened === undefined || flattened === decoded) break |
45 | | - decoded = flattened.slice(0, MAX_OCI_ERROR_INPUT_LENGTH) |
| 81 | +function decodeNestedJsonDiagnostic(value: string): string | undefined { |
| 82 | + if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined |
| 83 | + if (!looksLikeStructuredJson(value)) return value |
| 84 | + try { |
| 85 | + return flattenJsonDiagnostic(JSON.parse(value)) |
| 86 | + } catch { |
| 87 | + return undefined |
46 | 88 | } |
47 | | - return decoded |
48 | 89 | } |
49 | 90 |
|
50 | 91 | function sanitizeOciErrorField( |
51 | 92 | value: unknown, |
52 | 93 | sensitiveValues: readonly string[] = [] |
53 | 94 | ): string | undefined { |
54 | 95 | if (typeof value !== 'string') return undefined |
55 | | - let sanitized = decodeNestedJsonDiagnostic(value) |
| 96 | + if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined |
| 97 | + if (containsEncodedDiagnosticSentinel(value)) return undefined |
| 98 | + const decoded = decodeNestedJsonDiagnostic(value) |
| 99 | + if (decoded === undefined) return undefined |
| 100 | + const exactValues = sensitiveValues.flatMap((sensitiveValue) => { |
| 101 | + const jsonEncoded = JSON.stringify(sensitiveValue).slice(1, -1) |
| 102 | + return jsonEncoded === sensitiveValue ? [sensitiveValue] : [sensitiveValue, jsonEncoded] |
| 103 | + }) |
| 104 | + let exactRedacted: string |
| 105 | + try { |
| 106 | + exactRedacted = redactExactSensitiveValues(decoded, exactValues) |
| 107 | + } catch { |
| 108 | + return undefined |
| 109 | + } |
| 110 | + const sanitized = exactRedacted |
56 | 111 | .replace(/-----BEGIN[\s\S]*/gi, '[redacted-key]') |
57 | 112 | .replace(/https?:\/\/[^\s"']+/gi, '[redacted-url]') |
58 | | - .replace(/Signature\s+version="1",[^\r\n]*/gi, '[redacted-authorization]') |
59 | | - for (const sensitiveValue of sensitiveValues) { |
60 | | - if (sensitiveValue.length > 0) sanitized = sanitized.split(sensitiveValue).join('[redacted]') |
61 | | - } |
62 | | - sanitized = sanitized.replace(/[\u0000-\u001f\u007f]/g, ' ').trim() |
| 113 | + .replace(/Signature\s+version=\\*"1\\*",[^\r\n]*/gi, '[redacted-authorization]') |
| 114 | + .replace(/[\u0000-\u001f\u007f]/g, ' ') |
| 115 | + .trim() |
63 | 116 | return sanitized ? sanitized.slice(0, MAX_OCI_ERROR_FIELD_LENGTH) : undefined |
64 | 117 | } |
65 | 118 |
|
|
0 commit comments