@chdlabs/my-shield 0.1.3 → 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 +245 -113
- package/models/default-weights.json +682 -0
- package/package.json +19 -4
- package/src/admin/otpGate.js +370 -0
- package/src/biometrics/index.js +10 -0
- package/src/biometrics/minirocket.js +213 -0
- package/src/biometrics/ridge.js +144 -0
- package/src/client/collector.js +136 -0
- package/src/client/index.js +68 -0
- package/src/client/shieldClient.js +118 -0
- package/src/core/ipResolver.js +225 -0
- package/src/index.d.ts +181 -0
- package/src/index.js +138 -5
- package/src/middleware/behavioralGuard.js +304 -0
- package/src/middleware/honeypot.js +6 -4
- package/src/middleware/notFoundCollector.js +6 -4
- package/src/middleware/shieldGuard.js +3 -0
- package/src/storage/BaseAdapter.js +58 -0
- package/src/storage/CallbackStorageAdapter.js +69 -0
- package/src/storage/FileStorageAdapter.js +174 -0
- package/src/storage/MemoryAdapter.js +56 -0
- package/src/storage/index.js +53 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/middleware/behavioralGuard.js
|
|
3
|
+
* Behavioral Biometrics Express Middleware
|
|
4
|
+
* Evaluates pointer kinematics telemetry against MiniRocket + Dual Ridge model.
|
|
5
|
+
*
|
|
6
|
+
* @author ChdLabs
|
|
7
|
+
* @license MIT
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { MiniRocket } from '../biometrics/minirocket.js';
|
|
15
|
+
import { DualRidgeClassifier } from '../biometrics/ridge.js';
|
|
16
|
+
import { LRUCache } from '../core/lruCache.js';
|
|
17
|
+
|
|
18
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
19
|
+
const __dirname = path.dirname(__filename);
|
|
20
|
+
|
|
21
|
+
export class BehavioralShieldService {
|
|
22
|
+
/**
|
|
23
|
+
* @param {Object} [options={}]
|
|
24
|
+
* @param {Object} [options.modelWeights] Custom model weights { thresholds, weights, bias }
|
|
25
|
+
* @param {Object} [options.shield] Shield instance
|
|
26
|
+
* @param {Object} [options.logger=console] Logger instance
|
|
27
|
+
*/
|
|
28
|
+
constructor({ modelWeights = null, shield = null, logger = console } = {}) {
|
|
29
|
+
this.shield = shield;
|
|
30
|
+
this.logger = logger;
|
|
31
|
+
|
|
32
|
+
let weights = modelWeights;
|
|
33
|
+
if (!weights) {
|
|
34
|
+
try {
|
|
35
|
+
const defaultWeightsPath = path.resolve(__dirname, '../../models/default-weights.json');
|
|
36
|
+
if (fs.existsSync(defaultWeightsPath)) {
|
|
37
|
+
weights = JSON.parse(fs.readFileSync(defaultWeightsPath, 'utf8'));
|
|
38
|
+
}
|
|
39
|
+
} catch (err) {
|
|
40
|
+
this.logger?.warn?.('[SHIELD:BIOMETRICS] Could not load default-weights.json:', err.message);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const featureCount = weights?.weights?.length || 336;
|
|
45
|
+
const windowLength = weights?.windowLength || 128;
|
|
46
|
+
|
|
47
|
+
this.transformer = new MiniRocket(windowLength, featureCount);
|
|
48
|
+
if (weights?.thresholds) {
|
|
49
|
+
this.transformer.loadThresholds(weights.thresholds);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.classifier = new DualRidgeClassifier();
|
|
53
|
+
if (weights?.weights) {
|
|
54
|
+
this.classifier.loadWeights(weights.weights, weights.bias || 0.0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Bounded LRU cache for replay nonce protection (eliminates memory leaks)
|
|
58
|
+
this.nonceCache = new LRUCache({
|
|
59
|
+
maxSize: 10_000,
|
|
60
|
+
ttlMs: 60_000
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Verify a 128-sample velocity vector
|
|
66
|
+
* @param {number[]|Float64Array} velocityArray
|
|
67
|
+
* @returns {{ score: number, probability: number, isHuman: boolean, latencyMs: number }}
|
|
68
|
+
*/
|
|
69
|
+
verify(velocityArray) {
|
|
70
|
+
const t0 = performance.now();
|
|
71
|
+
const series = Float64Array.from(velocityArray);
|
|
72
|
+
const features = this.transformer.transform(series);
|
|
73
|
+
const result = this.classifier.predict(features);
|
|
74
|
+
const latency = +(performance.now() - t0).toFixed(2);
|
|
75
|
+
|
|
76
|
+
return { ...result, latencyMs: latency };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extract client IP safely from headers or socket
|
|
81
|
+
* @param {Object} req
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
getClientIP(req) {
|
|
85
|
+
if (this.shield?.ipResolver) {
|
|
86
|
+
return this.shield.ipResolver.resolve(req);
|
|
87
|
+
}
|
|
88
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
89
|
+
if (forwarded && typeof forwarded === 'string') {
|
|
90
|
+
const first = forwarded.split(',')[0].trim();
|
|
91
|
+
if (first) return first;
|
|
92
|
+
}
|
|
93
|
+
return req.socket?.remoteAddress || req.ip || '127.0.0.1';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Express middleware factory
|
|
98
|
+
* @param {Object} [options={}]
|
|
99
|
+
* @param {number} [options.threshold=0.50] Probability threshold for human classification (calibrated to Ridge boundary)
|
|
100
|
+
* @param {'block'|'audit'|'header-only'} [options.mode='block'] Execution mode: 'block' rejects bots, 'audit' logs and sets headers, 'header-only' sets headers
|
|
101
|
+
* @param {string} [options.headerName='x-shield-telemetry'] Header name for telemetry JSON
|
|
102
|
+
* @param {number} [options.replayWindowMs=15000] Maximum acceptable age/future drift in ms
|
|
103
|
+
* @param {boolean} [options.allowFallbackOnError=false] Fail-open vs fail-closed on missing/malformed telemetry
|
|
104
|
+
* @param {boolean} [options.autoBlockIP=false] Automatically add detected bot IP to Shield L1 blocklist
|
|
105
|
+
* @param {Function} [options.onBotDetected] Custom callback for bot detection (in 'block' mode)
|
|
106
|
+
* @param {Function} [options.onVerdict] Callback invoked for all outcomes: (verdict, req, res) => void
|
|
107
|
+
*/
|
|
108
|
+
middleware(options = {}) {
|
|
109
|
+
const {
|
|
110
|
+
threshold = 0.50,
|
|
111
|
+
mode = 'block',
|
|
112
|
+
headerName = 'x-shield-telemetry',
|
|
113
|
+
replayWindowMs = 15_000,
|
|
114
|
+
allowFallbackOnError = false,
|
|
115
|
+
autoBlockIP = false,
|
|
116
|
+
onBotDetected,
|
|
117
|
+
onVerdict
|
|
118
|
+
} = options;
|
|
119
|
+
|
|
120
|
+
return (req, res, next) => {
|
|
121
|
+
const rawHeader = req.headers[headerName] || req.body?._shieldTelemetry;
|
|
122
|
+
|
|
123
|
+
const handleVerdict = (verdict) => {
|
|
124
|
+
if (typeof onVerdict === 'function') {
|
|
125
|
+
try {
|
|
126
|
+
onVerdict(verdict, req, res);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
this.logger?.error?.('[SHIELD:BIOMETRICS] Error in onVerdict callback:', e.message);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const setVerdictHeader = (isHuman, confidence, score, reason) => {
|
|
134
|
+
if (res.headersSent) return;
|
|
135
|
+
const headerVal = isHuman
|
|
136
|
+
? `HUMAN; confidence=${confidence.toFixed(2)}; score=${score.toFixed(2)}`
|
|
137
|
+
: `BOT; confidence=${confidence.toFixed(2)}; score=${score.toFixed(2)}; reason=${reason || 'AUTOMATED_KINEMATICS_DETECTED'}`;
|
|
138
|
+
res.setHeader('X-Shield-Verdict', headerVal);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
if (!rawHeader) {
|
|
142
|
+
if (mode === 'audit' || mode === 'header-only') {
|
|
143
|
+
if (!res.headersSent) {
|
|
144
|
+
res.setHeader('X-Shield-Verdict', 'UNKNOWN; reason=MISSING_TELEMETRY');
|
|
145
|
+
}
|
|
146
|
+
req.shield = req.shield || {};
|
|
147
|
+
req.shield.biometrics = { isHuman: false, missingTelemetry: true };
|
|
148
|
+
handleVerdict({ isHuman: false, reason: 'MISSING_TELEMETRY', mode });
|
|
149
|
+
return next();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (allowFallbackOnError) return next();
|
|
153
|
+
handleVerdict({ isHuman: false, reason: 'MISSING_TELEMETRY', mode });
|
|
154
|
+
return this._deny(req, res, 'MISSING_TELEMETRY', onBotDetected);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const payload = typeof rawHeader === 'string' ? JSON.parse(rawHeader) : rawHeader;
|
|
159
|
+
|
|
160
|
+
// 1. Synthetic Dispatch Check
|
|
161
|
+
if (payload.isTrusted === false) {
|
|
162
|
+
if (mode === 'audit' || mode === 'header-only') {
|
|
163
|
+
setVerdictHeader(false, 0, -99, 'SYNTHETIC_EVENT_DISPATCH');
|
|
164
|
+
req.shield = req.shield || {};
|
|
165
|
+
req.shield.biometrics = { isHuman: false, reason: 'SYNTHETIC_EVENT_DISPATCH' };
|
|
166
|
+
handleVerdict({ isHuman: false, reason: 'SYNTHETIC_EVENT_DISPATCH', mode });
|
|
167
|
+
return next();
|
|
168
|
+
}
|
|
169
|
+
if (autoBlockIP && this.shield) {
|
|
170
|
+
const ip = this.getClientIP(req);
|
|
171
|
+
this.shield.blockIP(ip, 'behavioral_bot_detected', { reason: 'SYNTHETIC_EVENT_DISPATCH' });
|
|
172
|
+
}
|
|
173
|
+
handleVerdict({ isHuman: false, reason: 'SYNTHETIC_EVENT_DISPATCH', mode });
|
|
174
|
+
return this._deny(req, res, 'SYNTHETIC_EVENT_DISPATCH', onBotDetected);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 2. Replay Protection Window Check
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
if (!payload.timestamp || typeof payload.timestamp !== 'number' || Math.abs(now - payload.timestamp) > replayWindowMs) {
|
|
180
|
+
if (mode === 'audit' || mode === 'header-only') {
|
|
181
|
+
setVerdictHeader(false, 0, -99, 'EXPIRED_OR_FUTURE_TELEMETRY');
|
|
182
|
+
req.shield = req.shield || {};
|
|
183
|
+
req.shield.biometrics = { isHuman: false, reason: 'EXPIRED_OR_FUTURE_TELEMETRY' };
|
|
184
|
+
handleVerdict({ isHuman: false, reason: 'EXPIRED_OR_FUTURE_TELEMETRY', mode });
|
|
185
|
+
return next();
|
|
186
|
+
}
|
|
187
|
+
handleVerdict({ isHuman: false, reason: 'EXPIRED_OR_FUTURE_TELEMETRY', mode });
|
|
188
|
+
return this._deny(req, res, 'EXPIRED_OR_FUTURE_TELEMETRY', onBotDetected);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 3. Payload Schema Validation
|
|
192
|
+
const isMalformed = !Array.isArray(payload.velocity) ||
|
|
193
|
+
payload.velocity.length !== this.transformer.windowLength ||
|
|
194
|
+
payload.velocity.some(v => typeof v !== 'number' || !Number.isFinite(v));
|
|
195
|
+
|
|
196
|
+
if (isMalformed) {
|
|
197
|
+
if (mode === 'audit' || mode === 'header-only') {
|
|
198
|
+
setVerdictHeader(false, 0, -99, 'MALFORMED_KINEMATICS_VECTOR');
|
|
199
|
+
req.shield = req.shield || {};
|
|
200
|
+
req.shield.biometrics = { isHuman: false, reason: 'MALFORMED_KINEMATICS_VECTOR' };
|
|
201
|
+
handleVerdict({ isHuman: false, reason: 'MALFORMED_KINEMATICS_VECTOR', mode });
|
|
202
|
+
return next();
|
|
203
|
+
}
|
|
204
|
+
handleVerdict({ isHuman: false, reason: 'MALFORMED_KINEMATICS_VECTOR', mode });
|
|
205
|
+
return this._deny(req, res, 'MALFORMED_KINEMATICS_VECTOR', onBotDetected);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 4. Coordinate Signature Nonce Check
|
|
209
|
+
const nonce = crypto.createHash('sha256').update(JSON.stringify(payload.velocity)).digest('hex');
|
|
210
|
+
if (this.nonceCache.has(nonce)) {
|
|
211
|
+
if (mode === 'audit' || mode === 'header-only') {
|
|
212
|
+
setVerdictHeader(false, 0, -99, 'REPLAY_ATTACK_DETECTED');
|
|
213
|
+
req.shield = req.shield || {};
|
|
214
|
+
req.shield.biometrics = { isHuman: false, reason: 'REPLAY_ATTACK_DETECTED' };
|
|
215
|
+
handleVerdict({ isHuman: false, reason: 'REPLAY_ATTACK_DETECTED', mode });
|
|
216
|
+
return next();
|
|
217
|
+
}
|
|
218
|
+
handleVerdict({ isHuman: false, reason: 'REPLAY_ATTACK_DETECTED', mode });
|
|
219
|
+
return this._deny(req, res, 'REPLAY_ATTACK_DETECTED', onBotDetected);
|
|
220
|
+
}
|
|
221
|
+
this.nonceCache.set(nonce, true, replayWindowMs);
|
|
222
|
+
|
|
223
|
+
// 5. MiniRocket & Dual Ridge Scoring
|
|
224
|
+
const outcome = this.verify(payload.velocity);
|
|
225
|
+
const ip = this.getClientIP(req);
|
|
226
|
+
const isHuman = outcome.probability >= threshold;
|
|
227
|
+
|
|
228
|
+
req.shield = req.shield || {};
|
|
229
|
+
req.shield.biometrics = {
|
|
230
|
+
isHuman,
|
|
231
|
+
humanConfidence: outcome.probability,
|
|
232
|
+
latencyMs: outcome.latencyMs,
|
|
233
|
+
score: outcome.score
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
setVerdictHeader(isHuman, outcome.probability, outcome.score, isHuman ? null : 'AUTOMATED_KINEMATICS_DETECTED');
|
|
237
|
+
|
|
238
|
+
const verdictMeta = {
|
|
239
|
+
isHuman,
|
|
240
|
+
probability: outcome.probability,
|
|
241
|
+
score: outcome.score,
|
|
242
|
+
latencyMs: outcome.latencyMs,
|
|
243
|
+
ip,
|
|
244
|
+
mode
|
|
245
|
+
};
|
|
246
|
+
handleVerdict(verdictMeta);
|
|
247
|
+
|
|
248
|
+
// Record event in Shield core telemetry ring buffer
|
|
249
|
+
if (this.shield) {
|
|
250
|
+
this.shield.recordEvent({
|
|
251
|
+
type: isHuman ? 'BIOMETRICS_HUMAN' : (mode === 'block' ? 'BIOMETRICS_BLOCKED' : 'BIOMETRICS_AUDIT'),
|
|
252
|
+
ip,
|
|
253
|
+
isHuman,
|
|
254
|
+
confidence: outcome.probability,
|
|
255
|
+
score: outcome.score,
|
|
256
|
+
latencyMs: outcome.latencyMs,
|
|
257
|
+
mode
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (mode === 'audit' || mode === 'header-only') {
|
|
262
|
+
return next();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// mode === 'block'
|
|
266
|
+
if (!isHuman) {
|
|
267
|
+
if (autoBlockIP && this.shield) {
|
|
268
|
+
this.shield.blockIP(ip, 'behavioral_bot_detected', {
|
|
269
|
+
confidence: outcome.probability,
|
|
270
|
+
latencyMs: outcome.latencyMs,
|
|
271
|
+
score: outcome.score
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return this._deny(req, res, 'AUTOMATED_KINEMATICS_DETECTED', onBotDetected, outcome);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
next();
|
|
278
|
+
} catch (err) {
|
|
279
|
+
if (mode === 'audit' || mode === 'header-only' || allowFallbackOnError) {
|
|
280
|
+
if (!res.headersSent) {
|
|
281
|
+
res.setHeader('X-Shield-Verdict', 'UNKNOWN; reason=MALFORMED_TELEMETRY');
|
|
282
|
+
}
|
|
283
|
+
return next();
|
|
284
|
+
}
|
|
285
|
+
res.status(400).json({ error: 'Malformed telemetry packet', code: 'MALFORMED_TELEMETRY' });
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Handle blocked request
|
|
292
|
+
* @private
|
|
293
|
+
*/
|
|
294
|
+
_deny(req, res, reason, customHandler, meta = {}) {
|
|
295
|
+
if (customHandler && typeof customHandler === 'function') {
|
|
296
|
+
return customHandler(req, res, { reason, ...meta });
|
|
297
|
+
}
|
|
298
|
+
return res.status(403).json({
|
|
299
|
+
status: 'BLOCKED',
|
|
300
|
+
shieldCode: reason,
|
|
301
|
+
details: meta
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -171,10 +171,12 @@ export class Honeypot {
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
// Extract client IP
|
|
174
|
-
const ip =
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
174
|
+
const ip = this.shield?.ipResolver
|
|
175
|
+
? this.shield.ipResolver.resolve(req)
|
|
176
|
+
: ((req.headers['x-forwarded-for']?.split(',')[0]?.trim()) ||
|
|
177
|
+
req.socket?.remoteAddress ||
|
|
178
|
+
req.ip ||
|
|
179
|
+
'127.0.0.1');
|
|
178
180
|
|
|
179
181
|
const matchedPath = this.normalizePath(req.path);
|
|
180
182
|
const meta = this.routeMetadata.get(matchedPath);
|
|
@@ -62,10 +62,12 @@ export class NotFoundCollector {
|
|
|
62
62
|
* @param {Object} req Express request
|
|
63
63
|
*/
|
|
64
64
|
handle404(req) {
|
|
65
|
-
const ip =
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
const ip = this.shield?.ipResolver
|
|
66
|
+
? this.shield.ipResolver.resolve(req)
|
|
67
|
+
: ((req.headers['x-forwarded-for']?.split(',')[0]?.trim()) ||
|
|
68
|
+
req.socket?.remoteAddress ||
|
|
69
|
+
req.ip ||
|
|
70
|
+
'127.0.0.1');
|
|
69
71
|
|
|
70
72
|
const path = req.path || req.originalUrl || '/';
|
|
71
73
|
const userAgent = req.headers['user-agent'] || '';
|
|
@@ -38,6 +38,9 @@ export class ShieldGuard {
|
|
|
38
38
|
* @returns {string}
|
|
39
39
|
*/
|
|
40
40
|
getClientIP(req) {
|
|
41
|
+
if (this.shield?.ipResolver) {
|
|
42
|
+
return this.shield.ipResolver.resolve(req);
|
|
43
|
+
}
|
|
41
44
|
const forwarded = req.headers['x-forwarded-for'];
|
|
42
45
|
if (forwarded && typeof forwarded === 'string') {
|
|
43
46
|
const first = forwarded.split(',')[0].trim();
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base Storage Adapter Interface
|
|
3
|
+
*
|
|
4
|
+
* Defines the contract for pluggable threat persistence across container
|
|
5
|
+
* restarts and distributed multi-instance deployments.
|
|
6
|
+
*
|
|
7
|
+
* @author ChdLabs
|
|
8
|
+
* @license MIT
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export class BaseStorageAdapter {
|
|
12
|
+
/**
|
|
13
|
+
* Initialize adapter with parent Shield instance
|
|
14
|
+
* @param {Object} shield
|
|
15
|
+
*/
|
|
16
|
+
async init(shield) {
|
|
17
|
+
this.shield = shield;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Load persisted blocked IPs upon system initialization
|
|
22
|
+
* @returns {Promise<Array<{ ip: string, reason: string, metadata: object, expiresAt: number|null }>>}
|
|
23
|
+
*/
|
|
24
|
+
async loadBlockedIPs() {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Persist a newly blocked IP threat
|
|
30
|
+
* @param {string} ip
|
|
31
|
+
* @param {string} reason
|
|
32
|
+
* @param {Object} [metadata={}]
|
|
33
|
+
* @param {number|null} [expiresAt=null]
|
|
34
|
+
* @returns {Promise<void>}
|
|
35
|
+
*/
|
|
36
|
+
async saveBlockedIP(ip, reason, metadata = {}, expiresAt = null) {
|
|
37
|
+
// Subclasses implement persistence
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Remove an IP threat from persistent storage (unblock)
|
|
42
|
+
* @param {string} ip
|
|
43
|
+
* @returns {Promise<void>}
|
|
44
|
+
*/
|
|
45
|
+
async removeBlockedIP(ip) {
|
|
46
|
+
// Subclasses implement deletion
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Clean up timers or connection handles on graceful shutdown
|
|
51
|
+
* @returns {Promise<void>}
|
|
52
|
+
*/
|
|
53
|
+
async close() {
|
|
54
|
+
// Subclasses clean up resources
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default BaseStorageAdapter;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Callback Storage Adapter
|
|
3
|
+
*
|
|
4
|
+
* Universal bridge allowing developers to plug any database or caching layer
|
|
5
|
+
* (PostgreSQL, MySQL, Redis, MongoDB, DynamoDB, Prisma) into Shield
|
|
6
|
+
* via simple async function callbacks.
|
|
7
|
+
*
|
|
8
|
+
* @author ChdLabs
|
|
9
|
+
* @license MIT
|
|
10
|
+
*/
|
|
11
|
+
import { BaseStorageAdapter } from './BaseAdapter.js';
|
|
12
|
+
|
|
13
|
+
export class CallbackStorageAdapter extends BaseStorageAdapter {
|
|
14
|
+
/**
|
|
15
|
+
* @param {Object} options
|
|
16
|
+
* @param {Function} [options.onLoad] Async function returning Array of { ip, reason, metadata, expiresAt }
|
|
17
|
+
* @param {Function} [options.onBlock] Async function called when an IP is blocked: (ip, reason, metadata, expiresAt)
|
|
18
|
+
* @param {Function} [options.onUnblock] Async function called when an IP is unblocked: (ip)
|
|
19
|
+
* @param {Object} [options.logger]
|
|
20
|
+
*/
|
|
21
|
+
constructor(options = {}) {
|
|
22
|
+
super();
|
|
23
|
+
this.onLoad = options.onLoad || null;
|
|
24
|
+
this.onBlock = options.onBlock || null;
|
|
25
|
+
this.onUnblock = options.onUnblock || null;
|
|
26
|
+
this.logger = options.logger || console;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async init(shield) {
|
|
30
|
+
await super.init(shield);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async loadBlockedIPs() {
|
|
34
|
+
if (typeof this.onLoad !== 'function') {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const result = await this.onLoad();
|
|
39
|
+
return Array.isArray(result) ? result : [];
|
|
40
|
+
} catch (err) {
|
|
41
|
+
this.logger.error?.('[SHIELD:STORAGE] Error in custom onLoad storage callback:', err.message);
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async saveBlockedIP(ip, reason, metadata = {}, expiresAt = null) {
|
|
47
|
+
if (typeof this.onBlock !== 'function') return;
|
|
48
|
+
try {
|
|
49
|
+
await this.onBlock(ip, reason, metadata, expiresAt);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
this.logger.error?.(`[SHIELD:STORAGE] Error in custom onBlock callback for ${ip}:`, err.message);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async removeBlockedIP(ip) {
|
|
56
|
+
if (typeof this.onUnblock !== 'function') return;
|
|
57
|
+
try {
|
|
58
|
+
await this.onUnblock(ip);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
this.logger.error?.(`[SHIELD:STORAGE] Error in custom onUnblock callback for ${ip}:`, err.message);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async close() {
|
|
65
|
+
// No-op for callbacks unless custom teardown needed
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default CallbackStorageAdapter;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Storage Adapter
|
|
3
|
+
*
|
|
4
|
+
* Persists blocked IP threats and metadata to disk in JSON format.
|
|
5
|
+
* Employs atomic write operations (tempfile + rename) and debounced flushing
|
|
6
|
+
* to ensure integrity even during sudden container termination.
|
|
7
|
+
*
|
|
8
|
+
* @author ChdLabs
|
|
9
|
+
* @license MIT
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { BaseStorageAdapter } from './BaseAdapter.js';
|
|
14
|
+
|
|
15
|
+
export class FileStorageAdapter extends BaseStorageAdapter {
|
|
16
|
+
/**
|
|
17
|
+
* @param {Object} [options={}]
|
|
18
|
+
* @param {string} [options.directory='./data/shield']
|
|
19
|
+
* @param {string} [options.filename='blocked_ips.json']
|
|
20
|
+
* @param {number} [options.debounceMs=200]
|
|
21
|
+
* @param {Object} [options.logger]
|
|
22
|
+
*/
|
|
23
|
+
constructor(options = {}) {
|
|
24
|
+
super();
|
|
25
|
+
const baseDir = options.directory || './data/shield';
|
|
26
|
+
const rawFileName = options.filename || 'blocked_ips.json';
|
|
27
|
+
|
|
28
|
+
// Sanitize path against directory traversal
|
|
29
|
+
const safeFileName = path.basename(rawFileName);
|
|
30
|
+
this.directory = path.resolve(process.cwd(), baseDir);
|
|
31
|
+
this.filePath = path.join(this.directory, safeFileName);
|
|
32
|
+
this.debounceMs = Math.max(0, options.debounceMs ?? 200);
|
|
33
|
+
this.logger = options.logger || console;
|
|
34
|
+
|
|
35
|
+
this.records = new Map();
|
|
36
|
+
this._flushTimer = null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async init(shield) {
|
|
40
|
+
await super.init(shield);
|
|
41
|
+
this._ensureDirectory();
|
|
42
|
+
await this.loadBlockedIPs();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_ensureDirectory() {
|
|
46
|
+
try {
|
|
47
|
+
if (!fs.existsSync(this.directory)) {
|
|
48
|
+
fs.mkdirSync(this.directory, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
this.logger.error?.(`[SHIELD:STORAGE] Failed to create directory "${this.directory}":`, err.message);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async loadBlockedIPs() {
|
|
56
|
+
if (!fs.existsSync(this.filePath)) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const raw = fs.readFileSync(this.filePath, 'utf8');
|
|
62
|
+
if (!raw.trim()) return [];
|
|
63
|
+
const data = JSON.parse(raw);
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
const active = [];
|
|
66
|
+
|
|
67
|
+
this.records.clear();
|
|
68
|
+
|
|
69
|
+
if (Array.isArray(data)) {
|
|
70
|
+
for (const item of data) {
|
|
71
|
+
if (item && item.ip) {
|
|
72
|
+
if (!item.expiresAt || item.expiresAt > now) {
|
|
73
|
+
this.records.set(item.ip, item);
|
|
74
|
+
active.push(item);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} else if (typeof data === 'object' && data !== null) {
|
|
79
|
+
// Object format: { "1.2.3.4": { reason, metadata, expiresAt } }
|
|
80
|
+
for (const [ip, item] of Object.entries(data)) {
|
|
81
|
+
if (item) {
|
|
82
|
+
const record = { ip, ...item };
|
|
83
|
+
if (!record.expiresAt || record.expiresAt > now) {
|
|
84
|
+
this.records.set(ip, record);
|
|
85
|
+
active.push(record);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return active;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
this.logger.error?.(`[SHIELD:STORAGE] Error loading blocked IPs from "${this.filePath}":`, err.message);
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async saveBlockedIP(ip, reason, metadata = {}, expiresAt = null) {
|
|
99
|
+
if (!ip) return;
|
|
100
|
+
this.records.set(ip, {
|
|
101
|
+
ip,
|
|
102
|
+
reason,
|
|
103
|
+
metadata,
|
|
104
|
+
expiresAt,
|
|
105
|
+
savedAt: Date.now()
|
|
106
|
+
});
|
|
107
|
+
this._scheduleFlush();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async removeBlockedIP(ip) {
|
|
111
|
+
if (!ip) return;
|
|
112
|
+
if (this.records.delete(ip)) {
|
|
113
|
+
this._scheduleFlush();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_scheduleFlush() {
|
|
118
|
+
if (this.debounceMs === 0) {
|
|
119
|
+
this.flushSync();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (this._flushTimer) return;
|
|
124
|
+
this._flushTimer = setTimeout(() => {
|
|
125
|
+
this._flushTimer = null;
|
|
126
|
+
this.flushSync();
|
|
127
|
+
}, this.debounceMs);
|
|
128
|
+
|
|
129
|
+
if (typeof this._flushTimer.unref === 'function') {
|
|
130
|
+
this._flushTimer.unref();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Flush memory records to disk atomically
|
|
136
|
+
*/
|
|
137
|
+
flushSync() {
|
|
138
|
+
if (this._flushTimer) {
|
|
139
|
+
clearTimeout(this._flushTimer);
|
|
140
|
+
this._flushTimer = null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this._ensureDirectory();
|
|
144
|
+
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
const exportArray = [];
|
|
147
|
+
for (const [ip, item] of this.records.entries()) {
|
|
148
|
+
if (item.expiresAt && item.expiresAt <= now) {
|
|
149
|
+
this.records.delete(ip);
|
|
150
|
+
} else {
|
|
151
|
+
exportArray.push(item);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const tempPath = `${this.filePath}.tmp.${Date.now()}`;
|
|
156
|
+
try {
|
|
157
|
+
fs.writeFileSync(tempPath, JSON.stringify(exportArray, null, 2), 'utf8');
|
|
158
|
+
fs.renameSync(tempPath, this.filePath);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
this.logger.error?.(`[SHIELD:STORAGE] Failed writing blocked IPs to "${this.filePath}":`, err.message);
|
|
161
|
+
try {
|
|
162
|
+
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
163
|
+
} catch {
|
|
164
|
+
// Ignore cleanup error
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async close() {
|
|
170
|
+
this.flushSync();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export default FileStorageAdapter;
|