11import { createLogger } from '@sim/logger'
22import { getErrorMessage } from '@sim/utils/errors'
3+ import { truncate } from '@sim/utils/string'
34import { env } from '@/lib/core/config/env'
45import { 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'
511import { chunkIndicesByBudget } from '@/lib/guardrails/pii-batching'
612import 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'
718import { isAbortError } from '@/providers/streaming-tool-loop-shared'
819
920const 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 }
0 commit comments