@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.
@@ -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
+ }