UNPKG

@crowdin/app-project-module

Version:

Module that generates for you all common endpoints for serving standalone Crowdin App

170 lines (169 loc) 6.77 kB
"use strict"; /** * Masks JWTs, bearer tokens, passwords, API keys, and other secrets in log records * before they leave the process (stdout / Sentry). Registered with @crowdin/logs-formatter * at SDK bootstrap so every Crowdin app built on this SDK benefits without per-app changes. * * NOTE: a separate field-value masker lives at src/util/credentials-masker.ts (maskKey) — * it masks ALL but last 3 chars with '*', operates on form-data fields, and is wired into * request/response middleware. The two are intentionally distinct: this sanitizer uses * "first 4 chars + ***" on log records, and the field masker uses "***...XYZ" on form * payloads. Do not mix them. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.MASK_PLACEHOLDER = void 0; exports.maskString = maskString; exports.maskUrl = maskUrl; exports.sanitizeValue = sanitizeValue; exports.crowdinLogSanitizer = crowdinLogSanitizer; const SENSITIVE_KEY_REGEX = /^(jwt|jwt[_-]?token|token|access[_-]?token|refresh[_-]?token|api[_-]?key|secret|client[_-]?secret|password|passwd|pwd|authorization|cookie|set-cookie|x-api-key)$/i; const JWT_REGEX = /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g; const BEARER_REGEX = /Bearer\s+[A-Za-z0-9._\-+/=]+/gi; const URL_USERINFO_REGEX = /(\b[a-z][a-z0-9+.-]*:\/\/)([^@\s/]+):([^@\s]+)@/gi; // Matches util.inspect-style "<sensitiveKey>: '<value>'" with single/double/backtick quotes, // honoring backslash escapes inside the value (e.g. \' or \" or \\). const INSPECTED_FIELD_REGEX = /\b(jwt(?:[_-]?token)?|token|access[_-]?token|refresh[_-]?token|api[_-]?key|secret|client[_-]?secret|password|passwd|pwd|authorization|cookie|set-cookie|x-api-key)\s*:\s*(['"`])((?:\\\2|(?!\2).)*)\2/gi; // Sanitizer runs on every log call; skip the regex passes when no candidate token is present. const SANITIZE_HINT_REGEX = /eyJ|bearer|:\/\/|password|passwd|pwd|cookie|token|api[_-]?key|secret|authorization|jwt/i; const MAX_DEPTH = 10; exports.MASK_PLACEHOLDER = '***'; function maskPrefix4(value) { return value.length <= 4 ? exports.MASK_PLACEHOLDER : value.slice(0, 4) + exports.MASK_PLACEHOLDER; } function maskInspectedFields(input) { return input.replace(INSPECTED_FIELD_REGEX, (_, key, quote, value) => { const unescaped = value.replace(/\\(.)/g, '$1'); return `${key}: ${quote}${maskPrefix4(unescaped)}${quote}`; }); } function maskString(input) { if (typeof input !== 'string' || input.length === 0) { return input; } if (!SANITIZE_HINT_REGEX.test(input)) { return input; } return maskInspectedFields(input) .replace(JWT_REGEX, maskPrefix4) .replace(BEARER_REGEX, `Bearer ${exports.MASK_PLACEHOLDER}`) .replace(URL_USERINFO_REGEX, (_, scheme, user) => `${scheme}${user}:${exports.MASK_PLACEHOLDER}@`); } function maskValueAsKey(value) { return typeof value === 'string' ? maskPrefix4(value) : exports.MASK_PLACEHOLDER; } function maskUrl(url) { if (typeof url !== 'string') { return url; } const queryIndex = url.indexOf('?'); if (queryIndex === -1) { return maskString(url); } const head = url.slice(0, queryIndex); const tail = url.slice(queryIndex + 1); const [queryPart, ...fragmentParts] = tail.split('#'); const fragment = fragmentParts.length ? '#' + fragmentParts.join('#') : ''; const maskedQuery = queryPart .split('&') .map((pair) => { const eq = pair.indexOf('='); if (eq === -1) { return pair; } const key = pair.slice(0, eq); const value = pair.slice(eq + 1); const decodedKey = safeDecode(key); if (SENSITIVE_KEY_REGEX.test(decodedKey)) { return `${key}=${maskValueAsKey(safeDecode(value))}`; } return `${key}=${maskString(value)}`; }) .join('&'); return `${maskString(head)}?${maskedQuery}${fragment}`; } function safeDecode(value) { try { return decodeURIComponent(value); } catch (_a) { return value; } } function sanitizeValue(value) { return sanitizeValueImpl(value, 0, new WeakSet()); } const HANDLED_ERROR_KEYS = new Set(['name', 'message', 'stack']); function sanitizeValueImpl(value, depth, seen) { if (value === null || value === undefined) { return value; } if (depth > MAX_DEPTH) { return '[Truncated: max depth]'; } if (typeof value === 'string') { return maskString(value); } if (typeof value !== 'object') { return value; } if (seen.has(value)) { return '[Circular]'; } seen.add(value); if (Array.isArray(value)) { return value.map((item) => sanitizeValueImpl(item, depth + 1, seen)); } if (value instanceof Error) { const out = { name: value.name, message: maskString(value.message), }; if (value.stack) { out.stack = maskString(value.stack); } for (const key of Object.keys(value)) { if (HANDLED_ERROR_KEYS.has(key)) { continue; } out[key] = sanitizeField(key, value[key], depth, seen); } return out; } const out = {}; for (const [key, v] of Object.entries(value)) { out[key] = sanitizeField(key, v, depth, seen); } return out; } function sanitizeField(key, value, depth, seen) { if (SENSITIVE_KEY_REGEX.test(key)) { return maskValueAsKey(value); } if ((key === 'url' || key === 'originalUrl' || key === 'href') && typeof value === 'string') { return maskUrl(value); } return sanitizeValueImpl(value, depth + 1, seen); } // Returns a new LogRecord — never mutates the input. Falls back to the original record // on any throw so a sanitizer bug can't bring down the logging pipeline. (logs-formatter // also catches throws at the pipeline level; this inner guard is defense in depth.) // Typed with concrete LogRecord return (not Sanitizer's broader `LogRecord | void`) so // callers and tests get a non-nullable result; still assignable to Sanitizer. function crowdinLogSanitizer(record) { try { return { level: record.level, message: typeof record.message === 'string' ? maskString(record.message) : record.message, rawParams: Array.isArray(record.rawParams) ? record.rawParams.map((p) => sanitizeValue(p)) : record.rawParams, context: sanitizeValue(record.context), record: record.record && typeof record.record === 'object' ? sanitizeValue(record.record) : record.record, }; } catch (_a) { return record; } }