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..bd63d45452 --- /dev/null +++ b/.changeset/no-plaintext-search-terms-in-errors.md @@ -0,0 +1,10 @@ +--- +'@cipherstash/stack': patch +'@cipherstash/stack-supabase': patch +'@cipherstash/stack-prisma': patch +'stash': patch +--- + +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. 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..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,7 +538,7 @@ 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.`, + `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-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/__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 { diff --git a/languages/typescript/packages/stack-supabase/src/query-builder.ts b/languages/typescript/packages/stack-supabase/src/query-builder.ts index a8b66a5631..eecbe3d3dd 100644 --- a/languages/typescript/packages/stack-supabase/src/query-builder.ts +++ b/languages/typescript/packages/stack-supabase/src/query-builder.ts @@ -1,5 +1,7 @@ import type { AuditConfig } from '@cipherstash/stack/adapter-kit' import { + describeFailure, + likeWildcardError, logger, parseSelectorSegments, reconstructSelectorDocument, @@ -597,8 +599,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 @@ -893,7 +899,7 @@ 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.`, + `[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 new file mode 100644 index 0000000000..9a1f4ff0ff --- /dev/null +++ b/languages/typescript/packages/stack/__tests__/describe-failure.test.ts @@ -0,0 +1,89 @@ +import { PROTECT_ERROR_CODES } from '@cipherstash/protect-ffi' +import { describe, expect, it } from 'vitest' +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). 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', () => { + expect(describeFailure(new TypeError('value "Qzx" is bad'))).toBe( + 'TypeError', + ) + }) + + it('appends known 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', + ) + }) + + // 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 = 'QzxSecret' + 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') + }) + + // 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 4d80c8f6ec..d1838eafd6 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,55 @@ 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') + }) + + // `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, @@ -463,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() } @@ -478,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 664cac7e60..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 @@ -28,9 +32,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 @@ -91,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 a6e858d8f0..7229956723 100644 --- a/languages/typescript/packages/stack/src/adapter-kit.ts +++ b/languages/typescript/packages/stack/src/adapter-kit.ts @@ -69,7 +69,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 @@ -78,5 +81,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..3990d56f1b 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,14 +69,23 @@ 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). `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 1ce3380882..5755af8c85 100644 --- a/languages/typescript/packages/stack/src/schema/match-defaults.ts +++ b/languages/typescript/packages/stack/src/schema/match-defaults.ts @@ -127,6 +127,10 @@ 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. */ export function matchNeedleError( needle: unknown, @@ -142,5 +146,19 @@ 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.` +} + +/** + * 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 new file mode 100644 index 0000000000..7107b65299 --- /dev/null +++ b/languages/typescript/packages/stack/src/utils/logger/describe-failure.ts @@ -0,0 +1,131 @@ +// 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. + * + * Everything in the result comes from a fixed allowlist, never from the error: + * + * - 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(Object.assign(new Error('…'), { code: 'UNKNOWN_COLUMN' })) + * // 'Error: UNKNOWN_COLUMN' + */ +export function describeFailure(err: unknown): string { + 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 KNOWN_IDENTIFIERS: ReadonlySet = new Set([ + ...Object.values(EncryptionErrorTypes), + ...FFI_ERROR_CODES, + ...Object.keys(AUTH_ERROR_CODES), +]) + +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 { + return typeof value === 'object' && value !== null + ? (value as Record)[key] + : undefined +} + +function identifiersOf(value: unknown): string[] { + return (['type', 'code', 'authCode'] as const) + .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 039a880c8e..96884cf90c 100644 --- a/skills/stash-dynamodb/SKILL.md +++ b/skills/stash-dynamodb/SKILL.md @@ -192,12 +192,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}`) }, }, })