dsh-server-connector 0.2.2 → 0.3.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/lib/audit.js CHANGED
@@ -1,455 +1,469 @@
1
- // dsh-server-connector — execution audit log (design doc §3.4/§6.2).
2
- //
3
- // One JSON object per line, appended to `$DSH_HOME/server-connector/audit.log`.
4
- // The module only appends and reads the tail: it never participates in a
5
- // decision, never rewrites an earlier line, and never loads the whole file to
6
- // answer a tail(limit) call.
7
- //
8
- // Record shapes (doc §3.4):
9
- // exec: { ts, sessionId, serverId, serverName, command, decision,
10
- // policyRule, approved, exitCode, durationMs, truncated }
11
- // block: an exec record with decision="block", approved=null, exitCode=null
12
- // event: { ts, event, serverId, serverName, detail? } for non-execution
13
- // events such as hostkey-disabled / server-deleted / rule-disabled
14
- //
15
- // Rotation (doc §6.2): with `auditMaxBytes > 0`, the log is rotated once when
16
- // the next append would exceed that size. The current file becomes
17
- // `audit.log.1` and a fresh `audit.log` starts; a second threshold hit
18
- // overwrites `audit.log.1`, so at most two files of bounded size exist.
19
-
20
- import { mkdir, open, rename, stat, writeFile } from 'node:fs/promises'
21
- import { dirname, join, resolve } from 'node:path'
22
-
23
- import { connectorDirectory } from './registry.js'
24
-
25
- /** File name of the audit log. */
26
- export const AUDIT_FILE_NAME = 'audit.log'
27
-
28
- /** Suffix of the single rotated file. */
29
- export const AUDIT_ROTATED_SUFFIX = '.1'
30
-
31
- /** Byte size of the read window when tail() has to grow to satisfy `limit`. */
32
- const INITIAL_TAIL_WINDOW = 64 * 1024
33
-
34
- /**
35
- * One tail promise per log file: two AuditLog instances (tools and routes, say)
36
- * must not interleave a line or race the rotation check.
37
- * @type {Map<string, Promise<void>>}
38
- */
39
- const APPEND_QUEUES = new Map()
40
-
41
- /**
42
- * One audit record, exactly as persisted. Exec records, block records and
43
- * event records share this wrapper; `event` is present only on events.
44
- * @typedef {object} AuditRecord
45
- * @property {string} ts - ISO-8601 timestamp.
46
- * @property {string} [sessionId]
47
- * @property {string} [serverId]
48
- * @property {string} [serverName]
49
- * @property {string} [command]
50
- * @property {'allow'|'confirm'|'block'|'event'} [decision]
51
- * @property {string|null} [policyRule]
52
- * @property {boolean|null} [approved]
53
- * @property {number|null} [exitCode]
54
- * @property {number} [durationMs]
55
- * @property {boolean} [truncated]
56
- * @property {string} [event]
57
- * @property {string} [detail]
58
- */
59
-
60
- /**
61
- * Non-execution events the spec names (doc §3.4, §4.2.4, §9.6). `rule-disabled`
62
- * is the event that must be written when a rule id leaves a server's
63
- * `disabledRules` — i.e. when the rule is switched off (doc §4.2.4).
64
- * `server-enabled` / `server-disabled` record the enable switch, which applies
65
- * immediately rather than through the pending queue (enable-switch spec).
66
- * @typedef {'hostkey-disabled'|'server-deleted'|'rule-disabled'|'server-enabled'|'server-disabled'} AuditEvent
67
- */
68
-
69
- /** Every accepted event name, in one place so the validator and the type agree. */
70
- const AUDIT_EVENTS = Object.freeze([
71
- 'hostkey-disabled',
72
- 'server-deleted',
73
- 'rule-disabled',
74
- 'server-enabled',
75
- 'server-disabled',
76
- ])
77
-
78
- /**
79
- * What `append()` accepts. Every key except `ts` is optional; `ts` is stamped
80
- * by the log so callers cannot backdate an entry.
81
- * @typedef {object} AuditEntry
82
- * @property {string} [sessionId]
83
- * @property {string} [serverId]
84
- * @property {string} [serverName]
85
- * @property {string} [command]
86
- * @property {'allow'|'confirm'|'block'} [decision]
87
- * @property {string|null} [policyRule]
88
- * @property {boolean|null} [approved]
89
- * @property {number|null} [exitCode]
90
- * @property {number} [durationMs]
91
- * @property {boolean} [truncated]
92
- * @property {AuditEvent} [event]
93
- * @property {string} [detail]
94
- */
95
-
96
- /**
97
- * Machine-readable audit failure. `INVALID_AUDIT` marks a malformed call (bad
98
- * record, bad rotation limit); nothing is written when it is thrown.
99
- * @typedef {'INVALID_AUDIT'} AuditErrorCode
100
- */
101
- export class AuditError extends Error {
102
- /**
103
- * @param {AuditErrorCode} code
104
- * @param {string} message
105
- */
106
- constructor(code, message) {
107
- super(message)
108
- this.name = 'AuditError'
109
- /** @type {AuditErrorCode} */
110
- this.code = code
111
- }
112
- }
113
-
114
- /**
115
- * Default location of `audit.log` (doc §3.5).
116
- * @param {NodeJS.ProcessEnv} [env]
117
- * @returns {string}
118
- */
119
- export function defaultAuditFile(env = process.env) {
120
- return join(connectorDirectory(env), AUDIT_FILE_NAME)
121
- }
122
-
123
- /**
124
- * @param {unknown} value
125
- * @returns {value is Record<string, unknown>}
126
- */
127
- function isPlainObject(value) {
128
- return typeof value === 'object' && value !== null && !Array.isArray(value)
129
- }
130
-
131
- /**
132
- * @param {unknown} error
133
- * @param {string} code
134
- * @returns {boolean}
135
- */
136
- function isErrno(error, code) {
137
- return isPlainObject(error) && error.code === code
138
- }
139
-
140
- /**
141
- * Sequence one record's keys in the order of doc §3.4 instead of insertion
142
- * order, so the file stays readable and greppable in a stable shape.
143
- * @param {AuditRecord} record
144
- * @returns {string}
145
- */
146
- function serialize(record) {
147
- /** @type {Record<string, unknown>} */
148
- const ordered = { ts: record.ts }
149
- for (const key of [
150
- 'event',
151
- 'detail',
152
- 'sessionId',
153
- 'serverId',
154
- 'serverName',
155
- 'command',
156
- 'decision',
157
- 'policyRule',
158
- 'approved',
159
- 'exitCode',
160
- 'durationMs',
161
- 'truncated',
162
- ]) {
163
- const value = /** @type {Record<string, unknown>} */ (record)[key]
164
- if (value !== undefined) ordered[key] = value
165
- }
166
- return `${JSON.stringify(ordered)}\n`
167
- }
168
-
169
- /**
170
- * Build a validated record from a caller's entry. Shorthands that the spec
171
- * fixes are applied here so a block record cannot be written wrongly:
172
- * a `decision: "block"` line always carries `approved: null` and
173
- * `exitCode: null` (doc §3.4).
174
- * @param {AuditEntry} entry
175
- * @param {Date} now
176
- * @returns {AuditRecord}
177
- */
178
- function buildRecord(entry, now) {
179
- if (!isPlainObject(entry)) throw new AuditError('INVALID_AUDIT', 'audit entry must be an object.')
180
- const isEvent = entry.event !== undefined
181
- if (isEvent) {
182
- if (!AUDIT_EVENTS.includes(/** @type {any} */ (entry.event))) {
183
- throw new AuditError(
184
- 'INVALID_AUDIT',
185
- `event must be one of ${AUDIT_EVENTS.join(', ')}.`,
186
- )
187
- }
188
- if (entry.decision !== undefined) {
189
- throw new AuditError('INVALID_AUDIT', 'an event record carries no exec decision.')
190
- }
191
- } else if (entry.decision === undefined) {
192
- // Never default to "allow": a forgotten decision would then be recorded as
193
- // an allowed execution, which is exactly the mislabelling an audit log must
194
- // not produce.
195
- throw new AuditError('INVALID_AUDIT', 'decision must be allow, confirm or block (or provide event).')
196
- } else if (entry.decision !== 'allow' && entry.decision !== 'confirm' && entry.decision !== 'block') {
197
- throw new AuditError('INVALID_AUDIT', 'decision must be allow, confirm or block (or provide event).')
198
- }
199
-
200
- /** @type {AuditRecord} */
201
- const record = { ts: now.toISOString() }
202
- if (isEvent) {
203
- record.event = /** @type {AuditEvent} */ (entry.event)
204
- if (entry.detail !== undefined) {
205
- if (typeof entry.detail !== 'string') throw new AuditError('INVALID_AUDIT', 'detail must be a string.')
206
- record.detail = entry.detail
207
- }
208
- } else {
209
- record.decision = /** @type {'allow'|'confirm'|'block'} */ (entry.decision)
210
- }
211
-
212
- for (const field of /** @type {const} */ (['sessionId', 'serverId', 'serverName', 'command'])) {
213
- const value = entry[field]
214
- if (value === undefined) continue
215
- if (typeof value !== 'string') throw new AuditError('INVALID_AUDIT', `${field} must be a string.`)
216
- record[field] = value
217
- }
218
-
219
- if (entry.policyRule !== undefined) {
220
- if (entry.policyRule !== null && typeof entry.policyRule !== 'string') {
221
- throw new AuditError('INVALID_AUDIT', 'policyRule must be a string or null.')
222
- }
223
- record.policyRule = entry.policyRule
224
- }
225
-
226
- const isBlock = !isEvent && record.decision === 'block'
227
- if (isBlock) {
228
- // Doc §3.4: a blocked command never ran, so approval and exit code are null
229
- // no matter what a caller passed. Timing/output fields are dropped for the
230
- // same reason: there is no execution to describe.
231
- record.approved = null
232
- record.exitCode = null
233
- } else if (!isEvent) {
234
- if (entry.approved !== undefined) {
235
- if (entry.approved !== null && typeof entry.approved !== 'boolean') {
236
- throw new AuditError('INVALID_AUDIT', 'approved must be a boolean or null.')
237
- }
238
- record.approved = entry.approved
239
- }
240
- if (entry.exitCode !== undefined) {
241
- if (entry.exitCode !== null && !(typeof entry.exitCode === 'number' && Number.isInteger(entry.exitCode))) {
242
- throw new AuditError('INVALID_AUDIT', 'exitCode must be an integer or null.')
243
- }
244
- record.exitCode = entry.exitCode
245
- }
246
- if (entry.durationMs !== undefined) {
247
- if (typeof entry.durationMs !== 'number' || !Number.isFinite(entry.durationMs) || entry.durationMs < 0) {
248
- throw new AuditError('INVALID_AUDIT', 'durationMs must be a non-negative number.')
249
- }
250
- record.durationMs = entry.durationMs
251
- }
252
- if (entry.truncated !== undefined) {
253
- if (typeof entry.truncated !== 'boolean') {
254
- throw new AuditError('INVALID_AUDIT', 'truncated must be a boolean.')
255
- }
256
- record.truncated = entry.truncated
257
- }
258
- }
259
- return record
260
- }
261
-
262
- /**
263
- * Split a byte window into complete JSONL records, dropping a partial first
264
- * line (the window started mid-record) and a partial last line (the record is
265
- * still being appended). Malformed complete lines are skipped rather than
266
- * aborting the whole read: a damaged log must not make the panel unusable.
267
- * @param {string} text
268
- * @param {boolean} windowStartsAtFileStart
269
- * @returns {AuditRecord[]}
270
- */
271
- function parseLines(text, windowStartsAtFileStart) {
272
- const lines = text.split('\n')
273
- // The window starts mid-record unless it begins at offset 0.
274
- if (!windowStartsAtFileStart) lines.shift()
275
- // The final element is either "" (the text ended with "\n") or an
276
- // unterminated fragment still being appended; neither is a whole record.
277
- lines.pop()
278
- /** @type {AuditRecord[]} */
279
- const records = []
280
- for (const line of lines) {
281
- if (line.trim() === '') continue
282
- try {
283
- const parsed = JSON.parse(line)
284
- if (isPlainObject(parsed)) records.push(/** @type {AuditRecord} */ (parsed))
285
- } catch {
286
- // Skip a truncated or hand-edited line; the rest of the log is still valid.
287
- }
288
- }
289
- return records
290
- }
291
-
292
- /**
293
- * Append-only audit log with bounded tail reads and optional single rotation.
294
- *
295
- * The log is disabled by `auditEnabled = false` (doc §10): the same methods
296
- * still work, but {@link AuditLog.append} writes nothing and reports
297
- * `enabled: false`, which is exactly what the caller must not treat as an
298
- * error — the command itself still runs.
299
- */
300
- export class AuditLog {
301
- /** @type {string} */
302
- #file
303
- /** @type {boolean} */
304
- #enabled
305
- /** @type {number} */
306
- #maxBytes
307
-
308
- /**
309
- * @param {{file?: string, enabled?: boolean, maxBytes?: number}} [options]
310
- * `file` defaults to `$DSH_HOME/server-connector/audit.log`; `enabled`
311
- * mirrors `auditEnabled` and `maxBytes` mirrors `auditMaxBytes` (0 = never
312
- * rotate).
313
- */
314
- constructor(options = {}) {
315
- this.#file = options.file ?? defaultAuditFile()
316
- this.#enabled = options.enabled ?? true
317
- const maxBytes = options.maxBytes ?? 0
318
- if (!(typeof maxBytes === 'number' && Number.isSafeInteger(maxBytes) && maxBytes >= 0)) {
319
- throw new AuditError('INVALID_AUDIT', 'maxBytes must be a non-negative integer (0 = never rotate).')
320
- }
321
- this.#maxBytes = maxBytes
322
- }
323
-
324
- /** Absolute path of the log this instance appends to. */
325
- get file() {
326
- return this.#file
327
- }
328
-
329
- /** Whether appends are recorded (`auditEnabled`). */
330
- get enabled() {
331
- return this.#enabled
332
- }
333
-
334
- /** Rotation threshold in bytes; 0 means the log is never rotated. */
335
- get maxBytes() {
336
- return this.#maxBytes
337
- }
338
-
339
- /**
340
- * Append one record. The call resolves only after the line reached the file.
341
- *
342
- * A block record is normalised to `approved: null, exitCode: null`; an event
343
- * record carries `event` instead of an exec decision. Records are serialised
344
- * per log file so two concurrent appends cannot interleave inside one line.
345
- * @param {AuditEntry} entry
346
- * @returns {Promise<{written: boolean, enabled: boolean, rotated: boolean}>}
347
- * @throws {AuditError} for a malformed record, before anything is written.
348
- */
349
- async append(entry) {
350
- const now = new Date()
351
- const record = buildRecord(entry, now)
352
- const line = serialize(record)
353
- if (!this.#enabled) return { written: false, enabled: false, rotated: false }
354
- return this.#queued(async () => {
355
- const rotated = await this.#rotateIfNeeded(Buffer.byteLength(line, 'utf8'))
356
- await mkdir(dirname(this.#file), { recursive: true, mode: 0o700 })
357
- await writeFile(this.#file, line, { encoding: 'utf8', flag: 'a', mode: 0o600 })
358
- return { written: true, enabled: true, rotated }
359
- })
360
- }
361
-
362
- /**
363
- * Read the last `limit` records, oldest first, without loading the file.
364
- *
365
- * The read starts from a growing window at the end of the file, so a 1 GB
366
- * log costs one bounded read for a 20-record tail. A missing file is an
367
- * empty log. Records are returned exactly as stored, newest last.
368
- * @param {number} [limit] - how many records to return; defaults to 20.
369
- * @returns {Promise<AuditRecord[]>}
370
- * @throws {AuditError} when `limit` is not a positive integer.
371
- */
372
- async tail(limit = 20) {
373
- if (!(typeof limit === 'number' && Number.isSafeInteger(limit) && limit > 0)) {
374
- throw new AuditError('INVALID_AUDIT', 'limit must be a positive integer.')
375
- }
376
- /** @type {import('node:fs/promises').FileHandle} */
377
- let handle
378
- try {
379
- handle = await open(this.#file, 'r')
380
- } catch (error) {
381
- if (isErrno(error, 'ENOENT')) return []
382
- throw error
383
- }
384
- try {
385
- const { size } = await handle.stat()
386
- if (size === 0) return []
387
- let window = Math.min(size, Math.max(INITIAL_TAIL_WINDOW, limit * 256))
388
- /** @type {AuditRecord[]} */
389
- let records = []
390
- for (;;) {
391
- const start = Math.max(0, size - window)
392
- const buffer = Buffer.alloc(size - start)
393
- // FileHandle.read may return short; keep reading until the window is full.
394
- let filled = 0
395
- while (filled < buffer.length) {
396
- const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, start + filled)
397
- if (bytesRead === 0) break
398
- filled += bytesRead
399
- }
400
- records = parseLines(buffer.subarray(0, filled).toString('utf8'), start === 0)
401
- if (records.length >= limit || start === 0) break
402
- window *= 4
403
- }
404
- return records.slice(-limit)
405
- } finally {
406
- await handle.close()
407
- }
408
- }
409
-
410
- /**
411
- * Rotate the log when the next append would pass `maxBytes` (doc §6.2).
412
- * Only one rotation is ever kept: the current file replaces `audit.log.1`.
413
- * @param {number} incomingBytes
414
- * @returns {Promise<boolean>} whether a rotation happened.
415
- */
416
- async #rotateIfNeeded(incomingBytes) {
417
- if (this.#maxBytes <= 0) return false
418
- let size
419
- try {
420
- size = (await stat(this.#file)).size
421
- } catch (error) {
422
- if (isErrno(error, 'ENOENT')) return false
423
- throw error
424
- }
425
- if (size === 0 || size + incomingBytes <= this.#maxBytes) return false
426
- const rotated = `${this.#file}${AUDIT_ROTATED_SUFFIX}`
427
- try {
428
- await rename(this.#file, rotated)
429
- } catch (error) {
430
- if (!isErrno(error, 'ENOENT')) throw error
431
- }
432
- return true
433
- }
434
-
435
- /**
436
- * Run an append after every earlier append to the same file finished.
437
- * @template T
438
- * @param {() => Promise<T>} operation
439
- * @returns {Promise<T>}
440
- */
441
- async #queued(operation) {
442
- const key = resolve(this.#file)
443
- const previous = APPEND_QUEUES.get(key) ?? Promise.resolve()
444
- const running = previous.then(operation)
445
- const settled = running.then(
446
- () => undefined,
447
- () => undefined,
448
- )
449
- APPEND_QUEUES.set(key, settled)
450
- void settled.then(() => {
451
- if (APPEND_QUEUES.get(key) === settled) APPEND_QUEUES.delete(key)
452
- })
453
- return running
454
- }
455
- }
1
+ // dsh-server-connector — execution audit log (design doc §3.4/§6.2).
2
+ //
3
+ // One JSON object per line, appended to `$DSH_HOME/server-connector/audit.log`.
4
+ // The module only appends and reads the tail: it never participates in a
5
+ // decision, never rewrites an earlier line, and never loads the whole file to
6
+ // answer a tail(limit) call.
7
+ //
8
+ // Record shapes (doc §3.4):
9
+ // exec: { ts, sessionId, serverId, serverName, command, decision,
10
+ // policyRule, approved, exitCode, durationMs, truncated }
11
+ // block: an exec record with decision="block", approved=null, exitCode=null
12
+ // event: { ts, event, serverId, serverName, detail? } for non-execution
13
+ // events such as hostkey-disabled / server-deleted / rule-disabled
14
+ //
15
+ // Rotation (doc §6.2): with `auditMaxBytes > 0`, the log is rotated once when
16
+ // the next append would exceed that size. The current file becomes
17
+ // `audit.log.1` and a fresh `audit.log` starts; a second threshold hit
18
+ // overwrites `audit.log.1`, so at most two files of bounded size exist.
19
+
20
+ import { mkdir, open, rename, stat, writeFile } from 'node:fs/promises'
21
+ import { dirname, join, resolve } from 'node:path'
22
+
23
+ import { connectorDirectory } from './registry.js'
24
+
25
+ /** File name of the audit log. */
26
+ export const AUDIT_FILE_NAME = 'audit.log'
27
+
28
+ /** Suffix of the single rotated file. */
29
+ export const AUDIT_ROTATED_SUFFIX = '.1'
30
+
31
+ /** Byte size of the read window when tail() has to grow to satisfy `limit`. */
32
+ const INITIAL_TAIL_WINDOW = 64 * 1024
33
+
34
+ /**
35
+ * One tail promise per log file: two AuditLog instances (tools and routes, say)
36
+ * must not interleave a line or race the rotation check.
37
+ * @type {Map<string, Promise<void>>}
38
+ */
39
+ const APPEND_QUEUES = new Map()
40
+
41
+ /**
42
+ * One audit record, exactly as persisted. Exec records, block records and
43
+ * event records share this wrapper; `event` is present only on events.
44
+ * @typedef {object} AuditRecord
45
+ * @property {string} ts - ISO-8601 timestamp.
46
+ * @property {string} [sessionId]
47
+ * @property {string} [serverId]
48
+ * @property {string} [serverName]
49
+ * @property {string} [command]
50
+ * @property {'allow'|'confirm'|'block'|'event'} [decision]
51
+ * @property {string|null} [policyRule]
52
+ * @property {boolean|null} [approved]
53
+ * @property {number|null} [exitCode]
54
+ * @property {number} [durationMs]
55
+ * @property {boolean} [truncated]
56
+ * @property {string} [event]
57
+ * @property {string} [detail]
58
+ */
59
+
60
+ /**
61
+ * Non-execution events the spec names (doc §3.4, §4.2.4, §9.6). `rule-disabled`
62
+ * is the event that must be written when a rule id leaves a server's
63
+ * `disabledRules` — i.e. when the rule is switched off (doc §4.2.4).
64
+ * `server-enabled` / `server-disabled` record the enable switch, which applies
65
+ * immediately rather than through the pending queue (enable-switch spec).
66
+ * @typedef {'hostkey-disabled'|'server-deleted'|'rule-disabled'|'server-enabled'|'server-disabled'} AuditEvent
67
+ */
68
+
69
+ /** Every accepted event name, in one place so the validator and the type agree. */
70
+ const AUDIT_EVENTS = Object.freeze([
71
+ 'hostkey-disabled',
72
+ 'server-deleted',
73
+ 'rule-disabled',
74
+ 'server-enabled',
75
+ 'server-disabled',
76
+ ])
77
+
78
+ /**
79
+ * What `append()` accepts. Every key except `ts` is optional; `ts` is stamped
80
+ * by the log so callers cannot backdate an entry.
81
+ * @typedef {object} AuditEntry
82
+ * @property {string} [sessionId]
83
+ * @property {string} [serverId]
84
+ * @property {string} [serverName]
85
+ * @property {string} [command]
86
+ * @property {'allow'|'confirm'|'block'} [decision]
87
+ * @property {string|null} [policyRule]
88
+ * @property {boolean|null} [approved]
89
+ * @property {number|null} [exitCode]
90
+ * @property {number} [durationMs]
91
+ * @property {boolean} [truncated]
92
+ * @property {AuditEvent} [event]
93
+ * @property {string} [detail]
94
+ */
95
+
96
+ /**
97
+ * Machine-readable audit failure. `INVALID_AUDIT` marks a malformed call (bad
98
+ * record, bad rotation limit); nothing is written when it is thrown.
99
+ * @typedef {'INVALID_AUDIT'} AuditErrorCode
100
+ */
101
+ export class AuditError extends Error {
102
+ /**
103
+ * @param {AuditErrorCode} code
104
+ * @param {string} message
105
+ */
106
+ constructor(code, message) {
107
+ super(message)
108
+ this.name = 'AuditError'
109
+ /** @type {AuditErrorCode} */
110
+ this.code = code
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Default location of `audit.log` (doc §3.5).
116
+ * @param {NodeJS.ProcessEnv} [env]
117
+ * @returns {string}
118
+ */
119
+ export function defaultAuditFile(env = process.env) {
120
+ return join(connectorDirectory(env), AUDIT_FILE_NAME)
121
+ }
122
+
123
+ /**
124
+ * @param {unknown} value
125
+ * @returns {value is Record<string, unknown>}
126
+ */
127
+ function isPlainObject(value) {
128
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
129
+ }
130
+
131
+ /**
132
+ * @param {unknown} error
133
+ * @param {string} code
134
+ * @returns {boolean}
135
+ */
136
+ function isErrno(error, code) {
137
+ return isPlainObject(error) && error.code === code
138
+ }
139
+
140
+ /**
141
+ * Sequence one record's keys in the order of doc §3.4 instead of insertion
142
+ * order, so the file stays readable and greppable in a stable shape.
143
+ * @param {AuditRecord} record
144
+ * @returns {string}
145
+ */
146
+ function serialize(record) {
147
+ /** @type {Record<string, unknown>} */
148
+ const ordered = { ts: record.ts }
149
+ for (const key of [
150
+ 'event',
151
+ 'detail',
152
+ 'sessionId',
153
+ 'serverId',
154
+ 'serverName',
155
+ 'command',
156
+ 'decision',
157
+ 'policyRule',
158
+ 'approved',
159
+ 'exitCode',
160
+ 'durationMs',
161
+ 'truncated',
162
+ ]) {
163
+ const value = /** @type {Record<string, unknown>} */ (record)[key]
164
+ if (value !== undefined) ordered[key] = value
165
+ }
166
+ return `${JSON.stringify(ordered)}\n`
167
+ }
168
+
169
+ /**
170
+ * Build a validated record from a caller's entry. Shorthands that the spec
171
+ * fixes are applied here so a block record cannot be written wrongly:
172
+ * a `decision: "block"` line always carries `approved: null` and
173
+ * `exitCode: null` (doc §3.4).
174
+ * @param {AuditEntry} entry
175
+ * @param {Date} now
176
+ * @returns {AuditRecord}
177
+ */
178
+ function buildRecord(entry, now) {
179
+ if (!isPlainObject(entry)) throw new AuditError('INVALID_AUDIT', 'audit entry must be an object.')
180
+ const isEvent = entry.event !== undefined
181
+ if (isEvent) {
182
+ if (!AUDIT_EVENTS.includes(/** @type {any} */ (entry.event))) {
183
+ throw new AuditError(
184
+ 'INVALID_AUDIT',
185
+ `event must be one of ${AUDIT_EVENTS.join(', ')}.`,
186
+ )
187
+ }
188
+ if (entry.decision !== undefined) {
189
+ throw new AuditError('INVALID_AUDIT', 'an event record carries no exec decision.')
190
+ }
191
+ } else if (entry.decision === undefined) {
192
+ // Never default to "allow": a forgotten decision would then be recorded as
193
+ // an allowed execution, which is exactly the mislabelling an audit log must
194
+ // not produce.
195
+ throw new AuditError('INVALID_AUDIT', 'decision must be allow, confirm or block (or provide event).')
196
+ } else if (entry.decision !== 'allow' && entry.decision !== 'confirm' && entry.decision !== 'block') {
197
+ throw new AuditError('INVALID_AUDIT', 'decision must be allow, confirm or block (or provide event).')
198
+ }
199
+
200
+ /** @type {AuditRecord} */
201
+ const record = { ts: now.toISOString() }
202
+ if (isEvent) {
203
+ record.event = /** @type {AuditEvent} */ (entry.event)
204
+ if (entry.detail !== undefined) {
205
+ if (typeof entry.detail !== 'string') throw new AuditError('INVALID_AUDIT', 'detail must be a string.')
206
+ record.detail = entry.detail
207
+ }
208
+ } else {
209
+ record.decision = /** @type {'allow'|'confirm'|'block'} */ (entry.decision)
210
+ }
211
+
212
+ for (const field of /** @type {const} */ (['sessionId', 'serverId', 'serverName', 'command'])) {
213
+ const value = entry[field]
214
+ if (value === undefined) continue
215
+ if (typeof value !== 'string') throw new AuditError('INVALID_AUDIT', `${field} must be a string.`)
216
+ record[field] = value
217
+ }
218
+
219
+ if (entry.policyRule !== undefined) {
220
+ if (entry.policyRule !== null && typeof entry.policyRule !== 'string') {
221
+ throw new AuditError('INVALID_AUDIT', 'policyRule must be a string or null.')
222
+ }
223
+ record.policyRule = entry.policyRule
224
+ }
225
+
226
+ const isBlock = !isEvent && record.decision === 'block'
227
+ if (isBlock) {
228
+ // Doc §3.4: a blocked command never ran, so approval and exit code are null
229
+ // no matter what a caller passed. Timing/output fields are dropped for the
230
+ // same reason: there is no execution to describe.
231
+ record.approved = null
232
+ record.exitCode = null
233
+ } else if (!isEvent) {
234
+ if (entry.approved !== undefined) {
235
+ if (entry.approved !== null && typeof entry.approved !== 'boolean') {
236
+ throw new AuditError('INVALID_AUDIT', 'approved must be a boolean or null.')
237
+ }
238
+ record.approved = entry.approved
239
+ }
240
+ if (entry.exitCode !== undefined) {
241
+ if (entry.exitCode !== null && !(typeof entry.exitCode === 'number' && Number.isInteger(entry.exitCode))) {
242
+ throw new AuditError('INVALID_AUDIT', 'exitCode must be an integer or null.')
243
+ }
244
+ record.exitCode = entry.exitCode
245
+ }
246
+ if (entry.durationMs !== undefined) {
247
+ if (typeof entry.durationMs !== 'number' || !Number.isFinite(entry.durationMs) || entry.durationMs < 0) {
248
+ throw new AuditError('INVALID_AUDIT', 'durationMs must be a non-negative number.')
249
+ }
250
+ record.durationMs = entry.durationMs
251
+ }
252
+ if (entry.truncated !== undefined) {
253
+ if (typeof entry.truncated !== 'boolean') {
254
+ throw new AuditError('INVALID_AUDIT', 'truncated must be a boolean.')
255
+ }
256
+ record.truncated = entry.truncated
257
+ }
258
+ }
259
+ return record
260
+ }
261
+
262
+ /**
263
+ * Split a byte window into complete JSONL records, dropping a partial first
264
+ * line (the window started mid-record) and a partial last line (the record is
265
+ * still being appended). Malformed complete lines are skipped rather than
266
+ * aborting the whole read: a damaged log must not make the panel unusable.
267
+ * @param {string} text
268
+ * @param {boolean} windowStartsAtFileStart
269
+ * @returns {AuditRecord[]}
270
+ */
271
+ function parseLines(text, windowStartsAtFileStart) {
272
+ const lines = text.split('\n')
273
+ // The window starts mid-record unless it begins at offset 0.
274
+ if (!windowStartsAtFileStart) lines.shift()
275
+ // The final element is either "" (the text ended with "\n") or an
276
+ // unterminated fragment still being appended; neither is a whole record.
277
+ lines.pop()
278
+ /** @type {AuditRecord[]} */
279
+ const records = []
280
+ for (const line of lines) {
281
+ if (line.trim() === '') continue
282
+ try {
283
+ const parsed = JSON.parse(line)
284
+ if (isPlainObject(parsed)) records.push(/** @type {AuditRecord} */ (parsed))
285
+ } catch {
286
+ // Skip a truncated or hand-edited line; the rest of the log is still valid.
287
+ }
288
+ }
289
+ return records
290
+ }
291
+
292
+ /**
293
+ * Append-only audit log with bounded tail reads and optional single rotation.
294
+ *
295
+ * The log is disabled by `auditEnabled = false` (doc §10): the same methods
296
+ * still work, but {@link AuditLog.append} writes nothing and reports
297
+ * `enabled: false`, which is exactly what the caller must not treat as an
298
+ * error — the command itself still runs.
299
+ */
300
+ export class AuditLog {
301
+ /** @type {string} */
302
+ #file
303
+ /** @type {boolean} */
304
+ #enabled
305
+ /** @type {number} */
306
+ #maxBytes
307
+
308
+ /**
309
+ * @param {{file?: string, enabled?: boolean, maxBytes?: number}} [options]
310
+ * `file` defaults to `$DSH_HOME/server-connector/audit.log`; `enabled`
311
+ * mirrors `auditEnabled` and `maxBytes` mirrors `auditMaxBytes` (0 = never
312
+ * rotate).
313
+ */
314
+ constructor(options = {}) {
315
+ this.#file = options.file ?? defaultAuditFile()
316
+ this.#enabled = options.enabled ?? true
317
+ const maxBytes = options.maxBytes ?? 0
318
+ if (!(typeof maxBytes === 'number' && Number.isSafeInteger(maxBytes) && maxBytes >= 0)) {
319
+ throw new AuditError('INVALID_AUDIT', 'maxBytes must be a non-negative integer (0 = never rotate).')
320
+ }
321
+ this.#maxBytes = maxBytes
322
+ }
323
+
324
+ /** Absolute path of the log this instance appends to. */
325
+ get file() {
326
+ return this.#file
327
+ }
328
+
329
+ /** Whether appends are recorded (`auditEnabled`). */
330
+ get enabled() {
331
+ return this.#enabled
332
+ }
333
+
334
+ /** Rotation threshold in bytes; 0 means the log is never rotated. */
335
+ get maxBytes() {
336
+ return this.#maxBytes
337
+ }
338
+
339
+ /**
340
+ * Apply an edited global configuration (doc §10, live settings).
341
+ *
342
+ * The audit log is a plain file handle plus two flags, so an edit lands in
343
+ * place: turning auditing off must stop *appending* without losing the file,
344
+ * and the rotation threshold is read on the next append anyway.
345
+ * @param {{enabled?: boolean, maxBytes?: number}} values
346
+ * @returns {void}
347
+ */
348
+ configure(values) {
349
+ if (typeof values?.enabled === 'boolean') this.#enabled = values.enabled
350
+ if (typeof values?.maxBytes === 'number') this.#maxBytes = values.maxBytes
351
+ }
352
+
353
+ /**
354
+ * Append one record. The call resolves only after the line reached the file.
355
+ *
356
+ * A block record is normalised to `approved: null, exitCode: null`; an event
357
+ * record carries `event` instead of an exec decision. Records are serialised
358
+ * per log file so two concurrent appends cannot interleave inside one line.
359
+ * @param {AuditEntry} entry
360
+ * @returns {Promise<{written: boolean, enabled: boolean, rotated: boolean}>}
361
+ * @throws {AuditError} for a malformed record, before anything is written.
362
+ */
363
+ async append(entry) {
364
+ const now = new Date()
365
+ const record = buildRecord(entry, now)
366
+ const line = serialize(record)
367
+ if (!this.#enabled) return { written: false, enabled: false, rotated: false }
368
+ return this.#queued(async () => {
369
+ const rotated = await this.#rotateIfNeeded(Buffer.byteLength(line, 'utf8'))
370
+ await mkdir(dirname(this.#file), { recursive: true, mode: 0o700 })
371
+ await writeFile(this.#file, line, { encoding: 'utf8', flag: 'a', mode: 0o600 })
372
+ return { written: true, enabled: true, rotated }
373
+ })
374
+ }
375
+
376
+ /**
377
+ * Read the last `limit` records, oldest first, without loading the file.
378
+ *
379
+ * The read starts from a growing window at the end of the file, so a 1 GB
380
+ * log costs one bounded read for a 20-record tail. A missing file is an
381
+ * empty log. Records are returned exactly as stored, newest last.
382
+ * @param {number} [limit] - how many records to return; defaults to 20.
383
+ * @returns {Promise<AuditRecord[]>}
384
+ * @throws {AuditError} when `limit` is not a positive integer.
385
+ */
386
+ async tail(limit = 20) {
387
+ if (!(typeof limit === 'number' && Number.isSafeInteger(limit) && limit > 0)) {
388
+ throw new AuditError('INVALID_AUDIT', 'limit must be a positive integer.')
389
+ }
390
+ /** @type {import('node:fs/promises').FileHandle} */
391
+ let handle
392
+ try {
393
+ handle = await open(this.#file, 'r')
394
+ } catch (error) {
395
+ if (isErrno(error, 'ENOENT')) return []
396
+ throw error
397
+ }
398
+ try {
399
+ const { size } = await handle.stat()
400
+ if (size === 0) return []
401
+ let window = Math.min(size, Math.max(INITIAL_TAIL_WINDOW, limit * 256))
402
+ /** @type {AuditRecord[]} */
403
+ let records = []
404
+ for (;;) {
405
+ const start = Math.max(0, size - window)
406
+ const buffer = Buffer.alloc(size - start)
407
+ // FileHandle.read may return short; keep reading until the window is full.
408
+ let filled = 0
409
+ while (filled < buffer.length) {
410
+ const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, start + filled)
411
+ if (bytesRead === 0) break
412
+ filled += bytesRead
413
+ }
414
+ records = parseLines(buffer.subarray(0, filled).toString('utf8'), start === 0)
415
+ if (records.length >= limit || start === 0) break
416
+ window *= 4
417
+ }
418
+ return records.slice(-limit)
419
+ } finally {
420
+ await handle.close()
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Rotate the log when the next append would pass `maxBytes` (doc §6.2).
426
+ * Only one rotation is ever kept: the current file replaces `audit.log.1`.
427
+ * @param {number} incomingBytes
428
+ * @returns {Promise<boolean>} whether a rotation happened.
429
+ */
430
+ async #rotateIfNeeded(incomingBytes) {
431
+ if (this.#maxBytes <= 0) return false
432
+ let size
433
+ try {
434
+ size = (await stat(this.#file)).size
435
+ } catch (error) {
436
+ if (isErrno(error, 'ENOENT')) return false
437
+ throw error
438
+ }
439
+ if (size === 0 || size + incomingBytes <= this.#maxBytes) return false
440
+ const rotated = `${this.#file}${AUDIT_ROTATED_SUFFIX}`
441
+ try {
442
+ await rename(this.#file, rotated)
443
+ } catch (error) {
444
+ if (!isErrno(error, 'ENOENT')) throw error
445
+ }
446
+ return true
447
+ }
448
+
449
+ /**
450
+ * Run an append after every earlier append to the same file finished.
451
+ * @template T
452
+ * @param {() => Promise<T>} operation
453
+ * @returns {Promise<T>}
454
+ */
455
+ async #queued(operation) {
456
+ const key = resolve(this.#file)
457
+ const previous = APPEND_QUEUES.get(key) ?? Promise.resolve()
458
+ const running = previous.then(operation)
459
+ const settled = running.then(
460
+ () => undefined,
461
+ () => undefined,
462
+ )
463
+ APPEND_QUEUES.set(key, settled)
464
+ void settled.then(() => {
465
+ if (APPEND_QUEUES.get(key) === settled) APPEND_QUEUES.delete(key)
466
+ })
467
+ return running
468
+ }
469
+ }