@chdlabs/my-shield 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 +210 -226
- package/package.json +5 -1
- package/src/admin/otpGate.js +370 -0
- package/src/client/collector.js +24 -5
- package/src/client/index.js +1 -0
- package/src/client/shieldClient.js +118 -0
- package/src/core/ipResolver.js +225 -0
- package/src/index.d.ts +131 -10
- package/src/index.js +105 -4
- package/src/middleware/behavioralGuard.js +115 -20
- 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
|
@@ -82,6 +82,9 @@ export class BehavioralShieldService {
|
|
|
82
82
|
* @returns {string}
|
|
83
83
|
*/
|
|
84
84
|
getClientIP(req) {
|
|
85
|
+
if (this.shield?.ipResolver) {
|
|
86
|
+
return this.shield.ipResolver.resolve(req);
|
|
87
|
+
}
|
|
85
88
|
const forwarded = req.headers['x-forwarded-for'];
|
|
86
89
|
if (forwarded && typeof forwarded === 'string') {
|
|
87
90
|
const first = forwarded.split(',')[0].trim();
|
|
@@ -93,28 +96,61 @@ export class BehavioralShieldService {
|
|
|
93
96
|
/**
|
|
94
97
|
* Express middleware factory
|
|
95
98
|
* @param {Object} [options={}]
|
|
96
|
-
* @param {number} [options.threshold=0.
|
|
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
|
|
97
101
|
* @param {string} [options.headerName='x-shield-telemetry'] Header name for telemetry JSON
|
|
98
102
|
* @param {number} [options.replayWindowMs=15000] Maximum acceptable age/future drift in ms
|
|
99
|
-
* @param {boolean} [options.allowFallbackOnError=false] Fail-open vs fail-closed
|
|
103
|
+
* @param {boolean} [options.allowFallbackOnError=false] Fail-open vs fail-closed on missing/malformed telemetry
|
|
100
104
|
* @param {boolean} [options.autoBlockIP=false] Automatically add detected bot IP to Shield L1 blocklist
|
|
101
|
-
* @param {Function} [options.onBotDetected] Custom callback for bot detection
|
|
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
|
|
102
107
|
*/
|
|
103
108
|
middleware(options = {}) {
|
|
104
109
|
const {
|
|
105
|
-
threshold = 0.
|
|
110
|
+
threshold = 0.50,
|
|
111
|
+
mode = 'block',
|
|
106
112
|
headerName = 'x-shield-telemetry',
|
|
107
113
|
replayWindowMs = 15_000,
|
|
108
114
|
allowFallbackOnError = false,
|
|
109
115
|
autoBlockIP = false,
|
|
110
|
-
onBotDetected
|
|
116
|
+
onBotDetected,
|
|
117
|
+
onVerdict
|
|
111
118
|
} = options;
|
|
112
119
|
|
|
113
120
|
return (req, res, next) => {
|
|
114
121
|
const rawHeader = req.headers[headerName] || req.body?._shieldTelemetry;
|
|
115
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
|
+
|
|
116
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
|
+
|
|
117
152
|
if (allowFallbackOnError) return next();
|
|
153
|
+
handleVerdict({ isHuman: false, reason: 'MISSING_TELEMETRY', mode });
|
|
118
154
|
return this._deny(req, res, 'MISSING_TELEMETRY', onBotDetected);
|
|
119
155
|
}
|
|
120
156
|
|
|
@@ -123,30 +159,63 @@ export class BehavioralShieldService {
|
|
|
123
159
|
|
|
124
160
|
// 1. Synthetic Dispatch Check
|
|
125
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 });
|
|
126
174
|
return this._deny(req, res, 'SYNTHETIC_EVENT_DISPATCH', onBotDetected);
|
|
127
175
|
}
|
|
128
176
|
|
|
129
177
|
// 2. Replay Protection Window Check
|
|
130
178
|
const now = Date.now();
|
|
131
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 });
|
|
132
188
|
return this._deny(req, res, 'EXPIRED_OR_FUTURE_TELEMETRY', onBotDetected);
|
|
133
189
|
}
|
|
134
190
|
|
|
135
191
|
// 3. Payload Schema Validation
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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));
|
|
139
195
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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();
|
|
144
203
|
}
|
|
204
|
+
handleVerdict({ isHuman: false, reason: 'MALFORMED_KINEMATICS_VECTOR', mode });
|
|
205
|
+
return this._deny(req, res, 'MALFORMED_KINEMATICS_VECTOR', onBotDetected);
|
|
145
206
|
}
|
|
146
207
|
|
|
147
208
|
// 4. Coordinate Signature Nonce Check
|
|
148
209
|
const nonce = crypto.createHash('sha256').update(JSON.stringify(payload.velocity)).digest('hex');
|
|
149
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 });
|
|
150
219
|
return this._deny(req, res, 'REPLAY_ATTACK_DETECTED', onBotDetected);
|
|
151
220
|
}
|
|
152
221
|
this.nonceCache.set(nonce, true, replayWindowMs);
|
|
@@ -154,31 +223,52 @@ export class BehavioralShieldService {
|
|
|
154
223
|
// 5. MiniRocket & Dual Ridge Scoring
|
|
155
224
|
const outcome = this.verify(payload.velocity);
|
|
156
225
|
const ip = this.getClientIP(req);
|
|
226
|
+
const isHuman = outcome.probability >= threshold;
|
|
157
227
|
|
|
158
228
|
req.shield = req.shield || {};
|
|
159
229
|
req.shield.biometrics = {
|
|
160
|
-
isHuman
|
|
230
|
+
isHuman,
|
|
161
231
|
humanConfidence: outcome.probability,
|
|
162
232
|
latencyMs: outcome.latencyMs,
|
|
163
233
|
score: outcome.score
|
|
164
234
|
};
|
|
165
235
|
|
|
166
|
-
|
|
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
|
|
167
249
|
if (this.shield) {
|
|
168
250
|
this.shield.recordEvent({
|
|
169
|
-
type: '
|
|
251
|
+
type: isHuman ? 'BIOMETRICS_HUMAN' : (mode === 'block' ? 'BIOMETRICS_BLOCKED' : 'BIOMETRICS_AUDIT'),
|
|
170
252
|
ip,
|
|
171
|
-
isHuman
|
|
253
|
+
isHuman,
|
|
172
254
|
confidence: outcome.probability,
|
|
173
|
-
|
|
255
|
+
score: outcome.score,
|
|
256
|
+
latencyMs: outcome.latencyMs,
|
|
257
|
+
mode
|
|
174
258
|
});
|
|
175
259
|
}
|
|
176
260
|
|
|
177
|
-
if (
|
|
261
|
+
if (mode === 'audit' || mode === 'header-only') {
|
|
262
|
+
return next();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// mode === 'block'
|
|
266
|
+
if (!isHuman) {
|
|
178
267
|
if (autoBlockIP && this.shield) {
|
|
179
268
|
this.shield.blockIP(ip, 'behavioral_bot_detected', {
|
|
180
269
|
confidence: outcome.probability,
|
|
181
|
-
latencyMs: outcome.latencyMs
|
|
270
|
+
latencyMs: outcome.latencyMs,
|
|
271
|
+
score: outcome.score
|
|
182
272
|
});
|
|
183
273
|
}
|
|
184
274
|
return this._deny(req, res, 'AUTOMATED_KINEMATICS_DETECTED', onBotDetected, outcome);
|
|
@@ -186,7 +276,12 @@ export class BehavioralShieldService {
|
|
|
186
276
|
|
|
187
277
|
next();
|
|
188
278
|
} catch (err) {
|
|
189
|
-
if (allowFallbackOnError)
|
|
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
|
+
}
|
|
190
285
|
res.status(400).json({ error: 'Malformed telemetry packet', code: 'MALFORMED_TELEMETRY' });
|
|
191
286
|
}
|
|
192
287
|
};
|
|
@@ -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;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-Memory Storage Adapter
|
|
3
|
+
*
|
|
4
|
+
* Default ephemeral adapter for non-clustered, single-process deployments
|
|
5
|
+
* with zero filesystem or network overhead.
|
|
6
|
+
*
|
|
7
|
+
* @author ChdLabs
|
|
8
|
+
* @license MIT
|
|
9
|
+
*/
|
|
10
|
+
import { BaseStorageAdapter } from './BaseAdapter.js';
|
|
11
|
+
|
|
12
|
+
export class MemoryAdapter extends BaseStorageAdapter {
|
|
13
|
+
constructor() {
|
|
14
|
+
super();
|
|
15
|
+
this.records = new Map();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async init(shield) {
|
|
19
|
+
await super.init(shield);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async loadBlockedIPs() {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const active = [];
|
|
25
|
+
for (const [ip, item] of this.records.entries()) {
|
|
26
|
+
if (item.expiresAt && item.expiresAt <= now) {
|
|
27
|
+
this.records.delete(ip);
|
|
28
|
+
} else {
|
|
29
|
+
active.push(item);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return active;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async saveBlockedIP(ip, reason, metadata = {}, expiresAt = null) {
|
|
36
|
+
if (!ip) return;
|
|
37
|
+
this.records.set(ip, {
|
|
38
|
+
ip,
|
|
39
|
+
reason,
|
|
40
|
+
metadata,
|
|
41
|
+
expiresAt,
|
|
42
|
+
savedAt: Date.now()
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async removeBlockedIP(ip) {
|
|
47
|
+
if (!ip) return;
|
|
48
|
+
this.records.delete(ip);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async close() {
|
|
52
|
+
this.records.clear();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default MemoryAdapter;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Subsystem Exports & Factory
|
|
3
|
+
*
|
|
4
|
+
* @author ChdLabs
|
|
5
|
+
* @license MIT
|
|
6
|
+
*/
|
|
7
|
+
import { BaseStorageAdapter } from './BaseAdapter.js';
|
|
8
|
+
import { MemoryAdapter } from './MemoryAdapter.js';
|
|
9
|
+
import { FileStorageAdapter } from './FileStorageAdapter.js';
|
|
10
|
+
import { CallbackStorageAdapter } from './CallbackStorageAdapter.js';
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
BaseStorageAdapter,
|
|
14
|
+
MemoryAdapter,
|
|
15
|
+
FileStorageAdapter,
|
|
16
|
+
CallbackStorageAdapter
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Instantiate appropriate storage adapter based on options
|
|
21
|
+
* @param {string|Object|BaseStorageAdapter} [storageOption]
|
|
22
|
+
* @param {Object} [logger]
|
|
23
|
+
* @returns {BaseStorageAdapter}
|
|
24
|
+
*/
|
|
25
|
+
export function createStorageAdapter(storageOption, logger = console) {
|
|
26
|
+
if (!storageOption || storageOption === 'memory') {
|
|
27
|
+
return new MemoryAdapter();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (storageOption instanceof BaseStorageAdapter) {
|
|
31
|
+
return storageOption;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (typeof storageOption === 'object') {
|
|
35
|
+
// Callback adapter: has onLoad, onBlock, or onUnblock
|
|
36
|
+
if (storageOption.onLoad || storageOption.onBlock || storageOption.onUnblock) {
|
|
37
|
+
return new CallbackStorageAdapter({ ...storageOption, logger });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// File adapter: has type === 'file' or directory or filename
|
|
41
|
+
if (storageOption.type === 'file' || storageOption.directory || storageOption.filename) {
|
|
42
|
+
return new FileStorageAdapter({ ...storageOption, logger });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (storageOption === 'file') {
|
|
47
|
+
return new FileStorageAdapter({ logger });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return new MemoryAdapter();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default createStorageAdapter;
|