@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/compliance-controls",
3
- "version": "0.1.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.1",
16
- "@crewhaus/errors": "0.1.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@studiomax.io",
22
- "url": "https://studiomax.io"
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": "restricted"
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 { AuditRecord } from "@crewhaus/audit-log";
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: AuditRecord[] = [
176
- makeRecord({ kind: "policy_decision", payload: { action: "allow" }, hash: "h1" }),
177
- makeRecord({ kind: "model_call", payload: { model: "claude" }, hash: "h2" }),
178
- makeRecord({ kind: "policy_decision", payload: { action: "deny" }, hash: "h3" }),
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.hash)).toEqual(["h1", "h3"]);
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: AuditRecord[] = [
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: AuditRecord[] = [
212
- makeRecord({ kind: "policy_decision", payload: {}, hash: "h1" }),
213
- makeRecord({ kind: "secrets_rotation", payload: {}, hash: "h2" }),
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: AuditRecord[] = [
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 { AuditKind, AuditRecord } from "@crewhaus/audit-log";
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
- if (!(filter.payloadField in payload)) return false;
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
- const text = records.map((r) => r.hash).join("|");
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
- if (bundle.frameworkId.includes("/") || bundle.controlId.includes("/")) {
295
- throw new ComplianceControlsError(
296
- `writeBundle: framework/control id may not contain "/" (got "${bundle.frameworkId}/${bundle.controlId}")`,
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 });