| 1 | import * as Redacted from "effect/Redacted"; |
| 2 | import * as Stream from "effect/Stream"; |
| 3 | import { BadArgument, SystemError } from "effect/PlatformError"; |
| 4 | import type { CommandProps } from "./Command.ts"; |
| 5 | |
| 6 | const DEFAULT_REDACTION_MARKER = "[REDACTED]"; |
| 7 | |
| 8 | export interface CommandRedactor { |
| 9 | readonly redact: (value: string) => string; |
| 10 | readonly stream: <E, R>( |
| 11 | stream: Stream.Stream<string, E, R>, |
| 12 | ) => Stream.Stream<string, E, R>; |
| 13 | } |
| 14 | |
| 15 | const redactedValues = (env: CommandProps["env"]): ReadonlyArray<string> => |
| 16 | [ |
| 17 | ...new Set( |
| 18 | Object.values(env ?? {}) |
| 19 | .filter((value): value is Redacted.Redacted<string> => |
| 20 | Redacted.isRedacted(value), |
| 21 | ) |
| 22 | .map(Redacted.value) |
| 23 | .filter((value) => value.length > 0), |
| 24 | ), |
| 25 | ].sort((left, right) => right.length - left.length); |
| 26 | |
| 27 | const redactionMarker = (secrets: ReadonlyArray<string>): string => { |
| 28 | if (!secrets.some((secret) => DEFAULT_REDACTION_MARKER.includes(secret))) { |
| 29 | return DEFAULT_REDACTION_MARKER; |
| 30 | } |
| 31 | |
| 32 | // The usual marker can itself be a secret (or contain a very short secret). |
| 33 | // Pick a single private-use character absent from every secret so replacing |
| 34 | // a value can never reproduce that exact value in output. |
| 35 | for (let codePoint = 0xe000; codePoint <= 0xf8ff; codePoint++) { |
| 36 | const candidate = String.fromCodePoint(codePoint); |
| 37 | if (secrets.every((secret) => !secret.includes(candidate))) { |
| 38 | return candidate; |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | // An env value containing the entire private-use block is pathological, but |
| 43 | // a supplementary private-use code point still gives us a safe marker. |
| 44 | for (let codePoint = 0xf0000; codePoint <= 0xffffd; codePoint++) { |
| 45 | const candidate = String.fromCodePoint(codePoint); |
| 46 | if (secrets.every((secret) => !secret.includes(candidate))) { |
| 47 | return candidate; |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | // This requires a secret larger than the full private-use character space. |
| 52 | // Keeping an empty replacement is safer than reproducing any part of it. |
| 53 | return ""; |
| 54 | }; |
| 55 | |
| 56 | const redact = ( |
| 57 | value: string, |
| 58 | secrets: ReadonlyArray<string>, |
| 59 | marker: string, |
| 60 | ): string => |
| 61 | secrets.reduce((safe, secret) => safe.split(secret).join(marker), value); |
| 62 | |
| 63 | /** |
| 64 | * Redact a text stream without leaking secrets split across chunk boundaries. |
| 65 | * The pending suffix is always the longest suffix that could still grow into |
| 66 | * one of the configured secrets. Complete matches are replaced before any |
| 67 | * text is emitted. |
| 68 | */ |
| 69 | const redactStream = <E, R>( |
| 70 | stream: Stream.Stream<string, E, R>, |
| 71 | secrets: ReadonlyArray<string>, |
| 72 | marker: string, |
| 73 | ): Stream.Stream<string, E, R> => { |
| 74 | if (secrets.length === 0) return stream; |
| 75 | |
| 76 | return stream.pipe( |
| 77 | Stream.mapAccum( |
| 78 | () => "", |
| 79 | (pending, chunk) => { |
| 80 | let remaining = pending + chunk; |
| 81 | let output = ""; |
| 82 | |
| 83 | while (remaining.length > 0) { |
| 84 | let matchIndex = -1; |
| 85 | let match: string | undefined; |
| 86 | for (const secret of secrets) { |
| 87 | const index = remaining.indexOf(secret); |
| 88 | if ( |
| 89 | index >= 0 && |
| 90 | (matchIndex < 0 || |
| 91 | index < matchIndex || |
| 92 | (index === matchIndex && secret.length > (match?.length ?? 0))) |
| 93 | ) { |
| 94 | matchIndex = index; |
| 95 | match = secret; |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | if (match !== undefined) { |
| 100 | output += remaining.slice(0, matchIndex) + marker; |
| 101 | remaining = remaining.slice(matchIndex + match.length); |
| 102 | continue; |
| 103 | } |
| 104 | |
| 105 | let suffixLength = 0; |
| 106 | for (const secret of secrets) { |
| 107 | const candidateLength = Math.min( |
| 108 | secret.length - 1, |
| 109 | remaining.length, |
| 110 | ); |
| 111 | for ( |
| 112 | let length = candidateLength; |
| 113 | length > suffixLength; |
| 114 | length-- |
| 115 | ) { |
| 116 | if (secret.startsWith(remaining.slice(-length))) { |
| 117 | suffixLength = length; |
| 118 | break; |
| 119 | } |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | output += remaining.slice(0, remaining.length - suffixLength); |
| 124 | remaining = remaining.slice(remaining.length - suffixLength); |
| 125 | break; |
| 126 | } |
| 127 | |
| 128 | return [remaining, output.length === 0 ? [] : [output]] as const; |
| 129 | }, |
| 130 | { |
| 131 | onHalt: (pending) => |
| 132 | pending.length === 0 ? [] : [redact(pending, secrets, marker)], |
| 133 | }, |
| 134 | ), |
| 135 | ); |
| 136 | }; |
| 137 | |
| 138 | export const makeCommandRedactor = ( |
| 139 | env: CommandProps["env"], |
| 140 | ): CommandRedactor => { |
| 141 | const secrets = redactedValues(env); |
| 142 | const marker = redactionMarker(secrets); |
| 143 | return { |
| 144 | redact: (value) => redact(value, secrets, marker), |
| 145 | stream: (stream) => redactStream(stream, secrets, marker), |
| 146 | }; |
| 147 | }; |
| 148 | |
| 149 | export const redactPlatformReason = ( |
| 150 | reason: BadArgument | SystemError, |
| 151 | redactor: CommandRedactor, |
| 152 | ): BadArgument | SystemError => { |
| 153 | if (reason instanceof BadArgument) { |
| 154 | return new BadArgument({ |
| 155 | module: redactor.redact(reason.module), |
| 156 | method: redactor.redact(reason.method), |
| 157 | description: |
| 158 | reason.description === undefined |
| 159 | ? undefined |
| 160 | : redactor.redact(reason.description), |
| 161 | }); |
| 162 | } |
| 163 | |
| 164 | return new SystemError({ |
| 165 | _tag: reason._tag, |
| 166 | module: redactor.redact(reason.module), |
| 167 | method: redactor.redact(reason.method), |
| 168 | description: |
| 169 | reason.description === undefined |
| 170 | ? undefined |
| 171 | : redactor.redact(reason.description), |
| 172 | syscall: |
| 173 | reason.syscall === undefined |
| 174 | ? undefined |
| 175 | : redactor.redact(reason.syscall), |
| 176 | pathOrDescriptor: |
| 177 | typeof reason.pathOrDescriptor === "string" |
| 178 | ? redactor.redact(reason.pathOrDescriptor) |
| 179 | : reason.pathOrDescriptor, |
| 180 | }); |
| 181 | }; |