Files
simstudioai--sim/apps/sim/lib/logs/execution/pii-redaction.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

244 lines
8.3 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import { maskPIIBatchViaHttp } from '@/lib/guardrails/mask-client'
const logger = createLogger('PiiRedaction')
/** Replaces text we could not safely mask, so PII is never persisted on failure. */
export const REDACTION_FAILED_MARKER = '[REDACTION_FAILED]'
/**
* How to handle a masking failure (Presidio error or over-ceiling payload):
* - `'scrub'` (default): replace eligible strings with {@link REDACTION_FAILED_MARKER}.
* Safe for the log stage — execution already succeeded.
* - `'throw'`: throw {@link PiiRedactionError}. Used for the execution-altering
* stages (input/block outputs) where a marker would corrupt computed data and
* fail-open would leak — so the run aborts instead.
*/
export type PiiRedactionFailureMode = 'scrub' | 'throw'
export interface PiiRedactionOptions {
/** Presidio entity types to mask. Empty = redact all detected PII. */
entityTypes: string[]
language?: string
/** Failure handling. Defaults to `'scrub'`. */
onFailure?: PiiRedactionFailureMode
}
/** Thrown when in-flight redaction (`onFailure: 'throw'`) cannot mask safely. */
export class PiiRedactionError extends Error {
constructor(message: string) {
super(message)
this.name = 'PiiRedactionError'
}
}
export interface RedactablePayload {
traceSpans?: unknown
finalOutput?: unknown
workflowInput?: unknown
error?: unknown
completionFailure?: unknown
trigger?: unknown
executionState?: unknown
environment?: unknown
correlation?: unknown
}
/** Keys of {@link RedactablePayload} processed by the redactor, in order. */
const REDACTABLE_KEYS: (keyof RedactablePayload)[] = [
'traceSpans',
'finalOutput',
'workflowInput',
'error',
'completionFailure',
'trigger',
'executionState',
'environment',
'correlation',
]
/** Trace-span fields that carry runtime content (and therefore possible PII). */
const SPAN_CONTENT_FIELDS = [
'input',
'output',
'thinking',
'modelToolCalls',
'toolCalls',
'error',
'errorMessage',
] as const
function isEligibleString(value: string): boolean {
return value.length > 0
}
/**
* Rebuild `value` replacing every eligible string leaf with `handle(leaf)`.
* Used for both collection (handle records and returns the input) and
* substitution (handle returns the masked value), so traversal order and
* eligibility are guaranteed identical across the two passes.
*/
function transformStrings(value: unknown, handle: (s: string) => string): unknown {
if (typeof value === 'string') {
return isEligibleString(value) ? handle(value) : value
}
// Treat offloaded large-value refs as opaque: masking their internal `key`/`id`
// strings would corrupt the reference. Their content is handled separately
// (hydrate → mask → re-store) before this runs.
if (isLargeValueRef(value) || isLargeArrayManifest(value)) {
return value
}
if (Array.isArray(value)) {
return value.map((item) => transformStrings(item, handle))
}
if (value !== null && typeof value === 'object') {
const out: Record<string, unknown> = {}
for (const [key, v] of Object.entries(value)) {
out[key] = transformStrings(v, handle)
}
return out
}
return value
}
/**
* Redact a trace span: only its content fields ({@link SPAN_CONTENT_FIELDS}) and
* nested `children` are walked, leaving structural metadata (blockId, name,
* status, timing) untouched so log correlation/display is preserved.
*/
function transformSpan(span: unknown, handle: (s: string) => string): unknown {
if (span === null || typeof span !== 'object' || Array.isArray(span)) {
return transformStrings(span, handle)
}
const source = span as Record<string, unknown>
const out: Record<string, unknown> = { ...source }
for (const field of SPAN_CONTENT_FIELDS) {
if (field in out) out[field] = transformStrings(out[field], handle)
}
if (Array.isArray(source.children)) {
out.children = source.children.map((child) => transformSpan(child, handle))
}
return out
}
function transformUnit(
key: keyof RedactablePayload,
value: unknown,
handle: (s: string) => string
): unknown {
if (key === 'traceSpans' && Array.isArray(value)) {
return value.map((span) => transformSpan(span, handle))
}
return transformStrings(value, handle)
}
/**
* Mask a batch of collected strings via Presidio. On a hard failure, either scrub
* to {@link REDACTION_FAILED_MARKER} or throw {@link PiiRedactionError}, per
* `options.onFailure`. Returns masked values aligned 1:1 with `collected`.
*
* There is no total-size ceiling — the batching layer chunks the request and
* fans out with bounded concurrency, so payloads of any size are masked properly
* rather than scrubbed. The per-chunk request timeout is the real backstop.
*/
async function maskCollected(
collected: string[],
options: PiiRedactionOptions
): Promise<{ masked: string[]; scrubbed: boolean }> {
const onFailure = options.onFailure ?? 'scrub'
const language = options.language ?? 'en'
try {
// Presidio runs only in the app container; the persist + execution paths also
// run in the trigger.dev runtime, so masking always goes over HTTP to the app.
const masked = await maskPIIBatchViaHttp(collected, options.entityTypes, language)
return { masked, scrubbed: false }
} catch (error) {
logger.error('PII masking failed', {
error: getErrorMessage(error),
stringCount: collected.length,
onFailure,
})
if (onFailure === 'throw') {
throw new PiiRedactionError(`PII redaction failed: ${getErrorMessage(error)}`)
}
return { masked: collected.map(() => REDACTION_FAILED_MARKER), scrubbed: true }
}
}
/**
* Mask every eligible string leaf of an arbitrary object in place-preserving
* fashion: collect all leaves in one deterministic pass, mask in a single batched
* Presidio call, then rebuild from the masked slice (the identical traversal
* order makes the two passes line up). Used for the execution-altering input and
* block-output stages, so it defaults callers toward `onFailure: 'throw'`.
*/
export async function redactObjectStrings<T>(value: T, options: PiiRedactionOptions): Promise<T> {
const collected: string[] = []
transformStrings(value, (s) => {
collected.push(s)
return s
})
if (collected.length === 0) return value
const { masked } = await maskCollected(collected, options)
let index = 0
return transformStrings(value, () => masked[index++]) as T
}
/**
* Mask PII across an execution's `traceSpans` / `finalOutput` / `workflowInput`.
*
* All eligible string leaves are collected in one deterministic pass and masked
* in a single batched (byte-chunked) Presidio call — so subprocess count scales
* with payload size, not block count. Each unit is then rebuilt independently
* from the masked slice, preserving the JSON structure (Presidio never sees the
* envelope). On a hard masking failure or when the payload exceeds the ceiling,
* eligible strings are replaced with {@link REDACTION_FAILED_MARKER} (the default
* `onFailure: 'scrub'`) rather than left unredacted — PII is never persisted on
* the failure path.
*/
export async function redactPIIFromExecution(
payload: RedactablePayload,
options: PiiRedactionOptions
): Promise<RedactablePayload> {
const startedAt = performance.now()
const units = REDACTABLE_KEYS.filter((key) => payload[key] !== undefined).map((key) => ({
key,
value: payload[key],
}))
const collected: string[] = []
let totalBytes = 0
for (const unit of units) {
transformUnit(unit.key, unit.value, (s) => {
collected.push(s)
totalBytes += Buffer.byteLength(s, 'utf8')
return s
})
}
if (collected.length === 0) return payload
const { masked, scrubbed } = await maskCollected(collected, options)
let index = 0
const result: RedactablePayload = { ...payload }
for (const unit of units) {
result[unit.key] = transformUnit(unit.key, unit.value, () => masked[index++])
}
logger.info('PII redaction completed', {
stringCount: collected.length,
totalBytes,
durationMs: Math.round(performance.now() - startedAt),
scrubbed,
})
return result
}