Skip to content

Commit f987fd6

Browse files
committed
fix(logging): redact credentials from every logged value
The logger summarized only a top-level Axios error. Every logged argument now goes through one redaction walk, for both the output channel and the flight-recorder buffer. It redacts credential fields with string values in plain objects and errors, and summarizes Axios errors at any depth, including error causes. The walk reads only data properties, never throws, and skips entries the channel drops.
1 parent 2c347e5 commit f987fd6

5 files changed

Lines changed: 174 additions & 64 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333
in a future release, so ask your Coder administrator to enable dynamic client
3434
registration. For instructions, see
3535
[OAuth2 provider](https://coder.com/docs/admin/integrations/oauth2-provider).
36-
- Keep session tokens, OAuth tokens, and client secrets from failed requests out
37-
of the buffered logs that the Coder output channel replays.
36+
- Keep session tokens, OAuth tokens, and client secrets out of the Coder output
37+
channel and its buffered logs, including from failed requests.
3838
- CLI commands and SSH connections that the extension starts ignore
3939
`CODER_SESSION_TOKEN` from the environment, so they no longer fail or run as a
4040
different user inside a Coder workspace.

‎src/logging/flightRecorder.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { safeStringify } from "./formatters";
1+
import { redactSecrets, safeStringify } from "./formatters";
22

33
import type { LogSink, Logger } from "./logger";
44

@@ -109,8 +109,13 @@ export class FlightRecorder implements Logger {
109109
/** Pass a call through to the sink and buffer it when below the level. */
110110
private wrap(level: Level): (message: string, ...args: unknown[]) => void {
111111
return (message, ...args) => {
112-
this.record(level, message, args);
113-
this.inner[level](message, ...args);
112+
if (SEVERITY[level] < this.channel.logLevel) {
113+
// The channel drops this entry, so only the buffered copy is formatted.
114+
this.record(level, message, args);
115+
this.inner[level](message, ...args);
116+
} else {
117+
this.inner[level](message, ...args.map(redactSecrets));
118+
}
114119
};
115120
}
116121

@@ -130,7 +135,7 @@ export class FlightRecorder implements Logger {
130135
}
131136

132137
private record(level: Level, message: string, args: unknown[]): void {
133-
if (this.capacity === 0 || SEVERITY[level] >= this.channel.logLevel) {
138+
if (this.capacity === 0) {
134139
return;
135140
}
136141
const text = [message, ...args.map((arg) => safeStringify(arg) ?? "")].join(

‎src/logging/formatters.ts‎

Lines changed: 102 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ const SENSITIVE_HEADERS: ReadonlySet<Lowercase<string>> = new Set([
1414
"x-api-key",
1515
]);
1616

17-
/** Credential fields from OAuth token requests/responses, logged at BODY level. */
18-
const SENSITIVE_BODY_FIELDS: ReadonlySet<Lowercase<string>> = new Set([
17+
/** Credential fields, such as those in OAuth requests, responses, and sessions. */
18+
const SENSITIVE_FIELDS: ReadonlySet<Lowercase<string>> = new Set([
1919
"access_token",
2020
"client_secret",
2121
"code",
@@ -78,88 +78,132 @@ export function formatHeaders(
7878

7979
export function formatBody(body: unknown): string {
8080
if (body) {
81-
return safeStringify(redactBodyFields(body)) ?? "<invalid body>";
81+
return stringify(redactBody(body)) ?? "<invalid body>";
8282
} else {
8383
return "<no body>";
8484
}
8585
}
8686

8787
/**
88-
* Redact known credential fields, copying only what changes; untouched
89-
* values keep their original reference. util.inspect has no replacer
90-
* hook, so the value is walked before stringifying.
88+
* Copy of `value` with credentials redacted and Axios errors summarized at any
89+
* depth; unchanged values keep their reference. Never throws.
9190
*/
92-
function redactBodyFields(
93-
value: unknown,
94-
seen = new WeakSet<object>(),
95-
): unknown {
96-
if (typeof value === "string") {
97-
return redactStringBody(value, seen);
98-
}
99-
if (value instanceof URLSearchParams) {
100-
const keys = [...value.keys()];
101-
if (!keys.some((key) => SENSITIVE_BODY_FIELDS.has(lowercase(key)))) {
102-
return value;
103-
}
104-
return new URLSearchParams(
105-
[...value].map(([key, entry]) => [
106-
key,
107-
SENSITIVE_BODY_FIELDS.has(lowercase(key)) ? REDACTED : entry,
108-
]),
109-
);
91+
export function redactSecrets(value: unknown): unknown {
92+
try {
93+
return redact(value, new WeakSet());
94+
} catch {
95+
return REDACTED;
11096
}
97+
}
98+
99+
function redact(value: unknown, seen: WeakSet<object>): unknown {
111100
if (typeof value !== "object" || value === null || seen.has(value)) {
112101
return value;
113102
}
114103
seen.add(value);
104+
if (isAxiosError(value)) {
105+
return summarizeAxiosError(value);
106+
}
107+
if (value instanceof URLSearchParams) {
108+
return redactParams(value);
109+
}
115110
if (Array.isArray(value)) {
116-
const entries: readonly unknown[] = value;
117-
let copy: unknown[] | undefined;
118-
entries.forEach((entry, index) => {
119-
const redacted = redactBodyFields(entry, seen);
120-
if (redacted !== entry) {
121-
copy ??= [...entries];
122-
copy[index] = redacted;
123-
}
124-
});
125-
return copy ?? value;
111+
return redactArray(value, seen);
126112
}
127-
// Rebuilding a Date or Buffer from its entries would mangle its output.
113+
// Copying a Date, Buffer, or class instance property by property would mangle it.
128114
const proto: unknown = Object.getPrototypeOf(value);
129-
if (proto !== Object.prototype && proto !== null) {
130-
return value;
115+
return value instanceof Error || proto === Object.prototype || proto === null
116+
? redactProperties(value, seen)
117+
: value;
118+
}
119+
120+
function redactArray(
121+
array: readonly unknown[],
122+
seen: WeakSet<object>,
123+
): readonly unknown[] {
124+
let copy: unknown[] | undefined;
125+
for (const [index, entry] of array.entries()) {
126+
const redacted = redact(entry, seen);
127+
if (redacted !== entry) {
128+
copy ??= [...array];
129+
copy[index] = redacted;
130+
}
131131
}
132-
let copy: Record<string, unknown> | undefined;
133-
for (const [key, entry] of Object.entries(value)) {
134-
const redacted = SENSITIVE_BODY_FIELDS.has(lowercase(key))
135-
? REDACTED
136-
: redactBodyFields(entry, seen);
132+
return copy ?? array;
133+
}
134+
135+
/** Reads data properties only, so getters never run. */
136+
function redactProperties(value: object, seen: WeakSet<object>): object {
137+
let descriptors: PropertyDescriptorMap | undefined;
138+
for (const key of Reflect.ownKeys(value)) {
139+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
140+
if (!descriptor || !("value" in descriptor)) {
141+
continue;
142+
}
143+
const entry: unknown = descriptor.value;
144+
const redacted = isCredential(key, entry) ? REDACTED : redact(entry, seen);
137145
if (redacted !== entry) {
138-
copy ??= { ...value };
139-
copy[key] = redacted;
146+
descriptors ??= Object.getOwnPropertyDescriptors(value);
147+
descriptors[key] = { ...descriptor, value: redacted };
140148
}
141149
}
142-
return copy ?? value;
150+
if (!descriptors) {
151+
return value;
152+
}
153+
if (value instanceof Error) {
154+
// V8's `stack` accessor reads the original error's internal slot.
155+
descriptors.stack = {
156+
value: value.stack,
157+
writable: true,
158+
configurable: true,
159+
};
160+
}
161+
return Object.create(
162+
Object.getPrototypeOf(value) as object | null,
163+
descriptors,
164+
) as object;
143165
}
144166

145-
/**
146-
* Axios error paths expose only the serialized body, so JSON and
147-
* form-encoded strings are parsed too. Clean strings pass through as-is.
148-
*/
149-
function redactStringBody(body: string, seen: WeakSet<object>): unknown {
167+
function redactParams(params: URLSearchParams): URLSearchParams {
168+
const entries = [...params];
169+
if (!entries.some(([key, entry]) => isCredential(key, entry))) {
170+
return params;
171+
}
172+
return new URLSearchParams(
173+
entries.map(([key, entry]) => [
174+
key,
175+
isCredential(key, entry) ? REDACTED : entry,
176+
]),
177+
);
178+
}
179+
180+
/** Credentials are strings, so numeric fields such as a close `code` stay readable. */
181+
function isCredential(key: PropertyKey, value: unknown): boolean {
182+
return (
183+
typeof key === "string" &&
184+
typeof value === "string" &&
185+
SENSITIVE_FIELDS.has(lowercase(key))
186+
);
187+
}
188+
189+
/** Axios error paths expose only the serialized body, so JSON and form-encoded strings are parsed too. */
190+
function redactBody(body: unknown): unknown {
191+
if (typeof body !== "string") {
192+
return redactSecrets(body);
193+
}
150194
const trimmed = body.trim();
151195
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
152196
try {
153197
const parsed: unknown = JSON.parse(trimmed);
154-
const redacted = redactBodyFields(parsed, seen);
198+
const redacted = redactSecrets(parsed);
155199
return redacted === parsed ? body : redacted;
156200
} catch {
157201
// Not JSON; fall through.
158202
}
159203
}
160204
if (trimmed.includes("=")) {
161205
const params = new URLSearchParams(trimmed);
162-
const redacted = redactBodyFields(params, seen);
206+
const redacted = redactParams(params);
163207
return redacted === params ? body : redacted;
164208
}
165209
return body;
@@ -208,13 +252,17 @@ function summarizeAxiosError(error: AxiosError) {
208252
method: formatMethod(error.config?.method),
209253
url: formatUri(error.config),
210254
status: error.response?.status,
211-
data: redactBodyFields(error.response?.data),
255+
data: redactBody(error.response?.data),
212256
};
213257
}
214258

215259
export function safeStringify(data: unknown): string | null {
260+
return stringify(redactSecrets(data));
261+
}
262+
263+
function stringify(data: unknown): string | null {
216264
try {
217-
return util.inspect(isAxiosError(data) ? summarizeAxiosError(data) : data, {
265+
return util.inspect(data, {
218266
showHidden: false,
219267
// Bounded so a single log line cannot balloon in size.
220268
depth: 8,
@@ -225,7 +273,6 @@ export function safeStringify(data: unknown): string | null {
225273
getters: false, // avoid side-effects
226274
});
227275
} catch {
228-
// Should rarely happen but just in case
229276
return null;
230277
}
231278
}

‎test/unit/logging/flightRecorder.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,17 @@ describe("FlightRecorder", () => {
7070
]);
7171
});
7272

73+
it("redacts credentials from forwarded and buffered args", () => {
74+
const { buffer, calls, flush } = setup(INFO, 10);
75+
const auth = { url: "https://coder.example.com", token: "secret" };
76+
77+
buffer.info("shown", auth);
78+
buffer.debug("hidden", auth);
79+
80+
expect(calls[0].args).toEqual([{ ...auth, token: "<redacted>" }]);
81+
expect(flush().join(" ")).not.toContain("secret");
82+
});
83+
7384
interface LevelCase {
7485
level: number;
7586
hidden: LogMethod;

‎test/unit/logging/formatters.test.ts‎

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { AxiosError, AxiosHeaders } from "axios";
2-
import { describe, expect, it } from "vitest";
2+
import { describe, expect, it, vi } from "vitest";
33

44
import {
55
formatBody,
@@ -8,6 +8,7 @@ import {
88
formatSize,
99
formatTime,
1010
formatUri,
11+
redactSecrets,
1112
safeStringify,
1213
sizeOf,
1314
} from "@/logging/formatters";
@@ -205,6 +206,40 @@ describe("Logging formatters", () => {
205206
});
206207
});
207208

209+
describe("redactSecrets", () => {
210+
it("returns unchanged values by reference", () => {
211+
const value = { nested: { code: 1006 }, list: ["a"] };
212+
expect(redactSecrets(value)).toBe(value);
213+
});
214+
215+
it("never runs getters and never throws", () => {
216+
const getter = vi.fn(() => "secret");
217+
const value = Object.defineProperty({ token: "secret" }, "lazy", {
218+
get: getter,
219+
enumerable: true,
220+
});
221+
const { proxy, revoke } = Proxy.revocable({}, {});
222+
revoke();
223+
224+
expect(redactSecrets(value)).toMatchObject({ token: "<redacted>" });
225+
expect(getter).not.toHaveBeenCalled();
226+
expect(redactSecrets(proxy)).toBe("<redacted>");
227+
});
228+
229+
it("redacts an error's fields on a copy that keeps its stack", () => {
230+
const error = Object.assign(new TypeError("failed"), { token: "secret" });
231+
232+
const redacted = redactSecrets(error);
233+
234+
expect(redacted).toBeInstanceOf(TypeError);
235+
expect(redacted).toMatchObject({
236+
message: "failed",
237+
stack: error.stack,
238+
token: "<redacted>",
239+
});
240+
});
241+
});
242+
208243
describe("sizeOf", () => {
209244
type SizeOfTestCase = [data: unknown, bytes: number | undefined];
210245
it.each<SizeOfTestCase>([
@@ -293,7 +328,17 @@ describe("safeStringify", () => {
293328
expect(safeStringify(nested)).not.toContain("bottom");
294329
});
295330

296-
it("keeps request credentials out of Axios errors", () => {
331+
it("redacts credential fields in any logged object", () => {
332+
const result = safeStringify({
333+
token: "access-secret",
334+
oauth: { refresh_token: "refresh-secret", scope: "coder:all" },
335+
});
336+
337+
expect(result).toContain("coder:all");
338+
expect(result).not.toMatch(/access-secret|refresh-secret/);
339+
});
340+
341+
it("keeps request credentials out of Axios errors at any depth", () => {
297342
const config = {
298343
method: "post",
299344
url: "https://coder.example.com/oauth2/revoke",
@@ -314,8 +359,10 @@ describe("safeStringify", () => {
314359
},
315360
);
316361

317-
const result = safeStringify(error);
362+
const wrapped = new Error("Revocation failed", { cause: error });
363+
const result = safeStringify({ attempts: [wrapped] });
318364

365+
expect(result).toContain("Revocation failed");
319366
expect(result).toContain("POST");
320367
expect(result).toContain("https://coder.example.com/oauth2/revoke");
321368
expect(result).toContain("status: 400");

0 commit comments

Comments
 (0)