@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 +8 -0
- package/lib/audit-log.js +277 -8
- package/lib/counters.js +144 -0
- package/lib/engine.js +134 -16
- package/lib/index.js +521 -480
- package/lib/messages.js +294 -282
- package/package.json +1 -1
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 {{
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
// but never leak errors into agent prompt.
|
|
342
|
+
return [];
|
|
74
343
|
}
|
|
75
344
|
}
|
|
76
345
|
}
|
package/lib/counters.js
ADDED
|
@@ -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
|
+
}
|