wulf-pulse/lib/services/analyzer/itglue-redact.ts

65 lines
2.3 KiB
TypeScript
Raw Normal View History

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