65 lines
2.3 KiB
TypeScript
65 lines
2.3 KiB
TypeScript
|
|
/**
|
||
|
|
* IT Glue redaction.
|
||
|
|
*
|
||
|
|
* SECURITY-CRITICAL. This module recursively walks an arbitrary value
|
||
|
|
* (object | array | scalar) and replaces any value whose KEY name looks like
|
||
|
|
* a credential with the string "[REDACTED]". The original input is not
|
||
|
|
* mutated — a deep-cloned copy is returned.
|
||
|
|
*
|
||
|
|
* Why it exists: IT Glue documents and configurations frequently embed plain-
|
||
|
|
* text passwords, API keys, and tokens. None of those values may EVER reach
|
||
|
|
* the LLM context, log lines, or any database row — only doc IDs and names.
|
||
|
|
* See docs/wulf-pulse-ticket-analyzer-prompt.md → "Critical correctness notes".
|
||
|
|
*
|
||
|
|
* The match pattern is intentionally broad. False positives (a benign field
|
||
|
|
* happens to be named "key") are acceptable; false negatives are not.
|
||
|
|
*/
|
||
|
|
|
||
|
|
const SENSITIVE_KEY_PATTERN = /password|secret|key|token|credential|api[_-]?key/i;
|
||
|
|
|
||
|
|
export const REDACTED_VALUE = '[REDACTED]';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Recursively redact values whose keys match the sensitive-key pattern.
|
||
|
|
* Returns a new value; does not mutate the input.
|
||
|
|
*/
|
||
|
|
export function redact<T>(value: T): T {
|
||
|
|
return redactInternal(value, new WeakSet()) as T;
|
||
|
|
}
|
||
|
|
|
||
|
|
function redactInternal(value: unknown, seen: WeakSet<object>): unknown {
|
||
|
|
if (value === null || value === undefined) return value;
|
||
|
|
|
||
|
|
// Primitives — return as-is.
|
||
|
|
if (typeof value !== 'object') return value;
|
||
|
|
|
||
|
|
// Cycle guard — if we've already seen this object on this branch, return a
|
||
|
|
// marker rather than recursing infinitely. (IT Glue payloads shouldn't have
|
||
|
|
// cycles in practice, but defensive against malformed input.)
|
||
|
|
if (seen.has(value as object)) return '[CIRCULAR]';
|
||
|
|
seen.add(value as object);
|
||
|
|
|
||
|
|
if (Array.isArray(value)) {
|
||
|
|
return value.map((item) => redactInternal(item, seen));
|
||
|
|
}
|
||
|
|
|
||
|
|
const out: Record<string, unknown> = {};
|
||
|
|
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
||
|
|
if (isSensitiveKey(key)) {
|
||
|
|
// Even if the value is an object/array, replace the whole subtree —
|
||
|
|
// a credentials block named "auth" should not leak its leaves.
|
||
|
|
out[key] = nested === null || nested === undefined ? nested : REDACTED_VALUE;
|
||
|
|
} else {
|
||
|
|
out[key] = redactInternal(nested, seen);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Exported for tests. True if the key name looks like a credential field.
|
||
|
|
*/
|
||
|
|
export function isSensitiveKey(key: string): boolean {
|
||
|
|
return SENSITIVE_KEY_PATTERN.test(key);
|
||
|
|
}
|