From 93f6c84cdd4583e466bb914831f1e7eafc2bad98 Mon Sep 17 00:00:00 2001 From: rajeshaipython-stack Date: Sun, 30 Aug 2026 23:45:57 +0530 Subject: [PATCH 1/5] fix(runtime): redact deeply nested attributes --- packages/runtime-node/src/redact.ts | 65 +++++++++++++++------- packages/runtime-node/test/redact.test.mjs | 35 ++++++++++++ 2 files changed, 79 insertions(+), 21 deletions(-) diff --git a/packages/runtime-node/src/redact.ts b/packages/runtime-node/src/redact.ts index 93431a7..3a5ede5 100644 --- a/packages/runtime-node/src/redact.ts +++ b/packages/runtime-node/src/redact.ts @@ -131,22 +131,46 @@ 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; +function redactValue( + value: unknown, + r: CompiledRedactor, + ancestors: WeakMap, +): unknown { + if (typeof value === "string") return redactString(value, r); + + if (Array.isArray(value)) { + const existing = ancestors.get(value); + if (existing) return existing; + + const out: unknown[] = []; + ancestors.set(value, out); + + for (const item of value) { + out.push(redactValue(item, r, ancestors)); + } + + ancestors.delete(value); + return out; + } + + if (typeof value === "object" && value !== null) { + const existing = ancestors.get(value); + if (existing) return existing; + + const out: Record = {}; + ancestors.set(value, out); + + for (const [k, v] of Object.entries(value as Record)) { + out[k] = isSensitiveKey(k, r) + ? r.mask + : redactValue(v, r, ancestors); + } + + ancestors.delete(value); + return out; + } + + return value; } function isSensitiveKey(key: string, r: CompiledRedactor): boolean { @@ -164,13 +188,12 @@ 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, + r: CompiledRedactor, ): Attributes { const out: Attributes = {}; if (!attributes) return out; @@ -178,7 +201,7 @@ function redactWith( if (value === undefined) continue; out[key] = isSensitiveKey(key, r) ? r.mask - : (redactValue(value, r, maxDepth) as Attributes[string]); + : (redactValue(value, r, new WeakMap()) as Attributes[string]); } return out; } @@ -194,5 +217,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..ece81b6 100644 --- a/packages/runtime-node/test/redact.test.mjs +++ b/packages/runtime-node/test/redact.test.mjs @@ -124,3 +124,38 @@ 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("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, out.context); +}); From 33f33d67078b80fef9bb4081d6143e5c60142243 Mon Sep 17 00:00:00 2001 From: rajeshaipython-stack Date: Mon, 31 Aug 2026 07:41:04 +0530 Subject: [PATCH 2/5] fix(runtime): bound deep redaction traversal --- packages/runtime-node/src/redact.ts | 83 ++++++++++++++++------ packages/runtime-node/test/redact.test.mjs | 9 +++ 2 files changed, 71 insertions(+), 21 deletions(-) diff --git a/packages/runtime-node/src/redact.ts b/packages/runtime-node/src/redact.ts index 3a5ede5..c6a23c2 100644 --- a/packages/runtime-node/src/redact.ts +++ b/packages/runtime-node/src/redact.ts @@ -131,42 +131,77 @@ function redactString(value: string, r: CompiledRedactor): string { return out; } +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, - ancestors: WeakMap, + state: RedactionState, + depth: number, ): unknown { if (typeof value === "string") return redactString(value, r); if (Array.isArray(value)) { - const existing = ancestors.get(value); + const existing = state.ancestors.get(value); if (existing) return existing; + if (!canTraverse(depth, state)) return r.mask; + const out: unknown[] = []; - ancestors.set(value, out); + state.ancestors.set(value, out); + + const limit = Math.min(value.length, MAX_COLLECTION_ENTRIES); + for (let i = 0; i < limit; i += 1) { + out.push(redactValue(value[i], r, state, depth + 1)); + } - for (const item of value) { - out.push(redactValue(item, r, ancestors)); + if (value.length > limit) { + out.push(r.mask); } - ancestors.delete(value); + state.ancestors.delete(value); return out; } if (typeof value === "object" && value !== null) { - const existing = ancestors.get(value); + const existing = state.ancestors.get(value); if (existing) return existing; + if (!canTraverse(depth, state)) return r.mask; + const out: Record = {}; - ancestors.set(value, out); + state.ancestors.set(value, out); + + let count = 0; + for (const [k, v] of Object.entries( + value as Record, + )) { + if (count >= MAX_COLLECTION_ENTRIES) break; + count += 1; - for (const [k, v] of Object.entries(value as Record)) { out[k] = isSensitiveKey(k, r) ? r.mask - : redactValue(v, r, ancestors); + : redactValue(v, r, state, depth + 1); + } + + if (Object.keys(value).length > MAX_COLLECTION_ENTRIES) { + out.__redaction_truncated__ = r.mask; } - ancestors.delete(value); + state.ancestors.delete(value); return out; } @@ -192,18 +227,24 @@ export function redactAttributes( } function redactWith( - attributes: Attributes | null | undefined, + 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, new WeakMap()) as Attributes[string]); - } - return out; + const out: Attributes = {}; + if (!attributes) return out; + + const state: RedactionState = { + ancestors: new WeakMap(), + remainingWork: MAX_REDACTION_WORK, + }; + + for (const [key, value] of Object.entries(attributes)) { + if (value === undefined) continue; + out[key] = isSensitiveKey(key, r) + ? r.mask + : (redactValue(value, r, state, 0) as Attributes[string]); + } + return out; } /** diff --git a/packages/runtime-node/test/redact.test.mjs b/packages/runtime-node/test/redact.test.mjs index ece81b6..a92ccf0 100644 --- a/packages/runtime-node/test/redact.test.mjs +++ b/packages/runtime-node/test/redact.test.mjs @@ -146,6 +146,15 @@ test("redacts sensitive keys beyond the nested traversal depth", () => { ); }); +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("handles circular references without leaking sensitive values", () => { const context = {}; const nested = { password: "SECRET", safe: "ok" }; From c83a39a01787604075b3f74621312f7be90b783b Mon Sep 17 00:00:00 2001 From: rajeshaipython-stack Date: Mon, 31 Aug 2026 09:39:01 +0530 Subject: [PATCH 3/5] fix(runtime): harden deep redaction traversal --- packages/runtime-node/src/redact.ts | 89 ++++++++++++++++++---- packages/runtime-node/test/redact.test.mjs | 60 +++++++++++++++ 2 files changed, 135 insertions(+), 14 deletions(-) diff --git a/packages/runtime-node/src/redact.ts b/packages/runtime-node/src/redact.ts index c6a23c2..1da117b 100644 --- a/packages/runtime-node/src/redact.ts +++ b/packages/runtime-node/src/redact.ts @@ -186,18 +186,48 @@ function redactValue( state.ancestors.set(value, out); let count = 0; - for (const [k, v] of Object.entries( - value as Record, - )) { - if (count >= MAX_COLLECTION_ENTRIES) break; - count += 1; - - out[k] = isSensitiveKey(k, r) - ? r.mask - : redactValue(v, r, state, depth + 1); + 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 (Object.keys(value).length > MAX_COLLECTION_ENTRIES) { + if (truncated) { out.__redaction_truncated__ = r.mask; } @@ -208,9 +238,40 @@ function redactValue( return value; } -function isSensitiveKey(key: string, r: CompiledRedactor): boolean { - const lowered = key.toLowerCase(); - return r.keyPatterns.some((re) => re.test(lowered)); +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 + ); + } + + // All other sensitive keys, including token-like keys, are redacted. + return r.keyPatterns.some((re) => re.test(lowered)); } /** @@ -240,7 +301,7 @@ function redactWith( for (const [key, value] of Object.entries(attributes)) { if (value === undefined) continue; - out[key] = isSensitiveKey(key, r) + out[key] = isSensitiveKey(key, value, r) ? r.mask : (redactValue(value, r, state, 0) as Attributes[string]); } diff --git a/packages/runtime-node/test/redact.test.mjs b/packages/runtime-node/test/redact.test.mjs index a92ccf0..3d289f9 100644 --- a/packages/runtime-node/test/redact.test.mjs +++ b/packages/runtime-node/test/redact.test.mjs @@ -155,6 +155,66 @@ test("bounds extremely deep object traversal safely", () => { 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 an attribute getter fails", () => { + const hostile = {}; + Object.defineProperty(hostile, "secret", { + enumerable: true, + get() { + throw new Error("getter failed"); + }, + }); + + assert.doesNotThrow(() => redactAttributes({ context: hostile })); +}); test("handles circular references without leaking sensitive values", () => { const context = {}; const nested = { password: "SECRET", safe: "ok" }; From b29d899a4a8dbed46d59171a09774918980515a3 Mon Sep 17 00:00:00 2001 From: rajeshaipython-stack Date: Mon, 31 Aug 2026 11:08:10 +0530 Subject: [PATCH 4/5] fix(runtime): harden redaction traversal --- packages/runtime-node/src/redact.ts | 201 ++++++++++++++------- packages/runtime-node/test/redact.test.mjs | 38 ++++ 2 files changed, 171 insertions(+), 68 deletions(-) diff --git a/packages/runtime-node/src/redact.ts b/packages/runtime-node/src/redact.ts index 1da117b..41067f3 100644 --- a/packages/runtime-node/src/redact.ts +++ b/packages/runtime-node/src/redact.ts @@ -154,90 +154,132 @@ function redactValue( ): unknown { if (typeof value === "string") return redactString(value, r); - if (Array.isArray(value)) { - const existing = state.ancestors.get(value); - if (existing) return existing; + let isArray = false; + try { + isArray = Array.isArray(value); + } catch { + return r.mask; + } - if (!canTraverse(depth, state)) return r.mask; + if (isArray) { + return redactArray(value as unknown[], r, state, depth); + } - const out: unknown[] = []; - state.ancestors.set(value, out); + if (typeof value === "object" && value !== null) { + return redactObject(value, r, state, depth); + } - const limit = Math.min(value.length, MAX_COLLECTION_ENTRIES); - for (let i = 0; i < limit; i += 1) { - out.push(redactValue(value[i], r, state, depth + 1)); - } + return value; +} - if (value.length > limit) { - out.push(r.mask); - } +function redactArray( + value: unknown[], + r: CompiledRedactor, + state: RedactionState, + depth: number, +): unknown { + const existing = state.ancestors.get(value); + if (existing) return existing; + + 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 out; + return r.mask; } - if (typeof value === "object" && value !== null) { - const existing = state.ancestors.get(value); - if (existing) return existing; + const limit = Math.min(length, MAX_COLLECTION_ENTRIES); + + for (let i = 0; i < limit; i += 1) { + let item: unknown; - if (!canTraverse(depth, state)) return r.mask; + try { + item = value[i]; + } catch { + out.push(r.mask); + continue; + } - const out: Record = {}; - state.ancestors.set(value, out); + out.push(redactValue(item, r, state, depth + 1)); + } - let count = 0; - let truncated = false; + if (length > limit) { + out.push(r.mask); + } - 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; - } + 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 existing; + + 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; - out[key] = isSensitiveKey(key, nestedValue, r) - ? r.mask - : redactValue( - nestedValue, - r, - state, - depth + 1, - ); + continue; } - } catch { - truncated = true; - } - if (truncated) { - out.__redaction_truncated__ = r.mask; + count += 1; + out[key] = isSensitiveKey(key, nestedValue, r) + ? r.mask + : redactValue( + nestedValue, + r, + state, + depth + 1, + ); } + } catch { + truncated = true; + } - state.ancestors.delete(value); - return out; + if (truncated) { + out.__redaction_truncated__ = r.mask; } - return value; + state.ancestors.delete(value); + return out; } - const USAGE_TOKEN_KEYS = new Set([ "input_tokens", "output_tokens", @@ -299,12 +341,35 @@ function redactWith( remainingWork: MAX_REDACTION_WORK, }; - for (const [key, value] of Object.entries(attributes)) { - if (value === undefined) continue; - out[key] = isSensitiveKey(key, value, r) - ? r.mask - : (redactValue(value, r, state, 0) as Attributes[string]); + try { + for (const key in attributes as Record) { + if ( + !Object.prototype.propertyIsEnumerable.call( + attributes, + key, + ) + ) { + continue; + } + + let value: unknown; + try { + value = (attributes as Record)[key]; + } catch { + out[key] = r.mask; + continue; + } + + if (value === undefined) continue; + + out[key] = isSensitiveKey(key, value, r) + ? r.mask + : (redactValue(value, r, state, 0) as Attributes[string]); + } + } catch { + out.__redaction_truncated__ = r.mask; } + return out; } diff --git a/packages/runtime-node/test/redact.test.mjs b/packages/runtime-node/test/redact.test.mjs index 3d289f9..11382e2 100644 --- a/packages/runtime-node/test/redact.test.mjs +++ b/packages/runtime-node/test/redact.test.mjs @@ -204,6 +204,44 @@ test("still masks secret token keys ending in 'token'", () => { 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", { From c69f8bc476c0745890fb83302f14267bae44740f Mon Sep 17 00:00:00 2001 From: rajeshaipython-stack Date: Mon, 31 Aug 2026 14:46:10 +0530 Subject: [PATCH 5/5] fix(runtime): make deep redaction serialization-safe --- packages/runtime-node/src/redact.ts | 24 ++++++++++++++++++++-- packages/runtime-node/test/redact.test.mjs | 17 ++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/runtime-node/src/redact.ts b/packages/runtime-node/src/redact.ts index 41067f3..bd93438 100644 --- a/packages/runtime-node/src/redact.ts +++ b/packages/runtime-node/src/redact.ts @@ -179,7 +179,7 @@ function redactArray( depth: number, ): unknown { const existing = state.ancestors.get(value); - if (existing) return existing; + if (existing) return r.mask; if (!canTraverse(depth, state)) return r.mask; @@ -224,7 +224,7 @@ function redactObject( depth: number, ): unknown { const existing = state.ancestors.get(value); - if (existing) return existing; + if (existing) return r.mask; if (!canTraverse(depth, state)) return r.mask; @@ -341,6 +341,9 @@ function redactWith( remainingWork: MAX_REDACTION_WORK, }; + let count = 0; + let truncated = false; + try { for (const key in attributes as Record) { if ( @@ -352,14 +355,27 @@ function redactWith( 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) @@ -367,6 +383,10 @@ function redactWith( : (redactValue(value, r, state, 0) as Attributes[string]); } } catch { + truncated = true; + } + + if (truncated) { out.__redaction_truncated__ = r.mask; } diff --git a/packages/runtime-node/test/redact.test.mjs b/packages/runtime-node/test/redact.test.mjs index 11382e2..bad04da 100644 --- a/packages/runtime-node/test/redact.test.mjs +++ b/packages/runtime-node/test/redact.test.mjs @@ -253,6 +253,21 @@ test("does not throw when an attribute getter fails", () => { 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" }; @@ -264,5 +279,5 @@ test("handles circular references without leaking sensitive values", () => { assert.equal(out.context.nested.password, MASK); assert.equal(out.context.nested.safe, "ok"); - assert.equal(out.context.self, out.context); + assert.equal(out.context.self, MASK); });