diff --git a/.changeset/unlucky-apples-warn.md b/.changeset/unlucky-apples-warn.md new file mode 100644 index 0000000000..5ba81815b7 --- /dev/null +++ b/.changeset/unlucky-apples-warn.md @@ -0,0 +1,6 @@ +--- +"@effect/platform": minor +"effect": minor +--- + +feat: implement Redactable. Used by Headers to not log sensitive information diff --git a/packages/effect/src/Inspectable.ts b/packages/effect/src/Inspectable.ts index 9573277190..c187e8abe9 100644 --- a/packages/effect/src/Inspectable.ts +++ b/packages/effect/src/Inspectable.ts @@ -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" /** @@ -38,7 +40,7 @@ export const toJSON = (x: unknown): unknown => { } else if (Array.isArray(x)) { return x.map(toJSON) } - return x + return redact(x) } /** @@ -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 = (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 +} diff --git a/packages/effect/src/internal/cause.ts b/packages/effect/src/internal/cause.ts index bb705011d4..38bcbb4ddd 100644 --- a/packages/effect/src/internal/cause.ts +++ b/packages/effect/src/internal/cause.ts @@ -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" @@ -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 @@ -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 = /\((.*)\)/ diff --git a/packages/effect/src/internal/fiberRuntime.ts b/packages/effect/src/internal/fiberRuntime.ts index 428e9cdf08..984f78d09b 100644 --- a/packages/effect/src/internal/fiberRuntime.ts +++ b/packages/effect/src/internal/fiberRuntime.ts @@ -827,18 +827,20 @@ export class FiberRuntime extends Effectable.Class 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 + }) + } + }) } } diff --git a/packages/effect/src/internal/logger.ts b/packages/effect/src/internal/logger.ts index 5bb919236a..f3cfdfeaa4 100644 --- a/packages/effect/src/internal/logger.ts +++ b/packages/effect/src/internal/logger.ts @@ -341,7 +341,7 @@ export const structuredMessage = (u: unknown): unknown => { return String(u) } default: { - return u + return Inspectable.toJSON(u) } } } @@ -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)) } } @@ -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) } } } diff --git a/packages/effect/src/internal/redacted.ts b/packages/effect/src/internal/redacted.ts index bf4365c183..0dedecd231 100644 --- a/packages/effect/src/internal/redacted.ts +++ b/packages/effect/src/internal/redacted.ts @@ -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" @@ -34,6 +35,9 @@ export const proto = { toJSON() { return "" }, + [NodeInspectSymbol]() { + return "" + }, [Hash.symbol](this: Redacted.Redacted): number { return pipe( Hash.hash(RedactedSymbolKey), diff --git a/packages/platform/src/Headers.ts b/packages/platform/src/Headers.ts index ec2508e5fd..830e713665 100644 --- a/packages/platform/src/Headers.ts +++ b/packages/platform/src/Headers.ts @@ -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" @@ -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> { + return redact(this, FiberRefs.getOrDefault(fiberRefs, currentRedactedNames)) + } }) const make = (input: Record.ReadonlyRecord): Mutable => @@ -248,7 +256,7 @@ export const redact: { * @since 1.0.0 * @category fiber refs */ -export const currentRedactedNames = globalValue( +export const currentRedactedNames: FiberRef.FiberRef> = globalValue( "@effect/platform/Headers/currentRedactedNames", () => FiberRef.unsafeMake>([ diff --git a/packages/platform/test/Headers.test.ts b/packages/platform/test/Headers.test.ts index 46d0e7e329..95abe753c6 100644 --- a/packages/platform/test/Headers.test.ts +++ b/packages/platform/test/Headers.test.ts @@ -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": "", + "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": "", + "x-api-key": "some-key" + }) + }) + + it.effect("logs redacted", () => + Effect.gen(function*() { + const messages: Array = [] + 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 = [] + 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({