Skip to content

Commit 0ebf57e

Browse files
committed
fix(stack): close v3 drizzle fail-open paths, fix false-negative tests
An audit of the EQL v3 Drizzle adapter found the index/query-type wiring correct, but several tests unable to detect the bug they guard. Three stayed green under the exact regression they existed to catch, and two source paths failed open. Source: - `contains` now rejects a needle shorter than the tokenizer's `token_length`. A short needle builds an EMPTY bloom filter and `stored_bf @> '{}'` holds for every row. Measured live: needles "ad", "a" and "x" each returned 3/3 seeded rows -- "x" occurs in none of them. The floor lives in `schema/match-defaults` so v2 and v3 share it; they build byte-identical blooms. - `v3FromDriver` throws the new `EqlV3CodecError` rather than surfacing a raw SyntaxError on malformed JSON or passing a wrong-shape payload through: `v3FromDriver('5')` returned `5` typed as `Encrypted`. The shape guard accepts SteVec documents, whose ciphertext is at `sv[0].c` with no top-level `c`. - Removed the unreachable `sql`false`` fallback in `inArray`/`notInArray`; the empty-list guard throws first. Tests (each verified RED against the bug it guards, then reverted): - `and` was indistinguishable from `or`: both predicates were true for ROW_B alone, so swapping the operator still passed. Now paired over disjoint rows and asserted from both directions. - bigint filters ran against a one-row table, where `gt` returning every row passed. Seeded a negative row they must exclude. - `between` was only ever called with identical bounds against a constant encrypt stub, so a min/max transposition in `range` was invisible. Added an echoing stub that pins operand order. - `contains` had no needle that must NOT match, and none longer than `token_length`. The unit test searched `TEXT_S[0]` -- the empty string -- i.e. it asserted the fail-open. Now drives four needles through a substring oracle, including a negative one. - Seeded a third matrix row. With two, every predicate could only return [A], [B], [A,B] or [] -- ordering ties were untestable and `eq` over-matching undetectable. Row `i` takes `samples[min(i, len-1)]`, the scheme `v3-matrix/matrix-live-pg` already uses. Lock context: The suite supplied the same context on seed and query in every test, so a regression dropping it from the index term would drop it identically on both sides and stay green. Added the negatives obtainable with one JWT: an identity-bound row must not match an `eq` issued with no lock context, and must not decrypt without it. A cross-identity proof needs a second JWT with a different `sub` and remains a follow-up. `lock-context.test.ts` wrapped `decryptModel` in try/catch, but it reports denial as a `Result` and never throws -- the test ran zero assertions and would also have passed had decryption wrongly succeeded. Smaller pins: sql-dialect asserts operands BIND rather than interpolate; schema-extraction uses toStrictEqual; the barrel is pinned exhaustively; codec fixtures are realistic envelopes; the column's customType mappers are exercised. Not included, per plan: the SQL `bloom_filter` empty-array change (contradicts a documented semantic; needs sign-off) and the Supabase needle guard (lives on another branch).
1 parent 222be16 commit 0ebf57e

14 files changed

Lines changed: 704 additions & 113 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@cipherstash/stack': minor
3+
---
4+
5+
Close two fail-open paths in the EQL v3 Drizzle adapter.
6+
7+
`ops.contains()` now throws `EncryptionOperatorError` when the search term is
8+
shorter than the match index tokenizer's `token_length` (3 by default). Such a
9+
term produces no ngrams, so its bloom filter is empty — and `stored_bf @> '{}'`
10+
is true for every row. Previously a user searching `"ad"` silently received the
11+
entire table; measured live, the needles `"ad"`, `"a"` and `"x"` each returned
12+
every seeded row, including one in which `"x"` did not appear. The length floor
13+
is shared with the v2 adapter via `matchNeedleError` in `schema/match-defaults`,
14+
since both build byte-identical bloom filters.
15+
16+
`v3FromDriver()` now throws the new `EqlV3CodecError` on a payload that is not
17+
an EQL envelope, instead of surfacing a raw `SyntaxError` for malformed JSON and
18+
passing a bare scalar through unchecked — `v3FromDriver('5')` previously returned
19+
`5` typed as `Encrypted`, which then reached `decrypt` as garbage. The guard
20+
accepts both scalar envelopes (ciphertext at `c`) and SteVec documents
21+
(ciphertext at `sv[0].c`). `EqlV3CodecError` is exported from
22+
`@cipherstash/stack/eql/v3/drizzle` so callers can catch it.
23+
24+
Also removes an unreachable branch in `inArray`/`notInArray`, whose empty-list
25+
guard already throws before it.
Lines changed: 86 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,62 @@
11
import { describe, expect, it } from 'vitest'
2-
import { v3FromDriver, v3ToDriver } from '@/eql/v3/drizzle/codec'
2+
import {
3+
EqlV3CodecError,
4+
v3FromDriver,
5+
v3ToDriver,
6+
} from '@/eql/v3/drizzle/codec'
7+
8+
// A realistic `public.text_eq` envelope: schema version, table/column
9+
// identifier, mp_base85 ciphertext, HMAC term. The trivial `{v:1,c:'ct'}`
10+
// fixture this replaced could not distinguish an envelope from a bare object.
11+
const ENVELOPE = {
12+
v: 3,
13+
i: { t: 'users', c: 'email' },
14+
c: 'mBbKmbNmNhX@Vv0N%<Ke7fH(=Zc*d4rDy0h3~yPZLq!kGm8x7$2Fw<TnR>1jQvA',
15+
hm: '3a1f9c0e5b7d2a48e6c1f0b93d7a2e58c4b6f19d0e3a7c25b8f14d69a0c3e7b2',
16+
} as const
17+
18+
// SteVec documents carry the root ciphertext at `sv[0].c` and have no top-level
19+
// `c` — the envelope guard must accept them.
20+
const STE_VEC_ENVELOPE = {
21+
v: 3,
22+
k: 'sv',
23+
i: { t: 'users', c: 'profile' },
24+
sv: [{ c: 'mBbKciphertext', s: 'selector', b: 'term' }],
25+
} as const
326

427
describe('v3 codec', () => {
5-
it('serialises an object to a jsonb string', () => {
6-
expect(v3ToDriver({ v: 1, c: 'ct' })).toBe('{"v":1,"c":"ct"}')
28+
it('serialises an envelope to a jsonb string', () => {
29+
expect(v3ToDriver(ENVELOPE as never)).toBe(JSON.stringify(ENVELOPE))
730
})
831

932
it('maps null/undefined to SQL NULL (JS null), never the JSON null literal', () => {
1033
expect(v3ToDriver(null)).toBeNull()
1134
expect(v3ToDriver(undefined)).toBeNull()
1235
})
1336

14-
it('parses a jsonb string back to an object', () => {
15-
expect(v3FromDriver('{"v":1,"c":"ct"}')).toEqual({ v: 1, c: 'ct' })
37+
it('round-trips a realistic envelope through both directions', () => {
38+
const serialised = v3ToDriver(ENVELOPE as never)
39+
expect(v3FromDriver(serialised)).toEqual(ENVELOPE)
40+
})
41+
42+
it('round-trips unicode and a long ciphertext', () => {
43+
const envelope = {
44+
...ENVELOPE,
45+
i: { t: 'users', c: 'café_☕' },
46+
c: 'z'.repeat(8192),
47+
}
48+
expect(v3FromDriver(v3ToDriver(envelope as never))).toEqual(envelope)
49+
})
50+
51+
it('passes an already-parsed envelope through unchanged', () => {
52+
expect(v3FromDriver(ENVELOPE as never)).toBe(ENVELOPE)
1653
})
1754

18-
it('passes an already-parsed object through unchanged', () => {
19-
const obj = { v: 1 }
20-
expect(v3FromDriver(obj)).toBe(obj)
55+
it('accepts a SteVec document, whose ciphertext lives at sv[0].c', () => {
56+
expect(v3FromDriver(STE_VEC_ENVELOPE as never)).toBe(STE_VEC_ENVELOPE)
57+
expect(v3FromDriver(JSON.stringify(STE_VEC_ENVELOPE))).toEqual(
58+
STE_VEC_ENVELOPE,
59+
)
2160
})
2261

2362
it('normalises null/undefined to SQL NULL (JS null) on read', () => {
@@ -26,6 +65,44 @@ describe('v3 codec', () => {
2665
})
2766

2867
it('does not throw on a stray bigint in the envelope (defensive)', () => {
29-
expect(v3ToDriver({ v: 1n } as never)).toBe('{"v":"1"}')
68+
expect(v3ToDriver({ ...ENVELOPE, v: 3n } as never)).toContain('"v":"3"')
69+
})
70+
71+
// Before these, a malformed string surfaced a raw SyntaxError and a
72+
// wrong-shape payload was returned as-is — `v3FromDriver('5')` yielded `5`
73+
// typed as an envelope, which then reached `decrypt` as garbage.
74+
describe('malformed and non-envelope payloads', () => {
75+
it.each([
76+
'{bad',
77+
'',
78+
'{"v":3,',
79+
])('throws EqlV3CodecError on unparseable jsonb %j', (raw) => {
80+
expect(() => v3FromDriver(raw)).toThrow(EqlV3CodecError)
81+
expect(() => v3FromDriver(raw)).toThrow(/Failed to parse/)
82+
})
83+
84+
it.each([
85+
'5',
86+
'"a string"',
87+
'true',
88+
'[]',
89+
'[{"v":3,"c":"ct"}]',
90+
])('throws on valid JSON %j that is not an envelope', (raw) => {
91+
expect(() => v3FromDriver(raw)).toThrow(EqlV3CodecError)
92+
})
93+
94+
it('throws on an object missing the schema version', () => {
95+
expect(() => v3FromDriver({ c: 'ct' })).toThrow(/missing "v"/)
96+
})
97+
98+
it('throws on an object carrying no ciphertext', () => {
99+
expect(() => v3FromDriver({ v: 3, i: { t: 'u', c: 'e' } })).toThrow(
100+
/missing a ciphertext/,
101+
)
102+
})
103+
104+
it('throws on an array', () => {
105+
expect(() => v3FromDriver([ENVELOPE] as never)).toThrow(/got an array/)
106+
})
30107
})
31108
})

packages/stack/__tests__/drizzle-v3/column.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,45 @@ describe('makeEqlV3Column', () => {
133133
expect(pgColumn?.getSQLType()).toBe(eqlType)
134134
expect(getEqlV3Column(columnName, pgColumn)?.getEqlType()).toBe(eqlType)
135135
})
136+
137+
// The `toDriver`/`fromDriver` closures wired into `customType` (column.ts) are
138+
// otherwise only tested via the standalone codec functions, so the wiring
139+
// itself — including the SQL-NULL safety net — was never exercised.
140+
describe('codec wiring on the built column', () => {
141+
type Mapper = {
142+
mapToDriverValue(value: unknown): unknown
143+
mapFromDriverValue(value: unknown): unknown
144+
}
145+
const mapperFor = (name: string): Mapper => {
146+
const table = pgTable('users', {
147+
[name]: makeEqlV3Column(v3Types.TextEq(name)),
148+
} as never)
149+
return (table as unknown as Record<string, Mapper>)[name]
150+
}
151+
152+
const ENVELOPE = {
153+
v: 3,
154+
i: { t: 'users', c: 'nickname' },
155+
c: 'mBbKciphertext',
156+
hm: 'hmac',
157+
}
158+
159+
it('serialises an envelope on write and parses it back on read', () => {
160+
const column = mapperFor('nickname')
161+
const driverValue = column.mapToDriverValue(ENVELOPE)
162+
163+
expect(driverValue).toBe(JSON.stringify(ENVELOPE))
164+
expect(column.mapFromDriverValue(driverValue)).toEqual(ENVELOPE)
165+
})
166+
167+
it('maps a null envelope to SQL NULL on write', () => {
168+
expect(mapperFor('nickname').mapToDriverValue(null)).toBeNull()
169+
})
170+
171+
it('rejects a non-envelope driver value rather than passing it through', () => {
172+
expect(() => mapperFor('nickname').mapFromDriverValue('5')).toThrow(
173+
/EQL encrypted envelope/,
174+
)
175+
})
176+
})
136177
})
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,41 @@
11
import { describe, expect, it } from 'vitest'
22
import * as barrel from '@/eql/v3/drizzle'
33

4+
// Exhaustive, so ADDING an export fails here too. The previous version asserted
5+
// only four names, leaving the codec/column re-exports unpinned and letting a
6+
// new export slip into the public surface unnoticed.
7+
const PUBLIC_SURFACE = [
8+
'EncryptionOperatorError',
9+
'EqlV3CodecError',
10+
'createEncryptionOperatorsV3',
11+
'extractEncryptionSchemaV3',
12+
'getEqlV3Column',
13+
'isEqlV3Column',
14+
'makeEqlV3Column',
15+
'types',
16+
'v3FromDriver',
17+
'v3ToDriver',
18+
] as const
19+
420
describe('v3 drizzle barrel', () => {
21+
it('exports exactly the public surface', () => {
22+
expect(Object.keys(barrel).sort()).toEqual([...PUBLIC_SURFACE])
23+
})
24+
525
it('exports the public surface', () => {
626
expect(typeof barrel.createEncryptionOperatorsV3).toBe('function')
727
expect(typeof barrel.extractEncryptionSchemaV3).toBe('function')
828
expect(typeof barrel.EncryptionOperatorError).toBe('function')
29+
expect(typeof barrel.EqlV3CodecError).toBe('function')
930
expect(typeof barrel.types.TextSearch).toBe('function')
1031
expect(barrel.types.IntegerOrd('age')).toBeDefined()
1132
})
33+
34+
it('re-exports the codec and column helpers as callables', () => {
35+
expect(typeof barrel.v3ToDriver).toBe('function')
36+
expect(typeof barrel.v3FromDriver).toBe('function')
37+
expect(typeof barrel.getEqlV3Column).toBe('function')
38+
expect(typeof barrel.isEqlV3Column).toBe('function')
39+
expect(typeof barrel.makeEqlV3Column).toBe('function')
40+
})
1241
})

0 commit comments

Comments
 (0)