@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.
Files changed (57) hide show
  1. package/dist/audit-logger.d.ts +46 -0
  2. package/dist/audit-logger.d.ts.map +1 -0
  3. package/dist/audit-logger.js +105 -0
  4. package/dist/audit-logger.js.map +1 -0
  5. package/dist/cli.d.ts +9 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +115 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/correlation-engine.d.ts +41 -0
  10. package/dist/correlation-engine.d.ts.map +1 -0
  11. package/dist/correlation-engine.js +313 -0
  12. package/dist/correlation-engine.js.map +1 -0
  13. package/dist/database.d.ts +63 -0
  14. package/dist/database.d.ts.map +1 -0
  15. package/dist/database.js +444 -0
  16. package/dist/database.js.map +1 -0
  17. package/dist/feed-distributor.d.ts +36 -0
  18. package/dist/feed-distributor.d.ts.map +1 -0
  19. package/dist/feed-distributor.js +125 -0
  20. package/dist/feed-distributor.js.map +1 -0
  21. package/dist/index.d.ts +13 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +12 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/ioc-store.d.ts +83 -0
  26. package/dist/ioc-store.d.ts.map +1 -0
  27. package/dist/ioc-store.js +278 -0
  28. package/dist/ioc-store.js.map +1 -0
  29. package/dist/query-handlers.d.ts +40 -0
  30. package/dist/query-handlers.d.ts.map +1 -0
  31. package/dist/query-handlers.js +211 -0
  32. package/dist/query-handlers.js.map +1 -0
  33. package/dist/reputation-engine.d.ts +44 -0
  34. package/dist/reputation-engine.d.ts.map +1 -0
  35. package/dist/reputation-engine.js +169 -0
  36. package/dist/reputation-engine.js.map +1 -0
  37. package/dist/rule-generator.d.ts +47 -0
  38. package/dist/rule-generator.d.ts.map +1 -0
  39. package/dist/rule-generator.js +238 -0
  40. package/dist/rule-generator.js.map +1 -0
  41. package/dist/scheduler.d.ts +52 -0
  42. package/dist/scheduler.d.ts.map +1 -0
  43. package/dist/scheduler.js +143 -0
  44. package/dist/scheduler.js.map +1 -0
  45. package/dist/server.d.ts +99 -0
  46. package/dist/server.d.ts.map +1 -0
  47. package/dist/server.js +809 -0
  48. package/dist/server.js.map +1 -0
  49. package/dist/sighting-store.d.ts +61 -0
  50. package/dist/sighting-store.d.ts.map +1 -0
  51. package/dist/sighting-store.js +191 -0
  52. package/dist/sighting-store.js.map +1 -0
  53. package/dist/types.d.ts +352 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +6 -0
  56. package/dist/types.js.map +1 -0
  57. 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