@crewhaus/compliance-controls 0.1.3 → 0.1.5

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.
@@ -0,0 +1,108 @@
1
+ import { type AuditKind, type AuditRecord } from "@crewhaus/audit-log";
2
+ import { CrewhausError } from "@crewhaus/errors";
3
+ /**
4
+ * Catalog R17 `compliance-controls` — Section 39 SOC 2 / ISO 27001 /
5
+ * HIPAA evidence collection.
6
+ *
7
+ * A `ControlDefinition` declares which audit-log records prove a
8
+ * given control is operating. The collector walks the audit log per
9
+ * control, gathers matching records into an `EvidenceBundle`, signs
10
+ * the bundle with HMAC-SHA256, and writes it to
11
+ * `.crewhaus/compliance/<framework>/<controlId>/<period>.json`.
12
+ *
13
+ * Built-in framework definitions (SOC 2 Type II CC6.x + CC7.x, ISO
14
+ * 27001 A.12.4, HIPAA §164.312(b)) are exported at module load so
15
+ * callers don't have to author the filter shapes themselves.
16
+ *
17
+ * Layer R17. Pairs with `audit-log` (R-infra — primary record source),
18
+ * `data-retention-engine` (§39 — adds an audit window so the records
19
+ * referenced by an in-progress evidence collection are not purged
20
+ * mid-run).
21
+ */
22
+ export declare class ComplianceControlsError extends CrewhausError {
23
+ readonly name = "ComplianceControlsError";
24
+ constructor(message: string, cause?: unknown);
25
+ }
26
+ export type AuditEventFilter = {
27
+ /** Match the audit record's `kind` field. */
28
+ readonly kind?: AuditKind;
29
+ /** Match a specific top-level field within `payload`. */
30
+ readonly payloadField?: string;
31
+ /** Substring match on a serialized payload field — e.g. action="rotate". */
32
+ readonly payloadFieldEquals?: string;
33
+ /** Inclusive lower bound on `ts`. */
34
+ readonly tsAfter?: number;
35
+ /** Exclusive upper bound on `ts`. */
36
+ readonly tsBefore?: number;
37
+ };
38
+ export type ControlDefinition = {
39
+ readonly frameworkId: string;
40
+ readonly controlId: string;
41
+ /** Short description shown in the evidence bundle for auditors. */
42
+ readonly description: string;
43
+ /** OR-merged: a record matching any filter is evidence for this control. */
44
+ readonly evidenceQueries: ReadonlyArray<AuditEventFilter>;
45
+ };
46
+ export type EvidenceBundle = {
47
+ readonly frameworkId: string;
48
+ readonly controlId: string;
49
+ readonly description: string;
50
+ readonly period: string;
51
+ readonly generatedAt: number;
52
+ readonly recordCount: number;
53
+ readonly records: ReadonlyArray<AuditRecord>;
54
+ /** Hex SHA-256 of the canonical record list, with HMAC signature when keyed. */
55
+ readonly digest: string;
56
+ readonly signature: string | null;
57
+ };
58
+ export interface AuditRecordSource {
59
+ /** Yield every record across all days. */
60
+ read(): AsyncIterable<AuditRecord>;
61
+ }
62
+ export type CollectOptions = {
63
+ /** Period label (e.g. "2026-Q2"). Used in the output path. */
64
+ readonly period: string;
65
+ /** Optional HMAC key for signing the bundle. */
66
+ readonly signingKey?: string;
67
+ /** Optional clock for tests. */
68
+ readonly now?: () => number;
69
+ };
70
+ export type CollectorOptions = {
71
+ readonly auditSource: AuditRecordSource;
72
+ readonly outputDir?: string;
73
+ readonly controls?: ReadonlyArray<ControlDefinition>;
74
+ };
75
+ export interface ComplianceCollector {
76
+ registerControl(def: ControlDefinition): void;
77
+ listControls(filter?: {
78
+ frameworkId?: string;
79
+ }): ReadonlyArray<ControlDefinition>;
80
+ collect(framework: string, controlId: string, opts: CollectOptions): Promise<EvidenceBundle>;
81
+ collectAll(framework: string, opts: CollectOptions): Promise<ReadonlyArray<EvidenceBundle>>;
82
+ /** Test seam — write the bundle to disk under the configured outputDir. */
83
+ writeBundle(bundle: EvidenceBundle): string;
84
+ }
85
+ export declare const SOC2_CONTROLS: ReadonlyArray<ControlDefinition>;
86
+ export declare const ISO27001_CONTROLS: ReadonlyArray<ControlDefinition>;
87
+ export declare const HIPAA_CONTROLS: ReadonlyArray<ControlDefinition>;
88
+ export declare const BUILT_IN_CONTROLS: ReadonlyArray<ControlDefinition>;
89
+ declare function matches(record: AuditRecord, filter: AuditEventFilter): boolean;
90
+ declare function matchesAny(record: AuditRecord, queries: ReadonlyArray<AuditEventFilter>): boolean;
91
+ declare function canonicalDigest(records: ReadonlyArray<AuditRecord>): string;
92
+ declare function signDigest(digest: string, key: string): string;
93
+ export type BundleVerification = {
94
+ readonly ok: true;
95
+ } | {
96
+ readonly ok: false;
97
+ readonly reason: string;
98
+ };
99
+ /**
100
+ * Re-verify an EvidenceBundle on read. Recomputes the digest over the bundle's
101
+ * records (so a post-signing payload rewrite is detected), re-checks each
102
+ * record's body↔hash consistency, and — when a signing key is supplied —
103
+ * re-derives the HMAC and compares it in constant time. Bundles MUST be
104
+ * verified with this before being trusted as authoritative evidence.
105
+ */
106
+ export declare function verifyBundle(bundle: EvidenceBundle, signingKey?: string): BundleVerification;
107
+ export declare function createComplianceCollector(opts: CollectorOptions): ComplianceCollector;
108
+ export { matches as _matchesForTest, matchesAny as _matchesAnyForTest, canonicalDigest as _canonicalDigestForTest, signDigest as _signDigestForTest, };
package/dist/index.js ADDED
@@ -0,0 +1,316 @@
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { recomputeRecordHash } from "@crewhaus/audit-log";
5
+ import { CrewhausError } from "@crewhaus/errors";
6
+ /**
7
+ * Catalog R17 `compliance-controls` — Section 39 SOC 2 / ISO 27001 /
8
+ * HIPAA evidence collection.
9
+ *
10
+ * A `ControlDefinition` declares which audit-log records prove a
11
+ * given control is operating. The collector walks the audit log per
12
+ * control, gathers matching records into an `EvidenceBundle`, signs
13
+ * the bundle with HMAC-SHA256, and writes it to
14
+ * `.crewhaus/compliance/<framework>/<controlId>/<period>.json`.
15
+ *
16
+ * Built-in framework definitions (SOC 2 Type II CC6.x + CC7.x, ISO
17
+ * 27001 A.12.4, HIPAA §164.312(b)) are exported at module load so
18
+ * callers don't have to author the filter shapes themselves.
19
+ *
20
+ * Layer R17. Pairs with `audit-log` (R-infra — primary record source),
21
+ * `data-retention-engine` (§39 — adds an audit window so the records
22
+ * referenced by an in-progress evidence collection are not purged
23
+ * mid-run).
24
+ */
25
+ export class ComplianceControlsError extends CrewhausError {
26
+ name = "ComplianceControlsError";
27
+ constructor(message, cause) {
28
+ super("config", message, cause);
29
+ }
30
+ }
31
+ // --------------------------------------------------------------------
32
+ // Built-in framework definitions
33
+ // --------------------------------------------------------------------
34
+ export const SOC2_CONTROLS = [
35
+ {
36
+ frameworkId: "soc2",
37
+ controlId: "CC6.1",
38
+ description: "Logical and physical access controls — every policy decision is audit-logged.",
39
+ evidenceQueries: [{ kind: "policy_decision" }],
40
+ },
41
+ {
42
+ frameworkId: "soc2",
43
+ controlId: "CC6.7",
44
+ description: "Restricted access to system functions — secrets-manager rotation + access events captured.",
45
+ evidenceQueries: [{ kind: "secrets_rotation" }, { kind: "secrets_access" }],
46
+ },
47
+ {
48
+ frameworkId: "soc2",
49
+ controlId: "CC7.2",
50
+ description: "System monitoring — model invocations and tool classifications are captured.",
51
+ evidenceQueries: [{ kind: "model_call" }, { kind: "tool_classification" }],
52
+ },
53
+ {
54
+ frameworkId: "soc2",
55
+ controlId: "CC7.3",
56
+ description: "Detection of incidents — gateway requests captured for replay/replay analysis.",
57
+ evidenceQueries: [{ kind: "gateway_request" }],
58
+ },
59
+ ];
60
+ export const ISO27001_CONTROLS = [
61
+ {
62
+ frameworkId: "iso27001",
63
+ controlId: "A.12.4",
64
+ description: "Logging and monitoring — append-only hash-chained audit trail across every privileged operation.",
65
+ evidenceQueries: [
66
+ { kind: "policy_decision" },
67
+ { kind: "model_call" },
68
+ { kind: "secrets_rotation" },
69
+ { kind: "deployment_action" },
70
+ ],
71
+ },
72
+ ];
73
+ export const HIPAA_CONTROLS = [
74
+ {
75
+ frameworkId: "hipaa",
76
+ controlId: "164.312(b)",
77
+ description: "Audit controls — every access to or decision involving protected data appears in the audit log.",
78
+ evidenceQueries: [
79
+ { kind: "policy_decision" },
80
+ { kind: "secrets_access" },
81
+ { kind: "tenancy_context" },
82
+ ],
83
+ },
84
+ ];
85
+ export const BUILT_IN_CONTROLS = [
86
+ ...SOC2_CONTROLS,
87
+ ...ISO27001_CONTROLS,
88
+ ...HIPAA_CONTROLS,
89
+ ];
90
+ // --------------------------------------------------------------------
91
+ // Match logic
92
+ // --------------------------------------------------------------------
93
+ function matches(record, filter) {
94
+ if (filter.kind !== undefined && record.kind !== filter.kind)
95
+ return false;
96
+ if (filter.tsAfter !== undefined && record.ts < filter.tsAfter)
97
+ return false;
98
+ if (filter.tsBefore !== undefined && record.ts >= filter.tsBefore)
99
+ return false;
100
+ if (filter.payloadField !== undefined) {
101
+ const payload = record.payload;
102
+ if (payload === null || typeof payload !== "object")
103
+ return false;
104
+ // Own-property check only: a filter targets a "top-level field within
105
+ // payload" (see AuditEventFilter.payloadField). The `in` operator would
106
+ // also walk the prototype chain, so e.g. `payloadField: "toString"` or
107
+ // "constructor" would spuriously match every object payload.
108
+ if (!Object.hasOwn(payload, filter.payloadField))
109
+ return false;
110
+ if (filter.payloadFieldEquals !== undefined) {
111
+ const value = payload[filter.payloadField];
112
+ if (typeof value === "string") {
113
+ if (value !== filter.payloadFieldEquals)
114
+ return false;
115
+ }
116
+ else if (String(value) !== filter.payloadFieldEquals) {
117
+ return false;
118
+ }
119
+ }
120
+ }
121
+ return true;
122
+ }
123
+ function matchesAny(record, queries) {
124
+ if (queries.length === 0)
125
+ return false;
126
+ for (const q of queries) {
127
+ if (matches(record, q))
128
+ return true;
129
+ }
130
+ return false;
131
+ }
132
+ /** Canonical serialization of a record — the bytes the digest must commit to. */
133
+ function canonicalRecord(r) {
134
+ return JSON.stringify({
135
+ ts: r.ts,
136
+ version: r.version,
137
+ kind: r.kind,
138
+ seq: r.seq,
139
+ payload: r.payload,
140
+ prevHash: r.prevHash,
141
+ hash: r.hash,
142
+ });
143
+ }
144
+ function canonicalDigest(records) {
145
+ // Commit to the FULL record (the payload/kind/ts the auditor actually reads),
146
+ // not just `r.hash`. Hashing only `r.hash` let an attacker rewrite a written
147
+ // bundle's payload while leaving its hash field untouched, so digest + HMAC
148
+ // still validated against content that no longer matched.
149
+ const text = records.map(canonicalRecord).join("\n");
150
+ return createHash("sha256").update(text).digest("hex");
151
+ }
152
+ function signDigest(digest, key) {
153
+ return createHmac("sha256", key).update(digest).digest("hex");
154
+ }
155
+ function constantTimeEqualHex(a, b) {
156
+ const ab = Buffer.from(a, "utf8");
157
+ const bb = Buffer.from(b, "utf8");
158
+ if (ab.length !== bb.length)
159
+ return false;
160
+ return timingSafeEqual(ab, bb);
161
+ }
162
+ /**
163
+ * Re-verify an EvidenceBundle on read. Recomputes the digest over the bundle's
164
+ * records (so a post-signing payload rewrite is detected), re-checks each
165
+ * record's body↔hash consistency, and — when a signing key is supplied —
166
+ * re-derives the HMAC and compares it in constant time. Bundles MUST be
167
+ * verified with this before being trusted as authoritative evidence.
168
+ */
169
+ export function verifyBundle(bundle, signingKey) {
170
+ if (!constantTimeEqualHex(canonicalDigest(bundle.records), bundle.digest)) {
171
+ return {
172
+ ok: false,
173
+ reason: "digest does not match bundle.records (records altered after signing)",
174
+ };
175
+ }
176
+ for (const r of bundle.records) {
177
+ if (r.hash !== recomputeRecordHash(r)) {
178
+ return { ok: false, reason: `record seq ${r.seq} body does not hash to its stored hash` };
179
+ }
180
+ }
181
+ if (signingKey !== undefined) {
182
+ if (bundle.signature === null)
183
+ return { ok: false, reason: "bundle is unsigned" };
184
+ if (!constantTimeEqualHex(signDigest(bundle.digest, signingKey), bundle.signature)) {
185
+ return { ok: false, reason: "HMAC signature mismatch" };
186
+ }
187
+ }
188
+ return { ok: true };
189
+ }
190
+ /**
191
+ * Reject any path component that could escape the output directory. Each of
192
+ * `frameworkId` / `controlId` / `period` becomes one path segment in the
193
+ * evidence-bundle path; a segment containing a path separator (`/` or `\`),
194
+ * a `..` traversal token, or a NUL byte could redirect the write to an
195
+ * arbitrary location on disk. Returns true when the component is unsafe.
196
+ */
197
+ function isUnsafePathComponent(component) {
198
+ return (component.includes("/") ||
199
+ component.includes("\\") ||
200
+ component.includes("\0") ||
201
+ component === ".." ||
202
+ component === ".");
203
+ }
204
+ // --------------------------------------------------------------------
205
+ // Collector
206
+ // --------------------------------------------------------------------
207
+ export function createComplianceCollector(opts) {
208
+ if (opts.auditSource === undefined) {
209
+ throw new ComplianceControlsError("auditSource is required");
210
+ }
211
+ const outputDir = opts.outputDir ?? join(process.cwd(), ".crewhaus", "compliance");
212
+ const controls = new Map();
213
+ const seedControls = opts.controls ?? BUILT_IN_CONTROLS;
214
+ for (const c of seedControls) {
215
+ controls.set(`${c.frameworkId}::${c.controlId}`, c);
216
+ }
217
+ function key(framework, controlId) {
218
+ return `${framework}::${controlId}`;
219
+ }
220
+ return {
221
+ registerControl(def) {
222
+ if (typeof def.frameworkId !== "string" || def.frameworkId === "") {
223
+ throw new ComplianceControlsError("registerControl: frameworkId required");
224
+ }
225
+ if (typeof def.controlId !== "string" || def.controlId === "") {
226
+ throw new ComplianceControlsError("registerControl: controlId required");
227
+ }
228
+ controls.set(key(def.frameworkId, def.controlId), def);
229
+ },
230
+ listControls(filter) {
231
+ const items = [...controls.values()];
232
+ const filtered = filter?.frameworkId !== undefined
233
+ ? items.filter((c) => c.frameworkId === filter.frameworkId)
234
+ : items;
235
+ return filtered.sort((a, b) => a.frameworkId.localeCompare(b.frameworkId) || a.controlId.localeCompare(b.controlId));
236
+ },
237
+ async collect(framework, controlId, collectOpts) {
238
+ const def = controls.get(key(framework, controlId));
239
+ if (def === undefined) {
240
+ throw new ComplianceControlsError(`collect: control "${framework}/${controlId}" not registered`);
241
+ }
242
+ // Verify the audit chain BEFORE accepting records as evidence — the
243
+ // plain `read()` iterator does no validation, so a record whose payload
244
+ // was rewritten (without repairing its hash), or a record deleted from
245
+ // the middle of the chain, would otherwise flow straight into a signed
246
+ // bundle presented to auditors as authoritative. We re-check every
247
+ // record's body↔hash, and the link + gapless-seq contiguity of the
248
+ // sequence `read()` yields, then filter the verified records.
249
+ const matched = [];
250
+ let prev;
251
+ for await (const record of opts.auditSource.read()) {
252
+ if (record.hash !== recomputeRecordHash(record)) {
253
+ throw new ComplianceControlsError(`collect: audit record at seq ${record.seq} fails hash integrity — its body was modified after signing; refusing to bundle tampered evidence`);
254
+ }
255
+ if (prev !== undefined) {
256
+ if (record.prevHash !== prev.hash) {
257
+ throw new ComplianceControlsError(`collect: audit chain broken at seq ${record.seq} — prevHash does not link to the prior record (a record was reordered or removed)`);
258
+ }
259
+ if (record.seq !== prev.seq + 1) {
260
+ throw new ComplianceControlsError(`collect: audit chain has a seq gap before seq ${record.seq} (expected ${prev.seq + 1}) — a record was removed`);
261
+ }
262
+ }
263
+ prev = record;
264
+ if (matchesAny(record, def.evidenceQueries)) {
265
+ matched.push(record);
266
+ }
267
+ }
268
+ const digest = canonicalDigest(matched);
269
+ const signature = collectOpts.signingKey ? signDigest(digest, collectOpts.signingKey) : null;
270
+ const now = collectOpts.now?.() ?? Date.now();
271
+ return {
272
+ frameworkId: def.frameworkId,
273
+ controlId: def.controlId,
274
+ description: def.description,
275
+ period: collectOpts.period,
276
+ generatedAt: now,
277
+ recordCount: matched.length,
278
+ records: matched,
279
+ digest,
280
+ signature,
281
+ };
282
+ },
283
+ async collectAll(framework, collectOpts) {
284
+ const matchingControls = [...controls.values()]
285
+ .filter((c) => c.frameworkId === framework)
286
+ .sort((a, b) => a.controlId.localeCompare(b.controlId));
287
+ if (matchingControls.length === 0) {
288
+ throw new ComplianceControlsError(`collectAll: no controls registered for framework "${framework}"`);
289
+ }
290
+ const bundles = [];
291
+ for (const c of matchingControls) {
292
+ bundles.push(await this.collect(c.frameworkId, c.controlId, collectOpts));
293
+ }
294
+ return bundles;
295
+ },
296
+ writeBundle(bundle) {
297
+ // All three components are interpolated into the on-disk path; validate
298
+ // each so a caller-supplied id or period label cannot traverse out of
299
+ // outputDir (e.g. period "../../etc/cron.d/x" or an absolute path).
300
+ for (const [field, value] of [
301
+ ["frameworkId", bundle.frameworkId],
302
+ ["controlId", bundle.controlId],
303
+ ["period", bundle.period],
304
+ ]) {
305
+ if (isUnsafePathComponent(value)) {
306
+ throw new ComplianceControlsError(`writeBundle: ${field} may not contain a path separator or ".." segment (got "${value}")`);
307
+ }
308
+ }
309
+ const path = join(outputDir, bundle.frameworkId, bundle.controlId, `${bundle.period}.json`);
310
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
311
+ writeFileSync(path, `${JSON.stringify(bundle, null, 2)}\n`, { mode: 0o600 });
312
+ return path;
313
+ },
314
+ };
315
+ }
316
+ export { matches as _matchesForTest, matchesAny as _matchesAnyForTest, canonicalDigest as _canonicalDigestForTest, signDigest as _signDigestForTest, };
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "@crewhaus/compliance-controls",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/audit-log": "0.1.3",
16
- "@crewhaus/errors": "0.1.3"
18
+ "@crewhaus/audit-log": "0.1.5",
19
+ "@crewhaus/errors": "0.1.5"
17
20
  },
18
21
  "license": "Apache-2.0",
19
22
  "author": {
@@ -33,5 +36,5 @@
33
36
  "publishConfig": {
34
37
  "access": "public"
35
38
  },
36
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
39
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
37
40
  }
package/src/index.test.ts DELETED
@@ -1,442 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { type AuditRecord, recomputeRecordHash } from "@crewhaus/audit-log";
6
- import {
7
- type AuditRecordSource,
8
- BUILT_IN_CONTROLS,
9
- ComplianceControlsError,
10
- HIPAA_CONTROLS,
11
- ISO27001_CONTROLS,
12
- SOC2_CONTROLS,
13
- _canonicalDigestForTest,
14
- _matchesAnyForTest,
15
- _matchesForTest,
16
- _signDigestForTest,
17
- createComplianceCollector,
18
- verifyBundle,
19
- } from "./index";
20
-
21
- function makeRecord(
22
- partial: Pick<AuditRecord, "kind" | "payload"> & Partial<AuditRecord>,
23
- ): AuditRecord {
24
- return {
25
- ts: partial.ts ?? Date.now(),
26
- version: 1,
27
- seq: partial.seq ?? 0,
28
- kind: partial.kind,
29
- payload: partial.payload,
30
- prevHash: partial.prevHash ?? "GENESIS",
31
- hash: partial.hash ?? `hash-${Math.random()}`,
32
- };
33
- }
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
-
57
- function source(records: ReadonlyArray<AuditRecord>): AuditRecordSource {
58
- return {
59
- async *read() {
60
- for (const r of records) yield r;
61
- },
62
- };
63
- }
64
-
65
- describe("Built-in framework definitions", () => {
66
- test("SOC2 controls cover CC6.x + CC7.x families", () => {
67
- const ids = SOC2_CONTROLS.map((c) => c.controlId);
68
- expect(ids).toContain("CC6.1");
69
- expect(ids).toContain("CC6.7");
70
- expect(ids).toContain("CC7.2");
71
- expect(ids).toContain("CC7.3");
72
- });
73
-
74
- test("ISO27001 covers A.12.4", () => {
75
- const ids = ISO27001_CONTROLS.map((c) => c.controlId);
76
- expect(ids).toContain("A.12.4");
77
- });
78
-
79
- test("HIPAA covers 164.312(b)", () => {
80
- const ids = HIPAA_CONTROLS.map((c) => c.controlId);
81
- expect(ids).toContain("164.312(b)");
82
- });
83
-
84
- test("BUILT_IN_CONTROLS aggregates all frameworks", () => {
85
- const frameworks = new Set(BUILT_IN_CONTROLS.map((c) => c.frameworkId));
86
- expect(frameworks.has("soc2")).toBe(true);
87
- expect(frameworks.has("iso27001")).toBe(true);
88
- expect(frameworks.has("hipaa")).toBe(true);
89
- });
90
- });
91
-
92
- describe("Match logic (T1)", () => {
93
- test("kind filter", () => {
94
- const r = makeRecord({ kind: "policy_decision", payload: {} });
95
- expect(_matchesForTest(r, { kind: "policy_decision" })).toBe(true);
96
- expect(_matchesForTest(r, { kind: "model_call" })).toBe(false);
97
- });
98
-
99
- test("ts range filters (inclusive lower, exclusive upper)", () => {
100
- const r = makeRecord({ kind: "policy_decision", payload: {}, ts: 1000 });
101
- expect(_matchesForTest(r, { kind: "policy_decision", tsAfter: 999 })).toBe(true);
102
- expect(_matchesForTest(r, { kind: "policy_decision", tsAfter: 1000 })).toBe(true);
103
- expect(_matchesForTest(r, { kind: "policy_decision", tsAfter: 1001 })).toBe(false);
104
- expect(_matchesForTest(r, { kind: "policy_decision", tsBefore: 1001 })).toBe(true);
105
- expect(_matchesForTest(r, { kind: "policy_decision", tsBefore: 1000 })).toBe(false);
106
- });
107
-
108
- test("payloadField filter", () => {
109
- const r = makeRecord({
110
- kind: "policy_decision",
111
- payload: { action: "allow", risk: 0.1 },
112
- });
113
- expect(_matchesForTest(r, { kind: "policy_decision", payloadField: "action" })).toBe(true);
114
- expect(_matchesForTest(r, { kind: "policy_decision", payloadField: "missing" })).toBe(false);
115
- });
116
-
117
- test("payloadFieldEquals filter", () => {
118
- const r = makeRecord({
119
- kind: "policy_decision",
120
- payload: { action: "allow" },
121
- });
122
- expect(
123
- _matchesForTest(r, {
124
- kind: "policy_decision",
125
- payloadField: "action",
126
- payloadFieldEquals: "allow",
127
- }),
128
- ).toBe(true);
129
- expect(
130
- _matchesForTest(r, {
131
- kind: "policy_decision",
132
- payloadField: "action",
133
- payloadFieldEquals: "deny",
134
- }),
135
- ).toBe(false);
136
- });
137
-
138
- test("matchesAny is OR-merged", () => {
139
- const r = makeRecord({ kind: "policy_decision", payload: {} });
140
- const queries = [{ kind: "model_call" as const }, { kind: "policy_decision" as const }];
141
- expect(_matchesAnyForTest(r, queries)).toBe(true);
142
- });
143
-
144
- test("matchesAny returns false for empty queries", () => {
145
- const r = makeRecord({ kind: "policy_decision", payload: {} });
146
- expect(_matchesAnyForTest(r, [])).toBe(false);
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
- });
168
- });
169
-
170
- describe("Digest + signature helpers (T1)", () => {
171
- test("canonicalDigest is deterministic", () => {
172
- const r1 = makeRecord({ kind: "policy_decision", payload: {}, hash: "h1" });
173
- const r2 = makeRecord({ kind: "policy_decision", payload: {}, hash: "h2" });
174
- expect(_canonicalDigestForTest([r1, r2])).toBe(_canonicalDigestForTest([r1, r2]));
175
- expect(_canonicalDigestForTest([r1, r2])).not.toBe(_canonicalDigestForTest([r2, r1]));
176
- });
177
-
178
- test("signDigest produces a stable HMAC-SHA256", () => {
179
- expect(_signDigestForTest("abc", "k1")).toBe(_signDigestForTest("abc", "k1"));
180
- expect(_signDigestForTest("abc", "k1")).not.toBe(_signDigestForTest("abc", "k2"));
181
- });
182
- });
183
-
184
- describe("createComplianceCollector (T1 + T3)", () => {
185
- test("requires auditSource", () => {
186
- expect(() =>
187
- createComplianceCollector({ auditSource: undefined as unknown as AuditRecordSource }),
188
- ).toThrow(ComplianceControlsError);
189
- });
190
-
191
- test("listControls returns built-ins by default, sorted", () => {
192
- const collector = createComplianceCollector({ auditSource: source([]) });
193
- const list = collector.listControls();
194
- expect(list.length).toBe(BUILT_IN_CONTROLS.length);
195
- // Sorted by framework first, then control.
196
- const frameworks = list.map((c) => c.frameworkId);
197
- expect([...frameworks].sort()).toEqual(frameworks);
198
- });
199
-
200
- test("listControls with frameworkId filter narrows the result", () => {
201
- const collector = createComplianceCollector({ auditSource: source([]) });
202
- const soc2 = collector.listControls({ frameworkId: "soc2" });
203
- expect(soc2.every((c) => c.frameworkId === "soc2")).toBe(true);
204
- expect(soc2.length).toBe(SOC2_CONTROLS.length);
205
- });
206
-
207
- test("registerControl adds custom controls", () => {
208
- const collector = createComplianceCollector({ auditSource: source([]) });
209
- collector.registerControl({
210
- frameworkId: "internal",
211
- controlId: "X-1",
212
- description: "internal control",
213
- evidenceQueries: [{ kind: "policy_decision" }],
214
- });
215
- expect(collector.listControls({ frameworkId: "internal" })).toHaveLength(1);
216
- });
217
-
218
- test("collect builds evidence bundle for a single control", async () => {
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
- ]);
224
- const collector = createComplianceCollector({ auditSource: source(records) });
225
- const bundle = await collector.collect("soc2", "CC6.1", { period: "2026-Q2" });
226
- expect(bundle.frameworkId).toBe("soc2");
227
- expect(bundle.controlId).toBe("CC6.1");
228
- expect(bundle.recordCount).toBe(2);
229
- expect(bundle.records.map((r) => (r.payload as { action: string }).action)).toEqual([
230
- "allow",
231
- "deny",
232
- ]);
233
- expect(bundle.signature).toBeNull();
234
- expect(bundle.digest).toMatch(/^[a-f0-9]{64}$/);
235
- });
236
-
237
- test("collect with signingKey produces a non-null HMAC signature", async () => {
238
- const records = chainOf([{ kind: "policy_decision", payload: {} }]);
239
- const collector = createComplianceCollector({ auditSource: source(records) });
240
- const bundle = await collector.collect("soc2", "CC6.1", {
241
- period: "2026-Q2",
242
- signingKey: "k1",
243
- });
244
- expect(bundle.signature).toMatch(/^[a-f0-9]{64}$/);
245
- expect(bundle.signature).toBe(_signDigestForTest(bundle.digest, "k1"));
246
- });
247
-
248
- test("collect with unknown control throws", async () => {
249
- const collector = createComplianceCollector({ auditSource: source([]) });
250
- await expect(collector.collect("ghost", "X", { period: "2026-Q2" })).rejects.toThrow(
251
- /not registered/,
252
- );
253
- });
254
-
255
- test("collectAll returns one bundle per control in the framework", async () => {
256
- const records = chainOf([
257
- { kind: "policy_decision", payload: {} },
258
- { kind: "secrets_rotation", payload: {} },
259
- ]);
260
- const collector = createComplianceCollector({ auditSource: source(records) });
261
- const bundles = await collector.collectAll("soc2", { period: "2026-Q2" });
262
- expect(bundles.length).toBe(SOC2_CONTROLS.length);
263
- expect(bundles.every((b) => b.frameworkId === "soc2")).toBe(true);
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
- });
330
- });
331
-
332
- describe("writeBundle (T3 — disk persistence)", () => {
333
- let tmp: string;
334
- beforeEach(() => {
335
- tmp = mkdtempSync(join(tmpdir(), "compliance-controls-test-"));
336
- });
337
- afterEach(() => {
338
- rmSync(tmp, { recursive: true, force: true });
339
- });
340
-
341
- test("writes bundle to <outputDir>/<framework>/<control>/<period>.json", async () => {
342
- const records = chainOf([{ kind: "policy_decision", payload: {} }]);
343
- const collector = createComplianceCollector({
344
- auditSource: source(records),
345
- outputDir: tmp,
346
- });
347
- const bundle = await collector.collect("soc2", "CC6.1", { period: "2026-Q2" });
348
- const path = collector.writeBundle(bundle);
349
- expect(path).toBe(join(tmp, "soc2", "CC6.1", "2026-Q2.json"));
350
- expect(existsSync(path)).toBe(true);
351
- const content = JSON.parse(readFileSync(path, "utf8"));
352
- expect(content.recordCount).toBe(1);
353
- expect(content.digest).toBe(bundle.digest);
354
- });
355
-
356
- test("writeBundle refuses path-traversal in framework/control id", () => {
357
- const collector = createComplianceCollector({
358
- auditSource: source([]),
359
- outputDir: tmp,
360
- });
361
- const bundle = {
362
- frameworkId: "../etc",
363
- controlId: "passwd",
364
- description: "x",
365
- period: "2026",
366
- generatedAt: 1,
367
- recordCount: 0,
368
- records: [],
369
- digest: "x",
370
- signature: null,
371
- };
372
- expect(() => collector.writeBundle(bundle)).toThrow(/may not contain/);
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
- });
442
- });
package/src/index.ts DELETED
@@ -1,424 +0,0 @@
1
- import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
- import { mkdirSync, writeFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import { type AuditKind, type AuditRecord, recomputeRecordHash } from "@crewhaus/audit-log";
5
- import { CrewhausError } from "@crewhaus/errors";
6
-
7
- /**
8
- * Catalog R17 `compliance-controls` — Section 39 SOC 2 / ISO 27001 /
9
- * HIPAA evidence collection.
10
- *
11
- * A `ControlDefinition` declares which audit-log records prove a
12
- * given control is operating. The collector walks the audit log per
13
- * control, gathers matching records into an `EvidenceBundle`, signs
14
- * the bundle with HMAC-SHA256, and writes it to
15
- * `.crewhaus/compliance/<framework>/<controlId>/<period>.json`.
16
- *
17
- * Built-in framework definitions (SOC 2 Type II CC6.x + CC7.x, ISO
18
- * 27001 A.12.4, HIPAA §164.312(b)) are exported at module load so
19
- * callers don't have to author the filter shapes themselves.
20
- *
21
- * Layer R17. Pairs with `audit-log` (R-infra — primary record source),
22
- * `data-retention-engine` (§39 — adds an audit window so the records
23
- * referenced by an in-progress evidence collection are not purged
24
- * mid-run).
25
- */
26
-
27
- export class ComplianceControlsError extends CrewhausError {
28
- override readonly name = "ComplianceControlsError";
29
- constructor(message: string, cause?: unknown) {
30
- super("config", message, cause);
31
- }
32
- }
33
-
34
- export type AuditEventFilter = {
35
- /** Match the audit record's `kind` field. */
36
- readonly kind?: AuditKind;
37
- /** Match a specific top-level field within `payload`. */
38
- readonly payloadField?: string;
39
- /** Substring match on a serialized payload field — e.g. action="rotate". */
40
- readonly payloadFieldEquals?: string;
41
- /** Inclusive lower bound on `ts`. */
42
- readonly tsAfter?: number;
43
- /** Exclusive upper bound on `ts`. */
44
- readonly tsBefore?: number;
45
- };
46
-
47
- export type ControlDefinition = {
48
- readonly frameworkId: string;
49
- readonly controlId: string;
50
- /** Short description shown in the evidence bundle for auditors. */
51
- readonly description: string;
52
- /** OR-merged: a record matching any filter is evidence for this control. */
53
- readonly evidenceQueries: ReadonlyArray<AuditEventFilter>;
54
- };
55
-
56
- export type EvidenceBundle = {
57
- readonly frameworkId: string;
58
- readonly controlId: string;
59
- readonly description: string;
60
- readonly period: string;
61
- readonly generatedAt: number;
62
- readonly recordCount: number;
63
- readonly records: ReadonlyArray<AuditRecord>;
64
- /** Hex SHA-256 of the canonical record list, with HMAC signature when keyed. */
65
- readonly digest: string;
66
- readonly signature: string | null;
67
- };
68
-
69
- export interface AuditRecordSource {
70
- /** Yield every record across all days. */
71
- read(): AsyncIterable<AuditRecord>;
72
- }
73
-
74
- export type CollectOptions = {
75
- /** Period label (e.g. "2026-Q2"). Used in the output path. */
76
- readonly period: string;
77
- /** Optional HMAC key for signing the bundle. */
78
- readonly signingKey?: string;
79
- /** Optional clock for tests. */
80
- readonly now?: () => number;
81
- };
82
-
83
- export type CollectorOptions = {
84
- readonly auditSource: AuditRecordSource;
85
- readonly outputDir?: string;
86
- readonly controls?: ReadonlyArray<ControlDefinition>;
87
- };
88
-
89
- export interface ComplianceCollector {
90
- registerControl(def: ControlDefinition): void;
91
- listControls(filter?: { frameworkId?: string }): ReadonlyArray<ControlDefinition>;
92
- collect(framework: string, controlId: string, opts: CollectOptions): Promise<EvidenceBundle>;
93
- collectAll(framework: string, opts: CollectOptions): Promise<ReadonlyArray<EvidenceBundle>>;
94
- /** Test seam — write the bundle to disk under the configured outputDir. */
95
- writeBundle(bundle: EvidenceBundle): string;
96
- }
97
-
98
- // --------------------------------------------------------------------
99
- // Built-in framework definitions
100
- // --------------------------------------------------------------------
101
-
102
- export const SOC2_CONTROLS: ReadonlyArray<ControlDefinition> = [
103
- {
104
- frameworkId: "soc2",
105
- controlId: "CC6.1",
106
- description: "Logical and physical access controls — every policy decision is audit-logged.",
107
- evidenceQueries: [{ kind: "policy_decision" }],
108
- },
109
- {
110
- frameworkId: "soc2",
111
- controlId: "CC6.7",
112
- description:
113
- "Restricted access to system functions — secrets-manager rotation + access events captured.",
114
- evidenceQueries: [{ kind: "secrets_rotation" }, { kind: "secrets_access" }],
115
- },
116
- {
117
- frameworkId: "soc2",
118
- controlId: "CC7.2",
119
- description: "System monitoring — model invocations and tool classifications are captured.",
120
- evidenceQueries: [{ kind: "model_call" }, { kind: "tool_classification" }],
121
- },
122
- {
123
- frameworkId: "soc2",
124
- controlId: "CC7.3",
125
- description: "Detection of incidents — gateway requests captured for replay/replay analysis.",
126
- evidenceQueries: [{ kind: "gateway_request" }],
127
- },
128
- ];
129
-
130
- export const ISO27001_CONTROLS: ReadonlyArray<ControlDefinition> = [
131
- {
132
- frameworkId: "iso27001",
133
- controlId: "A.12.4",
134
- description:
135
- "Logging and monitoring — append-only hash-chained audit trail across every privileged operation.",
136
- evidenceQueries: [
137
- { kind: "policy_decision" },
138
- { kind: "model_call" },
139
- { kind: "secrets_rotation" },
140
- { kind: "deployment_action" },
141
- ],
142
- },
143
- ];
144
-
145
- export const HIPAA_CONTROLS: ReadonlyArray<ControlDefinition> = [
146
- {
147
- frameworkId: "hipaa",
148
- controlId: "164.312(b)",
149
- description:
150
- "Audit controls — every access to or decision involving protected data appears in the audit log.",
151
- evidenceQueries: [
152
- { kind: "policy_decision" },
153
- { kind: "secrets_access" },
154
- { kind: "tenancy_context" },
155
- ],
156
- },
157
- ];
158
-
159
- export const BUILT_IN_CONTROLS: ReadonlyArray<ControlDefinition> = [
160
- ...SOC2_CONTROLS,
161
- ...ISO27001_CONTROLS,
162
- ...HIPAA_CONTROLS,
163
- ];
164
-
165
- // --------------------------------------------------------------------
166
- // Match logic
167
- // --------------------------------------------------------------------
168
-
169
- function matches(record: AuditRecord, filter: AuditEventFilter): boolean {
170
- if (filter.kind !== undefined && record.kind !== filter.kind) return false;
171
- if (filter.tsAfter !== undefined && record.ts < filter.tsAfter) return false;
172
- if (filter.tsBefore !== undefined && record.ts >= filter.tsBefore) return false;
173
- if (filter.payloadField !== undefined) {
174
- const payload = record.payload as Record<string, unknown> | null;
175
- if (payload === null || typeof payload !== "object") 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;
181
- if (filter.payloadFieldEquals !== undefined) {
182
- const value = payload[filter.payloadField];
183
- if (typeof value === "string") {
184
- if (value !== filter.payloadFieldEquals) return false;
185
- } else if (String(value) !== filter.payloadFieldEquals) {
186
- return false;
187
- }
188
- }
189
- }
190
- return true;
191
- }
192
-
193
- function matchesAny(record: AuditRecord, queries: ReadonlyArray<AuditEventFilter>): boolean {
194
- if (queries.length === 0) return false;
195
- for (const q of queries) {
196
- if (matches(record, q)) return true;
197
- }
198
- return false;
199
- }
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
-
214
- function canonicalDigest(records: ReadonlyArray<AuditRecord>): string {
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");
220
- return createHash("sha256").update(text).digest("hex");
221
- }
222
-
223
- function signDigest(digest: string, key: string): string {
224
- return createHmac("sha256", key).update(digest).digest("hex");
225
- }
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
-
283
- // --------------------------------------------------------------------
284
- // Collector
285
- // --------------------------------------------------------------------
286
-
287
- export function createComplianceCollector(opts: CollectorOptions): ComplianceCollector {
288
- if (opts.auditSource === undefined) {
289
- throw new ComplianceControlsError("auditSource is required");
290
- }
291
- const outputDir = opts.outputDir ?? join(process.cwd(), ".crewhaus", "compliance");
292
- const controls = new Map<string, ControlDefinition>();
293
- const seedControls = opts.controls ?? BUILT_IN_CONTROLS;
294
- for (const c of seedControls) {
295
- controls.set(`${c.frameworkId}::${c.controlId}`, c);
296
- }
297
-
298
- function key(framework: string, controlId: string): string {
299
- return `${framework}::${controlId}`;
300
- }
301
-
302
- return {
303
- registerControl(def: ControlDefinition): void {
304
- if (typeof def.frameworkId !== "string" || def.frameworkId === "") {
305
- throw new ComplianceControlsError("registerControl: frameworkId required");
306
- }
307
- if (typeof def.controlId !== "string" || def.controlId === "") {
308
- throw new ComplianceControlsError("registerControl: controlId required");
309
- }
310
- controls.set(key(def.frameworkId, def.controlId), def);
311
- },
312
-
313
- listControls(filter?: { frameworkId?: string }): ReadonlyArray<ControlDefinition> {
314
- const items = [...controls.values()];
315
- const filtered =
316
- filter?.frameworkId !== undefined
317
- ? items.filter((c) => c.frameworkId === filter.frameworkId)
318
- : items;
319
- return filtered.sort(
320
- (a, b) =>
321
- a.frameworkId.localeCompare(b.frameworkId) || a.controlId.localeCompare(b.controlId),
322
- );
323
- },
324
-
325
- async collect(framework, controlId, collectOpts): Promise<EvidenceBundle> {
326
- const def = controls.get(key(framework, controlId));
327
- if (def === undefined) {
328
- throw new ComplianceControlsError(
329
- `collect: control "${framework}/${controlId}" not registered`,
330
- );
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.
339
- const matched: AuditRecord[] = [];
340
- let prev: AuditRecord | undefined;
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;
360
- if (matchesAny(record, def.evidenceQueries)) {
361
- matched.push(record);
362
- }
363
- }
364
- const digest = canonicalDigest(matched);
365
- const signature = collectOpts.signingKey ? signDigest(digest, collectOpts.signingKey) : null;
366
- const now = collectOpts.now?.() ?? Date.now();
367
- return {
368
- frameworkId: def.frameworkId,
369
- controlId: def.controlId,
370
- description: def.description,
371
- period: collectOpts.period,
372
- generatedAt: now,
373
- recordCount: matched.length,
374
- records: matched,
375
- digest,
376
- signature,
377
- };
378
- },
379
-
380
- async collectAll(framework, collectOpts): Promise<ReadonlyArray<EvidenceBundle>> {
381
- const matchingControls = [...controls.values()]
382
- .filter((c) => c.frameworkId === framework)
383
- .sort((a, b) => a.controlId.localeCompare(b.controlId));
384
- if (matchingControls.length === 0) {
385
- throw new ComplianceControlsError(
386
- `collectAll: no controls registered for framework "${framework}"`,
387
- );
388
- }
389
- const bundles: EvidenceBundle[] = [];
390
- for (const c of matchingControls) {
391
- bundles.push(await this.collect(c.frameworkId, c.controlId, collectOpts));
392
- }
393
- return bundles;
394
- },
395
-
396
- writeBundle(bundle: EvidenceBundle): string {
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
- }
410
- }
411
- const path = join(outputDir, bundle.frameworkId, bundle.controlId, `${bundle.period}.json`);
412
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
413
- writeFileSync(path, `${JSON.stringify(bundle, null, 2)}\n`, { mode: 0o600 });
414
- return path;
415
- },
416
- };
417
- }
418
-
419
- export {
420
- matches as _matchesForTest,
421
- matchesAny as _matchesAnyForTest,
422
- canonicalDigest as _canonicalDigestForTest,
423
- signDigest as _signDigestForTest,
424
- };