@@ -6,7 +6,12 @@ import {
66 secureFetchWithPinnedIP ,
77 validateUrlWithDNS ,
88} from '@/lib/core/security/input-validation.server'
9- import { redactExactSensitiveValues } from '@/lib/core/security/redaction'
9+ import {
10+ isSensitiveKey ,
11+ REDACTED_MARKER ,
12+ redactApiKeys ,
13+ redactExactSensitiveValues ,
14+ } from '@/lib/core/security/redaction'
1015import { consumeOrCancelBody } from '@/lib/core/utils/stream-limits'
1116import { normalizeOracleFusionApplicationOrigin } from '@/lib/credentials/client-credential-accounts/descriptors'
1217import type { OracleFusionAuthInput } from '@/lib/internal/oracle-fusion-financials/schema'
@@ -23,6 +28,10 @@ const TRANSIENT_TRANSPORT_CODES = new Set([
2328 'EPIPE' ,
2429 'ETIMEDOUT' ,
2530] )
31+ const MAX_STRUCTURED_DIAGNOSTIC_DEPTH = 8
32+ const UNSAFE_DIAGNOSTIC = Symbol ( 'unsafe-oracle-diagnostic' )
33+ const DIAGNOSTIC_KEY_VALUE_PATTERN =
34+ / (?: " ( [ ^ " \r \n ] { 1 , 128 } ) " | ' ( [ ^ ' \r \n ] { 1 , 128 } ) ' | ( [ A - Z a - z ] [ A - Z a - z 0 - 9 _ - ] { 0 , 127 } ) ) \s * (?: : | = ) / g
2635const LOSSLESS_DECIMAL_FIELDS = new Set ( [
2736 'InvoiceId' ,
2837 'InvoiceDistributionId' ,
@@ -82,15 +91,92 @@ function collectOracleErrorMessages(payload: unknown, depth = 0): string[] {
8291 return messages
8392}
8493
94+ function isSensitiveDiagnosticKey ( key : string ) : boolean {
95+ return isSensitiveKey ( key . trim ( ) . replaceAll ( / \s + / g, '_' ) )
96+ }
97+
98+ function containsSensitiveDiagnosticKey ( value : string ) : boolean {
99+ for ( const match of value . matchAll ( DIAGNOSTIC_KEY_VALUE_PATTERN ) ) {
100+ if ( isSensitiveDiagnosticKey ( match [ 1 ] ?? match [ 2 ] ?? match [ 3 ] ?? '' ) ) return true
101+ }
102+ return false
103+ }
104+
105+ function sanitizeStructuredDiagnosticValue (
106+ value : unknown ,
107+ accessToken : string ,
108+ depth : number
109+ ) : unknown | typeof UNSAFE_DIAGNOSTIC {
110+ if ( depth > MAX_STRUCTURED_DIAGNOSTIC_DEPTH ) return UNSAFE_DIAGNOSTIC
111+ if ( typeof value === 'string' ) {
112+ return sanitizeOracleDiagnostic ( value , accessToken , depth ) ?? UNSAFE_DIAGNOSTIC
113+ }
114+ if ( Array . isArray ( value ) ) {
115+ const sanitized : unknown [ ] = [ ]
116+ for ( const item of value ) {
117+ const result = sanitizeStructuredDiagnosticValue ( item , accessToken , depth + 1 )
118+ if ( result === UNSAFE_DIAGNOSTIC ) return UNSAFE_DIAGNOSTIC
119+ sanitized . push ( result )
120+ }
121+ return sanitized
122+ }
123+ if ( value && typeof value === 'object' ) {
124+ const sanitized : Record < string , unknown > = { }
125+ for ( const [ key , item ] of Object . entries ( value ) ) {
126+ if ( isSensitiveDiagnosticKey ( key ) ) {
127+ sanitized [ key ] = REDACTED_MARKER
128+ continue
129+ }
130+ const result = sanitizeStructuredDiagnosticValue ( item , accessToken , depth + 1 )
131+ if ( result === UNSAFE_DIAGNOSTIC ) return UNSAFE_DIAGNOSTIC
132+ sanitized [ key ] = result
133+ }
134+ return sanitized
135+ }
136+ return value
137+ }
138+
139+ function sanitizeOracleDiagnostic ( message : string , accessToken : string , depth = 0 ) : string | null {
140+ const trimmed = message . trim ( )
141+ if ( trimmed === REDACTED_MARKER ) return trimmed
142+ if ( ! trimmed . includes ( '{' ) && ! trimmed . includes ( '[' ) ) {
143+ const redacted = redactExactSensitiveValues ( trimmed , [ accessToken ] )
144+ return containsSensitiveDiagnosticKey ( redacted ) ? null : redacted
145+ }
146+ if ( depth >= MAX_STRUCTURED_DIAGNOSTIC_DEPTH ) return null
147+
148+ try {
149+ const structured = JSON . parse ( trimmed )
150+ if ( ! structured || typeof structured !== 'object' ) return null
151+ const sanitized = sanitizeStructuredDiagnosticValue (
152+ redactApiKeys ( structured ) ,
153+ accessToken ,
154+ depth + 1
155+ )
156+ if ( sanitized === UNSAFE_DIAGNOSTIC ) return null
157+ return redactExactSensitiveValues ( JSON . stringify ( sanitized ) , [ accessToken ] )
158+ } catch {
159+ // Provider-controlled text that resembles embedded structured data is not
160+ // reflected unless the complete diagnostic can be parsed and redacted.
161+ return null
162+ }
163+ }
164+
85165function sanitizeOracleError ( body : string , accessToken : string , status : number ) : string {
86166 let messages : string [ ] = [ ]
87167 try {
88- messages = collectOracleErrorMessages ( JSON . parse ( body ) )
168+ messages = collectOracleErrorMessages ( redactApiKeys ( JSON . parse ( body ) ) )
89169 } catch {
90170 // Non-JSON proxy pages are intentionally not reflected to tool callers.
91171 }
92- const unique = [ ...new Set ( messages ) ]
93- const safe = truncate ( redactExactSensitiveValues ( unique . join ( ' — ' ) , [ accessToken ] ) , 1_000 )
172+ const unique = [
173+ ...new Set (
174+ messages
175+ . map ( ( message ) => sanitizeOracleDiagnostic ( message , accessToken ) )
176+ . filter ( ( message ) : message is string => Boolean ( message ) )
177+ ) ,
178+ ]
179+ const safe = truncate ( unique . join ( ' — ' ) , 1_000 )
94180 return safe || `Oracle Fusion Financials request failed with HTTP ${ status } `
95181}
96182
0 commit comments