guardian-risk 0.2.1 → 0.3.1
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 +94 -23
- package/dist/index.cjs +336 -13
- package/dist/index.d.cts +149 -6
- package/dist/index.d.ts +149 -6
- package/dist/index.js +328 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
Configurable risk decision engine for TypeScript. Evaluate signals against rules and get an explainable risk score.
|
|
4
4
|
|
|
5
|
+
**Production-ready** at `0.3.x` — zero runtime dependencies, hardened validation, async hooks, and official plugins for Express, Redis, VPN, browser, and logging.
|
|
6
|
+
|
|
5
7
|
## Install
|
|
6
8
|
|
|
7
9
|
```bash
|
|
8
10
|
npm install guardian-risk
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
## Usage
|
|
13
|
+
## Usage (core only)
|
|
14
|
+
|
|
15
|
+
No plugins required — sync `analyze()` works when no hooks are registered:
|
|
12
16
|
|
|
13
17
|
```typescript
|
|
14
18
|
import { Guardian } from 'guardian-risk';
|
|
@@ -41,32 +45,49 @@ Install **core first**, then add only the plugins you need:
|
|
|
41
45
|
| Package | Install | Purpose |
|
|
42
46
|
|---------|---------|---------|
|
|
43
47
|
| **guardian-risk** (this) | `npm i guardian-risk` | Core engine |
|
|
44
|
-
| guardian-risk-express | `npm i guardian-risk-express` | Express
|
|
45
|
-
| guardian-risk-
|
|
46
|
-
| guardian-risk-redis | `npm i guardian-risk-redis` | Redis session counters |
|
|
48
|
+
| guardian-risk-express | `npm i guardian-risk-express` | Express middleware + validated IP |
|
|
49
|
+
| guardian-risk-redis | `npm i guardian-risk-redis` | Redis session counters + rate limits |
|
|
47
50
|
| guardian-risk-vpn | `npm i guardian-risk-vpn` | VPN / proxy / Tor detection |
|
|
51
|
+
| guardian-risk-browser | `npm i guardian-risk-browser` | Browser behavioral signals |
|
|
48
52
|
| guardian-risk-logger | `npm i guardian-risk-logger` | Audit logging |
|
|
49
53
|
|
|
50
54
|
```bash
|
|
51
|
-
|
|
52
|
-
npm install
|
|
55
|
+
npm install guardian-risk guardian-risk-express guardian-risk-redis guardian-risk-vpn guardian-risk-logger
|
|
56
|
+
npm install ioredis # optional, required for Redis in production
|
|
53
57
|
```
|
|
54
58
|
|
|
59
|
+
### Production Express example
|
|
60
|
+
|
|
55
61
|
```typescript
|
|
62
|
+
import express from 'express';
|
|
56
63
|
import { Guardian } from 'guardian-risk';
|
|
57
|
-
import { expressPlugin } from 'guardian-risk-express';
|
|
58
|
-
import {
|
|
59
|
-
import {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
import { expressPlugin, guardianMiddleware } from 'guardian-risk-express';
|
|
65
|
+
import { redisPlugin } from 'guardian-risk-redis';
|
|
66
|
+
import { vpnPlugin, StaticIpProvider } from 'guardian-risk-vpn';
|
|
67
|
+
import { loggerPlugin } from 'guardian-risk-logger';
|
|
68
|
+
|
|
69
|
+
const app = express();
|
|
70
|
+
app.set('trust proxy', 1);
|
|
71
|
+
|
|
72
|
+
const template = new Guardian()
|
|
73
|
+
.use(expressPlugin({ trustProxy: true }))
|
|
74
|
+
.use(redisPlugin({ url: process.env.REDIS_URL, allowInMemoryFallback: false }))
|
|
75
|
+
.use(vpnPlugin({ provider: new StaticIpProvider({}), vpnScore: 25 }))
|
|
76
|
+
.use(loggerPlugin({ minScore: 20 }))
|
|
77
|
+
.rule({ name: 'Burst', when: (s) => (s.requestsInWindow as number) > 30, score: 40 });
|
|
78
|
+
|
|
79
|
+
app.get('/health', (_req, res) => res.json({ ok: true }));
|
|
80
|
+
|
|
81
|
+
app.use(
|
|
82
|
+
guardianMiddleware(template, {
|
|
83
|
+
blockAboveScore: 80,
|
|
84
|
+
onAnalyzeError: 'block',
|
|
85
|
+
exposeBlockDetails: false,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
67
88
|
```
|
|
68
89
|
|
|
69
|
-
>
|
|
90
|
+
> Use your own IP intelligence provider in production (MaxMind, IPinfo, etc.) — not the dev-only `IpApiProvider`.
|
|
70
91
|
|
|
71
92
|
## Plugins API
|
|
72
93
|
|
|
@@ -76,11 +97,39 @@ import type { Plugin } from 'guardian-risk';
|
|
|
76
97
|
const myPlugin: Plugin = {
|
|
77
98
|
name: 'my-plugin',
|
|
78
99
|
install(guardian) {
|
|
79
|
-
guardian.
|
|
100
|
+
guardian.beforeAnalyze(async ({ guardian: g }) => {
|
|
101
|
+
g.signal('customSignal', true);
|
|
102
|
+
});
|
|
80
103
|
},
|
|
81
104
|
};
|
|
82
105
|
|
|
83
|
-
new Guardian().use(myPlugin);
|
|
106
|
+
await new Guardian().use(myPlugin).analyzeAsync();
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Typed signals & presets
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
import { defineSignals, applyRules, botDetectionRules } from 'guardian-risk';
|
|
113
|
+
|
|
114
|
+
const bot = defineSignals<{ mouseLinearity: number; headlessUA: boolean }>();
|
|
115
|
+
|
|
116
|
+
const guardian = applyRules(bot.create(), botDetectionRules)
|
|
117
|
+
.signal('mouseLinearity', 0.95)
|
|
118
|
+
.signal('headlessUA', true);
|
|
119
|
+
|
|
120
|
+
const report = await guardian.analyzeAsync();
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Rule groups
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
guardian.ruleGroup({
|
|
127
|
+
name: 'login',
|
|
128
|
+
maxScore: 40,
|
|
129
|
+
rules: [
|
|
130
|
+
{ name: 'BruteForce', when: (s) => (s.loginAttempts as number) > 5, score: 45 },
|
|
131
|
+
],
|
|
132
|
+
});
|
|
84
133
|
```
|
|
85
134
|
|
|
86
135
|
## API
|
|
@@ -88,26 +137,48 @@ new Guardian().use(myPlugin);
|
|
|
88
137
|
| Method | Description |
|
|
89
138
|
|--------|-------------|
|
|
90
139
|
| `guardian.signal(key, value)` | Add a signal |
|
|
140
|
+
| `guardian.getSignal(key)` | Read a signal without modifying state |
|
|
91
141
|
| `guardian.rule({ name, when, score, reason? })` | Register a rule |
|
|
142
|
+
| `guardian.ruleGroup({ name, maxScore, rules })` | Register capped rule group |
|
|
92
143
|
| `guardian.use(plugin)` | Install a plugin (once per name) |
|
|
93
|
-
| `guardian.
|
|
144
|
+
| `guardian.beforeAnalyze(hook)` | Run hook before evaluation (async OK) |
|
|
145
|
+
| `guardian.afterAnalyze(hook)` | Run hook after report is built |
|
|
146
|
+
| `guardian.analyze()` | Sync analysis (only when no hooks registered) |
|
|
147
|
+
| `guardian.analyzeAsync(context?)` | Async analysis with lifecycle hooks |
|
|
148
|
+
| `guardian.fork()` | Clone rules/plugins for per-request use |
|
|
94
149
|
| `guardian.reset()` | Clear signals (rules + plugins persist) |
|
|
95
150
|
| `guardian.getInstalledPlugins()` | List installed plugin names |
|
|
96
151
|
|
|
152
|
+
## Production checklist
|
|
153
|
+
|
|
154
|
+
1. One **template** `Guardian` at startup; `fork()` or middleware per request
|
|
155
|
+
2. `await analyzeAsync(req)` when plugins are installed
|
|
156
|
+
3. `app.set('trust proxy', 1)` behind load balancers
|
|
157
|
+
4. `REDIS_URL` set; `allowInMemoryFallback: false` in production
|
|
158
|
+
5. Your own VPN/IP provider — not default external APIs
|
|
159
|
+
6. `onAnalyzeError: 'block'` and `exposeBlockDetails: false` when blocking
|
|
160
|
+
7. Browser/client signals are hints only — never sole auth factor
|
|
161
|
+
|
|
162
|
+
Full details: [SECURITY.md](https://github.com/himanshu6306singh/guardian-risk/blob/main/SECURITY.md)
|
|
163
|
+
|
|
97
164
|
## Security
|
|
98
165
|
|
|
99
166
|
- **Zero runtime dependencies** — minimal supply chain risk
|
|
100
167
|
- **No install scripts** — nothing runs on `npm install`
|
|
168
|
+
- String signals capped at 4 KB; `NaN`/`Infinity` rejected
|
|
101
169
|
- Prototype pollution protection on signal keys
|
|
102
170
|
- Rule `when()` errors isolated — engine stays stable
|
|
171
|
+
- Hook timeout (10s); rules/plugins locked during `analyzeAsync()`
|
|
172
|
+
- Deep-frozen matched rules in reports
|
|
103
173
|
- Score bounds: ±10,000 per rule, ±1,000,000 total
|
|
104
|
-
|
|
105
|
-
|
|
174
|
+
|
|
175
|
+
See [SECURITY.md](https://github.com/himanshu6306singh/guardian-risk/blob/main/SECURITY.md) for vulnerability reporting.
|
|
106
176
|
|
|
107
177
|
## Links
|
|
108
178
|
|
|
109
179
|
- [GitHub monorepo](https://github.com/himanshu6306singh/guardian-risk)
|
|
110
|
-
- [
|
|
180
|
+
- [Migration guide](https://github.com/himanshu6306singh/guardian-risk/blob/main/MIGRATION.md)
|
|
181
|
+
- [All packages](https://github.com/himanshu6306singh/guardian-risk/blob/main/ECOSYSTEM.md)
|
|
111
182
|
|
|
112
183
|
## License
|
|
113
184
|
|
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 = [
|
|
@@ -16,12 +17,38 @@ var MAX_SIGNALS = 1e3;
|
|
|
16
17
|
var MAX_KEY_LENGTH = 256;
|
|
17
18
|
var MAX_RULE_SCORE = 1e4;
|
|
18
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;
|
|
19
24
|
var BLOCKED_SIGNAL_KEYS = /* @__PURE__ */ new Set([
|
|
20
25
|
"__proto__",
|
|
21
26
|
"constructor",
|
|
22
27
|
"prototype"
|
|
23
28
|
]);
|
|
24
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
|
+
|
|
25
52
|
// src/utils/resolveLevel.ts
|
|
26
53
|
function resolveLevel(score, thresholds = DEFAULT_RISK_LEVELS) {
|
|
27
54
|
for (const threshold of thresholds) {
|
|
@@ -41,11 +68,14 @@ var ReportBuilder = class {
|
|
|
41
68
|
build(score, matchedRules, thresholds, analyzedAt = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
42
69
|
const reasons = matchedRules.map((rule) => rule.reason);
|
|
43
70
|
const level = resolveLevel(score, thresholds);
|
|
71
|
+
const frozenRules = matchedRules.map(
|
|
72
|
+
(rule) => Object.freeze({ ...rule })
|
|
73
|
+
);
|
|
44
74
|
const report = {
|
|
45
75
|
score,
|
|
46
76
|
level,
|
|
47
77
|
reasons: Object.freeze([...reasons]),
|
|
48
|
-
matchedRules: Object.freeze(
|
|
78
|
+
matchedRules: Object.freeze(frozenRules),
|
|
49
79
|
analyzedAt
|
|
50
80
|
};
|
|
51
81
|
return Object.freeze(report);
|
|
@@ -72,7 +102,8 @@ var RuleEvaluator = class {
|
|
|
72
102
|
id: rule.id,
|
|
73
103
|
name: rule.name,
|
|
74
104
|
score: rule.score,
|
|
75
|
-
reason: rule.reason ?? rule.name
|
|
105
|
+
reason: rule.reason ?? rule.name,
|
|
106
|
+
...rule.group !== void 0 ? { group: rule.group } : {}
|
|
76
107
|
});
|
|
77
108
|
}
|
|
78
109
|
}
|
|
@@ -83,10 +114,24 @@ var RuleEvaluator = class {
|
|
|
83
114
|
// src/score/ScoreCalculator.ts
|
|
84
115
|
var ScoreCalculator = class {
|
|
85
116
|
/**
|
|
86
|
-
* Sum scores from
|
|
117
|
+
* Sum scores from matched rules, applying group caps when configured.
|
|
87
118
|
*/
|
|
88
|
-
calculate(matchedRules) {
|
|
89
|
-
const
|
|
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
|
+
}
|
|
90
135
|
if (!Number.isFinite(total)) {
|
|
91
136
|
return 0;
|
|
92
137
|
}
|
|
@@ -104,7 +149,13 @@ function validateSignalValue(value) {
|
|
|
104
149
|
return true;
|
|
105
150
|
}
|
|
106
151
|
const type = typeof value;
|
|
107
|
-
|
|
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";
|
|
108
159
|
}
|
|
109
160
|
function validateSignalKey(key) {
|
|
110
161
|
if (typeof key !== "string" || key.length === 0) {
|
|
@@ -149,6 +200,14 @@ function validateRuleInput(input) {
|
|
|
149
200
|
throw new TypeError(`Rule description exceeds maximum length of ${MAX_KEY_LENGTH}`);
|
|
150
201
|
}
|
|
151
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
|
+
}
|
|
152
211
|
}
|
|
153
212
|
function validatePlugin(plugin) {
|
|
154
213
|
if (typeof plugin.name !== "string" || plugin.name.trim().length === 0) {
|
|
@@ -174,6 +233,22 @@ function validateRiskLevels(levels) {
|
|
|
174
233
|
}
|
|
175
234
|
}
|
|
176
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
|
+
}
|
|
251
|
+
}
|
|
177
252
|
function generateId() {
|
|
178
253
|
return crypto.randomUUID();
|
|
179
254
|
}
|
|
@@ -235,6 +310,7 @@ var RiskEngine = class {
|
|
|
235
310
|
}
|
|
236
311
|
deps;
|
|
237
312
|
rules = [];
|
|
313
|
+
groupCaps = [];
|
|
238
314
|
thresholds;
|
|
239
315
|
/**
|
|
240
316
|
* Register a rule for evaluation.
|
|
@@ -251,13 +327,30 @@ var RiskEngine = class {
|
|
|
251
327
|
getRules() {
|
|
252
328
|
return this.rules;
|
|
253
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
|
+
}
|
|
254
347
|
/**
|
|
255
348
|
* Run the full risk analysis pipeline.
|
|
256
349
|
*/
|
|
257
350
|
analyze() {
|
|
258
351
|
const signals = this.deps.signalStore.getAll();
|
|
259
352
|
const matchedRules = this.deps.ruleEvaluator.evaluate(this.rules, signals);
|
|
260
|
-
const score = this.deps.scoreCalculator.calculate(matchedRules);
|
|
353
|
+
const score = this.deps.scoreCalculator.calculate(matchedRules, this.groupCaps);
|
|
261
354
|
return this.deps.reportBuilder.build(score, matchedRules, this.thresholds);
|
|
262
355
|
}
|
|
263
356
|
};
|
|
@@ -275,7 +368,8 @@ var RuleBuilder = class {
|
|
|
275
368
|
score: input.score,
|
|
276
369
|
when: input.when,
|
|
277
370
|
...input.description !== void 0 ? { description: input.description } : {},
|
|
278
|
-
...input.reason !== void 0 ? { reason: input.reason } : {}
|
|
371
|
+
...input.reason !== void 0 ? { reason: input.reason } : {},
|
|
372
|
+
...input.group !== void 0 ? { group: input.group } : {}
|
|
279
373
|
};
|
|
280
374
|
return rule;
|
|
281
375
|
}
|
|
@@ -319,6 +413,15 @@ var PluginRegistry = class {
|
|
|
319
413
|
has(name) {
|
|
320
414
|
return this.installed.has(name);
|
|
321
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
|
+
}
|
|
322
425
|
/**
|
|
323
426
|
* Get names of all installed plugins in registration order.
|
|
324
427
|
*/
|
|
@@ -328,12 +431,17 @@ var PluginRegistry = class {
|
|
|
328
431
|
};
|
|
329
432
|
|
|
330
433
|
// src/engine/Guardian.ts
|
|
331
|
-
var Guardian = class {
|
|
434
|
+
var Guardian = class _Guardian {
|
|
332
435
|
signalStore;
|
|
333
436
|
riskEngine;
|
|
334
437
|
pluginRegistry = new PluginRegistry();
|
|
438
|
+
plugins = [];
|
|
439
|
+
beforeHooks = [];
|
|
440
|
+
afterHooks = [];
|
|
441
|
+
thresholds;
|
|
442
|
+
analyzing = false;
|
|
335
443
|
constructor(config = {}) {
|
|
336
|
-
|
|
444
|
+
this.thresholds = config.levels !== void 0 ? [...config.levels] : DEFAULT_RISK_LEVELS;
|
|
337
445
|
if (config.levels !== void 0) {
|
|
338
446
|
validateRiskLevels(config.levels);
|
|
339
447
|
}
|
|
@@ -344,7 +452,7 @@ var Guardian = class {
|
|
|
344
452
|
scoreCalculator: new ScoreCalculator(),
|
|
345
453
|
reportBuilder: new ReportBuilder()
|
|
346
454
|
};
|
|
347
|
-
this.riskEngine = new RiskEngine(deps, thresholds);
|
|
455
|
+
this.riskEngine = new RiskEngine(deps, this.thresholds);
|
|
348
456
|
}
|
|
349
457
|
/**
|
|
350
458
|
* Add a signal value for risk evaluation.
|
|
@@ -353,21 +461,60 @@ var Guardian = class {
|
|
|
353
461
|
this.signalStore.set(key, value);
|
|
354
462
|
return this;
|
|
355
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* Read a signal value without modifying state.
|
|
466
|
+
*/
|
|
467
|
+
getSignal(key) {
|
|
468
|
+
return this.signalStore.get(key);
|
|
469
|
+
}
|
|
356
470
|
/**
|
|
357
471
|
* Register a rule. ID is auto-generated.
|
|
358
472
|
*/
|
|
359
473
|
rule(input) {
|
|
474
|
+
this.assertNotAnalyzing("register rules");
|
|
360
475
|
const rule = RuleBuilder.create(input);
|
|
361
476
|
this.riskEngine.addRule(rule);
|
|
362
477
|
return this;
|
|
363
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
|
+
}
|
|
364
493
|
/**
|
|
365
494
|
* Install a plugin. Each plugin name may only be registered once.
|
|
366
495
|
*/
|
|
367
496
|
use(plugin) {
|
|
497
|
+
this.assertNotAnalyzing("install plugins");
|
|
498
|
+
this.plugins.push(plugin);
|
|
368
499
|
this.pluginRegistry.install(plugin, this);
|
|
369
500
|
return this;
|
|
370
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
|
+
}
|
|
371
518
|
/**
|
|
372
519
|
* Returns names of installed plugins.
|
|
373
520
|
*/
|
|
@@ -375,22 +522,191 @@ var Guardian = class {
|
|
|
375
522
|
return this.pluginRegistry.getInstalled();
|
|
376
523
|
}
|
|
377
524
|
/**
|
|
378
|
-
* Run risk analysis
|
|
525
|
+
* Run risk analysis synchronously (skips lifecycle hooks).
|
|
526
|
+
* Prefer {@link analyzeAsync} when hooks are registered.
|
|
379
527
|
*/
|
|
380
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
|
+
}
|
|
381
534
|
return this.riskEngine.analyze();
|
|
382
535
|
}
|
|
383
536
|
/**
|
|
384
|
-
*
|
|
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.
|
|
385
584
|
*/
|
|
386
585
|
reset() {
|
|
387
586
|
this.signalStore.clear();
|
|
388
587
|
return this;
|
|
389
588
|
}
|
|
589
|
+
assertNotAnalyzing(action) {
|
|
590
|
+
if (this.analyzing) {
|
|
591
|
+
throw new Error(`Cannot ${action} while analysis is in progress`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
390
594
|
};
|
|
391
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
|
+
|
|
392
706
|
exports.DEFAULT_RISK_LEVELS = DEFAULT_RISK_LEVELS;
|
|
393
707
|
exports.Guardian = Guardian;
|
|
708
|
+
exports.HOOK_TIMEOUT_MS = HOOK_TIMEOUT_MS;
|
|
709
|
+
exports.MAX_SIGNAL_STRING_LENGTH = MAX_SIGNAL_STRING_LENGTH;
|
|
394
710
|
exports.PluginAlreadyInstalledError = PluginAlreadyInstalledError;
|
|
395
711
|
exports.PluginInstallError = PluginInstallError;
|
|
396
712
|
exports.PluginRegistry = PluginRegistry;
|
|
@@ -399,4 +715,11 @@ exports.RuleBuilder = RuleBuilder;
|
|
|
399
715
|
exports.RuleEvaluator = RuleEvaluator;
|
|
400
716
|
exports.ScoreCalculator = ScoreCalculator;
|
|
401
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;
|
|
402
724
|
exports.resolveLevel = resolveLevel;
|
|
725
|
+
exports.sanitizeSessionId = sanitizeSessionId;
|