guardian-risk 0.2.0 → 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/README.md +93 -4
- package/dist/index.cjs +459 -18
- package/dist/index.d.cts +157 -6
- package/dist/index.d.ts +157 -6
- package/dist/index.js +450 -19
- package/package.json +21 -4
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var crypto = require('crypto');
|
|
4
|
+
var net = require('net');
|
|
4
5
|
|
|
5
6
|
// src/constants/defaults.ts
|
|
6
7
|
var DEFAULT_RISK_LEVELS = [
|
|
@@ -10,6 +11,44 @@ var DEFAULT_RISK_LEVELS = [
|
|
|
10
11
|
{ max: Infinity, level: "CRITICAL" }
|
|
11
12
|
];
|
|
12
13
|
|
|
14
|
+
// src/constants/security.ts
|
|
15
|
+
var MAX_RULES = 1e3;
|
|
16
|
+
var MAX_SIGNALS = 1e3;
|
|
17
|
+
var MAX_KEY_LENGTH = 256;
|
|
18
|
+
var MAX_RULE_SCORE = 1e4;
|
|
19
|
+
var MAX_TOTAL_SCORE = 1e6;
|
|
20
|
+
var MAX_SIGNAL_STRING_LENGTH = 4096;
|
|
21
|
+
var MAX_SESSION_ID_LENGTH = 128;
|
|
22
|
+
var SESSION_ID_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
23
|
+
var HOOK_TIMEOUT_MS = 1e4;
|
|
24
|
+
var BLOCKED_SIGNAL_KEYS = /* @__PURE__ */ new Set([
|
|
25
|
+
"__proto__",
|
|
26
|
+
"constructor",
|
|
27
|
+
"prototype"
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
// src/hooks/runHooks.ts
|
|
31
|
+
async function runHooks(hooks, context, timeoutMs = HOOK_TIMEOUT_MS) {
|
|
32
|
+
for (const hook of hooks) {
|
|
33
|
+
await runWithTimeout(() => hook(context), timeoutMs);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function runWithTimeout(operation, timeoutMs) {
|
|
37
|
+
let timer;
|
|
38
|
+
const timeout = new Promise((_, reject) => {
|
|
39
|
+
timer = setTimeout(() => {
|
|
40
|
+
reject(new Error(`Guardian analyze hook timed out after ${timeoutMs}ms`));
|
|
41
|
+
}, timeoutMs);
|
|
42
|
+
});
|
|
43
|
+
try {
|
|
44
|
+
await Promise.race([Promise.resolve(operation()), timeout]);
|
|
45
|
+
} finally {
|
|
46
|
+
if (timer !== void 0) {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
13
52
|
// src/utils/resolveLevel.ts
|
|
14
53
|
function resolveLevel(score, thresholds = DEFAULT_RISK_LEVELS) {
|
|
15
54
|
for (const threshold of thresholds) {
|
|
@@ -29,11 +68,14 @@ var ReportBuilder = class {
|
|
|
29
68
|
build(score, matchedRules, thresholds, analyzedAt = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
30
69
|
const reasons = matchedRules.map((rule) => rule.reason);
|
|
31
70
|
const level = resolveLevel(score, thresholds);
|
|
71
|
+
const frozenRules = matchedRules.map(
|
|
72
|
+
(rule) => Object.freeze({ ...rule })
|
|
73
|
+
);
|
|
32
74
|
const report = {
|
|
33
75
|
score,
|
|
34
76
|
level,
|
|
35
77
|
reasons: Object.freeze([...reasons]),
|
|
36
|
-
matchedRules: Object.freeze(
|
|
78
|
+
matchedRules: Object.freeze(frozenRules),
|
|
37
79
|
analyzedAt
|
|
38
80
|
};
|
|
39
81
|
return Object.freeze(report);
|
|
@@ -49,12 +91,19 @@ var RuleEvaluator = class {
|
|
|
49
91
|
evaluate(rules, signals) {
|
|
50
92
|
const matched = [];
|
|
51
93
|
for (const rule of rules) {
|
|
52
|
-
|
|
94
|
+
let isMatched = false;
|
|
95
|
+
try {
|
|
96
|
+
isMatched = rule.when(signals);
|
|
97
|
+
} catch {
|
|
98
|
+
isMatched = false;
|
|
99
|
+
}
|
|
100
|
+
if (isMatched) {
|
|
53
101
|
matched.push({
|
|
54
102
|
id: rule.id,
|
|
55
103
|
name: rule.name,
|
|
56
104
|
score: rule.score,
|
|
57
|
-
reason: rule.reason ?? rule.name
|
|
105
|
+
reason: rule.reason ?? rule.name,
|
|
106
|
+
...rule.group !== void 0 ? { group: rule.group } : {}
|
|
58
107
|
});
|
|
59
108
|
}
|
|
60
109
|
}
|
|
@@ -65,10 +114,34 @@ var RuleEvaluator = class {
|
|
|
65
114
|
// src/score/ScoreCalculator.ts
|
|
66
115
|
var ScoreCalculator = class {
|
|
67
116
|
/**
|
|
68
|
-
* Sum scores from
|
|
117
|
+
* Sum scores from matched rules, applying group caps when configured.
|
|
69
118
|
*/
|
|
70
|
-
calculate(matchedRules) {
|
|
71
|
-
|
|
119
|
+
calculate(matchedRules, groupCaps = []) {
|
|
120
|
+
const caps = new Map(groupCaps.map((cap) => [cap.name, cap.maxScore]));
|
|
121
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
122
|
+
let ungroupedTotal = 0;
|
|
123
|
+
for (const rule of matchedRules) {
|
|
124
|
+
if (rule.group !== void 0) {
|
|
125
|
+
grouped.set(rule.group, (grouped.get(rule.group) ?? 0) + rule.score);
|
|
126
|
+
} else {
|
|
127
|
+
ungroupedTotal += rule.score;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
let total = ungroupedTotal;
|
|
131
|
+
for (const [groupName, groupScore] of grouped) {
|
|
132
|
+
const cap = caps.get(groupName);
|
|
133
|
+
total += cap !== void 0 ? Math.min(groupScore, cap) : groupScore;
|
|
134
|
+
}
|
|
135
|
+
if (!Number.isFinite(total)) {
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
if (total > MAX_TOTAL_SCORE) {
|
|
139
|
+
return MAX_TOTAL_SCORE;
|
|
140
|
+
}
|
|
141
|
+
if (total < -MAX_TOTAL_SCORE) {
|
|
142
|
+
return -MAX_TOTAL_SCORE;
|
|
143
|
+
}
|
|
144
|
+
return total;
|
|
72
145
|
}
|
|
73
146
|
};
|
|
74
147
|
function validateSignalValue(value) {
|
|
@@ -76,7 +149,105 @@ function validateSignalValue(value) {
|
|
|
76
149
|
return true;
|
|
77
150
|
}
|
|
78
151
|
const type = typeof value;
|
|
79
|
-
|
|
152
|
+
if (type === "string") {
|
|
153
|
+
return value.length <= MAX_SIGNAL_STRING_LENGTH;
|
|
154
|
+
}
|
|
155
|
+
if (type === "number") {
|
|
156
|
+
return Number.isFinite(value);
|
|
157
|
+
}
|
|
158
|
+
return type === "boolean";
|
|
159
|
+
}
|
|
160
|
+
function validateSignalKey(key) {
|
|
161
|
+
if (typeof key !== "string" || key.length === 0) {
|
|
162
|
+
throw new TypeError("Signal key must be a non-empty string");
|
|
163
|
+
}
|
|
164
|
+
if (key.length > MAX_KEY_LENGTH) {
|
|
165
|
+
throw new TypeError(`Signal key exceeds maximum length of ${MAX_KEY_LENGTH}`);
|
|
166
|
+
}
|
|
167
|
+
if (BLOCKED_SIGNAL_KEYS.has(key)) {
|
|
168
|
+
throw new TypeError(`Signal key "${key}" is not allowed`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function validateRuleInput(input) {
|
|
172
|
+
if (typeof input.name !== "string" || input.name.trim().length === 0) {
|
|
173
|
+
throw new TypeError("Rule name must be a non-empty string");
|
|
174
|
+
}
|
|
175
|
+
if (input.name.length > MAX_KEY_LENGTH) {
|
|
176
|
+
throw new TypeError(`Rule name exceeds maximum length of ${MAX_KEY_LENGTH}`);
|
|
177
|
+
}
|
|
178
|
+
if (typeof input.when !== "function") {
|
|
179
|
+
throw new TypeError("Rule when must be a function");
|
|
180
|
+
}
|
|
181
|
+
if (typeof input.score !== "number" || !Number.isFinite(input.score)) {
|
|
182
|
+
throw new TypeError("Rule score must be a finite number");
|
|
183
|
+
}
|
|
184
|
+
if (Math.abs(input.score) > MAX_RULE_SCORE) {
|
|
185
|
+
throw new TypeError(`Rule score must be between -${MAX_RULE_SCORE} and ${MAX_RULE_SCORE}`);
|
|
186
|
+
}
|
|
187
|
+
if (input.reason !== void 0) {
|
|
188
|
+
if (typeof input.reason !== "string") {
|
|
189
|
+
throw new TypeError("Rule reason must be a string");
|
|
190
|
+
}
|
|
191
|
+
if (input.reason.length > MAX_KEY_LENGTH) {
|
|
192
|
+
throw new TypeError(`Rule reason exceeds maximum length of ${MAX_KEY_LENGTH}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (input.description !== void 0) {
|
|
196
|
+
if (typeof input.description !== "string") {
|
|
197
|
+
throw new TypeError("Rule description must be a string");
|
|
198
|
+
}
|
|
199
|
+
if (input.description.length > MAX_KEY_LENGTH) {
|
|
200
|
+
throw new TypeError(`Rule description exceeds maximum length of ${MAX_KEY_LENGTH}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (input.group !== void 0) {
|
|
204
|
+
if (typeof input.group !== "string" || input.group.trim().length === 0) {
|
|
205
|
+
throw new TypeError("Rule group must be a non-empty string");
|
|
206
|
+
}
|
|
207
|
+
if (input.group.length > MAX_KEY_LENGTH) {
|
|
208
|
+
throw new TypeError(`Rule group exceeds maximum length of ${MAX_KEY_LENGTH}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function validatePlugin(plugin) {
|
|
213
|
+
if (typeof plugin.name !== "string" || plugin.name.trim().length === 0) {
|
|
214
|
+
throw new TypeError("Plugin name must be a non-empty string");
|
|
215
|
+
}
|
|
216
|
+
if (plugin.name.length > MAX_KEY_LENGTH) {
|
|
217
|
+
throw new TypeError(`Plugin name exceeds maximum length of ${MAX_KEY_LENGTH}`);
|
|
218
|
+
}
|
|
219
|
+
if (typeof plugin.install !== "function") {
|
|
220
|
+
throw new TypeError("Plugin install must be a function");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function validateRiskLevels(levels) {
|
|
224
|
+
if (levels.length === 0) {
|
|
225
|
+
throw new TypeError("Risk levels must contain at least one threshold");
|
|
226
|
+
}
|
|
227
|
+
for (const threshold of levels) {
|
|
228
|
+
if (typeof threshold.level !== "string" || threshold.level.trim().length === 0) {
|
|
229
|
+
throw new TypeError("Risk level label must be a non-empty string");
|
|
230
|
+
}
|
|
231
|
+
if (typeof threshold.max !== "number" || !Number.isFinite(threshold.max) && threshold.max !== Infinity) {
|
|
232
|
+
throw new TypeError("Risk level max must be a finite number or Infinity");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function validateRuleGroupInput(input) {
|
|
237
|
+
if (typeof input.name !== "string" || input.name.trim().length === 0) {
|
|
238
|
+
throw new TypeError("Rule group name must be a non-empty string");
|
|
239
|
+
}
|
|
240
|
+
if (input.name.length > MAX_KEY_LENGTH) {
|
|
241
|
+
throw new TypeError(`Rule group name exceeds maximum length of ${MAX_KEY_LENGTH}`);
|
|
242
|
+
}
|
|
243
|
+
if (input.maxScore !== void 0) {
|
|
244
|
+
if (typeof input.maxScore !== "number" || !Number.isFinite(input.maxScore) || input.maxScore < 0) {
|
|
245
|
+
throw new TypeError("Rule group maxScore must be a non-negative finite number");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (!Array.isArray(input.rules) || input.rules.length === 0) {
|
|
249
|
+
throw new TypeError("Rule group must contain at least one rule");
|
|
250
|
+
}
|
|
80
251
|
}
|
|
81
252
|
function generateId() {
|
|
82
253
|
return crypto.randomUUID();
|
|
@@ -89,11 +260,15 @@ var SignalStore = class {
|
|
|
89
260
|
* Set a signal value. Overwrites any existing value for the key.
|
|
90
261
|
*/
|
|
91
262
|
set(key, value) {
|
|
263
|
+
validateSignalKey(key);
|
|
92
264
|
if (!validateSignalValue(value)) {
|
|
93
265
|
throw new TypeError(
|
|
94
266
|
`Invalid signal value for "${key}": signals must be string, number, boolean, or null`
|
|
95
267
|
);
|
|
96
268
|
}
|
|
269
|
+
if (!this.signals.has(key) && this.signals.size >= MAX_SIGNALS) {
|
|
270
|
+
throw new RangeError(`Cannot exceed maximum of ${MAX_SIGNALS} signals`);
|
|
271
|
+
}
|
|
97
272
|
this.signals.set(key, value);
|
|
98
273
|
return this;
|
|
99
274
|
}
|
|
@@ -113,7 +288,7 @@ var SignalStore = class {
|
|
|
113
288
|
* Returns a frozen snapshot of all signals.
|
|
114
289
|
*/
|
|
115
290
|
getAll() {
|
|
116
|
-
const snapshot =
|
|
291
|
+
const snapshot = /* @__PURE__ */ Object.create(null);
|
|
117
292
|
for (const [key, value] of this.signals) {
|
|
118
293
|
snapshot[key] = value;
|
|
119
294
|
}
|
|
@@ -135,11 +310,15 @@ var RiskEngine = class {
|
|
|
135
310
|
}
|
|
136
311
|
deps;
|
|
137
312
|
rules = [];
|
|
313
|
+
groupCaps = [];
|
|
138
314
|
thresholds;
|
|
139
315
|
/**
|
|
140
316
|
* Register a rule for evaluation.
|
|
141
317
|
*/
|
|
142
318
|
addRule(rule) {
|
|
319
|
+
if (this.rules.length >= MAX_RULES) {
|
|
320
|
+
throw new RangeError(`Cannot exceed maximum of ${MAX_RULES} rules`);
|
|
321
|
+
}
|
|
143
322
|
this.rules.push(rule);
|
|
144
323
|
}
|
|
145
324
|
/**
|
|
@@ -148,13 +327,30 @@ var RiskEngine = class {
|
|
|
148
327
|
getRules() {
|
|
149
328
|
return this.rules;
|
|
150
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* Get configured per-group score caps.
|
|
332
|
+
*/
|
|
333
|
+
getGroupCaps() {
|
|
334
|
+
return this.groupCaps;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Cap the combined score of matched rules in a group.
|
|
338
|
+
*/
|
|
339
|
+
setGroupCap(name, maxScore) {
|
|
340
|
+
const existing = this.groupCaps.findIndex((cap) => cap.name === name);
|
|
341
|
+
if (existing >= 0) {
|
|
342
|
+
this.groupCaps[existing] = { name, maxScore };
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
this.groupCaps.push({ name, maxScore });
|
|
346
|
+
}
|
|
151
347
|
/**
|
|
152
348
|
* Run the full risk analysis pipeline.
|
|
153
349
|
*/
|
|
154
350
|
analyze() {
|
|
155
351
|
const signals = this.deps.signalStore.getAll();
|
|
156
352
|
const matchedRules = this.deps.ruleEvaluator.evaluate(this.rules, signals);
|
|
157
|
-
const score = this.deps.scoreCalculator.calculate(matchedRules);
|
|
353
|
+
const score = this.deps.scoreCalculator.calculate(matchedRules, this.groupCaps);
|
|
158
354
|
return this.deps.reportBuilder.build(score, matchedRules, this.thresholds);
|
|
159
355
|
}
|
|
160
356
|
};
|
|
@@ -165,19 +361,28 @@ var RuleBuilder = class {
|
|
|
165
361
|
* Create a new rule from input configuration.
|
|
166
362
|
*/
|
|
167
363
|
static create(input) {
|
|
364
|
+
validateRuleInput(input);
|
|
168
365
|
const rule = {
|
|
169
366
|
id: generateId(),
|
|
170
367
|
name: input.name,
|
|
171
368
|
score: input.score,
|
|
172
369
|
when: input.when,
|
|
173
370
|
...input.description !== void 0 ? { description: input.description } : {},
|
|
174
|
-
...input.reason !== void 0 ? { reason: input.reason } : {}
|
|
371
|
+
...input.reason !== void 0 ? { reason: input.reason } : {},
|
|
372
|
+
...input.group !== void 0 ? { group: input.group } : {}
|
|
175
373
|
};
|
|
176
374
|
return rule;
|
|
177
375
|
}
|
|
178
376
|
};
|
|
179
377
|
|
|
180
378
|
// src/plugins/PluginRegistry.ts
|
|
379
|
+
var PluginInstallError = class extends Error {
|
|
380
|
+
constructor(pluginName, cause) {
|
|
381
|
+
const message = cause instanceof Error ? cause.message : "Unknown plugin install error";
|
|
382
|
+
super(`Plugin "${pluginName}" failed to install: ${message}`);
|
|
383
|
+
this.name = "PluginInstallError";
|
|
384
|
+
}
|
|
385
|
+
};
|
|
181
386
|
var PluginAlreadyInstalledError = class extends Error {
|
|
182
387
|
constructor(pluginName) {
|
|
183
388
|
super(`Plugin "${pluginName}" is already installed`);
|
|
@@ -191,10 +396,15 @@ var PluginRegistry = class {
|
|
|
191
396
|
* Each plugin name may only be installed once per Guardian instance.
|
|
192
397
|
*/
|
|
193
398
|
install(plugin, guardian) {
|
|
399
|
+
validatePlugin(plugin);
|
|
194
400
|
if (this.installed.has(plugin.name)) {
|
|
195
401
|
throw new PluginAlreadyInstalledError(plugin.name);
|
|
196
402
|
}
|
|
197
|
-
|
|
403
|
+
try {
|
|
404
|
+
plugin.install(guardian);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
throw new PluginInstallError(plugin.name, error);
|
|
407
|
+
}
|
|
198
408
|
this.installed.add(plugin.name);
|
|
199
409
|
}
|
|
200
410
|
/**
|
|
@@ -203,6 +413,15 @@ var PluginRegistry = class {
|
|
|
203
413
|
has(name) {
|
|
204
414
|
return this.installed.has(name);
|
|
205
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* Copy installed plugin names from a template (used by Guardian.fork).
|
|
418
|
+
* Does not call install() — rules and hooks are copied separately.
|
|
419
|
+
*/
|
|
420
|
+
adoptInstalled(names) {
|
|
421
|
+
for (const name of names) {
|
|
422
|
+
this.installed.add(name);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
206
425
|
/**
|
|
207
426
|
* Get names of all installed plugins in registration order.
|
|
208
427
|
*/
|
|
@@ -212,12 +431,20 @@ var PluginRegistry = class {
|
|
|
212
431
|
};
|
|
213
432
|
|
|
214
433
|
// src/engine/Guardian.ts
|
|
215
|
-
var Guardian = class {
|
|
434
|
+
var Guardian = class _Guardian {
|
|
216
435
|
signalStore;
|
|
217
436
|
riskEngine;
|
|
218
437
|
pluginRegistry = new PluginRegistry();
|
|
438
|
+
plugins = [];
|
|
439
|
+
beforeHooks = [];
|
|
440
|
+
afterHooks = [];
|
|
441
|
+
thresholds;
|
|
442
|
+
analyzing = false;
|
|
219
443
|
constructor(config = {}) {
|
|
220
|
-
|
|
444
|
+
this.thresholds = config.levels !== void 0 ? [...config.levels] : DEFAULT_RISK_LEVELS;
|
|
445
|
+
if (config.levels !== void 0) {
|
|
446
|
+
validateRiskLevels(config.levels);
|
|
447
|
+
}
|
|
221
448
|
this.signalStore = new SignalStore();
|
|
222
449
|
const deps = {
|
|
223
450
|
signalStore: this.signalStore,
|
|
@@ -225,7 +452,7 @@ var Guardian = class {
|
|
|
225
452
|
scoreCalculator: new ScoreCalculator(),
|
|
226
453
|
reportBuilder: new ReportBuilder()
|
|
227
454
|
};
|
|
228
|
-
this.riskEngine = new RiskEngine(deps, thresholds);
|
|
455
|
+
this.riskEngine = new RiskEngine(deps, this.thresholds);
|
|
229
456
|
}
|
|
230
457
|
/**
|
|
231
458
|
* Add a signal value for risk evaluation.
|
|
@@ -234,21 +461,60 @@ var Guardian = class {
|
|
|
234
461
|
this.signalStore.set(key, value);
|
|
235
462
|
return this;
|
|
236
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* Read a signal value without modifying state.
|
|
466
|
+
*/
|
|
467
|
+
getSignal(key) {
|
|
468
|
+
return this.signalStore.get(key);
|
|
469
|
+
}
|
|
237
470
|
/**
|
|
238
471
|
* Register a rule. ID is auto-generated.
|
|
239
472
|
*/
|
|
240
473
|
rule(input) {
|
|
474
|
+
this.assertNotAnalyzing("register rules");
|
|
241
475
|
const rule = RuleBuilder.create(input);
|
|
242
476
|
this.riskEngine.addRule(rule);
|
|
243
477
|
return this;
|
|
244
478
|
}
|
|
479
|
+
/**
|
|
480
|
+
* Register a named group of rules with an optional combined score cap.
|
|
481
|
+
*/
|
|
482
|
+
ruleGroup(input) {
|
|
483
|
+
this.assertNotAnalyzing("register rule groups");
|
|
484
|
+
validateRuleGroupInput(input);
|
|
485
|
+
for (const ruleInput of input.rules) {
|
|
486
|
+
this.rule({ ...ruleInput, group: input.name });
|
|
487
|
+
}
|
|
488
|
+
if (input.maxScore !== void 0) {
|
|
489
|
+
this.riskEngine.setGroupCap(input.name, input.maxScore);
|
|
490
|
+
}
|
|
491
|
+
return this;
|
|
492
|
+
}
|
|
245
493
|
/**
|
|
246
494
|
* Install a plugin. Each plugin name may only be registered once.
|
|
247
495
|
*/
|
|
248
496
|
use(plugin) {
|
|
497
|
+
this.assertNotAnalyzing("install plugins");
|
|
498
|
+
this.plugins.push(plugin);
|
|
249
499
|
this.pluginRegistry.install(plugin, this);
|
|
250
500
|
return this;
|
|
251
501
|
}
|
|
502
|
+
/**
|
|
503
|
+
* Register a hook that runs before rule evaluation.
|
|
504
|
+
* Use for loading signals from requests, Redis, IP lookups, etc.
|
|
505
|
+
*/
|
|
506
|
+
beforeAnalyze(hook) {
|
|
507
|
+
this.beforeHooks.push(hook);
|
|
508
|
+
return this;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Register a hook that runs after the report is built.
|
|
512
|
+
* Use for audit logging, metrics, or blocking responses.
|
|
513
|
+
*/
|
|
514
|
+
afterAnalyze(hook) {
|
|
515
|
+
this.afterHooks.push(hook);
|
|
516
|
+
return this;
|
|
517
|
+
}
|
|
252
518
|
/**
|
|
253
519
|
* Returns names of installed plugins.
|
|
254
520
|
*/
|
|
@@ -256,29 +522,204 @@ var Guardian = class {
|
|
|
256
522
|
return this.pluginRegistry.getInstalled();
|
|
257
523
|
}
|
|
258
524
|
/**
|
|
259
|
-
* Run risk analysis
|
|
525
|
+
* Run risk analysis synchronously (skips lifecycle hooks).
|
|
526
|
+
* Prefer {@link analyzeAsync} when hooks are registered.
|
|
260
527
|
*/
|
|
261
528
|
analyze() {
|
|
529
|
+
if (this.beforeHooks.length > 0 || this.afterHooks.length > 0) {
|
|
530
|
+
throw new Error(
|
|
531
|
+
"Guardian has analyze hooks registered. Use analyzeAsync() instead of analyze()."
|
|
532
|
+
);
|
|
533
|
+
}
|
|
262
534
|
return this.riskEngine.analyze();
|
|
263
535
|
}
|
|
264
536
|
/**
|
|
265
|
-
*
|
|
537
|
+
* Run lifecycle hooks, evaluate rules, and return an immutable report.
|
|
538
|
+
*
|
|
539
|
+
* @param context Optional caller context passed to hooks (e.g. Express `req`).
|
|
540
|
+
*/
|
|
541
|
+
async analyzeAsync(context) {
|
|
542
|
+
this.analyzing = true;
|
|
543
|
+
try {
|
|
544
|
+
const analyzeContext = {
|
|
545
|
+
data: context,
|
|
546
|
+
guardian: this
|
|
547
|
+
};
|
|
548
|
+
await runHooks(this.beforeHooks, analyzeContext);
|
|
549
|
+
const report = this.riskEngine.analyze();
|
|
550
|
+
const afterContext = {
|
|
551
|
+
...analyzeContext,
|
|
552
|
+
report
|
|
553
|
+
};
|
|
554
|
+
await runHooks(this.afterHooks, afterContext);
|
|
555
|
+
return report;
|
|
556
|
+
} finally {
|
|
557
|
+
this.analyzing = false;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Create an isolated copy sharing rules, plugins, and hooks.
|
|
562
|
+
* Each fork has its own signal store for safe concurrent use.
|
|
563
|
+
*/
|
|
564
|
+
fork() {
|
|
565
|
+
const child = new _Guardian({ levels: [...this.thresholds] });
|
|
566
|
+
for (const rule of this.riskEngine.getRules()) {
|
|
567
|
+
child.riskEngine.addRule(rule);
|
|
568
|
+
}
|
|
569
|
+
for (const cap of this.riskEngine.getGroupCaps()) {
|
|
570
|
+
child.riskEngine.setGroupCap(cap.name, cap.maxScore);
|
|
571
|
+
}
|
|
572
|
+
child.plugins.push(...this.plugins);
|
|
573
|
+
child.pluginRegistry.adoptInstalled(this.pluginRegistry.getInstalled());
|
|
574
|
+
for (const hook of this.beforeHooks) {
|
|
575
|
+
child.beforeHooks.push(hook);
|
|
576
|
+
}
|
|
577
|
+
for (const hook of this.afterHooks) {
|
|
578
|
+
child.afterHooks.push(hook);
|
|
579
|
+
}
|
|
580
|
+
return child;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Clear all signals. Rules, plugins, and hooks persist across resets.
|
|
266
584
|
*/
|
|
267
585
|
reset() {
|
|
268
586
|
this.signalStore.clear();
|
|
269
587
|
return this;
|
|
270
588
|
}
|
|
589
|
+
assertNotAnalyzing(action) {
|
|
590
|
+
if (this.analyzing) {
|
|
591
|
+
throw new Error(`Cannot ${action} while analysis is in progress`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
271
594
|
};
|
|
272
595
|
|
|
596
|
+
// src/guardian/defineSignals.ts
|
|
597
|
+
function defineSignals() {
|
|
598
|
+
return {
|
|
599
|
+
create(config) {
|
|
600
|
+
return new Guardian(config);
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// src/presets/applyRules.ts
|
|
606
|
+
function applyRules(guardian, rules) {
|
|
607
|
+
for (const rule of rules) {
|
|
608
|
+
guardian.rule(rule);
|
|
609
|
+
}
|
|
610
|
+
return guardian;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// src/presets/botDetection.ts
|
|
614
|
+
var botDetectionRules = [
|
|
615
|
+
{
|
|
616
|
+
name: "LinearMouseMovement",
|
|
617
|
+
reason: "Mouse movement is unnaturally linear",
|
|
618
|
+
when: (s) => s.mouseLinearity > 0.9,
|
|
619
|
+
score: 25
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
name: "RequestBurst",
|
|
623
|
+
reason: "Unusually high request rate in short window",
|
|
624
|
+
when: (s) => s.requestBurst > 50,
|
|
625
|
+
score: 30
|
|
626
|
+
},
|
|
627
|
+
{
|
|
628
|
+
name: "HeadlessBrowser",
|
|
629
|
+
reason: "User agent indicates headless browser",
|
|
630
|
+
when: (s) => s.headlessUA === true,
|
|
631
|
+
score: 40
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
name: "NewSession",
|
|
635
|
+
reason: "Session is very new",
|
|
636
|
+
when: (s) => s.sessionAgeSeconds < 10,
|
|
637
|
+
score: 10
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
name: "HighRequestRate",
|
|
641
|
+
reason: "Too many requests per minute",
|
|
642
|
+
when: (s) => s.requestsPerMinute > 30,
|
|
643
|
+
score: 35
|
|
644
|
+
}
|
|
645
|
+
];
|
|
646
|
+
var loginProtectionRules = [
|
|
647
|
+
{
|
|
648
|
+
name: "BruteForceAttempts",
|
|
649
|
+
reason: "Multiple failed login attempts",
|
|
650
|
+
when: (s) => s.loginAttempts > 5,
|
|
651
|
+
score: 45,
|
|
652
|
+
group: "login"
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
name: "RapidLoginBurst",
|
|
656
|
+
reason: "Login attempts in rapid succession",
|
|
657
|
+
when: (s) => s.requestsPerMinute > 10,
|
|
658
|
+
score: 25,
|
|
659
|
+
group: "login"
|
|
660
|
+
}
|
|
661
|
+
];
|
|
662
|
+
function parseIpAddress(value) {
|
|
663
|
+
const trimmed = value.trim();
|
|
664
|
+
if (trimmed.length === 0 || trimmed.length > 45) {
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
return net.isIP(trimmed) === 0 ? null : trimmed;
|
|
668
|
+
}
|
|
669
|
+
function isPrivateIp(ip) {
|
|
670
|
+
const parsed = parseIpAddress(ip);
|
|
671
|
+
if (!parsed) {
|
|
672
|
+
return true;
|
|
673
|
+
}
|
|
674
|
+
if (net.isIP(parsed) === 4) {
|
|
675
|
+
if (parsed.startsWith("10.") || parsed.startsWith("127.") || parsed.startsWith("192.168.") || parsed.startsWith("169.254.") || parsed.startsWith("0.")) {
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
if (parsed.startsWith("172.")) {
|
|
679
|
+
const second = Number(parsed.split(".")[1]);
|
|
680
|
+
if (second >= 16 && second <= 31) {
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (parsed.startsWith("100.")) {
|
|
685
|
+
const second = Number(parsed.split(".")[1]);
|
|
686
|
+
if (second >= 64 && second <= 127) {
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return false;
|
|
691
|
+
}
|
|
692
|
+
const lower = parsed.toLowerCase();
|
|
693
|
+
return lower === "::1" || lower.startsWith("fc") || lower.startsWith("fd") || lower.startsWith("fe80");
|
|
694
|
+
}
|
|
695
|
+
function sanitizeSessionId(value) {
|
|
696
|
+
const trimmed = value.trim();
|
|
697
|
+
if (trimmed.length === 0 || trimmed.length > MAX_SESSION_ID_LENGTH) {
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
if (!SESSION_ID_PATTERN.test(trimmed)) {
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
return trimmed;
|
|
704
|
+
}
|
|
705
|
+
|
|
273
706
|
exports.DEFAULT_RISK_LEVELS = DEFAULT_RISK_LEVELS;
|
|
274
707
|
exports.Guardian = Guardian;
|
|
708
|
+
exports.HOOK_TIMEOUT_MS = HOOK_TIMEOUT_MS;
|
|
709
|
+
exports.MAX_SIGNAL_STRING_LENGTH = MAX_SIGNAL_STRING_LENGTH;
|
|
275
710
|
exports.PluginAlreadyInstalledError = PluginAlreadyInstalledError;
|
|
711
|
+
exports.PluginInstallError = PluginInstallError;
|
|
276
712
|
exports.PluginRegistry = PluginRegistry;
|
|
277
713
|
exports.RiskEngine = RiskEngine;
|
|
278
714
|
exports.RuleBuilder = RuleBuilder;
|
|
279
715
|
exports.RuleEvaluator = RuleEvaluator;
|
|
280
716
|
exports.ScoreCalculator = ScoreCalculator;
|
|
281
717
|
exports.SignalStore = SignalStore;
|
|
718
|
+
exports.applyRules = applyRules;
|
|
719
|
+
exports.botDetectionRules = botDetectionRules;
|
|
720
|
+
exports.defineSignals = defineSignals;
|
|
721
|
+
exports.isPrivateIp = isPrivateIp;
|
|
722
|
+
exports.loginProtectionRules = loginProtectionRules;
|
|
723
|
+
exports.parseIpAddress = parseIpAddress;
|
|
282
724
|
exports.resolveLevel = resolveLevel;
|
|
283
|
-
|
|
284
|
-
//# sourceMappingURL=index.cjs.map
|
|
725
|
+
exports.sanitizeSessionId = sanitizeSessionId;
|