@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
JavaScript
;
/**
* 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;
}
}