@chdlabs/my-shield 0.2.0 → 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 +212 -226
- package/package.json +5 -1
- package/src/admin/otpGate.js +370 -0
- package/src/client/collector.js +303 -53
- package/src/client/index.js +2 -1
- package/src/client/shieldClient.js +121 -0
- package/src/core/ipResolver.js +225 -0
- package/src/index.d.ts +152 -10
- package/src/index.js +113 -4
- package/src/middleware/behavioralGuard.js +172 -24
- 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
|
@@ -76,12 +76,58 @@ export class BehavioralShieldService {
|
|
|
76
76
|
return { ...result, latencyMs: latency };
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Evaluate mobile tap dwell kinetics against human physiological tap distribution
|
|
81
|
+
* @param {number} dwellMs Tap contact duration in milliseconds
|
|
82
|
+
* @returns {{ score: number, probability: number, isHuman: boolean, latencyMs: number, reason?: string }}
|
|
83
|
+
*/
|
|
84
|
+
verifyTapDwell(dwellMs) {
|
|
85
|
+
const t0 = performance.now();
|
|
86
|
+
if (typeof dwellMs !== 'number' || !Number.isFinite(dwellMs) || dwellMs < 20) {
|
|
87
|
+
return {
|
|
88
|
+
score: -2.5,
|
|
89
|
+
probability: 0.05,
|
|
90
|
+
isHuman: false,
|
|
91
|
+
latencyMs: +(performance.now() - t0).toFixed(2),
|
|
92
|
+
reason: 'BOT_INSTANT_TAP'
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (dwellMs > 1500) {
|
|
96
|
+
return {
|
|
97
|
+
score: -1.2,
|
|
98
|
+
probability: 0.20,
|
|
99
|
+
isHuman: false,
|
|
100
|
+
latencyMs: +(performance.now() - t0).toFixed(2),
|
|
101
|
+
reason: 'ABNORMAL_TAP_DWELL'
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Calibrated log-normal human tap distribution (mean = ~90ms, std = 0.55)
|
|
106
|
+
const logVal = Math.log(dwellMs);
|
|
107
|
+
const mean = Math.log(90);
|
|
108
|
+
const std = 0.55;
|
|
109
|
+
const z = (logVal - mean) / std;
|
|
110
|
+
const score = +(2.0 - 0.8 * Math.abs(z)).toFixed(2);
|
|
111
|
+
const probability = +(1 / (1 + Math.exp(-score))).toFixed(2);
|
|
112
|
+
const latency = +(performance.now() - t0).toFixed(2);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
score,
|
|
116
|
+
probability,
|
|
117
|
+
isHuman: probability >= 0.50,
|
|
118
|
+
latencyMs: latency
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
79
122
|
/**
|
|
80
123
|
* Extract client IP safely from headers or socket
|
|
81
124
|
* @param {Object} req
|
|
82
125
|
* @returns {string}
|
|
83
126
|
*/
|
|
84
127
|
getClientIP(req) {
|
|
128
|
+
if (this.shield?.ipResolver) {
|
|
129
|
+
return this.shield.ipResolver.resolve(req);
|
|
130
|
+
}
|
|
85
131
|
const forwarded = req.headers['x-forwarded-for'];
|
|
86
132
|
if (forwarded && typeof forwarded === 'string') {
|
|
87
133
|
const first = forwarded.split(',')[0].trim();
|
|
@@ -93,28 +139,61 @@ export class BehavioralShieldService {
|
|
|
93
139
|
/**
|
|
94
140
|
* Express middleware factory
|
|
95
141
|
* @param {Object} [options={}]
|
|
96
|
-
* @param {number} [options.threshold=0.
|
|
142
|
+
* @param {number} [options.threshold=0.50] Probability threshold for human classification (calibrated to Ridge boundary)
|
|
143
|
+
* @param {'block'|'audit'|'header-only'} [options.mode='block'] Execution mode: 'block' rejects bots, 'audit' logs and sets headers, 'header-only' sets headers
|
|
97
144
|
* @param {string} [options.headerName='x-shield-telemetry'] Header name for telemetry JSON
|
|
98
145
|
* @param {number} [options.replayWindowMs=15000] Maximum acceptable age/future drift in ms
|
|
99
|
-
* @param {boolean} [options.allowFallbackOnError=false] Fail-open vs fail-closed
|
|
146
|
+
* @param {boolean} [options.allowFallbackOnError=false] Fail-open vs fail-closed on missing/malformed telemetry
|
|
100
147
|
* @param {boolean} [options.autoBlockIP=false] Automatically add detected bot IP to Shield L1 blocklist
|
|
101
|
-
* @param {Function} [options.onBotDetected] Custom callback for bot detection
|
|
148
|
+
* @param {Function} [options.onBotDetected] Custom callback for bot detection (in 'block' mode)
|
|
149
|
+
* @param {Function} [options.onVerdict] Callback invoked for all outcomes: (verdict, req, res) => void
|
|
102
150
|
*/
|
|
103
151
|
middleware(options = {}) {
|
|
104
152
|
const {
|
|
105
|
-
threshold = 0.
|
|
153
|
+
threshold = 0.50,
|
|
154
|
+
mode = 'block',
|
|
106
155
|
headerName = 'x-shield-telemetry',
|
|
107
156
|
replayWindowMs = 15_000,
|
|
108
157
|
allowFallbackOnError = false,
|
|
109
158
|
autoBlockIP = false,
|
|
110
|
-
onBotDetected
|
|
159
|
+
onBotDetected,
|
|
160
|
+
onVerdict
|
|
111
161
|
} = options;
|
|
112
162
|
|
|
113
163
|
return (req, res, next) => {
|
|
114
164
|
const rawHeader = req.headers[headerName] || req.body?._shieldTelemetry;
|
|
115
165
|
|
|
166
|
+
const handleVerdict = (verdict) => {
|
|
167
|
+
if (typeof onVerdict === 'function') {
|
|
168
|
+
try {
|
|
169
|
+
onVerdict(verdict, req, res);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
this.logger?.error?.('[SHIELD:BIOMETRICS] Error in onVerdict callback:', e.message);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const setVerdictHeader = (isHuman, confidence, score, reason) => {
|
|
177
|
+
if (res.headersSent || typeof res.setHeader !== 'function') return;
|
|
178
|
+
const headerVal = isHuman
|
|
179
|
+
? `HUMAN; confidence=${confidence.toFixed(2)}; score=${score.toFixed(2)}`
|
|
180
|
+
: `BOT; confidence=${confidence.toFixed(2)}; score=${score.toFixed(2)}; reason=${reason || 'AUTOMATED_KINEMATICS_DETECTED'}`;
|
|
181
|
+
res.setHeader('X-Shield-Verdict', headerVal);
|
|
182
|
+
};
|
|
183
|
+
|
|
116
184
|
if (!rawHeader) {
|
|
185
|
+
if (mode === 'audit' || mode === 'header-only') {
|
|
186
|
+
if (!res.headersSent) {
|
|
187
|
+
res.setHeader('X-Shield-Verdict', 'UNKNOWN; reason=MISSING_TELEMETRY');
|
|
188
|
+
}
|
|
189
|
+
req.shield = req.shield || {};
|
|
190
|
+
req.shield.biometrics = { isHuman: false, missingTelemetry: true };
|
|
191
|
+
handleVerdict({ isHuman: false, reason: 'MISSING_TELEMETRY', mode });
|
|
192
|
+
return next();
|
|
193
|
+
}
|
|
194
|
+
|
|
117
195
|
if (allowFallbackOnError) return next();
|
|
196
|
+
handleVerdict({ isHuman: false, reason: 'MISSING_TELEMETRY', mode });
|
|
118
197
|
return this._deny(req, res, 'MISSING_TELEMETRY', onBotDetected);
|
|
119
198
|
}
|
|
120
199
|
|
|
@@ -123,70 +202,139 @@ export class BehavioralShieldService {
|
|
|
123
202
|
|
|
124
203
|
// 1. Synthetic Dispatch Check
|
|
125
204
|
if (payload.isTrusted === false) {
|
|
205
|
+
if (mode === 'audit' || mode === 'header-only') {
|
|
206
|
+
setVerdictHeader(false, 0, -99, 'SYNTHETIC_EVENT_DISPATCH');
|
|
207
|
+
req.shield = req.shield || {};
|
|
208
|
+
req.shield.biometrics = { isHuman: false, reason: 'SYNTHETIC_EVENT_DISPATCH' };
|
|
209
|
+
handleVerdict({ isHuman: false, reason: 'SYNTHETIC_EVENT_DISPATCH', mode });
|
|
210
|
+
return next();
|
|
211
|
+
}
|
|
212
|
+
if (autoBlockIP && this.shield) {
|
|
213
|
+
const ip = this.getClientIP(req);
|
|
214
|
+
this.shield.blockIP(ip, 'behavioral_bot_detected', { reason: 'SYNTHETIC_EVENT_DISPATCH' });
|
|
215
|
+
}
|
|
216
|
+
handleVerdict({ isHuman: false, reason: 'SYNTHETIC_EVENT_DISPATCH', mode });
|
|
126
217
|
return this._deny(req, res, 'SYNTHETIC_EVENT_DISPATCH', onBotDetected);
|
|
127
218
|
}
|
|
128
219
|
|
|
129
220
|
// 2. Replay Protection Window Check
|
|
130
221
|
const now = Date.now();
|
|
131
222
|
if (!payload.timestamp || typeof payload.timestamp !== 'number' || Math.abs(now - payload.timestamp) > replayWindowMs) {
|
|
223
|
+
if (mode === 'audit' || mode === 'header-only') {
|
|
224
|
+
setVerdictHeader(false, 0, -99, 'EXPIRED_OR_FUTURE_TELEMETRY');
|
|
225
|
+
req.shield = req.shield || {};
|
|
226
|
+
req.shield.biometrics = { isHuman: false, reason: 'EXPIRED_OR_FUTURE_TELEMETRY' };
|
|
227
|
+
handleVerdict({ isHuman: false, reason: 'EXPIRED_OR_FUTURE_TELEMETRY', mode });
|
|
228
|
+
return next();
|
|
229
|
+
}
|
|
230
|
+
handleVerdict({ isHuman: false, reason: 'EXPIRED_OR_FUTURE_TELEMETRY', mode });
|
|
132
231
|
return this._deny(req, res, 'EXPIRED_OR_FUTURE_TELEMETRY', onBotDetected);
|
|
133
232
|
}
|
|
134
233
|
|
|
135
234
|
// 3. Payload Schema Validation
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
235
|
+
const isMalformed = !Array.isArray(payload.velocity) ||
|
|
236
|
+
payload.velocity.length !== this.transformer.windowLength ||
|
|
237
|
+
payload.velocity.some(v => typeof v !== 'number' || !Number.isFinite(v));
|
|
139
238
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
239
|
+
if (isMalformed) {
|
|
240
|
+
if (mode === 'audit' || mode === 'header-only') {
|
|
241
|
+
setVerdictHeader(false, 0, -99, 'MALFORMED_KINEMATICS_VECTOR');
|
|
242
|
+
req.shield = req.shield || {};
|
|
243
|
+
req.shield.biometrics = { isHuman: false, reason: 'MALFORMED_KINEMATICS_VECTOR' };
|
|
244
|
+
handleVerdict({ isHuman: false, reason: 'MALFORMED_KINEMATICS_VECTOR', mode });
|
|
245
|
+
return next();
|
|
144
246
|
}
|
|
247
|
+
handleVerdict({ isHuman: false, reason: 'MALFORMED_KINEMATICS_VECTOR', mode });
|
|
248
|
+
return this._deny(req, res, 'MALFORMED_KINEMATICS_VECTOR', onBotDetected);
|
|
145
249
|
}
|
|
146
250
|
|
|
147
251
|
// 4. Coordinate Signature Nonce Check
|
|
148
252
|
const nonce = crypto.createHash('sha256').update(JSON.stringify(payload.velocity)).digest('hex');
|
|
149
253
|
if (this.nonceCache.has(nonce)) {
|
|
254
|
+
if (mode === 'audit' || mode === 'header-only') {
|
|
255
|
+
setVerdictHeader(false, 0, -99, 'REPLAY_ATTACK_DETECTED');
|
|
256
|
+
req.shield = req.shield || {};
|
|
257
|
+
req.shield.biometrics = { isHuman: false, reason: 'REPLAY_ATTACK_DETECTED' };
|
|
258
|
+
handleVerdict({ isHuman: false, reason: 'REPLAY_ATTACK_DETECTED', mode });
|
|
259
|
+
return next();
|
|
260
|
+
}
|
|
261
|
+
handleVerdict({ isHuman: false, reason: 'REPLAY_ATTACK_DETECTED', mode });
|
|
150
262
|
return this._deny(req, res, 'REPLAY_ATTACK_DETECTED', onBotDetected);
|
|
151
263
|
}
|
|
152
264
|
this.nonceCache.set(nonce, true, replayWindowMs);
|
|
153
265
|
|
|
154
|
-
// 5. MiniRocket
|
|
155
|
-
const
|
|
266
|
+
// 5. Kinematics Scoring (MiniRocket or Touch Tap Dwell)
|
|
267
|
+
const isTap = payload.source === 'tap' && typeof payload.dwellMs === 'number';
|
|
268
|
+
const outcome = isTap ? this.verifyTapDwell(payload.dwellMs) : this.verify(payload.velocity);
|
|
156
269
|
const ip = this.getClientIP(req);
|
|
270
|
+
const isHuman = outcome.probability >= threshold;
|
|
271
|
+
const failReason = outcome.reason || 'AUTOMATED_KINEMATICS_DETECTED';
|
|
157
272
|
|
|
158
273
|
req.shield = req.shield || {};
|
|
159
274
|
req.shield.biometrics = {
|
|
160
|
-
isHuman
|
|
275
|
+
isHuman,
|
|
161
276
|
humanConfidence: outcome.probability,
|
|
162
277
|
latencyMs: outcome.latencyMs,
|
|
163
|
-
score: outcome.score
|
|
278
|
+
score: outcome.score,
|
|
279
|
+
pointerType: payload.pointerType || 'mouse',
|
|
280
|
+
source: payload.source || 'trajectory'
|
|
164
281
|
};
|
|
165
282
|
|
|
166
|
-
|
|
283
|
+
setVerdictHeader(isHuman, outcome.probability, outcome.score, isHuman ? null : failReason);
|
|
284
|
+
|
|
285
|
+
const verdictMeta = {
|
|
286
|
+
isHuman,
|
|
287
|
+
probability: outcome.probability,
|
|
288
|
+
score: outcome.score,
|
|
289
|
+
latencyMs: outcome.latencyMs,
|
|
290
|
+
pointerType: payload.pointerType || 'mouse',
|
|
291
|
+
source: payload.source || 'trajectory',
|
|
292
|
+
ip,
|
|
293
|
+
mode
|
|
294
|
+
};
|
|
295
|
+
handleVerdict(verdictMeta);
|
|
296
|
+
|
|
297
|
+
// Record event in Shield core telemetry ring buffer
|
|
167
298
|
if (this.shield) {
|
|
168
299
|
this.shield.recordEvent({
|
|
169
|
-
type: '
|
|
300
|
+
type: isHuman ? 'BIOMETRICS_HUMAN' : (mode === 'block' ? 'BIOMETRICS_BLOCKED' : 'BIOMETRICS_AUDIT'),
|
|
170
301
|
ip,
|
|
171
|
-
isHuman
|
|
302
|
+
isHuman,
|
|
172
303
|
confidence: outcome.probability,
|
|
173
|
-
|
|
304
|
+
score: outcome.score,
|
|
305
|
+
latencyMs: outcome.latencyMs,
|
|
306
|
+
pointerType: payload.pointerType || 'mouse',
|
|
307
|
+
source: payload.source || 'trajectory',
|
|
308
|
+
mode
|
|
174
309
|
});
|
|
175
310
|
}
|
|
176
311
|
|
|
177
|
-
if (
|
|
312
|
+
if (mode === 'audit' || mode === 'header-only') {
|
|
313
|
+
return next();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// mode === 'block'
|
|
317
|
+
if (!isHuman) {
|
|
178
318
|
if (autoBlockIP && this.shield) {
|
|
179
319
|
this.shield.blockIP(ip, 'behavioral_bot_detected', {
|
|
180
320
|
confidence: outcome.probability,
|
|
181
|
-
latencyMs: outcome.latencyMs
|
|
321
|
+
latencyMs: outcome.latencyMs,
|
|
322
|
+
score: outcome.score,
|
|
323
|
+
reason: failReason,
|
|
324
|
+
pointerType: payload.pointerType || 'mouse'
|
|
182
325
|
});
|
|
183
326
|
}
|
|
184
|
-
return this._deny(req, res,
|
|
327
|
+
return this._deny(req, res, failReason, onBotDetected, outcome);
|
|
185
328
|
}
|
|
186
329
|
|
|
187
330
|
next();
|
|
188
331
|
} catch (err) {
|
|
189
|
-
if (allowFallbackOnError)
|
|
332
|
+
if (mode === 'audit' || mode === 'header-only' || allowFallbackOnError) {
|
|
333
|
+
if (!res.headersSent) {
|
|
334
|
+
res.setHeader('X-Shield-Verdict', 'UNKNOWN; reason=MALFORMED_TELEMETRY');
|
|
335
|
+
}
|
|
336
|
+
return next();
|
|
337
|
+
}
|
|
190
338
|
res.status(400).json({ error: 'Malformed telemetry packet', code: 'MALFORMED_TELEMETRY' });
|
|
191
339
|
}
|
|
192
340
|
};
|
|
@@ -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;
|