@goodandready/dsh-approval-gate 0.1.15 → 0.1.16

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.16
4
+
5
+ - Audit: Отказоустойчивый аудит с блокировкой (`fail-closed audit`) при невозможности записи в append-only журнал (Refs: #53).
6
+ - Audit: Однопроходная обратная ротация лога аудита без амплификации ввода-вывода (`auditRotateContent` до 5 МБ / 5000 строк) с сохранением хвоста (Refs: #54).
7
+ - Security: Нестираемые метки очистки журнала аудита (`tombstones`) вместо тихого удаления с сохранением sha256 и непрерывной цепочки событий (Refs: #55).
8
+ - Metrics: Счетчики перехватов (`BlockCounters`) и метрики частоты срабатывания правил (по стадиям, вердиктам, категориям и правилам) со snapshot в настройках (Refs: #48).
9
+ - Security: Блокировка таймаута на безусловный отказ (`lockedAsk`) для критических операций (sudo, rm -rf, ssh-keys, mkfs, reverse shell) с запретом обхода в unattended режиме (Refs: #52).
10
+
3
11
  ## 0.1.15
4
12
 
5
13
  - Rules: Каскадный декларативный YAML-конфиг правил `rules.yaml` (`.dsh/rules.yaml` в проекте и `~/.dsh/rules.yaml` пользователя) с поддержкой allow/deny/ask политик (Refs: #38).
package/lib/audit-log.js CHANGED
@@ -1,7 +1,10 @@
1
1
  // lib/audit-log.js
2
- // Isolated append-only audit logger for dsh-approval-gate (#56).
2
+ // Isolated append-only audit logger for dsh-approval-gate (#56, #53, #54, #55).
3
3
  // Logs gate decisions to ~/.dsh/approval-gate-audit.jsonl out-of-band to prevent prompt injection.
4
+ // Features: fail-closed write verification (#53), single-pass reverse rotation up to 5MB (#54),
5
+ // and non-erasable tombstones for tamper resistance (#55).
4
6
 
7
+ import crypto from "node:crypto";
5
8
  import fs from "node:fs";
6
9
  import os from "node:os";
7
10
  import path from "node:path";
@@ -9,13 +12,89 @@ import { computeFingerprint } from "./fingerprint.js";
9
12
  import { redactCredentials } from "./redact.js";
10
13
 
11
14
  export const DEFAULT_AUDIT_LOG_RELATIVE = path.join(".dsh", "approval-gate-audit.jsonl");
15
+ export const DEFAULT_MAX_AUDIT_BYTES = 5 * 1024 * 1024; // 5 MB (#54)
16
+ export const DEFAULT_MAX_AUDIT_LINES = 5000;
17
+ export const DEFAULT_RETAIN_LINES = 2500;
18
+ export const SCAN_CHUNK_SIZE = 64 * 1024; // 64 KB single-pass chunk
19
+
20
+ /**
21
+ * Pure function: single-pass reverse scan rotation for string or buffer content (#54).
22
+ * Scans from the tail backwards to avoid write amplification and full array allocation.
23
+ *
24
+ * @param {string} content
25
+ * @param {number} [maxBytes=DEFAULT_MAX_AUDIT_BYTES]
26
+ * @param {number} [maxLines=DEFAULT_MAX_AUDIT_LINES]
27
+ * @param {number} [retainLines=DEFAULT_RETAIN_LINES]
28
+ * @returns {{ rotated: boolean, content: string, previousBytes: number, previousLinesCount: number, retainedLinesCount: number }}
29
+ */
30
+ export function auditRotateContent(
31
+ content,
32
+ maxBytes = DEFAULT_MAX_AUDIT_BYTES,
33
+ maxLines = DEFAULT_MAX_AUDIT_LINES,
34
+ retainLines = DEFAULT_RETAIN_LINES
35
+ ) {
36
+ if (typeof content !== "string" || content.length === 0) {
37
+ return { rotated: false, content: "", previousBytes: 0, previousLinesCount: 0, retainedLinesCount: 0 };
38
+ }
39
+
40
+ const byteLength = Buffer.byteLength(content, "utf8");
41
+ const allLines = content.split("\n");
42
+ const nonEmptyLines = allLines[allLines.length - 1] === "" ? allLines.slice(0, -1) : allLines;
43
+ const lineCount = nonEmptyLines.length;
44
+
45
+ if (byteLength < maxBytes && lineCount < maxLines) {
46
+ return { rotated: false, content, previousBytes: byteLength, previousLinesCount: lineCount, retainedLinesCount: lineCount };
47
+ }
48
+
49
+ let end = content.length;
50
+ if (end > 0 && content.charCodeAt(end - 1) === 10) {
51
+ end--; // skip trailing newline for line scanning
52
+ }
53
+
54
+ let targetCutoff = 0;
55
+ let linesSeen = 0;
56
+ for (let i = end - 1; i >= 0; i--) {
57
+ if (content.charCodeAt(i) === 10) {
58
+ linesSeen++;
59
+ if (linesSeen === retainLines) {
60
+ targetCutoff = i + 1;
61
+ break;
62
+ }
63
+ }
64
+ }
65
+ if (linesSeen < retainLines) {
66
+ targetCutoff = 0;
67
+ }
68
+
69
+ const retained = content.slice(targetCutoff);
70
+ return {
71
+ rotated: true,
72
+ content: retained,
73
+ previousBytes: byteLength,
74
+ previousLinesCount: lineCount,
75
+ retainedLinesCount: Math.min(lineCount, retainLines),
76
+ };
77
+ }
12
78
 
13
79
  export class AuditLogger {
14
80
  /**
15
- * @param {{ logPath?: string, disabled?: boolean }} [options]
81
+ * @param {{
82
+ * logPath?: string,
83
+ * disabled?: boolean,
84
+ * failClosed?: boolean,
85
+ * maxBytes?: number,
86
+ * maxLines?: number,
87
+ * retainLines?: number
88
+ * }} [options]
16
89
  */
17
90
  constructor(options = {}) {
18
91
  this.disabled = options.disabled === true;
92
+ this.failClosed = options.failClosed === true;
93
+ this.maxBytes = typeof options.maxBytes === "number" && options.maxBytes > 0 ? options.maxBytes : DEFAULT_MAX_AUDIT_BYTES;
94
+ this.maxLines = typeof options.maxLines === "number" && options.maxLines > 0 ? options.maxLines : DEFAULT_MAX_AUDIT_LINES;
95
+ this.retainLines = typeof options.retainLines === "number" && options.retainLines > 0 ? options.retainLines : DEFAULT_RETAIN_LINES;
96
+ this.lastError = null;
97
+
19
98
  if (options.logPath) {
20
99
  this.logPath = options.logPath;
21
100
  } else {
@@ -25,17 +104,18 @@ export class AuditLogger {
25
104
  }
26
105
 
27
106
  /**
28
- * Write an audit record to the isolated log file.
107
+ * Write an audit record to the isolated log file. Returns boolean (#53).
29
108
  *
30
109
  * @param {{
31
110
  * sessionId?: string,
32
111
  * execution?: any,
33
- * decision: { verdict: string, stage?: string, reason?: string, detail?: string, dryRun?: boolean },
112
+ * decision: { verdict: string, stage?: string, reason?: string, detail?: string, dryRun?: boolean, ruleId?: string },
34
113
  * dryRun?: boolean
35
114
  * }} record
115
+ * @returns {boolean} True if append succeeded or disabled, false if write failed
36
116
  */
37
- log(record) {
38
- if (this.disabled || !this.logPath) return;
117
+ appendAuditLine(record) {
118
+ if (this.disabled || !this.logPath) return true;
39
119
 
40
120
  try {
41
121
  const now = Date.now();
@@ -57,6 +137,7 @@ export class AuditLogger {
57
137
  verdict: decision.verdict,
58
138
  stage: decision.stage,
59
139
  reason: decision.reason,
140
+ ruleId: decision.ruleId,
60
141
  detail: decision.detail,
61
142
  fingerprint: fp,
62
143
  dryRun: Boolean(record.dryRun || decision.dryRun),
@@ -67,10 +148,198 @@ export class AuditLogger {
67
148
  if (!fs.existsSync(dir)) {
68
149
  fs.mkdirSync(dir, { recursive: true });
69
150
  }
151
+
70
152
  fs.appendFileSync(this.logPath, line, "utf8");
153
+ this.lastError = null;
154
+
155
+ // Check if rotation is needed (#54)
156
+ this._checkRotation();
157
+ return true;
158
+ } catch (err) {
159
+ this.lastError = err;
160
+ if (this.failClosed) {
161
+ return false;
162
+ }
163
+ return false;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Alias for backward compatibility.
169
+ * @param {any} record
170
+ * @returns {boolean}
171
+ */
172
+ log(record) {
173
+ return this.appendAuditLine(record);
174
+ }
175
+
176
+ /**
177
+ * Single-pass reverse scan rotation (#54).
178
+ * @private
179
+ */
180
+ _checkRotation() {
181
+ if (!this.logPath || !fs.existsSync(this.logPath)) return;
182
+ try {
183
+ const stat = fs.statSync(this.logPath);
184
+ if (stat.size >= this.maxBytes) {
185
+ this.rotate();
186
+ }
187
+ } catch {
188
+ // Rotation check failure must not crash logger
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Explicitly rotate audit log using single-pass reverse scan and tombstone (#54, #55).
194
+ * @returns {boolean}
195
+ */
196
+ rotate() {
197
+ if (!this.logPath || !fs.existsSync(this.logPath)) return false;
198
+
199
+ try {
200
+ const raw = fs.readFileSync(this.logPath, "utf8");
201
+ const prevSha256 = crypto.createHash("sha256").update(raw, "utf8").digest("hex");
202
+ // Force rotation to retainLines
203
+ const rot = auditRotateContent(raw, 0, 0, this.retainLines);
204
+
205
+ const now = Date.now();
206
+ const tombstone = {
207
+ tombstone: true,
208
+ timestamp: now,
209
+ iso: new Date(now).toISOString(),
210
+ reason: "rotation",
211
+ clearedBy: "system:rotator",
212
+ previousSha256: prevSha256,
213
+ previousBytes: rot.previousBytes,
214
+ previousLinesCount: rot.previousLinesCount,
215
+ retainedLinesCount: rot.retainedLinesCount,
216
+ };
217
+
218
+ const tombstoneLine = JSON.stringify(tombstone) + "\n";
219
+ const newContent = tombstoneLine + rot.content;
220
+
221
+ const tmpPath = this.logPath + ".tmp." + now;
222
+ fs.writeFileSync(tmpPath, newContent, "utf8");
223
+ fs.renameSync(tmpPath, this.logPath);
224
+ return true;
225
+ } catch (err) {
226
+ this.lastError = err;
227
+ return false;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Clear the audit log with a non-erasable tombstone (#55).
233
+ * Instead of silent deletion, creates an immutable tombstone record preserving hash and event chain.
234
+ *
235
+ * @param {{ reason?: string, clearedBy?: string }} [options]
236
+ * @returns {{
237
+ * tombstone: boolean,
238
+ * timestamp: number,
239
+ * iso: string,
240
+ * reason: string,
241
+ * clearedBy: string,
242
+ * previousSha256: string,
243
+ * previousBytes: number,
244
+ * previousLinesCount: number
245
+ * }}
246
+ */
247
+ clear(options = {}) {
248
+ const reason = options.reason || "manual_clear";
249
+ const clearedBy = options.clearedBy || "admin";
250
+ let prevSha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
251
+ let prevBytes = 0;
252
+ let prevLines = 0;
253
+
254
+ if (this.logPath && fs.existsSync(this.logPath)) {
255
+ try {
256
+ const raw = fs.readFileSync(this.logPath, "utf8");
257
+ prevBytes = Buffer.byteLength(raw, "utf8");
258
+ prevSha256 = crypto.createHash("sha256").update(raw, "utf8").digest("hex");
259
+ prevLines = raw.split("\n").filter((l) => l.trim().length > 0).length;
260
+ } catch {
261
+ // Read error
262
+ }
263
+ }
264
+
265
+ const now = Date.now();
266
+ const tombstone = {
267
+ tombstone: true,
268
+ timestamp: now,
269
+ iso: new Date(now).toISOString(),
270
+ reason,
271
+ clearedBy,
272
+ previousSha256: prevSha256,
273
+ previousBytes: prevBytes,
274
+ previousLinesCount: prevLines,
275
+ };
276
+
277
+ if (this.logPath) {
278
+ const dir = path.dirname(this.logPath);
279
+ if (!fs.existsSync(dir)) {
280
+ fs.mkdirSync(dir, { recursive: true });
281
+ }
282
+ const line = JSON.stringify(tombstone) + "\n";
283
+ fs.writeFileSync(this.logPath, line, "utf8");
284
+ }
285
+
286
+ return tombstone;
287
+ }
288
+
289
+ /**
290
+ * Read all tombstones from the log file (#55).
291
+ * @returns {Array<any>}
292
+ */
293
+ getTombstones() {
294
+ if (!this.logPath || !fs.existsSync(this.logPath)) return [];
295
+ try {
296
+ const raw = fs.readFileSync(this.logPath, "utf8");
297
+ const lines = raw.split("\n").filter((l) => l.trim().length > 0);
298
+ const tombstones = [];
299
+ for (const line of lines) {
300
+ try {
301
+ const parsed = JSON.parse(line);
302
+ if (parsed && parsed.tombstone === true) {
303
+ tombstones.push(parsed);
304
+ }
305
+ } catch {
306
+ // ignore corrupted lines
307
+ }
308
+ }
309
+ return tombstones;
310
+ } catch {
311
+ return [];
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Read recent entries from the audit log.
317
+ * @param {{ includeTombstones?: boolean, limit?: number }} [options]
318
+ * @returns {Array<any>}
319
+ */
320
+ readEntries(options = {}) {
321
+ if (!this.logPath || !fs.existsSync(this.logPath)) return [];
322
+ const includeTombstones = options.includeTombstones !== false;
323
+ const limit = typeof options.limit === "number" && options.limit > 0 ? options.limit : 100;
324
+
325
+ try {
326
+ const raw = fs.readFileSync(this.logPath, "utf8");
327
+ const lines = raw.split("\n").filter((l) => l.trim().length > 0);
328
+ const entries = [];
329
+ for (let i = lines.length - 1; i >= 0 && entries.length < limit; i--) {
330
+ try {
331
+ const parsed = JSON.parse(lines[i]);
332
+ if (!includeTombstones && parsed.tombstone === true) {
333
+ continue;
334
+ }
335
+ entries.push(parsed);
336
+ } catch {
337
+ // ignore
338
+ }
339
+ }
340
+ return entries.reverse();
71
341
  } catch {
72
- // Security audit failures fail open to prevent DoS,
73
- // but never leak errors into agent prompt.
342
+ return [];
74
343
  }
75
344
  }
76
345
  }
@@ -0,0 +1,144 @@
1
+ // lib/counters.js
2
+ // In-memory block counters and rule hit frequency metrics for dsh-approval-gate (#48).
3
+ // Tracks total evaluated decisions, verdicts, pipeline stages, top rules, and risk categories.
4
+
5
+ /**
6
+ * Categorize a decision reason into a high-level risk category.
7
+ * @param {string} [reason]
8
+ * @returns {string}
9
+ */
10
+ export function categorizeReason(reason) {
11
+ const r = String(reason || "").toLowerCase();
12
+ if (r.includes("band") || r.includes("hard-deny") || r.includes("rm") || r.includes("destructive") || r.includes("delete") || r.includes("drop") || r.includes("truncate") || r.includes("wipe") || r.includes("clean")) {
13
+ return "destructive";
14
+ }
15
+ if (r.includes("privilege") || r.includes("sudo") || r.includes("systemctl") || r.includes("reboot") || r.includes("service") || r.includes("kill") || r.includes("chmod") || r.includes("chown")) {
16
+ return "system";
17
+ }
18
+ if (r.includes("network") || r.includes("egress") || r.includes("curl") || r.includes("wget") || r.includes("ssh") || r.includes("socket") || r.includes("connect")) {
19
+ return "network";
20
+ }
21
+ if (r.includes("secret") || r.includes("env") || r.includes("token") || r.includes("key") || r.includes("credential") || r.includes("password")) {
22
+ return "secrets";
23
+ }
24
+ if (r.includes("persist") || r.includes("crontab") || r.includes("profile") || r.includes("bashrc") || r.includes("startup")) {
25
+ return "persistence";
26
+ }
27
+ if (r.includes("syntax") || r.includes("parser") || r.includes("subshell") || r.includes("uncertain")) {
28
+ return "syntax";
29
+ }
30
+ return "other";
31
+ }
32
+
33
+ export class BlockCounters {
34
+ constructor() {
35
+ this.reset();
36
+ }
37
+
38
+ /**
39
+ * Reset all counters to zero.
40
+ */
41
+ reset() {
42
+ this.totalEvaluated = 0;
43
+ this.byVerdict = {
44
+ allow: 0,
45
+ deny: 0,
46
+ ask: 0,
47
+ };
48
+ this.byStage = {
49
+ P0: 0,
50
+ P1: 0,
51
+ P2: 0,
52
+ P3: 0,
53
+ P4: 0,
54
+ };
55
+ this.byCategory = {
56
+ destructive: 0,
57
+ system: 0,
58
+ network: 0,
59
+ secrets: 0,
60
+ persistence: 0,
61
+ syntax: 0,
62
+ other: 0,
63
+ };
64
+ /** @type {Map<string, { hits: number, lastHitAt: number, lastVerdict: string, category: string }>} */
65
+ this.byRule = new Map();
66
+ }
67
+
68
+ /**
69
+ * Record a decision event.
70
+ * @param {{ verdict: string, stage?: string, reason?: string, ruleId?: string }} decision
71
+ * @param {any} [execution]
72
+ */
73
+ record(decision, execution) {
74
+ if (!decision || typeof decision !== "object") return;
75
+
76
+ this.totalEvaluated++;
77
+
78
+ const v = decision.verdict;
79
+ if (v === "allow" || v === "deny" || v === "ask") {
80
+ this.byVerdict[v] = (this.byVerdict[v] || 0) + 1;
81
+ }
82
+
83
+ const st = decision.stage;
84
+ if (st && this.byStage[st] !== undefined) {
85
+ this.byStage[st] = (this.byStage[st] || 0) + 1;
86
+ }
87
+
88
+ const ruleKey = String(decision.ruleId || decision.reason || "unspecified").trim();
89
+ const category = categorizeReason(decision.reason);
90
+
91
+ this.byCategory[category] = (this.byCategory[category] || 0) + 1;
92
+
93
+ const existing = this.byRule.get(ruleKey);
94
+ const now = Date.now();
95
+ if (existing) {
96
+ existing.hits++;
97
+ existing.lastHitAt = now;
98
+ existing.lastVerdict = decision.verdict;
99
+ existing.category = category;
100
+ } else {
101
+ this.byRule.set(ruleKey, {
102
+ hits: 1,
103
+ lastHitAt: now,
104
+ lastVerdict: decision.verdict,
105
+ category,
106
+ });
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Get stats for a specific rule.
112
+ * @param {string} ruleId
113
+ * @returns {{ rule: string, hits: number, lastHitAt: number, lastVerdict: string, category: string } | null}
114
+ */
115
+ getRuleStats(ruleId) {
116
+ const s = this.byRule.get(String(ruleId || "").trim());
117
+ if (!s) return null;
118
+ return { rule: ruleId, ...s };
119
+ }
120
+
121
+ /**
122
+ * Get complete snapshot of all counters for settings UI / API.
123
+ * @returns {{
124
+ * total: number,
125
+ * byVerdict: { allow: number, deny: number, ask: number },
126
+ * byStage: { P0: number, P1: number, P2: number, P3: number, P4: number },
127
+ * byCategory: Record<string, number>,
128
+ * rules: Array<{ rule: string, hits: number, lastHitAt: number, lastVerdict: string, category: string }>
129
+ * }}
130
+ */
131
+ getSnapshot() {
132
+ const rulesList = Array.from(this.byRule.entries())
133
+ .map(([rule, data]) => ({ rule, ...data }))
134
+ .sort((a, b) => b.hits - a.hits);
135
+
136
+ return {
137
+ total: this.totalEvaluated,
138
+ byVerdict: { ...this.byVerdict },
139
+ byStage: { ...this.byStage },
140
+ byCategory: { ...this.byCategory },
141
+ rules: rulesList,
142
+ };
143
+ }
144
+ }