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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,8 @@ describe('cipherstash v3 operator lowering — eqlMatch needle guards', () => {
// 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')
expect(() => lowerMatch(needle), needle).toThrow(/cannot honor/)
expect(() => lowerMatch(needle), needle).not.toThrow(/Qzx|wvy/)
}
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,9 @@ describe('plaintext search terms stay out of errors and logs', () => {

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')
const like = () => builder().select('id').like('email', pattern)
expect(like, pattern).toThrow(/cannot honor/)
expect(like, pattern).not.toThrow(/Qzx|wvy/)
}
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -599,10 +599,7 @@ 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`.
// Log-safe: see describeFailure. The full message goes back in `error`.
logger.error(
`Supabase encrypted query failed on table "${this.tableName}" (${describeFailure(err)}).`,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import { PROTECT_ERROR_CODES } from '@cipherstash/protect-ffi'
import { describe, expect, it } from 'vitest'
import {
describeFailure,
FFI_ERROR_CODES,
} from '@/utils/logger/describe-failure'
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). Everything it returns must come from
Expand Down Expand Up @@ -82,8 +78,4 @@ describe('describeFailure', () => {
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())
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -448,15 +448,13 @@ describe('handleError', () => {

// 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 leakyFailure = () =>
Object.assign(new Error('value "Qzx-secret-wvy" rejected'), {
code: 'UNKNOWN_COLUMN',
})

const error = handleError(failure, 'encryptModel')
it('logs the failure kind and code, never the message', () => {
const error = handleError(leakyFailure(), 'encryptModel')

expect(error.message).toContain('Qzx-secret-wvy')
const logged = errorLog.mock.calls.flat().join('\n')
Expand All @@ -469,16 +467,10 @@ describe('handleError', () => {
// 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', {
const error = handleError(leakyFailure(), 'encryptModel', {
logger: { error: (message, err) => logged.push(message, err) },
errorHandler: (err) => handled.push(err),
})
Expand Down
8 changes: 3 additions & 5 deletions languages/typescript/packages/stack/src/dynamodb/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,9 @@ export function handleError(
context,
})

// 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.
// Log-safe: see describeFailure. `options.logger` is a log sink too, so it
// gets a copy carrying the description; the full message stays on the
// returned error and reaches `errorHandler`.
const description = `DynamoDB error in ${context} (${describeFailure(error)}).`
logger.error(description)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,8 @@ export function matchNeedleError(
* 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.
* Only the pattern's length appears: it is plaintext, and callers may log the
* message. 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 "_").`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// 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).
// as `Record`s over the code unions, which makes the compiler prove them
// complete.
import type { AuthErrorCode } from '@cipherstash/auth'
import type { ProtectErrorCode } from '@cipherstash/protect-ffi'
import { EncryptionErrorTypes } from '@/errors'
Expand Down Expand Up @@ -45,8 +45,8 @@ export function describeFailure(err: unknown): string {
}
}

// Any other name is reported as plain `Error`.
const ERROR_CLASSES: ReadonlySet<string> = new Set([
'Error',
'AggregateError',
'EvalError',
'RangeError',
Expand All @@ -59,26 +59,25 @@ const ERROR_CLASSES: ReadonlySet<string> = new Set([
'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 each union, so the compiler rejects a missing or unknown code.
const FFI_ERROR_CODES: Record<ProtectErrorCode, true> = {
INVARIANT_VIOLATION: true,
UNKNOWN_QUERY_OP: true,
UNKNOWN_COLUMN: true,
MISSING_INDEX: true,
INVALID_QUERY_INPUT: true,
SHORT_MATCH_NEEDLE: true,
INVALID_JSON_PATH: true,
STE_VEC_REQUIRES_JSON_CAST_AS: true,
MATCH_REQUIRES_TEXT: true,
UNSUPPORTED_CONFIG_VERSION: true,
INVALID_EQL_VERSION: true,
EQL_V3_UNSUPPORTED_COLUMN: true,
EQL_V3_CONVERSION_FAILED: true,
INVALID_CIPHERTEXT: true,
UNKNOWN: true,
}

// A `Record` over the union, so the compiler rejects a missing or unknown code.
const AUTH_ERROR_CODES: Record<AuthErrorCode, true> = {
REQUEST_ERROR: true,
ACCESS_DENIED: true,
Expand All @@ -105,7 +104,7 @@ const AUTH_ERROR_CODES: Record<AuthErrorCode, true> = {

const KNOWN_IDENTIFIERS: ReadonlySet<string> = new Set([
...Object.values(EncryptionErrorTypes),
...FFI_ERROR_CODES,
...Object.keys(FFI_ERROR_CODES),
...Object.keys(AUTH_ERROR_CODES),
])

Expand Down
Loading