From 43967d3048f1e6b5f5d125ceeb05b8e63018e000 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 10:45:54 +0000 Subject: [PATCH 1/5] fix(stack,supabase,prisma): keep plaintext search terms out of errors and logs The free-text needle guard (matchNeedleError) quoted the rejected search term, and the Supabase and Prisma wildcard errors quoted the rejected pattern. Supabase's query-level catch then logged the full message at error level, the default STASH_STACK_LOG level, so a short needle reached production logs. Callers also commonly log thrown errors, and the Prisma pattern can be arbitrarily long. The three messages now report the term's length in codepoints instead of the term. The Supabase catch logs only the failure's kind (error class, plus the encryption error's type and code), since it sees errors from every layer and any of their messages may quote a value. The full message still reaches the caller in error.message. Fixes CIP-5161. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7pT73uHWFP7RKEikuh --- .../no-plaintext-search-terms-in-errors.md | 7 ++ .../stack-prisma/src/v3/operators-v3.ts | 4 +- .../operator-lowering-v3-text-search.test.ts | 16 +++ .../__tests__/no-plaintext-in-errors.test.ts | 99 +++++++++++++++++++ .../stack-supabase/src/query-builder.ts | 23 ++++- .../__tests__/match-needle-guard.test.ts | 16 ++- .../stack/src/schema/match-defaults.ts | 5 +- 7 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 .changeset/no-plaintext-search-terms-in-errors.md create mode 100644 languages/typescript/packages/stack-supabase/__tests__/no-plaintext-in-errors.test.ts diff --git a/.changeset/no-plaintext-search-terms-in-errors.md b/.changeset/no-plaintext-search-terms-in-errors.md new file mode 100644 index 0000000000..f06a35a581 --- /dev/null +++ b/.changeset/no-plaintext-search-terms-in-errors.md @@ -0,0 +1,7 @@ +--- +'@cipherstash/stack': patch +'@cipherstash/stack-supabase': patch +'@cipherstash/stack-prisma': patch +--- + +Search terms no longer appear in error messages or logs. A free-text search term that is too short, or a `like`/`ilike` pattern with wildcards the encrypted search cannot honour, is now reported by its length instead of being quoted. The Supabase adapter's error log for a failed query now records the failure's kind (the error class and, for encryption failures, its `type` and `code`) instead of the full message, which could quote a plaintext value. The full message is still returned to the caller in `error.message`. diff --git a/languages/typescript/packages/stack-prisma/src/v3/operators-v3.ts b/languages/typescript/packages/stack-prisma/src/v3/operators-v3.ts index a82d799a64..f07d5a59c5 100644 --- a/languages/typescript/packages/stack-prisma/src/v3/operators-v3.ts +++ b/languages/typescript/packages/stack-prisma/src/v3/operators-v3.ts @@ -537,7 +537,9 @@ function normalizeMatchNeedle( throw operatorError( ctx, method, - `cipherstash ${method}: pattern ${JSON.stringify(value)} on column "${ctx.columnName ?? 'unknown'}" has wildcards fuzzy free-text matching cannot honor (an interior "%" or any "_"). Pass a literal search term.`, + // The pattern is the caller's plaintext search term: name its length, + // never its content (CIP-5161). + `cipherstash ${method}: the ${[...value].length}-character pattern on column "${ctx.columnName ?? 'unknown'}" has wildcards fuzzy free-text matching cannot honor (an interior "%" or any "_"). Pass a literal search term.`, ) } const match = ctx.meta.indexes.match diff --git a/languages/typescript/packages/stack-prisma/test/v3/operator-lowering-v3-text-search.test.ts b/languages/typescript/packages/stack-prisma/test/v3/operator-lowering-v3-text-search.test.ts index 74cb53aaa4..7d5060c2c2 100644 --- a/languages/typescript/packages/stack-prisma/test/v3/operator-lowering-v3-text-search.test.ts +++ b/languages/typescript/packages/stack-prisma/test/v3/operator-lowering-v3-text-search.test.ts @@ -124,6 +124,22 @@ describe('cipherstash v3 operator lowering — eqlMatch needle guards', () => { } }) + // The pattern is the caller's plaintext search term. The error is thrown to + // the caller, who commonly logs it, so it reports the term's length only. + it('never echoes the rejected pattern in the wildcard error', () => { + for (const needle of ['Qzx%wvy', 'Qzx_wvy']) { + let message = '' + try { + lowerMatch(needle) + } catch (err) { + message = (err as Error).message + } + expect(message, needle).toMatch(/cannot honor/) + expect(message, needle).not.toContain('Qzx') + expect(message, needle).not.toContain('wvy') + } + }) + it('rejects a needle that is empty after stripping with the matchNeedleError reason', () => { expect(() => lowerMatch('%%')).toThrow(EncryptionOperatorError) expect(() => lowerMatch('%%')).toThrow( diff --git a/languages/typescript/packages/stack-supabase/__tests__/no-plaintext-in-errors.test.ts b/languages/typescript/packages/stack-supabase/__tests__/no-plaintext-in-errors.test.ts new file mode 100644 index 0000000000..2ba5983235 --- /dev/null +++ b/languages/typescript/packages/stack-supabase/__tests__/no-plaintext-in-errors.test.ts @@ -0,0 +1,99 @@ +import { logger } from '@cipherstash/stack/adapter-kit' +import { encryptedTable, types } from '@cipherstash/stack/eql/v3' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { EncryptedQueryBuilderImpl } from '../src/query-builder' +import { + createMockEncryptionClient, + createMockSupabase, +} from './helpers/supabase-mock' + +// A search term on an encrypted column is plaintext the caller chose to +// protect. The library promises never to log plaintext, and its own error +// messages are routinely logged by callers, so neither may carry the term. +// These tests use distinctive sentinels so a substring check cannot pass by +// accident against an ordinary English word in the message. + +const users = encryptedTable('users', { + email: types.TextSearch('email'), + nickname: types.TextEq('nickname'), +}) +const COLUMNS = ['id', 'email', 'nickname'] + +function builder(encryptionClient = createMockEncryptionClient()) { + const supabase = createMockSupabase([]) + return new EncryptedQueryBuilderImpl( + 'users', + users, + encryptionClient, + supabase.client, + COLUMNS, + ) +} + +/** Every string the logger was handed, across all levels. */ +function logged(spies: ReturnType[]): string { + return spies + .flatMap((spy) => spy.mock.calls.flat()) + .map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))) + .join('\n') +} + +function spyLogger() { + return (['error', 'warn', 'info', 'debug'] as const).map((level) => + vi.spyOn(logger, level).mockImplementation(() => {}), + ) +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('plaintext search terms stay out of errors and logs', () => { + it('a too-short matches() needle is neither logged nor echoed in the error', async () => { + const spies = spyLogger() + + const { error } = await builder().select('id').matches('email', 'Qz') + + expect(error?.message).toMatch(/at least 3 characters/) + expect(error?.message).not.toContain('Qz') + expect(logged(spies)).not.toContain('Qz') + }) + + it('a like() pattern with unsupported wildcards is not echoed in the error', () => { + for (const pattern of ['Qzx%wvy', 'Qzx_wvy']) { + let message = '' + try { + builder().select('id').like('email', pattern) + } catch (err) { + message = (err as Error).message + } + expect(message, pattern).toMatch(/cannot honor/) + expect(message, pattern).not.toContain('Qzx') + expect(message, pattern).not.toContain('wvy') + } + }) + + // The catch around query execution sees errors from every layer (encryption, + // PostgREST, the network), and any of them may quote a value. It reports the + // failure's kind; the message itself goes back to the caller, not to the log. + it('a failed query logs the failure kind, not the error message', async () => { + const encryption = createMockEncryptionClient() + vi.spyOn( + encryption as unknown as { bulkEncrypt: (...a: unknown[]) => unknown }, + 'bulkEncrypt', + ).mockImplementation(() => { + throw new TypeError('value "Qzx-secret-wvy" could not be encoded') + }) + const spies = spyLogger() + + const { error } = await builder(encryption) + .select('id') + .in('nickname', ['Qzx-secret-wvy', 'other']) + + expect(error?.message).toContain('Qzx-secret-wvy') + const output = logged(spies) + expect(output).toMatch(/failed on table "users"/) + expect(output).toContain('TypeError') + expect(output).not.toContain('Qzx') + }) +}) diff --git a/languages/typescript/packages/stack-supabase/src/query-builder.ts b/languages/typescript/packages/stack-supabase/src/query-builder.ts index 2c69841568..3e20f164b9 100644 --- a/languages/typescript/packages/stack-supabase/src/query-builder.ts +++ b/languages/typescript/packages/stack-supabase/src/query-builder.ts @@ -49,6 +49,19 @@ import type { export { EncryptionFailedError } from './query-encrypt' +/** + * A log-safe description of a query failure: the error's class, plus the + * encryption error's `type` and `code` when there is one. These are fixed + * identifiers chosen by the library and the FFI, never caller data, so unlike + * the message they cannot carry a plaintext value. + */ +function describeFailure(err: unknown): string { + if (!(err instanceof Error)) return typeof err + if (!(err instanceof EncryptionFailedError)) return err.name + const { type, code } = err.encryptionError + return [err.name, type, code].filter(Boolean).join(': ') +} + /** Warn once per (op, column) that a `like`/`ilike` was delegated to `matches`. */ const warnedLikeDelegation = new Set() @@ -598,8 +611,12 @@ export class EncryptedQueryBuilderImpl< } satisfies DecryptContext) } catch (err) { const message = err instanceof Error ? err.message : String(err) + // Log the failure's kind, never its message: this catch sees errors from + // every layer (query-term validation, encryption, PostgREST), and their + // messages may quote a plaintext value. The full message still reaches + // the caller in the returned `error`. logger.error( - `Supabase encrypted query failed on table "${this.tableName}": ${message}`, + `Supabase encrypted query failed on table "${this.tableName}" (${describeFailure(err)}).`, ) // A failure inside any of the encrypt/decrypt steps above is thrown as an @@ -894,7 +911,9 @@ export class EncryptedQueryBuilderImpl< const { needle, hasUnsupportedWildcard } = parseLikeNeedle(pattern) if (hasUnsupportedWildcard) { throw new Error( - `[supabase v3]: "${op}" pattern "${pattern}" on encrypted column "${column}" has wildcards fuzzy free-text matching cannot honor (an unescaped internal "%" or any unescaped "_"). Use matches("${column}", term) with a literal search term.`, + // The pattern is the caller's plaintext search term: name its length, + // never its content (CIP-5161). + `[supabase v3]: "${op}" ${[...pattern].length}-character pattern on encrypted column "${column}" has wildcards fuzzy free-text matching cannot honor (an unescaped internal "%" or any unescaped "_"). Use matches("${column}", term) with a literal search term.`, ) } const key = `${op}:${column}` diff --git a/languages/typescript/packages/stack/__tests__/match-needle-guard.test.ts b/languages/typescript/packages/stack/__tests__/match-needle-guard.test.ts index 664cac7e60..65c16b03c2 100644 --- a/languages/typescript/packages/stack/__tests__/match-needle-guard.test.ts +++ b/languages/typescript/packages/stack/__tests__/match-needle-guard.test.ts @@ -28,9 +28,21 @@ describe('matchNeedleError', () => { expect(matchNeedleError('lovelace', ngram)).toBeUndefined() }) - it('rejects a needle below the floor, naming the floor and the term', () => { + it('rejects a needle below the floor, naming the floor and the length', () => { expect(matchNeedleError('ad', ngram)).toMatch(/at least 3 characters/) - expect(matchNeedleError('ad', ngram)).toMatch(/"ad"/) + expect(matchNeedleError('ad', ngram)).toMatch(/has 2\b/) + }) + + // The search term is plaintext: a caller searching an encrypted column is + // searching for something sensitive. Adapters throw this reason to the caller + // and some log it, so it must never carry the term itself — only its length. + it('never echoes the search term in the message', () => { + for (const needle of ['Qz', 'Q', '\u{1F512}X', '"z']) { + const reason = matchNeedleError(needle, ngram) + expect(reason, needle).toBeTypeOf('string') + expect(reason, needle).not.toContain(needle) + expect(reason, needle).not.toContain(JSON.stringify(needle)) + } }) // The tokenizer counts Unicode CODEPOINTS. `'👍👍'` is 2 codepoints but 4 diff --git a/languages/typescript/packages/stack/src/schema/match-defaults.ts b/languages/typescript/packages/stack/src/schema/match-defaults.ts index 1ce3380882..d08092f259 100644 --- a/languages/typescript/packages/stack/src/schema/match-defaults.ts +++ b/languages/typescript/packages/stack/src/schema/match-defaults.ts @@ -128,6 +128,9 @@ export function matchNeedleMinLength(opts: MatchIndexOpts): number | undefined { * (`'%ada%'`), so the floor may have to apply to the unwrapped term rather than * to the string the caller passed. */ +// The message never quotes the needle: it is the caller's plaintext search +// term, and every adapter throws this reason to its caller (who may log it). +// Report the length, which is what the caller needs to fix the query. export function matchNeedleError( needle: unknown, opts: MatchIndexOpts, @@ -142,5 +145,5 @@ export function matchNeedleError( return `free-text search needs a non-empty search term; an empty term produces no tokens and would match every row.` } if (min === undefined || length >= min) return undefined - return `free-text search needs at least ${min} characters (the index tokenizer's token_length), but the search term ${JSON.stringify(needle)} has ${length}. A shorter term produces no tokens and would match every row.` + return `free-text search needs at least ${min} characters (the index tokenizer's token_length), but the search term has ${length}. A shorter term produces no tokens and would match every row.` } From ba272d905bfc9748acc0d576b0a94686ad3e1cbe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 10:49:09 +0000 Subject: [PATCH 2/5] fix(stack,supabase): log a failure's kind, not its message, in DynamoDB too Review follow-up. The DynamoDB adapter's handleError logged the full error message at error level, the same leak the Supabase catch had: the message can quote a plaintext value from any layer below. The log-safe description moves to a shared describeFailure helper in adapter-kit, used by both adapters. Besides the error class it now keeps the fixed identifiers a failure carries (type, code, authCode, on the error or a nested encryptionError), so a PostgREST code or a token service refusal such as USAGE_LIMIT_EXCEEDED still reaches the log. Each must look like an identifier, so a field holding data is dropped. Also aligns the Supabase and Prisma wildcard error wording. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7pT73uHWFP7RKEikuh --- .../no-plaintext-search-terms-in-errors.md | 4 +- .../stack-prisma/src/v3/operators-v3.ts | 2 +- .../stack-supabase/src/query-builder.ts | 16 +---- .../stack/__tests__/describe-failure.test.ts | 61 +++++++++++++++++++ .../dynamodb/resolve-decrypt.test.ts | 19 ++++++ .../packages/stack/src/adapter-kit.ts | 3 + .../packages/stack/src/dynamodb/helpers.ts | 5 +- .../src/utils/logger/describe-failure.ts | 52 ++++++++++++++++ 8 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 languages/typescript/packages/stack/__tests__/describe-failure.test.ts create mode 100644 languages/typescript/packages/stack/src/utils/logger/describe-failure.ts diff --git a/.changeset/no-plaintext-search-terms-in-errors.md b/.changeset/no-plaintext-search-terms-in-errors.md index f06a35a581..ecd832ad67 100644 --- a/.changeset/no-plaintext-search-terms-in-errors.md +++ b/.changeset/no-plaintext-search-terms-in-errors.md @@ -4,4 +4,6 @@ '@cipherstash/stack-prisma': patch --- -Search terms no longer appear in error messages or logs. A free-text search term that is too short, or a `like`/`ilike` pattern with wildcards the encrypted search cannot honour, is now reported by its length instead of being quoted. The Supabase adapter's error log for a failed query now records the failure's kind (the error class and, for encryption failures, its `type` and `code`) instead of the full message, which could quote a plaintext value. The full message is still returned to the caller in `error.message`. +Search terms no longer appear in error messages or logs. A free-text search term that is too short, or a `like`/`ilike` pattern with wildcards the encrypted search cannot honour, is now reported by its length instead of being quoted. + +The Supabase and DynamoDB adapters' error logs now record a failure's kind instead of its full message, which could quote a plaintext value: the error class, plus any `type`, `code` and `authCode` identifiers it carries (for example a PostgREST error code or an FFI error code). The full message is still returned to the caller on the error. `@cipherstash/stack/adapter-kit` exports the shared `describeFailure` helper that produces this description. diff --git a/languages/typescript/packages/stack-prisma/src/v3/operators-v3.ts b/languages/typescript/packages/stack-prisma/src/v3/operators-v3.ts index f07d5a59c5..12293dba5b 100644 --- a/languages/typescript/packages/stack-prisma/src/v3/operators-v3.ts +++ b/languages/typescript/packages/stack-prisma/src/v3/operators-v3.ts @@ -539,7 +539,7 @@ function normalizeMatchNeedle( method, // The pattern is the caller's plaintext search term: name its length, // never its content (CIP-5161). - `cipherstash ${method}: the ${[...value].length}-character pattern on column "${ctx.columnName ?? 'unknown'}" has wildcards fuzzy free-text matching cannot honor (an interior "%" or any "_"). Pass a literal search term.`, + `cipherstash ${method}: pattern (${[...value].length} characters) on column "${ctx.columnName ?? 'unknown'}" has wildcards fuzzy free-text matching cannot honor (an interior "%" or any "_"). Pass a literal search term.`, ) } const match = ctx.meta.indexes.match diff --git a/languages/typescript/packages/stack-supabase/src/query-builder.ts b/languages/typescript/packages/stack-supabase/src/query-builder.ts index 3e20f164b9..2974066f37 100644 --- a/languages/typescript/packages/stack-supabase/src/query-builder.ts +++ b/languages/typescript/packages/stack-supabase/src/query-builder.ts @@ -1,5 +1,6 @@ import type { AuditConfig } from '@cipherstash/stack/adapter-kit' import { + describeFailure, logger, parseSelectorSegments, reconstructSelectorDocument, @@ -49,19 +50,6 @@ import type { export { EncryptionFailedError } from './query-encrypt' -/** - * A log-safe description of a query failure: the error's class, plus the - * encryption error's `type` and `code` when there is one. These are fixed - * identifiers chosen by the library and the FFI, never caller data, so unlike - * the message they cannot carry a plaintext value. - */ -function describeFailure(err: unknown): string { - if (!(err instanceof Error)) return typeof err - if (!(err instanceof EncryptionFailedError)) return err.name - const { type, code } = err.encryptionError - return [err.name, type, code].filter(Boolean).join(': ') -} - /** Warn once per (op, column) that a `like`/`ilike` was delegated to `matches`. */ const warnedLikeDelegation = new Set() @@ -913,7 +901,7 @@ export class EncryptedQueryBuilderImpl< throw new Error( // The pattern is the caller's plaintext search term: name its length, // never its content (CIP-5161). - `[supabase v3]: "${op}" ${[...pattern].length}-character pattern on encrypted column "${column}" has wildcards fuzzy free-text matching cannot honor (an unescaped internal "%" or any unescaped "_"). Use matches("${column}", term) with a literal search term.`, + `[supabase v3]: "${op}" pattern (${[...pattern].length} characters) on encrypted column "${column}" has wildcards fuzzy free-text matching cannot honor (an unescaped internal "%" or any unescaped "_"). Use matches("${column}", term) with a literal search term.`, ) } const key = `${op}:${column}` diff --git a/languages/typescript/packages/stack/__tests__/describe-failure.test.ts b/languages/typescript/packages/stack/__tests__/describe-failure.test.ts new file mode 100644 index 0000000000..1f7857fb82 --- /dev/null +++ b/languages/typescript/packages/stack/__tests__/describe-failure.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { describeFailure } from '@/utils/logger/describe-failure' + +// `describeFailure` is what adapters log in place of an error's message, which +// may quote a plaintext value (CIP-5161). It must name the failure's kind and +// fixed identifiers, and nothing a caller supplied. +describe('describeFailure', () => { + it('names an Error by its class, never its message', () => { + const out = describeFailure(new TypeError('value "Qzx" is bad')) + expect(out).toBe('TypeError') + }) + + it('appends the type, code and authCode identifiers', () => { + const err = Object.assign(new Error('boom Qzx'), { + type: 'EncryptionError', + code: 'UNKNOWN_COLUMN', + authCode: 'USAGE_LIMIT_EXCEEDED', + }) + expect(describeFailure(err)).toBe( + 'Error: EncryptionError: UNKNOWN_COLUMN: USAGE_LIMIT_EXCEEDED', + ) + }) + + it('reads identifiers from a nested encryptionError', () => { + const err = Object.assign(new Error('Qzx'), { + name: 'EncryptionFailedError', + encryptionError: { + type: 'EncryptionError', + message: 'Qzx', + authCode: 'USAGE_LIMIT_EXCEEDED', + }, + }) + expect(describeFailure(err)).toBe( + 'EncryptionFailedError: EncryptionError: USAGE_LIMIT_EXCEEDED', + ) + }) + + it('keeps the code of a non-Error failure, such as a PostgREST error object', () => { + expect(describeFailure({ code: '42883', message: 'Qzx' })).toBe( + 'object: 42883', + ) + expect(describeFailure({ code: 503 })).toBe('object: 503') + }) + + it('drops an identifier field that carries data instead of an identifier', () => { + const err = Object.assign(new Error('x'), { + code: 'value "Qzx" is not allowed', + type: 'a'.repeat(65), + }) + expect(describeFailure(err)).toBe('Error') + const renamed = new Error('x') + renamed.name = 'Qzx secret' + expect(describeFailure(renamed)).toBe('Error') + }) + + it('describes a non-object by its type', () => { + expect(describeFailure('Qzx')).toBe('string') + expect(describeFailure(null)).toBe('null') + expect(describeFailure(undefined)).toBe('undefined') + }) +}) diff --git a/languages/typescript/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts b/languages/typescript/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts index 4d80c8f6ec..e557e90744 100644 --- a/languages/typescript/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts +++ b/languages/typescript/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts @@ -446,6 +446,25 @@ describe('handleError', () => { expect(rethrow('ECONNRESET').code).toBe('DYNAMODB_ENCRYPTION_ERROR') }) + // The message may quote a plaintext value (CIP-5161). It is returned to the + // caller on the error, but the library's own log names only the failure kind. + it('logs the failure kind and code, never the message', () => { + const failure = Object.assign( + new Error('value "Qzx-secret-wvy" rejected'), + { + code: 'UNKNOWN_COLUMN', + }, + ) + + const error = handleError(failure, 'encryptModel') + + expect(error.message).toContain('Qzx-secret-wvy') + const logged = errorLog.mock.calls.flat().join('\n') + expect(logged).toContain('encryptModel') + expect(logged).toContain('UNKNOWN_COLUMN') + expect(logged).not.toContain('Qzx') + }) + it('extracts the message from an Error, a plain object, or anything else', () => { expect( handleError(new Error('from an Error'), 'decryptModel').message, diff --git a/languages/typescript/packages/stack/src/adapter-kit.ts b/languages/typescript/packages/stack/src/adapter-kit.ts index 754d8c590e..12aae603b6 100644 --- a/languages/typescript/packages/stack/src/adapter-kit.ts +++ b/languages/typescript/packages/stack/src/adapter-kit.ts @@ -74,5 +74,8 @@ export { matchNeedleError } from './schema/match-defaults.js' // `./types`: deciding which wire version a table targets is adapter plumbing, // not something an end user should be reaching for. export { hasBuildColumnKeyMap } from './types.js' +// Log-safe failure description: adapters log an error's kind, never its +// message, which may quote a plaintext value. +export { describeFailure } from './utils/logger/describe-failure.js' // Shared structured logger (adapters log rejections through the same instance). export { logger } from './utils/logger/index.js' diff --git a/languages/typescript/packages/stack/src/dynamodb/helpers.ts b/languages/typescript/packages/stack/src/dynamodb/helpers.ts index 76f94f1155..2c5a8fc8bf 100644 --- a/languages/typescript/packages/stack/src/dynamodb/helpers.ts +++ b/languages/typescript/packages/stack/src/dynamodb/helpers.ts @@ -4,6 +4,7 @@ import { resolveEncryptColumnMap } from '@/encryption/helpers/model-helpers' import { DATE_LIKE_CASTS } from '@/eql/v3/columns' import type { EncryptedValue } from '@/types' import { logger } from '@/utils/logger' +import { describeFailure } from '@/utils/logger/describe-failure' import type { AnyEncryptedTable, EncryptedDynamoDBError } from './types' export const ciphertextAttrSuffix = '__source' @@ -68,7 +69,9 @@ export function handleError( context, }) - logger.error(`DynamoDB error in ${context}: ${errorMessage}`) + // Log the failure's kind, never its message: it may quote a plaintext value + // (CIP-5161). The caller gets the full message on the returned error. + logger.error(`DynamoDB error in ${context} (${describeFailure(error)}).`) if (options?.errorHandler) { options.errorHandler(dynamoError) diff --git a/languages/typescript/packages/stack/src/utils/logger/describe-failure.ts b/languages/typescript/packages/stack/src/utils/logger/describe-failure.ts new file mode 100644 index 0000000000..28c8a6bc26 --- /dev/null +++ b/languages/typescript/packages/stack/src/utils/logger/describe-failure.ts @@ -0,0 +1,52 @@ +/** + * A log-safe description of a failure, for an adapter's error log. + * + * An adapter's catch sees errors from every layer below it (query validation, + * encryption, the database client, the network), and any of their messages may + * quote a plaintext value. The library promises never to log plaintext, so an + * error log names the failure's KIND, never its message: the error's class plus + * the fixed identifiers libraries attach to it. The full message still goes back + * to the caller, who owns the decision to log it. + * + * The identifiers read are `type`, `code` and `authCode`, from the error itself + * and from a nested `encryptionError` (the shape the Supabase adapter's + * `EncryptionFailedError` carries). Each must look like an identifier — word + * characters, `.` or `-`, at most 64 — so a library that puts data into one of + * these fields cannot smuggle it into the log. + * + * @example + * describeFailure(new TypeError('value "x" is bad')) // 'TypeError' + * describeFailure({ code: '42883', message: '…' }) // 'object: 42883' + */ +export function describeFailure(err: unknown): string { + const kind = + err instanceof Error + ? (safeIdentifier(err.name) ?? 'Error') + : err === null + ? 'null' + : typeof err + const ids = [ + ...identifiersOf(err), + ...identifiersOf(fieldOf(err, 'encryptionError')), + ] + return [kind, ...new Set(ids)].join(': ') +} + +const IDENTIFIER = /^[\w.-]{1,64}$/ + +function safeIdentifier(value: unknown): string | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return String(value) + return typeof value === 'string' && IDENTIFIER.test(value) ? value : undefined +} + +function fieldOf(value: unknown, key: string): unknown { + return typeof value === 'object' && value !== null + ? (value as Record)[key] + : undefined +} + +function identifiersOf(value: unknown): string[] { + return (['type', 'code', 'authCode'] as const) + .map((key) => safeIdentifier(fieldOf(value, key))) + .filter((id): id is string => id !== undefined) +} From c0ee5635b235a5a8cd0b811d6a746e34f69bc323 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 01:33:26 +0000 Subject: [PATCH 3/5] fix(stack,supabase,prisma): allowlist logged failure codes, sanitize DynamoDB logger hook Review follow-up on CIP-5161. - describeFailure accepted any identifier-shaped type/code/authCode, so an error with code 'Qzx-secret-wvy' wrote that value to the log. It now logs only allowlisted values: built-in and library error class names, EncryptionErrorTypes, the FFI error codes (pinned to PROTECT_ERROR_CODES by a test) and the auth codes (a Record over AuthErrorCode, so the compiler rejects a missing one). The imports are type-only, so adapter-kit still loads no native module. - describeFailure returns 'unknown failure' if reading the error throws, so describing a failure inside a catch cannot become a second failure. - DynamoDB's options.logger hook received the full error. It now gets a copy carrying the log-safe description and the same code; errorHandler and the caller still get the full error. The stash-dynamodb skill example now logs error.code, not error.message. - The Supabase and Prisma like/ilike wildcard messages share a new likeWildcardError(pattern) helper, which takes the pattern so no caller can interpolate it. - matchNeedleError's no-quoting rule moves into its JSDoc. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7pT73uHWFP7RKEikuh --- .../no-plaintext-search-terms-in-errors.md | 3 +- .../stack-prisma/src/v3/operators-v3.ts | 5 +- .../stack-supabase/src/query-builder.ts | 5 +- .../stack/__tests__/describe-failure.test.ts | 64 ++++++--- .../dynamodb/resolve-decrypt.test.ts | 43 +++++- .../__tests__/match-needle-guard.test.ts | 19 ++- .../packages/stack/src/adapter-kit.ts | 5 +- .../packages/stack/src/dynamodb/helpers.ts | 13 +- .../stack/src/schema/match-defaults.ts | 21 ++- .../src/utils/logger/describe-failure.ts | 133 ++++++++++++++---- skills/stash-dynamodb/SKILL.md | 6 +- 11 files changed, 254 insertions(+), 63 deletions(-) diff --git a/.changeset/no-plaintext-search-terms-in-errors.md b/.changeset/no-plaintext-search-terms-in-errors.md index ecd832ad67..816e36c57d 100644 --- a/.changeset/no-plaintext-search-terms-in-errors.md +++ b/.changeset/no-plaintext-search-terms-in-errors.md @@ -2,8 +2,9 @@ '@cipherstash/stack': patch '@cipherstash/stack-supabase': patch '@cipherstash/stack-prisma': patch +'stash': patch --- Search terms no longer appear in error messages or logs. A free-text search term that is too short, or a `like`/`ilike` pattern with wildcards the encrypted search cannot honour, is now reported by its length instead of being quoted. -The Supabase and DynamoDB adapters' error logs now record a failure's kind instead of its full message, which could quote a plaintext value: the error class, plus any `type`, `code` and `authCode` identifiers it carries (for example a PostgREST error code or an FFI error code). The full message is still returned to the caller on the error. `@cipherstash/stack/adapter-kit` exports the shared `describeFailure` helper that produces this description. +The Supabase and DynamoDB adapters' error logs now record a failure's kind instead of its full message, which could quote a plaintext value. The kind is the error class plus any known encryption error type, FFI error code or auth code it carries; anything else is left out. The DynamoDB `options.logger` hook now receives a copy of the error whose message is that description, with the same `code`. The full message is still returned to the caller and passed to `errorHandler`. `@cipherstash/stack/adapter-kit` exports the shared `describeFailure` and `likeWildcardError` helpers that produce these messages. diff --git a/languages/typescript/packages/stack-prisma/src/v3/operators-v3.ts b/languages/typescript/packages/stack-prisma/src/v3/operators-v3.ts index 12293dba5b..e805f7d8b4 100644 --- a/languages/typescript/packages/stack-prisma/src/v3/operators-v3.ts +++ b/languages/typescript/packages/stack-prisma/src/v3/operators-v3.ts @@ -75,6 +75,7 @@ import { jsonPathOf, + likeWildcardError, matchNeedleError, parseSelectorSegments, unsupportedLeafReason, @@ -537,9 +538,7 @@ function normalizeMatchNeedle( throw operatorError( ctx, method, - // The pattern is the caller's plaintext search term: name its length, - // never its content (CIP-5161). - `cipherstash ${method}: pattern (${[...value].length} characters) on column "${ctx.columnName ?? 'unknown'}" has wildcards fuzzy free-text matching cannot honor (an interior "%" or any "_"). Pass a literal search term.`, + `cipherstash ${method} on column "${ctx.columnName ?? 'unknown'}": ${likeWildcardError(value)} Pass a literal search term.`, ) } const match = ctx.meta.indexes.match diff --git a/languages/typescript/packages/stack-supabase/src/query-builder.ts b/languages/typescript/packages/stack-supabase/src/query-builder.ts index 2974066f37..2a45fbdac2 100644 --- a/languages/typescript/packages/stack-supabase/src/query-builder.ts +++ b/languages/typescript/packages/stack-supabase/src/query-builder.ts @@ -1,6 +1,7 @@ import type { AuditConfig } from '@cipherstash/stack/adapter-kit' import { describeFailure, + likeWildcardError, logger, parseSelectorSegments, reconstructSelectorDocument, @@ -899,9 +900,7 @@ export class EncryptedQueryBuilderImpl< const { needle, hasUnsupportedWildcard } = parseLikeNeedle(pattern) if (hasUnsupportedWildcard) { throw new Error( - // The pattern is the caller's plaintext search term: name its length, - // never its content (CIP-5161). - `[supabase v3]: "${op}" pattern (${[...pattern].length} characters) on encrypted column "${column}" has wildcards fuzzy free-text matching cannot honor (an unescaped internal "%" or any unescaped "_"). Use matches("${column}", term) with a literal search term.`, + `[supabase v3]: "${op}" on encrypted column "${column}": ${likeWildcardError(pattern)} Escape a literal "%" or "_" as "\\%" or "\\_", or use matches("${column}", term) with a literal search term.`, ) } const key = `${op}:${column}` diff --git a/languages/typescript/packages/stack/__tests__/describe-failure.test.ts b/languages/typescript/packages/stack/__tests__/describe-failure.test.ts index 1f7857fb82..9a1f4ff0ff 100644 --- a/languages/typescript/packages/stack/__tests__/describe-failure.test.ts +++ b/languages/typescript/packages/stack/__tests__/describe-failure.test.ts @@ -1,16 +1,22 @@ +import { PROTECT_ERROR_CODES } from '@cipherstash/protect-ffi' import { describe, expect, it } from 'vitest' -import { describeFailure } from '@/utils/logger/describe-failure' +import { + describeFailure, + FFI_ERROR_CODES, +} from '@/utils/logger/describe-failure' // `describeFailure` is what adapters log in place of an error's message, which -// may quote a plaintext value (CIP-5161). It must name the failure's kind and -// fixed identifiers, and nothing a caller supplied. +// may quote a plaintext value (CIP-5161). Everything it returns must come from +// a fixed allowlist, never from the error, however identifier-like the error's +// fields look. describe('describeFailure', () => { it('names an Error by its class, never its message', () => { - const out = describeFailure(new TypeError('value "Qzx" is bad')) - expect(out).toBe('TypeError') + expect(describeFailure(new TypeError('value "Qzx" is bad'))).toBe( + 'TypeError', + ) }) - it('appends the type, code and authCode identifiers', () => { + it('appends known type, code and authCode identifiers', () => { const err = Object.assign(new Error('boom Qzx'), { type: 'EncryptionError', code: 'UNKNOWN_COLUMN', @@ -35,21 +41,22 @@ describe('describeFailure', () => { ) }) - it('keeps the code of a non-Error failure, such as a PostgREST error object', () => { - expect(describeFailure({ code: '42883', message: 'Qzx' })).toBe( - 'object: 42883', - ) - expect(describeFailure({ code: 503 })).toBe('object: 503') - }) - - it('drops an identifier field that carries data instead of an identifier', () => { - const err = Object.assign(new Error('x'), { - code: 'value "Qzx" is not allowed', - type: 'a'.repeat(65), + // An identifier-shaped field can still hold caller data, so the shape is not + // the test: only known values pass. + it('drops identifier-shaped values that are not known codes', () => { + const err = Object.assign(new Error('invalid value'), { + code: 'Qzx-secret-wvy', + type: 'alice.smith', + authCode: '123-45-6789', }) expect(describeFailure(err)).toBe('Error') + expect(describeFailure({ code: 'Qzx_secret', message: 'x' })).toBe('object') + expect(describeFailure({ code: 42883 })).toBe('object') + }) + + it('reports an unknown error class as Error', () => { const renamed = new Error('x') - renamed.name = 'Qzx secret' + renamed.name = 'QzxSecret' expect(describeFailure(renamed)).toBe('Error') }) @@ -58,4 +65,25 @@ describe('describeFailure', () => { expect(describeFailure(null)).toBe('null') expect(describeFailure(undefined)).toBe('undefined') }) + + // Called from inside a catch: if describing the failure threw, Supabase would + // reject instead of returning `{ error }`, and DynamoDB would skip its + // errorHandler. + it('returns a fixed description when reading the error throws', () => { + const err = new Error('x') + Object.defineProperty(err, 'type', { + get() { + throw new Error('getter Qzx') + }, + }) + expect(describeFailure(err)).toBe('unknown failure') + + const { proxy, revoke } = Proxy.revocable({}, {}) + revoke() + expect(describeFailure(proxy)).toBe('unknown failure') + }) + + it('allowlists exactly the FFI error codes protect-ffi declares', () => { + expect([...FFI_ERROR_CODES].sort()).toEqual([...PROTECT_ERROR_CODES].sort()) + }) }) diff --git a/languages/typescript/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts b/languages/typescript/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts index e557e90744..d1838eafd6 100644 --- a/languages/typescript/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts +++ b/languages/typescript/packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts @@ -465,6 +465,36 @@ describe('handleError', () => { expect(logged).not.toContain('Qzx') }) + // `options.logger` is a log sink the caller wires to `console` or a log + // service, so it gets the same log-safe description as the shared logger. + // The full message stays on the returned error and on `errorHandler`'s. + it('hands the configured logger a log-safe error, and errorHandler the full one', () => { + const failure = Object.assign( + new Error('value "Qzx-secret-wvy" rejected'), + { + code: 'UNKNOWN_COLUMN', + }, + ) + const logged: unknown[] = [] + const handled: Error[] = [] + + const error = handleError(failure, 'encryptModel', { + logger: { error: (message, err) => logged.push(message, err) }, + errorHandler: (err) => handled.push(err), + }) + + expect(error.message).toContain('Qzx-secret-wvy') + expect(handled[0]?.message).toContain('Qzx-secret-wvy') + const [message, loggedError] = logged as [string, Error & { code: string }] + expect(message).toContain('encryptModel') + expect(loggedError).toBeInstanceOf(Error) + expect(loggedError.code).toBe('UNKNOWN_COLUMN') + expect( + [message, loggedError.message, String(loggedError.stack)].join('\n'), + ).not.toContain('Qzx') + expect(loggedError.message).toContain('UNKNOWN_COLUMN') + }) + it('extracts the message from an Error, a plain object, or anything else', () => { expect( handleError(new Error('from an Error'), 'decryptModel').message, @@ -482,7 +512,7 @@ describe('handleError', () => { expect(handleError(null, 'decryptModel').message).toBe('null') }) - it('hands the constructed error to both the errorHandler and the caller logger', () => { + it('hands the constructed error to the errorHandler and a log-safe copy to the caller logger', () => { const seen: unknown[] = [] const callerLog = { error: vi.fn() } @@ -497,7 +527,16 @@ describe('handleError', () => { // classified one. expect(seen).toHaveLength(1) expect(seen[0]).toBe(error) - expect(callerLog.error).toHaveBeenCalledWith('Error in decryptModel', error) + // The logger gets a copy with the same code but a log-safe message + // (CIP-5161), not the caller's error. + expect(callerLog.error).toHaveBeenCalledWith( + 'Error in decryptModel', + expect.objectContaining({ + code: error.code, + message: expect.stringContaining('DynamoDB error in decryptModel'), + }), + ) + expect(callerLog.error.mock.calls[0][1]).not.toBe(error) expect(errorLog).toHaveBeenCalledWith( expect.stringContaining('DynamoDB error in decryptModel'), ) diff --git a/languages/typescript/packages/stack/__tests__/match-needle-guard.test.ts b/languages/typescript/packages/stack/__tests__/match-needle-guard.test.ts index 65c16b03c2..5fca1c0862 100644 --- a/languages/typescript/packages/stack/__tests__/match-needle-guard.test.ts +++ b/languages/typescript/packages/stack/__tests__/match-needle-guard.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import type { MatchIndexOpts } from '@/schema/internal' -import { matchNeedleError, matchNeedleMinLength } from '@/schema/match-defaults' +import { + likeWildcardError, + matchNeedleError, + matchNeedleMinLength, +} from '@/schema/match-defaults' // The floor a free-text needle must clear before it yields any ngram. A needle // below it tokenizes to nothing, so its bloom filter is EMPTY — and @@ -103,3 +107,16 @@ describe('matchNeedleError', () => { expect(matchNeedleError(null, ngram)).toBeUndefined() }) }) + +// Shared by the Supabase and Prisma like/ilike shims so neither can quote the +// pattern (CIP-5161): it is the caller's plaintext search term. +describe('likeWildcardError', () => { + it('names the wildcard problem and the pattern length in codepoints, never the pattern', () => { + for (const pattern of ['Qzx%wvy', 'Qzx_wvy', '\u{1F512}%Qz']) { + const reason = likeWildcardError(pattern) + expect(reason, pattern).toMatch(/cannot honor/) + expect(reason, pattern).toContain(`${[...pattern].length}-character`) + expect(reason, pattern).not.toContain('Qz') + } + }) +}) diff --git a/languages/typescript/packages/stack/src/adapter-kit.ts b/languages/typescript/packages/stack/src/adapter-kit.ts index 12aae603b6..43e9566b3c 100644 --- a/languages/typescript/packages/stack/src/adapter-kit.ts +++ b/languages/typescript/packages/stack/src/adapter-kit.ts @@ -65,7 +65,10 @@ export { unsupportedLeafReason, } from './eql/v3/selector-path.js' // Shared match-index guard (short-needle rejection), reused by both adapters. -export { matchNeedleError } from './schema/match-defaults.js' +export { + likeWildcardError, + matchNeedleError, +} from './schema/match-defaults.js' // The canonical EQL v2/v3 table discriminator. Added because the adapters must // make the same routing decision core does, and that function's own doctrine is // that every site routes through it — "a second hand-written spelling of the diff --git a/languages/typescript/packages/stack/src/dynamodb/helpers.ts b/languages/typescript/packages/stack/src/dynamodb/helpers.ts index 2c5a8fc8bf..3990d56f1b 100644 --- a/languages/typescript/packages/stack/src/dynamodb/helpers.ts +++ b/languages/typescript/packages/stack/src/dynamodb/helpers.ts @@ -70,15 +70,22 @@ export function handleError( }) // Log the failure's kind, never its message: it may quote a plaintext value - // (CIP-5161). The caller gets the full message on the returned error. - logger.error(`DynamoDB error in ${context} (${describeFailure(error)}).`) + // (CIP-5161). `options.logger` is a log sink too (typically `console`), so it + // gets a copy of the error carrying the same description instead of the + // message. The full message stays on the returned error and reaches + // `errorHandler`, which hands it back to the caller. + const description = `DynamoDB error in ${context} (${describeFailure(error)}).` + logger.error(description) if (options?.errorHandler) { options.errorHandler(dynamoError) } if (options?.logger) { - options.logger.error(`Error in ${context}`, dynamoError) + options.logger.error( + `Error in ${context}`, + new EncryptedDynamoDBErrorImpl(description, errorCode, { context }), + ) } return dynamoError diff --git a/languages/typescript/packages/stack/src/schema/match-defaults.ts b/languages/typescript/packages/stack/src/schema/match-defaults.ts index d08092f259..5755af8c85 100644 --- a/languages/typescript/packages/stack/src/schema/match-defaults.ts +++ b/languages/typescript/packages/stack/src/schema/match-defaults.ts @@ -127,10 +127,11 @@ export function matchNeedleMinLength(opts: MatchIndexOpts): number | undefined { * measuring what its tokenizer actually receives: v2 needles carry SQL wildcards * (`'%ada%'`), so the floor may have to apply to the unwrapped term rather than * to the string the caller passed. + * + * The message never quotes the needle: it is the caller's plaintext search + * term, and every adapter throws this reason to its caller, who may log it. It + * reports the length, which is what the caller needs to fix the query. */ -// The message never quotes the needle: it is the caller's plaintext search -// term, and every adapter throws this reason to its caller (who may log it). -// Report the length, which is what the caller needs to fix the query. export function matchNeedleError( needle: unknown, opts: MatchIndexOpts, @@ -147,3 +148,17 @@ export function matchNeedleError( if (min === undefined || length >= min) return undefined return `free-text search needs at least ${min} characters (the index tokenizer's token_length), but the search term has ${length}. A shorter term produces no tokens and would match every row.` } + +/** + * The reason a free-text `like`/`ilike` pattern was rejected for carrying a + * wildcard the bloom tokenizer cannot honour (an interior `%` or a `_`, which it + * would treat as an ordinary character and so silently match nothing). + * + * Takes the pattern rather than its length so no caller can be tempted to + * interpolate it: the pattern is the caller's plaintext search term, and the + * message is thrown to callers who may log it. Only its length appears. + * Adapters prefix their surface and append their own remedy. + */ +export function likeWildcardError(pattern: string): string { + return `the ${[...pattern].length}-character search pattern has wildcards fuzzy free-text matching cannot honor (an interior "%" or a "_").` +} diff --git a/languages/typescript/packages/stack/src/utils/logger/describe-failure.ts b/languages/typescript/packages/stack/src/utils/logger/describe-failure.ts index 28c8a6bc26..7107b65299 100644 --- a/languages/typescript/packages/stack/src/utils/logger/describe-failure.ts +++ b/languages/typescript/packages/stack/src/utils/logger/describe-failure.ts @@ -1,42 +1,119 @@ +// Type-only imports: adapter-kit must stay importable without loading the +// native binding or the auth module, so the allowlists below are written out +// and the types prove them complete (`describe-failure.test.ts` checks the FFI +// list against the runtime one). +import type { AuthErrorCode } from '@cipherstash/auth' +import type { ProtectErrorCode } from '@cipherstash/protect-ffi' +import { EncryptionErrorTypes } from '@/errors' + /** * A log-safe description of a failure, for an adapter's error log. * * An adapter's catch sees errors from every layer below it (query validation, * encryption, the database client, the network), and any of their messages may * quote a plaintext value. The library promises never to log plaintext, so an - * error log names the failure's KIND, never its message: the error's class plus - * the fixed identifiers libraries attach to it. The full message still goes back - * to the caller, who owns the decision to log it. + * error log names the failure's KIND, never its message. + * + * Everything in the result comes from a fixed allowlist, never from the error: * - * The identifiers read are `type`, `code` and `authCode`, from the error itself - * and from a nested `encryptionError` (the shape the Supabase adapter's - * `EncryptionFailedError` carries). Each must look like an identifier — word - * characters, `.` or `-`, at most 64 — so a library that puts data into one of - * these fields cannot smuggle it into the log. + * - the error class, when it is a built-in JS error or one of this library's + * error classes, else `Error` (or `typeof` for a non-Error value); + * - the error's `type`, `code` and `authCode`, and those of a nested + * `encryptionError` (the Supabase adapter's `EncryptionFailedError`), when + * each is a known {@link EncryptionErrorTypes} value, FFI error code + * (`PROTECT_ERROR_CODES`) or auth code (`AuthErrorCode`). + * + * Any other value is dropped, however harmless it looks: a field that merely + * looks like an identifier can still hold caller data. If reading the error + * throws (a throwing getter, a revoked proxy), the result is `unknown failure`, + * so describing a failure never becomes a second failure. * * @example - * describeFailure(new TypeError('value "x" is bad')) // 'TypeError' - * describeFailure({ code: '42883', message: '…' }) // 'object: 42883' + * describeFailure(new TypeError('value "x" is bad')) // 'TypeError' + * describeFailure(Object.assign(new Error('…'), { code: 'UNKNOWN_COLUMN' })) + * // 'Error: UNKNOWN_COLUMN' */ export function describeFailure(err: unknown): string { - const kind = - err instanceof Error - ? (safeIdentifier(err.name) ?? 'Error') - : err === null - ? 'null' - : typeof err - const ids = [ - ...identifiersOf(err), - ...identifiersOf(fieldOf(err, 'encryptionError')), - ] - return [kind, ...new Set(ids)].join(': ') + try { + const ids = [ + ...identifiersOf(err), + ...identifiersOf(fieldOf(err, 'encryptionError')), + ] + return [kindOf(err), ...new Set(ids)].join(': ') + } catch { + return 'unknown failure' + } +} + +const ERROR_CLASSES: ReadonlySet = new Set([ + 'Error', + 'AggregateError', + 'EvalError', + 'RangeError', + 'ReferenceError', + 'SyntaxError', + 'TypeError', + 'URIError', + // This library's own error classes. + 'EncryptedDynamoDBError', + 'EncryptionFailedError', +]) + +/** Every FFI error code. `describe-failure.test.ts` pins it to the runtime list. */ +export const FFI_ERROR_CODES = [ + 'INVARIANT_VIOLATION', + 'UNKNOWN_QUERY_OP', + 'UNKNOWN_COLUMN', + 'MISSING_INDEX', + 'INVALID_QUERY_INPUT', + 'SHORT_MATCH_NEEDLE', + 'INVALID_JSON_PATH', + 'STE_VEC_REQUIRES_JSON_CAST_AS', + 'MATCH_REQUIRES_TEXT', + 'UNSUPPORTED_CONFIG_VERSION', + 'INVALID_EQL_VERSION', + 'EQL_V3_UNSUPPORTED_COLUMN', + 'EQL_V3_CONVERSION_FAILED', + 'INVALID_CIPHERTEXT', + 'UNKNOWN', +] as const satisfies readonly ProtectErrorCode[] + +// A `Record` over the union, so the compiler rejects a missing or unknown code. +const AUTH_ERROR_CODES: Record = { + REQUEST_ERROR: true, + ACCESS_DENIED: true, + EXPIRED_TOKEN: true, + INVALID_GRANT: true, + INVALID_CLIENT: true, + INVALID_URL: true, + INVALID_REGION: true, + INVALID_TOKEN: true, + USAGE_LIMIT_EXCEEDED: true, + ORG_NOT_PROVISIONED: true, + SERVER_ERROR: true, + NOT_AUTHENTICATED: true, + MISSING_WORKSPACE_CRN: true, + INVALID_ACCESS_KEY: true, + INVALID_CRN: true, + WORKSPACE_MISMATCH: true, + INVALID_WORKSPACE_ID: true, + ALREADY_CONSUMED: true, + INTERNAL_ERROR: true, + CUSTOM: true, + STORE_ERROR: true, } -const IDENTIFIER = /^[\w.-]{1,64}$/ +const KNOWN_IDENTIFIERS: ReadonlySet = new Set([ + ...Object.values(EncryptionErrorTypes), + ...FFI_ERROR_CODES, + ...Object.keys(AUTH_ERROR_CODES), +]) -function safeIdentifier(value: unknown): string | undefined { - if (typeof value === 'number' && Number.isFinite(value)) return String(value) - return typeof value === 'string' && IDENTIFIER.test(value) ? value : undefined +function kindOf(err: unknown): string { + if (err instanceof Error) { + return ERROR_CLASSES.has(err.name) ? err.name : 'Error' + } + return err === null ? 'null' : typeof err } function fieldOf(value: unknown, key: string): unknown { @@ -47,6 +124,8 @@ function fieldOf(value: unknown, key: string): unknown { function identifiersOf(value: unknown): string[] { return (['type', 'code', 'authCode'] as const) - .map((key) => safeIdentifier(fieldOf(value, key))) - .filter((id): id is string => id !== undefined) + .map((key) => fieldOf(value, key)) + .filter( + (id): id is string => typeof id === 'string' && KNOWN_IDENTIFIERS.has(id), + ) } diff --git a/skills/stash-dynamodb/SKILL.md b/skills/stash-dynamodb/SKILL.md index 6facd6f96b..d2c6223aa8 100644 --- a/skills/stash-dynamodb/SKILL.md +++ b/skills/stash-dynamodb/SKILL.md @@ -181,12 +181,16 @@ const decrypted = await dynamo.decryptModel( const dynamo = encryptedDynamoDB({ encryptionClient, options: { + // Receives a log-safe copy of the error: its message names the failure's + // kind and code, never the original message. logger: { error: (message, error) => console.error(`[DynamoDB] ${message}`, error), }, + // Receives the full error. Its message can quote a plaintext value, so + // log `error.code`, not `error.message`. errorHandler: (error) => { // Send to monitoring, etc. - console.error(`[${error.code}] ${error.message}`) + console.error(`[DynamoDB] ${error.code}`) }, }, }) From 1eb3135a642abe6026ce18e1899922648aaec3ac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 01:34:48 +0000 Subject: [PATCH 4/5] docs(changeset): limit the privacy claim to the changed errors and logs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7pT73uHWFP7RKEikuh --- .changeset/no-plaintext-search-terms-in-errors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/no-plaintext-search-terms-in-errors.md b/.changeset/no-plaintext-search-terms-in-errors.md index 816e36c57d..bd63d45452 100644 --- a/.changeset/no-plaintext-search-terms-in-errors.md +++ b/.changeset/no-plaintext-search-terms-in-errors.md @@ -5,6 +5,6 @@ 'stash': patch --- -Search terms no longer appear in error messages or logs. A free-text search term that is too short, or a `like`/`ilike` pattern with wildcards the encrypted search cannot honour, is now reported by its length instead of being quoted. +Two validation errors no longer quote the caller's search term: a free-text search term that is too short, and a `like`/`ilike` pattern with wildcards the encrypted search cannot honour. Each now reports the term's length instead. The Supabase and DynamoDB adapters' error logs now record a failure's kind instead of its full message, which could quote a plaintext value. The kind is the error class plus any known encryption error type, FFI error code or auth code it carries; anything else is left out. The DynamoDB `options.logger` hook now receives a copy of the error whose message is that description, with the same `code`. The full message is still returned to the caller and passed to `errorHandler`. `@cipherstash/stack/adapter-kit` exports the shared `describeFailure` and `likeWildcardError` helpers that produce these messages. From b29eaa183b93b4fdb50e70cf1e8ef9fa5539f2a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 01:37:50 +0000 Subject: [PATCH 5/5] test(stack-supabase): assert the ciphertext error log carries the failure kind main's plaintext-table test (from the CIP-5151 fix) expected the error log to carry the row path "[0].meta". This branch stops logging the error message at all (CIP-5161), so the log now names the failure kind (DecryptionError) instead. The assertion that matters, that the jsonb key never reaches the log, still holds; the path is still in the error returned to the caller. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7pT73uHWFP7RKEikuh --- .../__tests__/supabase-plaintext-table.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/languages/typescript/packages/stack-supabase/__tests__/supabase-plaintext-table.test.ts b/languages/typescript/packages/stack-supabase/__tests__/supabase-plaintext-table.test.ts index eab1a26c82..de14d807cf 100644 --- a/languages/typescript/packages/stack-supabase/__tests__/supabase-plaintext-table.test.ts +++ b/languages/typescript/packages/stack-supabase/__tests__/supabase-plaintext-table.test.ts @@ -262,10 +262,11 @@ describe('plaintext table rows that carry ciphertext', () => { expect(error?.message).toContain('"[0].meta"') expect(error?.message).not.toContain('ssn-078-05-1120') - // The message reaches the logger too, so the log must not carry it either. + // The log names the failure's kind, never the message (CIP-5161), so it + // carries neither the key nor the row path. expect(logged).toHaveBeenCalled() for (const args of logged.mock.calls) { - expect(args.join(' ')).toContain('"[0].meta"') + expect(args.join(' ')).toContain('DecryptionError') expect(args.join(' ')).not.toContain('ssn-078-05-1120') } } finally {