@goodandready/dsh-approval-gate 0.1.15 → 0.1.17
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 +154 -138
- package/lib/audit-log.js +277 -8
- package/lib/classifier.js +392 -0
- package/lib/counters.js +144 -0
- package/lib/engine.js +464 -289
- package/lib/index.js +557 -480
- package/lib/messages.js +312 -282
- package/package.json +55 -55
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// lib/classifier.js
|
|
2
|
+
// LLM Semantic Classifier resilience, URL normalization, fault tolerance,
|
|
3
|
+
// secret sanitization, context truncation, and timeout policies (#127, #129, #118, #115, #117).
|
|
4
|
+
|
|
5
|
+
import { redactCredentials, redactUrl } from "./redact.js";
|
|
6
|
+
import { CircuitBreaker } from "./breaker.js";
|
|
7
|
+
|
|
8
|
+
// Additional regex patterns for sensitive tokens & API keys (#115)
|
|
9
|
+
const OPENAI_KEY_PATTERN = /\bsk-[a-zA-Z0-9_\-]{20,}\b/g;
|
|
10
|
+
const ANTHROPIC_KEY_PATTERN = /\bsk-ant-[a-zA-Z0-9_\-]{20,}\b/g;
|
|
11
|
+
const NPM_TOKEN_PATTERN = /\bnpm_[a-zA-Z0-9]{36}\b/g;
|
|
12
|
+
const BEARER_HEADER_PATTERN = /\bBearer\s+[a-zA-Z0-9_\-\.]{15,}\b/gi;
|
|
13
|
+
const INLINE_CREDENTIAL_PATTERN = /(?:-p|--password|--passwd|--token|--api[-_]?key)\s*(?:=|\s+)[^\s'"]+/gi;
|
|
14
|
+
const SENSITIVE_KEY_NAMES = /^(?:token|key|api[-_]?key|secret|password|passwd|auth|authorization|private[-_]?key|client[-_]?secret)$/i;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Normalizes user-provided LLM endpoint URLs (#127).
|
|
18
|
+
* Handles bare host/port, duplicate slashes, trailing slashes, and appends /chat/completions when missing.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} inputUrl
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeChatCompletionsUrl(inputUrl) {
|
|
24
|
+
if (typeof inputUrl !== "string" || inputUrl.trim() === "") {
|
|
25
|
+
throw new TypeError("Classifier endpoint URL must be a non-empty string");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let raw = inputUrl.trim();
|
|
29
|
+
if (!/^https?:\/\//i.test(raw)) {
|
|
30
|
+
raw = "http://" + raw;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Remove duplicate slashes after protocol
|
|
34
|
+
raw = raw.replace(/([^:]\/)\/+/g, "$1");
|
|
35
|
+
|
|
36
|
+
const parsed = new URL(raw);
|
|
37
|
+
let pathname = parsed.pathname.replace(/\/+$/, ""); // remove trailing slashes
|
|
38
|
+
pathname = pathname.replace(/\/{2,}/g, "/");
|
|
39
|
+
|
|
40
|
+
if (pathname.endsWith("/chat/completions")) {
|
|
41
|
+
// Already has standard completions endpoint
|
|
42
|
+
parsed.pathname = pathname;
|
|
43
|
+
} else if (pathname.endsWith("/v1")) {
|
|
44
|
+
parsed.pathname = pathname + "/chat/completions";
|
|
45
|
+
} else if (pathname === "" || pathname === "/") {
|
|
46
|
+
parsed.pathname = "/chat/completions";
|
|
47
|
+
} else {
|
|
48
|
+
parsed.pathname = pathname + "/chat/completions";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Ensure clean pathname without trailing slash
|
|
52
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
|
|
53
|
+
return parsed.toString();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Sanitizes arguments and command strings to prevent credential leaks to external LLM APIs (#115).
|
|
58
|
+
* Masks tokens, passwords, private keys, auth headers, and sensitive object keys recursively.
|
|
59
|
+
*
|
|
60
|
+
* @param {any} input
|
|
61
|
+
* @returns {any}
|
|
62
|
+
*/
|
|
63
|
+
export function sanitizeClassifierInput(input) {
|
|
64
|
+
if (typeof input === "string") {
|
|
65
|
+
let sanitized = redactCredentials(input);
|
|
66
|
+
sanitized = redactUrl(sanitized);
|
|
67
|
+
sanitized = sanitized.replace(OPENAI_KEY_PATTERN, "***");
|
|
68
|
+
sanitized = sanitized.replace(ANTHROPIC_KEY_PATTERN, "***");
|
|
69
|
+
sanitized = sanitized.replace(NPM_TOKEN_PATTERN, "***");
|
|
70
|
+
sanitized = sanitized.replace(BEARER_HEADER_PATTERN, "Bearer ***");
|
|
71
|
+
sanitized = sanitized.replace(INLINE_CREDENTIAL_PATTERN, "***");
|
|
72
|
+
return sanitized;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (Array.isArray(input)) {
|
|
76
|
+
return input.map((item) => sanitizeClassifierInput(item));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (input !== null && typeof input === "object") {
|
|
80
|
+
const sanitizedObj = {};
|
|
81
|
+
for (const [key, val] of Object.entries(input)) {
|
|
82
|
+
if (SENSITIVE_KEY_NAMES.test(key)) {
|
|
83
|
+
sanitizedObj[key] = "***";
|
|
84
|
+
} else {
|
|
85
|
+
sanitizedObj[key] = sanitizeClassifierInput(val);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return sanitizedObj;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return input;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Truncates classifier payload context to minimize token consumption (#117).
|
|
96
|
+
*
|
|
97
|
+
* @param {any} input - String or object payload
|
|
98
|
+
* @param {number} [maxChars=1000] - Character cap
|
|
99
|
+
* @returns {string} Truncated string representation with metadata marker
|
|
100
|
+
*/
|
|
101
|
+
export function truncateClassifierContext(input, maxChars = 1000) {
|
|
102
|
+
const cap = typeof maxChars === "number" && maxChars > 0 ? maxChars : 1000;
|
|
103
|
+
|
|
104
|
+
if (typeof input === "string") {
|
|
105
|
+
if (input.length <= cap) {
|
|
106
|
+
return input;
|
|
107
|
+
}
|
|
108
|
+
return `${input.slice(0, cap)}\n[...truncated, total: ${input.length} chars]`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let jsonStr;
|
|
112
|
+
try {
|
|
113
|
+
jsonStr = JSON.stringify(input, null, 2);
|
|
114
|
+
} catch {
|
|
115
|
+
jsonStr = String(input);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (jsonStr.length <= cap) {
|
|
119
|
+
return jsonStr;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Attempt smart field truncation if object has large fields
|
|
123
|
+
if (input !== null && typeof input === "object" && !Array.isArray(input)) {
|
|
124
|
+
const copy = { ...input };
|
|
125
|
+
const fieldBudget = Math.max(100, Math.floor(cap / 2));
|
|
126
|
+
for (const key of ["command", "content", "diff", "text", "script", "patch", "data"]) {
|
|
127
|
+
if (typeof copy[key] === "string" && copy[key].length > fieldBudget) {
|
|
128
|
+
copy[key] = `${copy[key].slice(0, fieldBudget)}\n[...truncated field, total: ${copy[key].length} chars]`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const reducedJson = JSON.stringify(copy, null, 2);
|
|
133
|
+
if (reducedJson.length <= cap) {
|
|
134
|
+
return reducedJson;
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// fallback to hard truncation below
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return `${jsonStr.slice(0, cap)}\n[...truncated, total: ${jsonStr.length} chars]`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Default error inspector for retrying LLM HTTP requests (#129).
|
|
146
|
+
* Retries on network faults, timeouts, 429 rate limits, and 5xx server errors.
|
|
147
|
+
* Never retries on 400, 401, 403, 404, 422 client errors.
|
|
148
|
+
*
|
|
149
|
+
* @param {any} err
|
|
150
|
+
* @returns {boolean}
|
|
151
|
+
*/
|
|
152
|
+
export function shouldRetryLlmError(err) {
|
|
153
|
+
if (!err) return false;
|
|
154
|
+
if (CircuitBreaker.isFaultOrTimeout(err)) return true;
|
|
155
|
+
|
|
156
|
+
const status = err.status || (err.response && err.response.status);
|
|
157
|
+
if (typeof status === "number") {
|
|
158
|
+
if (status === 429) return true;
|
|
159
|
+
if (status >= 500) return true;
|
|
160
|
+
if (status >= 400 && status < 500) return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const code = err.code;
|
|
164
|
+
if (code && ["ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "UND_ERR_CONNECT_TIMEOUT"].includes(code)) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (err.name === "AbortError" || err.isTimeout) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Executes an async LLM operation with automatic retry and exponential backoff (#129).
|
|
177
|
+
*
|
|
178
|
+
* @template T
|
|
179
|
+
* @param {(attempt: number) => Promise<T>} fn
|
|
180
|
+
* @param {{ maxRetries?: number, retryDelayMs?: number, backoffFactor?: number, retryOn?: (err: any) => boolean }} [options]
|
|
181
|
+
* @returns {Promise<T>}
|
|
182
|
+
*/
|
|
183
|
+
export async function withLlmRetry(fn, options = {}) {
|
|
184
|
+
const maxRetries = typeof options.maxRetries === "number" ? options.maxRetries : 2;
|
|
185
|
+
const initialDelay = typeof options.retryDelayMs === "number" ? options.retryDelayMs : 200;
|
|
186
|
+
const backoffFactor = typeof options.backoffFactor === "number" ? options.backoffFactor : 2;
|
|
187
|
+
const checkRetry = options.retryOn || shouldRetryLlmError;
|
|
188
|
+
|
|
189
|
+
let attempt = 0;
|
|
190
|
+
let delay = initialDelay;
|
|
191
|
+
|
|
192
|
+
while (true) {
|
|
193
|
+
try {
|
|
194
|
+
return await fn(attempt);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
attempt++;
|
|
197
|
+
if (attempt > maxRetries || !checkRetry(err)) {
|
|
198
|
+
if (err && typeof err === "object") {
|
|
199
|
+
err.attempts = attempt;
|
|
200
|
+
}
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
204
|
+
delay *= backoffFactor;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Checks if an execution represents a low-risk, read-only operation (#118).
|
|
211
|
+
*
|
|
212
|
+
* @param {{ name?: string, arguments?: any }} execution
|
|
213
|
+
* @returns {boolean}
|
|
214
|
+
*/
|
|
215
|
+
export function isLowRiskExecution(execution) {
|
|
216
|
+
if (!execution) return false;
|
|
217
|
+
const toolName = execution.name || "";
|
|
218
|
+
const command = execution.arguments && execution.arguments.command;
|
|
219
|
+
|
|
220
|
+
// File viewing / inspection tools
|
|
221
|
+
if (["read_file", "view_file", "list_dir", "glob", "search_files", "grep"].includes(toolName)) {
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (typeof command === "string") {
|
|
226
|
+
const trimmed = command.trim();
|
|
227
|
+
if (!trimmed) return true;
|
|
228
|
+
|
|
229
|
+
// Reject any dangerous or mutating commands
|
|
230
|
+
if (/\b(?:rm|mv|cp|chmod|chown|sudo|dd|mkfs|curl|wget|nc|bash|sh|zsh|kill|pkill)\b/i.test(trimmed)) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
// Reject shell redirections or pipes to execution
|
|
234
|
+
if (/[><|;&]/.test(trimmed)) {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
// Safe commands
|
|
238
|
+
if (/^(?:git\s+(?:status|log|diff|branch|show)|ls|pwd|cat|echo|head|tail|wc|whoami|uname)\b/.test(trimmed)) {
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Standalone resilient LLM Classifier client (#127, #129, #118, #115, #117).
|
|
248
|
+
*/
|
|
249
|
+
export class LlmClassifier {
|
|
250
|
+
/**
|
|
251
|
+
* @param {{
|
|
252
|
+
* endpoint?: string,
|
|
253
|
+
* apiKey?: string,
|
|
254
|
+
* model?: string,
|
|
255
|
+
* timeoutMs?: number,
|
|
256
|
+
* timeoutPolicy?: "strict" | "permissive",
|
|
257
|
+
* maxRetries?: number,
|
|
258
|
+
* maxContextChars?: number,
|
|
259
|
+
* fetch?: typeof globalThis.fetch
|
|
260
|
+
* }} [options]
|
|
261
|
+
*/
|
|
262
|
+
constructor(options = {}) {
|
|
263
|
+
this.endpoint = options.endpoint ? normalizeChatCompletionsUrl(options.endpoint) : null;
|
|
264
|
+
this.apiKey = options.apiKey || "";
|
|
265
|
+
this.model = options.model || "gpt-4o-mini";
|
|
266
|
+
this.timeoutMs = typeof options.timeoutMs === "number" ? options.timeoutMs : 3000;
|
|
267
|
+
this.timeoutPolicy = options.timeoutPolicy === "permissive" ? "permissive" : "strict";
|
|
268
|
+
this.maxRetries = typeof options.maxRetries === "number" ? options.maxRetries : 2;
|
|
269
|
+
this.maxContextChars = typeof options.maxContextChars === "number" ? options.maxContextChars : 1000;
|
|
270
|
+
this.fetch = options.fetch || globalThis.fetch;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Builds prompt messages with sanitized credentials and bounded context size (#115, #117).
|
|
275
|
+
*
|
|
276
|
+
* @param {any} execution
|
|
277
|
+
* @param {any} [context]
|
|
278
|
+
* @returns {Array<{ role: string, content: string }>}
|
|
279
|
+
*/
|
|
280
|
+
formatMessages(execution, context) {
|
|
281
|
+
const sanitized = sanitizeClassifierInput(execution);
|
|
282
|
+
const truncatedPayload = truncateClassifierContext(sanitized, this.maxContextChars);
|
|
283
|
+
|
|
284
|
+
return [
|
|
285
|
+
{
|
|
286
|
+
role: "system",
|
|
287
|
+
content:
|
|
288
|
+
"You are a security approval gate classifier. Analyze the provided tool execution request. " +
|
|
289
|
+
"Respond strictly in valid JSON format: {\"verdict\": \"allow\"|\"deny\"|\"ask\", \"reason\": \"<short explanation>\"}.",
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
role: "user",
|
|
293
|
+
content: `Evaluate execution:\n${truncatedPayload}`,
|
|
294
|
+
},
|
|
295
|
+
];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Classifies an execution request using the configured LLM endpoint (#118, #127, #129, #115, #117).
|
|
300
|
+
*
|
|
301
|
+
* @param {any} execution
|
|
302
|
+
* @param {any} [context]
|
|
303
|
+
* @returns {Promise<{ verdict: "allow" | "deny" | "ask", reason: string, timeout?: boolean, error?: any }>}
|
|
304
|
+
*/
|
|
305
|
+
async classify(execution, context = {}) {
|
|
306
|
+
if (!this.endpoint || typeof this.fetch !== "function") {
|
|
307
|
+
return { verdict: "ask", reason: "classifier-not-configured" };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const messages = this.formatMessages(execution, context);
|
|
311
|
+
const body = JSON.stringify({
|
|
312
|
+
model: this.model,
|
|
313
|
+
messages,
|
|
314
|
+
temperature: 0.1,
|
|
315
|
+
response_format: { type: "json_object" },
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
const response = await withLlmRetry(
|
|
320
|
+
async () => {
|
|
321
|
+
const controller = new AbortController();
|
|
322
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
323
|
+
try {
|
|
324
|
+
const res = await this.fetch(this.endpoint, {
|
|
325
|
+
method: "POST",
|
|
326
|
+
headers: {
|
|
327
|
+
"Content-Type": "application/json",
|
|
328
|
+
...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
|
|
329
|
+
},
|
|
330
|
+
body,
|
|
331
|
+
signal: controller.signal,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
if (!res.ok) {
|
|
335
|
+
const err = new Error(`LLM endpoint HTTP ${res.status}: ${res.statusText}`);
|
|
336
|
+
err.status = res.status;
|
|
337
|
+
throw err;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return await res.json();
|
|
341
|
+
} catch (err) {
|
|
342
|
+
if (err.name === "AbortError") {
|
|
343
|
+
const timeoutErr = new Error(`LLM classifier request timed out after ${this.timeoutMs}ms`);
|
|
344
|
+
timeoutErr.isTimeout = true;
|
|
345
|
+
throw timeoutErr;
|
|
346
|
+
}
|
|
347
|
+
throw err;
|
|
348
|
+
} finally {
|
|
349
|
+
clearTimeout(timer);
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
maxRetries: this.maxRetries,
|
|
354
|
+
retryOn: shouldRetryLlmError,
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
const content = response?.choices?.[0]?.message?.content;
|
|
359
|
+
if (!content) {
|
|
360
|
+
return { verdict: "ask", reason: "classifier-empty-response" };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
let parsed;
|
|
364
|
+
try {
|
|
365
|
+
parsed = JSON.parse(content);
|
|
366
|
+
} catch {
|
|
367
|
+
return { verdict: "ask", reason: "classifier-invalid-json" };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const verdict = ["allow", "deny", "ask"].includes(parsed.verdict) ? parsed.verdict : "ask";
|
|
371
|
+
return {
|
|
372
|
+
verdict,
|
|
373
|
+
reason: parsed.reason || `classifier-${verdict}`,
|
|
374
|
+
};
|
|
375
|
+
} catch (err) {
|
|
376
|
+
const isTimeout = CircuitBreaker.isFaultOrTimeout(err) || Boolean(err?.isTimeout);
|
|
377
|
+
if (isTimeout && this.timeoutPolicy === "permissive") {
|
|
378
|
+
const isLow = isLowRiskExecution(execution);
|
|
379
|
+
if (isLow) {
|
|
380
|
+
return { verdict: "allow", reason: "classifier-timeout-fallback-permissive", timeout: true };
|
|
381
|
+
}
|
|
382
|
+
return { verdict: "ask", reason: "classifier-timeout-fallback-strict", timeout: true };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (isTimeout) {
|
|
386
|
+
return { verdict: "ask", reason: "classifier-timeout-strict", timeout: true, error: err };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return { verdict: "ask", reason: "classifier-error", error: err };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
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
|
+
}
|