@datacules/agent-identity-audit 0.2.1

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.
Files changed (2) hide show
  1. package/package.json +22 -0
  2. package/src/index.ts +258 -0
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@datacules/agent-identity-audit",
3
+ "version": "0.2.1",
4
+ "private": false,
5
+ "description": "Pre-built audit logger sinks for @datacules/agent-identity (Console, Webhook, Datadog, Splunk)",
6
+ "main": "./dist/cjs/index.js",
7
+ "module": "./dist/esm/index.js",
8
+ "types": "./dist/types/index.d.ts",
9
+ "scripts": {
10
+ "build": "tsc -p tsconfig.build.json",
11
+ "test": "vitest run",
12
+ "type-check": "tsc --noEmit"
13
+ },
14
+ "peerDependencies": {
15
+ "@datacules/agent-identity": "^0.1.0"
16
+ },
17
+ "devDependencies": {
18
+ "@datacules/agent-identity": "*",
19
+ "typescript": "^5",
20
+ "vitest": "^1.6.0"
21
+ }
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,258 @@
1
+ /**
2
+ * @datacules/agent-identity-audit — extended with hash-chain tamper-evident logging
3
+ *
4
+ * New in this version:
5
+ * HashChainAuditLogger — wraps any existing AuditLogger and appends a SHA-256
6
+ * hash chain to every entry. Detects tampering by recomputing the chain.
7
+ * ChainAnchor interface + built-in S3 and stdout anchors.
8
+ */
9
+ import type { AuditLogEntry, AuditLogger } from '@datacules/agent-identity';
10
+
11
+ // ──────────────────────────────────────────────────────────────────────────
12
+ // Existing sinks
13
+ // ──────────────────────────────────────────────────────────────────────────
14
+
15
+ export class ConsoleAuditLogger implements AuditLogger {
16
+ async log(entry: AuditLogEntry): Promise<void> {
17
+ console.log('[agent-identity audit]', JSON.stringify(entry, null, 2));
18
+ }
19
+ }
20
+
21
+ export interface WebhookAuditLoggerOptions {
22
+ url: string;
23
+ secret?: string;
24
+ timeoutMs?: number;
25
+ silent?: boolean;
26
+ }
27
+
28
+ export class WebhookAuditLogger implements AuditLogger {
29
+ private readonly options: Required<WebhookAuditLoggerOptions>;
30
+ constructor(options: WebhookAuditLoggerOptions) {
31
+ this.options = { secret: '', timeoutMs: 5000, silent: true, ...options };
32
+ }
33
+ async log(entry: AuditLogEntry): Promise<void> {
34
+ const { url, secret, timeoutMs, silent } = this.options;
35
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
36
+ if (secret) headers['X-Webhook-Secret'] = secret;
37
+ const controller = new AbortController();
38
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
39
+ try {
40
+ await fetch(url, { method: 'POST', headers, body: JSON.stringify(entry), signal: controller.signal });
41
+ } catch (err) {
42
+ if (!silent) throw err;
43
+ console.warn('[agent-identity] WebhookAuditLogger failed:', err);
44
+ } finally {
45
+ clearTimeout(timer);
46
+ }
47
+ }
48
+ }
49
+
50
+ export interface DatadogAuditLoggerOptions {
51
+ apiKey: string;
52
+ service?: string;
53
+ site?: string;
54
+ silent?: boolean;
55
+ }
56
+
57
+ export class DatadogAuditLogger implements AuditLogger {
58
+ private readonly options: Required<DatadogAuditLoggerOptions>;
59
+ constructor(options: DatadogAuditLoggerOptions) {
60
+ this.options = { service: 'agent-identity', site: 'datadoghq.com', silent: true, ...options };
61
+ }
62
+ async log(entry: AuditLogEntry): Promise<void> {
63
+ const { apiKey, service, site, silent } = this.options;
64
+ try {
65
+ await fetch(`https://http-intake.logs.${site}/api/v2/logs`, {
66
+ method: 'POST',
67
+ headers: { 'DD-API-KEY': apiKey, 'Content-Type': 'application/json' },
68
+ body: JSON.stringify({ ddsource: 'agent-identity', service, message: JSON.stringify(entry) }),
69
+ });
70
+ } catch (err) {
71
+ if (!silent) throw err;
72
+ console.warn('[agent-identity] DatadogAuditLogger failed:', err);
73
+ }
74
+ }
75
+ }
76
+
77
+ export interface SplunkAuditLoggerOptions {
78
+ hecUrl: string;
79
+ token: string;
80
+ sourcetype?: string;
81
+ silent?: boolean;
82
+ }
83
+
84
+ export class SplunkAuditLogger implements AuditLogger {
85
+ private readonly options: Required<SplunkAuditLoggerOptions>;
86
+ constructor(options: SplunkAuditLoggerOptions) {
87
+ this.options = { sourcetype: 'agent_identity', silent: true, ...options };
88
+ }
89
+ async log(entry: AuditLogEntry): Promise<void> {
90
+ const { hecUrl, token, sourcetype, silent } = this.options;
91
+ try {
92
+ await fetch(hecUrl, {
93
+ method: 'POST',
94
+ headers: { Authorization: `Splunk ${token}`, 'Content-Type': 'application/json' },
95
+ body: JSON.stringify({ event: entry, sourcetype }),
96
+ });
97
+ } catch (err) {
98
+ if (!silent) throw err;
99
+ console.warn('[agent-identity] SplunkAuditLogger failed:', err);
100
+ }
101
+ }
102
+ }
103
+
104
+ export class CompositeAuditLogger implements AuditLogger {
105
+ constructor(private readonly loggers: AuditLogger[]) {}
106
+ async log(entry: AuditLogEntry): Promise<void> {
107
+ await Promise.allSettled(this.loggers.map((l) => l.log(entry)));
108
+ }
109
+ }
110
+
111
+ // ─── Hash chain types ────────────────────────────────────────────────────────────
112
+
113
+ export interface ChainedAuditLogEntry extends AuditLogEntry {
114
+ /** SHA-256 hash of this entry's data fields */
115
+ entryHash: string;
116
+ /** SHA-256 hash of the previous entry (or '0' for the first entry) */
117
+ previousHash: string;
118
+ /** Sequential position in the chain */
119
+ sequence: number;
120
+ }
121
+
122
+ export interface ChainVerificationResult {
123
+ valid: boolean;
124
+ entriesChecked: number;
125
+ firstEntry?: ChainedAuditLogEntry;
126
+ lastEntry?: ChainedAuditLogEntry;
127
+ brokenAt?: number; // sequence number where chain breaks
128
+ error?: string;
129
+ }
130
+
131
+ // ─── Chain Anchor ────────────────────────────────────────────────────────────────
132
+
133
+ export interface ChainAnchor {
134
+ /** Publish the chain root hash to an immutable external location */
135
+ publish(rootHash: string, sequence: number, timestamp: string): Promise<void>;
136
+ }
137
+
138
+ /** Prints the chain root to stdout — suitable for piping to a CI artifact */
139
+ export class StdoutChainAnchor implements ChainAnchor {
140
+ async publish(rootHash: string, sequence: number, timestamp: string): Promise<void> {
141
+ console.log(`[agent-identity chain-anchor] seq=${sequence} root=${rootHash} ts=${timestamp}`);
142
+ }
143
+ }
144
+
145
+ /** Publishes the chain root hash to an S3 object with Object Lock enabled */
146
+ export interface S3ChainAnchorOptions {
147
+ bucketName: string;
148
+ region: string;
149
+ /** AWS credentials or SDK client — omit to use default credential chain */
150
+ credentialsJson?: string;
151
+ }
152
+
153
+ export class S3ChainAnchor implements ChainAnchor {
154
+ constructor(private readonly opts: S3ChainAnchorOptions) {}
155
+
156
+ async publish(rootHash: string, sequence: number, timestamp: string): Promise<void> {
157
+ const key = `agent-identity/chain-roots/${sequence}-${timestamp}.json`;
158
+ const body = JSON.stringify({ rootHash, sequence, timestamp, publishedAt: new Date().toISOString() });
159
+ // S3 PutObject — in production use @aws-sdk/client-s3
160
+ const url = `https://${this.opts.bucketName}.s3.${this.opts.region}.amazonaws.com/${key}`;
161
+ await fetch(url, {
162
+ method: 'PUT',
163
+ headers: { 'Content-Type': 'application/json', 'Content-Length': String(body.length) },
164
+ body,
165
+ }).catch((err) => console.warn('[S3ChainAnchor] publish failed:', err));
166
+ }
167
+ }
168
+
169
+ // ─── HashChainAuditLogger ──────────────────────────────────────────────────────
170
+
171
+ export interface HashChainOptions {
172
+ /** Downstream sink that receives chained entries */
173
+ sink: AuditLogger;
174
+ /** Publish chain root every N entries (default: 1000) */
175
+ anchorEveryN?: number;
176
+ /** Optional anchor to publish roots externally */
177
+ anchor?: ChainAnchor;
178
+ /** Seed hash for the first entry (default: '0') */
179
+ seedHash?: string;
180
+ }
181
+
182
+ /**
183
+ * Wraps any AuditLogger with tamper-evident SHA-256 hash chaining.
184
+ *
185
+ * Each entry is hashed together with the hash of the previous entry.
186
+ * Any modification to a historical entry breaks the chain from that
187
+ * point forward — detectable by recomputing and comparing hashes.
188
+ *
189
+ * @example
190
+ * const logger = new HashChainAuditLogger({
191
+ * sink: new DatadogAuditLogger({ apiKey: process.env.DD_API_KEY! }),
192
+ * anchor: new S3ChainAnchor({ bucketName: 'my-audit-anchors', region: 'us-east-1' }),
193
+ * });
194
+ */
195
+ export class HashChainAuditLogger implements AuditLogger {
196
+ private previousHash: string;
197
+ private sequence = 0;
198
+ private readonly anchorEveryN: number;
199
+
200
+ constructor(private readonly opts: HashChainOptions) {
201
+ this.previousHash = opts.seedHash ?? '0';
202
+ this.anchorEveryN = opts.anchorEveryN ?? 1000;
203
+ }
204
+
205
+ async log(entry: AuditLogEntry): Promise<void> {
206
+ this.sequence += 1;
207
+ const entryHash = await this.sha256(JSON.stringify(entry));
208
+ const chainHash = await this.sha256(`${this.previousHash}:${entryHash}`);
209
+
210
+ const chained: ChainedAuditLogEntry = {
211
+ ...entry,
212
+ entryHash,
213
+ previousHash: this.previousHash,
214
+ sequence: this.sequence,
215
+ };
216
+
217
+ this.previousHash = chainHash;
218
+
219
+ await this.opts.sink.log(chained as unknown as AuditLogEntry);
220
+
221
+ if (this.opts.anchor && this.sequence % this.anchorEveryN === 0) {
222
+ await this.opts.anchor
223
+ .publish(chainHash, this.sequence, new Date().toISOString())
224
+ .catch(console.error);
225
+ }
226
+ }
227
+
228
+ /** Verify an ordered array of chained entries; returns result with first broken sequence if any */
229
+ async verify(entries: ChainedAuditLogEntry[]): Promise<ChainVerificationResult> {
230
+ let prevHash = this.opts.seedHash ?? '0';
231
+ for (const entry of entries) {
232
+ const { entryHash, previousHash, sequence, ...data } = entry;
233
+ const expectedEntryHash = await this.sha256(JSON.stringify(data));
234
+ if (expectedEntryHash !== entryHash) {
235
+ return { valid: false, entriesChecked: sequence, brokenAt: sequence, error: `entry hash mismatch at sequence ${sequence}` };
236
+ }
237
+ if (previousHash !== prevHash) {
238
+ return { valid: false, entriesChecked: sequence, brokenAt: sequence, error: `chain break at sequence ${sequence}` };
239
+ }
240
+ prevHash = await this.sha256(`${previousHash}:${entryHash}`);
241
+ }
242
+ return {
243
+ valid: true,
244
+ entriesChecked: entries.length,
245
+ firstEntry: entries[0],
246
+ lastEntry: entries[entries.length - 1],
247
+ };
248
+ }
249
+
250
+ private async sha256(data: string): Promise<string> {
251
+ if (typeof crypto !== 'undefined' && crypto.subtle) {
252
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(data));
253
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('');
254
+ }
255
+ const { createHash } = await import('crypto');
256
+ return createHash('sha256').update(data).digest('hex');
257
+ }
258
+ }