Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/docs/content/docs/platform/enterprise/data-retention.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ Redaction applies to text strings at the configured workflow stages. It does not

If log redaction fails, affected strings are replaced with `[REDACTION_FAILED]` rather than stored unmasked. Failure at Workflow input or Block outputs stops the run instead of passing unredacted values downstream.

Base64-encoded file content, such as a `data:` URL or a long base64 string, can't be checked for PII as text, because any PII is inside the encoded file. In stored logs it is replaced with `[BINARY_CONTENT_REDACTED]`. At Workflow input and Block outputs it is masked like any other string. **Run from block** and re-running with a previous run's input read that run's log, so they see the marker in place of that content, the same way they see masked PII.

## Self-hosted setup

### Enable cleanup and schedule it
Expand Down
136 changes: 122 additions & 14 deletions apps/pii/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ def build_analyzer() -> AnalyzerEngine:
anonymizer: AnonymizerEngine
NER_ENTITIES: frozenset
_BLANK_ARTIFACTS: dict
_NLP_MAX_LENGTH: dict

# Every entity the spaCy NER recognizers can actually produce. A request touching
# any of these must run spaCy; a request naming only non-NER (regex/checksum)
Expand Down Expand Up @@ -227,6 +228,96 @@ def _regex_only(entities: list[str] | None, score_threshold: float | None) -> bo
)


# spaCy refuses (error E088) any text longer than its model's `max_length`, so a
# longer text runs NER in windows of this many characters, following spaCy's
# guidance to segment long documents (it sizes NER memory at roughly 1GB per
# 100,000 characters of one text).
NER_WINDOW_CHARS = 100_000
# How much of the previous window each window repeats. Far longer than any entity
# spaCy tags, so an entity cut by one window's edge lies whole inside the next.
NER_WINDOW_OVERLAP = 1_000


def _exceeds_nlp_length(text: str, language: str) -> bool:
limit = _NLP_MAX_LENGTH.get(language)
return limit is not None and len(text) > limit


def _window_entities(
entities: list[str] | None, language: str, score_threshold: float | None
) -> list[str]:
"""The requested built-in entities this language serves that windows must find,
or all of them when `entities` is empty (detect-all). Without a score_threshold
that is only what spaCy NER produces; with one it also includes the pattern
entities, whose context-boosted score can decide whether a match clears the
threshold and needs the real NLP artifacts only a window has."""
served = set(analyzer.get_supported_entities(language))
if score_threshold is None or score_threshold <= 0:
served &= NER_ENTITIES
return sorted(served if not entities else served.intersection(entities))


def _analyze_long(
text: str,
language: str,
entities: list[str] | None,
score_threshold: float | None,
return_decision_process: bool = False,
ad_hoc_recognizers: list[PatternRecognizer] | None = None,
) -> list[RecognizerResult]:
"""Analyze a text longer than spaCy's `max_length`.
Pattern, checksum, and custom recognizers have no length limit, so they run
once over the whole text (against blank NLP artifacts, as the regex-only path
does) and no match can straddle a cut. Only NER runs in overlapping windows
(see NER_WINDOW_CHARS), asked for just its own entities, plus the pattern
entities when a score_threshold makes context scoring matter (see
`_window_entities`). Every window's spans are kept, shifted to whole-text
offsets: NER reads context, so a window can find a name its neighbour missed
or cut short. A span found more than once keeps its highest score, and
overlapping spans of one type are merged by the anonymizer."""
entities = entities or None
results = analyzer.analyze(
text=text,
language=language,
entities=entities,
score_threshold=score_threshold,
return_decision_process=return_decision_process,
nlp_artifacts=_BLANK_ARTIFACTS.get(language),
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
ad_hoc_recognizers=ad_hoc_recognizers or None,
)
window_entities = _window_entities(entities, language, score_threshold)
if not window_entities:
return results
step = NER_WINDOW_CHARS - NER_WINDOW_OVERLAP
for start in range(0, len(text), step):
for result in analyzer.analyze(
text=text[start : start + NER_WINDOW_CHARS],
language=language,
entities=window_entities,
score_threshold=score_threshold,
return_decision_process=return_decision_process,
):
results.append(
RecognizerResult(
result.entity_type,
result.start + start,
result.end + start,
result.score,
result.analysis_explanation,
result.recognition_metadata,
)
)
if start + NER_WINDOW_CHARS >= len(text):
break
unique: dict[tuple[str, int, int], RecognizerResult] = {}
for result in results:
key = (result.entity_type, result.start, result.end)
if key not in unique or result.score > unique[key].score:
unique[key] = result
return list(unique.values())


def _analyze_one(
text: str,
language: str,
Expand All @@ -235,12 +326,15 @@ def _analyze_one(
return_decision_process: bool = False,
ad_hoc_recognizers: list[PatternRecognizer] | None = None,
):
regex_only = _regex_only(entities, score_threshold)
if not regex_only and _exceeds_nlp_length(text, language):
return _analyze_long(
text, language, entities, score_threshold, return_decision_process, ad_hoc_recognizers
)
# Regex-only requests reuse a blank NlpArtifacts to skip the spaCy NLP pass;
# otherwise analyze() computes artifacts (runs spaCy) as usual. Custom-pattern
# recognizers are regex-based, so they run fine against the blank artifacts.
nlp_artifacts = (
_BLANK_ARTIFACTS.get(language) if _regex_only(entities, score_threshold) else None
)
nlp_artifacts = _BLANK_ARTIFACTS.get(language) if regex_only else None
return analyzer.analyze(
text=text,
language=language,
Expand All @@ -264,7 +358,9 @@ def _analyze_many(
score_threshold: float | None,
ad_hoc_recognizers: list[PatternRecognizer] | None = None,
):
"""Analyze many texts, skipping the spaCy pass for regex-only requests."""
"""Analyze many texts, skipping the spaCy pass for regex-only requests and
analyzing any text longer than spaCy's `max_length` on its own (see
`_analyze_long`)."""
if _regex_only(entities, score_threshold):
blank = _BLANK_ARTIFACTS.get(language)
return [
Expand All @@ -278,21 +374,29 @@ def _analyze_many(
)
for text in texts
]
return list(
batch_analyzer.analyze_iterator(
texts=texts,
language=language,
batch_size=NLP_BATCH_SIZE,
entities=entities or None,
score_threshold=score_threshold,
ad_hoc_recognizers=ad_hoc_recognizers or None,
)
long_indices = {i for i, text in enumerate(texts) if _exceeds_nlp_length(text, language)}
short_indices = [i for i in range(len(texts)) if i not in long_indices]
results: list[list[RecognizerResult]] = [[] for _ in texts]
short_results = batch_analyzer.analyze_iterator(
texts=[texts[i] for i in short_indices],
language=language,
batch_size=NLP_BATCH_SIZE,
entities=entities or None,
score_threshold=score_threshold,
ad_hoc_recognizers=ad_hoc_recognizers or None,
)
for i, per_text in zip(short_indices, short_results):
results[i] = list(per_text)
for i in sorted(long_indices):
results[i] = _analyze_long(
texts[i], language, entities, score_threshold, ad_hoc_recognizers=ad_hoc_recognizers
)
return results


@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
global analyzer, batch_analyzer, anonymizer, NER_ENTITIES, _BLANK_ARTIFACTS
global analyzer, batch_analyzer, anonymizer, NER_ENTITIES, _BLANK_ARTIFACTS, _NLP_MAX_LENGTH
logger.info("building analyzer (spacy)")
analyzer = build_analyzer()
batch_analyzer = BatchAnalyzerEngine(analyzer_engine=analyzer)
Expand All @@ -319,6 +423,10 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
language: analyzer.nlp_engine.process_text("", language)
for language in SUPPORTED_LANGUAGES
}
_NLP_MAX_LENGTH = {
language: nlp.max_length
for language, nlp in getattr(analyzer.nlp_engine, "nlp", {}).items()
}
yield


Expand Down
8 changes: 8 additions & 0 deletions apps/sim/lib/guardrails/mask-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ describe('maskPIIBatchViaHttp', () => {
expect(mockSleep).toHaveBeenCalledTimes(7)
})

it('stops after three attempts on a persistent 500, which can be a deterministic input failure', async () => {
fetchMock.mockImplementation(async () => new Response('failed', { status: 500 }))

await expect(maskPIIBatchViaHttp(['a'], [])).rejects.toThrow(/mask-batch request failed/)
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(mockSleep).toHaveBeenCalledTimes(2)
})

describe('with a failed-chunk placeholder', () => {
const PLACEHOLDER = '[FAILED]'
const CHUNK_SIZE = 2000
Expand Down
15 changes: 14 additions & 1 deletion apps/sim/lib/guardrails/mask-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ const CHUNK_CONCURRENCY = env.PII_MASK_CHUNK_CONCURRENCY ?? 64
*/
const MAX_CHUNK_ATTEMPTS = 8

/**
* Attempts for a chunk the route answers with a 500. The route keeps 503 for a
* Presidio outage and 422 for input Presidio refused, so a 500 is an unclassified
* failure that may be deterministic for this input: two retries cover a one-off
* crash without spending the full outage budget resending input that fails the
* same way every time.
*/
const MAX_ATTEMPTS_AFTER_SERVER_ERROR = 3

const RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504])

class MaskChunkHttpError extends Error {
Expand Down Expand Up @@ -196,7 +205,11 @@ async function postChunk(
try {
return await postChunkOnce(url, texts, entityTypes, language, customPatterns)
} catch (error) {
if (attempt >= MAX_CHUNK_ATTEMPTS || !isRetryableChunkError(error)) {
const maxAttempts =
error instanceof MaskChunkHttpError && error.status === 500
? MAX_ATTEMPTS_AFTER_SERVER_ERROR
: MAX_CHUNK_ATTEMPTS
if (attempt >= maxAttempts || !isRetryableChunkError(error)) {
throw error
}
const retryAfterMs = error instanceof MaskChunkHttpError ? error.retryAfterMs : null
Expand Down
31 changes: 31 additions & 0 deletions apps/sim/lib/logs/execution/pii-large-values.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,37 @@ describe('redactManifest — chunk-wise', () => {
})
})

describe('encoded file bodies inside large values', () => {
const encodedFile = Buffer.from(
Array.from({ length: 48 * 1024 }, (_, i) => (i * 7919 + 13) % 256)
).toString('base64')

it('replaces them in the execution log', async () => {
chunkData.set(REF.id, { file: encodedFile, note: 'contact bob' })

const result = await redactLargeValueRefs(
{ finalOutput: REF },
{ entityTypes: ['PERSON'], language: 'en', store: STORE }
)

expect(result.finalOutput).toEqual({
file: '[BINARY_CONTENT_REDACTED]',
note: 'MASKED(contact bob)',
})
})

it('masks them as text in live block state', async () => {
chunkData.set(REF.id, { file: encodedFile })

const result = await redactLargeValueRefsInValue(
{ 'block-1': { output: REF } },
{ entityTypes: ['PERSON'], language: 'en', store: STORE, onFailure: 'throw' }
)

expect(result).toEqual({ 'block-1': { output: { file: `MASKED(${encodedFile})` } } })
})
})

describe('redactLargeValueRefsInValue (arbitrary blockStates)', () => {
it('hydrates + re-stores a ref nested in a non-RedactablePayload shape', async () => {
chunkData.set(REF.id, { note: 'contact bob' })
Expand Down
9 changes: 8 additions & 1 deletion apps/sim/lib/logs/execution/pii-large-values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ export interface RedactLargeValueRefsOptions {
* than feeding a marker (or leaking raw bytes) downstream.
*/
onFailure?: PiiRedactionFailureMode
/**
* @internal Set by {@link redactLargeValueRefs}, the execution log's entry point,
* so encoded file bodies inside its large values are replaced like inline ones
* (see `PiiRedactionOptions.replaceEncodedBinary`). Callers never set it.
*/
replaceEncodedBinary?: boolean
/**
* @internal Shared across every nested invocation spawned from one entry-point
* call, so oversized hydrations serialize globally — not just within their own
Expand Down Expand Up @@ -100,7 +106,7 @@ export async function redactLargeValueRefs(
payload: RedactablePayload,
options: RedactLargeValueRefsOptions
): Promise<RedactablePayload> {
const gatedOptions = withOversizedGate(options)
const gatedOptions = withOversizedGate({ ...options, replaceEncodedBinary: true })
// Collect refs across the WHOLE payload first (shared `seen`), so every ref is
// hydrated+masked+re-stored in one bounded-concurrency pass instead of one
// sequential pass per key. A ref shared across keys is walked/masked once.
Expand Down Expand Up @@ -269,6 +275,7 @@ async function maskMaterializedValue(
language: options.language,
customPatterns: options.customPatterns,
onFailure: options.onFailure ?? 'scrub',
replaceEncodedBinary: options.replaceEncodedBinary,
})
return redactValueRefs(masked, options)
}
Expand Down
67 changes: 66 additions & 1 deletion apps/sim/lib/logs/execution/pii-redaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/auth/internal', () => authInternalMock)

import {
BINARY_CONTENT_REDACTED_MARKER,
PiiRedactionError,
REDACTION_FAILED_MARKER,
redactObjectStrings,
Expand Down Expand Up @@ -92,7 +93,7 @@ describe('redactPIIFromExecution', () => {
})

it('masks large strings too (never left unredacted)', async () => {
const big = 'x'.repeat(200 * 1024)
const big = 'lorem ipsum '.repeat(17_000)
const payload = { finalOutput: { big, small: 'pii' } }

const result = await redactPIIFromExecution(payload, { entityTypes: [] })
Expand Down Expand Up @@ -129,6 +130,70 @@ describe('redactPIIFromExecution', () => {
})
})

/** Base64 of 48KB of non-text bytes, the shape a file body takes in a payload. */
const encodedFile = Buffer.from(
Array.from({ length: 48 * 1024 }, (_, i) => (i * 7919 + 13) % 256)
).toString('base64')

describe('encoded binary content', () => {
it('replaces encoded file bodies in a persisted log without sending them to Presidio', async () => {
const payload = {
traceSpans: [{ blockId: 'write', input: { content: encodedFile, name: 'Ada Lovelace' } }],
workflowInput: { file: `data:application/pdf;base64,${encodedFile}` },
}

const result = await redactPIIFromExecution(payload, { entityTypes: ['PERSON'] })

const [span] = result.traceSpans as { input: { content: string; name: string } }[]
expect(span.input.content).toBe(BINARY_CONTENT_REDACTED_MARKER)
expect((result.workflowInput as { file: string }).file).toBe(BINARY_CONTENT_REDACTED_MARKER)
expect(span.input.name).toBe('MASKED(Ada Lovelace)')
expect(sentTexts()).toEqual(['Ada Lovelace'])
})

it('keeps live data on the masking path, so no string skips redaction by its shape', async () => {
const shapedLikeAFile = `${'Ab1_'.repeat(256)}123-45-6789`
const value = { content: encodedFile, id: shapedLikeAFile }

const result = await redactObjectStrings(value, { entityTypes: [], onFailure: 'throw' })

expect(result).toEqual({ content: `MASKED(${encodedFile})`, id: `MASKED(${shapedLikeAFile})` })
expect(sentTexts()).toEqual([encodedFile, shapedLikeAFile])
})

/** `encodedFile` wrapped at `width` columns, as MIME (76) and PEM (64) encoders emit it. */
const wrapped = (width: number, newline: string) =>
(encodedFile.match(new RegExp(`.{1,${width}}`, 'g')) ?? []).join(newline)

it.each([
['with a trailing newline', `${encodedFile}\n`],
['wrapped at 76 columns', wrapped(76, '\n')],
['wrapped at 64 columns with CRLF', `${wrapped(64, '\r\n')}\r\n`],
['as a wrapped data URL', `data:application/pdf;base64,${wrapped(76, '\n')}`],
['as a data URL with an uppercase scheme', `DATA:application/pdf;base64,${encodedFile}`],
[
'as a data URL with long metadata',
`data:application/pdf;name=${'quarterly-report-'.repeat(40)}.pdf;base64,${encodedFile}`,
],
])('replaces an encoded file body %s in a persisted log', async (_label, body) => {
const result = await redactPIIFromExecution({ finalOutput: body }, { entityTypes: [] })

expect(result.finalOutput).toBe(BINARY_CONTENT_REDACTED_MARKER)
expect(sentTexts()).toEqual([])
})

it.each([
['a newline-separated list of usernames', 'AdaLovelace1815\n'.repeat(100)],
['a short base64 token', 'QWRhIExvdmVsYWNl'],
['a long space-separated sentence', 'Ada Lovelace wrote code in 1843 '.repeat(150)],
])('still masks %s in a persisted log, which is not an encoded file', async (_label, text) => {
const result = await redactPIIFromExecution({ finalOutput: text }, { entityTypes: [] })

expect(result.finalOutput).toBe(`MASKED(${text})`)
expect(sentTexts()).toEqual([text])
})
})

describe('redactObjectStrings', () => {
it('throws PiiRedactionError on masking failure when onFailure is throw', async () => {
maskRoute.mockResolvedValueOnce(rejected())
Expand Down
Loading
Loading