@panguard-ai/threat-cloud 0.2.0 → 0.2.2

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 (66) hide show
  1. package/dist/admin-dashboard.d.ts +11 -0
  2. package/dist/admin-dashboard.d.ts.map +1 -0
  3. package/dist/admin-dashboard.js +482 -0
  4. package/dist/admin-dashboard.js.map +1 -0
  5. package/dist/backup.d.ts +40 -0
  6. package/dist/backup.d.ts.map +1 -0
  7. package/dist/backup.js +123 -0
  8. package/dist/backup.js.map +1 -0
  9. package/dist/cli.js +24 -64
  10. package/dist/cli.js.map +1 -1
  11. package/dist/database.d.ts +78 -37
  12. package/dist/database.d.ts.map +1 -1
  13. package/dist/database.js +590 -324
  14. package/dist/database.js.map +1 -1
  15. package/dist/index.d.ts +4 -10
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -9
  18. package/dist/index.js.map +1 -1
  19. package/dist/llm-reviewer.d.ts +47 -0
  20. package/dist/llm-reviewer.d.ts.map +1 -0
  21. package/dist/llm-reviewer.js +203 -0
  22. package/dist/llm-reviewer.js.map +1 -0
  23. package/dist/server.d.ts +56 -63
  24. package/dist/server.d.ts.map +1 -1
  25. package/dist/server.js +525 -635
  26. package/dist/server.js.map +1 -1
  27. package/dist/types.d.ts +71 -301
  28. package/dist/types.d.ts.map +1 -1
  29. package/package.json +20 -18
  30. package/LICENSE +0 -21
  31. package/dist/audit-logger.d.ts +0 -46
  32. package/dist/audit-logger.d.ts.map +0 -1
  33. package/dist/audit-logger.js +0 -105
  34. package/dist/audit-logger.js.map +0 -1
  35. package/dist/correlation-engine.d.ts +0 -41
  36. package/dist/correlation-engine.d.ts.map +0 -1
  37. package/dist/correlation-engine.js +0 -313
  38. package/dist/correlation-engine.js.map +0 -1
  39. package/dist/feed-distributor.d.ts +0 -36
  40. package/dist/feed-distributor.d.ts.map +0 -1
  41. package/dist/feed-distributor.js +0 -125
  42. package/dist/feed-distributor.js.map +0 -1
  43. package/dist/ioc-store.d.ts +0 -83
  44. package/dist/ioc-store.d.ts.map +0 -1
  45. package/dist/ioc-store.js +0 -278
  46. package/dist/ioc-store.js.map +0 -1
  47. package/dist/query-handlers.d.ts +0 -40
  48. package/dist/query-handlers.d.ts.map +0 -1
  49. package/dist/query-handlers.js +0 -211
  50. package/dist/query-handlers.js.map +0 -1
  51. package/dist/reputation-engine.d.ts +0 -44
  52. package/dist/reputation-engine.d.ts.map +0 -1
  53. package/dist/reputation-engine.js +0 -169
  54. package/dist/reputation-engine.js.map +0 -1
  55. package/dist/rule-generator.d.ts +0 -47
  56. package/dist/rule-generator.d.ts.map +0 -1
  57. package/dist/rule-generator.js +0 -238
  58. package/dist/rule-generator.js.map +0 -1
  59. package/dist/scheduler.d.ts +0 -52
  60. package/dist/scheduler.d.ts.map +0 -1
  61. package/dist/scheduler.js +0 -143
  62. package/dist/scheduler.js.map +0 -1
  63. package/dist/sighting-store.d.ts +0 -61
  64. package/dist/sighting-store.d.ts.map +0 -1
  65. package/dist/sighting-store.js +0 -191
  66. package/dist/sighting-store.js.map +0 -1
package/dist/server.js CHANGED
@@ -3,27 +3,38 @@
3
3
  * 威脅雲 HTTP API 伺服器
4
4
  *
5
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
6
+ * - POST /api/threats Upload anonymized threat data (single or batch)
7
+ * - GET /api/rules Fetch rules (optional ?since= filter)
8
+ * - POST /api/rules Publish a new community rule
9
+ * - GET /api/stats Get threat statistics
10
+ * - POST /api/atr-proposals Submit or confirm ATR rule proposal
11
+ * - POST /api/atr-feedback Submit feedback on ATR rule
12
+ * - POST /api/skill-threats Submit skill threat from audit
13
+ * - GET /api/atr-rules Fetch confirmed ATR rules (?since= filter)
14
+ * - GET /api/yara-rules Fetch YARA rules (?since= filter)
15
+ * - GET /api/feeds/ip-blocklist IP blocklist feed (text/plain, ?minReputation=)
16
+ * - GET /api/feeds/domain-blocklist Domain blocklist feed (text/plain, ?minReputation=)
17
+ * - GET /api/skill-blacklist Community skill blacklist (aggregated threats)
18
+ * - GET /health Health check
14
19
  *
15
20
  * @module @panguard-ai/threat-cloud/server
16
21
  */
17
22
  import { createServer } from 'node:http';
18
- import { createHash, timingSafeEqual } from 'node:crypto';
23
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
24
+ import { join, basename, relative, dirname } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
19
26
  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
+ import { LLMReviewer } from './llm-reviewer.js';
28
+ import { getAdminHTML } from './admin-dashboard.js';
29
+ /** Simple structured logger for threat-cloud (no core dependency) */
30
+ const log = {
31
+ info: (msg) => {
32
+ process.stdout.write(`[threat-cloud] ${msg}\n`);
33
+ },
34
+ error: (msg, err) => {
35
+ process.stderr.write(`[threat-cloud] ERROR ${msg}${err ? `: ${err instanceof Error ? err.message : String(err)}` : ''}\n`);
36
+ },
37
+ };
27
38
  /**
28
39
  * Threat Cloud API Server
29
40
  * 威脅雲 API 伺服器
@@ -31,30 +42,18 @@ import { Scheduler } from './scheduler.js';
31
42
  export class ThreatCloudServer {
32
43
  server = null;
33
44
  db;
34
- iocStore;
35
- correlation;
36
- queryHandlers;
37
- feedDistributor;
38
- sightingStore;
39
- auditLogger;
40
- scheduler;
41
45
  config;
46
+ llmReviewer;
47
+ promotionTimer = null;
42
48
  rateLimits = new Map();
43
- /** Pre-hashed API keys for constant-time comparison */
44
- hashedApiKeys;
49
+ /** Promotion interval: 15 minutes / 推廣間隔:15 分鐘 */
50
+ static PROMOTION_INTERVAL_MS = 15 * 60 * 1000;
45
51
  constructor(config) {
46
52
  this.config = config;
47
53
  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());
54
+ this.llmReviewer = config.anthropicApiKey
55
+ ? new LLMReviewer(config.anthropicApiKey, this.db)
56
+ : null;
58
57
  }
59
58
  /** Start the server / 啟動伺服器 */
60
59
  async start() {
@@ -63,105 +62,94 @@ export class ThreatCloudServer {
63
62
  void this.handleRequest(req, res);
64
63
  });
65
64
  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.');
65
+ log.info(`Server started on ${this.config.host}:${this.config.port}`);
66
+ if (this.llmReviewer) {
67
+ log.info('LLM reviewer enabled for ATR proposal review');
70
68
  }
71
- this.scheduler.start();
69
+ // Auto-seed rules from bundled config/ if DB is empty (first startup)
70
+ const stats = this.db.getStats();
71
+ if (stats.totalRules === 0) {
72
+ log.info('First startup detected — seeding bundled rules...');
73
+ try {
74
+ const seeded = this.seedFromBundled();
75
+ log.info(`Seeded ${seeded} rules into database`);
76
+ }
77
+ catch (err) {
78
+ log.error('Rule seeding failed', err);
79
+ }
80
+ }
81
+ else {
82
+ log.info(`Database: ${stats.totalRules} rules, ${stats.totalThreats} threats`);
83
+ }
84
+ // Backfill classification for existing unclassified rules (one-time on startup)
85
+ try {
86
+ const backfilled = this.db.backfillClassification();
87
+ if (backfilled > 0) {
88
+ log.info(`Backfilled classification for ${backfilled} rules`);
89
+ }
90
+ }
91
+ catch (err) {
92
+ log.error('Classification backfill failed', err);
93
+ }
94
+ // Start promotion cron (every 15 minutes)
95
+ this.promotionTimer = setInterval(() => {
96
+ try {
97
+ const promoted = this.db.promoteConfirmedProposals();
98
+ if (promoted > 0) {
99
+ log.info(`Promotion cycle: ${promoted} proposal(s) promoted to rules`);
100
+ }
101
+ }
102
+ catch (err) {
103
+ log.error('Promotion cycle failed', err);
104
+ }
105
+ }, ThreatCloudServer.PROMOTION_INTERVAL_MS);
72
106
  resolve();
73
107
  });
74
108
  });
75
109
  }
76
- /** Stop the server gracefully / 優雅停止伺服器 */
110
+ /** Stop the server / 停止伺服器 */
77
111
  async stop() {
78
- this.scheduler.stop();
79
112
  return new Promise((resolve) => {
113
+ if (this.promotionTimer) {
114
+ clearInterval(this.promotionTimer);
115
+ this.promotionTimer = null;
116
+ }
117
+ this.db.close();
80
118
  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);
119
+ this.server.close(() => resolve());
91
120
  }
92
121
  else {
93
- this.db.close();
94
122
  resolve();
95
123
  }
96
124
  });
97
125
  }
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
126
  async handleRequest(req, res) {
111
127
  // Security headers
112
128
  res.setHeader('X-Content-Type-Options', 'nosniff');
113
129
  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
130
  res.setHeader('Content-Type', 'application/json');
121
131
  const clientIP = req.socket.remoteAddress ?? 'unknown';
122
- // Rate limiting (per client IP)
132
+ // Rate limiting
123
133
  if (!this.checkRateLimit(clientIP)) {
124
134
  this.sendJson(res, 429, { ok: false, error: 'Rate limit exceeded' });
125
135
  return;
126
136
  }
127
- // API key verification
137
+ // API key verification (skip for health check)
128
138
  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
- }
139
+ const pathname = url.split('?')[0];
140
+ if (pathname !== '/health' && this.config.apiKeyRequired) {
148
141
  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' });
142
+ const token = authHeader.replace('Bearer ', '');
143
+ if (!this.config.apiKeys.includes(token)) {
144
+ this.sendJson(res, 401, { ok: false, error: 'Invalid API key' });
158
145
  return;
159
146
  }
160
147
  }
161
- // CORS
162
- const allowedOrigins = (process.env['CORS_ALLOWED_ORIGINS'] ?? 'https://panguard.ai,https://www.panguard.ai').split(',');
148
+ // CORS — restrict to known origins
149
+ const allowedOrigins = (process.env['CORS_ALLOWED_ORIGINS'] ??
150
+ 'https://panguard.ai,https://www.panguard.ai,https://tc.panguard.ai,https://get.panguard.ai,https://docs.panguard.ai').split(',');
163
151
  const origin = req.headers.origin ?? '';
164
- if (origin && allowedOrigins.includes(origin)) {
152
+ if (allowedOrigins.includes(origin)) {
165
153
  res.setHeader('Access-Control-Allow-Origin', origin);
166
154
  }
167
155
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
@@ -171,622 +159,407 @@ export class ThreatCloudServer {
171
159
  res.end();
172
160
  return;
173
161
  }
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
162
  try {
185
- // Route matching
186
- if (pathname === '/health') {
187
- try {
188
- this.db.getDB().prepare('SELECT 1').get();
163
+ switch (pathname) {
164
+ case '/health':
189
165
  this.sendJson(res, 200, {
190
166
  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' },
167
+ data: { status: 'healthy', uptime: process.uptime() },
198
168
  });
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;
169
+ break;
170
+ case '/admin':
171
+ this.serveAdminDashboard(req, res);
172
+ break;
173
+ case '/api/threats':
174
+ if (req.method === 'POST') {
175
+ await this.handlePostThreat(req, res);
176
+ }
177
+ else {
178
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
179
+ }
180
+ break;
181
+ case '/api/rules':
182
+ if (req.method === 'GET') {
183
+ this.handleGetRules(url, res);
184
+ }
185
+ else if (req.method === 'POST') {
186
+ if (!this.checkAdminAuth(req)) {
187
+ this.sendJson(res, 403, {
188
+ ok: false,
189
+ error: 'Admin API key required for rule publishing',
190
+ });
191
+ break;
192
+ }
193
+ await this.handlePostRule(req, res);
194
+ }
195
+ else {
196
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
197
+ }
198
+ break;
199
+ case '/api/stats':
200
+ if (req.method === 'GET') {
201
+ this.handleGetStats(res);
202
+ }
203
+ else {
204
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
205
+ }
206
+ break;
207
+ case '/api/atr-proposals':
208
+ if (req.method === 'POST') {
209
+ await this.handlePostATRProposal(req, res);
210
+ }
211
+ else {
212
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
213
+ }
214
+ break;
215
+ case '/api/atr-feedback':
216
+ if (req.method === 'POST') {
217
+ await this.handlePostATRFeedback(req, res);
218
+ }
219
+ else {
220
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
221
+ }
222
+ break;
223
+ case '/api/skill-threats':
224
+ if (req.method === 'POST') {
225
+ await this.handlePostSkillThreat(req, res);
226
+ }
227
+ else {
228
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
229
+ }
230
+ break;
231
+ case '/api/atr-rules':
232
+ if (req.method === 'GET') {
233
+ this.handleGetATRRules(url, res);
234
+ }
235
+ else {
236
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
237
+ }
238
+ break;
239
+ case '/api/yara-rules':
240
+ if (req.method === 'GET') {
241
+ this.handleGetYaraRules(url, res);
242
+ }
243
+ else {
244
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
245
+ }
246
+ break;
247
+ case '/api/feeds/ip-blocklist':
248
+ if (req.method === 'GET') {
249
+ this.handleGetIPBlocklist(url, res);
250
+ }
251
+ else {
252
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
253
+ }
254
+ break;
255
+ case '/api/feeds/domain-blocklist':
256
+ if (req.method === 'GET') {
257
+ this.handleGetDomainBlocklist(url, res);
258
+ }
259
+ else {
260
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
261
+ }
262
+ break;
263
+ case '/api/skill-whitelist':
264
+ if (req.method === 'GET') {
265
+ this.handleGetSkillWhitelist(res);
266
+ }
267
+ else if (req.method === 'POST') {
268
+ await this.handlePostSkillWhitelist(req, res);
269
+ }
270
+ else {
271
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
272
+ }
273
+ break;
274
+ case '/api/skill-blacklist':
275
+ if (req.method === 'GET') {
276
+ this.handleGetSkillBlacklist(url, res);
277
+ }
278
+ else {
279
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
280
+ }
281
+ break;
282
+ default:
283
+ this.sendJson(res, 404, { ok: false, error: 'Not found' });
298
284
  }
299
- this.sendJson(res, 404, { ok: false, error: 'Not found' });
300
285
  }
301
286
  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 });
287
+ log.error('Request failed', err);
288
+ this.sendJson(res, 500, { ok: false, error: 'Internal server error' });
311
289
  }
312
290
  }
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) {
291
+ /** POST /api/threats - Upload anonymized threat data (single or batch) */
292
+ async handlePostThreat(req, res) {
388
293
  const body = await this.readBody(req);
389
294
  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) {
295
+ // Support both single object and batch { events: [...] } format
296
+ const events = 'events' in parsed && Array.isArray(parsed.events)
297
+ ? parsed.events
298
+ : [parsed];
299
+ for (const data of events) {
398
300
  // 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++;
301
+ if (!data.attackSourceIP ||
302
+ !data.attackType ||
303
+ !data.mitreTechnique ||
304
+ !data.sigmaRuleMatched ||
305
+ !data.timestamp ||
306
+ !data.region) {
307
+ this.sendJson(res, 400, {
308
+ ok: false,
309
+ error: 'Missing required fields: attackSourceIP, attackType, mitreTechnique, sigmaRuleMatched, timestamp, region',
310
+ });
311
+ return;
448
312
  }
313
+ // Anonymize IP further (zero last octet if not already)
314
+ data.attackSourceIP = this.anonymizeIP(data.attackSourceIP);
315
+ this.db.insertThreat(data);
449
316
  }
450
- // Audit log
451
- this.auditLogger.log('trap_intel_upload', 'trap_intel', 'batch', {
452
- ...auditCtx,
453
- details: { accepted, duplicates, batchSize: items.length },
454
- });
455
317
  this.sendJson(res, 201, {
456
318
  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'),
319
+ data: { message: 'Threat data received', count: events.length },
477
320
  });
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
321
  }
490
- // -------------------------------------------------------------------------
491
- // Existing handlers / 既有處理器
492
- // -------------------------------------------------------------------------
493
- /** GET /api/rules?since=<ISO timestamp> */
322
+ /** GET /api/rules?since=<ISO>&category=<cat>&severity=<sev>&source=<src> */
494
323
  handleGetRules(url, res) {
495
324
  const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
496
325
  const since = params.get('since');
497
- const rules = since ? this.db.getRulesSince(since) : this.db.getAllRules();
326
+ const filters = {
327
+ category: params.get('category') ?? undefined,
328
+ severity: params.get('severity') ?? undefined,
329
+ source: params.get('source') ?? undefined,
330
+ };
331
+ // Cache for 1 hour — rules rarely change, let CDN absorb traffic
332
+ res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=3600');
333
+ const rules = since
334
+ ? this.db.getRulesSince(since, filters)
335
+ : this.db.getAllRules(5000, filters);
498
336
  this.sendJson(res, 200, rules);
499
337
  }
500
- /** POST /api/rules */
501
- async handlePostRule(req, res, auditCtx) {
338
+ /** POST /api/rules - Publish rules (single or batch) */
339
+ async handlePostRule(req, res) {
502
340
  const body = await this.readBody(req);
503
- const rule = JSON.parse(body);
504
- if (!rule.ruleId || !rule.ruleContent || !rule.source) {
341
+ const parsed = JSON.parse(body);
342
+ // Support both single object and batch { rules: [...] } format
343
+ const rules = 'rules' in parsed && Array.isArray(parsed.rules) ? parsed.rules : [parsed];
344
+ const now = new Date().toISOString();
345
+ let count = 0;
346
+ for (const rule of rules) {
347
+ if (!rule.ruleId || !rule.ruleContent || !rule.source)
348
+ continue;
349
+ rule.publishedAt = rule.publishedAt || now;
350
+ this.db.upsertRule(rule);
351
+ count++;
352
+ }
353
+ this.sendJson(res, 201, { ok: true, data: { message: `${count} rule(s) published`, count } });
354
+ }
355
+ /** GET /api/stats */
356
+ handleGetStats(res) {
357
+ res.setHeader('Cache-Control', 'public, max-age=300, s-maxage=300');
358
+ const stats = this.db.getStats();
359
+ this.sendJson(res, 200, { ok: true, data: stats });
360
+ }
361
+ /** POST /api/atr-proposals - Submit or confirm an ATR rule proposal */
362
+ async handlePostATRProposal(req, res) {
363
+ const body = await this.readBody(req);
364
+ const data = JSON.parse(body);
365
+ const clientId = req.headers['x-panguard-client-id'] ?? undefined;
366
+ if (!data.patternHash || !data.ruleContent) {
505
367
  this.sendJson(res, 400, {
506
368
  ok: false,
507
- error: 'Missing required fields: ruleId, ruleContent, source',
369
+ error: 'Missing required fields: patternHash, ruleContent',
508
370
  });
509
371
  return;
510
372
  }
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) {
373
+ // Check if a proposal with the same patternHash already exists
374
+ const proposals = this.db.getATRProposals();
375
+ const existing = proposals.find((p) => p['pattern_hash'] === data.patternHash);
376
+ if (existing) {
377
+ this.db.confirmATRProposal(data.patternHash);
378
+ this.sendJson(res, 200, {
379
+ ok: true,
380
+ data: { message: 'Proposal confirmed', patternHash: data.patternHash },
381
+ });
382
+ }
383
+ else {
384
+ const proposal = {
385
+ ...data,
386
+ clientId: clientId ?? data.clientId,
387
+ };
388
+ this.db.insertATRProposal(proposal);
389
+ // Fire-and-forget LLM review on first submission only
390
+ if (this.llmReviewer?.isAvailable()) {
391
+ void this.llmReviewer.reviewProposal(data.patternHash, data.ruleContent).catch((err) => {
392
+ log.error(`LLM review failed for ${data.patternHash}`, err);
393
+ });
394
+ }
395
+ this.sendJson(res, 201, {
396
+ ok: true,
397
+ data: { message: 'Proposal submitted', patternHash: data.patternHash },
398
+ });
399
+ }
400
+ }
401
+ /** POST /api/atr-feedback - Submit feedback on an ATR rule */
402
+ async handlePostATRFeedback(req, res) {
403
+ const body = await this.readBody(req);
404
+ const data = JSON.parse(body);
405
+ const clientId = req.headers['x-panguard-client-id'] ?? undefined;
406
+ if (!data.ruleId || typeof data.isTruePositive !== 'boolean') {
514
407
  this.sendJson(res, 400, {
515
408
  ok: false,
516
- error: `ruleContent exceeds maximum size of ${MAX_RULE_CONTENT_SIZE} bytes`,
409
+ error: 'Missing required fields: ruleId (string), isTruePositive (boolean)',
517
410
  });
518
411
  return;
519
412
  }
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) {
413
+ this.db.insertATRFeedback(data.ruleId, data.isTruePositive, clientId);
414
+ this.sendJson(res, 201, { ok: true, data: { message: 'Feedback received' } });
415
+ }
416
+ /** POST /api/skill-threats - Submit skill threat from audit */
417
+ async handlePostSkillThreat(req, res) {
418
+ const body = await this.readBody(req);
419
+ const data = JSON.parse(body);
420
+ const clientId = req.headers['x-panguard-client-id'] ?? undefined;
421
+ if (!data.skillHash || !data.skillName) {
523
422
  this.sendJson(res, 400, {
524
423
  ok: false,
525
- error: 'ruleContent must be valid Sigma YAML (missing title: or detection:)',
424
+ error: 'Missing required fields: skillHash, skillName',
526
425
  });
527
426
  return;
528
427
  }
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' });
428
+ if (typeof data.riskScore !== 'number' || data.riskScore < 0 || data.riskScore > 100) {
429
+ this.sendJson(res, 400, { ok: false, error: 'riskScore must be a number between 0 and 100' });
564
430
  return;
565
431
  }
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' });
432
+ if (!data.riskLevel || typeof data.riskLevel !== 'string') {
433
+ this.sendJson(res, 400, { ok: false, error: 'riskLevel is required and must be a string' });
578
434
  return;
579
435
  }
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 });
436
+ const submission = {
437
+ ...data,
438
+ clientId: clientId ?? data.clientId,
439
+ };
440
+ this.db.insertSkillThreat(submission);
441
+ this.sendJson(res, 201, { ok: true, data: { message: 'Skill threat received' } });
588
442
  }
589
- handleTrends(url, res) {
443
+ /** GET /api/atr-rules?since=<ISO> - Fetch confirmed/promoted ATR rules */
444
+ handleGetATRRules(url, res) {
445
+ res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=3600');
590
446
  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 });
447
+ const since = params.get('since') ?? undefined;
448
+ const rules = this.db.getConfirmedATRRules(since);
449
+ this.sendJson(res, 200, rules);
594
450
  }
595
- handleMitreHeatmap(url, res) {
451
+ /** GET /api/yara-rules?since=<ISO> - Fetch YARA rules */
452
+ handleGetYaraRules(url, res) {
453
+ res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=3600');
596
454
  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 });
455
+ const since = params.get('since') ?? undefined;
456
+ const rules = this.db.getRulesBySource('yara', since);
457
+ this.sendJson(res, 200, rules);
599
458
  }
600
- // -------------------------------------------------------------------------
601
- // Feed handlers / Feed 處理器
602
- // -------------------------------------------------------------------------
603
- handleIPBlocklist(url, res) {
459
+ /** GET /api/feeds/ip-blocklist?minReputation=70 - IP blocklist feed (plain text) */
460
+ handleGetIPBlocklist(url, res) {
604
461
  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);
462
+ const minReputation = Number(params.get('minReputation') ?? '70');
463
+ const ips = this.db.getIPBlocklist(minReputation);
607
464
  res.setHeader('Content-Type', 'text/plain');
465
+ res.setHeader('Cache-Control', 'public, max-age=1800, s-maxage=1800');
608
466
  res.writeHead(200);
609
- res.end(blocklist);
467
+ res.end(ips.join('\n'));
610
468
  }
611
- handleDomainBlocklist(url, res) {
469
+ /** GET /api/feeds/domain-blocklist?minReputation=70 - Domain blocklist feed (plain text) */
470
+ handleGetDomainBlocklist(url, res) {
612
471
  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);
472
+ const minReputation = Number(params.get('minReputation') ?? '70');
473
+ const domains = this.db.getDomainBlocklist(minReputation);
615
474
  res.setHeader('Content-Type', 'text/plain');
475
+ res.setHeader('Cache-Control', 'public, max-age=1800, s-maxage=1800');
616
476
  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 });
477
+ res.end(domains.join('\n'));
628
478
  }
629
- // -------------------------------------------------------------------------
630
- // Utility methods / 工具方法
631
- // -------------------------------------------------------------------------
632
- // -------------------------------------------------------------------------
633
- // Sighting + Audit handlers
634
- // -------------------------------------------------------------------------
635
- async handlePostSighting(req, res, auditCtx) {
479
+ /** POST /api/skill-whitelist - Report a safe skill (audit passed) */
480
+ async handlePostSkillWhitelist(req, res) {
636
481
  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;
482
+ const data = JSON.parse(body);
483
+ const skills = 'skills' in data && Array.isArray(data.skills)
484
+ ? data.skills
485
+ : [data];
486
+ let count = 0;
487
+ for (const skill of skills) {
488
+ if (!skill.skillName || typeof skill.skillName !== 'string')
489
+ continue;
490
+ this.db.reportSafeSkill(skill.skillName, skill.fingerprintHash);
491
+ count++;
674
492
  }
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 } });
493
+ this.sendJson(res, 201, { ok: true, data: { message: `${count} skill(s) reported`, count } });
681
494
  }
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 });
495
+ /** GET /api/skill-whitelist - Fetch community-confirmed safe skills */
496
+ handleGetSkillWhitelist(res) {
497
+ const whitelist = this.db.getSkillWhitelist();
498
+ this.sendJson(res, 200, { ok: true, data: whitelist });
693
499
  }
694
- // -------------------------------------------------------------------------
695
- // Utility methods / 工具方法
696
- // -------------------------------------------------------------------------
697
500
  /**
698
- * Anonymize IP by zeroing last two octets (/16 for IPv4).
699
- * GDPR-compliant: /16 masking prevents re-identification.
700
- * 匿名化 IP(IPv4 遮蔽最後兩個八位元組,符合 GDPR)
501
+ * GET /api/skill-blacklist?minReports=3&minAvgRisk=70
502
+ * Fetch community skill blacklist (aggregated from skill threat reports)
503
+ * 取得社群技能黑名單(從技能威脅回報聚合)
701
504
  */
505
+ handleGetSkillBlacklist(url, res) {
506
+ const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
507
+ const minReports = Number(params.get('minReports') ?? '3');
508
+ const minAvgRisk = Number(params.get('minAvgRisk') ?? '70');
509
+ res.setHeader('Cache-Control', 'public, max-age=1800, s-maxage=1800');
510
+ const blacklist = this.db.getSkillBlacklist(minReports, minAvgRisk);
511
+ this.sendJson(res, 200, { ok: true, data: blacklist });
512
+ }
513
+ /** Anonymize IP by zeroing last octet / 匿名化 IP */
702
514
  anonymizeIP(ip) {
703
515
  if (ip.includes('.')) {
704
516
  const parts = ip.split('.');
705
517
  if (parts.length === 4) {
706
- parts[2] = '0';
707
518
  parts[3] = '0';
708
519
  return parts.join('.');
709
520
  }
710
521
  }
522
+ // IPv6: truncate last segment
711
523
  if (ip.includes(':')) {
712
524
  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
- }
525
+ parts[parts.length - 1] = '0';
526
+ return parts.join(':');
719
527
  }
720
528
  return ip;
721
529
  }
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;
530
+ /** Serve admin dashboard HTML (requires admin key or returns login page) */
531
+ serveAdminDashboard(_req, res) {
532
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
533
+ res.setHeader('Cache-Control', 'no-store');
534
+ res.setHeader('X-Robots-Tag', 'noindex, nofollow');
535
+ res.writeHead(200);
536
+ res.end(getAdminHTML());
537
+ }
538
+ /** Check admin API key for write-protected endpoints / 檢查管理員 API 金鑰 */
539
+ checkAdminAuth(req) {
540
+ if (!this.config.adminApiKey)
541
+ return true; // no admin key configured = open
542
+ const authHeader = req.headers.authorization ?? '';
543
+ const token = authHeader.replace('Bearer ', '');
544
+ return token === this.config.adminApiKey;
545
+ }
546
+ /** Rate limit check / 速率限制檢查 */
547
+ checkRateLimit(ip) {
738
548
  const now = Date.now();
739
- const entry = this.rateLimits.get(key);
549
+ const entry = this.rateLimits.get(ip);
740
550
  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
- }
551
+ this.rateLimits.set(ip, { count: 1, resetAt: now + 60_000 });
749
552
  return true;
750
553
  }
751
554
  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);
555
+ return entry.count <= this.config.rateLimitPerMinute;
783
556
  }
784
- /** Read request body with size limit / 讀取請求主體 */
557
+ /** Read request body with size limit / 讀取請求主體(含大小限制) */
785
558
  readBody(req) {
786
559
  return new Promise((resolve, reject) => {
787
560
  const chunks = [];
788
561
  let size = 0;
789
- const MAX_BODY = 1_048_576; // 1MB
562
+ const MAX_BODY = 52_428_800; // 50MB (for batch rule uploads)
790
563
  req.on('data', (chunk) => {
791
564
  size += chunk.length;
792
565
  if (size > MAX_BODY) {
@@ -805,5 +578,122 @@ export class ThreatCloudServer {
805
578
  res.writeHead(status);
806
579
  res.end(JSON.stringify(data));
807
580
  }
581
+ /**
582
+ * Seed rules from bundled config/ directory on first startup.
583
+ * Looks for config/ in cwd, relative to this file, or common Docker paths.
584
+ */
585
+ seedFromBundled() {
586
+ const __dirname = dirname(fileURLToPath(import.meta.url));
587
+ const candidates = [
588
+ join(process.cwd(), 'config'),
589
+ join(__dirname, '..', '..', '..', 'config'), // monorepo: packages/threat-cloud/dist -> config
590
+ join(__dirname, '..', '..', '..', '..', 'config'), // deeper nesting
591
+ '/app/config', // Docker standard
592
+ ];
593
+ const configDir = candidates.find((d) => {
594
+ try {
595
+ return statSync(d).isDirectory();
596
+ }
597
+ catch {
598
+ return false;
599
+ }
600
+ });
601
+ if (!configDir) {
602
+ log.info(`No config/ directory found (searched: ${candidates.join(', ')})`);
603
+ return 0;
604
+ }
605
+ log.info(`Using config directory: ${configDir}`);
606
+ const now = new Date().toISOString();
607
+ let seeded = 0;
608
+ const collectFiles = (dir, extensions) => {
609
+ const results = [];
610
+ try {
611
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
612
+ const fullPath = join(dir, entry.name);
613
+ if (entry.isDirectory()) {
614
+ results.push(...collectFiles(fullPath, extensions));
615
+ }
616
+ else if (extensions.some((ext) => entry.name.endsWith(ext))) {
617
+ results.push(fullPath);
618
+ }
619
+ }
620
+ }
621
+ catch (err) {
622
+ log.error(`Cannot read directory ${dir}`, err);
623
+ }
624
+ return results;
625
+ };
626
+ // Sigma rules
627
+ const sigmaDir = join(configDir, 'sigma-rules');
628
+ try {
629
+ const files = collectFiles(sigmaDir, ['.yml', '.yaml']);
630
+ for (const file of files) {
631
+ const content = readFileSync(file, 'utf-8');
632
+ const ruleId = `sigma:${relative(sigmaDir, file).replace(/\//g, ':')}`;
633
+ this.db.upsertRule({ ruleId, ruleContent: content, publishedAt: now, source: 'sigma' });
634
+ seeded++;
635
+ }
636
+ log.info(` Sigma: ${files.length} files`);
637
+ }
638
+ catch (err) {
639
+ log.error('Sigma seeding failed', err);
640
+ }
641
+ // YARA rules (split multi-rule files)
642
+ const yaraDir = join(configDir, 'yara-rules');
643
+ try {
644
+ const files = collectFiles(yaraDir, ['.yar', '.yara']);
645
+ for (const file of files) {
646
+ const content = readFileSync(file, 'utf-8');
647
+ const ruleMatches = content.match(/rule\s+\w+/g);
648
+ if (ruleMatches && ruleMatches.length > 1) {
649
+ for (const match of ruleMatches) {
650
+ const ruleName = match.replace('rule ', '');
651
+ const ruleId = `yara:${basename(file, '.yar').replace('.yara', '')}:${ruleName}`;
652
+ this.db.upsertRule({ ruleId, ruleContent: content, publishedAt: now, source: 'yara' });
653
+ seeded++;
654
+ }
655
+ }
656
+ else {
657
+ const ruleId = `yara:${relative(yaraDir, file).replace(/\//g, ':')}`;
658
+ this.db.upsertRule({ ruleId, ruleContent: content, publishedAt: now, source: 'yara' });
659
+ seeded++;
660
+ }
661
+ }
662
+ log.info(` YARA: ${files.length} files`);
663
+ }
664
+ catch (err) {
665
+ log.error('YARA seeding failed', err);
666
+ }
667
+ // ATR rules
668
+ const atrCandidates = [
669
+ join(process.cwd(), 'node_modules', 'agent-threat-rules', 'rules'),
670
+ join(__dirname, '..', '..', 'atr', 'rules'),
671
+ join(__dirname, '..', '..', '..', 'packages', 'atr', 'rules'),
672
+ ];
673
+ const atrDir = atrCandidates.find((d) => {
674
+ try {
675
+ return statSync(d).isDirectory();
676
+ }
677
+ catch {
678
+ return false;
679
+ }
680
+ });
681
+ if (atrDir) {
682
+ try {
683
+ const files = collectFiles(atrDir, ['.yaml', '.yml']);
684
+ for (const file of files) {
685
+ const content = readFileSync(file, 'utf-8');
686
+ const ruleId = `atr:${relative(atrDir, file).replace(/\//g, ':')}`;
687
+ this.db.upsertRule({ ruleId, ruleContent: content, publishedAt: now, source: 'atr' });
688
+ seeded++;
689
+ }
690
+ log.info(` ATR: ${files.length} files`);
691
+ }
692
+ catch (err) {
693
+ log.error('ATR seeding failed', err);
694
+ }
695
+ }
696
+ return seeded;
697
+ }
808
698
  }
809
699
  //# sourceMappingURL=server.js.map