diff --git a/packages/runtime-node/src/redact.ts b/packages/runtime-node/src/redact.ts index 93431a7..bd93438 100644 --- a/packages/runtime-node/src/redact.ts +++ b/packages/runtime-node/src/redact.ts @@ -131,27 +131,189 @@ function redactString(value: string, r: CompiledRedactor): string { return out; } -function redactValue(value: unknown, r: CompiledRedactor, depth: number): unknown { - if (typeof value === "string") return redactString(value, r); - if (Array.isArray(value)) { - if (depth <= 0) return value; - return value.map((item) => redactValue(item, r, depth - 1)); - } - // Defensive: OTLP attributes are flat, but callers hand us arbitrary - // objects — walk one more level so nothing sensitive hides inside. - if (typeof value === "object" && value !== null && depth > 0) { - const out: Record = {}; - for (const [k, v] of Object.entries(value as Record)) { - out[k] = isSensitiveKey(k, r) ? r.mask : redactValue(v, r, depth - 1); - } - return out; - } - return value; +const MAX_REDACTION_DEPTH = 64; +const MAX_REDACTION_WORK = 10_000; +const MAX_COLLECTION_ENTRIES = 1_000; + +interface RedactionState { + ancestors: WeakMap; + remainingWork: number; +} + +function canTraverse(depth: number, state: RedactionState): boolean { + if (depth > MAX_REDACTION_DEPTH || state.remainingWork <= 0) return false; + state.remainingWork -= 1; + return true; +} + +function redactValue( + value: unknown, + r: CompiledRedactor, + state: RedactionState, + depth: number, +): unknown { + if (typeof value === "string") return redactString(value, r); + + let isArray = false; + try { + isArray = Array.isArray(value); + } catch { + return r.mask; + } + + if (isArray) { + return redactArray(value as unknown[], r, state, depth); + } + + if (typeof value === "object" && value !== null) { + return redactObject(value, r, state, depth); + } + + return value; +} + +function redactArray( + value: unknown[], + r: CompiledRedactor, + state: RedactionState, + depth: number, +): unknown { + const existing = state.ancestors.get(value); + if (existing) return r.mask; + + if (!canTraverse(depth, state)) return r.mask; + + const out: unknown[] = []; + state.ancestors.set(value, out); + + let length: number; + try { + length = value.length; + } catch { + state.ancestors.delete(value); + return r.mask; + } + + const limit = Math.min(length, MAX_COLLECTION_ENTRIES); + + for (let i = 0; i < limit; i += 1) { + let item: unknown; + + try { + item = value[i]; + } catch { + out.push(r.mask); + continue; + } + + out.push(redactValue(item, r, state, depth + 1)); + } + + if (length > limit) { + out.push(r.mask); + } + + state.ancestors.delete(value); + return out; +} + +function redactObject( + value: object, + r: CompiledRedactor, + state: RedactionState, + depth: number, +): unknown { + const existing = state.ancestors.get(value); + if (existing) return r.mask; + + if (!canTraverse(depth, state)) return r.mask; + + const out: Record = {}; + state.ancestors.set(value, out); + + let count = 0; + let truncated = false; + + try { + for (const key in value as Record) { + if ( + !Object.prototype.propertyIsEnumerable.call( + value, + key, + ) + ) { + continue; + } + + if (count >= MAX_COLLECTION_ENTRIES) { + truncated = true; + break; + } + + let nestedValue: unknown; + try { + nestedValue = (value as Record)[key]; + } catch { + out[key] = r.mask; + count += 1; + continue; + } + + count += 1; + out[key] = isSensitiveKey(key, nestedValue, r) + ? r.mask + : redactValue( + nestedValue, + r, + state, + depth + 1, + ); + } + } catch { + truncated = true; + } + + if (truncated) { + out.__redaction_truncated__ = r.mask; + } + + state.ancestors.delete(value); + return out; } +const USAGE_TOKEN_KEYS = new Set([ + "input_tokens", + "output_tokens", + "prompt_tokens", + "completion_tokens", + "total_tokens", + "token_count", + "gen_ai.usage.input_tokens", + "gen_ai.usage.output_tokens", + "gen_ai.usage.prompt_tokens", + "gen_ai.usage.completion_tokens", + "gen_ai.usage.total_tokens", + "gen_ai.usage.token_count", +]); + +function isSensitiveKey( + key: string, + value: unknown, + r: CompiledRedactor, +): boolean { + const lowered = key.toLowerCase(); + + // Canonical GenAI usage attributes are safe when they contain + // valid non-negative numeric counts. + if (USAGE_TOKEN_KEYS.has(lowered)) { + return !( + typeof value === "number" && + Number.isFinite(value) && + value >= 0 + ); + } -function isSensitiveKey(key: string, r: CompiledRedactor): boolean { - const lowered = key.toLowerCase(); - return r.keyPatterns.some((re) => re.test(lowered)); + // All other sensitive keys, including token-like keys, are redacted. + return r.keyPatterns.some((re) => re.test(lowered)); } /** @@ -164,23 +326,71 @@ export function redactAttributes( options?: RedactOptions, ): Attributes { const r = compile(options); - return redactWith(attributes, r, 4); + return redactWith(attributes, r); } function redactWith( - attributes: Attributes | null | undefined, - r: CompiledRedactor, - maxDepth: number, + attributes: Attributes | null | undefined, + r: CompiledRedactor, ): Attributes { - const out: Attributes = {}; - if (!attributes) return out; - for (const [key, value] of Object.entries(attributes)) { - if (value === undefined) continue; - out[key] = isSensitiveKey(key, r) - ? r.mask - : (redactValue(value, r, maxDepth) as Attributes[string]); - } - return out; + const out: Attributes = {}; + if (!attributes) return out; + + const state: RedactionState = { + ancestors: new WeakMap(), + remainingWork: MAX_REDACTION_WORK, + }; + + let count = 0; + let truncated = false; + + try { + for (const key in attributes as Record) { + if ( + !Object.prototype.propertyIsEnumerable.call( + attributes, + key, + ) + ) { + continue; + } + + if ( + count >= MAX_COLLECTION_ENTRIES || + state.remainingWork <= 0 + ) { + truncated = true; + break; + } + + let value: unknown; + try { + value = (attributes as Record)[key]; + } catch { + out[key] = r.mask; + count += 1; + state.remainingWork -= 1; + continue; + } + + count += 1; + state.remainingWork -= 1; + + if (value === undefined) continue; + + out[key] = isSensitiveKey(key, value, r) + ? r.mask + : (redactValue(value, r, state, 0) as Attributes[string]); + } + } catch { + truncated = true; + } + + if (truncated) { + out.__redaction_truncated__ = r.mask; + } + + return out; } /** @@ -194,5 +404,5 @@ export function makeRedactor( return (attributes) => ({ ...(attributes ?? {}) }); } const r = compile(options === true ? undefined : options); - return (attributes) => redactWith(attributes, r, 4); + return (attributes) => redactWith(attributes, r); } diff --git a/packages/runtime-node/test/redact.test.mjs b/packages/runtime-node/test/redact.test.mjs index 3cf0a1d..bad04da 100644 --- a/packages/runtime-node/test/redact.test.mjs +++ b/packages/runtime-node/test/redact.test.mjs @@ -124,3 +124,160 @@ test("empty/nullish input yields an empty object", () => { assert.deepEqual(redactAttributes(), {}); assert.deepEqual(redactAttributes(null), {}); }); + +test("redacts sensitive keys beyond the nested traversal depth", () => { + const out = redactAttributes({ + context: { + level1: { + level2: { + level3: { + level4: { + password: "SECRET", + }, + }, + }, + }, + }, + }); + + assert.equal( + out.context.level1.level2.level3.level4.password, + MASK, + ); +}); + +test("bounds extremely deep object traversal safely", () => { + let value = { password: "SECRET" }; + + for (let i = 0; i < 200; i += 1) { + value = { nested: value }; + } + + assert.doesNotThrow(() => redactAttributes({ context: value })); +}); +test("keeps only supported GenAI/usage token-count attributes", () => { + const out = redactAttributes({ + "gen_ai.usage.input_tokens": 512, + "gen_ai.usage.output_tokens": 128, + prompt_tokens: 512, + completion_tokens: 128, + total_tokens: 640, + token_count: 42, + max_tokens: 1000, + "secret.input_tokens": 999, + }); + + assert.deepEqual(out, { + "gen_ai.usage.input_tokens": 512, + "gen_ai.usage.output_tokens": 128, + prompt_tokens: 512, + completion_tokens: 128, + total_tokens: 640, + token_count: 42, + max_tokens: MASK, + "secret.input_tokens": MASK, + }); +}); + +test("masks invalid values for supported GenAI usage keys", () => { + const out = redactAttributes({ + "gen_ai.usage.input_tokens": "512", + "gen_ai.usage.output_tokens": -1, + token_count: Number.NaN, + }); + + assert.equal(out["gen_ai.usage.input_tokens"], MASK); + assert.equal(out["gen_ai.usage.output_tokens"], MASK); + assert.equal(out.token_count, MASK); +}); +test("still masks secret token keys ending in 'token'", () => { + const out = redactAttributes({ + token: "raw", + access_token: "raw", + refresh_token: "raw", + authToken: "raw", + token_value: "raw", + tokenString: "raw", + token_id: "raw", + id_token_hint: "raw", + }); + + for (const value of Object.values(out)) assert.equal(value, MASK); +}); +test("does not throw when a revoked array proxy is encountered", () => { + const target = []; + const { proxy, revoke } = Proxy.revocable(target, {}); + revoke(); + + assert.doesNotThrow(() => redactAttributes({ context: proxy })); +}); + +test("does not throw when a revoked root proxy is encountered", () => { + const target = {}; + const { proxy, revoke } = Proxy.revocable(target, {}); + revoke(); + + assert.doesNotThrow(() => redactAttributes(proxy)); +}); +test("does not throw when top-level attribute enumeration fails", () => { + const hostile = new Proxy( + {}, + { + ownKeys() { + throw new Error("ownKeys failed"); + }, + }, + ); + + assert.doesNotThrow(() => redactAttributes(hostile)); +}); +test("does not throw when an array element getter fails", () => { + const hostile = []; + Object.defineProperty(hostile, 0, { + enumerable: true, + get() { + throw new Error("array getter failed"); + }, + }); + + assert.doesNotThrow(() => redactAttributes({ context: hostile })); +}); +test("does not throw when an attribute getter fails", () => { + const hostile = {}; + Object.defineProperty(hostile, "secret", { + enumerable: true, + get() { + throw new Error("getter failed"); + }, + }); + + assert.doesNotThrow(() => redactAttributes({ context: hostile })); +}); +test("bounds top-level attributes safely", () => { + const attributes = {}; + + for (let i = 0; i < 1005; i += 1) { + attributes["key_" + i] = "value"; + } + + const out = redactAttributes(attributes); + + assert.ok(Object.keys(out).length <= 1001); + assert.equal(out.__redaction_truncated__, MASK); + assert.equal(out.key_0, "value"); + assert.equal(out.key_999, "value"); + assert.equal(out.key_1000, undefined); +}); +test("handles circular references without leaking sensitive values", () => { + const context = {}; + const nested = { password: "SECRET", safe: "ok" }; + + context.self = context; + context.nested = nested; + + const out = redactAttributes({ context }); + + assert.equal(out.context.nested.password, MASK); + assert.equal(out.context.nested.safe, "ok"); + assert.equal(out.context.self, MASK); +});