@mcp-audit-gateway/core 0.1.0 → 0.4.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/.github/workflows/ci.yml +37 -0
- package/.well-known/agent-governance.json +47 -0
- package/.well-known/security-insights-snippet.yml +9 -0
- package/CHANGELOG.md +32 -0
- package/README.md +31 -3
- package/dist/attestation/audit-log.d.ts +35 -3
- package/dist/attestation/audit-log.d.ts.map +1 -1
- package/dist/attestation/audit-log.js +303 -8
- package/dist/attestation/audit-log.js.map +1 -1
- package/dist/attestation/checkpoint.test.d.ts +2 -0
- package/dist/attestation/checkpoint.test.d.ts.map +1 -0
- package/dist/attestation/checkpoint.test.js +870 -0
- package/dist/attestation/checkpoint.test.js.map +1 -0
- package/dist/attestation/signer.d.ts +24 -9
- package/dist/attestation/signer.d.ts.map +1 -1
- package/dist/attestation/signer.js +151 -10
- package/dist/attestation/signer.js.map +1 -1
- package/dist/attestation/signer.test.js +83 -0
- package/dist/attestation/signer.test.js.map +1 -1
- package/dist/attestation/verify.d.ts +23 -2
- package/dist/attestation/verify.d.ts.map +1 -1
- package/dist/attestation/verify.js +219 -2
- package/dist/attestation/verify.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/integration.test.js +1 -0
- package/dist/integration.test.js.map +1 -1
- package/dist/policy/engine.d.ts +12 -1
- package/dist/policy/engine.d.ts.map +1 -1
- package/dist/policy/engine.js +26 -4
- package/dist/policy/engine.js.map +1 -1
- package/dist/policy/engine.test.js +42 -1
- package/dist/policy/engine.test.js.map +1 -1
- package/dist/proxy/gateway.d.ts +4 -0
- package/dist/proxy/gateway.d.ts.map +1 -1
- package/dist/proxy/gateway.js +9 -2
- package/dist/proxy/gateway.js.map +1 -1
- package/dist/proxy/gateway.test.js +19 -0
- package/dist/proxy/gateway.test.js.map +1 -1
- package/dist/proxy/mcp-server-adapter.d.ts +1 -0
- package/dist/proxy/mcp-server-adapter.d.ts.map +1 -1
- package/dist/proxy/mcp-server-adapter.js +28 -1
- package/dist/proxy/mcp-server-adapter.js.map +1 -1
- package/dist/proxy/mcp-server-adapter.test.js +1 -0
- package/dist/proxy/mcp-server-adapter.test.js.map +1 -1
- package/dist/types.d.ts +81 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -1
- package/dist/wrap/proxy.test.js +2 -2
- package/dist/wrap/proxy.test.js.map +1 -1
- package/docs/BACKLOG.md +33 -0
- package/docs/SECURITY-DESIGN.md +126 -0
- package/docs/v0.4.0-patch-audit.md +115 -0
- package/package.json +1 -1
- package/src/attestation/audit-log.ts +357 -13
- package/src/attestation/checkpoint.test.ts +956 -0
- package/src/attestation/signer.test.ts +98 -0
- package/src/attestation/signer.ts +159 -19
- package/src/attestation/verify.ts +270 -4
- package/src/index.ts +1 -1
- package/src/integration.test.ts +1 -0
- package/src/policy/engine.test.ts +47 -1
- package/src/policy/engine.ts +38 -5
- package/src/proxy/gateway.test.ts +18 -0
- package/src/proxy/gateway.ts +10 -1
- package/src/proxy/mcp-server-adapter.test.ts +1 -0
- package/src/proxy/mcp-server-adapter.ts +26 -0
- package/src/types.ts +56 -0
- package/src/wrap/proxy.test.ts +2 -2
- package/test/vectors/README.md +44 -0
- package/test/vectors/aps-action-ref-v1-vectors.json +351 -0
- package/test/vectors/aps-action-ref-v1.mjs +145 -0
- package/test/vectors/canonicalization.json +764 -0
- package/test/vectors/checkpoint.json +450 -0
- package/test/vectors/generate.mjs +354 -0
- package/test/vectors/verify-checkpoint.mjs +344 -0
- package/test/vectors/verify-checkpoint.py +358 -0
- package/test/vectors/verify.mjs +346 -0
- package/test/vectors/verify.py +354 -0
|
@@ -1,17 +1,57 @@
|
|
|
1
|
-
import { appendFile, stat, rename, open } from "node:fs/promises";
|
|
1
|
+
import { appendFile, stat, rename, open, writeFile as fsWriteFile } from "node:fs/promises";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import type { AuditRecord } from "../types.js";
|
|
3
|
+
import type { AuditRecord, CheckpointRecord, ChainBreakRecord, PartyAttribution, ChainRecord } from "../types.js";
|
|
4
|
+
import { isCheckpoint, isChainBreak } from "../types.js";
|
|
4
5
|
import type { Signer } from "./signer.js";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
const GATEWAY_WITNESSED_FIELDS = [
|
|
8
|
+
"id", "timestamp", "method", "toolName", "namespace",
|
|
9
|
+
"upstream", "principal", "durationMs", "success", "errorCode",
|
|
10
|
+
"previousHash",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function buildParties(hasDecisionContext: boolean, hasAiInvocation: boolean): PartyAttribution[] {
|
|
14
|
+
const parties: PartyAttribution[] = [
|
|
15
|
+
{ party: "gateway", role: "witness", scope: GATEWAY_WITNESSED_FIELDS },
|
|
16
|
+
];
|
|
17
|
+
if (hasDecisionContext) {
|
|
18
|
+
parties.push({
|
|
19
|
+
party: "policy-engine",
|
|
20
|
+
role: "asserter",
|
|
21
|
+
scope: ["decisionContextDigest"],
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
if (hasAiInvocation) {
|
|
25
|
+
parties.push({
|
|
26
|
+
party: "client",
|
|
27
|
+
role: "asserter",
|
|
28
|
+
scope: ["aiInvocation"],
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return parties;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function hashRecord(record: AuditRecord | CheckpointRecord | ChainBreakRecord): string {
|
|
7
35
|
const json = JSON.stringify(record);
|
|
8
36
|
return createHash("sha256").update(json).digest("hex");
|
|
9
37
|
}
|
|
10
38
|
|
|
39
|
+
export interface CheckpointConfig {
|
|
40
|
+
enabled: boolean;
|
|
41
|
+
intervalRecords: number;
|
|
42
|
+
intervalSeconds: number;
|
|
43
|
+
trigger: "records" | "time" | "whichever_first";
|
|
44
|
+
}
|
|
45
|
+
|
|
11
46
|
export class AuditLog {
|
|
12
47
|
private currentSize = 0;
|
|
13
48
|
private lastHash: string = "genesis";
|
|
14
49
|
private writeQueue: Promise<void> = Promise.resolve();
|
|
50
|
+
private recordsSinceCheckpoint = 0;
|
|
51
|
+
private lastCheckpointTime = Date.now();
|
|
52
|
+
private checkpointSequence = 0;
|
|
53
|
+
private totalRecordCount = 0;
|
|
54
|
+
private checkpointConfig: CheckpointConfig | null = null;
|
|
15
55
|
|
|
16
56
|
constructor(
|
|
17
57
|
private path: string,
|
|
@@ -19,6 +59,10 @@ export class AuditLog {
|
|
|
19
59
|
private rotateAfterBytes: number,
|
|
20
60
|
) {}
|
|
21
61
|
|
|
62
|
+
enableCheckpoints(config: CheckpointConfig): void {
|
|
63
|
+
this.checkpointConfig = config;
|
|
64
|
+
}
|
|
65
|
+
|
|
22
66
|
async record(
|
|
23
67
|
method: string,
|
|
24
68
|
opts: {
|
|
@@ -29,6 +73,8 @@ export class AuditLog {
|
|
|
29
73
|
durationMs: number;
|
|
30
74
|
success: boolean;
|
|
31
75
|
errorCode?: number;
|
|
76
|
+
decisionContextDigest?: string;
|
|
77
|
+
aiInvocation?: { turnId?: string; invocationReason?: string; model?: string };
|
|
32
78
|
},
|
|
33
79
|
): Promise<AuditRecord> {
|
|
34
80
|
return new Promise((resolve, reject) => {
|
|
@@ -53,13 +99,18 @@ export class AuditLog {
|
|
|
53
99
|
durationMs: number;
|
|
54
100
|
success: boolean;
|
|
55
101
|
errorCode?: number;
|
|
102
|
+
decisionContextDigest?: string;
|
|
103
|
+
aiInvocation?: { turnId?: string; invocationReason?: string; model?: string };
|
|
56
104
|
},
|
|
57
105
|
): Promise<AuditRecord> {
|
|
106
|
+
const { aiInvocation, ...rest } = opts;
|
|
58
107
|
const record: AuditRecord = {
|
|
59
108
|
id: randomUUID(),
|
|
60
109
|
timestamp: new Date().toISOString(),
|
|
61
110
|
method,
|
|
62
|
-
...
|
|
111
|
+
...rest,
|
|
112
|
+
...(aiInvocation ? { aiInvocation } : {}),
|
|
113
|
+
parties: buildParties(opts.decisionContextDigest != null, aiInvocation != null),
|
|
63
114
|
previousHash: this.lastHash,
|
|
64
115
|
};
|
|
65
116
|
|
|
@@ -70,6 +121,12 @@ export class AuditLog {
|
|
|
70
121
|
this.currentSize += Buffer.byteLength(line);
|
|
71
122
|
|
|
72
123
|
this.lastHash = hashRecord(record);
|
|
124
|
+
this.totalRecordCount++;
|
|
125
|
+
this.recordsSinceCheckpoint++;
|
|
126
|
+
|
|
127
|
+
if (this.shouldEmitCheckpoint()) {
|
|
128
|
+
await this.writeCheckpoint();
|
|
129
|
+
}
|
|
73
130
|
|
|
74
131
|
if (this.currentSize >= this.rotateAfterBytes) {
|
|
75
132
|
await this.rotate();
|
|
@@ -78,49 +135,336 @@ export class AuditLog {
|
|
|
78
135
|
return record;
|
|
79
136
|
}
|
|
80
137
|
|
|
138
|
+
private shouldEmitCheckpoint(): boolean {
|
|
139
|
+
if (!this.checkpointConfig?.enabled) return false;
|
|
140
|
+
const { trigger, intervalRecords, intervalSeconds } = this.checkpointConfig;
|
|
141
|
+
const recordsHit = this.recordsSinceCheckpoint >= intervalRecords;
|
|
142
|
+
const timeHit = (Date.now() - this.lastCheckpointTime) >= intervalSeconds * 1000;
|
|
143
|
+
|
|
144
|
+
if (trigger === "records") return recordsHit;
|
|
145
|
+
if (trigger === "time") return timeHit;
|
|
146
|
+
return recordsHit || timeHit;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async writeCheckpoint(): Promise<CheckpointRecord> {
|
|
150
|
+
this.checkpointSequence++;
|
|
151
|
+
const checkpoint: CheckpointRecord = {
|
|
152
|
+
id: `ckpt_${randomUUID()}`,
|
|
153
|
+
type: "checkpoint",
|
|
154
|
+
timestamp: new Date().toISOString(),
|
|
155
|
+
sequence: this.checkpointSequence,
|
|
156
|
+
recordCount: this.totalRecordCount,
|
|
157
|
+
previousHash: this.lastHash,
|
|
158
|
+
parties: [{ party: "gateway", role: "witness", scope: ["sequence", "recordCount", "previousHash"] }],
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
checkpoint.attestation = await this.signer.sign(checkpoint);
|
|
162
|
+
const line = JSON.stringify(checkpoint) + "\n";
|
|
163
|
+
|
|
164
|
+
await appendFile(this.path, line);
|
|
165
|
+
this.currentSize += Buffer.byteLength(line);
|
|
166
|
+
|
|
167
|
+
this.lastHash = hashRecord(checkpoint);
|
|
168
|
+
this.recordsSinceCheckpoint = 0;
|
|
169
|
+
this.lastCheckpointTime = Date.now();
|
|
170
|
+
|
|
171
|
+
return checkpoint;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async emitCheckpoint(): Promise<CheckpointRecord> {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
this.writeQueue = this.writeQueue.then(async () => {
|
|
177
|
+
try {
|
|
178
|
+
const result = await this.writeCheckpoint();
|
|
179
|
+
resolve(result);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
reject(err);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
getLastHash(): string {
|
|
188
|
+
return this.lastHash;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
getCheckpointSequence(): number {
|
|
192
|
+
return this.checkpointSequence;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
getRecordCount(): number {
|
|
196
|
+
return this.totalRecordCount;
|
|
197
|
+
}
|
|
198
|
+
|
|
81
199
|
private async rotate(): Promise<void> {
|
|
82
200
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
83
201
|
const rotatedPath = this.path.replace(/\.jsonl$/, `-${ts}.jsonl`);
|
|
84
202
|
try {
|
|
85
203
|
await rename(this.path, rotatedPath);
|
|
86
204
|
this.currentSize = 0;
|
|
87
|
-
|
|
205
|
+
// Chain-global: lastHash, sequence, recordCount survive rotation.
|
|
206
|
+
// The first record in the new file chains to the last hash of the
|
|
207
|
+
// previous file. Resetting to "genesis" would allow truncation-via-rotation.
|
|
208
|
+
await this.persistState();
|
|
88
209
|
} catch {}
|
|
89
210
|
}
|
|
90
211
|
|
|
91
|
-
|
|
212
|
+
private rotationBoundaryHash: string = "genesis";
|
|
213
|
+
|
|
214
|
+
private async persistState(): Promise<void> {
|
|
215
|
+
const statePath = this.path.replace(/\.jsonl$/, ".state.json");
|
|
216
|
+
const tmpPath = statePath + ".tmp";
|
|
217
|
+
const state = {
|
|
218
|
+
lastHash: this.lastHash,
|
|
219
|
+
rotationBoundaryHash: this.lastHash,
|
|
220
|
+
checkpointSequence: this.checkpointSequence,
|
|
221
|
+
totalRecordCount: this.totalRecordCount,
|
|
222
|
+
};
|
|
223
|
+
await fsWriteFile(tmpPath, JSON.stringify(state));
|
|
224
|
+
await rename(tmpPath, statePath);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private async persistChainState(): Promise<void> {
|
|
228
|
+
const statePath = this.path.replace(/\.jsonl$/, ".state.json");
|
|
229
|
+
const tmpPath = statePath + ".tmp";
|
|
230
|
+
const state = {
|
|
231
|
+
lastHash: this.lastHash,
|
|
232
|
+
rotationBoundaryHash: this.rotationBoundaryHash,
|
|
233
|
+
checkpointSequence: this.checkpointSequence,
|
|
234
|
+
totalRecordCount: this.totalRecordCount,
|
|
235
|
+
};
|
|
236
|
+
await fsWriteFile(tmpPath, JSON.stringify(state));
|
|
237
|
+
await rename(tmpPath, statePath);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async recoverState(): Promise<"found" | "not_found" | "corrupt"> {
|
|
241
|
+
const statePath = this.path.replace(/\.jsonl$/, ".state.json");
|
|
242
|
+
try {
|
|
243
|
+
const { readFile } = await import("node:fs/promises");
|
|
244
|
+
const raw = await readFile(statePath, "utf-8");
|
|
245
|
+
const state = JSON.parse(raw);
|
|
246
|
+
if (typeof state.lastHash !== "string" || typeof state.checkpointSequence !== "number" ||
|
|
247
|
+
typeof state.rotationBoundaryHash !== "string") {
|
|
248
|
+
return "corrupt";
|
|
249
|
+
}
|
|
250
|
+
this.lastHash = state.lastHash;
|
|
251
|
+
this.rotationBoundaryHash = state.rotationBoundaryHash;
|
|
252
|
+
this.checkpointSequence = state.checkpointSequence ?? 0;
|
|
253
|
+
this.totalRecordCount = state.totalRecordCount ?? 0;
|
|
254
|
+
return "found";
|
|
255
|
+
} catch (err: unknown) {
|
|
256
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
257
|
+
if (code === "ENOENT") return "not_found";
|
|
258
|
+
return "corrupt";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async init(opts?: { forceNewChain?: boolean }): Promise<void> {
|
|
263
|
+
const stateResult = await this.recoverState();
|
|
264
|
+
|
|
265
|
+
let logExists = false;
|
|
92
266
|
let s;
|
|
93
267
|
try {
|
|
94
268
|
s = await stat(this.path);
|
|
269
|
+
logExists = true;
|
|
95
270
|
} catch {
|
|
271
|
+
logExists = false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Three-way startup:
|
|
275
|
+
// (a) No state file, no log → genuine first run
|
|
276
|
+
if (stateResult === "not_found" && !logExists) {
|
|
96
277
|
this.currentSize = 0;
|
|
97
278
|
return;
|
|
98
279
|
}
|
|
99
280
|
|
|
100
|
-
|
|
281
|
+
// (c) State file corrupt or exists but unparseable → refuse unless forced
|
|
282
|
+
if (stateResult === "corrupt") {
|
|
283
|
+
if (opts?.forceNewChain) {
|
|
284
|
+
await this.emitChainBreak("state_file_corrupt");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
throw new Error(
|
|
288
|
+
"[audit-log] state file corrupt or inconsistent. " +
|
|
289
|
+
"Use --force-new-chain to start a new chain (emits a signed chain_break record)."
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// (b) State file present and parseable → base+delta resume
|
|
294
|
+
if (!logExists) {
|
|
295
|
+
this.currentSize = 0;
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
101
298
|
|
|
102
|
-
|
|
103
|
-
|
|
299
|
+
this.currentSize = s!.size;
|
|
300
|
+
|
|
301
|
+
if (s!.size > 0) {
|
|
302
|
+
const boundaryHash = stateResult === "found" ? this.rotationBoundaryHash : undefined;
|
|
104
303
|
const fh = await open(this.path, "r");
|
|
105
304
|
try {
|
|
305
|
+
let headBuf = Buffer.alloc(0);
|
|
306
|
+
let offset = 0;
|
|
307
|
+
const chunkSize = 4096;
|
|
308
|
+
const maxFirstLine = 1024 * 1024;
|
|
309
|
+
let firstLine: string | null = null;
|
|
310
|
+
while (offset < s!.size) {
|
|
311
|
+
if (headBuf.length >= maxFirstLine) {
|
|
312
|
+
if (opts?.forceNewChain) {
|
|
313
|
+
await this.emitChainBreak("log_first_record_oversize");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
throw new Error(
|
|
317
|
+
"[audit-log] first record exceeds 1MB cap (no newline found within limit). " +
|
|
318
|
+
"Use --force-new-chain to start a new chain."
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
const readSize = Math.min(chunkSize, s!.size - offset);
|
|
322
|
+
const chunk = Buffer.alloc(readSize);
|
|
323
|
+
await fh.read(chunk, 0, readSize, offset);
|
|
324
|
+
headBuf = Buffer.concat([headBuf, chunk]);
|
|
325
|
+
const text = headBuf.toString("utf-8");
|
|
326
|
+
const nlIdx = text.indexOf("\n");
|
|
327
|
+
if (nlIdx !== -1) {
|
|
328
|
+
firstLine = text.slice(0, nlIdx);
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
offset += readSize;
|
|
332
|
+
}
|
|
333
|
+
if (firstLine === null) {
|
|
334
|
+
const text = headBuf.toString("utf-8").trim();
|
|
335
|
+
if (text.length === 0) {
|
|
336
|
+
if (opts?.forceNewChain) {
|
|
337
|
+
await this.emitChainBreak("log_empty_no_newline");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
throw new Error(
|
|
341
|
+
"[audit-log] log file exists but contains no parseable content. " +
|
|
342
|
+
"Use --force-new-chain to start a new chain."
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
firstLine = text;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Parse first record — if it doesn't parse, the log is corrupt
|
|
349
|
+
let firstRecordPrevHash: string | undefined;
|
|
350
|
+
let firstRecordIsBreak = false;
|
|
351
|
+
try {
|
|
352
|
+
const firstRecord = JSON.parse(firstLine);
|
|
353
|
+
if (firstRecord.type === "chain_break") {
|
|
354
|
+
firstRecordIsBreak = true;
|
|
355
|
+
firstRecordPrevHash = firstRecord.priorHead;
|
|
356
|
+
} else {
|
|
357
|
+
firstRecordPrevHash = firstRecord.previousHash;
|
|
358
|
+
}
|
|
359
|
+
} catch {
|
|
360
|
+
if (opts?.forceNewChain) {
|
|
361
|
+
await this.emitChainBreak("log_first_record_corrupt");
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
throw new Error(
|
|
365
|
+
"[audit-log] first record in log is corrupt (unparseable JSON). " +
|
|
366
|
+
"Use --force-new-chain to start a new chain."
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Read tail: recover chain head and checkpoint state
|
|
371
|
+
const tailSize = Math.min(s!.size, 8192);
|
|
106
372
|
const buf = Buffer.alloc(tailSize);
|
|
107
|
-
await fh.read(buf, 0, tailSize, s
|
|
373
|
+
await fh.read(buf, 0, tailSize, s!.size - tailSize);
|
|
108
374
|
const tail = buf.toString("utf-8");
|
|
109
375
|
const lines = tail.trimEnd().split("\n");
|
|
376
|
+
let foundLast = false;
|
|
377
|
+
let recordCountFromTail = 0;
|
|
378
|
+
let tailMaxSequence = 0;
|
|
379
|
+
|
|
110
380
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
111
381
|
try {
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
382
|
+
const parsed = JSON.parse(lines[i]);
|
|
383
|
+
recordCountFromTail++;
|
|
384
|
+
|
|
385
|
+
if (!foundLast) {
|
|
386
|
+
this.lastHash = hashRecord(parsed);
|
|
387
|
+
foundLast = true;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (isCheckpoint(parsed)) {
|
|
391
|
+
if (parsed.sequence > tailMaxSequence) {
|
|
392
|
+
tailMaxSequence = parsed.sequence;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
115
395
|
} catch {
|
|
116
396
|
process.stderr.write(
|
|
117
397
|
`[audit-log] warning: corrupt record in tail, skipping\n`,
|
|
118
398
|
);
|
|
119
399
|
}
|
|
120
400
|
}
|
|
401
|
+
|
|
402
|
+
if (tailMaxSequence > this.checkpointSequence) {
|
|
403
|
+
this.checkpointSequence = tailMaxSequence;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (boundaryHash !== undefined && firstRecordPrevHash !== undefined &&
|
|
407
|
+
firstRecordPrevHash !== boundaryHash) {
|
|
408
|
+
if (opts?.forceNewChain) {
|
|
409
|
+
await this.emitChainBreak("state_log_inconsistent");
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
throw new Error(
|
|
413
|
+
"[audit-log] state file inconsistent with log: first record does not " +
|
|
414
|
+
"chain from rotationBoundaryHash. Use --force-new-chain to start a new chain."
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (stateResult === "not_found") {
|
|
419
|
+
this.totalRecordCount = recordCountFromTail;
|
|
420
|
+
}
|
|
121
421
|
} finally {
|
|
122
422
|
await fh.close();
|
|
123
423
|
}
|
|
124
424
|
}
|
|
125
425
|
}
|
|
426
|
+
|
|
427
|
+
private async emitChainBreak(reason: string): Promise<ChainBreakRecord> {
|
|
428
|
+
const record: ChainBreakRecord = {
|
|
429
|
+
id: `break_${randomUUID()}`,
|
|
430
|
+
type: "chain_break",
|
|
431
|
+
timestamp: new Date().toISOString(),
|
|
432
|
+
reason,
|
|
433
|
+
priorHead: this.lastHash !== "genesis" ? this.lastHash : undefined,
|
|
434
|
+
priorSequence: this.checkpointSequence > 0 ? this.checkpointSequence : undefined,
|
|
435
|
+
priorRecordCount: this.totalRecordCount > 0 ? this.totalRecordCount : undefined,
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
record.attestation = await this.signer.sign(record);
|
|
439
|
+
const line = JSON.stringify(record) + "\n";
|
|
440
|
+
|
|
441
|
+
await appendFile(this.path, line);
|
|
442
|
+
this.currentSize += Buffer.byteLength(line);
|
|
443
|
+
|
|
444
|
+
// Reset chain state — this is a new chain.
|
|
445
|
+
// rotationBoundaryHash is NOT updated: it tracks the file boundary,
|
|
446
|
+
// not the chain boundary. The linkage check compares the first record
|
|
447
|
+
// of a file against rotationBoundaryHash, so mid-file breaks must not
|
|
448
|
+
// overwrite it.
|
|
449
|
+
this.lastHash = hashRecord(record);
|
|
450
|
+
this.checkpointSequence = 0;
|
|
451
|
+
this.totalRecordCount = 0;
|
|
452
|
+
this.recordsSinceCheckpoint = 0;
|
|
453
|
+
|
|
454
|
+
await this.persistChainState();
|
|
455
|
+
return record;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async forceNewChain(reason: string): Promise<ChainBreakRecord> {
|
|
459
|
+
return new Promise((resolve, reject) => {
|
|
460
|
+
this.writeQueue = this.writeQueue.then(async () => {
|
|
461
|
+
try {
|
|
462
|
+
const result = await this.emitChainBreak(reason);
|
|
463
|
+
resolve(result);
|
|
464
|
+
} catch (err) {
|
|
465
|
+
reject(err);
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
}
|
|
126
470
|
}
|