Skip to content

Commit

Permalink
add Redactable to Inspectable module (#3777)
Browse files Browse the repository at this point in the history
Co-authored-by: Tim <[email protected]>
  • Loading branch information
patroza and tim-smart authored Oct 21, 2024
1 parent c2575c4 commit 8e898ac
Show file tree
Hide file tree
Showing 8 changed files with 198 additions and 27 deletions.
6 changes: 6 additions & 0 deletions .changeset/unlucky-apples-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@effect/platform": minor
"effect": minor
---

feat: implement Redactable. Used by Headers to not log sensitive information
58 changes: 56 additions & 2 deletions packages/effect/src/Inspectable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* @since 2.0.0
*/

import type * as FiberRefs from "./FiberRefs.js"
import { globalValue } from "./GlobalValue.js"
import { hasProperty, isFunction } from "./Predicate.js"

/**
Expand Down Expand Up @@ -38,7 +40,7 @@ export const toJSON = (x: unknown): unknown => {
} else if (Array.isArray(x)) {
return x.map(toJSON)
}
return x
return redact(x)
}

/**
Expand Down Expand Up @@ -108,10 +110,62 @@ export const stringifyCircular = (obj: unknown, whitespace?: number | string | u
typeof value === "object" && value !== null
? cache.includes(value)
? undefined // circular reference
: cache.push(value) && value
: cache.push(value) && (redactableState.fiberRefs !== undefined && isRedactable(value)
? value[symbolRedactable](redactableState.fiberRefs)
: value)
: value,
whitespace
)
;(cache as any) = undefined
return retVal
}

/**
* @since 3.10.0
* @category redactable
*/
export interface Redactable {
readonly [symbolRedactable]: (fiberRefs: FiberRefs.FiberRefs) => unknown
}

/**
* @since 3.10.0
* @category redactable
*/
export const symbolRedactable: unique symbol = Symbol.for("effect/Inspectable/Redactable")

/**
* @since 3.10.0
* @category redactable
*/
export const isRedactable = (u: unknown): u is Redactable =>
typeof u === "object" && u !== null && symbolRedactable in u

const redactableState = globalValue("effect/Inspectable/redactableState", () => ({
fiberRefs: undefined as FiberRefs.FiberRefs | undefined
}))

/**
* @since 3.10.0
* @category redactable
*/
export const withRedactableContext = <A>(context: FiberRefs.FiberRefs, f: () => A): A => {
const prev = redactableState.fiberRefs
redactableState.fiberRefs = context
try {
return f()
} finally {
redactableState.fiberRefs = prev
}
}

/**
* @since 3.10.0
* @category redactable
*/
export const redact = (u: unknown): unknown => {
if (isRedactable(u) && redactableState.fiberRefs !== undefined) {
return u[symbolRedactable](redactableState.fiberRefs)
}
return u
}
6 changes: 3 additions & 3 deletions packages/effect/src/internal/cause.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { constFalse, constTrue, dual, identity, pipe } from "../Function.js"
import { globalValue } from "../GlobalValue.js"
import * as Hash from "../Hash.js"
import * as HashSet from "../HashSet.js"
import { NodeInspectSymbol, toJSON } from "../Inspectable.js"
import { NodeInspectSymbol, stringifyCircular, toJSON } from "../Inspectable.js"
import * as Option from "../Option.js"
import { pipeArguments } from "../Pipeable.js"
import type { Predicate, Refinement } from "../Predicate.js"
Expand Down Expand Up @@ -1042,7 +1042,7 @@ class PrettyError extends globalThis.Error implements Cause.PrettyError {
* 1) If the input `u` is already a string, it's considered a message.
* 2) If `u` is an Error instance with a message defined, it uses the message.
* 3) If `u` has a user-defined `toString()` method, it uses that method.
* 4) Otherwise, it uses `JSON.stringify` to produce a string representation and uses it as the error message,
* 4) Otherwise, it uses `Inspectable.stringifyCircular` to produce a string representation and uses it as the error message,
* with "Error" added as a prefix.
*
* @internal
Expand Down Expand Up @@ -1070,7 +1070,7 @@ export const prettyErrorMessage = (u: unknown): string => {
// something's off, rollback to json
}
// 4)
return JSON.stringify(u)
return stringifyCircular(u)
}

const locationRegex = /\((.*)\)/
Expand Down
26 changes: 14 additions & 12 deletions packages/effect/src/internal/fiberRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -827,18 +827,20 @@ export class FiberRuntime<in out A, in out E = never> extends Effectable.Class<A
if (HashSet.size(loggers) > 0) {
const clockService = Context.get(this.getFiberRef(defaultServices.currentServices), clock.clockTag)
const date = new Date(clockService.unsafeCurrentTimeMillis())
for (const logger of loggers) {
logger.log({
fiberId: this.id(),
logLevel,
message,
cause,
context: contextMap,
spans,
annotations,
date
})
}
Inspectable.withRedactableContext(contextMap, () => {
for (const logger of loggers) {
logger.log({
fiberId: this.id(),
logLevel,
message,
cause,
context: contextMap,
spans,
annotations,
date
})
}
})
}
}

Expand Down
13 changes: 7 additions & 6 deletions packages/effect/src/internal/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ export const structuredMessage = (u: unknown): unknown => {
return String(u)
}
default: {
return u
return Inspectable.toJSON(u)
}
}
}
Expand Down Expand Up @@ -488,13 +488,13 @@ const prettyLoggerTty = (options: {

if (messageIndex < message.length) {
for (; messageIndex < message.length; messageIndex++) {
log(message[messageIndex])
log(Inspectable.redact(message[messageIndex]))
}
}

if (HashMap.size(annotations) > 0) {
for (const [key, value] of annotations) {
log(color(`${key}:`, colors.bold, colors.white), value)
log(color(`${key}:`, colors.bold, colors.white), Inspectable.redact(value))
}
}

Expand Down Expand Up @@ -553,16 +553,17 @@ const prettyLoggerBrowser = (options: {

if (messageIndex < message.length) {
for (; messageIndex < message.length; messageIndex++) {
console.log(message[messageIndex])
console.log(Inspectable.redact(message[messageIndex]))
}
}

if (HashMap.size(annotations) > 0) {
for (const [key, value] of annotations) {
const redacted = Inspectable.redact(value)
if (options.colors) {
console.log(`%c${key}:`, "color:gray", value)
console.log(`%c${key}:`, "color:gray", redacted)
} else {
console.log(`${key}:`, value)
console.log(`${key}:`, redacted)
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions packages/effect/src/internal/redacted.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { NodeInspectSymbol } from "effect/Inspectable"
import * as Equal from "../Equal.js"
import { pipe } from "../Function.js"
import { globalValue } from "../GlobalValue.js"
Expand Down Expand Up @@ -34,6 +35,9 @@ export const proto = {
toJSON() {
return "<redacted>"
},
[NodeInspectSymbol]() {
return "<redacted>"
},
[Hash.symbol]<T>(this: Redacted.Redacted<T>): number {
return pipe(
Hash.hash(RedactedSymbolKey),
Expand Down
14 changes: 11 additions & 3 deletions packages/platform/src/Headers.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/**
* @since 1.0.0
*/
import { FiberRefs } from "effect"
import * as FiberRef from "effect/FiberRef"
import { dual, identity } from "effect/Function"
import { globalValue } from "effect/GlobalValue"
import { type Redactable, symbolRedactable } from "effect/Inspectable"
import type * as Option from "effect/Option"
import * as Predicate from "effect/Predicate"
import * as Record from "effect/Record"
Expand Down Expand Up @@ -34,13 +36,19 @@ export const isHeaders = (u: unknown): u is Headers => Predicate.hasProperty(u,
* @since 1.0.0
* @category models
*/
export interface Headers {
export interface Headers extends Redactable {
readonly [HeadersTypeId]: HeadersTypeId
readonly [key: string]: string
}

const Proto = Object.assign(Object.create(null), {
[HeadersTypeId]: HeadersTypeId
[HeadersTypeId]: HeadersTypeId,
[symbolRedactable](
this: Headers,
fiberRefs: FiberRefs.FiberRefs
): Record<string, string | Redacted.Redacted<string>> {
return redact(this, FiberRefs.getOrDefault(fiberRefs, currentRedactedNames))
}
})

const make = (input: Record.ReadonlyRecord<string, string>): Mutable<Headers> =>
Expand Down Expand Up @@ -248,7 +256,7 @@ export const redact: {
* @since 1.0.0
* @category fiber refs
*/
export const currentRedactedNames = globalValue(
export const currentRedactedNames: FiberRef.FiberRef<ReadonlyArray<string | RegExp>> = globalValue(
"@effect/platform/Headers/currentRedactedNames",
() =>
FiberRef.unsafeMake<ReadonlyArray<string | RegExp>>([
Expand Down
98 changes: 97 additions & 1 deletion packages/platform/test/Headers.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,104 @@
import * as Headers from "@effect/platform/Headers"
import { assert, describe, it } from "@effect/vitest"
import { Effect, FiberId, FiberRef, FiberRefs, HashSet, Inspectable, Logger } from "effect"
import * as Redacted from "effect/Redacted"
import { assert, describe, it } from "vitest"

describe("Headers", () => {
describe("Redactable", () => {
it("one key", () => {
const headers = Headers.fromInput({
"Content-Type": "application/json",
"Authorization": "Bearer some-token",
"X-Api-Key": "some-key"
})

const fiberRefs = FiberRefs.unsafeMake(
new Map([
[
Headers.currentRedactedNames,
[[FiberId.none, ["Authorization"]] as const]
] as const
])
)
const r = Inspectable.withRedactableContext(fiberRefs, () => Inspectable.toStringUnknown(headers))
const redacted = JSON.parse(r)

assert.deepEqual(redacted, {
"content-type": "application/json",
"authorization": "<redacted>",
"x-api-key": "some-key"
})
})

it("one key nested", () => {
const headers = Headers.fromInput({
"Content-Type": "application/json",
"Authorization": "Bearer some-token",
"X-Api-Key": "some-key"
})

const fiberRefs = FiberRefs.unsafeMake(
new Map([
[
Headers.currentRedactedNames,
[[FiberId.none, ["Authorization"]] as const]
] as const
])
)
const r = Inspectable.withRedactableContext(fiberRefs, () => Inspectable.toStringUnknown({ headers }))
const redacted = JSON.parse(r) as { headers: unknown }

assert.deepEqual(redacted.headers, {
"content-type": "application/json",
"authorization": "<redacted>",
"x-api-key": "some-key"
})
})

it.effect("logs redacted", () =>
Effect.gen(function*() {
const messages: Array<string> = []
const logger = Logger.stringLogger.pipe(
Logger.map((msg) => {
messages.push(msg)
})
)
yield* FiberRef.update(FiberRef.currentLoggers, HashSet.add(logger))
const headers = Headers.fromInput({
"Content-Type": "application/json",
"Authorization": "Bearer some-token",
"X-Api-Key": "some-key"
})
yield* Effect.log(headers).pipe(
Effect.annotateLogs({ headers })
)
assert.include(messages[0], "application/json")
assert.notInclude(messages[0], "some-token")
assert.notInclude(messages[0], "some-key")
}))

it.effect("logs redacted structured", () =>
Effect.gen(function*() {
const messages: Array<any> = []
const logger = Logger.structuredLogger.pipe(
Logger.map((msg) => {
messages.push(msg)
})
)
yield* FiberRef.update(FiberRef.currentLoggers, HashSet.add(logger))
const headers = Headers.fromInput({
"Content-Type": "application/json",
"Authorization": "Bearer some-token",
"X-Api-Key": "some-key"
})
yield* Effect.log(headers).pipe(
Effect.annotateLogs({ headers })
)
assert.strictEqual(Redacted.isRedacted(messages[0].message.authorization), true)
assert.strictEqual(Redacted.isRedacted(messages[0].annotations.headers.authorization), true)
}))
})

describe("redact", () => {
it("one key", () => {
const headers = Headers.fromInput({
Expand Down

0 comments on commit 8e898ac

Please sign in to comment.