@crewhaus/audit-log 0.1.1 → 0.1.2
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/package.json +6 -11
- package/src/index.test.ts +477 -4
- package/src/index.ts +411 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/audit-log",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Per-tenant hash-chained JSONL audit trail for the managed-daemon target",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"test": "bun test src"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.1.
|
|
15
|
+
"@crewhaus/errors": "0.1.2"
|
|
16
16
|
},
|
|
17
17
|
"license": "Apache-2.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Max Meier",
|
|
20
|
-
"email": "max@
|
|
21
|
-
"url": "https://
|
|
20
|
+
"email": "max@crewhaus.ai",
|
|
21
|
+
"url": "https://crewhaus.ai"
|
|
22
22
|
},
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|
|
@@ -30,12 +30,7 @@
|
|
|
30
30
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
|
-
"access": "
|
|
33
|
+
"access": "public"
|
|
34
34
|
},
|
|
35
|
-
"files": [
|
|
36
|
-
"src",
|
|
37
|
-
"README.md",
|
|
38
|
-
"LICENSE",
|
|
39
|
-
"NOTICE"
|
|
40
|
-
]
|
|
35
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
41
36
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type AnchorRecord,
|
|
7
|
+
type AnchorStore,
|
|
8
|
+
type AuditLog,
|
|
9
|
+
AuditLogError,
|
|
10
|
+
GENESIS_HASH,
|
|
11
|
+
InMemoryAnchorStore,
|
|
12
|
+
openAuditLog,
|
|
13
|
+
verify,
|
|
14
|
+
} from "./index";
|
|
6
15
|
|
|
7
16
|
let tmp: string;
|
|
8
17
|
|
|
@@ -54,14 +63,19 @@ describe("append + read", () => {
|
|
|
54
63
|
});
|
|
55
64
|
|
|
56
65
|
describe("verify (T4)", () => {
|
|
57
|
-
test("clean chain reports ok=true", async () => {
|
|
66
|
+
test("clean chain reports ok=true and matches the tail anchor", async () => {
|
|
58
67
|
const log = await makeLog();
|
|
59
68
|
for (let i = 0; i < 10; i += 1) {
|
|
60
69
|
await log.append({ kind: "policy_decision", payload: { i } });
|
|
61
70
|
}
|
|
62
71
|
const r = await verify(tmp);
|
|
63
72
|
expect(r.ok).toBe(true);
|
|
64
|
-
if (r.ok)
|
|
73
|
+
if (r.ok) {
|
|
74
|
+
expect(r.recordsChecked).toBe(10);
|
|
75
|
+
// The _chain-tail.json anchor exists and matches the surviving tail,
|
|
76
|
+
// so the truncation guard actually ran (not the missing-anchor path).
|
|
77
|
+
expect(r.anchorChecked).toBe(true);
|
|
78
|
+
}
|
|
65
79
|
});
|
|
66
80
|
|
|
67
81
|
test("empty rootDir reports ok=true with 0 records", async () => {
|
|
@@ -130,6 +144,144 @@ describe("verify (T4)", () => {
|
|
|
130
144
|
expect(r.reason).toMatch(/malformed JSON/);
|
|
131
145
|
}
|
|
132
146
|
});
|
|
147
|
+
|
|
148
|
+
test("a corrupt _chain-tail.json is reported, not thrown (no verifier DoS)", async () => {
|
|
149
|
+
const log = await makeLog();
|
|
150
|
+
await log.append({ kind: "model_call", payload: 1 });
|
|
151
|
+
await log.append({ kind: "model_call", payload: 2 });
|
|
152
|
+
|
|
153
|
+
// Garble the on-host anchor (a crash mid-write, or a one-byte attacker
|
|
154
|
+
// edit, leaves invalid JSON). verify must surface this as a broken link
|
|
155
|
+
// rather than crash with an unhandled JSON.parse throw.
|
|
156
|
+
writeFileSync(join(tmp, "_chain-tail.json"), "{ this is not valid json");
|
|
157
|
+
|
|
158
|
+
const r = await verify(tmp);
|
|
159
|
+
expect(r.ok).toBe(false);
|
|
160
|
+
if (!r.ok) {
|
|
161
|
+
expect(r.reason).toMatch(/chain-tail anchor unreadable/);
|
|
162
|
+
expect(r.file).toMatch(/_chain-tail\.json$/);
|
|
163
|
+
// The chain walk itself completed before the anchor was consulted.
|
|
164
|
+
expect(r.recordsChecked).toBe(2);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("tail-truncation detection (CWE-345)", () => {
|
|
170
|
+
const readLines = (file: string): string[] =>
|
|
171
|
+
readFileSync(file, "utf8")
|
|
172
|
+
.split("\n")
|
|
173
|
+
.filter((l) => l !== "");
|
|
174
|
+
|
|
175
|
+
test("every record carries a 0-based, gapless seq", async () => {
|
|
176
|
+
const log = await makeLog();
|
|
177
|
+
for (let i = 0; i < 5; i += 1) {
|
|
178
|
+
await log.append({ kind: "model_call", payload: i });
|
|
179
|
+
}
|
|
180
|
+
const seqs: number[] = [];
|
|
181
|
+
for await (const r of log.read()) seqs.push(r.seq);
|
|
182
|
+
expect(seqs).toEqual([0, 1, 2, 3, 4]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("the persisted chain-tail anchor records the last seq", async () => {
|
|
186
|
+
const log = await makeLog();
|
|
187
|
+
await log.append({ kind: "model_call", payload: "a" });
|
|
188
|
+
await log.append({ kind: "model_call", payload: "b" });
|
|
189
|
+
await log.append({ kind: "model_call", payload: "c" });
|
|
190
|
+
const anchor = JSON.parse(readFileSync(join(tmp, "_chain-tail.json"), "utf8"));
|
|
191
|
+
expect(anchor.seq).toBe(2);
|
|
192
|
+
expect(anchor.day).toBe(FIXED_DAY);
|
|
193
|
+
expect(anchor.hash).toMatch(/^[0-9a-f]{64}$/);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("truncating the last record is detected via the tail anchor", async () => {
|
|
197
|
+
const log = await makeLog();
|
|
198
|
+
await log.append({ kind: "secrets_access", payload: "sensitive-1" });
|
|
199
|
+
await log.append({ kind: "secrets_access", payload: "sensitive-2" });
|
|
200
|
+
await log.append({ kind: "secrets_access", payload: "sensitive-3" });
|
|
201
|
+
|
|
202
|
+
// Attacker deletes the trailing record to erase their activity, but
|
|
203
|
+
// leaves _chain-tail.json untouched. The survivors still chain cleanly
|
|
204
|
+
// and their seq run (0,1) is gapless — only the independent anchor,
|
|
205
|
+
// which still commits to the dropped record, reveals the truncation.
|
|
206
|
+
const file = join(tmp, `${FIXED_DAY}.jsonl`);
|
|
207
|
+
const lines = readLines(file);
|
|
208
|
+
writeFileSync(file, `${lines.slice(0, -1).join("\n")}\n`);
|
|
209
|
+
|
|
210
|
+
const r = await verify(tmp);
|
|
211
|
+
expect(r.ok).toBe(false);
|
|
212
|
+
if (!r.ok) {
|
|
213
|
+
expect(r.reason).toMatch(/chain-tail anchor mismatch/);
|
|
214
|
+
expect(r.recordsChecked).toBe(2);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("deleting the entire newest-day file is detected", async () => {
|
|
219
|
+
const log = await makeLog();
|
|
220
|
+
await log.append({ kind: "gateway_request", payload: 1 });
|
|
221
|
+
await log.append({ kind: "gateway_request", payload: 2 });
|
|
222
|
+
|
|
223
|
+
// rm the only day file but keep the anchor: the surviving chain is now
|
|
224
|
+
// empty (ends at GENESIS) yet the anchor still points at a real hash.
|
|
225
|
+
unlinkSync(join(tmp, `${FIXED_DAY}.jsonl`));
|
|
226
|
+
|
|
227
|
+
const r = await verify(tmp);
|
|
228
|
+
expect(r.ok).toBe(false);
|
|
229
|
+
if (!r.ok) expect(r.reason).toMatch(/chain-tail anchor mismatch/);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("deleting a record from the middle is detected as a seq gap", async () => {
|
|
233
|
+
const log = await makeLog();
|
|
234
|
+
await log.append({ kind: "model_call", payload: 0 });
|
|
235
|
+
await log.append({ kind: "model_call", payload: 1 });
|
|
236
|
+
await log.append({ kind: "model_call", payload: 2 });
|
|
237
|
+
|
|
238
|
+
// Remove the middle line. prevHash linkage would also break, but the
|
|
239
|
+
// gapless-seq assertion fires first on the now-missing seq 1.
|
|
240
|
+
const file = join(tmp, `${FIXED_DAY}.jsonl`);
|
|
241
|
+
const lines = readLines(file);
|
|
242
|
+
writeFileSync(file, `${[lines[0], lines[2]].join("\n")}\n`);
|
|
243
|
+
|
|
244
|
+
const r = await verify(tmp);
|
|
245
|
+
expect(r.ok).toBe(false);
|
|
246
|
+
if (!r.ok) expect(r.reason).toMatch(/seq gap|prevHash mismatch/);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("a missing anchor is reported as a limitation, not a clean pass", async () => {
|
|
250
|
+
const log = await makeLog();
|
|
251
|
+
await log.append({ kind: "model_call", payload: 1 });
|
|
252
|
+
await log.append({ kind: "model_call", payload: 2 });
|
|
253
|
+
|
|
254
|
+
// Without the independent anchor, tail-truncation cannot be ruled out:
|
|
255
|
+
// the surviving chain is internally consistent, so verify still reports
|
|
256
|
+
// ok, but flags anchorChecked=false so callers know it is not proof of
|
|
257
|
+
// completeness.
|
|
258
|
+
unlinkSync(join(tmp, "_chain-tail.json"));
|
|
259
|
+
|
|
260
|
+
const r = await verify(tmp);
|
|
261
|
+
expect(r.ok).toBe(true);
|
|
262
|
+
if (r.ok) {
|
|
263
|
+
expect(r.recordsChecked).toBe(2);
|
|
264
|
+
expect(r.anchorChecked).toBe(false);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("a legit append after verify still chains and verifies", async () => {
|
|
269
|
+
const log = await makeLog();
|
|
270
|
+
await log.append({ kind: "model_call", payload: 1 });
|
|
271
|
+
const first = await verify(tmp);
|
|
272
|
+
expect(first.ok).toBe(true);
|
|
273
|
+
|
|
274
|
+
// Guard against over-blocking: continuing to append must keep seq
|
|
275
|
+
// monotone and the anchor in sync, so a fresh verify still passes.
|
|
276
|
+
await log.append({ kind: "model_call", payload: 2 });
|
|
277
|
+
await log.append({ kind: "model_call", payload: 3 });
|
|
278
|
+
const second = await verify(tmp);
|
|
279
|
+
expect(second.ok).toBe(true);
|
|
280
|
+
if (second.ok) {
|
|
281
|
+
expect(second.recordsChecked).toBe(3);
|
|
282
|
+
expect(second.anchorChecked).toBe(true);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
133
285
|
});
|
|
134
286
|
|
|
135
287
|
describe("file mode (T8)", () => {
|
|
@@ -143,3 +295,324 @@ describe("file mode (T8)", () => {
|
|
|
143
295
|
expect(mode).toBe(0o600);
|
|
144
296
|
});
|
|
145
297
|
});
|
|
298
|
+
|
|
299
|
+
describe("off-host anchor (#156-followup)", () => {
|
|
300
|
+
const readLines = (file: string): string[] =>
|
|
301
|
+
readFileSync(file, "utf8")
|
|
302
|
+
.split("\n")
|
|
303
|
+
.filter((l) => l !== "");
|
|
304
|
+
|
|
305
|
+
const makeLogWith = (store: AnchorStore): Promise<AuditLog> =>
|
|
306
|
+
openAuditLog({ rootDir: tmp, now: fixedNow(), day: () => FIXED_DAY, anchorStore: store });
|
|
307
|
+
|
|
308
|
+
// Simulate a same-uid attacker who not only truncates the JSONL but ALSO
|
|
309
|
+
// rewrites _chain-tail.json to commit to the new (shorter) survivors — so
|
|
310
|
+
// the on-host anchor check is defeated and only the off-host store can tell.
|
|
311
|
+
const truncateAndRewriteOnHostAnchor = (keep: number): void => {
|
|
312
|
+
const file = join(tmp, `${FIXED_DAY}.jsonl`);
|
|
313
|
+
const lines = readLines(file);
|
|
314
|
+
const survivors = lines.slice(0, keep);
|
|
315
|
+
writeFileSync(file, `${survivors.join("\n")}\n`);
|
|
316
|
+
const last = JSON.parse(survivors[survivors.length - 1] as string);
|
|
317
|
+
writeFileSync(
|
|
318
|
+
join(tmp, "_chain-tail.json"),
|
|
319
|
+
JSON.stringify({ day: FIXED_DAY, hash: last.hash, seq: last.seq }),
|
|
320
|
+
);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
test("append best-effort mirrors the tail to the store", async () => {
|
|
324
|
+
const store = new InMemoryAnchorStore();
|
|
325
|
+
const log = await makeLogWith(store);
|
|
326
|
+
const a = await log.append({ kind: "model_call", payload: 1 });
|
|
327
|
+
const b = await log.append({ kind: "model_call", payload: 2 });
|
|
328
|
+
|
|
329
|
+
const anchor = await store.getAnchor(tmp);
|
|
330
|
+
expect(anchor).toEqual({ seq: b.seq, hash: b.hash });
|
|
331
|
+
// The mirrored hash is the live tail, not a stale earlier record.
|
|
332
|
+
expect(anchor?.hash).not.toBe(a.hash);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("THREAT: external anchor ahead of a truncated+rewritten chain fails verify", async () => {
|
|
336
|
+
const store = new InMemoryAnchorStore();
|
|
337
|
+
const log = await makeLogWith(store);
|
|
338
|
+
await log.append({ kind: "secrets_access", payload: "sensitive-1" });
|
|
339
|
+
await log.append({ kind: "secrets_access", payload: "sensitive-2" });
|
|
340
|
+
await log.append({ kind: "secrets_access", payload: "sensitive-3" });
|
|
341
|
+
|
|
342
|
+
// The external store now witnesses seq 2. Attacker drops the last record
|
|
343
|
+
// AND rewrites the on-host anchor to match the survivors (seq 1).
|
|
344
|
+
truncateAndRewriteOnHostAnchor(2);
|
|
345
|
+
|
|
346
|
+
// On-host-only verify is fooled — the lockstep rewrite makes survivors +
|
|
347
|
+
// _chain-tail.json mutually consistent. This is the gap the follow-up closes.
|
|
348
|
+
const fooled = await verify(tmp);
|
|
349
|
+
expect(fooled.ok).toBe(true);
|
|
350
|
+
if (fooled.ok) expect(fooled.externalAnchorChecked).toBe(false);
|
|
351
|
+
|
|
352
|
+
// With the off-host anchor consulted, the dropped seq 2 is caught.
|
|
353
|
+
const caught = await verify(tmp, { anchorStore: store });
|
|
354
|
+
expect(caught.ok).toBe(false);
|
|
355
|
+
if (!caught.ok) {
|
|
356
|
+
expect(caught.reason).toMatch(/external anchor mismatch/);
|
|
357
|
+
expect(caught.reason).toMatch(/seq 2/);
|
|
358
|
+
expect(caught.recordsChecked).toBe(2);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("external anchor catches a wholesale delete that also removed the on-host anchor", async () => {
|
|
363
|
+
const store = new InMemoryAnchorStore();
|
|
364
|
+
const log = await makeLogWith(store);
|
|
365
|
+
await log.append({ kind: "gateway_request", payload: 1 });
|
|
366
|
+
await log.append({ kind: "gateway_request", payload: 2 });
|
|
367
|
+
|
|
368
|
+
// Attacker rm's the day file AND the on-host anchor — nothing local is
|
|
369
|
+
// left to contradict an empty trail. The external anchor still witnesses seq 1.
|
|
370
|
+
unlinkSync(join(tmp, `${FIXED_DAY}.jsonl`));
|
|
371
|
+
unlinkSync(join(tmp, "_chain-tail.json"));
|
|
372
|
+
|
|
373
|
+
const r = await verify(tmp, { anchorStore: store });
|
|
374
|
+
expect(r.ok).toBe(false);
|
|
375
|
+
if (!r.ok) expect(r.reason).toMatch(/external anchor mismatch/);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("external anchor catches a same-seq in-place tail rewrite", async () => {
|
|
379
|
+
const store = new InMemoryAnchorStore();
|
|
380
|
+
const log = await makeLogWith(store);
|
|
381
|
+
await log.append({ kind: "model_call", payload: "a" });
|
|
382
|
+
const tail = await log.append({ kind: "model_call", payload: "b" });
|
|
383
|
+
|
|
384
|
+
// Re-forge the tail record's payload and re-chain it so the local walk +
|
|
385
|
+
// on-host anchor accept it; the external anchor still pins the old hash.
|
|
386
|
+
const file = join(tmp, `${FIXED_DAY}.jsonl`);
|
|
387
|
+
const lines = readLines(file);
|
|
388
|
+
const forged = JSON.parse(lines[1] as string);
|
|
389
|
+
forged.payload = "forged";
|
|
390
|
+
// Recompute a valid hash for the forged body so the hash-chain walk passes.
|
|
391
|
+
const { createHash } = await import("node:crypto");
|
|
392
|
+
const body = {
|
|
393
|
+
ts: forged.ts,
|
|
394
|
+
version: forged.version,
|
|
395
|
+
kind: forged.kind,
|
|
396
|
+
seq: forged.seq,
|
|
397
|
+
payload: forged.payload,
|
|
398
|
+
};
|
|
399
|
+
forged.hash = createHash("sha256")
|
|
400
|
+
.update(forged.prevHash)
|
|
401
|
+
.update("|")
|
|
402
|
+
.update(JSON.stringify(body))
|
|
403
|
+
.digest("hex");
|
|
404
|
+
lines[1] = JSON.stringify(forged);
|
|
405
|
+
writeFileSync(file, `${lines.join("\n")}\n`);
|
|
406
|
+
// Rewrite on-host anchor to the forged tip (lockstep), same seq.
|
|
407
|
+
writeFileSync(
|
|
408
|
+
join(tmp, "_chain-tail.json"),
|
|
409
|
+
JSON.stringify({ day: FIXED_DAY, hash: forged.hash, seq: forged.seq }),
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
expect(forged.hash).not.toBe(tail.hash);
|
|
413
|
+
const r = await verify(tmp, { anchorStore: store });
|
|
414
|
+
expect(r.ok).toBe(false);
|
|
415
|
+
if (!r.ok) expect(r.reason).toMatch(/external anchor mismatch/);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("NO-REGRESSION: a chain that legitimately extends past a lagging anchor verifies", async () => {
|
|
419
|
+
// Anchor lags behind newer appends (benign best-effort put lag): verify
|
|
420
|
+
// must NOT flag it, or normal operation would be over-blocked.
|
|
421
|
+
const store = new InMemoryAnchorStore();
|
|
422
|
+
const log = await makeLogWith(store);
|
|
423
|
+
await log.append({ kind: "model_call", payload: 1 });
|
|
424
|
+
await log.append({ kind: "model_call", payload: 2 });
|
|
425
|
+
// Snapshot the store as it was at seq 1, then append more "live" records.
|
|
426
|
+
const lagging = await store.getAnchor(tmp);
|
|
427
|
+
const stale = new InMemoryAnchorStore();
|
|
428
|
+
await stale.putAnchor(tmp, lagging as AnchorRecord);
|
|
429
|
+
await log.append({ kind: "model_call", payload: 3 });
|
|
430
|
+
await log.append({ kind: "model_call", payload: 4 });
|
|
431
|
+
|
|
432
|
+
const r = await verify(tmp, { anchorStore: stale });
|
|
433
|
+
expect(r.ok).toBe(true);
|
|
434
|
+
if (r.ok) {
|
|
435
|
+
expect(r.recordsChecked).toBe(4);
|
|
436
|
+
expect(r.externalAnchorChecked).toBe(true);
|
|
437
|
+
expect(r.anchorChecked).toBe(true);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("clean chain with a matching store reports externalAnchorChecked=true", async () => {
|
|
442
|
+
const store = new InMemoryAnchorStore();
|
|
443
|
+
const log = await makeLogWith(store);
|
|
444
|
+
for (let i = 0; i < 4; i += 1) await log.append({ kind: "policy_decision", payload: i });
|
|
445
|
+
const r = await verify(tmp, { anchorStore: store });
|
|
446
|
+
expect(r.ok).toBe(true);
|
|
447
|
+
if (r.ok) {
|
|
448
|
+
expect(r.recordsChecked).toBe(4);
|
|
449
|
+
expect(r.externalAnchorChecked).toBe(true);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("verify without a store leaves externalAnchorChecked=false (no-op default)", async () => {
|
|
454
|
+
const log = await makeLog();
|
|
455
|
+
await log.append({ kind: "model_call", payload: 1 });
|
|
456
|
+
const r = await verify(tmp);
|
|
457
|
+
expect(r.ok).toBe(true);
|
|
458
|
+
if (r.ok) {
|
|
459
|
+
expect(r.anchorChecked).toBe(true);
|
|
460
|
+
expect(r.externalAnchorChecked).toBe(false);
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("a store with no anchor yet is reported as not-checked, not a failure", async () => {
|
|
465
|
+
const log = await makeLog(); // opened WITHOUT a store, so nothing was published
|
|
466
|
+
await log.append({ kind: "model_call", payload: 1 });
|
|
467
|
+
const emptyStore = new InMemoryAnchorStore();
|
|
468
|
+
const r = await verify(tmp, { anchorStore: emptyStore });
|
|
469
|
+
expect(r.ok).toBe(true);
|
|
470
|
+
if (r.ok) expect(r.externalAnchorChecked).toBe(false);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("a getAnchor that throws degrades to not-checked (never fabricates tamper)", async () => {
|
|
474
|
+
const log = await makeLog();
|
|
475
|
+
await log.append({ kind: "model_call", payload: 1 });
|
|
476
|
+
const flaky: AnchorStore = {
|
|
477
|
+
async putAnchor(): Promise<void> {},
|
|
478
|
+
async getAnchor(): Promise<AnchorRecord | undefined> {
|
|
479
|
+
throw new Error("anchor backend offline");
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
const r = await verify(tmp, { anchorStore: flaky });
|
|
483
|
+
expect(r.ok).toBe(true);
|
|
484
|
+
if (r.ok) expect(r.externalAnchorChecked).toBe(false);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("a putAnchor that throws does not break the durable local append", async () => {
|
|
488
|
+
const flaky: AnchorStore = {
|
|
489
|
+
async putAnchor(): Promise<void> {
|
|
490
|
+
throw new Error("WORM bucket unreachable");
|
|
491
|
+
},
|
|
492
|
+
async getAnchor(): Promise<AnchorRecord | undefined> {
|
|
493
|
+
return undefined;
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
const log = await makeLogWith(flaky);
|
|
497
|
+
// The append must still succeed and the local chain remain verifiable.
|
|
498
|
+
const rec = await log.append({ kind: "model_call", payload: "durable" });
|
|
499
|
+
expect(rec.seq).toBe(0);
|
|
500
|
+
const r = await verify(tmp);
|
|
501
|
+
expect(r.ok).toBe(true);
|
|
502
|
+
if (r.ok) expect(r.recordsChecked).toBe(1);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("InMemoryAnchorStore is monotonic — a stale put cannot regress the tip", async () => {
|
|
506
|
+
const store = new InMemoryAnchorStore();
|
|
507
|
+
await store.putAnchor("log-a", { seq: 5, hash: "h5" });
|
|
508
|
+
// A late/replayed lower-seq put is ignored so the witnessed tip never regresses.
|
|
509
|
+
await store.putAnchor("log-a", { seq: 2, hash: "h2" });
|
|
510
|
+
expect(await store.getAnchor("log-a")).toEqual({ seq: 5, hash: "h5" });
|
|
511
|
+
// Distinct logIds are independent.
|
|
512
|
+
expect(await store.getAnchor("log-b")).toBeUndefined();
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
describe("openAuditLog input validation", () => {
|
|
517
|
+
test("an empty rootDir throws AuditLogError", async () => {
|
|
518
|
+
await expect(openAuditLog({ rootDir: "" })).rejects.toBeInstanceOf(AuditLogError);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("a non-string rootDir throws AuditLogError with the config code", async () => {
|
|
522
|
+
// The guard is `typeof rootDir !== "string" || rootDir === ""`; exercise the
|
|
523
|
+
// typeof branch (distinct from the empty-string branch above).
|
|
524
|
+
await expect(
|
|
525
|
+
// biome-ignore lint/suspicious/noExplicitAny: deliberately bad input for the guard
|
|
526
|
+
openAuditLog({ rootDir: undefined as any }),
|
|
527
|
+
).rejects.toMatchObject({ name: "AuditLogError", code: "config" });
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
describe("default clock + day selector", () => {
|
|
532
|
+
test("openAuditLog works with the built-in Date.now()/today defaults", async () => {
|
|
533
|
+
// Opening WITHOUT `now`/`day` exercises the default `() => Date.now()`
|
|
534
|
+
// clock and the default `todayStr` day selector (UTC ISO date). We assert
|
|
535
|
+
// structural invariants rather than a fixed value so the test stays
|
|
536
|
+
// deterministic despite using the real clock.
|
|
537
|
+
const log = await openAuditLog({ rootDir: tmp });
|
|
538
|
+
const before = Date.now();
|
|
539
|
+
const rec = await log.append({ kind: "model_call", payload: { ok: true } });
|
|
540
|
+
const after = Date.now();
|
|
541
|
+
expect(rec.seq).toBe(0);
|
|
542
|
+
expect(rec.prevHash).toBe(GENESIS_HASH);
|
|
543
|
+
expect(rec.ts).toBeGreaterThanOrEqual(before);
|
|
544
|
+
expect(rec.ts).toBeLessThanOrEqual(after);
|
|
545
|
+
|
|
546
|
+
// The day file is named for today's UTC date (what `todayStr` returns).
|
|
547
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
548
|
+
const lines = readFileSync(join(tmp, `${today}.jsonl`), "utf8")
|
|
549
|
+
.split("\n")
|
|
550
|
+
.filter((l) => l !== "");
|
|
551
|
+
expect(lines).toHaveLength(1);
|
|
552
|
+
|
|
553
|
+
// read() with the default day selector must round-trip the record back.
|
|
554
|
+
const out: unknown[] = [];
|
|
555
|
+
for await (const r of log.read()) out.push(r.payload);
|
|
556
|
+
expect(out).toEqual([{ ok: true }]);
|
|
557
|
+
|
|
558
|
+
const v = await verify(tmp);
|
|
559
|
+
expect(v.ok).toBe(true);
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
describe("read with an explicit day", () => {
|
|
564
|
+
test("read({ day }) targets that day's file and round-trips its records", async () => {
|
|
565
|
+
const log = await openAuditLog({ rootDir: tmp, now: fixedNow(), day: () => FIXED_DAY });
|
|
566
|
+
await log.append({ kind: "model_call", payload: "x" });
|
|
567
|
+
await log.append({ kind: "model_call", payload: "y" });
|
|
568
|
+
|
|
569
|
+
// Explicitly request the known day instead of relying on the default.
|
|
570
|
+
const out: unknown[] = [];
|
|
571
|
+
for await (const r of log.read({ day: FIXED_DAY })) out.push(r.payload);
|
|
572
|
+
expect(out).toEqual(["x", "y"]);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("read({ day }) for a day with no file yields nothing", async () => {
|
|
576
|
+
const log = await openAuditLog({ rootDir: tmp, now: fixedNow(), day: () => FIXED_DAY });
|
|
577
|
+
await log.append({ kind: "model_call", payload: "x" });
|
|
578
|
+
|
|
579
|
+
const out: unknown[] = [];
|
|
580
|
+
for await (const r of log.read({ day: "1999-01-01" })) out.push(r.payload);
|
|
581
|
+
expect(out).toEqual([]);
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
describe("append evaluates the day selector once (midnight-boundary safety)", () => {
|
|
586
|
+
test("the JSONL filename and the chain-tail day stay consistent across a boundary", async () => {
|
|
587
|
+
// A `day` selector that flips on its SECOND call simulates an append that
|
|
588
|
+
// straddles midnight. The record file and the persisted anchor's `day`
|
|
589
|
+
// must agree (both the FIRST value), and the chain must still verify.
|
|
590
|
+
const days = ["2026-05-08", "2026-05-09"];
|
|
591
|
+
let calls = 0;
|
|
592
|
+
const flippingDay = (): string => {
|
|
593
|
+
const d = days[Math.min(calls, days.length - 1)] as string;
|
|
594
|
+
calls += 1;
|
|
595
|
+
return d;
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
const log = await openAuditLog({ rootDir: tmp, now: fixedNow(), day: flippingDay });
|
|
599
|
+
const rec = await log.append({ kind: "model_call", payload: "boundary" });
|
|
600
|
+
|
|
601
|
+
// The record landed in the FIRST day's file...
|
|
602
|
+
const firstDayLines = readFileSync(join(tmp, "2026-05-08.jsonl"), "utf8")
|
|
603
|
+
.split("\n")
|
|
604
|
+
.filter((l) => l !== "");
|
|
605
|
+
expect(firstDayLines).toHaveLength(1);
|
|
606
|
+
|
|
607
|
+
// ...and the anchor's `day` matches that same file (not the flipped value).
|
|
608
|
+
const anchor = JSON.parse(readFileSync(join(tmp, "_chain-tail.json"), "utf8"));
|
|
609
|
+
expect(anchor.day).toBe("2026-05-08");
|
|
610
|
+
expect(anchor.hash).toBe(rec.hash);
|
|
611
|
+
|
|
612
|
+
// No second-day file was created by the single append.
|
|
613
|
+
expect(() => readFileSync(join(tmp, "2026-05-09.jsonl"), "utf8")).toThrow();
|
|
614
|
+
|
|
615
|
+
// The single append consumed exactly one `day()` evaluation post-fix.
|
|
616
|
+
expect(calls).toBe(1);
|
|
617
|
+
});
|
|
618
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -7,14 +7,53 @@
|
|
|
7
7
|
*
|
|
8
8
|
* {
|
|
9
9
|
* ts: <ms epoch>, version: 1, kind: "policy_decision" | …,
|
|
10
|
+
* seq: <0-based strictly-increasing per-log counter>,
|
|
10
11
|
* payload: <opaque JSON>,
|
|
11
12
|
* prevHash: <hex of prior line, or "GENESIS">,
|
|
12
|
-
* hash: <SHA-256(prevHash || JSON.stringify({ts,version,kind,payload}))>
|
|
13
|
+
* hash: <SHA-256(prevHash || JSON.stringify({ts,version,kind,seq,payload}))>
|
|
13
14
|
* }
|
|
14
15
|
*
|
|
15
16
|
* `verify(rootDir)` walks the chain and reports the first broken link
|
|
16
17
|
* (line number + reason). The chain is per-day; the previous day's
|
|
17
|
-
* tail seeds the next day's `prevHash` via a one-line index
|
|
18
|
+
* tail seeds the next day's `prevHash` AND `seq` via a one-line index
|
|
19
|
+
* file (`_chain-tail.json` = `{ day, hash, seq }`).
|
|
20
|
+
*
|
|
21
|
+
* Tail-truncation detection. A pure hash chain is only tamper-evident
|
|
22
|
+
* for *interior* edits: deleting a suffix of the newest day file (or
|
|
23
|
+
* `rm`-ing the whole newest day) leaves the survivors internally
|
|
24
|
+
* consistent, so a naive walk would still pass. Two commitments close
|
|
25
|
+
* that gap: (1) the per-record `seq` lets `verify` assert the chain is
|
|
26
|
+
* gapless from 0 — a hole proves records were removed from the middle;
|
|
27
|
+
* (2) `verify` reads the `_chain-tail.json` anchor and fails if the
|
|
28
|
+
* surviving chain's last `{ hash, seq }` does not match the recorded
|
|
29
|
+
* tail, which catches a truncation that drops trailing records.
|
|
30
|
+
*
|
|
31
|
+
* IMPORTANT — on-host files are NOT tamper-proof against a same-uid
|
|
32
|
+
* attacker. The 0o600 mode only blocks *other* users; the gateway's own
|
|
33
|
+
* uid (or anything that compromises it) can rewrite both the JSONL and
|
|
34
|
+
* `_chain-tail.json` in lockstep, defeating the on-host anchor check
|
|
35
|
+
* above. True non-repudiation requires an anchor the audit-writer cannot
|
|
36
|
+
* rewrite — an off-host / WORM bucket, a separate-privilege service, or
|
|
37
|
+
* periodic publication to an external transparency log. A *missing*
|
|
38
|
+
* on-host anchor is therefore reported by `verify` as a known limitation
|
|
39
|
+
* (`anchorChecked: false`), never silently treated as a pass.
|
|
40
|
+
*
|
|
41
|
+
* Off-host anchor hook (`AnchorStore`). To close the same-uid lockstep
|
|
42
|
+
* gap, `openAuditLog` and `verify` accept an OPTIONAL pluggable
|
|
43
|
+
* `AnchorStore` — `{ putAnchor(logId, {seq, hash}), getAnchor(logId) }`.
|
|
44
|
+
* On each `append`, the new tail `{ seq, hash }` is *best-effort* mirrored
|
|
45
|
+
* to the store (a put failure never blocks the durable local write). On
|
|
46
|
+
* `verify`, the external anchor is consulted IN ADDITION to
|
|
47
|
+
* `_chain-tail.json`: if it witnesses a `seq` the surviving chain no
|
|
48
|
+
* longer reaches — or a tip hash that disagrees at the anchored seq —
|
|
49
|
+
* verify FAILS, catching a tail truncation that ALSO rewrote the on-host
|
|
50
|
+
* `_chain-tail.json` in lockstep (the attacker cannot reach back into the
|
|
51
|
+
* external store). An anchor that merely lags behind newer appends (the
|
|
52
|
+
* benign best-effort case) is NOT treated as a failure. This package
|
|
53
|
+
* ships only an in-memory `InMemoryAnchorStore` reference implementation
|
|
54
|
+
* for tests/dev; PRODUCTION DEPLOYMENTS SHOULD SUPPLY A WORM-BACKED STORE
|
|
55
|
+
* (object-lock bucket, append-only/separate-privilege service, or
|
|
56
|
+
* transparency log) so the audit writer's own uid cannot rewrite it.
|
|
18
57
|
*
|
|
19
58
|
* Files are created with mode 0o600 (owner-only) so the audit trail
|
|
20
59
|
* cannot be read by other users on the host. Append uses
|
|
@@ -59,17 +98,32 @@ export type AuditKind =
|
|
|
59
98
|
// summary. The egress-classifier's `summarizeEgress(result)` produces
|
|
60
99
|
// the human-readable form that lands in the `payload_summary` field.
|
|
61
100
|
| "egress_decision"
|
|
62
|
-
// Pillar 3 intent gate —
|
|
63
|
-
// justification-evaluated tool call
|
|
64
|
-
//
|
|
101
|
+
// Pillar 3 intent gate — `runtime-core`'s justification gate appends one
|
|
102
|
+
// record per justification-evaluated tool call (allow OR deny) when a
|
|
103
|
+
// durable sink is wired via `runChatLoop({ justificationAuditSink })`. The
|
|
104
|
+
// CLI `run`/browser paths open a real audit-log rooted at `.crewhaus/audit`
|
|
105
|
+
// and pass it (disable with `--no-justification-audit`); the ephemeral
|
|
106
|
+
// `permission_decision` trace-bus event mirrors the same verdict + judge
|
|
107
|
+
// identity for live observability. Payload shape (opaque JSON):
|
|
108
|
+
// { toolName, justification, verdict: "allow"|"deny", reason, judgeModel,
|
|
109
|
+
// confidence? }
|
|
65
110
|
// Stored verbatim because the justification IS the audit artifact;
|
|
66
|
-
// redacting it would defeat the purpose.
|
|
111
|
+
// redacting it would defeat the purpose. (`runtime-core` declares a minimal
|
|
112
|
+
// structural `JustificationAuditSink` rather than importing this package, to
|
|
113
|
+
// avoid a dependency cycle; this `AuditLog` satisfies that seam.)
|
|
67
114
|
| "permission_justification_evaluated";
|
|
68
115
|
|
|
69
116
|
export type AuditRecord = {
|
|
70
117
|
readonly ts: number;
|
|
71
118
|
readonly version: 1;
|
|
72
119
|
readonly kind: AuditKind;
|
|
120
|
+
/**
|
|
121
|
+
* Strictly-increasing per-log sequence number, 0-based and gapless.
|
|
122
|
+
* Committed into `hash` so it cannot be rewritten without breaking the
|
|
123
|
+
* chain; `verify` asserts the run starts at 0 and has no holes, which
|
|
124
|
+
* surfaces records removed from the middle of the chain.
|
|
125
|
+
*/
|
|
126
|
+
readonly seq: number;
|
|
73
127
|
readonly payload: unknown;
|
|
74
128
|
readonly prevHash: string;
|
|
75
129
|
readonly hash: string;
|
|
@@ -84,10 +138,75 @@ export class AuditLogError extends CrewhausError {
|
|
|
84
138
|
}
|
|
85
139
|
}
|
|
86
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Off-host anchor commitment for a single log: the latest `{ seq, hash }`
|
|
143
|
+
* the writer has published externally. Deliberately minimal so a
|
|
144
|
+
* deployment can back it with a WORM bucket, an append-only/separate-
|
|
145
|
+
* privilege service, or a transparency log.
|
|
146
|
+
*/
|
|
147
|
+
export type AnchorRecord = { readonly seq: number; readonly hash: string };
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Pluggable hook for mirroring the hash-chain tail to an anchor the audit
|
|
151
|
+
* writer's own uid CANNOT rewrite. `append` calls `putAnchor` best-effort
|
|
152
|
+
* after the local write; `verify` calls `getAnchor` to cross-check the
|
|
153
|
+
* surviving chain (see file header). `logId` namespaces multiple logs in
|
|
154
|
+
* one store — `openAuditLog` defaults it to the absolute `rootDir`.
|
|
155
|
+
*
|
|
156
|
+
* Implementations should make `putAnchor` monotonic (never regress to a
|
|
157
|
+
* lower `seq`) when the backing store permits; a stale/lagging anchor is
|
|
158
|
+
* benign for `verify`, but a regressed one weakens truncation detection.
|
|
159
|
+
* PRODUCTION SHOULD SUPPLY A WORM-BACKED IMPLEMENTATION — the bundled
|
|
160
|
+
* {@link InMemoryAnchorStore} is for tests/dev only and offers no
|
|
161
|
+
* tamper-resistance on its own.
|
|
162
|
+
*/
|
|
163
|
+
export interface AnchorStore {
|
|
164
|
+
putAnchor(logId: string, anchor: AnchorRecord): Promise<void>;
|
|
165
|
+
getAnchor(logId: string): Promise<AnchorRecord | undefined>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Reference {@link AnchorStore} that keeps the latest anchor per `logId`
|
|
170
|
+
* in process memory, monotonic on `seq`. Useful for tests and for wiring
|
|
171
|
+
* the `verify` cross-check in a single process; it provides NO durability
|
|
172
|
+
* or tamper-resistance and MUST NOT be relied on for non-repudiation in
|
|
173
|
+
* production — back the seam with a WORM store instead.
|
|
174
|
+
*/
|
|
175
|
+
export class InMemoryAnchorStore implements AnchorStore {
|
|
176
|
+
private readonly anchors: Map<string, AnchorRecord>;
|
|
177
|
+
|
|
178
|
+
constructor() {
|
|
179
|
+
this.anchors = new Map<string, AnchorRecord>();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async putAnchor(logId: string, anchor: AnchorRecord): Promise<void> {
|
|
183
|
+
const existing = this.anchors.get(logId);
|
|
184
|
+
// Monotonic: never let a later put regress the committed tip backwards.
|
|
185
|
+
if (existing !== undefined && anchor.seq < existing.seq) return;
|
|
186
|
+
this.anchors.set(logId, { seq: anchor.seq, hash: anchor.hash });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async getAnchor(logId: string): Promise<AnchorRecord | undefined> {
|
|
190
|
+
return this.anchors.get(logId);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
87
194
|
export type OpenAuditLogOptions = {
|
|
88
195
|
readonly rootDir: string;
|
|
89
196
|
readonly now?: () => number;
|
|
90
197
|
readonly day?: () => string;
|
|
198
|
+
/**
|
|
199
|
+
* Optional off-host anchor store. When supplied, every `append`
|
|
200
|
+
* best-effort mirrors the new tail `{ seq, hash }` to it (a put failure
|
|
201
|
+
* is swallowed so the durable local write is never blocked). Pass the
|
|
202
|
+
* same store + `logId` to {@link verify} to cross-check the chain.
|
|
203
|
+
*/
|
|
204
|
+
readonly anchorStore?: AnchorStore;
|
|
205
|
+
/**
|
|
206
|
+
* Key under which this log's anchor is stored in `anchorStore`. Defaults
|
|
207
|
+
* to the absolute `rootDir`, letting one store hold many logs.
|
|
208
|
+
*/
|
|
209
|
+
readonly logId?: string;
|
|
91
210
|
};
|
|
92
211
|
|
|
93
212
|
export interface AuditLog {
|
|
@@ -96,7 +215,7 @@ export interface AuditLog {
|
|
|
96
215
|
}
|
|
97
216
|
|
|
98
217
|
function hashBody(
|
|
99
|
-
body: { ts: number; version: 1; kind: AuditKind; payload: unknown },
|
|
218
|
+
body: { ts: number; version: 1; kind: AuditKind; seq: number; payload: unknown },
|
|
100
219
|
prevHash: string,
|
|
101
220
|
): string {
|
|
102
221
|
return createHash("sha256")
|
|
@@ -106,6 +225,24 @@ function hashBody(
|
|
|
106
225
|
.digest("hex");
|
|
107
226
|
}
|
|
108
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Recompute a record's hash from its own fields — the exact derivation
|
|
230
|
+
* `append` and `verify` use. Lets an external consumer (e.g.
|
|
231
|
+
* `@crewhaus/compliance-controls`) re-check a record's body↔hash consistency
|
|
232
|
+
* without re-reading the log files. Compare the result against `record.hash`:
|
|
233
|
+
* a mismatch means the body (payload/kind/ts/seq) was altered after signing.
|
|
234
|
+
*/
|
|
235
|
+
export function recomputeRecordHash(record: AuditRecord): string {
|
|
236
|
+
const body = {
|
|
237
|
+
ts: record.ts,
|
|
238
|
+
version: record.version,
|
|
239
|
+
kind: record.kind,
|
|
240
|
+
seq: record.seq,
|
|
241
|
+
payload: record.payload,
|
|
242
|
+
};
|
|
243
|
+
return hashBody(body, record.prevHash);
|
|
244
|
+
}
|
|
245
|
+
|
|
109
246
|
function todayStr(): string {
|
|
110
247
|
return new Date().toISOString().slice(0, 10);
|
|
111
248
|
}
|
|
@@ -114,7 +251,7 @@ function indexPath(rootDir: string): string {
|
|
|
114
251
|
return join(rootDir, "_chain-tail.json");
|
|
115
252
|
}
|
|
116
253
|
|
|
117
|
-
type ChainTail = { readonly day: string; readonly hash: string };
|
|
254
|
+
type ChainTail = { readonly day: string; readonly hash: string; readonly seq: number };
|
|
118
255
|
|
|
119
256
|
function readChainTail(rootDir: string): ChainTail | undefined {
|
|
120
257
|
const p = indexPath(rootDir);
|
|
@@ -122,9 +259,9 @@ function readChainTail(rootDir: string): ChainTail | undefined {
|
|
|
122
259
|
return JSON.parse(readFileSync(p, "utf8")) as ChainTail;
|
|
123
260
|
}
|
|
124
261
|
|
|
125
|
-
function writeChainTail(rootDir: string, day: string, hash: string): void {
|
|
262
|
+
function writeChainTail(rootDir: string, day: string, hash: string, seq: number): void {
|
|
126
263
|
const p = indexPath(rootDir);
|
|
127
|
-
writeFileSync(p, JSON.stringify({ day, hash }), { mode: 0o600 });
|
|
264
|
+
writeFileSync(p, JSON.stringify({ day, hash, seq }), { mode: 0o600 });
|
|
128
265
|
}
|
|
129
266
|
|
|
130
267
|
export async function openAuditLog(opts: OpenAuditLogOptions): Promise<AuditLog> {
|
|
@@ -134,17 +271,46 @@ export async function openAuditLog(opts: OpenAuditLogOptions): Promise<AuditLog>
|
|
|
134
271
|
mkdirSync(opts.rootDir, { recursive: true, mode: 0o700 });
|
|
135
272
|
const now = opts.now ?? ((): number => Date.now());
|
|
136
273
|
const day = opts.day ?? todayStr;
|
|
274
|
+
const anchorStore = opts.anchorStore;
|
|
275
|
+
const logId = opts.logId ?? opts.rootDir;
|
|
137
276
|
|
|
138
277
|
return {
|
|
139
278
|
async append(input: AppendInput): Promise<AuditRecord> {
|
|
140
279
|
const tail = readChainTail(opts.rootDir);
|
|
141
280
|
const prevHash = tail?.hash ?? GENESIS_HASH;
|
|
142
|
-
|
|
281
|
+
// First record gets seq 0; thereafter strictly increment the tail's
|
|
282
|
+
// seq. A pre-`seq` anchor (`seq` absent) is treated as -1 so the next
|
|
283
|
+
// record starts the gapless run at 0.
|
|
284
|
+
const seq = (tail?.seq ?? -1) + 1;
|
|
285
|
+
const body = {
|
|
286
|
+
ts: now(),
|
|
287
|
+
version: 1 as const,
|
|
288
|
+
kind: input.kind,
|
|
289
|
+
seq,
|
|
290
|
+
payload: input.payload,
|
|
291
|
+
};
|
|
143
292
|
const hash = hashBody(body, prevHash);
|
|
144
293
|
const record: AuditRecord = { ...body, prevHash, hash };
|
|
145
|
-
|
|
294
|
+
// Evaluate the day selector exactly once: a second call could straddle
|
|
295
|
+
// a midnight boundary (with the default UTC-date `day`), writing the
|
|
296
|
+
// record to one day's file while committing the tail under the next
|
|
297
|
+
// day — leaving the JSONL filename and the anchor's `day` inconsistent.
|
|
298
|
+
const today = day();
|
|
299
|
+
const file = join(opts.rootDir, `${today}.jsonl`);
|
|
146
300
|
appendFileSync(file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
147
|
-
writeChainTail(opts.rootDir,
|
|
301
|
+
writeChainTail(opts.rootDir, today, hash, seq);
|
|
302
|
+
// Best-effort: mirror the new tail to the off-host anchor. A failure
|
|
303
|
+
// here (network/WORM hiccup) must NOT fail the durable local append —
|
|
304
|
+
// the chain remains internally verifiable, and a lagging external
|
|
305
|
+
// anchor is benign for `verify` (it only ever flags an anchor that is
|
|
306
|
+
// AHEAD of a truncated chain, never one that trails).
|
|
307
|
+
if (anchorStore !== undefined) {
|
|
308
|
+
try {
|
|
309
|
+
await anchorStore.putAnchor(logId, { seq, hash });
|
|
310
|
+
} catch {
|
|
311
|
+
// Swallowed by design — see comment above.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
148
314
|
return record;
|
|
149
315
|
},
|
|
150
316
|
read(readOpts: { day?: string } = {}): AsyncIterable<AuditRecord> {
|
|
@@ -178,7 +344,30 @@ async function* readDay(rootDir: string, day: string): AsyncIterable<AuditRecord
|
|
|
178
344
|
}
|
|
179
345
|
|
|
180
346
|
export type VerifyResult =
|
|
181
|
-
| {
|
|
347
|
+
| {
|
|
348
|
+
readonly ok: true;
|
|
349
|
+
readonly recordsChecked: number;
|
|
350
|
+
/**
|
|
351
|
+
* Whether the surviving chain was matched against the independent
|
|
352
|
+
* on-host `_chain-tail.json` anchor. `false` means the anchor was
|
|
353
|
+
* absent, so tail-truncation could NOT be ruled out — `ok: true` here
|
|
354
|
+
* attests only that the survivors are internally consistent and
|
|
355
|
+
* gapless from 0, not that nothing was dropped off the end. Callers
|
|
356
|
+
* that need non-repudiation must treat `ok && !anchorChecked` as a
|
|
357
|
+
* limitation, not a clean bill of health. (And see the file header:
|
|
358
|
+
* even a present on-host anchor is rewritable by a same-uid attacker.)
|
|
359
|
+
*/
|
|
360
|
+
readonly anchorChecked: boolean;
|
|
361
|
+
/**
|
|
362
|
+
* Whether an off-host {@link AnchorStore} was supplied AND held an
|
|
363
|
+
* anchor that was cross-checked against the surviving chain. `false`
|
|
364
|
+
* means no store was passed, or it had no anchor yet — so a same-uid
|
|
365
|
+
* lockstep rewrite of the on-host anchor could NOT be ruled out. Only
|
|
366
|
+
* `ok && externalAnchorChecked` attests that an anchor the writer
|
|
367
|
+
* cannot rewrite agreed with the chain tip.
|
|
368
|
+
*/
|
|
369
|
+
readonly externalAnchorChecked: boolean;
|
|
370
|
+
}
|
|
182
371
|
| {
|
|
183
372
|
readonly ok: false;
|
|
184
373
|
readonly recordsChecked: number;
|
|
@@ -187,19 +376,69 @@ export type VerifyResult =
|
|
|
187
376
|
readonly reason: string;
|
|
188
377
|
};
|
|
189
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Optional cross-check inputs for {@link verify}. Supply the same
|
|
381
|
+
* `anchorStore` (and matching `logId`) used when the log was opened to
|
|
382
|
+
* have `verify` consult the off-host anchor in addition to the on-host
|
|
383
|
+
* `_chain-tail.json`. Both are optional and back-compatible — calling
|
|
384
|
+
* `verify(rootDir)` keeps the original on-host-only behaviour.
|
|
385
|
+
*/
|
|
386
|
+
export type VerifyOptions = {
|
|
387
|
+
readonly anchorStore?: AnchorStore;
|
|
388
|
+
readonly logId?: string;
|
|
389
|
+
};
|
|
390
|
+
|
|
190
391
|
/**
|
|
191
392
|
* Walk every `<rootDir>/*.jsonl` chain, verifying each record's
|
|
192
|
-
* `hash` against `SHA-256(prevHash || canonical-body)
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
393
|
+
* `hash` against `SHA-256(prevHash || canonical-body)`, that `prevHash`
|
|
394
|
+
* matches the previous record's `hash`, and that `seq` is gapless from
|
|
395
|
+
* 0. After the walk, the surviving chain's last `{ hash, seq }` is
|
|
396
|
+
* matched against the independent on-host `_chain-tail.json` anchor so a
|
|
397
|
+
* dropped suffix (tail truncation / newest-day deletion) is caught.
|
|
398
|
+
*
|
|
399
|
+
* If a `{ anchorStore }` is supplied (see {@link VerifyOptions}), the
|
|
400
|
+
* off-host anchor is consulted IN ADDITION to `_chain-tail.json`: when it
|
|
401
|
+
* witnesses a `seq` the surviving chain no longer reaches — or a tip hash
|
|
402
|
+
* that disagrees at the anchored seq — verify FAILS. This catches a tail
|
|
403
|
+
* truncation that ALSO rewrote the on-host anchor in lockstep, since a
|
|
404
|
+
* same-uid attacker cannot reach into the external (ideally WORM) store.
|
|
405
|
+
* An external anchor that merely lags BEHIND newer appends (the benign
|
|
406
|
+
* best-effort case) is not a failure.
|
|
407
|
+
*
|
|
408
|
+
* Returns the first broken link (file + line number + reason), or
|
|
409
|
+
* `{ ok: true }` if the chain is intact. On success, `anchorChecked`
|
|
410
|
+
* reports whether the on-host tail anchor was present and matched, and
|
|
411
|
+
* `externalAnchorChecked` whether an off-host anchor was supplied and
|
|
412
|
+
* cross-checked; `false` on either means that gap could not be ruled out
|
|
413
|
+
* (a limitation, not a pass — see the file header on same-uid tamper).
|
|
196
414
|
*/
|
|
197
|
-
export async function verify(rootDir: string): Promise<VerifyResult> {
|
|
198
|
-
if (!existsSync(rootDir))
|
|
415
|
+
export async function verify(rootDir: string, options: VerifyOptions = {}): Promise<VerifyResult> {
|
|
416
|
+
if (!existsSync(rootDir)) {
|
|
417
|
+
// No local chain at all. Even so, an external anchor may witness records
|
|
418
|
+
// that a wholesale deletion (rootDir rm'd) destroyed — cross-check it
|
|
419
|
+
// against the empty (GENESIS-tipped, seq -1) chain before passing.
|
|
420
|
+
const empty = await crossCheckExternalAnchor(rootDir, options, GENESIS_HASH, -1);
|
|
421
|
+
if (empty !== undefined && !empty.ok) {
|
|
422
|
+
return {
|
|
423
|
+
ok: false,
|
|
424
|
+
recordsChecked: 0,
|
|
425
|
+
file: empty.file,
|
|
426
|
+
line: empty.line,
|
|
427
|
+
reason: empty.reason,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
return {
|
|
431
|
+
ok: true,
|
|
432
|
+
recordsChecked: 0,
|
|
433
|
+
anchorChecked: false,
|
|
434
|
+
externalAnchorChecked: empty?.externalAnchorChecked ?? false,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
199
437
|
const files = readdirSync(rootDir)
|
|
200
438
|
.filter((f) => f.endsWith(".jsonl"))
|
|
201
439
|
.sort();
|
|
202
440
|
let prevHash = GENESIS_HASH;
|
|
441
|
+
let expectedSeq = 0;
|
|
203
442
|
let recordsChecked = 0;
|
|
204
443
|
for (const f of files) {
|
|
205
444
|
const file = join(rootDir, f);
|
|
@@ -235,7 +474,18 @@ export async function verify(rootDir: string): Promise<VerifyResult> {
|
|
|
235
474
|
reason: `prevHash mismatch — expected "${prevHash}", got "${r.prevHash}"`,
|
|
236
475
|
};
|
|
237
476
|
}
|
|
238
|
-
|
|
477
|
+
if (r.seq !== expectedSeq) {
|
|
478
|
+
stream.close();
|
|
479
|
+
rl.close();
|
|
480
|
+
return {
|
|
481
|
+
ok: false,
|
|
482
|
+
recordsChecked,
|
|
483
|
+
file,
|
|
484
|
+
line: lineNumber,
|
|
485
|
+
reason: `seq gap — expected ${expectedSeq}, got ${r.seq}`,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const body = { ts: r.ts, version: r.version, kind: r.kind, seq: r.seq, payload: r.payload };
|
|
239
489
|
const expected = hashBody(body, r.prevHash);
|
|
240
490
|
if (r.hash !== expected) {
|
|
241
491
|
stream.close();
|
|
@@ -249,6 +499,7 @@ export async function verify(rootDir: string): Promise<VerifyResult> {
|
|
|
249
499
|
};
|
|
250
500
|
}
|
|
251
501
|
prevHash = r.hash;
|
|
502
|
+
expectedSeq = r.seq + 1;
|
|
252
503
|
recordsChecked += 1;
|
|
253
504
|
}
|
|
254
505
|
} finally {
|
|
@@ -256,5 +507,143 @@ export async function verify(rootDir: string): Promise<VerifyResult> {
|
|
|
256
507
|
stream.close();
|
|
257
508
|
}
|
|
258
509
|
}
|
|
259
|
-
|
|
510
|
+
|
|
511
|
+
const lastSeq = expectedSeq - 1;
|
|
512
|
+
|
|
513
|
+
// Off-host anchor check FIRST: the external store is the only commitment a
|
|
514
|
+
// same-uid attacker cannot rewrite, so it is what catches a truncation that
|
|
515
|
+
// also rewrote `_chain-tail.json` in lockstep. (See file header.)
|
|
516
|
+
const external = await crossCheckExternalAnchor(rootDir, options, prevHash, lastSeq);
|
|
517
|
+
if (external !== undefined && !external.ok) {
|
|
518
|
+
return {
|
|
519
|
+
ok: false,
|
|
520
|
+
recordsChecked,
|
|
521
|
+
file: external.file,
|
|
522
|
+
line: external.line,
|
|
523
|
+
reason: external.reason,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
const externalAnchorChecked = external?.externalAnchorChecked ?? false;
|
|
527
|
+
|
|
528
|
+
// On-host anchor check: compare the surviving chain's last { hash, seq }
|
|
529
|
+
// against the independent _chain-tail.json. A truncation that drops trailing
|
|
530
|
+
// records leaves the survivors internally consistent, so this anchor — not
|
|
531
|
+
// the walk above — is what catches it. A missing anchor is reported
|
|
532
|
+
// (anchorChecked: false), never silently treated as a clean pass. NOTE: an
|
|
533
|
+
// on-host anchor is rewritable by a same-uid attacker (see header); it
|
|
534
|
+
// defends only against truncation by a party that does NOT also rewrite the
|
|
535
|
+
// anchor in lockstep — which is exactly what the external anchor above adds.
|
|
536
|
+
// A corrupt/partially-written `_chain-tail.json` (truncated by a crash, or
|
|
537
|
+
// garbled by an attacker) must be REPORTED as a broken link, not crash the
|
|
538
|
+
// verifier with an unhandled JSON.parse throw — mirroring how a malformed
|
|
539
|
+
// JSONL line is surfaced above. Throwing here would let a one-byte anchor
|
|
540
|
+
// corruption turn `verify` into a denial of service.
|
|
541
|
+
let tail: ChainTail | undefined;
|
|
542
|
+
try {
|
|
543
|
+
tail = readChainTail(rootDir);
|
|
544
|
+
} catch (err) {
|
|
545
|
+
return {
|
|
546
|
+
ok: false,
|
|
547
|
+
recordsChecked,
|
|
548
|
+
file: indexPath(rootDir),
|
|
549
|
+
line: 0,
|
|
550
|
+
reason: `chain-tail anchor unreadable — ${(err as Error).message}`,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
if (tail === undefined) {
|
|
554
|
+
return { ok: true, recordsChecked, anchorChecked: false, externalAnchorChecked };
|
|
555
|
+
}
|
|
556
|
+
if (tail.hash !== prevHash) {
|
|
557
|
+
return {
|
|
558
|
+
ok: false,
|
|
559
|
+
recordsChecked,
|
|
560
|
+
file: indexPath(rootDir),
|
|
561
|
+
line: 0,
|
|
562
|
+
reason:
|
|
563
|
+
`chain-tail anchor mismatch — anchor records hash "${tail.hash}" ` +
|
|
564
|
+
`but surviving chain ends at "${prevHash}" (records were dropped from the tail)`,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
// The seq field is optional in legacy anchors; only assert when present.
|
|
568
|
+
if (typeof tail.seq === "number" && tail.seq !== lastSeq) {
|
|
569
|
+
return {
|
|
570
|
+
ok: false,
|
|
571
|
+
recordsChecked,
|
|
572
|
+
file: indexPath(rootDir),
|
|
573
|
+
line: 0,
|
|
574
|
+
reason:
|
|
575
|
+
`chain-tail anchor mismatch — anchor records seq ${tail.seq} ` +
|
|
576
|
+
`but surviving chain ends at seq ${lastSeq} (records were dropped from the tail)`,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
return { ok: true, recordsChecked, anchorChecked: true, externalAnchorChecked };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Cross-check the surviving chain tip (`tipHash` at `lastSeq`; an empty
|
|
584
|
+
* chain is `GENESIS_HASH` at `-1`) against an off-host {@link AnchorStore}.
|
|
585
|
+
*
|
|
586
|
+
* Threat model — the store witnesses the highest tail the writer published.
|
|
587
|
+
* - external `seq > lastSeq` → the chain no longer reaches a record the
|
|
588
|
+
* anchor saw ⇒ tail truncation (even if `_chain-tail.json` was rewritten
|
|
589
|
+
* in lockstep, which the attacker cannot do to the external store) → FAIL.
|
|
590
|
+
* - external `seq === lastSeq` but `hash !== tipHash` → the tip was rewritten
|
|
591
|
+
* in place at the anchored height → FAIL.
|
|
592
|
+
* - external `seq < lastSeq` → the anchor merely lags behind newer appends
|
|
593
|
+
* (benign best-effort `putAnchor` lag) → pass.
|
|
594
|
+
*
|
|
595
|
+
* Returns `undefined` when no store was supplied; otherwise a partial result
|
|
596
|
+
* carrying `externalAnchorChecked` (true once an anchor was actually read and
|
|
597
|
+
* compared) plus, on mismatch, the failure fields. A `getAnchor` that throws
|
|
598
|
+
* is treated as "anchor unavailable" (not consulted), not as tamper.
|
|
599
|
+
*/
|
|
600
|
+
type ExternalAnchorCheck =
|
|
601
|
+
| { readonly ok: true; readonly externalAnchorChecked: boolean }
|
|
602
|
+
| {
|
|
603
|
+
readonly ok: false;
|
|
604
|
+
readonly file: string;
|
|
605
|
+
readonly line: number;
|
|
606
|
+
readonly reason: string;
|
|
607
|
+
readonly externalAnchorChecked: boolean;
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
async function crossCheckExternalAnchor(
|
|
611
|
+
rootDir: string,
|
|
612
|
+
options: VerifyOptions,
|
|
613
|
+
tipHash: string,
|
|
614
|
+
lastSeq: number,
|
|
615
|
+
): Promise<ExternalAnchorCheck | undefined> {
|
|
616
|
+
const store = options.anchorStore;
|
|
617
|
+
if (store === undefined) return undefined;
|
|
618
|
+
const logId = options.logId ?? rootDir;
|
|
619
|
+
let anchor: AnchorRecord | undefined;
|
|
620
|
+
try {
|
|
621
|
+
anchor = await store.getAnchor(logId);
|
|
622
|
+
} catch {
|
|
623
|
+
// Anchor backend unavailable: cannot rule the truncation gap in OR out,
|
|
624
|
+
// so report "not checked" rather than fabricating a pass or a failure.
|
|
625
|
+
return { ok: true, externalAnchorChecked: false };
|
|
626
|
+
}
|
|
627
|
+
if (anchor === undefined) return { ok: true, externalAnchorChecked: false };
|
|
628
|
+
|
|
629
|
+
if (anchor.seq > lastSeq) {
|
|
630
|
+
return {
|
|
631
|
+
ok: false,
|
|
632
|
+
file: "<external-anchor>",
|
|
633
|
+
line: 0,
|
|
634
|
+
reason: `external anchor mismatch — store witnesses seq ${anchor.seq} (hash "${anchor.hash}") but surviving chain ends at seq ${lastSeq} (records were dropped from the tail and the on-host anchor rewritten in lockstep)`,
|
|
635
|
+
externalAnchorChecked: true,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
if (anchor.seq === lastSeq && anchor.hash !== tipHash) {
|
|
639
|
+
return {
|
|
640
|
+
ok: false,
|
|
641
|
+
file: "<external-anchor>",
|
|
642
|
+
line: 0,
|
|
643
|
+
reason: `external anchor mismatch — store records hash "${anchor.hash}" at seq ${anchor.seq} but surviving chain tip is "${tipHash}" (the tail record was rewritten in place)`,
|
|
644
|
+
externalAnchorChecked: true,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
// anchor.seq < lastSeq → benign lag; or matched tip → consistent.
|
|
648
|
+
return { ok: true, externalAnchorChecked: true };
|
|
260
649
|
}
|