Skip to content

Commit 367f06f

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(oracle-fusion): sanitize structured diagnostics
1 parent 795c7fe commit 367f06f

2 files changed

Lines changed: 158 additions & 4 deletions

File tree

apps/sim/lib/internal/oracle-fusion-financials/client.ts

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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'
1015
import { consumeOrCancelBody } from '@/lib/core/utils/stream-limits'
1116
import { normalizeOracleFusionApplicationOrigin } from '@/lib/credentials/client-credential-accounts/descriptors'
1217
import 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-Za-z][A-Za-z0-9 _-]{0,127}))\s*(?::|=)/g
2635
const 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+
85165
function 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

apps/sim/lib/internal/oracle-fusion-financials/oracle-fusion-financials.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1171,6 +1171,74 @@ describe('Oracle Fusion Financials provider', () => {
11711171
expect(mockSecureFetch).toHaveBeenCalledTimes(3)
11721172
})
11731173

1174+
it('recursively redacts credentials from structured Oracle diagnostics', async () => {
1175+
mockSecureFetch.mockResolvedValueOnce(
1176+
response(400, {
1177+
title: 'validation failed',
1178+
detail: JSON.stringify({
1179+
context: {
1180+
client_secret: 'provider-nested-secret-canary',
1181+
'api key': 'provider-spaced-api-key-canary',
1182+
' private key ': 'provider-spaced-private-key-canary',
1183+
'proxy authorization': 'provider-spaced-proxy-auth-canary',
1184+
entries: [{ access_token: 'provider-nested-token-canary' }],
1185+
serialized: JSON.stringify({
1186+
private_key: 'provider-double-serialized-secret-canary',
1187+
}),
1188+
},
1189+
reason: 'invalid request',
1190+
}),
1191+
})
1192+
)
1193+
1194+
const error = await requestOracleFusionJson(AUTH, {
1195+
path: `${RESOURCE_PATH}/invoices`,
1196+
}).catch((caught) => caught)
1197+
1198+
expect(error).toBeInstanceOf(OracleFusionFinancialsProviderError)
1199+
expect(error).toMatchObject({ status: 400 })
1200+
expect((error as Error).message).toContain('validation failed')
1201+
expect((error as Error).message).toContain('invalid request')
1202+
expect((error as Error).message).toContain('[REDACTED]')
1203+
expect((error as Error).message).not.toContain('provider-nested-secret-canary')
1204+
expect((error as Error).message).not.toContain('provider-spaced-api-key-canary')
1205+
expect((error as Error).message).not.toContain('provider-spaced-private-key-canary')
1206+
expect((error as Error).message).not.toContain('provider-spaced-proxy-auth-canary')
1207+
expect((error as Error).message).not.toContain('provider-nested-token-canary')
1208+
expect((error as Error).message).not.toContain('provider-double-serialized-secret-canary')
1209+
})
1210+
1211+
it('does not reflect ambiguous structured Oracle diagnostics', async () => {
1212+
mockSecureFetch.mockResolvedValueOnce(
1213+
response(400, {
1214+
detail: 'validation failed {"client_secret":"provider-ambiguous-secret-canary"}',
1215+
})
1216+
)
1217+
1218+
await expect(
1219+
requestOracleFusionJson(AUTH, { path: `${RESOURCE_PATH}/invoices` })
1220+
).rejects.toMatchObject({
1221+
status: 400,
1222+
message: 'Oracle Fusion Financials request failed with HTTP 400',
1223+
})
1224+
})
1225+
1226+
it('does not reflect credential-shaped Oracle diagnostic fragments', async () => {
1227+
mockSecureFetch.mockResolvedValueOnce(
1228+
response(400, {
1229+
detail: 'api key: "provider-fragment-secret-canary"',
1230+
message: 'client_secret: "provider-fragment-client-secret-canary"',
1231+
})
1232+
)
1233+
1234+
await expect(
1235+
requestOracleFusionJson(AUTH, { path: `${RESOURCE_PATH}/invoices` })
1236+
).rejects.toMatchObject({
1237+
status: 400,
1238+
message: 'Oracle Fusion Financials request failed with HTTP 400',
1239+
})
1240+
})
1241+
11741242
it('propagates cancellation before a request and while waiting to retry', async () => {
11751243
const preAborted = new AbortController()
11761244
preAborted.abort(new DOMException('cancelled', 'AbortError'))

0 commit comments

Comments
 (0)