dpdp-erasure-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +55 -0
- package/Dockerfile +33 -0
- package/compliance.worker.yaml +64 -0
- package/package.json +41 -0
- package/src/constants/index.ts +1 -0
- package/src/errors/fail.ts +110 -0
- package/src/errors/index.ts +4 -0
- package/src/errors/inferer.ts +166 -0
- package/src/errors/registry.ts +122 -0
- package/src/errors/types.ts +65 -0
- package/src/errors/worker.ts +161 -0
- package/src/index.ts +328 -0
- package/src/lib/crypto/digest.ts +22 -0
- package/src/lib/crypto/encoding.ts +78 -0
- package/src/lib/crypto/index.ts +2 -0
- package/src/lib/index.ts +1 -0
- package/src/modules/bootstrap/index.ts +2 -0
- package/src/modules/bootstrap/integrity.ts +38 -0
- package/src/modules/bootstrap/preflight.ts +296 -0
- package/src/modules/cli/check-integrity.ts +48 -0
- package/src/modules/cli/dry-run.ts +90 -0
- package/src/modules/cli/graph.ts +87 -0
- package/src/modules/cli/index.ts +184 -0
- package/src/modules/cli/init.ts +115 -0
- package/src/modules/cli/inspect.ts +86 -0
- package/src/modules/cli/introspector.ts +117 -0
- package/src/modules/cli/keygen.ts +38 -0
- package/src/modules/cli/scan.ts +126 -0
- package/src/modules/cli/sign.ts +50 -0
- package/src/modules/cli/ui.ts +61 -0
- package/src/modules/cli/verify-schema.ts +31 -0
- package/src/modules/cli/verify.ts +85 -0
- package/src/modules/config/compatibility.ts +271 -0
- package/src/modules/config/index.ts +4 -0
- package/src/modules/config/reader.ts +149 -0
- package/src/modules/config/signature.ts +69 -0
- package/src/modules/config/validation.ts +658 -0
- package/src/modules/crypto/aes.ts +158 -0
- package/src/modules/crypto/envelope.ts +48 -0
- package/src/modules/crypto/hmac.ts +60 -0
- package/src/modules/crypto/index.ts +3 -0
- package/src/modules/db/drift.ts +36 -0
- package/src/modules/db/graph.ts +203 -0
- package/src/modules/db/index.ts +4 -0
- package/src/modules/db/migrations.ts +254 -0
- package/src/modules/db/sql-debug.ts +61 -0
- package/src/modules/engine/blob/index.ts +3 -0
- package/src/modules/engine/blob/s3.ts +455 -0
- package/src/modules/engine/blob/store.ts +236 -0
- package/src/modules/engine/blob/types.ts +44 -0
- package/src/modules/engine/helpers/identity.ts +47 -0
- package/src/modules/engine/helpers/index.ts +4 -0
- package/src/modules/engine/helpers/outbox.ts +118 -0
- package/src/modules/engine/helpers/runtime.ts +115 -0
- package/src/modules/engine/helpers/types.ts +61 -0
- package/src/modules/engine/index.ts +6 -0
- package/src/modules/engine/notifier/config.ts +147 -0
- package/src/modules/engine/notifier/dispatcher.ts +300 -0
- package/src/modules/engine/notifier/index.ts +3 -0
- package/src/modules/engine/notifier/payload.ts +51 -0
- package/src/modules/engine/notifier/reservation.ts +153 -0
- package/src/modules/engine/notifier/types.ts +38 -0
- package/src/modules/engine/shredder.ts +254 -0
- package/src/modules/engine/types.ts +146 -0
- package/src/modules/engine/vault/compiled-targets.ts +562 -0
- package/src/modules/engine/vault/context.ts +254 -0
- package/src/modules/engine/vault/dry-run.ts +94 -0
- package/src/modules/engine/vault/execution.ts +485 -0
- package/src/modules/engine/vault/index.ts +3 -0
- package/src/modules/engine/vault/purge.ts +82 -0
- package/src/modules/engine/vault/retention.ts +124 -0
- package/src/modules/engine/vault/satellite-mutation.ts +193 -0
- package/src/modules/engine/vault/satellite.ts +103 -0
- package/src/modules/engine/vault/shadow.ts +36 -0
- package/src/modules/engine/vault/static-plan.ts +116 -0
- package/src/modules/engine/vault/store.ts +34 -0
- package/src/modules/engine/vault/vault.ts +84 -0
- package/src/modules/introspector/classifier.ts +502 -0
- package/src/modules/introspector/dag.ts +276 -0
- package/src/modules/introspector/index.ts +7 -0
- package/src/modules/introspector/naming.ts +75 -0
- package/src/modules/introspector/report.ts +153 -0
- package/src/modules/introspector/run.ts +123 -0
- package/src/modules/introspector/s3-sampler.ts +227 -0
- package/src/modules/introspector/types.ts +131 -0
- package/src/modules/introspector/yaml.ts +101 -0
- package/src/modules/network/api/control-plane.ts +275 -0
- package/src/modules/network/api/index.ts +1 -0
- package/src/modules/network/api/validation.ts +71 -0
- package/src/modules/network/index.ts +4 -0
- package/src/modules/network/object-store/aws/client.ts +444 -0
- package/src/modules/network/object-store/aws/credentials.ts +271 -0
- package/src/modules/network/object-store/aws/index.ts +2 -0
- package/src/modules/network/object-store/aws/sigv4.ts +190 -0
- package/src/modules/network/object-store/aws/type.ts +6 -0
- package/src/modules/network/object-store/index.ts +1 -0
- package/src/modules/network/outbox/dispatcher.ts +183 -0
- package/src/modules/network/outbox/index.ts +3 -0
- package/src/modules/network/outbox/process.ts +133 -0
- package/src/modules/network/outbox/shared.ts +56 -0
- package/src/modules/network/outbox/store.ts +346 -0
- package/src/modules/network/outbox/types.ts +54 -0
- package/src/modules/network/request-signing.ts +61 -0
- package/src/modules/worker/index.ts +2 -0
- package/src/modules/worker/tasks.ts +58 -0
- package/src/modules/worker/types.ts +89 -0
- package/src/modules/worker/worker.ts +243 -0
- package/src/secrets/index.ts +4 -0
- package/src/secrets/kms/index.ts +2 -0
- package/src/secrets/kms/signature.ts +82 -0
- package/src/secrets/kms/validation.ts +64 -0
- package/src/secrets/reader.ts +42 -0
- package/src/secrets/repository/crypto.ts +89 -0
- package/src/secrets/repository/index.ts +2 -0
- package/src/secrets/repository/methods.ts +37 -0
- package/src/secrets/resolvers.ts +247 -0
- package/src/secrets/signature.ts +78 -0
- package/src/types/index.ts +1 -0
- package/src/types/types.ts +23 -0
- package/src/utils/identifiers.ts +48 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/json.ts +35 -0
- package/src/utils/logger.ts +161 -0
- package/src/validation/zod.ts +70 -0
- package/tests/adversarial.test.ts +464 -0
- package/tests/blob-s3.test.ts +216 -0
- package/tests/config.test.ts +395 -0
- package/tests/control-plane-client.test.ts +108 -0
- package/tests/crypto.test.ts +106 -0
- package/tests/errors.test.ts +69 -0
- package/tests/fetch-dispatcher.test.ts +213 -0
- package/tests/graph.test.ts +84 -0
- package/tests/helpers/index.ts +101 -0
- package/tests/index-preflight.test.ts +168 -0
- package/tests/introspector-classifier.test.ts +62 -0
- package/tests/introspector-report.test.ts +85 -0
- package/tests/introspector.test.ts +394 -0
- package/tests/kms.test.ts +124 -0
- package/tests/logger.test.ts +61 -0
- package/tests/notifier.test.ts +303 -0
- package/tests/outbox.test.ts +478 -0
- package/tests/purge-policy.test.ts +124 -0
- package/tests/retention.test.ts +103 -0
- package/tests/s3-client.test.ts +110 -0
- package/tests/satellite.test.ts +119 -0
- package/tests/schema-compatibility.test.ts +237 -0
- package/tests/schema-integrity.test.ts +64 -0
- package/tests/shredder.test.ts +163 -0
- package/tests/vault.compiled-targets.test.ts +243 -0
- package/tests/vault.replica.test.ts +59 -0
- package/tests/vault.test.ts +279 -0
- package/tests/worker.retry.test.ts +291 -0
- package/tests/worker.test.ts +200 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
const getDependencyGraphMock = vi.hoisted(() => vi.fn<() => Promise<unknown[]>>(() => Promise.resolve([])));
|
|
7
|
+
vi.mock("@modules/db/graph", () => ({
|
|
8
|
+
getDependencyGraph: getDependencyGraphMock,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
TEST_SECRETS,
|
|
13
|
+
createTestSql,
|
|
14
|
+
dropSchemas,
|
|
15
|
+
insertUser,
|
|
16
|
+
uniqueSchema,
|
|
17
|
+
} from "./helpers";
|
|
18
|
+
import { readWorkerConfig } from "@modules/config";
|
|
19
|
+
import { runMigrations } from "@modules/db";
|
|
20
|
+
import { vaultUser } from "@modules/engine";
|
|
21
|
+
import type { Sql } from "@/types";
|
|
22
|
+
import { decryptGCM, unwrapKey } from "@modules/crypto";
|
|
23
|
+
import { processOutbox } from "@modules/network";
|
|
24
|
+
import { calculateRetryDelayMs } from "@modules/network/outbox/shared";
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
const masterKeyHex = "42".repeat(32);
|
|
28
|
+
const hmacKeyBase64 = Buffer.from(new Uint8Array(32).fill(0x24)).toString("base64");
|
|
29
|
+
|
|
30
|
+
async function writeTempYaml(contents: string): Promise<string> {
|
|
31
|
+
const directory = await mkdtemp(join(tmpdir(), "adversarial-config-"));
|
|
32
|
+
const path = join(directory, "compliance.worker.yml");
|
|
33
|
+
await writeFile(path, contents, "utf8");
|
|
34
|
+
return path;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function deleteTempYaml(path: string) {
|
|
38
|
+
await rm(path, { force: true });
|
|
39
|
+
await rm(dirname(path), { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildVaultOptions(appSchema: string, engineSchema: string, now?: Date) {
|
|
43
|
+
return {
|
|
44
|
+
appSchema,
|
|
45
|
+
engineSchema,
|
|
46
|
+
now,
|
|
47
|
+
rootTable: "users",
|
|
48
|
+
rootIdColumn: "id",
|
|
49
|
+
rootPiiColumns: {
|
|
50
|
+
email: "HMAC" as const,
|
|
51
|
+
full_name: "STATIC_MASK" as const,
|
|
52
|
+
},
|
|
53
|
+
satelliteTargets: [],
|
|
54
|
+
compiledTargets: [
|
|
55
|
+
{ table: `${appSchema}.users`, pii_columns: ["email", "full_name"] },
|
|
56
|
+
{
|
|
57
|
+
table: `${appSchema}.orders`,
|
|
58
|
+
parent: `${appSchema}.users`,
|
|
59
|
+
join: `${appSchema}.users.id = ${appSchema}.orders.user_id`,
|
|
60
|
+
pii_columns: [],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("Adversarial Worker Suite", () => {
|
|
67
|
+
let sql: Sql;
|
|
68
|
+
const schemasToDrop: string[] = [];
|
|
69
|
+
const configPathsToDelete: string[] = [];
|
|
70
|
+
|
|
71
|
+
beforeAll(() => {
|
|
72
|
+
sql = createTestSql();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterAll(async () => {
|
|
76
|
+
for (const path of configPathsToDelete.splice(0, configPathsToDelete.length)) {
|
|
77
|
+
await deleteTempYaml(path);
|
|
78
|
+
}
|
|
79
|
+
await dropSchemas(sql, ...schemasToDrop);
|
|
80
|
+
await sql.end();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("Vector 1: fails closed on toxic config and quoted identifier injection attempts", async () => {
|
|
84
|
+
const nullRetentionPath = await writeTempYaml(`
|
|
85
|
+
version: "1.0"
|
|
86
|
+
database:
|
|
87
|
+
app_schema: tenant_app
|
|
88
|
+
engine_schema: tenant_engine
|
|
89
|
+
compliance_policy:
|
|
90
|
+
default_retention_years: null
|
|
91
|
+
notice_window_hours: 48
|
|
92
|
+
retention_rules:
|
|
93
|
+
- rule_name: RBI_KYC
|
|
94
|
+
legal_citation: "RBI KYC Directions, 2016, Sec 38"
|
|
95
|
+
if_has_data_in:
|
|
96
|
+
- kyc_documents
|
|
97
|
+
retention_years: 5
|
|
98
|
+
graph:
|
|
99
|
+
root_table: users
|
|
100
|
+
root_id_column: id
|
|
101
|
+
max_depth: 32
|
|
102
|
+
root_pii_columns:
|
|
103
|
+
email: HMAC
|
|
104
|
+
satellite_targets:
|
|
105
|
+
- table: marketing_leads
|
|
106
|
+
lookup_column: email
|
|
107
|
+
action: redact
|
|
108
|
+
masking_rules:
|
|
109
|
+
email: HMAC
|
|
110
|
+
outbox:
|
|
111
|
+
batch_size: 10
|
|
112
|
+
lease_seconds: 60
|
|
113
|
+
max_attempts: 10
|
|
114
|
+
base_backoff_ms: 1000
|
|
115
|
+
security:
|
|
116
|
+
notification_lease_seconds: 120
|
|
117
|
+
master_key_env: DPDP_MASTER_KEY
|
|
118
|
+
hmac_key_env: DPDP_HMAC_KEY
|
|
119
|
+
integrity:
|
|
120
|
+
expected_schema_hash: "${"1".repeat(64)}"
|
|
121
|
+
legal_attestation:
|
|
122
|
+
dpo_identifier: "dpo-name@client.com"
|
|
123
|
+
configuration_version: "v1.2.0"
|
|
124
|
+
legal_review_date: "2026-04-20"
|
|
125
|
+
acknowledgment: "I confirm this configuration accurately reflects our obligations."
|
|
126
|
+
`);
|
|
127
|
+
configPathsToDelete.push(nullRetentionPath);
|
|
128
|
+
|
|
129
|
+
await expect(
|
|
130
|
+
readWorkerConfig(
|
|
131
|
+
{
|
|
132
|
+
DPDP_MASTER_KEY: masterKeyHex,
|
|
133
|
+
DPDP_HMAC_KEY: `base64:${hmacKeyBase64}`,
|
|
134
|
+
},
|
|
135
|
+
nullRetentionPath
|
|
136
|
+
)
|
|
137
|
+
).rejects.toThrow(/default_retention_years/i);
|
|
138
|
+
|
|
139
|
+
const injectionPath = await writeTempYaml(`
|
|
140
|
+
version: "1.0"
|
|
141
|
+
database:
|
|
142
|
+
app_schema: tenant_app
|
|
143
|
+
engine_schema: tenant_engine
|
|
144
|
+
compliance_policy:
|
|
145
|
+
default_retention_years: 0
|
|
146
|
+
notice_window_hours: 48
|
|
147
|
+
retention_rules:
|
|
148
|
+
- rule_name: RBI_KYC
|
|
149
|
+
legal_citation: "RBI KYC Directions, 2016, Sec 38"
|
|
150
|
+
if_has_data_in:
|
|
151
|
+
- kyc_documents
|
|
152
|
+
retention_years: 5
|
|
153
|
+
graph:
|
|
154
|
+
root_table: "users; DROP TABLE clients;--"
|
|
155
|
+
root_id_column: id
|
|
156
|
+
max_depth: 32
|
|
157
|
+
root_pii_columns:
|
|
158
|
+
email: HMAC
|
|
159
|
+
satellite_targets:
|
|
160
|
+
- table: marketing_leads
|
|
161
|
+
lookup_column: email
|
|
162
|
+
action: redact
|
|
163
|
+
masking_rules:
|
|
164
|
+
email: HMAC
|
|
165
|
+
outbox:
|
|
166
|
+
batch_size: 10
|
|
167
|
+
lease_seconds: 60
|
|
168
|
+
max_attempts: 10
|
|
169
|
+
base_backoff_ms: 1000
|
|
170
|
+
security:
|
|
171
|
+
notification_lease_seconds: 120
|
|
172
|
+
master_key_env: DPDP_MASTER_KEY
|
|
173
|
+
hmac_key_env: DPDP_HMAC_KEY
|
|
174
|
+
integrity:
|
|
175
|
+
expected_schema_hash: "${"1".repeat(64)}"
|
|
176
|
+
legal_attestation:
|
|
177
|
+
dpo_identifier: "dpo-name@client.com"
|
|
178
|
+
configuration_version: "v1.2.0"
|
|
179
|
+
legal_review_date: "2026-04-20"
|
|
180
|
+
acknowledgment: "I confirm this configuration accurately reflects our obligations."
|
|
181
|
+
`);
|
|
182
|
+
configPathsToDelete.push(injectionPath);
|
|
183
|
+
|
|
184
|
+
await expect(
|
|
185
|
+
readWorkerConfig(
|
|
186
|
+
{
|
|
187
|
+
DPDP_MASTER_KEY: masterKeyHex,
|
|
188
|
+
DPDP_HMAC_KEY: `base64:${hmacKeyBase64}`,
|
|
189
|
+
},
|
|
190
|
+
injectionPath
|
|
191
|
+
)
|
|
192
|
+
).rejects.toThrow(/invalid graph root table/i);
|
|
193
|
+
|
|
194
|
+
const injectionSchema = uniqueSchema("adversarial_identifier");
|
|
195
|
+
schemasToDrop.push(injectionSchema);
|
|
196
|
+
await dropSchemas(sql, injectionSchema);
|
|
197
|
+
await sql`CREATE SCHEMA ${sql(injectionSchema)}`;
|
|
198
|
+
await sql`CREATE TABLE ${sql(injectionSchema)}.clients (id SERIAL PRIMARY KEY)`;
|
|
199
|
+
|
|
200
|
+
await expect(
|
|
201
|
+
sql`
|
|
202
|
+
SELECT 1
|
|
203
|
+
FROM ${sql(injectionSchema)}.${sql("users; DROP TABLE clients;--")}
|
|
204
|
+
`
|
|
205
|
+
).rejects.toThrow();
|
|
206
|
+
|
|
207
|
+
const [tableCheck] = await sql<{ regclass: string | null }[]>`
|
|
208
|
+
SELECT to_regclass(${`${injectionSchema}.clients`}) AS regclass
|
|
209
|
+
`;
|
|
210
|
+
expect(tableCheck?.regclass).toBe(`${injectionSchema}.clients`);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("Vector 2: prevents TOCTOU partial mutation under static-plan execution and concurrent FK insert", async () => {
|
|
214
|
+
const appSchema = uniqueSchema("adversarial_toctou_app");
|
|
215
|
+
const engineSchema = uniqueSchema("adversarial_toctou_engine");
|
|
216
|
+
schemasToDrop.push(appSchema, engineSchema);
|
|
217
|
+
|
|
218
|
+
await dropSchemas(sql, appSchema, engineSchema);
|
|
219
|
+
await sql`CREATE SCHEMA ${sql(appSchema)}`;
|
|
220
|
+
await sql`CREATE TABLE ${sql(appSchema)}.users (id SERIAL PRIMARY KEY, email TEXT NOT NULL, full_name TEXT NOT NULL)`;
|
|
221
|
+
await sql`
|
|
222
|
+
CREATE TABLE ${sql(appSchema)}.orders (
|
|
223
|
+
id SERIAL PRIMARY KEY,
|
|
224
|
+
user_id INTEGER REFERENCES ${sql(appSchema)}.users(id),
|
|
225
|
+
amount NUMERIC NOT NULL
|
|
226
|
+
)
|
|
227
|
+
`;
|
|
228
|
+
await sql.unsafe(`
|
|
229
|
+
CREATE FUNCTION "${appSchema}".slow_user_update()
|
|
230
|
+
RETURNS trigger
|
|
231
|
+
LANGUAGE plpgsql
|
|
232
|
+
AS $$
|
|
233
|
+
BEGIN
|
|
234
|
+
PERFORM pg_sleep(1);
|
|
235
|
+
RETURN NEW;
|
|
236
|
+
END;
|
|
237
|
+
$$;
|
|
238
|
+
CREATE TRIGGER slow_user_update
|
|
239
|
+
BEFORE UPDATE ON "${appSchema}".users
|
|
240
|
+
FOR EACH ROW EXECUTE FUNCTION "${appSchema}".slow_user_update();
|
|
241
|
+
`);
|
|
242
|
+
await runMigrations(sql, engineSchema);
|
|
243
|
+
|
|
244
|
+
const userId = await insertUser(sql, appSchema, "race@example.com", "Race User");
|
|
245
|
+
getDependencyGraphMock.mockClear();
|
|
246
|
+
getDependencyGraphMock.mockResolvedValue([]);
|
|
247
|
+
|
|
248
|
+
const vaultPromise = vaultUser(sql, userId, TEST_SECRETS, buildVaultOptions(appSchema, engineSchema, new Date()));
|
|
249
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
250
|
+
|
|
251
|
+
const insertPromise = sql`
|
|
252
|
+
INSERT INTO ${sql(appSchema)}.orders (user_id, amount)
|
|
253
|
+
VALUES (${userId}, 10.5)
|
|
254
|
+
RETURNING id
|
|
255
|
+
`;
|
|
256
|
+
const earlyOutcome = await Promise.race([
|
|
257
|
+
insertPromise.then(() => "done"),
|
|
258
|
+
new Promise<"blocked">((resolve) => setTimeout(() => resolve("blocked"), 200)),
|
|
259
|
+
]);
|
|
260
|
+
|
|
261
|
+
expect(earlyOutcome).toBe("blocked");
|
|
262
|
+
const vaultResult = await vaultPromise;
|
|
263
|
+
expect(vaultResult.action).toBe("vaulted");
|
|
264
|
+
expect(getDependencyGraphMock).not.toHaveBeenCalled();
|
|
265
|
+
|
|
266
|
+
const insertOutcome = await insertPromise.then(
|
|
267
|
+
() => "inserted",
|
|
268
|
+
() => "failed"
|
|
269
|
+
);
|
|
270
|
+
expect(["inserted", "failed"]).toContain(insertOutcome);
|
|
271
|
+
|
|
272
|
+
const [vaultRow] = await sql`
|
|
273
|
+
SELECT user_uuid_hash
|
|
274
|
+
FROM ${sql(engineSchema)}.pii_vault
|
|
275
|
+
WHERE root_schema = ${appSchema}
|
|
276
|
+
AND root_table = 'users'
|
|
277
|
+
AND root_id = ${userId.toString()}
|
|
278
|
+
`;
|
|
279
|
+
expect(vaultRow?.user_uuid_hash).toBe(vaultResult.userHash);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("Vector 3: terminates cyclic traversal and trips depth circuit breaker at >32", async () => {
|
|
283
|
+
const { getDependencyGraph } = await vi.importActual<typeof import("@modules/db/graph")>("@modules/db/graph");
|
|
284
|
+
const cycleSchema = uniqueSchema("adversarial_cycle");
|
|
285
|
+
schemasToDrop.push(cycleSchema);
|
|
286
|
+
|
|
287
|
+
await dropSchemas(sql, cycleSchema);
|
|
288
|
+
await sql`CREATE SCHEMA ${sql(cycleSchema)}`;
|
|
289
|
+
await sql`CREATE TABLE ${sql(cycleSchema)}.circ_a (id SERIAL PRIMARY KEY)`;
|
|
290
|
+
await sql`
|
|
291
|
+
CREATE TABLE ${sql(cycleSchema)}.circ_b (
|
|
292
|
+
id SERIAL PRIMARY KEY,
|
|
293
|
+
a_id INTEGER REFERENCES ${sql(cycleSchema)}.circ_a(id)
|
|
294
|
+
)
|
|
295
|
+
`;
|
|
296
|
+
await sql`ALTER TABLE ${sql(cycleSchema)}.circ_a ADD COLUMN b_id INTEGER REFERENCES ${sql(cycleSchema)}.circ_b(id)`;
|
|
297
|
+
|
|
298
|
+
const cyclicGraph = await getDependencyGraph(sql, cycleSchema, "circ_a", { maxDepth: 32 });
|
|
299
|
+
const circBRows = cyclicGraph.filter((row) => row.table_name === `${cycleSchema}.circ_b`);
|
|
300
|
+
expect(circBRows).toHaveLength(1);
|
|
301
|
+
|
|
302
|
+
const deepSchema = uniqueSchema("adversarial_depth");
|
|
303
|
+
schemasToDrop.push(deepSchema);
|
|
304
|
+
await dropSchemas(sql, deepSchema);
|
|
305
|
+
await sql`CREATE SCHEMA ${sql(deepSchema)}`;
|
|
306
|
+
await sql`CREATE TABLE ${sql(deepSchema)}.users (id SERIAL PRIMARY KEY)`;
|
|
307
|
+
|
|
308
|
+
let parentTable = "users";
|
|
309
|
+
for (let depth = 1; depth <= 33; depth += 1) {
|
|
310
|
+
const table = `level_${depth}`;
|
|
311
|
+
await sql`
|
|
312
|
+
CREATE TABLE ${sql(deepSchema)}.${sql(table)} (
|
|
313
|
+
id SERIAL PRIMARY KEY,
|
|
314
|
+
parent_id INTEGER REFERENCES ${sql(deepSchema)}.${sql(parentTable)}(id)
|
|
315
|
+
)
|
|
316
|
+
`;
|
|
317
|
+
parentTable = table;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
await expect(getDependencyGraph(sql, deepSchema, "users", { maxDepth: 32 })).rejects.toThrow(/safety limit/i);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("Vector 4: rejects corrupted AES-GCM auth tags and halts decryption", async () => {
|
|
324
|
+
const appSchema = uniqueSchema("adversarial_crypto_app");
|
|
325
|
+
const engineSchema = uniqueSchema("adversarial_crypto_engine");
|
|
326
|
+
schemasToDrop.push(appSchema, engineSchema);
|
|
327
|
+
|
|
328
|
+
await dropSchemas(sql, appSchema, engineSchema);
|
|
329
|
+
await sql`CREATE SCHEMA ${sql(appSchema)}`;
|
|
330
|
+
await sql`CREATE TABLE ${sql(appSchema)}.users (id SERIAL PRIMARY KEY, email TEXT NOT NULL, full_name TEXT NOT NULL)`;
|
|
331
|
+
await sql`
|
|
332
|
+
CREATE TABLE ${sql(appSchema)}.orders (
|
|
333
|
+
id SERIAL PRIMARY KEY,
|
|
334
|
+
user_id INTEGER REFERENCES ${sql(appSchema)}.users(id),
|
|
335
|
+
amount NUMERIC NOT NULL
|
|
336
|
+
)
|
|
337
|
+
`;
|
|
338
|
+
await runMigrations(sql, engineSchema);
|
|
339
|
+
|
|
340
|
+
getDependencyGraphMock.mockClear();
|
|
341
|
+
getDependencyGraphMock.mockResolvedValue([
|
|
342
|
+
{
|
|
343
|
+
table_schema: appSchema,
|
|
344
|
+
table_name: `${appSchema}.orders`,
|
|
345
|
+
column_name: "user_id",
|
|
346
|
+
parent_table: `${appSchema}.users`,
|
|
347
|
+
depth: 1,
|
|
348
|
+
},
|
|
349
|
+
]);
|
|
350
|
+
|
|
351
|
+
const userId = await insertUser(sql, appSchema, "cipher@example.com", "Cipher User");
|
|
352
|
+
const result = await vaultUser(sql, userId, TEST_SECRETS, buildVaultOptions(appSchema, engineSchema, new Date()));
|
|
353
|
+
expect(result.action).toBe("vaulted");
|
|
354
|
+
|
|
355
|
+
const [vaultRow] = await sql`
|
|
356
|
+
SELECT encrypted_pii
|
|
357
|
+
FROM ${sql(engineSchema)}.pii_vault
|
|
358
|
+
WHERE user_uuid_hash = ${result.userHash}
|
|
359
|
+
`;
|
|
360
|
+
const [keyRow] = await sql`
|
|
361
|
+
SELECT encrypted_dek
|
|
362
|
+
FROM ${sql(engineSchema)}.user_keys
|
|
363
|
+
WHERE user_uuid_hash = ${result.userHash}
|
|
364
|
+
`;
|
|
365
|
+
|
|
366
|
+
const wrappedDek = new Uint8Array(keyRow!.encrypted_dek);
|
|
367
|
+
const dek = await unwrapKey(wrappedDek, TEST_SECRETS.kek);
|
|
368
|
+
const payload = vaultRow!.encrypted_pii as { data: string };
|
|
369
|
+
const encryptedBytes = new Uint8Array(Buffer.from(payload.data, "base64"));
|
|
370
|
+
if (encryptedBytes.length === 0) {
|
|
371
|
+
throw new Error("Encrypted payload unexpectedly empty.");
|
|
372
|
+
}
|
|
373
|
+
const tagByteIndex = encryptedBytes.length - 1;
|
|
374
|
+
encryptedBytes[tagByteIndex] = encryptedBytes[tagByteIndex]! ^ 0xff;
|
|
375
|
+
|
|
376
|
+
await expect(decryptGCM(encryptedBytes, dek)).rejects.toThrow();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("Vector 5: retries with exponential backoff and dead-letters after 10 consecutive 500s", async () => {
|
|
380
|
+
const engineSchema = uniqueSchema("adversarial_outbox_engine");
|
|
381
|
+
schemasToDrop.push(engineSchema);
|
|
382
|
+
|
|
383
|
+
await dropSchemas(sql, engineSchema);
|
|
384
|
+
await runMigrations(sql, engineSchema);
|
|
385
|
+
|
|
386
|
+
await sql`
|
|
387
|
+
INSERT INTO ${sql(engineSchema)}.outbox (
|
|
388
|
+
idempotency_key,
|
|
389
|
+
user_uuid_hash,
|
|
390
|
+
event_type,
|
|
391
|
+
payload,
|
|
392
|
+
previous_hash,
|
|
393
|
+
current_hash,
|
|
394
|
+
status,
|
|
395
|
+
attempt_count,
|
|
396
|
+
next_attempt_at,
|
|
397
|
+
created_at,
|
|
398
|
+
updated_at
|
|
399
|
+
)
|
|
400
|
+
VALUES (
|
|
401
|
+
'adversarial:event:1',
|
|
402
|
+
'user-hash-1',
|
|
403
|
+
'USER_VAULTED',
|
|
404
|
+
${sql.json({ rootId: "1" })},
|
|
405
|
+
'GENESIS',
|
|
406
|
+
'hash-1',
|
|
407
|
+
'pending',
|
|
408
|
+
0,
|
|
409
|
+
${new Date("2026-04-18T00:00:00.000Z")},
|
|
410
|
+
NOW(),
|
|
411
|
+
NOW()
|
|
412
|
+
)
|
|
413
|
+
`;
|
|
414
|
+
|
|
415
|
+
let now = new Date("2026-04-18T00:00:00.000Z");
|
|
416
|
+
let deliveryCalls = 0;
|
|
417
|
+
const baseBackoffMs = 250;
|
|
418
|
+
|
|
419
|
+
for (let attempt = 1; attempt <= 10; attempt += 1) {
|
|
420
|
+
const result = await processOutbox(
|
|
421
|
+
sql,
|
|
422
|
+
async () => {
|
|
423
|
+
deliveryCalls += 1;
|
|
424
|
+
throw new Error("Brain API responded with HTTP 500.");
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
engineSchema,
|
|
428
|
+
batchSize: 1,
|
|
429
|
+
maxAttempts: 10,
|
|
430
|
+
baseBackoffMs,
|
|
431
|
+
now,
|
|
432
|
+
}
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
const [row] = await sql<{
|
|
436
|
+
status: "pending" | "dead_letter";
|
|
437
|
+
attempt_count: number;
|
|
438
|
+
next_attempt_at: Date;
|
|
439
|
+
last_error: string | null;
|
|
440
|
+
}[]>`
|
|
441
|
+
SELECT status, attempt_count, next_attempt_at, last_error
|
|
442
|
+
FROM ${sql(engineSchema)}.outbox
|
|
443
|
+
WHERE idempotency_key = 'adversarial:event:1'
|
|
444
|
+
`;
|
|
445
|
+
|
|
446
|
+
expect(result.failed).toBe(1);
|
|
447
|
+
expect(row?.attempt_count).toBe(attempt);
|
|
448
|
+
expect(row?.last_error).toContain("HTTP 500");
|
|
449
|
+
|
|
450
|
+
if (attempt < 10) {
|
|
451
|
+
expect(result.deadLettered).toBe(0);
|
|
452
|
+
expect(row?.status).toBe("pending");
|
|
453
|
+
const expectedDelay = calculateRetryDelayMs(attempt, baseBackoffMs);
|
|
454
|
+
expect(new Date(row!.next_attempt_at).getTime()).toBe(now.getTime() + expectedDelay);
|
|
455
|
+
now = new Date(now.getTime() + expectedDelay);
|
|
456
|
+
} else {
|
|
457
|
+
expect(result.deadLettered).toBe(1);
|
|
458
|
+
expect(row?.status).toBe("dead_letter");
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
expect(deliveryCalls).toBe(10);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { Sql } from "@/types";
|
|
3
|
+
import { shredUser } from "@modules/engine";
|
|
4
|
+
import { vaultUser } from "@modules/engine";
|
|
5
|
+
import { type S3Client } from "@modules/network";
|
|
6
|
+
import {
|
|
7
|
+
TEST_SECRETS,
|
|
8
|
+
createTestSql,
|
|
9
|
+
dropSchemas,
|
|
10
|
+
insertUser,
|
|
11
|
+
prepareWorkerSchemas,
|
|
12
|
+
uniqueSchema,
|
|
13
|
+
} from "./helpers";
|
|
14
|
+
|
|
15
|
+
describe("S3 blob compliance provider", () => {
|
|
16
|
+
let sql: Sql;
|
|
17
|
+
const schemasToDrop: string[] = [];
|
|
18
|
+
|
|
19
|
+
beforeAll(() => {
|
|
20
|
+
sql = createTestSql();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterAll(async () => {
|
|
24
|
+
await dropSchemas(sql, ...schemasToDrop);
|
|
25
|
+
await sql.end();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
async function prepare() {
|
|
29
|
+
const appSchema = uniqueSchema("blob_app");
|
|
30
|
+
const engineSchema = uniqueSchema("blob_engine");
|
|
31
|
+
schemasToDrop.push(appSchema, engineSchema);
|
|
32
|
+
await prepareWorkerSchemas(sql, appSchema, engineSchema, { withDependencies: true });
|
|
33
|
+
await sql`ALTER TABLE ${sql(appSchema)}.users ADD COLUMN kyc_document_url TEXT`;
|
|
34
|
+
const userId = await insertUser(sql, appSchema, "blob@example.com", "Blob User");
|
|
35
|
+
await sql`
|
|
36
|
+
UPDATE ${sql(appSchema)}.users
|
|
37
|
+
SET kyc_document_url = 's3://kyc-bucket/kyc/john-doe-aadhar.pdf?versionId=v1'
|
|
38
|
+
WHERE id = ${userId}
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
return { appSchema, engineSchema, userId };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function mockS3Client() {
|
|
45
|
+
const client: S3Client = {
|
|
46
|
+
headObject: vi.fn(async (input) => ({
|
|
47
|
+
bucket: input.bucket,
|
|
48
|
+
key: input.key,
|
|
49
|
+
versionId: input.versionId ?? "v1",
|
|
50
|
+
eTag: "etag-v1",
|
|
51
|
+
})),
|
|
52
|
+
putObjectLegalHold: vi.fn(async () => undefined),
|
|
53
|
+
listObjectVersions: vi.fn(async () => [
|
|
54
|
+
{ key: "kyc/john-doe-aadhar.pdf", versionId: "v1", eTag: "etag-v1", isDeleteMarker: false },
|
|
55
|
+
{ key: "kyc/john-doe-aadhar.pdf", versionId: "v2", eTag: "etag-v2", isDeleteMarker: false },
|
|
56
|
+
{ key: "kyc/john-doe-aadhar.pdf", versionId: "delete-marker", eTag: null, isDeleteMarker: true },
|
|
57
|
+
]),
|
|
58
|
+
deleteObjectVersion: vi.fn(async (input) => ({
|
|
59
|
+
key: input.key,
|
|
60
|
+
versionId: input.versionId ?? null,
|
|
61
|
+
deleteMarker: false,
|
|
62
|
+
status: 204,
|
|
63
|
+
})),
|
|
64
|
+
putObject: vi.fn(async (input) => ({
|
|
65
|
+
key: input.key,
|
|
66
|
+
versionId: "sanitized-v1",
|
|
67
|
+
eTag: "sanitized-etag",
|
|
68
|
+
status: 200,
|
|
69
|
+
})),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return client;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
it("applies legal hold, masks the DB URL, and stores raw object coordinates only in the worker schema", async () => {
|
|
76
|
+
const { appSchema, engineSchema, userId } = await prepare();
|
|
77
|
+
const s3Client = mockS3Client();
|
|
78
|
+
|
|
79
|
+
const result = await vaultUser(sql, userId, TEST_SECRETS, {
|
|
80
|
+
appSchema,
|
|
81
|
+
engineSchema,
|
|
82
|
+
rootTable: "users",
|
|
83
|
+
rootIdColumn: "id",
|
|
84
|
+
rootPiiColumns: {
|
|
85
|
+
email: "HMAC",
|
|
86
|
+
full_name: "STATIC_MASK",
|
|
87
|
+
},
|
|
88
|
+
satelliteTargets: [],
|
|
89
|
+
blobTargets: [
|
|
90
|
+
{
|
|
91
|
+
table: "users",
|
|
92
|
+
column: "kyc_document_url",
|
|
93
|
+
provider: "aws_s3",
|
|
94
|
+
region: "ap-south-1",
|
|
95
|
+
action: "versioned_hard_delete",
|
|
96
|
+
retention_mode: "governance",
|
|
97
|
+
expected_bucket_owner: "123456789012",
|
|
98
|
+
require_version_id: true,
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
s3Client,
|
|
102
|
+
now: new Date("2026-01-10T00:00:00.000Z"),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result.action).toBe("vaulted");
|
|
106
|
+
expect(result.blobProtectionCount).toBe(1);
|
|
107
|
+
expect(s3Client.putObjectLegalHold).toHaveBeenCalledWith(expect.objectContaining({
|
|
108
|
+
bucket: "kyc-bucket",
|
|
109
|
+
key: "kyc/john-doe-aadhar.pdf",
|
|
110
|
+
versionId: "v1",
|
|
111
|
+
expectedBucketOwner: "123456789012",
|
|
112
|
+
status: "ON",
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
const [user] = await sql<{ kyc_document_url: string }[]>`
|
|
116
|
+
SELECT kyc_document_url
|
|
117
|
+
FROM ${sql(appSchema)}.users
|
|
118
|
+
WHERE id = ${userId}
|
|
119
|
+
`;
|
|
120
|
+
expect(user?.kyc_document_url).toMatch(/^[0-9a-f]{64}$/);
|
|
121
|
+
|
|
122
|
+
const [blobRow] = await sql`
|
|
123
|
+
SELECT *
|
|
124
|
+
FROM ${sql(engineSchema)}.blob_objects
|
|
125
|
+
WHERE user_uuid_hash = ${result.userHash}
|
|
126
|
+
`;
|
|
127
|
+
expect(blobRow?.bucket).toBe("kyc-bucket");
|
|
128
|
+
expect(blobRow?.object_key).toBe("kyc/john-doe-aadhar.pdf");
|
|
129
|
+
expect(blobRow?.version_id).toBe("v1");
|
|
130
|
+
expect(blobRow?.expected_bucket_owner).toBe("123456789012");
|
|
131
|
+
|
|
132
|
+
const [outboxRow] = await sql<{ payload: { blob_protections: unknown[] } }[]>`
|
|
133
|
+
SELECT payload
|
|
134
|
+
FROM ${sql(engineSchema)}.outbox
|
|
135
|
+
WHERE event_type = 'USER_VAULTED'
|
|
136
|
+
`;
|
|
137
|
+
expect(JSON.stringify(outboxRow?.payload)).not.toContain("john-doe-aadhar");
|
|
138
|
+
expect(outboxRow?.payload.blob_protections).toHaveLength(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("removes legal hold and deletes every S3 version during shredding", async () => {
|
|
142
|
+
const { appSchema, engineSchema, userId } = await prepare();
|
|
143
|
+
const s3Client = mockS3Client();
|
|
144
|
+
const now = new Date("2026-01-10T00:00:00.000Z");
|
|
145
|
+
|
|
146
|
+
const vaultResult = await vaultUser(sql, userId, TEST_SECRETS, {
|
|
147
|
+
appSchema,
|
|
148
|
+
engineSchema,
|
|
149
|
+
rootTable: "users",
|
|
150
|
+
rootIdColumn: "id",
|
|
151
|
+
rootPiiColumns: {
|
|
152
|
+
email: "HMAC",
|
|
153
|
+
full_name: "STATIC_MASK",
|
|
154
|
+
},
|
|
155
|
+
satelliteTargets: [],
|
|
156
|
+
blobTargets: [
|
|
157
|
+
{
|
|
158
|
+
table: "users",
|
|
159
|
+
column: "kyc_document_url",
|
|
160
|
+
provider: "aws_s3",
|
|
161
|
+
region: "ap-south-1",
|
|
162
|
+
action: "versioned_hard_delete",
|
|
163
|
+
retention_mode: "governance",
|
|
164
|
+
expected_bucket_owner: "123456789012",
|
|
165
|
+
require_version_id: true,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
s3Client,
|
|
169
|
+
now,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await sql`
|
|
173
|
+
UPDATE ${sql(engineSchema)}.pii_vault
|
|
174
|
+
SET notification_sent_at = ${now},
|
|
175
|
+
retention_expiry = ${now}
|
|
176
|
+
WHERE user_uuid_hash = ${vaultResult.userHash}
|
|
177
|
+
`;
|
|
178
|
+
|
|
179
|
+
const shredResult = await shredUser(sql, userId, {
|
|
180
|
+
appSchema,
|
|
181
|
+
engineSchema,
|
|
182
|
+
rootTable: "users",
|
|
183
|
+
now,
|
|
184
|
+
hmacKey: TEST_SECRETS.hmacKey,
|
|
185
|
+
s3Client,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(shredResult.action).toBe("shredded");
|
|
189
|
+
expect(shredResult.blobReceiptCount).toBe(1);
|
|
190
|
+
expect(s3Client.putObjectLegalHold).toHaveBeenCalledWith(expect.objectContaining({
|
|
191
|
+
versionId: "v1",
|
|
192
|
+
expectedBucketOwner: "123456789012",
|
|
193
|
+
status: "OFF",
|
|
194
|
+
}));
|
|
195
|
+
expect(s3Client.deleteObjectVersion).toHaveBeenCalledTimes(3);
|
|
196
|
+
expect(s3Client.deleteObjectVersion).toHaveBeenCalledWith(expect.objectContaining({ versionId: "v1", expectedBucketOwner: "123456789012" }));
|
|
197
|
+
expect(s3Client.deleteObjectVersion).toHaveBeenCalledWith(expect.objectContaining({ versionId: "v2", expectedBucketOwner: "123456789012" }));
|
|
198
|
+
expect(s3Client.deleteObjectVersion).toHaveBeenCalledWith(expect.objectContaining({ versionId: "delete-marker", expectedBucketOwner: "123456789012" }));
|
|
199
|
+
|
|
200
|
+
const [blobRow] = await sql<{ shred_status: string; shred_receipt: { deletedVersionIdHashes: string[] } }[]>`
|
|
201
|
+
SELECT shred_status, shred_receipt
|
|
202
|
+
FROM ${sql(engineSchema)}.blob_objects
|
|
203
|
+
WHERE user_uuid_hash = ${vaultResult.userHash}
|
|
204
|
+
`;
|
|
205
|
+
expect(blobRow?.shred_status).toBe("purged");
|
|
206
|
+
expect(blobRow?.shred_receipt.deletedVersionIdHashes).toHaveLength(3);
|
|
207
|
+
|
|
208
|
+
const [outboxRow] = await sql<{ payload: { blob_receipts: unknown[] } }[]>`
|
|
209
|
+
SELECT payload
|
|
210
|
+
FROM ${sql(engineSchema)}.outbox
|
|
211
|
+
WHERE event_type = 'SHRED_SUCCESS'
|
|
212
|
+
`;
|
|
213
|
+
expect(JSON.stringify(outboxRow?.payload)).not.toContain("john-doe-aadhar");
|
|
214
|
+
expect(outboxRow?.payload.blob_receipts).toHaveLength(1);
|
|
215
|
+
});
|
|
216
|
+
});
|