@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,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-Promoter Engine
|
|
3
|
+
* Evaluates 404 intelligence data and automatically promotes frequently attacked paths
|
|
4
|
+
* to active honeypot traps, closing the self-improvement loop.
|
|
5
|
+
*
|
|
6
|
+
* @author ChdLabs
|
|
7
|
+
* @license MIT
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export class AutoPromoter {
|
|
11
|
+
/**
|
|
12
|
+
* @param {Object} options
|
|
13
|
+
* @param {boolean} [options.enabled=true]
|
|
14
|
+
* @param {number} [options.minHits=50] Minimum 404 hits before promotion
|
|
15
|
+
* @param {number} [options.minUniqueIPs=10] Minimum distinct IP addresses probing this path
|
|
16
|
+
* @param {Object} options.collector IntelligenceCollector instance
|
|
17
|
+
* @param {Object} options.honeypot Honeypot instance
|
|
18
|
+
* @param {Object} [options.logger=console]
|
|
19
|
+
*/
|
|
20
|
+
constructor({
|
|
21
|
+
enabled = true,
|
|
22
|
+
minHits = 50,
|
|
23
|
+
minUniqueIPs = 10,
|
|
24
|
+
collector,
|
|
25
|
+
honeypot,
|
|
26
|
+
logger = console
|
|
27
|
+
} = {}) {
|
|
28
|
+
this.enabled = enabled;
|
|
29
|
+
this.minHits = minHits;
|
|
30
|
+
this.minUniqueIPs = minUniqueIPs;
|
|
31
|
+
this.collector = collector;
|
|
32
|
+
this.honeypot = honeypot;
|
|
33
|
+
this.logger = logger;
|
|
34
|
+
this.promotedHistory = new Set();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Scan current intelligence paths and promote qualifiers to honeypot
|
|
39
|
+
* @returns {string[]} List of newly promoted path strings
|
|
40
|
+
*/
|
|
41
|
+
evaluate() {
|
|
42
|
+
if (!this.enabled || !this.collector || !this.honeypot) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const newlyPromoted = [];
|
|
47
|
+
|
|
48
|
+
for (const [path, entry] of this.collector.paths.entries()) {
|
|
49
|
+
if (entry.autoPromoted || this.promotedHistory.has(path)) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const hitCount = entry.count || 0;
|
|
54
|
+
const uniqueIpCount = entry.uniqueIPs ? entry.uniqueIPs.size : 0;
|
|
55
|
+
|
|
56
|
+
if (hitCount >= this.minHits && uniqueIpCount >= this.minUniqueIPs) {
|
|
57
|
+
entry.autoPromoted = true;
|
|
58
|
+
this.promotedHistory.add(path);
|
|
59
|
+
this.honeypot.addRoute(path, 'auto_promoted');
|
|
60
|
+
newlyPromoted.push(path);
|
|
61
|
+
|
|
62
|
+
if (this.logger && typeof this.logger.info === 'function') {
|
|
63
|
+
this.logger.info(`[SHIELD] Auto-promoted path "${path}" to honeypot (${hitCount} hits from ${uniqueIpCount} IPs)`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return newlyPromoted;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Force-promote a specific path manually
|
|
73
|
+
* @param {string} path
|
|
74
|
+
*/
|
|
75
|
+
promotePath(path) {
|
|
76
|
+
if (!path || typeof path !== 'string') return false;
|
|
77
|
+
const normalized = this.collector ? this.collector.normalizePath(path) : path;
|
|
78
|
+
this.promotedHistory.add(normalized);
|
|
79
|
+
if (this.collector && this.collector.paths.has(normalized)) {
|
|
80
|
+
this.collector.paths.get(normalized).autoPromoted = true;
|
|
81
|
+
}
|
|
82
|
+
if (this.honeypot) {
|
|
83
|
+
this.honeypot.addRoute(normalized, 'manual_promotion');
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
getPromotedPaths() {
|
|
89
|
+
return Array.from(this.promotedHistory);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 404 Intelligence Collector
|
|
3
|
+
* Deduplicates and aggregates 404 path occurrences, tracks scanner user-agents,
|
|
4
|
+
* and maintains statistical insights on probing patterns.
|
|
5
|
+
*
|
|
6
|
+
* @author ChdLabs
|
|
7
|
+
* @license MIT
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DEFAULT_EXCLUDE_PATTERNS = [
|
|
11
|
+
/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map)$/i,
|
|
12
|
+
/^\/favicon\.ico$/i,
|
|
13
|
+
/^\/robots\.txt$/i,
|
|
14
|
+
/^\/sitemap\.xml$/i,
|
|
15
|
+
/^\/apple-touch-icon/i,
|
|
16
|
+
/^\/manifest\.json$/i
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export class IntelligenceCollector {
|
|
20
|
+
/**
|
|
21
|
+
* @param {Object} options
|
|
22
|
+
* @param {number} [options.maxUniquePaths=50000] Maximum unique paths to store before pruning
|
|
23
|
+
* @param {Array<RegExp>} [options.excludePatterns] Patterns to ignore (e.g. static assets)
|
|
24
|
+
*/
|
|
25
|
+
constructor({ maxUniquePaths = 50_000, excludePatterns = [] } = {}) {
|
|
26
|
+
this.maxUniquePaths = Math.max(100, maxUniquePaths);
|
|
27
|
+
this.excludePatterns = [...DEFAULT_EXCLUDE_PATTERNS, ...excludePatterns];
|
|
28
|
+
this.paths = new Map(); // path -> { count, firstSeen, lastSeen, uniqueIPs: Set<string>, autoPromoted: boolean }
|
|
29
|
+
this.scannerFingerprints = new Map(); // userAgent -> count
|
|
30
|
+
this.startedAt = new Date().toISOString();
|
|
31
|
+
this.totalHits = 0;
|
|
32
|
+
this.totalBlocked = 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalize URL path: strip query params, fragments, duplicate slashes
|
|
37
|
+
* @param {string} rawPath
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
normalizePath(rawPath) {
|
|
41
|
+
if (!rawPath || typeof rawPath !== 'string') return '/';
|
|
42
|
+
const cleaned = rawPath.split('?')[0].split('#')[0].trim();
|
|
43
|
+
// Replace multiple consecutive slashes with single slash, except root
|
|
44
|
+
return cleaned.replace(/\/+/g, '/') || '/';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Test if a path should be excluded from intelligence gathering
|
|
49
|
+
* @param {string} path
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
isExcluded(path) {
|
|
53
|
+
return this.excludePatterns.some(regex => regex.test(path));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Record a 404 occurrence
|
|
58
|
+
* @param {Object} event
|
|
59
|
+
* @param {string} event.path
|
|
60
|
+
* @param {string} [event.ip]
|
|
61
|
+
* @param {string} [event.userAgent]
|
|
62
|
+
* @returns {boolean} true if recorded, false if excluded
|
|
63
|
+
*/
|
|
64
|
+
record404({ path, ip = '127.0.0.1', userAgent = '' }) {
|
|
65
|
+
const normalized = this.normalizePath(path);
|
|
66
|
+
if (this.isExcluded(normalized)) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const now = new Date().toISOString();
|
|
71
|
+
this.totalHits++;
|
|
72
|
+
|
|
73
|
+
let entry = this.paths.get(normalized);
|
|
74
|
+
if (!entry) {
|
|
75
|
+
// Memory guard: prune least recently updated path if capacity reached
|
|
76
|
+
if (this.paths.size >= this.maxUniquePaths) {
|
|
77
|
+
const oldestPath = this.paths.keys().next().value;
|
|
78
|
+
if (oldestPath !== undefined) {
|
|
79
|
+
this.paths.delete(oldestPath);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
entry = {
|
|
84
|
+
count: 1,
|
|
85
|
+
firstSeen: now,
|
|
86
|
+
lastSeen: now,
|
|
87
|
+
uniqueIPs: new Set([ip]),
|
|
88
|
+
autoPromoted: false
|
|
89
|
+
};
|
|
90
|
+
this.paths.set(normalized, entry);
|
|
91
|
+
} else {
|
|
92
|
+
entry.count++;
|
|
93
|
+
entry.lastSeen = now;
|
|
94
|
+
// Cap Set to 500 IPs per path to avoid memory ballooning on massive botnet targets
|
|
95
|
+
if (entry.uniqueIPs.size < 500) {
|
|
96
|
+
entry.uniqueIPs.add(ip);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Track scanner fingerprint if present
|
|
101
|
+
if (userAgent && typeof userAgent === 'string') {
|
|
102
|
+
const trimmedUA = userAgent.slice(0, 150).trim();
|
|
103
|
+
if (trimmedUA) {
|
|
104
|
+
const count = this.scannerFingerprints.get(trimmedUA) || 0;
|
|
105
|
+
if (this.scannerFingerprints.size < 1000 || this.scannerFingerprints.has(trimmedUA)) {
|
|
106
|
+
this.scannerFingerprints.set(trimmedUA, count + 1);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Increment total blocked threats counter
|
|
116
|
+
*/
|
|
117
|
+
recordBlock() {
|
|
118
|
+
this.totalBlocked++;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Export serializable snapshot matching the approved 404_intelligence.json schema
|
|
123
|
+
* @param {Array<string>} [promotedPaths=[]] Current list of promoted honeypot paths
|
|
124
|
+
* @returns {Object}
|
|
125
|
+
*/
|
|
126
|
+
getSnapshot(promotedPaths = []) {
|
|
127
|
+
const pathsObj = {};
|
|
128
|
+
const allUniqueIPs = new Set();
|
|
129
|
+
|
|
130
|
+
for (const [p, data] of this.paths.entries()) {
|
|
131
|
+
data.uniqueIPs.forEach(ip => allUniqueIPs.add(ip));
|
|
132
|
+
pathsObj[p] = {
|
|
133
|
+
count: data.count,
|
|
134
|
+
firstSeen: data.firstSeen,
|
|
135
|
+
lastSeen: data.lastSeen,
|
|
136
|
+
uniqueIPs: data.uniqueIPs.size,
|
|
137
|
+
autoPromoted: !!data.autoPromoted
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const fingerprintsObj = {};
|
|
142
|
+
// Top 50 fingerprints sorted by count
|
|
143
|
+
const sortedFingerprints = Array.from(this.scannerFingerprints.entries())
|
|
144
|
+
.sort((a, b) => b[1] - a[1])
|
|
145
|
+
.slice(0, 50);
|
|
146
|
+
for (const [ua, count] of sortedFingerprints) {
|
|
147
|
+
fingerprintsObj[ua] = count;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
version: 1,
|
|
152
|
+
lastSaved: new Date().toISOString(),
|
|
153
|
+
startedAt: this.startedAt,
|
|
154
|
+
summary: {
|
|
155
|
+
totalHits: this.totalHits,
|
|
156
|
+
uniquePaths: this.paths.size,
|
|
157
|
+
uniqueIPs: allUniqueIPs.size,
|
|
158
|
+
totalBlocked: this.totalBlocked
|
|
159
|
+
},
|
|
160
|
+
paths: pathsObj,
|
|
161
|
+
promotedPaths: Array.from(new Set(promotedPaths)),
|
|
162
|
+
scannerFingerprints: fingerprintsObj
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Restore state from a previously saved snapshot
|
|
168
|
+
* @param {Object} snapshot
|
|
169
|
+
* @returns {number} Number of restored paths
|
|
170
|
+
*/
|
|
171
|
+
restoreFromSnapshot(snapshot) {
|
|
172
|
+
if (!snapshot || typeof snapshot !== 'object') return 0;
|
|
173
|
+
|
|
174
|
+
if (snapshot.startedAt) {
|
|
175
|
+
this.startedAt = snapshot.startedAt;
|
|
176
|
+
}
|
|
177
|
+
if (snapshot.summary) {
|
|
178
|
+
this.totalHits = Number(snapshot.summary.totalHits) || 0;
|
|
179
|
+
this.totalBlocked = Number(snapshot.summary.totalBlocked) || 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let restoredPaths = 0;
|
|
183
|
+
if (snapshot.paths && typeof snapshot.paths === 'object') {
|
|
184
|
+
for (const [p, data] of Object.entries(snapshot.paths)) {
|
|
185
|
+
if (restoredPaths >= this.maxUniquePaths) break;
|
|
186
|
+
this.paths.set(p, {
|
|
187
|
+
count: Number(data.count) || 1,
|
|
188
|
+
firstSeen: data.firstSeen || new Date().toISOString(),
|
|
189
|
+
lastSeen: data.lastSeen || new Date().toISOString(),
|
|
190
|
+
uniqueIPs: new Set(new Array(Math.min(50, Number(data.uniqueIPs) || 1)).fill('restored')),
|
|
191
|
+
autoPromoted: !!data.autoPromoted
|
|
192
|
+
});
|
|
193
|
+
restoredPaths++;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (snapshot.scannerFingerprints && typeof snapshot.scannerFingerprints === 'object') {
|
|
198
|
+
for (const [ua, count] of Object.entries(snapshot.scannerFingerprints)) {
|
|
199
|
+
this.scannerFingerprints.set(ua, Number(count) || 1);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return restoredPaths;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Diagnostic and administrative stats
|
|
208
|
+
*/
|
|
209
|
+
getStats() {
|
|
210
|
+
return {
|
|
211
|
+
totalHits: this.totalHits,
|
|
212
|
+
uniquePaths: this.paths.size,
|
|
213
|
+
maxUniquePaths: this.maxUniquePaths,
|
|
214
|
+
totalBlocked: this.totalBlocked,
|
|
215
|
+
fingerprintCount: this.scannerFingerprints.size,
|
|
216
|
+
startedAt: this.startedAt
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-File Deduplicated Persistence for 404 Intelligence
|
|
3
|
+
* Atomically writes intelligence snapshots to data/shield/404_intelligence.json
|
|
4
|
+
* and restores learned patterns upon application startup.
|
|
5
|
+
*
|
|
6
|
+
* @author ChdLabs
|
|
7
|
+
* @license MIT
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
|
|
12
|
+
export class FilePersistence {
|
|
13
|
+
/**
|
|
14
|
+
* @param {Object} options
|
|
15
|
+
* @param {boolean} [options.enabled=true]
|
|
16
|
+
* @param {string} [options.directory='./data/shield'] Relative to process.cwd() or absolute
|
|
17
|
+
* @param {string} [options.filename='404_intelligence.json']
|
|
18
|
+
* @param {number} [options.intervalMs=3600000] Save interval in ms (default: 1 hour)
|
|
19
|
+
* @param {boolean} [options.loadOnStartup=true] Read file on startup to restore state
|
|
20
|
+
* @param {Object} options.collector IntelligenceCollector instance
|
|
21
|
+
* @param {Object} options.honeypot Honeypot instance
|
|
22
|
+
* @param {Object} [options.autoPromoter] AutoPromoter instance
|
|
23
|
+
* @param {Object} [options.logger=console]
|
|
24
|
+
*/
|
|
25
|
+
constructor({
|
|
26
|
+
enabled = true,
|
|
27
|
+
directory = './data/shield',
|
|
28
|
+
filename = '404_intelligence.json',
|
|
29
|
+
intervalMs = 60 * 60 * 1000,
|
|
30
|
+
loadOnStartup = true,
|
|
31
|
+
collector,
|
|
32
|
+
honeypot,
|
|
33
|
+
autoPromoter = null,
|
|
34
|
+
logger = console
|
|
35
|
+
} = {}) {
|
|
36
|
+
this.enabled = enabled;
|
|
37
|
+
this.intervalMs = Math.max(5000, intervalMs);
|
|
38
|
+
this.loadOnStartup = loadOnStartup;
|
|
39
|
+
this.collector = collector;
|
|
40
|
+
this.honeypot = honeypot;
|
|
41
|
+
this.autoPromoter = autoPromoter;
|
|
42
|
+
this.logger = logger;
|
|
43
|
+
this.timer = null;
|
|
44
|
+
this.lastSaved = null;
|
|
45
|
+
this.lastError = null;
|
|
46
|
+
|
|
47
|
+
// Secure path resolution & sandboxing against path traversal (CWE-22)
|
|
48
|
+
const baseDir = path.resolve(process.cwd(), directory);
|
|
49
|
+
const safeFilename = path.basename(filename || '404_intelligence.json');
|
|
50
|
+
const targetPath = path.resolve(baseDir, safeFilename);
|
|
51
|
+
|
|
52
|
+
// Verify directory boundary with trailing separator
|
|
53
|
+
const sandboxedBoundary = baseDir.endsWith(path.sep) ? baseDir : baseDir + path.sep;
|
|
54
|
+
if (!targetPath.startsWith(sandboxedBoundary) && targetPath !== baseDir) {
|
|
55
|
+
throw new Error(`[SHIELD SECURITY] Target path "${targetPath}" escapes permitted directory "${baseDir}"`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.baseDir = baseDir;
|
|
59
|
+
this.filePath = targetPath;
|
|
60
|
+
this.tempFilePath = `${targetPath}.tmp`;
|
|
61
|
+
|
|
62
|
+
// Ensure directory exists
|
|
63
|
+
if (this.enabled && !fs.existsSync(this.baseDir)) {
|
|
64
|
+
try {
|
|
65
|
+
fs.mkdirSync(this.baseDir, { recursive: true });
|
|
66
|
+
} catch (err) {
|
|
67
|
+
this.logger.warn?.(`[SHIELD] Warning: Could not create directory ${this.baseDir}: ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read and restore snapshot from disk on startup
|
|
74
|
+
* @returns {boolean} true if state was restored
|
|
75
|
+
*/
|
|
76
|
+
load() {
|
|
77
|
+
if (!this.enabled || !this.loadOnStartup) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
if (!fs.existsSync(this.filePath)) {
|
|
83
|
+
this.logger.info?.(`[SHIELD] No previous 404 intelligence file found at "${this.filePath}". Starting fresh.`);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const rawData = fs.readFileSync(this.filePath, 'utf8');
|
|
88
|
+
const parsed = JSON.parse(rawData);
|
|
89
|
+
|
|
90
|
+
// Restore collector paths & fingerprints
|
|
91
|
+
const restoredCount = this.collector.restoreFromSnapshot(parsed);
|
|
92
|
+
|
|
93
|
+
// Restore promoted paths into honeypot
|
|
94
|
+
let restoredTraps = 0;
|
|
95
|
+
if (parsed.promotedPaths && Array.isArray(parsed.promotedPaths)) {
|
|
96
|
+
for (const route of parsed.promotedPaths) {
|
|
97
|
+
if (this.honeypot && typeof route === 'string') {
|
|
98
|
+
this.honeypot.addRoute(route, 'restored_from_file');
|
|
99
|
+
restoredTraps++;
|
|
100
|
+
}
|
|
101
|
+
if (this.autoPromoter) {
|
|
102
|
+
this.autoPromoter.promotedHistory.add(route);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.logger.info?.(`[SHIELD] Restored ${restoredCount} intelligence paths and ${restoredTraps} promoted traps from "${this.filePath}"`);
|
|
108
|
+
return true;
|
|
109
|
+
} catch (err) {
|
|
110
|
+
this.lastError = err.message;
|
|
111
|
+
this.logger.warn?.(`[SHIELD] Warning: Failed to parse 404 intelligence file (${err.message}). Starting with clean state.`);
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Atomically save intelligence snapshot to disk
|
|
118
|
+
* @returns {boolean} true if successfully written
|
|
119
|
+
*/
|
|
120
|
+
save() {
|
|
121
|
+
if (!this.enabled || !this.collector) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
// First run auto-promoter evaluation to capture newly qualified traps
|
|
127
|
+
if (this.autoPromoter) {
|
|
128
|
+
this.autoPromoter.evaluate();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const promotedPaths = this.honeypot ? this.honeypot.getPromotedRoutes() : [];
|
|
132
|
+
const snapshot = this.collector.getSnapshot(promotedPaths);
|
|
133
|
+
const serialized = JSON.stringify(snapshot, null, 2);
|
|
134
|
+
|
|
135
|
+
// Ensure destination directory exists
|
|
136
|
+
if (!fs.existsSync(this.baseDir)) {
|
|
137
|
+
fs.mkdirSync(this.baseDir, { recursive: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Atomic write: write to temp file then rename
|
|
141
|
+
fs.writeFileSync(this.tempFilePath, serialized, 'utf8');
|
|
142
|
+
fs.renameSync(this.tempFilePath, this.filePath);
|
|
143
|
+
|
|
144
|
+
this.lastSaved = new Date().toISOString();
|
|
145
|
+
this.lastError = null;
|
|
146
|
+
return true;
|
|
147
|
+
} catch (err) {
|
|
148
|
+
this.lastError = err.message;
|
|
149
|
+
this.logger.error?.(`[SHIELD] Error saving 404 intelligence to "${this.filePath}": ${err.message}`);
|
|
150
|
+
// Cleanup temp file if stranded
|
|
151
|
+
try {
|
|
152
|
+
if (fs.existsSync(this.tempFilePath)) {
|
|
153
|
+
fs.unlinkSync(this.tempFilePath);
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
// Ignore cleanup errors
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Start periodic auto-save timer
|
|
164
|
+
*/
|
|
165
|
+
start() {
|
|
166
|
+
if (!this.enabled) return;
|
|
167
|
+
|
|
168
|
+
// Perform initial load if configured
|
|
169
|
+
if (this.loadOnStartup) {
|
|
170
|
+
this.load();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (this.timer) {
|
|
174
|
+
clearInterval(this.timer);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
this.timer = setInterval(() => {
|
|
178
|
+
this.save();
|
|
179
|
+
}, this.intervalMs);
|
|
180
|
+
|
|
181
|
+
// Do not hold Node process open if everything else shuts down
|
|
182
|
+
if (typeof this.timer.unref === 'function') {
|
|
183
|
+
this.timer.unref();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Stop periodic timer and perform final graceful flush
|
|
189
|
+
*/
|
|
190
|
+
async stop() {
|
|
191
|
+
if (this.timer) {
|
|
192
|
+
clearInterval(this.timer);
|
|
193
|
+
this.timer = null;
|
|
194
|
+
}
|
|
195
|
+
if (this.enabled) {
|
|
196
|
+
this.save();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Get persistence health and diagnostics
|
|
202
|
+
*/
|
|
203
|
+
getStatus() {
|
|
204
|
+
let fileExists = false;
|
|
205
|
+
let fileSize = 0;
|
|
206
|
+
try {
|
|
207
|
+
if (fs.existsSync(this.filePath)) {
|
|
208
|
+
fileExists = true;
|
|
209
|
+
const stat = fs.statSync(this.filePath);
|
|
210
|
+
fileSize = stat.size;
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// Ignore
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
enabled: this.enabled,
|
|
218
|
+
filePath: this.filePath,
|
|
219
|
+
intervalMs: this.intervalMs,
|
|
220
|
+
lastSaved: this.lastSaved,
|
|
221
|
+
lastError: this.lastError,
|
|
222
|
+
fileExists,
|
|
223
|
+
fileSize
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Denial-of-Wallet Cost Throttle Middleware Decorator
|
|
3
|
+
* Throttles expensive routes (e.g. AI generation, batch operations)
|
|
4
|
+
* by weighted cost units rather than raw request counts.
|
|
5
|
+
*
|
|
6
|
+
* @author ChdLabs
|
|
7
|
+
* @license MIT
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export class CostThrottle {
|
|
11
|
+
constructor() {
|
|
12
|
+
// ip -> { currentCost: number, windowStart: number }
|
|
13
|
+
this.clientCosts = new Map();
|
|
14
|
+
|
|
15
|
+
// Prune expired buckets every 2 minutes
|
|
16
|
+
this.cleanupTimer = setInterval(() => {
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
for (const [ip, record] of this.clientCosts.entries()) {
|
|
19
|
+
if (now - record.windowStart > 120_000) {
|
|
20
|
+
this.clientCosts.delete(ip);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}, 120_000);
|
|
24
|
+
|
|
25
|
+
if (typeof this.cleanupTimer.unref === 'function') {
|
|
26
|
+
this.cleanupTimer.unref();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create a route-level cost throttle middleware
|
|
32
|
+
* @param {Object} options
|
|
33
|
+
* @param {number} [options.weight=1] Cost units consumed per request
|
|
34
|
+
* @param {number} [options.maxCost=100] Maximum units allowed per window
|
|
35
|
+
* @param {number} [options.windowMs=60000] Sliding window in ms (default: 1 min)
|
|
36
|
+
*/
|
|
37
|
+
create({ weight = 1, maxCost = 100, windowMs = 60_000 } = {}) {
|
|
38
|
+
return (req, res, next) => {
|
|
39
|
+
const ip = (req.headers['x-forwarded-for']?.split(',')[0]?.trim()) ||
|
|
40
|
+
req.socket?.remoteAddress ||
|
|
41
|
+
req.ip ||
|
|
42
|
+
'127.0.0.1';
|
|
43
|
+
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
let record = this.clientCosts.get(ip);
|
|
46
|
+
|
|
47
|
+
if (!record || (now - record.windowStart > windowMs)) {
|
|
48
|
+
record = { currentCost: weight, windowStart: now };
|
|
49
|
+
this.clientCosts.set(ip, record);
|
|
50
|
+
return next();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (record.currentCost + weight > maxCost) {
|
|
54
|
+
const retryAfterSec = Math.ceil((windowMs - (now - record.windowStart)) / 1000);
|
|
55
|
+
res.setHeader('Retry-After', retryAfterSec);
|
|
56
|
+
return res.status(429).json({
|
|
57
|
+
error: 'Rate limit exceeded: Cost budget exhausted',
|
|
58
|
+
code: 'COST_BUDGET_EXCEEDED',
|
|
59
|
+
retryAfter: retryAfterSec
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
record.currentCost += weight;
|
|
64
|
+
next();
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
destroy() {
|
|
69
|
+
if (this.cleanupTimer) {
|
|
70
|
+
clearInterval(this.cleanupTimer);
|
|
71
|
+
this.cleanupTimer = null;
|
|
72
|
+
}
|
|
73
|
+
this.clientCosts.clear();
|
|
74
|
+
}
|
|
75
|
+
}
|