@crewhaus/compliance-controls 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +42 -0
- package/src/index.test.ts +266 -0
- package/src/index.ts +312 -0
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/compliance-controls",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
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",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@crewhaus/audit-log": "0.0.0",
|
|
16
|
+
"@crewhaus/errors": "0.0.0"
|
|
17
|
+
},
|
|
18
|
+
"license": "Apache-2.0",
|
|
19
|
+
"author": {
|
|
20
|
+
"name": "Max Meier",
|
|
21
|
+
"email": "max@studiomax.io",
|
|
22
|
+
"url": "https://studiomax.io"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
27
|
+
"directory": "packages/compliance-controls"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/compliance-controls#readme",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "restricted"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"src",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"NOTICE"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
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 } 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
|
+
} from "./index";
|
|
19
|
+
|
|
20
|
+
function makeRecord(
|
|
21
|
+
partial: Pick<AuditRecord, "kind" | "payload"> & Partial<AuditRecord>,
|
|
22
|
+
): AuditRecord {
|
|
23
|
+
return {
|
|
24
|
+
ts: partial.ts ?? Date.now(),
|
|
25
|
+
version: 1,
|
|
26
|
+
kind: partial.kind,
|
|
27
|
+
payload: partial.payload,
|
|
28
|
+
prevHash: partial.prevHash ?? "GENESIS",
|
|
29
|
+
hash: partial.hash ?? `hash-${Math.random()}`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function source(records: ReadonlyArray<AuditRecord>): AuditRecordSource {
|
|
34
|
+
return {
|
|
35
|
+
async *read() {
|
|
36
|
+
for (const r of records) yield r;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("Built-in framework definitions", () => {
|
|
42
|
+
test("SOC2 controls cover CC6.x + CC7.x families", () => {
|
|
43
|
+
const ids = SOC2_CONTROLS.map((c) => c.controlId);
|
|
44
|
+
expect(ids).toContain("CC6.1");
|
|
45
|
+
expect(ids).toContain("CC6.7");
|
|
46
|
+
expect(ids).toContain("CC7.2");
|
|
47
|
+
expect(ids).toContain("CC7.3");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("ISO27001 covers A.12.4", () => {
|
|
51
|
+
const ids = ISO27001_CONTROLS.map((c) => c.controlId);
|
|
52
|
+
expect(ids).toContain("A.12.4");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("HIPAA covers 164.312(b)", () => {
|
|
56
|
+
const ids = HIPAA_CONTROLS.map((c) => c.controlId);
|
|
57
|
+
expect(ids).toContain("164.312(b)");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("BUILT_IN_CONTROLS aggregates all frameworks", () => {
|
|
61
|
+
const frameworks = new Set(BUILT_IN_CONTROLS.map((c) => c.frameworkId));
|
|
62
|
+
expect(frameworks.has("soc2")).toBe(true);
|
|
63
|
+
expect(frameworks.has("iso27001")).toBe(true);
|
|
64
|
+
expect(frameworks.has("hipaa")).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("Match logic (T1)", () => {
|
|
69
|
+
test("kind filter", () => {
|
|
70
|
+
const r = makeRecord({ kind: "policy_decision", payload: {} });
|
|
71
|
+
expect(_matchesForTest(r, { kind: "policy_decision" })).toBe(true);
|
|
72
|
+
expect(_matchesForTest(r, { kind: "model_call" })).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("ts range filters (inclusive lower, exclusive upper)", () => {
|
|
76
|
+
const r = makeRecord({ kind: "policy_decision", payload: {}, ts: 1000 });
|
|
77
|
+
expect(_matchesForTest(r, { kind: "policy_decision", tsAfter: 999 })).toBe(true);
|
|
78
|
+
expect(_matchesForTest(r, { kind: "policy_decision", tsAfter: 1000 })).toBe(true);
|
|
79
|
+
expect(_matchesForTest(r, { kind: "policy_decision", tsAfter: 1001 })).toBe(false);
|
|
80
|
+
expect(_matchesForTest(r, { kind: "policy_decision", tsBefore: 1001 })).toBe(true);
|
|
81
|
+
expect(_matchesForTest(r, { kind: "policy_decision", tsBefore: 1000 })).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("payloadField filter", () => {
|
|
85
|
+
const r = makeRecord({
|
|
86
|
+
kind: "policy_decision",
|
|
87
|
+
payload: { action: "allow", risk: 0.1 },
|
|
88
|
+
});
|
|
89
|
+
expect(_matchesForTest(r, { kind: "policy_decision", payloadField: "action" })).toBe(true);
|
|
90
|
+
expect(_matchesForTest(r, { kind: "policy_decision", payloadField: "missing" })).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("payloadFieldEquals filter", () => {
|
|
94
|
+
const r = makeRecord({
|
|
95
|
+
kind: "policy_decision",
|
|
96
|
+
payload: { action: "allow" },
|
|
97
|
+
});
|
|
98
|
+
expect(
|
|
99
|
+
_matchesForTest(r, {
|
|
100
|
+
kind: "policy_decision",
|
|
101
|
+
payloadField: "action",
|
|
102
|
+
payloadFieldEquals: "allow",
|
|
103
|
+
}),
|
|
104
|
+
).toBe(true);
|
|
105
|
+
expect(
|
|
106
|
+
_matchesForTest(r, {
|
|
107
|
+
kind: "policy_decision",
|
|
108
|
+
payloadField: "action",
|
|
109
|
+
payloadFieldEquals: "deny",
|
|
110
|
+
}),
|
|
111
|
+
).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("matchesAny is OR-merged", () => {
|
|
115
|
+
const r = makeRecord({ kind: "policy_decision", payload: {} });
|
|
116
|
+
const queries = [{ kind: "model_call" as const }, { kind: "policy_decision" as const }];
|
|
117
|
+
expect(_matchesAnyForTest(r, queries)).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("matchesAny returns false for empty queries", () => {
|
|
121
|
+
const r = makeRecord({ kind: "policy_decision", payload: {} });
|
|
122
|
+
expect(_matchesAnyForTest(r, [])).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("Digest + signature helpers (T1)", () => {
|
|
127
|
+
test("canonicalDigest is deterministic", () => {
|
|
128
|
+
const r1 = makeRecord({ kind: "policy_decision", payload: {}, hash: "h1" });
|
|
129
|
+
const r2 = makeRecord({ kind: "policy_decision", payload: {}, hash: "h2" });
|
|
130
|
+
expect(_canonicalDigestForTest([r1, r2])).toBe(_canonicalDigestForTest([r1, r2]));
|
|
131
|
+
expect(_canonicalDigestForTest([r1, r2])).not.toBe(_canonicalDigestForTest([r2, r1]));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("signDigest produces a stable HMAC-SHA256", () => {
|
|
135
|
+
expect(_signDigestForTest("abc", "k1")).toBe(_signDigestForTest("abc", "k1"));
|
|
136
|
+
expect(_signDigestForTest("abc", "k1")).not.toBe(_signDigestForTest("abc", "k2"));
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("createComplianceCollector (T1 + T3)", () => {
|
|
141
|
+
test("requires auditSource", () => {
|
|
142
|
+
expect(() =>
|
|
143
|
+
createComplianceCollector({ auditSource: undefined as unknown as AuditRecordSource }),
|
|
144
|
+
).toThrow(ComplianceControlsError);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("listControls returns built-ins by default, sorted", () => {
|
|
148
|
+
const collector = createComplianceCollector({ auditSource: source([]) });
|
|
149
|
+
const list = collector.listControls();
|
|
150
|
+
expect(list.length).toBe(BUILT_IN_CONTROLS.length);
|
|
151
|
+
// Sorted by framework first, then control.
|
|
152
|
+
const frameworks = list.map((c) => c.frameworkId);
|
|
153
|
+
expect([...frameworks].sort()).toEqual(frameworks);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("listControls with frameworkId filter narrows the result", () => {
|
|
157
|
+
const collector = createComplianceCollector({ auditSource: source([]) });
|
|
158
|
+
const soc2 = collector.listControls({ frameworkId: "soc2" });
|
|
159
|
+
expect(soc2.every((c) => c.frameworkId === "soc2")).toBe(true);
|
|
160
|
+
expect(soc2.length).toBe(SOC2_CONTROLS.length);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("registerControl adds custom controls", () => {
|
|
164
|
+
const collector = createComplianceCollector({ auditSource: source([]) });
|
|
165
|
+
collector.registerControl({
|
|
166
|
+
frameworkId: "internal",
|
|
167
|
+
controlId: "X-1",
|
|
168
|
+
description: "internal control",
|
|
169
|
+
evidenceQueries: [{ kind: "policy_decision" }],
|
|
170
|
+
});
|
|
171
|
+
expect(collector.listControls({ frameworkId: "internal" })).toHaveLength(1);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
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
|
+
];
|
|
180
|
+
const collector = createComplianceCollector({ auditSource: source(records) });
|
|
181
|
+
const bundle = await collector.collect("soc2", "CC6.1", { period: "2026-Q2" });
|
|
182
|
+
expect(bundle.frameworkId).toBe("soc2");
|
|
183
|
+
expect(bundle.controlId).toBe("CC6.1");
|
|
184
|
+
expect(bundle.recordCount).toBe(2);
|
|
185
|
+
expect(bundle.records.map((r) => r.hash)).toEqual(["h1", "h3"]);
|
|
186
|
+
expect(bundle.signature).toBeNull();
|
|
187
|
+
expect(bundle.digest).toMatch(/^[a-f0-9]{64}$/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("collect with signingKey produces a non-null HMAC signature", async () => {
|
|
191
|
+
const records: AuditRecord[] = [
|
|
192
|
+
makeRecord({ kind: "policy_decision", payload: {}, hash: "h1" }),
|
|
193
|
+
];
|
|
194
|
+
const collector = createComplianceCollector({ auditSource: source(records) });
|
|
195
|
+
const bundle = await collector.collect("soc2", "CC6.1", {
|
|
196
|
+
period: "2026-Q2",
|
|
197
|
+
signingKey: "k1",
|
|
198
|
+
});
|
|
199
|
+
expect(bundle.signature).toMatch(/^[a-f0-9]{64}$/);
|
|
200
|
+
expect(bundle.signature).toBe(_signDigestForTest(bundle.digest, "k1"));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("collect with unknown control throws", async () => {
|
|
204
|
+
const collector = createComplianceCollector({ auditSource: source([]) });
|
|
205
|
+
await expect(collector.collect("ghost", "X", { period: "2026-Q2" })).rejects.toThrow(
|
|
206
|
+
/not registered/,
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
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
|
+
];
|
|
215
|
+
const collector = createComplianceCollector({ auditSource: source(records) });
|
|
216
|
+
const bundles = await collector.collectAll("soc2", { period: "2026-Q2" });
|
|
217
|
+
expect(bundles.length).toBe(SOC2_CONTROLS.length);
|
|
218
|
+
expect(bundles.every((b) => b.frameworkId === "soc2")).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("writeBundle (T3 — disk persistence)", () => {
|
|
223
|
+
let tmp: string;
|
|
224
|
+
beforeEach(() => {
|
|
225
|
+
tmp = mkdtempSync(join(tmpdir(), "compliance-controls-test-"));
|
|
226
|
+
});
|
|
227
|
+
afterEach(() => {
|
|
228
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("writes bundle to <outputDir>/<framework>/<control>/<period>.json", async () => {
|
|
232
|
+
const records: AuditRecord[] = [
|
|
233
|
+
makeRecord({ kind: "policy_decision", payload: {}, hash: "h1" }),
|
|
234
|
+
];
|
|
235
|
+
const collector = createComplianceCollector({
|
|
236
|
+
auditSource: source(records),
|
|
237
|
+
outputDir: tmp,
|
|
238
|
+
});
|
|
239
|
+
const bundle = await collector.collect("soc2", "CC6.1", { period: "2026-Q2" });
|
|
240
|
+
const path = collector.writeBundle(bundle);
|
|
241
|
+
expect(path).toBe(join(tmp, "soc2", "CC6.1", "2026-Q2.json"));
|
|
242
|
+
expect(existsSync(path)).toBe(true);
|
|
243
|
+
const content = JSON.parse(readFileSync(path, "utf8"));
|
|
244
|
+
expect(content.recordCount).toBe(1);
|
|
245
|
+
expect(content.digest).toBe(bundle.digest);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("writeBundle refuses path-traversal in framework/control id", () => {
|
|
249
|
+
const collector = createComplianceCollector({
|
|
250
|
+
auditSource: source([]),
|
|
251
|
+
outputDir: tmp,
|
|
252
|
+
});
|
|
253
|
+
const bundle = {
|
|
254
|
+
frameworkId: "../etc",
|
|
255
|
+
controlId: "passwd",
|
|
256
|
+
description: "x",
|
|
257
|
+
period: "2026",
|
|
258
|
+
generatedAt: 1,
|
|
259
|
+
recordCount: 0,
|
|
260
|
+
records: [],
|
|
261
|
+
digest: "x",
|
|
262
|
+
signature: null,
|
|
263
|
+
};
|
|
264
|
+
expect(() => collector.writeBundle(bundle)).toThrow(/may not contain/);
|
|
265
|
+
});
|
|
266
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { createHash, createHmac } from "node:crypto";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { AuditKind, AuditRecord } 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
|
+
if (!(filter.payloadField in payload)) return false;
|
|
177
|
+
if (filter.payloadFieldEquals !== undefined) {
|
|
178
|
+
const value = payload[filter.payloadField];
|
|
179
|
+
if (typeof value === "string") {
|
|
180
|
+
if (value !== filter.payloadFieldEquals) return false;
|
|
181
|
+
} else if (String(value) !== filter.payloadFieldEquals) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function matchesAny(record: AuditRecord, queries: ReadonlyArray<AuditEventFilter>): boolean {
|
|
190
|
+
if (queries.length === 0) return false;
|
|
191
|
+
for (const q of queries) {
|
|
192
|
+
if (matches(record, q)) return true;
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function canonicalDigest(records: ReadonlyArray<AuditRecord>): string {
|
|
198
|
+
const text = records.map((r) => r.hash).join("|");
|
|
199
|
+
return createHash("sha256").update(text).digest("hex");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function signDigest(digest: string, key: string): string {
|
|
203
|
+
return createHmac("sha256", key).update(digest).digest("hex");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --------------------------------------------------------------------
|
|
207
|
+
// Collector
|
|
208
|
+
// --------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
export function createComplianceCollector(opts: CollectorOptions): ComplianceCollector {
|
|
211
|
+
if (opts.auditSource === undefined) {
|
|
212
|
+
throw new ComplianceControlsError("auditSource is required");
|
|
213
|
+
}
|
|
214
|
+
const outputDir = opts.outputDir ?? join(process.cwd(), ".crewhaus", "compliance");
|
|
215
|
+
const controls = new Map<string, ControlDefinition>();
|
|
216
|
+
const seedControls = opts.controls ?? BUILT_IN_CONTROLS;
|
|
217
|
+
for (const c of seedControls) {
|
|
218
|
+
controls.set(`${c.frameworkId}::${c.controlId}`, c);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function key(framework: string, controlId: string): string {
|
|
222
|
+
return `${framework}::${controlId}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
registerControl(def: ControlDefinition): void {
|
|
227
|
+
if (typeof def.frameworkId !== "string" || def.frameworkId === "") {
|
|
228
|
+
throw new ComplianceControlsError("registerControl: frameworkId required");
|
|
229
|
+
}
|
|
230
|
+
if (typeof def.controlId !== "string" || def.controlId === "") {
|
|
231
|
+
throw new ComplianceControlsError("registerControl: controlId required");
|
|
232
|
+
}
|
|
233
|
+
controls.set(key(def.frameworkId, def.controlId), def);
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
listControls(filter?: { frameworkId?: string }): ReadonlyArray<ControlDefinition> {
|
|
237
|
+
const items = [...controls.values()];
|
|
238
|
+
const filtered =
|
|
239
|
+
filter?.frameworkId !== undefined
|
|
240
|
+
? items.filter((c) => c.frameworkId === filter.frameworkId)
|
|
241
|
+
: items;
|
|
242
|
+
return filtered.sort(
|
|
243
|
+
(a, b) =>
|
|
244
|
+
a.frameworkId.localeCompare(b.frameworkId) || a.controlId.localeCompare(b.controlId),
|
|
245
|
+
);
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
async collect(framework, controlId, collectOpts): Promise<EvidenceBundle> {
|
|
249
|
+
const def = controls.get(key(framework, controlId));
|
|
250
|
+
if (def === undefined) {
|
|
251
|
+
throw new ComplianceControlsError(
|
|
252
|
+
`collect: control "${framework}/${controlId}" not registered`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const matched: AuditRecord[] = [];
|
|
256
|
+
for await (const record of opts.auditSource.read()) {
|
|
257
|
+
if (matchesAny(record, def.evidenceQueries)) {
|
|
258
|
+
matched.push(record);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const digest = canonicalDigest(matched);
|
|
262
|
+
const signature = collectOpts.signingKey ? signDigest(digest, collectOpts.signingKey) : null;
|
|
263
|
+
const now = collectOpts.now?.() ?? Date.now();
|
|
264
|
+
return {
|
|
265
|
+
frameworkId: def.frameworkId,
|
|
266
|
+
controlId: def.controlId,
|
|
267
|
+
description: def.description,
|
|
268
|
+
period: collectOpts.period,
|
|
269
|
+
generatedAt: now,
|
|
270
|
+
recordCount: matched.length,
|
|
271
|
+
records: matched,
|
|
272
|
+
digest,
|
|
273
|
+
signature,
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
async collectAll(framework, collectOpts): Promise<ReadonlyArray<EvidenceBundle>> {
|
|
278
|
+
const matchingControls = [...controls.values()]
|
|
279
|
+
.filter((c) => c.frameworkId === framework)
|
|
280
|
+
.sort((a, b) => a.controlId.localeCompare(b.controlId));
|
|
281
|
+
if (matchingControls.length === 0) {
|
|
282
|
+
throw new ComplianceControlsError(
|
|
283
|
+
`collectAll: no controls registered for framework "${framework}"`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
const bundles: EvidenceBundle[] = [];
|
|
287
|
+
for (const c of matchingControls) {
|
|
288
|
+
bundles.push(await this.collect(c.frameworkId, c.controlId, collectOpts));
|
|
289
|
+
}
|
|
290
|
+
return bundles;
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
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
|
+
);
|
|
298
|
+
}
|
|
299
|
+
const path = join(outputDir, bundle.frameworkId, bundle.controlId, `${bundle.period}.json`);
|
|
300
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
301
|
+
writeFileSync(path, `${JSON.stringify(bundle, null, 2)}\n`, { mode: 0o600 });
|
|
302
|
+
return path;
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export {
|
|
308
|
+
matches as _matchesForTest,
|
|
309
|
+
matchesAny as _matchesAnyForTest,
|
|
310
|
+
canonicalDigest as _canonicalDigestForTest,
|
|
311
|
+
signDigest as _signDigestForTest,
|
|
312
|
+
};
|