@crewhaus/compliance-controls 0.1.1 → 0.1.3
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 +7 -12
- package/src/index.test.ts +193 -17
- package/src/index.ts +120 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/compliance-controls",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SOC 2 / ISO 27001 / HIPAA control-evidence collection. Walks audit-log per control, writes signed evidence bundles (Section 39)",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
"test": "bun test src"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@crewhaus/audit-log": "0.1.
|
|
16
|
-
"@crewhaus/errors": "0.1.
|
|
15
|
+
"@crewhaus/audit-log": "0.1.3",
|
|
16
|
+
"@crewhaus/errors": "0.1.3"
|
|
17
17
|
},
|
|
18
18
|
"license": "Apache-2.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Max Meier",
|
|
21
|
-
"email": "max@
|
|
22
|
-
"url": "https://
|
|
21
|
+
"email": "max@crewhaus.ai",
|
|
22
|
+
"url": "https://crewhaus.ai"
|
|
23
23
|
},
|
|
24
24
|
"repository": {
|
|
25
25
|
"type": "git",
|
|
@@ -31,12 +31,7 @@
|
|
|
31
31
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
32
32
|
},
|
|
33
33
|
"publishConfig": {
|
|
34
|
-
"access": "
|
|
34
|
+
"access": "public"
|
|
35
35
|
},
|
|
36
|
-
"files": [
|
|
37
|
-
"src",
|
|
38
|
-
"README.md",
|
|
39
|
-
"LICENSE",
|
|
40
|
-
"NOTICE"
|
|
41
|
-
]
|
|
36
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
42
37
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import type
|
|
5
|
+
import { type AuditRecord, recomputeRecordHash } from "@crewhaus/audit-log";
|
|
6
6
|
import {
|
|
7
7
|
type AuditRecordSource,
|
|
8
8
|
BUILT_IN_CONTROLS,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
_matchesForTest,
|
|
16
16
|
_signDigestForTest,
|
|
17
17
|
createComplianceCollector,
|
|
18
|
+
verifyBundle,
|
|
18
19
|
} from "./index";
|
|
19
20
|
|
|
20
21
|
function makeRecord(
|
|
@@ -23,6 +24,7 @@ function makeRecord(
|
|
|
23
24
|
return {
|
|
24
25
|
ts: partial.ts ?? Date.now(),
|
|
25
26
|
version: 1,
|
|
27
|
+
seq: partial.seq ?? 0,
|
|
26
28
|
kind: partial.kind,
|
|
27
29
|
payload: partial.payload,
|
|
28
30
|
prevHash: partial.prevHash ?? "GENESIS",
|
|
@@ -30,6 +32,28 @@ function makeRecord(
|
|
|
30
32
|
};
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
// Build a properly hash-chained, valid sequence (seq 0,1,…; prevHash links;
|
|
36
|
+
// real hashes) — collect() now verifies the chain, so its evidence must be
|
|
37
|
+
// genuine. The first record links to GENESIS.
|
|
38
|
+
function chainOf(partials: ReadonlyArray<Pick<AuditRecord, "kind" | "payload">>): AuditRecord[] {
|
|
39
|
+
const out: AuditRecord[] = [];
|
|
40
|
+
let prevHash = "GENESIS";
|
|
41
|
+
partials.forEach((p, seq) => {
|
|
42
|
+
const base = {
|
|
43
|
+
ts: 1000 + seq,
|
|
44
|
+
version: 1 as const,
|
|
45
|
+
kind: p.kind,
|
|
46
|
+
seq,
|
|
47
|
+
payload: p.payload,
|
|
48
|
+
prevHash,
|
|
49
|
+
};
|
|
50
|
+
const hash = recomputeRecordHash({ ...base, hash: "" });
|
|
51
|
+
out.push({ ...base, hash });
|
|
52
|
+
prevHash = hash;
|
|
53
|
+
});
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
33
57
|
function source(records: ReadonlyArray<AuditRecord>): AuditRecordSource {
|
|
34
58
|
return {
|
|
35
59
|
async *read() {
|
|
@@ -121,6 +145,26 @@ describe("Match logic (T1)", () => {
|
|
|
121
145
|
const r = makeRecord({ kind: "policy_decision", payload: {} });
|
|
122
146
|
expect(_matchesAnyForTest(r, [])).toBe(false);
|
|
123
147
|
});
|
|
148
|
+
|
|
149
|
+
test("payloadField does not match inherited Object.prototype keys", () => {
|
|
150
|
+
// Regression: `in` would walk the prototype chain so a filter targeting
|
|
151
|
+
// "toString" / "constructor" spuriously matched every object payload.
|
|
152
|
+
// payloadField is documented as a *top-level* (own) field.
|
|
153
|
+
const r = makeRecord({ kind: "policy_decision", payload: { action: "allow" } });
|
|
154
|
+
expect(_matchesForTest(r, { kind: "policy_decision", payloadField: "toString" })).toBe(false);
|
|
155
|
+
expect(_matchesForTest(r, { kind: "policy_decision", payloadField: "constructor" })).toBe(
|
|
156
|
+
false,
|
|
157
|
+
);
|
|
158
|
+
expect(_matchesForTest(r, { kind: "policy_decision", payloadField: "hasOwnProperty" })).toBe(
|
|
159
|
+
false,
|
|
160
|
+
);
|
|
161
|
+
// An own field by the same name still matches.
|
|
162
|
+
const r2 = makeRecord({
|
|
163
|
+
kind: "policy_decision",
|
|
164
|
+
payload: Object.assign(Object.create(null), { toString: "shadowed" }),
|
|
165
|
+
});
|
|
166
|
+
expect(_matchesForTest(r2, { kind: "policy_decision", payloadField: "toString" })).toBe(true);
|
|
167
|
+
});
|
|
124
168
|
});
|
|
125
169
|
|
|
126
170
|
describe("Digest + signature helpers (T1)", () => {
|
|
@@ -172,25 +216,26 @@ describe("createComplianceCollector (T1 + T3)", () => {
|
|
|
172
216
|
});
|
|
173
217
|
|
|
174
218
|
test("collect builds evidence bundle for a single control", async () => {
|
|
175
|
-
const records
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
];
|
|
219
|
+
const records = chainOf([
|
|
220
|
+
{ kind: "policy_decision", payload: { action: "allow" } },
|
|
221
|
+
{ kind: "model_call", payload: { model: "claude" } },
|
|
222
|
+
{ kind: "policy_decision", payload: { action: "deny" } },
|
|
223
|
+
]);
|
|
180
224
|
const collector = createComplianceCollector({ auditSource: source(records) });
|
|
181
225
|
const bundle = await collector.collect("soc2", "CC6.1", { period: "2026-Q2" });
|
|
182
226
|
expect(bundle.frameworkId).toBe("soc2");
|
|
183
227
|
expect(bundle.controlId).toBe("CC6.1");
|
|
184
228
|
expect(bundle.recordCount).toBe(2);
|
|
185
|
-
expect(bundle.records.map((r) => r.
|
|
229
|
+
expect(bundle.records.map((r) => (r.payload as { action: string }).action)).toEqual([
|
|
230
|
+
"allow",
|
|
231
|
+
"deny",
|
|
232
|
+
]);
|
|
186
233
|
expect(bundle.signature).toBeNull();
|
|
187
234
|
expect(bundle.digest).toMatch(/^[a-f0-9]{64}$/);
|
|
188
235
|
});
|
|
189
236
|
|
|
190
237
|
test("collect with signingKey produces a non-null HMAC signature", async () => {
|
|
191
|
-
const records
|
|
192
|
-
makeRecord({ kind: "policy_decision", payload: {}, hash: "h1" }),
|
|
193
|
-
];
|
|
238
|
+
const records = chainOf([{ kind: "policy_decision", payload: {} }]);
|
|
194
239
|
const collector = createComplianceCollector({ auditSource: source(records) });
|
|
195
240
|
const bundle = await collector.collect("soc2", "CC6.1", {
|
|
196
241
|
period: "2026-Q2",
|
|
@@ -208,15 +253,80 @@ describe("createComplianceCollector (T1 + T3)", () => {
|
|
|
208
253
|
});
|
|
209
254
|
|
|
210
255
|
test("collectAll returns one bundle per control in the framework", async () => {
|
|
211
|
-
const records
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
];
|
|
256
|
+
const records = chainOf([
|
|
257
|
+
{ kind: "policy_decision", payload: {} },
|
|
258
|
+
{ kind: "secrets_rotation", payload: {} },
|
|
259
|
+
]);
|
|
215
260
|
const collector = createComplianceCollector({ auditSource: source(records) });
|
|
216
261
|
const bundles = await collector.collectAll("soc2", { period: "2026-Q2" });
|
|
217
262
|
expect(bundles.length).toBe(SOC2_CONTROLS.length);
|
|
218
263
|
expect(bundles.every((b) => b.frameworkId === "soc2")).toBe(true);
|
|
219
264
|
});
|
|
265
|
+
|
|
266
|
+
// SECURITY: the collector must NOT sign tampered or incomplete evidence.
|
|
267
|
+
test("collect rejects a record whose payload was rewritten (hash no longer matches)", async () => {
|
|
268
|
+
const [original] = chainOf([{ kind: "policy_decision", payload: { action: "deny" } }]);
|
|
269
|
+
if (original === undefined) throw new Error("fixture");
|
|
270
|
+
// Attacker flips deny→allow but leaves the stored hash untouched.
|
|
271
|
+
const tampered: AuditRecord = { ...original, payload: { action: "allow" } };
|
|
272
|
+
const collector = createComplianceCollector({ auditSource: source([tampered]) });
|
|
273
|
+
await expect(collector.collect("soc2", "CC6.1", { period: "2026-Q2" })).rejects.toThrow(
|
|
274
|
+
/hash integrity/,
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("collect rejects a chain with a deleted middle record (seq gap)", async () => {
|
|
279
|
+
const [first, , third] = chainOf([
|
|
280
|
+
{ kind: "policy_decision", payload: { action: "deny" } },
|
|
281
|
+
{ kind: "policy_decision", payload: { action: "deny" } },
|
|
282
|
+
{ kind: "policy_decision", payload: { action: "allow" } },
|
|
283
|
+
]);
|
|
284
|
+
if (first === undefined || third === undefined) throw new Error("fixture");
|
|
285
|
+
// Drop the middle record — the survivors no longer link / are gapless.
|
|
286
|
+
const collector = createComplianceCollector({ auditSource: source([first, third]) });
|
|
287
|
+
await expect(collector.collect("soc2", "CC6.1", { period: "2026-Q2" })).rejects.toThrow(
|
|
288
|
+
/chain (broken|has a seq gap)/,
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("verifyBundle (evidence integrity)", () => {
|
|
294
|
+
test("accepts a freshly collected + signed bundle", async () => {
|
|
295
|
+
const records = chainOf([{ kind: "policy_decision", payload: { action: "deny" } }]);
|
|
296
|
+
const collector = createComplianceCollector({ auditSource: source(records) });
|
|
297
|
+
const bundle = await collector.collect("soc2", "CC6.1", {
|
|
298
|
+
period: "2026-Q2",
|
|
299
|
+
signingKey: "k1",
|
|
300
|
+
});
|
|
301
|
+
expect(verifyBundle(bundle, "k1")).toEqual({ ok: true });
|
|
302
|
+
expect(verifyBundle(bundle)).toEqual({ ok: true });
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("rejects a bundle whose record payload was rewritten after signing", async () => {
|
|
306
|
+
const records = chainOf([{ kind: "policy_decision", payload: { action: "deny" } }]);
|
|
307
|
+
const collector = createComplianceCollector({ auditSource: source(records) });
|
|
308
|
+
const bundle = await collector.collect("soc2", "CC6.1", {
|
|
309
|
+
period: "2026-Q2",
|
|
310
|
+
signingKey: "k1",
|
|
311
|
+
});
|
|
312
|
+
// Rewrite a payload in the written bundle, leaving digest + signature as-is.
|
|
313
|
+
const [rec0] = bundle.records;
|
|
314
|
+
if (rec0 === undefined) throw new Error("fixture");
|
|
315
|
+
const forged = { ...bundle, records: [{ ...rec0, payload: { action: "allow" } }] };
|
|
316
|
+
const result = verifyBundle(forged, "k1");
|
|
317
|
+
expect(result.ok).toBe(false);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("rejects a wrong HMAC key", async () => {
|
|
321
|
+
const records = chainOf([{ kind: "policy_decision", payload: {} }]);
|
|
322
|
+
const collector = createComplianceCollector({ auditSource: source(records) });
|
|
323
|
+
const bundle = await collector.collect("soc2", "CC6.1", {
|
|
324
|
+
period: "2026-Q2",
|
|
325
|
+
signingKey: "k1",
|
|
326
|
+
});
|
|
327
|
+
const result = verifyBundle(bundle, "wrong-key");
|
|
328
|
+
expect(result.ok).toBe(false);
|
|
329
|
+
});
|
|
220
330
|
});
|
|
221
331
|
|
|
222
332
|
describe("writeBundle (T3 — disk persistence)", () => {
|
|
@@ -229,9 +339,7 @@ describe("writeBundle (T3 — disk persistence)", () => {
|
|
|
229
339
|
});
|
|
230
340
|
|
|
231
341
|
test("writes bundle to <outputDir>/<framework>/<control>/<period>.json", async () => {
|
|
232
|
-
const records
|
|
233
|
-
makeRecord({ kind: "policy_decision", payload: {}, hash: "h1" }),
|
|
234
|
-
];
|
|
342
|
+
const records = chainOf([{ kind: "policy_decision", payload: {} }]);
|
|
235
343
|
const collector = createComplianceCollector({
|
|
236
344
|
auditSource: source(records),
|
|
237
345
|
outputDir: tmp,
|
|
@@ -263,4 +371,72 @@ describe("writeBundle (T3 — disk persistence)", () => {
|
|
|
263
371
|
};
|
|
264
372
|
expect(() => collector.writeBundle(bundle)).toThrow(/may not contain/);
|
|
265
373
|
});
|
|
374
|
+
|
|
375
|
+
test("writeBundle refuses path-traversal in the period label", () => {
|
|
376
|
+
// Regression: `period` was previously interpolated into the path with no
|
|
377
|
+
// validation, so a "../../.." period escaped outputDir entirely and wrote
|
|
378
|
+
// the bundle to an arbitrary location on disk.
|
|
379
|
+
const collector = createComplianceCollector({
|
|
380
|
+
auditSource: source([]),
|
|
381
|
+
outputDir: tmp,
|
|
382
|
+
});
|
|
383
|
+
const base = {
|
|
384
|
+
frameworkId: "soc2",
|
|
385
|
+
controlId: "CC6.1",
|
|
386
|
+
description: "x",
|
|
387
|
+
generatedAt: 1,
|
|
388
|
+
recordCount: 0,
|
|
389
|
+
records: [],
|
|
390
|
+
digest: "x",
|
|
391
|
+
signature: null,
|
|
392
|
+
};
|
|
393
|
+
const escapeTarget = join(tmp, "..", "pwned");
|
|
394
|
+
expect(() => collector.writeBundle({ ...base, period: "../../../../pwned" })).toThrow(
|
|
395
|
+
/period may not contain/,
|
|
396
|
+
);
|
|
397
|
+
// Absolute-path period is likewise rejected (contains a separator).
|
|
398
|
+
expect(() => collector.writeBundle({ ...base, period: "/etc/passwd" })).toThrow(
|
|
399
|
+
/period may not contain/,
|
|
400
|
+
);
|
|
401
|
+
// Backslash separator (Windows-style) is rejected too.
|
|
402
|
+
expect(() => collector.writeBundle({ ...base, period: "..\\win" })).toThrow(
|
|
403
|
+
/period may not contain/,
|
|
404
|
+
);
|
|
405
|
+
// The traversal target must never have been created.
|
|
406
|
+
expect(existsSync(`${escapeTarget}.json`)).toBe(false);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("writeBundle rejects backslash / dot-segment in framework and control ids", () => {
|
|
410
|
+
const collector = createComplianceCollector({
|
|
411
|
+
auditSource: source([]),
|
|
412
|
+
outputDir: tmp,
|
|
413
|
+
});
|
|
414
|
+
const base = {
|
|
415
|
+
description: "x",
|
|
416
|
+
period: "2026",
|
|
417
|
+
generatedAt: 1,
|
|
418
|
+
recordCount: 0,
|
|
419
|
+
records: [],
|
|
420
|
+
digest: "x",
|
|
421
|
+
signature: null,
|
|
422
|
+
};
|
|
423
|
+
expect(() =>
|
|
424
|
+
collector.writeBundle({ ...base, frameworkId: "..\\evil", controlId: "c" }),
|
|
425
|
+
).toThrow(/frameworkId may not contain/);
|
|
426
|
+
expect(() => collector.writeBundle({ ...base, frameworkId: "soc2", controlId: ".." })).toThrow(
|
|
427
|
+
/controlId may not contain/,
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("writeBundle still writes a legitimately-labelled period", async () => {
|
|
432
|
+
const records = chainOf([{ kind: "policy_decision", payload: {} }]);
|
|
433
|
+
const collector = createComplianceCollector({
|
|
434
|
+
auditSource: source(records),
|
|
435
|
+
outputDir: tmp,
|
|
436
|
+
});
|
|
437
|
+
const bundle = await collector.collect("soc2", "CC6.1", { period: "2026-Q2" });
|
|
438
|
+
const path = collector.writeBundle(bundle);
|
|
439
|
+
expect(existsSync(path)).toBe(true);
|
|
440
|
+
expect(path).toBe(join(tmp, "soc2", "CC6.1", "2026-Q2.json"));
|
|
441
|
+
});
|
|
266
442
|
});
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { createHash, createHmac } from "node:crypto";
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
-
import type
|
|
4
|
+
import { type AuditKind, type AuditRecord, recomputeRecordHash } from "@crewhaus/audit-log";
|
|
5
5
|
import { CrewhausError } from "@crewhaus/errors";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -173,7 +173,11 @@ function matches(record: AuditRecord, filter: AuditEventFilter): boolean {
|
|
|
173
173
|
if (filter.payloadField !== undefined) {
|
|
174
174
|
const payload = record.payload as Record<string, unknown> | null;
|
|
175
175
|
if (payload === null || typeof payload !== "object") return false;
|
|
176
|
-
|
|
176
|
+
// Own-property check only: a filter targets a "top-level field within
|
|
177
|
+
// payload" (see AuditEventFilter.payloadField). The `in` operator would
|
|
178
|
+
// also walk the prototype chain, so e.g. `payloadField: "toString"` or
|
|
179
|
+
// "constructor" would spuriously match every object payload.
|
|
180
|
+
if (!Object.hasOwn(payload, filter.payloadField)) return false;
|
|
177
181
|
if (filter.payloadFieldEquals !== undefined) {
|
|
178
182
|
const value = payload[filter.payloadField];
|
|
179
183
|
if (typeof value === "string") {
|
|
@@ -194,8 +198,25 @@ function matchesAny(record: AuditRecord, queries: ReadonlyArray<AuditEventFilter
|
|
|
194
198
|
return false;
|
|
195
199
|
}
|
|
196
200
|
|
|
201
|
+
/** Canonical serialization of a record — the bytes the digest must commit to. */
|
|
202
|
+
function canonicalRecord(r: AuditRecord): string {
|
|
203
|
+
return JSON.stringify({
|
|
204
|
+
ts: r.ts,
|
|
205
|
+
version: r.version,
|
|
206
|
+
kind: r.kind,
|
|
207
|
+
seq: r.seq,
|
|
208
|
+
payload: r.payload,
|
|
209
|
+
prevHash: r.prevHash,
|
|
210
|
+
hash: r.hash,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
197
214
|
function canonicalDigest(records: ReadonlyArray<AuditRecord>): string {
|
|
198
|
-
|
|
215
|
+
// Commit to the FULL record (the payload/kind/ts the auditor actually reads),
|
|
216
|
+
// not just `r.hash`. Hashing only `r.hash` let an attacker rewrite a written
|
|
217
|
+
// bundle's payload while leaving its hash field untouched, so digest + HMAC
|
|
218
|
+
// still validated against content that no longer matched.
|
|
219
|
+
const text = records.map(canonicalRecord).join("\n");
|
|
199
220
|
return createHash("sha256").update(text).digest("hex");
|
|
200
221
|
}
|
|
201
222
|
|
|
@@ -203,6 +224,62 @@ function signDigest(digest: string, key: string): string {
|
|
|
203
224
|
return createHmac("sha256", key).update(digest).digest("hex");
|
|
204
225
|
}
|
|
205
226
|
|
|
227
|
+
function constantTimeEqualHex(a: string, b: string): boolean {
|
|
228
|
+
const ab = Buffer.from(a, "utf8");
|
|
229
|
+
const bb = Buffer.from(b, "utf8");
|
|
230
|
+
if (ab.length !== bb.length) return false;
|
|
231
|
+
return timingSafeEqual(ab, bb);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export type BundleVerification =
|
|
235
|
+
| { readonly ok: true }
|
|
236
|
+
| { readonly ok: false; readonly reason: string };
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Re-verify an EvidenceBundle on read. Recomputes the digest over the bundle's
|
|
240
|
+
* records (so a post-signing payload rewrite is detected), re-checks each
|
|
241
|
+
* record's body↔hash consistency, and — when a signing key is supplied —
|
|
242
|
+
* re-derives the HMAC and compares it in constant time. Bundles MUST be
|
|
243
|
+
* verified with this before being trusted as authoritative evidence.
|
|
244
|
+
*/
|
|
245
|
+
export function verifyBundle(bundle: EvidenceBundle, signingKey?: string): BundleVerification {
|
|
246
|
+
if (!constantTimeEqualHex(canonicalDigest(bundle.records), bundle.digest)) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
reason: "digest does not match bundle.records (records altered after signing)",
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
for (const r of bundle.records) {
|
|
253
|
+
if (r.hash !== recomputeRecordHash(r)) {
|
|
254
|
+
return { ok: false, reason: `record seq ${r.seq} body does not hash to its stored hash` };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (signingKey !== undefined) {
|
|
258
|
+
if (bundle.signature === null) return { ok: false, reason: "bundle is unsigned" };
|
|
259
|
+
if (!constantTimeEqualHex(signDigest(bundle.digest, signingKey), bundle.signature)) {
|
|
260
|
+
return { ok: false, reason: "HMAC signature mismatch" };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return { ok: true };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Reject any path component that could escape the output directory. Each of
|
|
268
|
+
* `frameworkId` / `controlId` / `period` becomes one path segment in the
|
|
269
|
+
* evidence-bundle path; a segment containing a path separator (`/` or `\`),
|
|
270
|
+
* a `..` traversal token, or a NUL byte could redirect the write to an
|
|
271
|
+
* arbitrary location on disk. Returns true when the component is unsafe.
|
|
272
|
+
*/
|
|
273
|
+
function isUnsafePathComponent(component: string): boolean {
|
|
274
|
+
return (
|
|
275
|
+
component.includes("/") ||
|
|
276
|
+
component.includes("\\") ||
|
|
277
|
+
component.includes("\0") ||
|
|
278
|
+
component === ".." ||
|
|
279
|
+
component === "."
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
206
283
|
// --------------------------------------------------------------------
|
|
207
284
|
// Collector
|
|
208
285
|
// --------------------------------------------------------------------
|
|
@@ -252,8 +329,34 @@ export function createComplianceCollector(opts: CollectorOptions): ComplianceCol
|
|
|
252
329
|
`collect: control "${framework}/${controlId}" not registered`,
|
|
253
330
|
);
|
|
254
331
|
}
|
|
332
|
+
// Verify the audit chain BEFORE accepting records as evidence — the
|
|
333
|
+
// plain `read()` iterator does no validation, so a record whose payload
|
|
334
|
+
// was rewritten (without repairing its hash), or a record deleted from
|
|
335
|
+
// the middle of the chain, would otherwise flow straight into a signed
|
|
336
|
+
// bundle presented to auditors as authoritative. We re-check every
|
|
337
|
+
// record's body↔hash, and the link + gapless-seq contiguity of the
|
|
338
|
+
// sequence `read()` yields, then filter the verified records.
|
|
255
339
|
const matched: AuditRecord[] = [];
|
|
340
|
+
let prev: AuditRecord | undefined;
|
|
256
341
|
for await (const record of opts.auditSource.read()) {
|
|
342
|
+
if (record.hash !== recomputeRecordHash(record)) {
|
|
343
|
+
throw new ComplianceControlsError(
|
|
344
|
+
`collect: audit record at seq ${record.seq} fails hash integrity — its body was modified after signing; refusing to bundle tampered evidence`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
if (prev !== undefined) {
|
|
348
|
+
if (record.prevHash !== prev.hash) {
|
|
349
|
+
throw new ComplianceControlsError(
|
|
350
|
+
`collect: audit chain broken at seq ${record.seq} — prevHash does not link to the prior record (a record was reordered or removed)`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
if (record.seq !== prev.seq + 1) {
|
|
354
|
+
throw new ComplianceControlsError(
|
|
355
|
+
`collect: audit chain has a seq gap before seq ${record.seq} (expected ${prev.seq + 1}) — a record was removed`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
prev = record;
|
|
257
360
|
if (matchesAny(record, def.evidenceQueries)) {
|
|
258
361
|
matched.push(record);
|
|
259
362
|
}
|
|
@@ -291,10 +394,19 @@ export function createComplianceCollector(opts: CollectorOptions): ComplianceCol
|
|
|
291
394
|
},
|
|
292
395
|
|
|
293
396
|
writeBundle(bundle: EvidenceBundle): string {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
397
|
+
// All three components are interpolated into the on-disk path; validate
|
|
398
|
+
// each so a caller-supplied id or period label cannot traverse out of
|
|
399
|
+
// outputDir (e.g. period "../../etc/cron.d/x" or an absolute path).
|
|
400
|
+
for (const [field, value] of [
|
|
401
|
+
["frameworkId", bundle.frameworkId],
|
|
402
|
+
["controlId", bundle.controlId],
|
|
403
|
+
["period", bundle.period],
|
|
404
|
+
] as const) {
|
|
405
|
+
if (isUnsafePathComponent(value)) {
|
|
406
|
+
throw new ComplianceControlsError(
|
|
407
|
+
`writeBundle: ${field} may not contain a path separator or ".." segment (got "${value}")`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
298
410
|
}
|
|
299
411
|
const path = join(outputDir, bundle.frameworkId, bundle.controlId, `${bundle.period}.json`);
|
|
300
412
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|