@beignet/core
Version:
Core framework primitives for Beignet
343 lines (313 loc) • 8.93 kB
text/typescript
/**
* 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);
}