@panguard-ai/threat-cloud 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/dist/audit-logger.d.ts +46 -0
- package/dist/audit-logger.d.ts.map +1 -0
- package/dist/audit-logger.js +105 -0
- package/dist/audit-logger.js.map +1 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +115 -0
- package/dist/cli.js.map +1 -0
- package/dist/correlation-engine.d.ts +41 -0
- package/dist/correlation-engine.d.ts.map +1 -0
- package/dist/correlation-engine.js +313 -0
- package/dist/correlation-engine.js.map +1 -0
- package/dist/database.d.ts +63 -0
- package/dist/database.d.ts.map +1 -0
- package/dist/database.js +444 -0
- package/dist/database.js.map +1 -0
- package/dist/feed-distributor.d.ts +36 -0
- package/dist/feed-distributor.d.ts.map +1 -0
- package/dist/feed-distributor.js +125 -0
- package/dist/feed-distributor.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/ioc-store.d.ts +83 -0
- package/dist/ioc-store.d.ts.map +1 -0
- package/dist/ioc-store.js +278 -0
- package/dist/ioc-store.js.map +1 -0
- package/dist/query-handlers.d.ts +40 -0
- package/dist/query-handlers.d.ts.map +1 -0
- package/dist/query-handlers.js +211 -0
- package/dist/query-handlers.js.map +1 -0
- package/dist/reputation-engine.d.ts +44 -0
- package/dist/reputation-engine.d.ts.map +1 -0
- package/dist/reputation-engine.js +169 -0
- package/dist/reputation-engine.js.map +1 -0
- package/dist/rule-generator.d.ts +47 -0
- package/dist/rule-generator.d.ts.map +1 -0
- package/dist/rule-generator.js +238 -0
- package/dist/rule-generator.js.map +1 -0
- package/dist/scheduler.d.ts +52 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +143 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/server.d.ts +99 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +809 -0
- package/dist/server.js.map +1 -0
- package/dist/sighting-store.d.ts +61 -0
- package/dist/sighting-store.d.ts.map +1 -0
- package/dist/sighting-store.js +191 -0
- package/dist/sighting-store.js.map +1 -0
- package/dist/types.d.ts +352 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/package.json +37 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Threat Cloud HTTP API Server
|
|
3
|
+
* 威脅雲 HTTP API 伺服器
|
|
4
|
+
*
|
|
5
|
+
* Endpoints:
|
|
6
|
+
* - POST /api/threats Upload anonymized threat data
|
|
7
|
+
* - POST /api/trap-intel Upload trap intelligence data
|
|
8
|
+
* - GET /api/rules Fetch rules (optional ?since= filter)
|
|
9
|
+
* - POST /api/rules Publish a new community rule
|
|
10
|
+
* - GET /api/stats Get threat statistics
|
|
11
|
+
* - GET /api/iocs Search/list IoCs
|
|
12
|
+
* - GET /api/iocs/:value Lookup single IoC
|
|
13
|
+
* - GET /health Health check
|
|
14
|
+
*
|
|
15
|
+
* @module @panguard-ai/threat-cloud/server
|
|
16
|
+
*/
|
|
17
|
+
import { createServer } from 'node:http';
|
|
18
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
19
|
+
import { ThreatCloudDB } from './database.js';
|
|
20
|
+
import { IoCStore } from './ioc-store.js';
|
|
21
|
+
import { CorrelationEngine } from './correlation-engine.js';
|
|
22
|
+
import { QueryHandlers } from './query-handlers.js';
|
|
23
|
+
import { FeedDistributor } from './feed-distributor.js';
|
|
24
|
+
import { SightingStore } from './sighting-store.js';
|
|
25
|
+
import { AuditLogger } from './audit-logger.js';
|
|
26
|
+
import { Scheduler } from './scheduler.js';
|
|
27
|
+
/**
|
|
28
|
+
* Threat Cloud API Server
|
|
29
|
+
* 威脅雲 API 伺服器
|
|
30
|
+
*/
|
|
31
|
+
export class ThreatCloudServer {
|
|
32
|
+
server = null;
|
|
33
|
+
db;
|
|
34
|
+
iocStore;
|
|
35
|
+
correlation;
|
|
36
|
+
queryHandlers;
|
|
37
|
+
feedDistributor;
|
|
38
|
+
sightingStore;
|
|
39
|
+
auditLogger;
|
|
40
|
+
scheduler;
|
|
41
|
+
config;
|
|
42
|
+
rateLimits = new Map();
|
|
43
|
+
/** Pre-hashed API keys for constant-time comparison */
|
|
44
|
+
hashedApiKeys;
|
|
45
|
+
constructor(config) {
|
|
46
|
+
this.config = config;
|
|
47
|
+
this.db = new ThreatCloudDB(config.dbPath);
|
|
48
|
+
const rawDb = this.db.getDB();
|
|
49
|
+
this.iocStore = new IoCStore(rawDb);
|
|
50
|
+
this.correlation = new CorrelationEngine(rawDb);
|
|
51
|
+
this.queryHandlers = new QueryHandlers(rawDb);
|
|
52
|
+
this.feedDistributor = new FeedDistributor(rawDb);
|
|
53
|
+
this.sightingStore = new SightingStore(rawDb);
|
|
54
|
+
this.auditLogger = new AuditLogger(rawDb);
|
|
55
|
+
this.scheduler = new Scheduler(rawDb);
|
|
56
|
+
// Pre-hash API keys for constant-time comparison
|
|
57
|
+
this.hashedApiKeys = config.apiKeys.map((k) => createHash('sha256').update(k).digest());
|
|
58
|
+
}
|
|
59
|
+
/** Start the server / 啟動伺服器 */
|
|
60
|
+
async start() {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
this.server = createServer((req, res) => {
|
|
63
|
+
void this.handleRequest(req, res);
|
|
64
|
+
});
|
|
65
|
+
this.server.listen(this.config.port, this.config.host, () => {
|
|
66
|
+
console.log(`Threat Cloud server started on ${this.config.host}:${this.config.port}`);
|
|
67
|
+
if (this.hashedApiKeys.length === 0) {
|
|
68
|
+
console.warn('WARNING: No API keys configured. Write endpoints and sensitive data will return 503.');
|
|
69
|
+
console.warn('Set TC_API_KEYS=key1,key2 or use --api-key to enable full access.');
|
|
70
|
+
}
|
|
71
|
+
this.scheduler.start();
|
|
72
|
+
resolve();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** Stop the server gracefully / 優雅停止伺服器 */
|
|
77
|
+
async stop() {
|
|
78
|
+
this.scheduler.stop();
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
if (this.server) {
|
|
81
|
+
// Stop accepting new connections, wait for in-flight requests
|
|
82
|
+
this.server.close(() => {
|
|
83
|
+
this.db.close();
|
|
84
|
+
resolve();
|
|
85
|
+
});
|
|
86
|
+
// Force close after 25s if requests haven't drained
|
|
87
|
+
setTimeout(() => {
|
|
88
|
+
this.db.close();
|
|
89
|
+
resolve();
|
|
90
|
+
}, 25_000);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
this.db.close();
|
|
94
|
+
resolve();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** Expose DB for testing / 暴露 DB 供測試使用 */
|
|
99
|
+
getDB() {
|
|
100
|
+
return this.db;
|
|
101
|
+
}
|
|
102
|
+
/** Expose IoC store for testing / 暴露 IoCStore 供測試使用 */
|
|
103
|
+
getIoCStore() {
|
|
104
|
+
return this.iocStore;
|
|
105
|
+
}
|
|
106
|
+
/** Expose scheduler for backup management / 暴露排程器供備份管理 */
|
|
107
|
+
getScheduler() {
|
|
108
|
+
return this.scheduler;
|
|
109
|
+
}
|
|
110
|
+
async handleRequest(req, res) {
|
|
111
|
+
// Security headers
|
|
112
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
113
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
114
|
+
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
115
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
116
|
+
res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
|
|
117
|
+
if (process.env['NODE_ENV'] === 'production') {
|
|
118
|
+
res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
|
|
119
|
+
}
|
|
120
|
+
res.setHeader('Content-Type', 'application/json');
|
|
121
|
+
const clientIP = req.socket.remoteAddress ?? 'unknown';
|
|
122
|
+
// Rate limiting (per client IP)
|
|
123
|
+
if (!this.checkRateLimit(clientIP)) {
|
|
124
|
+
this.sendJson(res, 429, { ok: false, error: 'Rate limit exceeded' });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// API key verification
|
|
128
|
+
const url = req.url ?? '/';
|
|
129
|
+
const pathname = url.split('?')[0] ?? '/';
|
|
130
|
+
let apiKeyHash = '';
|
|
131
|
+
// Determine if this endpoint requires authentication
|
|
132
|
+
const isHealthCheck = pathname === '/health';
|
|
133
|
+
const allowAnonymousUpload = process.env['ALLOW_ANONYMOUS_UPLOAD'] === 'true';
|
|
134
|
+
const isAnonymousThreatUpload = allowAnonymousUpload && req.method === 'POST' && pathname === '/api/threats';
|
|
135
|
+
const isWriteOrSensitive = req.method === 'POST' || pathname === '/api/audit-log' || pathname === '/api/sightings';
|
|
136
|
+
// Write and sensitive endpoints ALWAYS require auth, except anonymous threat uploads
|
|
137
|
+
const requiresAuth = !isHealthCheck &&
|
|
138
|
+
!isAnonymousThreatUpload &&
|
|
139
|
+
(this.config.apiKeyRequired || isWriteOrSensitive);
|
|
140
|
+
if (requiresAuth) {
|
|
141
|
+
if (this.hashedApiKeys.length === 0) {
|
|
142
|
+
this.sendJson(res, 503, {
|
|
143
|
+
ok: false,
|
|
144
|
+
error: 'Server not configured with API keys. Set TC_API_KEYS environment variable.',
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const authHeader = req.headers.authorization ?? '';
|
|
149
|
+
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
|
|
150
|
+
if (!token || !this.verifyApiKey(token)) {
|
|
151
|
+
this.sendJson(res, 401, { ok: false, error: 'Unauthorized' });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
apiKeyHash = AuditLogger.hashApiKey(token);
|
|
155
|
+
// Per-API-key rate limiting (stricter for write ops)
|
|
156
|
+
if (req.method === 'POST' && !this.checkRateLimit(`key:${apiKeyHash}`, 30)) {
|
|
157
|
+
this.sendJson(res, 429, { ok: false, error: 'Rate limit exceeded' });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// CORS
|
|
162
|
+
const allowedOrigins = (process.env['CORS_ALLOWED_ORIGINS'] ?? 'https://panguard.ai,https://www.panguard.ai').split(',');
|
|
163
|
+
const origin = req.headers.origin ?? '';
|
|
164
|
+
if (origin && allowedOrigins.includes(origin)) {
|
|
165
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
166
|
+
}
|
|
167
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
168
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
169
|
+
if (req.method === 'OPTIONS') {
|
|
170
|
+
res.writeHead(204);
|
|
171
|
+
res.end();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// Strict Content-Type for POST requests
|
|
175
|
+
if (req.method === 'POST') {
|
|
176
|
+
const contentType = req.headers['content-type'] ?? '';
|
|
177
|
+
if (!contentType.includes('application/json')) {
|
|
178
|
+
this.sendJson(res, 415, { ok: false, error: 'Content-Type must be application/json' });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Attach audit context to this request (anonymize client IP for privacy)
|
|
183
|
+
const auditCtx = { actorHash: apiKeyHash, ipAddress: this.anonymizeIP(clientIP) };
|
|
184
|
+
try {
|
|
185
|
+
// Route matching
|
|
186
|
+
if (pathname === '/health') {
|
|
187
|
+
try {
|
|
188
|
+
this.db.getDB().prepare('SELECT 1').get();
|
|
189
|
+
this.sendJson(res, 200, {
|
|
190
|
+
ok: true,
|
|
191
|
+
data: { status: 'healthy', uptime: process.uptime(), db: 'connected' },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
this.sendJson(res, 503, {
|
|
196
|
+
ok: false,
|
|
197
|
+
data: { status: 'unhealthy', uptime: process.uptime(), db: 'disconnected' },
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (pathname === '/api/threats' && req.method === 'POST') {
|
|
203
|
+
await this.handlePostThreat(req, res, auditCtx);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (pathname === '/api/trap-intel' && req.method === 'POST') {
|
|
207
|
+
await this.handlePostTrapIntel(req, res, auditCtx);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// Sighting endpoints
|
|
211
|
+
if (pathname === '/api/sightings' && req.method === 'POST') {
|
|
212
|
+
await this.handlePostSighting(req, res, auditCtx);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (pathname === '/api/sightings' && req.method === 'GET') {
|
|
216
|
+
this.handleGetSightings(url, res);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// Audit log endpoint
|
|
220
|
+
if (pathname === '/api/audit-log' && req.method === 'GET') {
|
|
221
|
+
this.handleGetAuditLog(url, res);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (pathname === '/api/rules') {
|
|
225
|
+
if (req.method === 'GET') {
|
|
226
|
+
this.handleGetRules(url, res);
|
|
227
|
+
}
|
|
228
|
+
else if (req.method === 'POST') {
|
|
229
|
+
await this.handlePostRule(req, res, auditCtx);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (pathname === '/api/stats' && req.method === 'GET') {
|
|
237
|
+
this.handleGetStats(res);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// GET /api/iocs or GET /api/iocs/:value
|
|
241
|
+
if (pathname === '/api/iocs' && req.method === 'GET') {
|
|
242
|
+
this.handleSearchIoCs(url, res);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
// /api/iocs/xxx path param routing
|
|
246
|
+
if (pathname.startsWith('/api/iocs/') && req.method === 'GET') {
|
|
247
|
+
const value = decodeURIComponent(pathname.slice('/api/iocs/'.length));
|
|
248
|
+
this.handleLookupIoC(url, value, res);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// Campaign endpoints
|
|
252
|
+
if (pathname === '/api/campaigns/stats' && req.method === 'GET') {
|
|
253
|
+
this.handleCampaignStats(res);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (pathname === '/api/campaigns' && req.method === 'GET') {
|
|
257
|
+
this.handleListCampaigns(url, res);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (pathname.startsWith('/api/campaigns/') && req.method === 'GET') {
|
|
261
|
+
const id = decodeURIComponent(pathname.slice('/api/campaigns/'.length));
|
|
262
|
+
this.handleGetCampaign(id, res);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// Query endpoints
|
|
266
|
+
if (pathname === '/api/query/timeseries' && req.method === 'GET') {
|
|
267
|
+
this.handleTimeSeries(url, res);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (pathname === '/api/query/geo' && req.method === 'GET') {
|
|
271
|
+
this.handleGeoDistribution(url, res);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (pathname === '/api/query/trends' && req.method === 'GET') {
|
|
275
|
+
this.handleTrends(url, res);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (pathname === '/api/query/mitre-heatmap' && req.method === 'GET') {
|
|
279
|
+
this.handleMitreHeatmap(url, res);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// Feed endpoints
|
|
283
|
+
if (pathname === '/api/feeds/ip-blocklist' && req.method === 'GET') {
|
|
284
|
+
this.handleIPBlocklist(url, res);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (pathname === '/api/feeds/domain-blocklist' && req.method === 'GET') {
|
|
288
|
+
this.handleDomainBlocklist(url, res);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (pathname === '/api/feeds/iocs' && req.method === 'GET') {
|
|
292
|
+
this.handleIoCFeed(url, res);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (pathname === '/api/feeds/agent-update' && req.method === 'GET') {
|
|
296
|
+
this.handleAgentUpdate(url, res);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
this.sendJson(res, 404, { ok: false, error: 'Not found' });
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
// Never leak stack traces or internal details in production
|
|
303
|
+
if (err instanceof SyntaxError) {
|
|
304
|
+
this.sendJson(res, 400, { ok: false, error: 'Invalid JSON in request body' });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const isDev = process.env['NODE_ENV'] !== 'production';
|
|
308
|
+
const message = isDev && err instanceof Error ? err.message : 'Internal server error';
|
|
309
|
+
console.error('Request error:', err);
|
|
310
|
+
this.sendJson(res, 500, { ok: false, error: message });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// -------------------------------------------------------------------------
|
|
314
|
+
// POST /api/threats - Upload anonymized threat data (enhanced: also creates IoC)
|
|
315
|
+
// -------------------------------------------------------------------------
|
|
316
|
+
async handlePostThreat(req, res, auditCtx) {
|
|
317
|
+
const body = await this.readBody(req);
|
|
318
|
+
const data = JSON.parse(body);
|
|
319
|
+
// Input validation
|
|
320
|
+
if (!data.attackSourceIP ||
|
|
321
|
+
!data.attackType ||
|
|
322
|
+
!data.mitreTechnique ||
|
|
323
|
+
!data.sigmaRuleMatched ||
|
|
324
|
+
!data.timestamp ||
|
|
325
|
+
!data.region) {
|
|
326
|
+
this.sendJson(res, 400, {
|
|
327
|
+
ok: false,
|
|
328
|
+
error: 'Missing required fields: attackSourceIP, attackType, mitreTechnique, sigmaRuleMatched, timestamp, region',
|
|
329
|
+
});
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (!this.isValidIP(data.attackSourceIP)) {
|
|
333
|
+
this.sendJson(res, 400, { ok: false, error: 'Invalid IP address format' });
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (!this.isValidTimestamp(data.timestamp)) {
|
|
337
|
+
this.sendJson(res, 400, { ok: false, error: 'Invalid timestamp format' });
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
// Sanitize string fields
|
|
341
|
+
data.attackType = this.sanitizeString(data.attackType, 100);
|
|
342
|
+
data.mitreTechnique = this.sanitizeString(data.mitreTechnique, 50);
|
|
343
|
+
data.sigmaRuleMatched = this.sanitizeString(data.sigmaRuleMatched, 200);
|
|
344
|
+
data.region = this.sanitizeString(data.region, 10);
|
|
345
|
+
if (data.industry)
|
|
346
|
+
data.industry = this.sanitizeString(data.industry, 50);
|
|
347
|
+
// Anonymize IP
|
|
348
|
+
data.attackSourceIP = this.anonymizeIP(data.attackSourceIP);
|
|
349
|
+
// Insert into legacy threats table (backward compat)
|
|
350
|
+
this.db.insertThreat(data);
|
|
351
|
+
// Insert into enriched_threats
|
|
352
|
+
const enriched = ThreatCloudDB.guardToEnriched(data);
|
|
353
|
+
const enrichedId = this.db.insertEnrichedThreat(enriched);
|
|
354
|
+
// Extract IP as IoC + auto-sighting (learning)
|
|
355
|
+
if (data.attackSourceIP && data.attackSourceIP !== 'unknown') {
|
|
356
|
+
const ioc = this.iocStore.upsertIoC({
|
|
357
|
+
type: 'ip',
|
|
358
|
+
value: data.attackSourceIP,
|
|
359
|
+
threatType: data.attackType,
|
|
360
|
+
source: 'guard',
|
|
361
|
+
confidence: 50,
|
|
362
|
+
tags: [data.mitreTechnique],
|
|
363
|
+
});
|
|
364
|
+
// Auto-sighting: agent reported this IP → positive sighting
|
|
365
|
+
if (ioc.sightings > 1) {
|
|
366
|
+
this.sightingStore.recordAgentMatch(ioc.id, 'guard', auditCtx.actorHash);
|
|
367
|
+
// Check for cross-source correlation
|
|
368
|
+
this.sightingStore.recordCrossSourceMatch(ioc.id, auditCtx.actorHash);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// Audit log
|
|
372
|
+
this.auditLogger.log('threat_upload', 'enriched_threat', String(enrichedId ?? 'dup'), {
|
|
373
|
+
...auditCtx,
|
|
374
|
+
details: { attackType: data.attackType, region: data.region },
|
|
375
|
+
});
|
|
376
|
+
this.sendJson(res, 201, {
|
|
377
|
+
ok: true,
|
|
378
|
+
data: {
|
|
379
|
+
message: 'Threat data received',
|
|
380
|
+
enrichedId: enrichedId ?? 'duplicate',
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
// -------------------------------------------------------------------------
|
|
385
|
+
// POST /api/trap-intel - Upload trap intelligence
|
|
386
|
+
// -------------------------------------------------------------------------
|
|
387
|
+
async handlePostTrapIntel(req, res, auditCtx) {
|
|
388
|
+
const body = await this.readBody(req);
|
|
389
|
+
const parsed = JSON.parse(body);
|
|
390
|
+
const items = Array.isArray(parsed) ? parsed : [parsed];
|
|
391
|
+
if (items.length > 100) {
|
|
392
|
+
this.sendJson(res, 400, { ok: false, error: 'Batch size exceeds maximum of 100' });
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
let accepted = 0;
|
|
396
|
+
let duplicates = 0;
|
|
397
|
+
for (const data of items) {
|
|
398
|
+
// Validate required fields
|
|
399
|
+
if (!data.sourceIP || !data.attackType || !data.timestamp || !data.mitreTechniques) {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (!this.isValidIP(data.sourceIP))
|
|
403
|
+
continue;
|
|
404
|
+
// Sanitize
|
|
405
|
+
data.attackType = this.sanitizeString(data.attackType, 100);
|
|
406
|
+
if (data.region)
|
|
407
|
+
data.region = this.sanitizeString(data.region, 10);
|
|
408
|
+
// Anonymize IP
|
|
409
|
+
data.sourceIP = this.anonymizeIP(data.sourceIP);
|
|
410
|
+
// Convert and insert enriched threat
|
|
411
|
+
const enriched = ThreatCloudDB.trapToEnriched(data);
|
|
412
|
+
const enrichedId = this.db.insertEnrichedThreat(enriched);
|
|
413
|
+
if (enrichedId !== null) {
|
|
414
|
+
accepted++;
|
|
415
|
+
// Insert trap credentials (sanitize usernames)
|
|
416
|
+
if (data.topCredentials && data.topCredentials.length > 0) {
|
|
417
|
+
const safeCreds = data.topCredentials.slice(0, 50).map((c) => ({
|
|
418
|
+
username: this.sanitizeString(c.username, 200),
|
|
419
|
+
count: Math.max(0, Math.min(1_000_000, c.count)),
|
|
420
|
+
}));
|
|
421
|
+
this.db.insertTrapCredentials(enrichedId, safeCreds);
|
|
422
|
+
}
|
|
423
|
+
// Extract IP as IoC + auto-sighting (learning)
|
|
424
|
+
if (data.sourceIP && data.sourceIP !== 'unknown') {
|
|
425
|
+
const techniques = data.mitreTechniques ?? [];
|
|
426
|
+
const ioc = this.iocStore.upsertIoC({
|
|
427
|
+
type: 'ip',
|
|
428
|
+
value: data.sourceIP,
|
|
429
|
+
threatType: data.attackType,
|
|
430
|
+
source: 'trap',
|
|
431
|
+
confidence: 60,
|
|
432
|
+
tags: techniques.map((t) => this.sanitizeString(t, 50)),
|
|
433
|
+
metadata: {
|
|
434
|
+
serviceType: data.serviceType,
|
|
435
|
+
skillLevel: data.skillLevel,
|
|
436
|
+
intent: data.intent,
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
// Auto-sighting: trap agent confirmed this IP
|
|
440
|
+
if (ioc.sightings > 1) {
|
|
441
|
+
this.sightingStore.recordAgentMatch(ioc.id, 'trap', auditCtx.actorHash);
|
|
442
|
+
this.sightingStore.recordCrossSourceMatch(ioc.id, auditCtx.actorHash);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
duplicates++;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// Audit log
|
|
451
|
+
this.auditLogger.log('trap_intel_upload', 'trap_intel', 'batch', {
|
|
452
|
+
...auditCtx,
|
|
453
|
+
details: { accepted, duplicates, batchSize: items.length },
|
|
454
|
+
});
|
|
455
|
+
this.sendJson(res, 201, {
|
|
456
|
+
ok: true,
|
|
457
|
+
data: { message: 'Trap intelligence received', accepted, duplicates },
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
// -------------------------------------------------------------------------
|
|
461
|
+
// GET /api/iocs - Search IoCs
|
|
462
|
+
// -------------------------------------------------------------------------
|
|
463
|
+
handleSearchIoCs(url, res) {
|
|
464
|
+
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
465
|
+
const result = this.iocStore.searchIoCs({
|
|
466
|
+
type: params.get('type') || undefined,
|
|
467
|
+
source: params.get('source') || undefined,
|
|
468
|
+
minReputation: params.get('minReputation')
|
|
469
|
+
? Number(params.get('minReputation'))
|
|
470
|
+
: undefined,
|
|
471
|
+
status: params.get('status') || undefined,
|
|
472
|
+
since: params.get('since') || undefined,
|
|
473
|
+
search: params.get('search') || undefined,
|
|
474
|
+
}, {
|
|
475
|
+
page: Number(params.get('page') ?? '1'),
|
|
476
|
+
limit: Number(params.get('limit') ?? '50'),
|
|
477
|
+
});
|
|
478
|
+
this.sendJson(res, 200, { ok: true, data: result });
|
|
479
|
+
}
|
|
480
|
+
// -------------------------------------------------------------------------
|
|
481
|
+
// GET /api/iocs/:value - Lookup single IoC
|
|
482
|
+
// -------------------------------------------------------------------------
|
|
483
|
+
handleLookupIoC(url, value, res) {
|
|
484
|
+
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
485
|
+
const typeParam = params.get('type');
|
|
486
|
+
const type = typeParam ?? this.iocStore.detectType(value);
|
|
487
|
+
const result = this.iocStore.lookupIoCWithContext(type, value, (ip) => this.db.countRelatedThreats(ip));
|
|
488
|
+
this.sendJson(res, 200, { ok: true, data: result });
|
|
489
|
+
}
|
|
490
|
+
// -------------------------------------------------------------------------
|
|
491
|
+
// Existing handlers / 既有處理器
|
|
492
|
+
// -------------------------------------------------------------------------
|
|
493
|
+
/** GET /api/rules?since=<ISO timestamp> */
|
|
494
|
+
handleGetRules(url, res) {
|
|
495
|
+
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
496
|
+
const since = params.get('since');
|
|
497
|
+
const rules = since ? this.db.getRulesSince(since) : this.db.getAllRules();
|
|
498
|
+
this.sendJson(res, 200, rules);
|
|
499
|
+
}
|
|
500
|
+
/** POST /api/rules */
|
|
501
|
+
async handlePostRule(req, res, auditCtx) {
|
|
502
|
+
const body = await this.readBody(req);
|
|
503
|
+
const rule = JSON.parse(body);
|
|
504
|
+
if (!rule.ruleId || !rule.ruleContent || !rule.source) {
|
|
505
|
+
this.sendJson(res, 400, {
|
|
506
|
+
ok: false,
|
|
507
|
+
error: 'Missing required fields: ruleId, ruleContent, source',
|
|
508
|
+
});
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
// Validate ruleContent size and format
|
|
512
|
+
const MAX_RULE_CONTENT_SIZE = 65_536; // 64KB max
|
|
513
|
+
if (rule.ruleContent.length > MAX_RULE_CONTENT_SIZE) {
|
|
514
|
+
this.sendJson(res, 400, {
|
|
515
|
+
ok: false,
|
|
516
|
+
error: `ruleContent exceeds maximum size of ${MAX_RULE_CONTENT_SIZE} bytes`,
|
|
517
|
+
});
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
// Basic Sigma YAML validation: must look like YAML (has title: or detection:)
|
|
521
|
+
const looksLikeYaml = rule.ruleContent.includes('title:') || rule.ruleContent.includes('detection:');
|
|
522
|
+
if (!looksLikeYaml) {
|
|
523
|
+
this.sendJson(res, 400, {
|
|
524
|
+
ok: false,
|
|
525
|
+
error: 'ruleContent must be valid Sigma YAML (missing title: or detection:)',
|
|
526
|
+
});
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
rule.ruleId = this.sanitizeString(rule.ruleId, 200);
|
|
530
|
+
rule.ruleContent = this.sanitizeString(rule.ruleContent, MAX_RULE_CONTENT_SIZE);
|
|
531
|
+
rule.source = this.sanitizeString(rule.source, 100);
|
|
532
|
+
rule.publishedAt = rule.publishedAt || new Date().toISOString();
|
|
533
|
+
this.db.upsertRule(rule);
|
|
534
|
+
this.auditLogger.log('rule_publish', 'rule', rule.ruleId, {
|
|
535
|
+
...auditCtx,
|
|
536
|
+
details: { source: rule.source },
|
|
537
|
+
});
|
|
538
|
+
this.sendJson(res, 201, { ok: true, data: { message: 'Rule published', ruleId: rule.ruleId } });
|
|
539
|
+
}
|
|
540
|
+
/** GET /api/stats (enhanced) */
|
|
541
|
+
handleGetStats(res) {
|
|
542
|
+
const stats = this.queryHandlers.getEnhancedStats();
|
|
543
|
+
this.sendJson(res, 200, { ok: true, data: stats });
|
|
544
|
+
}
|
|
545
|
+
// -------------------------------------------------------------------------
|
|
546
|
+
// Campaign handlers / Campaign 處理器
|
|
547
|
+
// -------------------------------------------------------------------------
|
|
548
|
+
handleListCampaigns(url, res) {
|
|
549
|
+
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
550
|
+
const result = this.correlation.listCampaigns({
|
|
551
|
+
page: Number(params.get('page') ?? '1'),
|
|
552
|
+
limit: Number(params.get('limit') ?? '20'),
|
|
553
|
+
}, params.get('status') || undefined);
|
|
554
|
+
this.sendJson(res, 200, { ok: true, data: result });
|
|
555
|
+
}
|
|
556
|
+
handleCampaignStats(res) {
|
|
557
|
+
const stats = this.correlation.getCampaignStats();
|
|
558
|
+
this.sendJson(res, 200, { ok: true, data: stats });
|
|
559
|
+
}
|
|
560
|
+
handleGetCampaign(id, res) {
|
|
561
|
+
const campaign = this.correlation.getCampaign(id);
|
|
562
|
+
if (!campaign) {
|
|
563
|
+
this.sendJson(res, 404, { ok: false, error: 'Campaign not found' });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const events = this.correlation.getCampaignEvents(id);
|
|
567
|
+
this.sendJson(res, 200, { ok: true, data: { campaign, events } });
|
|
568
|
+
}
|
|
569
|
+
// -------------------------------------------------------------------------
|
|
570
|
+
// Query handlers / 查詢處理器
|
|
571
|
+
// -------------------------------------------------------------------------
|
|
572
|
+
handleTimeSeries(url, res) {
|
|
573
|
+
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
574
|
+
const rawGranularity = params.get('granularity') ?? 'day';
|
|
575
|
+
const ALLOWED_GRANULARITIES = ['hour', 'day', 'week'];
|
|
576
|
+
if (!ALLOWED_GRANULARITIES.includes(rawGranularity)) {
|
|
577
|
+
this.sendJson(res, 400, { ok: false, error: 'granularity must be one of: hour, day, week' });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const granularity = rawGranularity;
|
|
581
|
+
const result = this.queryHandlers.getTimeSeries(granularity, params.get('since') || undefined, params.get('attackType') || undefined);
|
|
582
|
+
this.sendJson(res, 200, { ok: true, data: result });
|
|
583
|
+
}
|
|
584
|
+
handleGeoDistribution(url, res) {
|
|
585
|
+
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
586
|
+
const result = this.queryHandlers.getGeoDistribution(params.get('since') || undefined);
|
|
587
|
+
this.sendJson(res, 200, { ok: true, data: result });
|
|
588
|
+
}
|
|
589
|
+
handleTrends(url, res) {
|
|
590
|
+
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
591
|
+
const periodDays = Number(params.get('periodDays') ?? '7');
|
|
592
|
+
const result = this.queryHandlers.getTrends(periodDays);
|
|
593
|
+
this.sendJson(res, 200, { ok: true, data: result });
|
|
594
|
+
}
|
|
595
|
+
handleMitreHeatmap(url, res) {
|
|
596
|
+
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
597
|
+
const result = this.queryHandlers.getMitreHeatmap(params.get('since') || undefined);
|
|
598
|
+
this.sendJson(res, 200, { ok: true, data: result });
|
|
599
|
+
}
|
|
600
|
+
// -------------------------------------------------------------------------
|
|
601
|
+
// Feed handlers / Feed 處理器
|
|
602
|
+
// -------------------------------------------------------------------------
|
|
603
|
+
handleIPBlocklist(url, res) {
|
|
604
|
+
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
605
|
+
const minRep = Number(params.get('minReputation') ?? '70');
|
|
606
|
+
const blocklist = this.feedDistributor.getIPBlocklist(minRep);
|
|
607
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
608
|
+
res.writeHead(200);
|
|
609
|
+
res.end(blocklist);
|
|
610
|
+
}
|
|
611
|
+
handleDomainBlocklist(url, res) {
|
|
612
|
+
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
613
|
+
const minRep = Number(params.get('minReputation') ?? '70');
|
|
614
|
+
const blocklist = this.feedDistributor.getDomainBlocklist(minRep);
|
|
615
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
616
|
+
res.writeHead(200);
|
|
617
|
+
res.end(blocklist);
|
|
618
|
+
}
|
|
619
|
+
handleIoCFeed(url, res) {
|
|
620
|
+
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
621
|
+
const result = this.feedDistributor.getIoCFeed(Number(params.get('minReputation') ?? '50'), Number(params.get('limit') ?? '1000'), params.get('since') || undefined);
|
|
622
|
+
this.sendJson(res, 200, { ok: true, data: result });
|
|
623
|
+
}
|
|
624
|
+
handleAgentUpdate(url, res) {
|
|
625
|
+
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
626
|
+
const result = this.feedDistributor.getAgentUpdate(params.get('since') || undefined);
|
|
627
|
+
this.sendJson(res, 200, { ok: true, data: result });
|
|
628
|
+
}
|
|
629
|
+
// -------------------------------------------------------------------------
|
|
630
|
+
// Utility methods / 工具方法
|
|
631
|
+
// -------------------------------------------------------------------------
|
|
632
|
+
// -------------------------------------------------------------------------
|
|
633
|
+
// Sighting + Audit handlers
|
|
634
|
+
// -------------------------------------------------------------------------
|
|
635
|
+
async handlePostSighting(req, res, auditCtx) {
|
|
636
|
+
const body = await this.readBody(req);
|
|
637
|
+
const input = JSON.parse(body);
|
|
638
|
+
if (!input.iocId || !input.type || !input.source) {
|
|
639
|
+
this.sendJson(res, 400, {
|
|
640
|
+
ok: false,
|
|
641
|
+
error: 'Missing required fields: iocId, type, source',
|
|
642
|
+
});
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (!['positive', 'negative', 'false_positive'].includes(input.type)) {
|
|
646
|
+
this.sendJson(res, 400, {
|
|
647
|
+
ok: false,
|
|
648
|
+
error: 'type must be one of: positive, negative, false_positive',
|
|
649
|
+
});
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
// Verify IoC exists
|
|
653
|
+
const ioc = this.iocStore.getIoCById(input.iocId);
|
|
654
|
+
if (!ioc) {
|
|
655
|
+
this.sendJson(res, 404, { ok: false, error: 'IoC not found' });
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
input.source = this.sanitizeString(input.source, 200);
|
|
659
|
+
if (input.details)
|
|
660
|
+
input.details = this.sanitizeString(input.details, 1000);
|
|
661
|
+
const sighting = this.sightingStore.createSighting(input, auditCtx.actorHash);
|
|
662
|
+
this.auditLogger.log('sighting_create', 'sighting', String(sighting.id), {
|
|
663
|
+
...auditCtx,
|
|
664
|
+
details: { iocId: input.iocId, type: input.type, source: input.source },
|
|
665
|
+
});
|
|
666
|
+
this.sendJson(res, 201, { ok: true, data: sighting });
|
|
667
|
+
}
|
|
668
|
+
handleGetSightings(url, res) {
|
|
669
|
+
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
670
|
+
const iocId = Number(params.get('iocId') ?? '0');
|
|
671
|
+
if (!iocId) {
|
|
672
|
+
this.sendJson(res, 400, { ok: false, error: 'iocId query parameter required' });
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const result = this.sightingStore.getSightingsForIoC(iocId, {
|
|
676
|
+
page: Number(params.get('page') ?? '1'),
|
|
677
|
+
limit: Number(params.get('limit') ?? '50'),
|
|
678
|
+
});
|
|
679
|
+
const summary = this.sightingStore.getSightingSummary(iocId);
|
|
680
|
+
this.sendJson(res, 200, { ok: true, data: { ...result, summary } });
|
|
681
|
+
}
|
|
682
|
+
handleGetAuditLog(url, res) {
|
|
683
|
+
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
684
|
+
const query = {
|
|
685
|
+
action: params.get('action') || undefined,
|
|
686
|
+
entityType: params.get('entityType') || undefined,
|
|
687
|
+
entityId: params.get('entityId') || undefined,
|
|
688
|
+
since: params.get('since') || undefined,
|
|
689
|
+
limit: Number(params.get('limit') ?? '50'),
|
|
690
|
+
};
|
|
691
|
+
const result = this.auditLogger.query(query);
|
|
692
|
+
this.sendJson(res, 200, { ok: true, data: result });
|
|
693
|
+
}
|
|
694
|
+
// -------------------------------------------------------------------------
|
|
695
|
+
// Utility methods / 工具方法
|
|
696
|
+
// -------------------------------------------------------------------------
|
|
697
|
+
/**
|
|
698
|
+
* Anonymize IP by zeroing last two octets (/16 for IPv4).
|
|
699
|
+
* GDPR-compliant: /16 masking prevents re-identification.
|
|
700
|
+
* 匿名化 IP(IPv4 遮蔽最後兩個八位元組,符合 GDPR)
|
|
701
|
+
*/
|
|
702
|
+
anonymizeIP(ip) {
|
|
703
|
+
if (ip.includes('.')) {
|
|
704
|
+
const parts = ip.split('.');
|
|
705
|
+
if (parts.length === 4) {
|
|
706
|
+
parts[2] = '0';
|
|
707
|
+
parts[3] = '0';
|
|
708
|
+
return parts.join('.');
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (ip.includes(':')) {
|
|
712
|
+
const parts = ip.split(':');
|
|
713
|
+
// Zero last two groups for IPv6
|
|
714
|
+
if (parts.length >= 2) {
|
|
715
|
+
parts[parts.length - 1] = '0';
|
|
716
|
+
parts[parts.length - 2] = '0';
|
|
717
|
+
return parts.join(':');
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return ip;
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Verify API key using constant-time comparison to prevent timing attacks.
|
|
724
|
+
* 使用常數時間比較驗證 API key 以防止計時攻擊
|
|
725
|
+
*/
|
|
726
|
+
verifyApiKey(token) {
|
|
727
|
+
const tokenHash = createHash('sha256').update(token).digest();
|
|
728
|
+
for (const keyHash of this.hashedApiKeys) {
|
|
729
|
+
if (tokenHash.length === keyHash.length && timingSafeEqual(tokenHash, keyHash)) {
|
|
730
|
+
return true;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return false;
|
|
734
|
+
}
|
|
735
|
+
/** Rate limit check (supports per-IP and per-key) / 速率限制檢查 */
|
|
736
|
+
checkRateLimit(key, maxPerMinute) {
|
|
737
|
+
const limit = maxPerMinute ?? this.config.rateLimitPerMinute;
|
|
738
|
+
const now = Date.now();
|
|
739
|
+
const entry = this.rateLimits.get(key);
|
|
740
|
+
if (!entry || now > entry.resetAt) {
|
|
741
|
+
this.rateLimits.set(key, { count: 1, resetAt: now + 60_000 });
|
|
742
|
+
// Periodic cleanup: remove expired entries every ~500 checks
|
|
743
|
+
if (this.rateLimits.size > 500) {
|
|
744
|
+
for (const [k, v] of this.rateLimits) {
|
|
745
|
+
if (now > v.resetAt)
|
|
746
|
+
this.rateLimits.delete(k);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return true;
|
|
750
|
+
}
|
|
751
|
+
entry.count++;
|
|
752
|
+
return entry.count <= limit;
|
|
753
|
+
}
|
|
754
|
+
/** Validate IPv4 or IPv6 format / 驗證 IP 格式 */
|
|
755
|
+
isValidIP(ip) {
|
|
756
|
+
// IPv4
|
|
757
|
+
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?$/.test(ip)) {
|
|
758
|
+
const parts = ip.replace(/:\d+$/, '').split('.');
|
|
759
|
+
return parts.every((p) => {
|
|
760
|
+
const n = Number(p);
|
|
761
|
+
return n >= 0 && n <= 255;
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
// IPv6
|
|
765
|
+
if (ip.includes(':') && /^[0-9a-fA-F:]+$/.test(ip))
|
|
766
|
+
return true;
|
|
767
|
+
return false;
|
|
768
|
+
}
|
|
769
|
+
/** Validate ISO timestamp with reasonable bounds / 驗證 ISO 時間戳格式 */
|
|
770
|
+
isValidTimestamp(ts) {
|
|
771
|
+
const d = new Date(ts);
|
|
772
|
+
if (isNaN(d.getTime()))
|
|
773
|
+
return false;
|
|
774
|
+
const now = Date.now();
|
|
775
|
+
const oneHourAhead = now + 3_600_000;
|
|
776
|
+
const oneYearAgo = now - 365 * 24 * 3_600_000;
|
|
777
|
+
return d.getTime() >= oneYearAgo && d.getTime() <= oneHourAhead;
|
|
778
|
+
}
|
|
779
|
+
/** Sanitize string input: truncate and strip control characters / 清理字串輸入 */
|
|
780
|
+
sanitizeString(input, maxLength) {
|
|
781
|
+
// eslint-disable-next-line no-control-regex
|
|
782
|
+
return input.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '').slice(0, maxLength);
|
|
783
|
+
}
|
|
784
|
+
/** Read request body with size limit / 讀取請求主體 */
|
|
785
|
+
readBody(req) {
|
|
786
|
+
return new Promise((resolve, reject) => {
|
|
787
|
+
const chunks = [];
|
|
788
|
+
let size = 0;
|
|
789
|
+
const MAX_BODY = 1_048_576; // 1MB
|
|
790
|
+
req.on('data', (chunk) => {
|
|
791
|
+
size += chunk.length;
|
|
792
|
+
if (size > MAX_BODY) {
|
|
793
|
+
req.destroy();
|
|
794
|
+
reject(new Error('Request body too large'));
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
chunks.push(chunk);
|
|
798
|
+
});
|
|
799
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
800
|
+
req.on('error', reject);
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
/** Send JSON response / 發送 JSON 回應 */
|
|
804
|
+
sendJson(res, status, data) {
|
|
805
|
+
res.writeHead(status);
|
|
806
|
+
res.end(JSON.stringify(data));
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
//# sourceMappingURL=server.js.map
|