@crewhaus/compliance-controls 0.1.4 → 0.1.6
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/dist/index.d.ts +108 -0
- package/dist/index.js +316 -0
- package/package.json +10 -7
- package/src/index.test.ts +0 -442
- package/src/index.ts +0 -424
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
"version": "0.1.6",
|
|
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": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
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.
|
|
16
|
-
"@crewhaus/errors": "0.1.
|
|
18
|
+
"@crewhaus/audit-log": "0.1.6",
|
|
19
|
+
"@crewhaus/errors": "0.1.6"
|
|
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": ["
|
|
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
|
-
};
|