Skip to content

Commit 0985bec

Browse files
fix(guardrails): bound PII validation results
1 parent ac03689 commit 0985bec

8 files changed

Lines changed: 292 additions & 26 deletions

File tree

apps/sim/lib/api/contracts/hotspots.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import {
88
import { defineRouteContract } from '@/lib/api/contracts/types'
99
import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages'
1010
import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata'
11+
import {
12+
MAX_PII_VALIDATION_DETECTED_ENTITIES,
13+
MAX_PII_VALIDATION_TEXT_CHARACTERS,
14+
} from '@/lib/guardrails/pii-limits'
1115

1216
const guardrailsMaskBatchBodySchema = z.object({
1317
texts: z.array(z.string()).max(100_000),
@@ -22,7 +26,7 @@ const guardrailsMaskBatchResponseSchema = z.object({
2226

2327
export const guardrailsPiiValidateBodySchema = z
2428
.object({
25-
text: z.string().max(10_000_000, 'Text is too long'),
29+
text: z.string().max(MAX_PII_VALIDATION_TEXT_CHARACTERS, 'Text is too long'),
2630
entityTypes: z.array(z.string().min(1, 'Entity type cannot be empty')).max(200),
2731
mode: z.enum(['block', 'mask']),
2832
language: z.string().min(1, 'Language cannot be empty').max(20).optional(),
@@ -36,16 +40,19 @@ export const detectedPiiEntitySchema = z
3640
start: z.number().int().nonnegative(),
3741
end: z.number().int().nonnegative(),
3842
score: z.number().min(0).max(1),
39-
text: z.string().max(10_000_000, 'Detected text is too long'),
43+
text: z.string().max(MAX_PII_VALIDATION_TEXT_CHARACTERS, 'Detected text is too long'),
4044
})
4145
.strict()
4246

4347
export const guardrailsPiiValidateResponseSchema = z
4448
.object({
4549
passed: z.boolean(),
4650
error: z.string().max(1_000).optional(),
47-
detectedEntities: z.array(detectedPiiEntitySchema),
48-
maskedText: z.string().max(10_000_000, 'Masked text is too long').optional(),
51+
detectedEntities: z.array(detectedPiiEntitySchema).max(MAX_PII_VALIDATION_DETECTED_ENTITIES),
52+
maskedText: z
53+
.string()
54+
.max(MAX_PII_VALIDATION_TEXT_CHARACTERS, 'Masked text is too long')
55+
.optional(),
4956
})
5057
.strict()
5158

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/** Maximum characters accepted or returned by single-text PII validation. */
2+
export const MAX_PII_VALIDATION_TEXT_CHARACTERS = 10_000_000
3+
4+
/** Maximum detected spans materialized into one guardrail verdict. */
5+
export const MAX_PII_VALIDATION_DETECTED_ENTITIES = 10_000
6+
7+
/** Maximum bytes read or serialized for one PII validation result. */
8+
export const MAX_PII_VALIDATION_RESPONSE_BYTES = 10 * 1024 * 1024

apps/sim/lib/guardrails/validate_pii.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
* @vitest-environment node
33
*/
44
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import {
6+
MAX_PII_VALIDATION_DETECTED_ENTITIES,
7+
MAX_PII_VALIDATION_RESPONSE_BYTES,
8+
} from '@/lib/guardrails/pii-limits'
59
import { maskPIIBatch, validatePII } from '@/lib/guardrails/validate_pii'
610

711
interface Span {
@@ -177,5 +181,73 @@ describe('validate_pii (Presidio service)', () => {
177181
expect(res.passed).toBe(true)
178182
expect(res.detectedEntities).toHaveLength(0)
179183
})
184+
185+
it('fails closed before materializing too many detected entities', async () => {
186+
const spans = Array.from({ length: MAX_PII_VALIDATION_DETECTED_ENTITIES + 1 }, () => ({
187+
entity_type: 'CUSTOM_0',
188+
start: 0,
189+
end: 1,
190+
score: 0.8,
191+
}))
192+
fetchMock.mockResolvedValueOnce(Response.json(spans))
193+
194+
const res = await validatePII({
195+
text: 'claim',
196+
entityTypes: [],
197+
mode: 'block',
198+
requestId: 'entity-limit',
199+
})
200+
201+
expect(res).toMatchObject({ passed: false, detectedEntities: [] })
202+
expect(res.error).toContain(
203+
`more than ${MAX_PII_VALIDATION_DETECTED_ENTITIES} detected entities`
204+
)
205+
expect(fetchMock).toHaveBeenCalledOnce()
206+
})
207+
208+
it('fails closed before parsing an oversized anonymizer response', async () => {
209+
fetchMock
210+
.mockResolvedValueOnce(
211+
Response.json([{ entity_type: 'EMAIL_ADDRESS', start: 0, end: 1, score: 0.9 }])
212+
)
213+
.mockResolvedValueOnce(
214+
new Response('{"text":"masked"}', {
215+
headers: { 'content-length': String(MAX_PII_VALIDATION_RESPONSE_BYTES + 1) },
216+
})
217+
)
218+
219+
const res = await validatePII({
220+
text: 'a',
221+
entityTypes: [],
222+
mode: 'mask',
223+
requestId: 'output-limit',
224+
})
225+
226+
expect(res).toMatchObject({ passed: false, detectedEntities: [] })
227+
expect(res.error).toContain('PII anonymizer response exceeds maximum size')
228+
expect(fetchMock).toHaveBeenCalledTimes(2)
229+
})
230+
231+
it('fails closed before materializing oversized detected-entity text', async () => {
232+
const text = 'x'.repeat(6_000)
233+
const spans = Array.from({ length: 2_000 }, () => ({
234+
entity_type: 'CUSTOM_0',
235+
start: 0,
236+
end: text.length,
237+
score: 0.8,
238+
}))
239+
fetchMock.mockResolvedValueOnce(Response.json(spans))
240+
241+
const res = await validatePII({
242+
text,
243+
entityTypes: [],
244+
mode: 'block',
245+
requestId: 'entity-byte-limit',
246+
})
247+
248+
expect(res).toMatchObject({ passed: false, detectedEntities: [] })
249+
expect(res.error).toContain('detected entities exceed the validation response size limit')
250+
expect(fetchMock).toHaveBeenCalledOnce()
251+
})
180252
})
181253
})

apps/sim/lib/guardrails/validate_pii.ts

Lines changed: 126 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
3+
import { truncate } from '@sim/utils/string'
34
import { env } from '@/lib/core/config/env'
45
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
6+
import {
7+
DEFAULT_MAX_ERROR_BODY_BYTES,
8+
readResponseJsonWithLimit,
9+
readResponseTextWithLimit,
10+
} from '@/lib/core/utils/stream-limits'
511
import { chunkIndicesByBudget } from '@/lib/guardrails/pii-batching'
612
import type { CustomPiiPattern } from '@/lib/guardrails/pii-entities'
13+
import {
14+
MAX_PII_VALIDATION_DETECTED_ENTITIES,
15+
MAX_PII_VALIDATION_RESPONSE_BYTES,
16+
MAX_PII_VALIDATION_TEXT_CHARACTERS,
17+
} from '@/lib/guardrails/pii-limits'
718
import { isAbortError } from '@/providers/streaming-tool-loop-shared'
819

920
const logger = createLogger('PIIValidator')
@@ -79,6 +90,77 @@ interface AnalyzerSpan {
7990
score: number
8091
}
8192

93+
function parseAnalyzerSpans(value: unknown, textLength: number): AnalyzerSpan[] {
94+
if (!Array.isArray(value)) throw new Error('PII analyzer returned an invalid result')
95+
if (value.length > MAX_PII_VALIDATION_DETECTED_ENTITIES) {
96+
throw new Error(
97+
`PII analyzer returned more than ${MAX_PII_VALIDATION_DETECTED_ENTITIES} detected entities`
98+
)
99+
}
100+
101+
return value.map((span, index) => {
102+
if (!span || typeof span !== 'object' || Array.isArray(span)) {
103+
throw new Error(`PII analyzer returned an invalid entity at index ${index}`)
104+
}
105+
const record = span as Record<string, unknown>
106+
if (
107+
typeof record.entity_type !== 'string' ||
108+
!record.entity_type ||
109+
record.entity_type.length > 100 ||
110+
typeof record.start !== 'number' ||
111+
typeof record.end !== 'number' ||
112+
!Number.isInteger(record.start) ||
113+
!Number.isInteger(record.end) ||
114+
record.start < 0 ||
115+
record.end < record.start ||
116+
record.end > textLength ||
117+
typeof record.score !== 'number' ||
118+
record.score < 0 ||
119+
record.score > 1
120+
) {
121+
throw new Error(`PII analyzer returned an invalid entity at index ${index}`)
122+
}
123+
return {
124+
entity_type: record.entity_type,
125+
start: record.start,
126+
end: record.end,
127+
score: record.score,
128+
}
129+
})
130+
}
131+
132+
function assertDetectedEntityBudget(
133+
text: string,
134+
spans: AnalyzerSpan[],
135+
patterns?: CustomPiiPattern[]
136+
): void {
137+
let estimatedBytes = 2
138+
for (const span of spans) {
139+
const type = displayEntityType(span.entity_type, patterns)
140+
estimatedBytes +=
141+
128 +
142+
Buffer.byteLength(type, 'utf8') +
143+
Buffer.byteLength(text.slice(span.start, span.end), 'utf8')
144+
if (estimatedBytes > MAX_PII_VALIDATION_RESPONSE_BYTES) {
145+
throw new Error('PII detected entities exceed the validation response size limit')
146+
}
147+
}
148+
}
149+
150+
function assertValidationResultBudget(result: PIIValidationResult): PIIValidationResult {
151+
if (
152+
result.maskedText !== undefined &&
153+
result.maskedText.length > MAX_PII_VALIDATION_TEXT_CHARACTERS
154+
) {
155+
throw new Error('PII masked text exceeds the validation response size limit')
156+
}
157+
const responseBytes = Buffer.byteLength(JSON.stringify(result), 'utf8')
158+
if (responseBytes > MAX_PII_VALIDATION_RESPONSE_BYTES) {
159+
throw new Error('PII validation result exceeds the response size limit')
160+
}
161+
return result
162+
}
163+
82164
/**
83165
* Detect PII spans via the Presidio analyzer. An empty `entityTypes` ⇒ detect all.
84166
* Throws on transport/HTTP failure so callers can apply their own fail-safe.
@@ -109,10 +191,19 @@ async function analyze(
109191
signal,
110192
})
111193
if (!response.ok) {
112-
const detail = await response.text().catch(() => '')
194+
const detail = await readResponseTextWithLimit(response, {
195+
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
196+
label: 'PII analyzer error response',
197+
signal,
198+
}).catch(() => '')
113199
throw new Error(`Presidio analyze failed (${response.status}): ${detail.slice(0, 200)}`)
114200
}
115-
return (await response.json()) as AnalyzerSpan[]
201+
const result = await readResponseJsonWithLimit(response, {
202+
maxBytes: MAX_PII_VALIDATION_RESPONSE_BYTES,
203+
label: 'PII analyzer response',
204+
signal,
205+
})
206+
return parseAnalyzerSpans(result, text.length)
116207
}
117208

118209
/**
@@ -251,11 +342,26 @@ async function anonymize(
251342
signal,
252343
})
253344
if (!response.ok) {
254-
const detail = await response.text().catch(() => '')
345+
const detail = await readResponseTextWithLimit(response, {
346+
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
347+
label: 'PII anonymizer error response',
348+
signal,
349+
}).catch(() => '')
255350
throw new Error(`Presidio anonymize failed (${response.status}): ${detail.slice(0, 200)}`)
256351
}
257-
const data = (await response.json()) as { text: string }
258-
return data.text
352+
const data = await readResponseJsonWithLimit<unknown>(response, {
353+
maxBytes: MAX_PII_VALIDATION_RESPONSE_BYTES,
354+
label: 'PII anonymizer response',
355+
signal,
356+
})
357+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
358+
throw new Error('PII anonymizer returned an invalid result')
359+
}
360+
const maskedText = (data as Record<string, unknown>).text
361+
if (typeof maskedText !== 'string') {
362+
throw new Error('PII anonymizer returned an invalid result')
363+
}
364+
return maskedText
259365
}
260366

261367
/**
@@ -279,6 +385,7 @@ export async function validatePII(input: PIIValidationInput): Promise<PIIValidat
279385
abortSignal?.throwIfAborted()
280386
const spans = await analyze(text, entityTypes, language, customPatterns, abortSignal)
281387
abortSignal?.throwIfAborted()
388+
assertDetectedEntityBudget(text, spans, customPatterns)
282389

283390
const detectedEntities: DetectedPIIEntity[] = spans.map((s) => ({
284391
type: displayEntityType(s.entity_type, customPatterns),
@@ -290,7 +397,11 @@ export async function validatePII(input: PIIValidationInput): Promise<PIIValidat
290397

291398
if (spans.length === 0) {
292399
logger.info(`[${requestId}] PII validation completed`, { passed: true, detectedCount: 0 })
293-
return { passed: true, detectedEntities: [], maskedText: mode === 'mask' ? text : undefined }
400+
return assertValidationResultBudget({
401+
passed: true,
402+
detectedEntities: [],
403+
maskedText: mode === 'mask' ? text : undefined,
404+
})
294405
}
295406

296407
if (mode === 'block') {
@@ -303,7 +414,11 @@ export async function validatePII(input: PIIValidationInput): Promise<PIIValidat
303414
passed: false,
304415
detectedCount: detectedEntities.length,
305416
})
306-
return { passed: false, error: `PII detected: ${summary}`, detectedEntities }
417+
return assertValidationResultBudget({
418+
passed: false,
419+
error: `PII detected: ${summary}`,
420+
detectedEntities,
421+
})
307422
}
308423

309424
// mask mode: the anonymizer replaces every span with `<ENTITY_TYPE>` (or the
@@ -315,13 +430,14 @@ export async function validatePII(input: PIIValidationInput): Promise<PIIValidat
315430
detectedCount: detectedEntities.length,
316431
hasMaskedText: true,
317432
})
318-
return { passed: true, detectedEntities, maskedText }
433+
return assertValidationResultBudget({ passed: true, detectedEntities, maskedText })
319434
} catch (error) {
320435
if (isAbortError(error) || abortSignal?.aborted) throw error
321-
logger.error(`[${requestId}] PII validation failed`, { error: getErrorMessage(error) })
436+
const errorMessage = truncate(getErrorMessage(error), 950)
437+
logger.error(`[${requestId}] PII validation failed`, { error: errorMessage })
322438
return {
323439
passed: false,
324-
error: `PII validation failed: ${getErrorMessage(error)}`,
440+
error: `PII validation failed: ${errorMessage}`,
325441
detectedEntities: [],
326442
}
327443
}

apps/sim/lib/guardrails/validation-client.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const { mockToken } = vi.hoisted(() => ({
1212

1313
vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: mockToken }))
1414

15+
import { MAX_PII_VALIDATION_RESPONSE_BYTES } from '@/lib/guardrails/pii-limits'
1516
import { validatePIIViaHttp } from '@/lib/guardrails/validation-client'
1617

1718
describe('validatePIIViaHttp', () => {
@@ -77,4 +78,17 @@ describe('validatePIIViaHttp', () => {
7778
).rejects.toThrow()
7879
expect(fetchMock).toHaveBeenCalledOnce()
7980
})
81+
82+
it('fails before parsing an oversized success body', async () => {
83+
fetchMock.mockResolvedValueOnce(
84+
new Response('{"passed":true}', {
85+
headers: { 'content-length': String(MAX_PII_VALIDATION_RESPONSE_BYTES + 1) },
86+
})
87+
)
88+
89+
await expect(
90+
validatePIIViaHttp({ text: 'claim', entityTypes: [], mode: 'block' })
91+
).rejects.toThrow('PII validation response exceeds maximum size')
92+
expect(fetchMock).toHaveBeenCalledOnce()
93+
})
8094
})

0 commit comments

Comments
 (0)