UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

343 lines (313 loc) 8.93 kB
/** * Default replacement used when a sensitive field is redacted. */ export const DEFAULT_REDACTED_VALUE = "[redacted]"; /** * Default replacement used when recursive redaction exceeds `maxDepth`. */ export const DEFAULT_TRUNCATED_VALUE = "[truncated]"; /** * Default replacement used when recursive redaction finds a circular object. */ export const DEFAULT_CIRCULAR_VALUE = "[circular]"; /** * Exact header/object keys redacted by default. */ export const DEFAULT_SENSITIVE_KEYS = [ "authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key", "api-key", "apikey", "access-token", "refresh-token", "credentials", "accesskey", "jwt", "session", ] as const; /** * Key substrings redacted by default. * * Matching is case-insensitive. */ export const DEFAULT_SENSITIVE_KEY_TERMS = [ "token", "password", "secret", "credential", "accesskey", "jwt", "session", "private-key", "privatekey", ] as const; /** * Context passed to custom redaction key decisions. */ export interface RedactionDecisionContext { /** * Current object/header key being evaluated. */ key: string; /** * Path to the current value from the root object. */ path: readonly string[]; /** * Current value being evaluated. */ value: unknown; } /** * Options that control recursive value and header redaction. */ export interface RedactionOptions { /** * Value used when a key is considered sensitive. */ replacement?: string; /** * Value used when recursion exceeds `maxDepth`. */ truncatedValue?: string; /** * Value used for circular references. */ circularValue?: string; /** * Maximum object/array depth to traverse before truncating. */ maxDepth?: number; /** * Additional exact keys to redact. */ sensitiveKeys?: readonly string[]; /** * Additional case-insensitive key substrings to redact. */ sensitiveKeyTerms?: readonly string[]; /** * Custom key-level redaction rule. */ shouldRedactKey?: (context: RedactionDecisionContext) => boolean; } /** * Function that returns a redacted copy of a value. */ export type Redactor<T = unknown> = (value: T) => T; /** * Header input shapes accepted by `redactHeaders(...)`. */ export type RedactableHeaders = | Headers | Iterable<readonly [string, unknown]> | Record<string, unknown>; function normalizeKey(key: string): string { return key.toLowerCase(); } function redactSensitiveText(value: string, replacement: string): string { return value .replace( /([a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/gi, (_match, prefix: string) => `${prefix}${replacement}:${replacement}@`, ) .replace(/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, (match) => { const scheme = match.slice(0, match.indexOf(" ")); return `${scheme} ${replacement}`; }) .replace( /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, () => replacement, ) .replace( /(\b(?:access[_-]?key|api[_-]?key|jwt|password|secret|session|token)\s*[=:]\s*)[^\s,;]+/gi, (_match, prefix: string) => `${prefix}${replacement}`, ) .replace( /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, () => replacement, ); } /** * Return whether a key should be redacted. * * Checks default exact keys, default key terms, user-provided exact keys, * user-provided key terms, and finally `shouldRedactKey`. * * @param key - Object or header key to evaluate. * @param options - Optional redaction behavior. * @param context - Optional path/value context for custom decisions. * @returns `true` when the key should be replaced. */ export function isSensitiveKey( key: string, options: RedactionOptions = {}, context?: Omit<RedactionDecisionContext, "key">, ): boolean { const normalized = normalizeKey(key); const exactKeys = new Set( [...DEFAULT_SENSITIVE_KEYS, ...(options.sensitiveKeys ?? [])].map( normalizeKey, ), ); if (exactKeys.has(normalized)) return true; const terms = [ ...DEFAULT_SENSITIVE_KEY_TERMS, ...(options.sensitiveKeyTerms ?? []), ].map(normalizeKey); if (terms.some((term) => normalized.includes(term))) return true; return ( options.shouldRedactKey?.({ key, path: context?.path ?? [], value: context?.value, }) ?? false ); } function redactUnknown( value: unknown, options: RedactionOptions, path: readonly string[], seen: WeakSet<object>, ): unknown { const maxDepth = options.maxDepth ?? 6; if (path.length > maxDepth) { return options.truncatedValue ?? DEFAULT_TRUNCATED_VALUE; } if (value === null || value === undefined) return value; const valueType = typeof value; if (valueType === "number" || valueType === "boolean") { return value; } if (valueType === "string") { return redactSensitiveText( value as string, options.replacement ?? DEFAULT_REDACTED_VALUE, ); } if (valueType === "bigint") { return value.toString(); } if (value instanceof Date) { return value; } if (value instanceof Error) { return { name: value.name, message: redactUnknown( value.message, options, [...path, "message"], seen, ), stack: redactUnknown(value.stack, options, [...path, "stack"], seen), }; } if (valueType !== "object") { return String(value); } const objectValue = value as object; if (seen.has(objectValue)) { return options.circularValue ?? DEFAULT_CIRCULAR_VALUE; } seen.add(objectValue); if (Array.isArray(value)) { const output = value.map((item, index) => redactUnknown(item, options, [...path, String(index)], seen), ); seen.delete(objectValue); return output; } const output: Record<string, unknown> = {}; for (const [key, nestedValue] of Object.entries(value)) { output[key] = isSensitiveKey(key, options, { path: [...path, key], value: nestedValue, }) ? (options.replacement ?? DEFAULT_REDACTED_VALUE) : redactUnknown(nestedValue, options, [...path, key], seen); } seen.delete(objectValue); return output; } /** * Recursively redact a value using Beignet's default sensitive-key rules plus * any custom rules in `options`. * * This returns a copy for objects and arrays. Numbers and booleans are returned * as is unless they are under a sensitive key. High-confidence credential * shapes inside strings are replaced, including authorization schemes, JWTs, * credential-bearing URLs, secret assignments, and private keys. Some runtime * shapes are normalized: * `bigint` becomes a string, `Error` becomes a plain object with `name`, * `message`, and `stack`, and class instances are copied from enumerable * entries. * * @param value - Value to redact. * @param options - Optional redaction behavior. * @returns A redacted value typed as the input type for caller convenience. */ export function redactValue<T = unknown>( value: T, options: RedactionOptions = {}, ): T { return redactUnknown(value, options, [], new WeakSet()) as T; } function headerEntries( headers: RedactableHeaders, ): Iterable<readonly [string, unknown]> { if (typeof Headers !== "undefined" && headers instanceof Headers) { const entries: Array<readonly [string, unknown]> = []; headers.forEach((value, key) => { entries.push([key, value]); }); return entries; } if ( typeof (headers as { [Symbol.iterator]?: unknown })[Symbol.iterator] === "function" ) { return headers as Iterable<readonly [string, unknown]>; } return Object.entries(headers); } /** * Redact headers into a plain object. * * Sensitive header names such as `authorization`, `cookie`, and token-like keys * are replaced. Non-sensitive values are passed through `redactValue(...)` so * nested object values are still sanitized. * * @param headers - Headers object, iterable entries, or plain object. * @param options - Optional redaction behavior. * @returns A plain object with redacted header values. */ export function redactHeaders( headers: RedactableHeaders, options: RedactionOptions = {}, ): Record<string, unknown> { const output: Record<string, unknown> = {}; for (const [key, value] of headerEntries(headers)) { output[key] = isSensitiveKey(key, options, { path: [key], value, }) ? (options.replacement ?? DEFAULT_REDACTED_VALUE) : redactValue(value, options); } return output; } /** * Create a reusable redactor function from options. * * @param options - Redaction behavior to apply on each call. * @returns A function that redacts values with the provided options. */ export function createRedactor<T = unknown>( options: RedactionOptions = {}, ): Redactor<T> { return (value) => redactValue(value, options); }