Skip to content

Commit c14dd00

Browse files
authored
Merge pull request #160 from cipherstash/audit-interface
feat(protect): audit logging
2 parents 18e5eec + 01fed9e commit c14dd00

20 files changed

+775
-558
lines changed

.changeset/ninety-dogs-sin.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@cipherstash/protect-dynamodb": minor
3+
"@cipherstash/protect": minor
4+
---
5+
6+
Added audit support for all protect and protect-dynamodb interfaces.
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import type { ProtectDynamoDBError } from './types'
2+
import type { EncryptedPayload } from '@cipherstash/protect'
3+
export const ciphertextAttrSuffix = '__source'
4+
export const searchTermAttrSuffix = '__hmac'
5+
6+
export class ProtectDynamoDBErrorImpl
7+
extends Error
8+
implements ProtectDynamoDBError
9+
{
10+
constructor(
11+
message: string,
12+
public code: string,
13+
public details?: Record<string, unknown>,
14+
) {
15+
super(message)
16+
this.name = 'ProtectDynamoDBError'
17+
}
18+
}
19+
20+
export function handleError(
21+
error: Error,
22+
context: string,
23+
options?: {
24+
logger?: {
25+
error: (message: string, error: Error) => void
26+
}
27+
errorHandler?: (error: ProtectDynamoDBError) => void
28+
},
29+
): ProtectDynamoDBError {
30+
const protectError = new ProtectDynamoDBErrorImpl(
31+
error.message,
32+
'PROTECT_DYNAMODB_ERROR',
33+
{ context },
34+
)
35+
36+
if (options?.errorHandler) {
37+
options.errorHandler(protectError)
38+
}
39+
40+
if (options?.logger) {
41+
options.logger.error(`Error in ${context}`, protectError)
42+
}
43+
44+
return protectError
45+
}
46+
47+
export function deepClone<T>(obj: T): T {
48+
if (obj === null || typeof obj !== 'object') {
49+
return obj
50+
}
51+
52+
if (Array.isArray(obj)) {
53+
return obj.map((item) => deepClone(item)) as unknown as T
54+
}
55+
56+
return Object.entries(obj as Record<string, unknown>).reduce(
57+
(acc, [key, value]) => ({
58+
// biome-ignore lint/performance/noAccumulatingSpread: TODO later
59+
...acc,
60+
[key]: deepClone(value),
61+
}),
62+
{} as T,
63+
)
64+
}
65+
66+
export function toEncryptedDynamoItem(
67+
encrypted: Record<string, unknown>,
68+
encryptedAttrs: string[],
69+
): Record<string, unknown> {
70+
function processValue(
71+
attrName: string,
72+
attrValue: unknown,
73+
isNested: boolean,
74+
): Record<string, unknown> {
75+
if (attrValue === null || attrValue === undefined) {
76+
return { [attrName]: attrValue }
77+
}
78+
79+
// Handle encrypted payload
80+
if (
81+
encryptedAttrs.includes(attrName) ||
82+
(isNested &&
83+
typeof attrValue === 'object' &&
84+
'c' in (attrValue as object))
85+
) {
86+
const encryptPayload = attrValue as EncryptedPayload
87+
if (encryptPayload?.c) {
88+
const result: Record<string, unknown> = {}
89+
if (encryptPayload.hm) {
90+
result[`${attrName}${searchTermAttrSuffix}`] = encryptPayload.hm
91+
}
92+
result[`${attrName}${ciphertextAttrSuffix}`] = encryptPayload.c
93+
return result
94+
}
95+
}
96+
97+
// Handle nested objects recursively
98+
if (typeof attrValue === 'object' && !Array.isArray(attrValue)) {
99+
const nestedResult = Object.entries(
100+
attrValue as Record<string, unknown>,
101+
).reduce(
102+
(acc, [key, val]) => {
103+
const processed = processValue(key, val, true)
104+
return Object.assign({}, acc, processed)
105+
},
106+
{} as Record<string, unknown>,
107+
)
108+
return { [attrName]: nestedResult }
109+
}
110+
111+
// Handle non-encrypted values
112+
return { [attrName]: attrValue }
113+
}
114+
115+
return Object.entries(encrypted).reduce(
116+
(putItem, [attrName, attrValue]) => {
117+
const processed = processValue(attrName, attrValue, false)
118+
return Object.assign({}, putItem, processed)
119+
},
120+
{} as Record<string, unknown>,
121+
)
122+
}
123+
124+
export function toItemWithEqlPayloads(
125+
decrypted: Record<string, EncryptedPayload | unknown>,
126+
encryptedAttrs: string[],
127+
): Record<string, unknown> {
128+
function processValue(
129+
attrName: string,
130+
attrValue: unknown,
131+
isNested: boolean,
132+
): Record<string, unknown> {
133+
if (attrValue === null || attrValue === undefined) {
134+
return { [attrName]: attrValue }
135+
}
136+
137+
// Skip HMAC fields
138+
if (attrName.endsWith(searchTermAttrSuffix)) {
139+
return {}
140+
}
141+
142+
// Handle encrypted payload
143+
if (
144+
attrName.endsWith(ciphertextAttrSuffix) &&
145+
(encryptedAttrs.includes(
146+
attrName.slice(0, -ciphertextAttrSuffix.length),
147+
) ||
148+
isNested)
149+
) {
150+
const baseName = attrName.slice(0, -ciphertextAttrSuffix.length)
151+
return {
152+
[baseName]: {
153+
c: attrValue,
154+
bf: null,
155+
hm: null,
156+
i: { c: 'notUsed', t: 'notUsed' },
157+
k: 'notUsed',
158+
ob: null,
159+
v: 2,
160+
},
161+
}
162+
}
163+
164+
// Handle nested objects recursively
165+
if (typeof attrValue === 'object' && !Array.isArray(attrValue)) {
166+
const nestedResult = Object.entries(
167+
attrValue as Record<string, unknown>,
168+
).reduce(
169+
(acc, [key, val]) => {
170+
const processed = processValue(key, val, true)
171+
return Object.assign({}, acc, processed)
172+
},
173+
{} as Record<string, unknown>,
174+
)
175+
return { [attrName]: nestedResult }
176+
}
177+
178+
// Handle non-encrypted values
179+
return { [attrName]: attrValue }
180+
}
181+
182+
return Object.entries(decrypted).reduce(
183+
(formattedItem, [attrName, attrValue]) => {
184+
const processed = processValue(attrName, attrValue, false)
185+
return Object.assign({}, formattedItem, processed)
186+
},
187+
{} as Record<string, unknown>,
188+
)
189+
}

0 commit comments

Comments
 (0)