@@ -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
7979export 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
215259export 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}
0 commit comments