@chdlabs/my-shield 0.1.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/LICENSE +21 -0
- package/README.md +197 -0
- package/package.json +45 -0
- package/src/admin/adminRouter.js +221 -0
- package/src/admin/dashboard/dashboard.css +534 -0
- package/src/admin/dashboard/dashboard.js +610 -0
- package/src/admin/dashboard/index.html +266 -0
- package/src/client/shield-client.js +63 -0
- package/src/core/bloomFilter.js +191 -0
- package/src/core/lruCache.js +178 -0
- package/src/core/ringBuffer.js +87 -0
- package/src/index.d.ts +97 -0
- package/src/index.js +317 -0
- package/src/intelligence/autoPromoter.js +91 -0
- package/src/intelligence/collector.js +219 -0
- package/src/intelligence/filePersistence.js +226 -0
- package/src/middleware/costThrottle.js +75 -0
- package/src/middleware/honeypot.js +215 -0
- package/src/middleware/keyLeakDetector.js +98 -0
- package/src/middleware/notFoundCollector.js +171 -0
- package/src/middleware/shieldGuard.js +93 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static & Dynamic Route Honeypot Traps
|
|
3
|
+
* Catches vulnerability scanners probing for known sensitive paths,
|
|
4
|
+
* silently returning a 200 OK decoy after a brief delay while instantly
|
|
5
|
+
* blacklisting the IP in the Bloom filter and LRU cache.
|
|
6
|
+
*
|
|
7
|
+
* @author ChdLabs
|
|
8
|
+
* @license MIT
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_TRAP_ROUTES = [
|
|
12
|
+
// WordPress / PHP probes
|
|
13
|
+
'/wp-admin/', '/wp-includes/', '/wp-login.php', '/wp-content/',
|
|
14
|
+
'/xmlrpc.php', '/wp-json/',
|
|
15
|
+
|
|
16
|
+
// Environment / config file probes
|
|
17
|
+
'/.env', '/.env.local', '/.env.production',
|
|
18
|
+
'/.git/config', '/.git/HEAD',
|
|
19
|
+
'/.aws/credentials', '/.docker/config.json',
|
|
20
|
+
|
|
21
|
+
// Database dumps
|
|
22
|
+
'/backup.sql', '/db.sql', '/dump.sql', '/database.sql',
|
|
23
|
+
|
|
24
|
+
// Admin panels
|
|
25
|
+
'/phpMyAdmin/', '/phpmyadmin/', '/adminer.php',
|
|
26
|
+
'/server-status', '/server-info',
|
|
27
|
+
|
|
28
|
+
// Debug / actuator endpoints
|
|
29
|
+
'/api/v1/debug', '/api/v1/admin/config',
|
|
30
|
+
'/actuator/health', '/actuator/env',
|
|
31
|
+
'/debug/vars', '/debug/pprof/',
|
|
32
|
+
|
|
33
|
+
// Common exploit paths
|
|
34
|
+
'/cgi-bin/', '/shell', '/cmd', '/console',
|
|
35
|
+
|
|
36
|
+
// Client DOM Tripwire
|
|
37
|
+
'/api/__shield_trap'
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export class Honeypot {
|
|
41
|
+
/**
|
|
42
|
+
* @param {Object} options
|
|
43
|
+
* @param {boolean} [options.enabled=true]
|
|
44
|
+
* @param {Array<string>} [options.additionalRoutes=[]] Custom trap paths
|
|
45
|
+
* @param {number} [options.delayMs=500] Delay before responding with silent 200 OK
|
|
46
|
+
* @param {Object} options.shield Shield core instance
|
|
47
|
+
* @param {Object} [options.logger=console]
|
|
48
|
+
*/
|
|
49
|
+
constructor({
|
|
50
|
+
enabled = true,
|
|
51
|
+
additionalRoutes = [],
|
|
52
|
+
delayMs = 500,
|
|
53
|
+
shield,
|
|
54
|
+
logger = console
|
|
55
|
+
} = {}) {
|
|
56
|
+
this.enabled = enabled;
|
|
57
|
+
this.delayMs = Math.max(0, delayMs);
|
|
58
|
+
this.shield = shield;
|
|
59
|
+
this.logger = logger;
|
|
60
|
+
|
|
61
|
+
// Set of exact match trap routes
|
|
62
|
+
this.exactTraps = new Set();
|
|
63
|
+
// Array of prefix match trap routes (ending with /)
|
|
64
|
+
this.prefixTraps = new Set();
|
|
65
|
+
// Promoted/dynamically added routes tracked with metadata
|
|
66
|
+
this.routeMetadata = new Map();
|
|
67
|
+
|
|
68
|
+
// Register default trap routes
|
|
69
|
+
for (const route of DEFAULT_TRAP_ROUTES) {
|
|
70
|
+
this.addRoute(route, 'builtin');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Register custom trap routes
|
|
74
|
+
if (Array.isArray(additionalRoutes)) {
|
|
75
|
+
for (const route of additionalRoutes) {
|
|
76
|
+
if (typeof route === 'string') {
|
|
77
|
+
this.addRoute(route, 'custom');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Normalize path for matching
|
|
85
|
+
* @param {string} rawPath
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
normalizePath(rawPath) {
|
|
89
|
+
if (!rawPath || typeof rawPath !== 'string') return '/';
|
|
90
|
+
const cleaned = rawPath.split('?')[0].split('#')[0].trim();
|
|
91
|
+
return cleaned.replace(/\/+/g, '/') || '/';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Add a route to the active trap list
|
|
96
|
+
* @param {string} route
|
|
97
|
+
* @param {string} [source='dynamic']
|
|
98
|
+
*/
|
|
99
|
+
addRoute(route, source = 'dynamic') {
|
|
100
|
+
const normalized = this.normalizePath(route);
|
|
101
|
+
if (normalized.endsWith('/') && normalized.length > 1) {
|
|
102
|
+
this.prefixTraps.add(normalized.toLowerCase());
|
|
103
|
+
} else {
|
|
104
|
+
this.exactTraps.add(normalized.toLowerCase());
|
|
105
|
+
}
|
|
106
|
+
this.routeMetadata.set(normalized, {
|
|
107
|
+
source,
|
|
108
|
+
addedAt: new Date().toISOString(),
|
|
109
|
+
hitCount: 0
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Remove a trap route
|
|
115
|
+
* @param {string} route
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
removeRoute(route) {
|
|
119
|
+
const normalized = this.normalizePath(route);
|
|
120
|
+
const lower = normalized.toLowerCase();
|
|
121
|
+
const removed = this.exactTraps.delete(lower) || this.prefixTraps.delete(lower);
|
|
122
|
+
this.routeMetadata.delete(normalized);
|
|
123
|
+
return removed;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Check if a given request path triggers a honeypot trap
|
|
128
|
+
* @param {string} path
|
|
129
|
+
* @returns {boolean}
|
|
130
|
+
*/
|
|
131
|
+
isTrap(path) {
|
|
132
|
+
if (!this.enabled || !path) return false;
|
|
133
|
+
const normalized = this.normalizePath(path).toLowerCase();
|
|
134
|
+
|
|
135
|
+
// Check exact match
|
|
136
|
+
if (this.exactTraps.has(normalized)) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check directory/prefix match
|
|
141
|
+
for (const prefix of this.prefixTraps) {
|
|
142
|
+
if (normalized.startsWith(prefix)) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get list of routes that were auto-promoted or restored
|
|
152
|
+
* @returns {string[]}
|
|
153
|
+
*/
|
|
154
|
+
getPromotedRoutes() {
|
|
155
|
+
const promoted = [];
|
|
156
|
+
for (const [route, meta] of this.routeMetadata.entries()) {
|
|
157
|
+
if (meta.source === 'auto_promoted' || meta.source === 'restored_from_file' || meta.source === 'manual_promotion') {
|
|
158
|
+
promoted.push(route);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return promoted;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Express middleware handler
|
|
166
|
+
*/
|
|
167
|
+
middleware() {
|
|
168
|
+
return (req, res, next) => {
|
|
169
|
+
if (!this.enabled || !this.isTrap(req.path)) {
|
|
170
|
+
return next();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Extract client IP
|
|
174
|
+
const ip = (req.headers['x-forwarded-for']?.split(',')[0]?.trim()) ||
|
|
175
|
+
req.socket?.remoteAddress ||
|
|
176
|
+
req.ip ||
|
|
177
|
+
'127.0.0.1';
|
|
178
|
+
|
|
179
|
+
const matchedPath = this.normalizePath(req.path);
|
|
180
|
+
const meta = this.routeMetadata.get(matchedPath);
|
|
181
|
+
if (meta) {
|
|
182
|
+
meta.hitCount = (meta.hitCount || 0) + 1;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 1. Immediately block IP in Bloom filter and LRU cache
|
|
186
|
+
if (this.shield) {
|
|
187
|
+
this.shield.blockIP(ip, 'honeypot_trap', { path: matchedPath });
|
|
188
|
+
this.shield.recordEvent({
|
|
189
|
+
type: 'honeypot',
|
|
190
|
+
ip,
|
|
191
|
+
path: matchedPath,
|
|
192
|
+
method: req.method,
|
|
193
|
+
userAgent: req.headers['user-agent'] || '',
|
|
194
|
+
timestamp: Date.now()
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this.logger.warn?.(`[SHIELD] Honeypot triggered by ${ip} on path "${matchedPath}" -> IP Blacklisted`);
|
|
199
|
+
|
|
200
|
+
// 2. Delayed Silent 200 OK decoy response
|
|
201
|
+
const respond = () => {
|
|
202
|
+
if (res.headersSent) return;
|
|
203
|
+
res.status(200)
|
|
204
|
+
.set('Content-Type', 'text/html')
|
|
205
|
+
.send('<html><head><title>Loading...</title></head><body></body></html>');
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
if (this.delayMs > 0) {
|
|
209
|
+
setTimeout(respond, this.delayMs);
|
|
210
|
+
} else {
|
|
211
|
+
respond();
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Key / Token Leak Detector Middleware
|
|
3
|
+
* Identifies tokens (e.g. JWT/PQC jti claims) used concurrently
|
|
4
|
+
* across multiple distinct IP addresses within a short sliding window.
|
|
5
|
+
*
|
|
6
|
+
* @author ChdLabs
|
|
7
|
+
* @license MIT
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export class KeyLeakDetector {
|
|
11
|
+
/**
|
|
12
|
+
* @param {Object} options
|
|
13
|
+
* @param {number} [options.windowMs=300000] Window to monitor (default 5 min)
|
|
14
|
+
* @param {number} [options.maxIpCount=2] Max concurrent IPs before flagging
|
|
15
|
+
* @param {Object} options.shield Shield core instance
|
|
16
|
+
* @param {Object} [options.logger=console]
|
|
17
|
+
*/
|
|
18
|
+
constructor({
|
|
19
|
+
windowMs = 5 * 60 * 1000,
|
|
20
|
+
maxIpCount = 2,
|
|
21
|
+
shield,
|
|
22
|
+
logger = console
|
|
23
|
+
} = {}) {
|
|
24
|
+
this.windowMs = windowMs;
|
|
25
|
+
this.maxIpCount = maxIpCount;
|
|
26
|
+
this.shield = shield;
|
|
27
|
+
this.logger = logger;
|
|
28
|
+
|
|
29
|
+
// jti -> { ips: Set<string>, firstSeen: number }
|
|
30
|
+
this.jtiTracker = new Map();
|
|
31
|
+
|
|
32
|
+
this.cleanupTimer = setInterval(() => {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
for (const [jti, rec] of this.jtiTracker.entries()) {
|
|
35
|
+
if (now - rec.firstSeen > this.windowMs) {
|
|
36
|
+
this.jtiTracker.delete(jti);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}, 60_000);
|
|
40
|
+
|
|
41
|
+
if (typeof this.cleanupTimer.unref === 'function') {
|
|
42
|
+
this.cleanupTimer.unref();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Middleware to inspect authenticated requests
|
|
48
|
+
* (Expects req.user?.jti or req.token?.jti to be populated by auth middleware)
|
|
49
|
+
*/
|
|
50
|
+
middleware() {
|
|
51
|
+
return (req, res, next) => {
|
|
52
|
+
const jti = req.user?.jti || req.token?.jti || req.user?.id;
|
|
53
|
+
if (!jti) {
|
|
54
|
+
return next();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ip = (req.headers['x-forwarded-for']?.split(',')[0]?.trim()) ||
|
|
58
|
+
req.socket?.remoteAddress ||
|
|
59
|
+
req.ip ||
|
|
60
|
+
'127.0.0.1';
|
|
61
|
+
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
let rec = this.jtiTracker.get(jti);
|
|
64
|
+
|
|
65
|
+
if (!rec || (now - rec.firstSeen > this.windowMs)) {
|
|
66
|
+
rec = { ips: new Set([ip]), firstSeen: now };
|
|
67
|
+
this.jtiTracker.set(jti, rec);
|
|
68
|
+
} else {
|
|
69
|
+
rec.ips.add(ip);
|
|
70
|
+
|
|
71
|
+
if (rec.ips.size > this.maxIpCount) {
|
|
72
|
+
const ipList = Array.from(rec.ips);
|
|
73
|
+
this.logger.warn?.(`[SHIELD] Security alert: Token ${jti} used from ${rec.ips.size} distinct IPs: ${ipList.join(', ')}`);
|
|
74
|
+
|
|
75
|
+
if (this.shield) {
|
|
76
|
+
this.shield.recordEvent({
|
|
77
|
+
type: 'key_leak_detected',
|
|
78
|
+
jti,
|
|
79
|
+
ips: ipList,
|
|
80
|
+
path: req.path,
|
|
81
|
+
timestamp: now
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
next();
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
destroy() {
|
|
92
|
+
if (this.cleanupTimer) {
|
|
93
|
+
clearInterval(this.cleanupTimer);
|
|
94
|
+
this.cleanupTimer = null;
|
|
95
|
+
}
|
|
96
|
+
this.jtiTracker.clear();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 404 Intelligence Collector & Anomaly Detector
|
|
3
|
+
* Captures all 404 status codes into the intelligence engine.
|
|
4
|
+
* Automatically bans IPs that probe >= 5 unique 404 paths within a 60-second window.
|
|
5
|
+
*
|
|
6
|
+
* @author ChdLabs
|
|
7
|
+
* @license MIT
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export class NotFoundCollector {
|
|
11
|
+
/**
|
|
12
|
+
* @param {Object} options
|
|
13
|
+
* @param {number} [options.anomalyThreshold=5] Unique 404s before IP is banned
|
|
14
|
+
* @param {number} [options.anomalyWindowMs=60000] Sliding window duration in ms
|
|
15
|
+
* @param {boolean} [options.captureAll=true]
|
|
16
|
+
* @param {Object} options.collector IntelligenceCollector instance
|
|
17
|
+
* @param {Object} options.shield Shield core instance
|
|
18
|
+
* @param {Object} [options.logger=console]
|
|
19
|
+
*/
|
|
20
|
+
constructor({
|
|
21
|
+
anomalyThreshold = 5,
|
|
22
|
+
anomalyWindowMs = 60_000,
|
|
23
|
+
captureAll = true,
|
|
24
|
+
collector,
|
|
25
|
+
shield,
|
|
26
|
+
logger = console
|
|
27
|
+
} = {}) {
|
|
28
|
+
this.anomalyThreshold = Math.max(1, anomalyThreshold);
|
|
29
|
+
this.anomalyWindowMs = Math.max(1000, anomalyWindowMs);
|
|
30
|
+
this.captureAll = captureAll;
|
|
31
|
+
this.collector = collector;
|
|
32
|
+
this.shield = shield;
|
|
33
|
+
this.logger = logger;
|
|
34
|
+
|
|
35
|
+
// Sliding window tracker: ip -> { paths: Set<string>, firstSeen: number }
|
|
36
|
+
this.ipWindows = new Map();
|
|
37
|
+
|
|
38
|
+
// Periodic pruning of expired IP windows every 60 seconds
|
|
39
|
+
this.cleanupTimer = setInterval(() => {
|
|
40
|
+
this.pruneWindows();
|
|
41
|
+
}, 60_000);
|
|
42
|
+
|
|
43
|
+
if (typeof this.cleanupTimer.unref === 'function') {
|
|
44
|
+
this.cleanupTimer.unref();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Prune expired sliding windows to keep memory strictly bounded
|
|
50
|
+
*/
|
|
51
|
+
pruneWindows() {
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
for (const [ip, window] of this.ipWindows.entries()) {
|
|
54
|
+
if (now - window.firstSeen > this.anomalyWindowMs) {
|
|
55
|
+
this.ipWindows.delete(ip);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Handle a detected 404 event
|
|
62
|
+
* @param {Object} req Express request
|
|
63
|
+
*/
|
|
64
|
+
handle404(req) {
|
|
65
|
+
const ip = (req.headers['x-forwarded-for']?.split(',')[0]?.trim()) ||
|
|
66
|
+
req.socket?.remoteAddress ||
|
|
67
|
+
req.ip ||
|
|
68
|
+
'127.0.0.1';
|
|
69
|
+
|
|
70
|
+
const path = req.path || req.originalUrl || '/';
|
|
71
|
+
const userAgent = req.headers['user-agent'] || '';
|
|
72
|
+
|
|
73
|
+
// If IP is whitelisted, skip anomaly banning
|
|
74
|
+
const isWhitelisted = this.shield?.isWhitelisted(ip);
|
|
75
|
+
|
|
76
|
+
// 1. Record in 404 intelligence collector
|
|
77
|
+
if (this.captureAll && this.collector) {
|
|
78
|
+
this.collector.record404({ path, ip, userAgent });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (isWhitelisted) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 2. Sliding window anomaly evaluation
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
let window = this.ipWindows.get(ip);
|
|
88
|
+
|
|
89
|
+
if (!window || (now - window.firstSeen > this.anomalyWindowMs)) {
|
|
90
|
+
window = {
|
|
91
|
+
paths: new Set([path]),
|
|
92
|
+
firstSeen: now
|
|
93
|
+
};
|
|
94
|
+
this.ipWindows.set(ip, window);
|
|
95
|
+
} else {
|
|
96
|
+
window.paths.add(path);
|
|
97
|
+
|
|
98
|
+
// Anomaly threshold reached!
|
|
99
|
+
if (window.paths.size >= this.anomalyThreshold) {
|
|
100
|
+
const uniqueCount = window.paths.size;
|
|
101
|
+
const pathList = Array.from(window.paths);
|
|
102
|
+
|
|
103
|
+
this.logger.warn?.(`[SHIELD] 404 Anomaly threshold reached for IP ${ip} (${uniqueCount} unique paths in ${Math.round((now - window.firstSeen) / 1000)}s) -> Blacklisting IP`);
|
|
104
|
+
|
|
105
|
+
if (this.shield) {
|
|
106
|
+
this.shield.blockIP(ip, '404_anomaly', {
|
|
107
|
+
threshold: this.anomalyThreshold,
|
|
108
|
+
uniquePathsCount: uniqueCount,
|
|
109
|
+
paths: pathList
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
this.shield.recordEvent({
|
|
113
|
+
type: '404_anomaly',
|
|
114
|
+
ip,
|
|
115
|
+
path,
|
|
116
|
+
uniqueCount,
|
|
117
|
+
paths: pathList,
|
|
118
|
+
userAgent,
|
|
119
|
+
timestamp: now
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Reset window for this banned IP
|
|
124
|
+
this.ipWindows.delete(ip);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Express middleware
|
|
131
|
+
*/
|
|
132
|
+
middleware() {
|
|
133
|
+
return (req, res, next) => {
|
|
134
|
+
let handled = false;
|
|
135
|
+
|
|
136
|
+
// Hook into response finish event to detect any 404 response
|
|
137
|
+
res.on('finish', () => {
|
|
138
|
+
if (res.statusCode === 404 && !handled) {
|
|
139
|
+
handled = true;
|
|
140
|
+
this.handle404(req);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// If an API route reaches here without being handled by app routes, send a 404 response
|
|
145
|
+
// Non-API GET requests pass through to the SPA fallback
|
|
146
|
+
if (req.path.startsWith('/api') || req.method !== 'GET') {
|
|
147
|
+
if (!res.headersSent) {
|
|
148
|
+
handled = true;
|
|
149
|
+
this.handle404(req);
|
|
150
|
+
return res.status(404).json({
|
|
151
|
+
error: 'Not Found',
|
|
152
|
+
path: req.path
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
next();
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Cleanup timer on shutdown
|
|
163
|
+
*/
|
|
164
|
+
destroy() {
|
|
165
|
+
if (this.cleanupTimer) {
|
|
166
|
+
clearInterval(this.cleanupTimer);
|
|
167
|
+
this.cleanupTimer = null;
|
|
168
|
+
}
|
|
169
|
+
this.ipWindows.clear();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1 Shield Guard Middleware
|
|
3
|
+
* High-performance dual-tier IP reputation gatekeeper:
|
|
4
|
+
* 1. Check Whitelist (instant pass)
|
|
5
|
+
* 2. Bloom Filter Lookup (< 0.1ms; if false -> instant pass)
|
|
6
|
+
* 3. LRU Cache Lookup (confirms whether bloom positive is a real threat)
|
|
7
|
+
*
|
|
8
|
+
* @author ChdLabs
|
|
9
|
+
* @license MIT
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export class ShieldGuard {
|
|
13
|
+
/**
|
|
14
|
+
* @param {Object} options
|
|
15
|
+
* @param {Object} options.bloom BloomFilter instance
|
|
16
|
+
* @param {Object} options.lru LRUCache instance
|
|
17
|
+
* @param {Object} options.shield Shield core instance
|
|
18
|
+
* @param {number} [options.blockStatusCode=403]
|
|
19
|
+
* @param {Object} [options.logger=console]
|
|
20
|
+
*/
|
|
21
|
+
constructor({
|
|
22
|
+
bloom,
|
|
23
|
+
lru,
|
|
24
|
+
shield,
|
|
25
|
+
blockStatusCode = 403,
|
|
26
|
+
logger = console
|
|
27
|
+
} = {}) {
|
|
28
|
+
this.bloom = bloom;
|
|
29
|
+
this.lru = lru;
|
|
30
|
+
this.shield = shield;
|
|
31
|
+
this.blockStatusCode = blockStatusCode;
|
|
32
|
+
this.logger = logger;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract client IP safely from headers or socket
|
|
37
|
+
* @param {Object} req
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
getClientIP(req) {
|
|
41
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
42
|
+
if (forwarded && typeof forwarded === 'string') {
|
|
43
|
+
const first = forwarded.split(',')[0].trim();
|
|
44
|
+
if (first) return first;
|
|
45
|
+
}
|
|
46
|
+
return req.socket?.remoteAddress || req.ip || '127.0.0.1';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Express middleware
|
|
51
|
+
*/
|
|
52
|
+
middleware() {
|
|
53
|
+
return (req, res, next) => {
|
|
54
|
+
const ip = this.getClientIP(req);
|
|
55
|
+
|
|
56
|
+
// 1. Whitelist Bypass
|
|
57
|
+
if (this.shield?.isWhitelisted(ip)) {
|
|
58
|
+
return next();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 2. L1 Bloom Filter Check (< 0.1ms)
|
|
62
|
+
// If false -> IP is definitely clean, pass immediately!
|
|
63
|
+
if (!this.bloom.has(ip)) {
|
|
64
|
+
return next();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 3. Bloom returned true: Check LRU cache to verify it's not a statistical false positive
|
|
68
|
+
const threat = this.lru.get(ip);
|
|
69
|
+
if (!threat) {
|
|
70
|
+
// False positive in Bloom filter (or threat expired from LRU) -> Pass request!
|
|
71
|
+
return next();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Confirmed threat!
|
|
75
|
+
this.shield?.recordBlock();
|
|
76
|
+
this.shield?.recordEvent({
|
|
77
|
+
type: 'blocked_request',
|
|
78
|
+
ip,
|
|
79
|
+
path: req.path,
|
|
80
|
+
method: req.method,
|
|
81
|
+
reason: threat.reason || 'blacklist',
|
|
82
|
+
timestamp: Date.now()
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
this.logger.warn?.(`[SHIELD] Blocked request from ${ip} to ${req.method} ${req.path} (${threat.reason || 'blacklist'})`);
|
|
86
|
+
|
|
87
|
+
res.status(this.blockStatusCode).json({
|
|
88
|
+
error: 'Access denied by security shield',
|
|
89
|
+
code: 'SHIELD_BLOCKED'
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|