@panguard-ai/threat-cloud 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Schema migration system for Threat Cloud database
3
+ * 威脅雲資料庫 schema 遷移系統
4
+ *
5
+ * Replaces the fragile try-catch ALTER TABLE pattern with numbered migrations.
6
+ * Each migration runs exactly once, tracked by a schema_version table.
7
+ *
8
+ * @module @panguard-ai/threat-cloud/migrations
9
+ */
10
+ /**
11
+ * All migrations in order. New migrations MUST be appended with
12
+ * the next sequential version number. Never remove or reorder.
13
+ * 所有遷移按順序排列,新遷移必須追加下一個版本號。
14
+ */
15
+ export const migrations = [
16
+ {
17
+ version: 1,
18
+ name: 'add_rules_classification_columns',
19
+ up: (db) => {
20
+ // These columns may already exist from the original CREATE TABLE.
21
+ // SQLite does not support IF NOT EXISTS for ADD COLUMN, so we
22
+ // check the table_info pragma before adding each one.
23
+ const existing = db
24
+ .prepare("PRAGMA table_info('rules')")
25
+ .all();
26
+ const columnNames = new Set(existing.map((c) => c.name));
27
+ const columnsToAdd = [
28
+ { name: 'category', type: 'TEXT' },
29
+ { name: 'severity', type: 'TEXT' },
30
+ { name: 'mitre_techniques', type: 'TEXT' },
31
+ { name: 'tags', type: 'TEXT' },
32
+ ];
33
+ for (const col of columnsToAdd) {
34
+ if (!columnNames.has(col.name)) {
35
+ db.exec(`ALTER TABLE rules ADD COLUMN ${col.name} ${col.type}`);
36
+ }
37
+ }
38
+ },
39
+ },
40
+ {
41
+ version: 2,
42
+ name: 'create_audit_log_table',
43
+ up: (db) => {
44
+ db.exec(`
45
+ CREATE TABLE IF NOT EXISTS audit_log (
46
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
47
+ timestamp TEXT NOT NULL DEFAULT (datetime('now')),
48
+ actor TEXT NOT NULL,
49
+ action TEXT NOT NULL,
50
+ resource_type TEXT NOT NULL,
51
+ resource_id TEXT,
52
+ details TEXT,
53
+ ip_address TEXT
54
+ );
55
+
56
+ CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp);
57
+ CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor);
58
+ CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
59
+ CREATE INDEX IF NOT EXISTS idx_audit_log_resource ON audit_log(resource_type);
60
+ `);
61
+ },
62
+ },
63
+ ];
64
+ /**
65
+ * Ensure the schema_version tracking table exists.
66
+ * 確保 schema_version 追蹤資料表存在。
67
+ */
68
+ function ensureVersionTable(db) {
69
+ db.exec(`
70
+ CREATE TABLE IF NOT EXISTS schema_version (
71
+ version INTEGER NOT NULL
72
+ )
73
+ `);
74
+ // Seed with version 0 if empty (fresh database)
75
+ const row = db.prepare('SELECT version FROM schema_version').get();
76
+ if (row === undefined) {
77
+ db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(0);
78
+ }
79
+ }
80
+ /**
81
+ * Get the current schema version from the database.
82
+ * 取得資料庫目前的 schema 版本。
83
+ */
84
+ function getCurrentVersion(db) {
85
+ const row = db.prepare('SELECT version FROM schema_version').get();
86
+ return row.version;
87
+ }
88
+ /**
89
+ * Run all pending migrations above the current schema version.
90
+ * Each migration runs inside a transaction for atomicity.
91
+ * 執行所有高於目前版本的待處理遷移。每個遷移在交易中執行以確保原子性。
92
+ *
93
+ * @returns The number of migrations applied
94
+ */
95
+ export function runMigrations(db) {
96
+ ensureVersionTable(db);
97
+ const currentVersion = getCurrentVersion(db);
98
+ const pending = migrations.filter((m) => m.version > currentVersion);
99
+ if (pending.length === 0) {
100
+ return 0;
101
+ }
102
+ let applied = 0;
103
+ for (const migration of pending) {
104
+ const runOne = db.transaction(() => {
105
+ console.log(`[threat-cloud] Running migration v${migration.version}: ${migration.name}`);
106
+ migration.up(db);
107
+ db.prepare('UPDATE schema_version SET version = ?').run(migration.version);
108
+ });
109
+ runOne();
110
+ applied++;
111
+ console.log(`[threat-cloud] Migration v${migration.version} applied successfully`);
112
+ }
113
+ console.log(`[threat-cloud] Schema up to date (v${getCurrentVersion(db)}, ${applied} migration(s) applied)`);
114
+ return applied;
115
+ }
116
+ //# sourceMappingURL=migrations.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrations.js","sourceRoot":"","sources":["../src/migrations.ts"],"names":[],"mappings":"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"}
package/dist/server.d.ts CHANGED
@@ -31,8 +31,12 @@ export declare class ThreatCloudServer {
31
31
  private readonly llmReviewer;
32
32
  private promotionTimer;
33
33
  private rateLimits;
34
+ private rateLimitCleanupTimer;
35
+ private statsCache;
34
36
  /** Promotion interval: 15 minutes / 推廣間隔:15 分鐘 */
35
37
  private static readonly PROMOTION_INTERVAL_MS;
38
+ /** Stats cache TTL: 60 seconds */
39
+ private static readonly STATS_CACHE_TTL_MS;
36
40
  constructor(config: ServerConfig);
37
41
  /** Start the server / 啟動伺服器 */
38
42
  start(): Promise<void>;
@@ -45,8 +49,14 @@ export declare class ThreatCloudServer {
45
49
  private handleGetRules;
46
50
  /** POST /api/rules - Publish rules (single or batch) */
47
51
  private handlePostRule;
48
- /** GET /api/stats */
52
+ /** GET /api/stats (cached 60s) */
49
53
  private handleGetStats;
54
+ /** GET /api/threats?page=1&limit=50 (admin-only, paginated) */
55
+ private handleGetThreats;
56
+ /** GET /api/atr-proposals?status=pending (admin-only) */
57
+ private handleGetATRProposals;
58
+ /** GET /api/skill-threats?limit=50 (admin-only) */
59
+ private handleGetSkillThreats;
50
60
  /** POST /api/atr-proposals - Submit or confirm an ATR rule proposal */
51
61
  private handlePostATRProposal;
52
62
  /** POST /api/atr-feedback - Submit feedback on an ATR rule */
@@ -71,9 +81,11 @@ export declare class ThreatCloudServer {
71
81
  * 取得社群技能黑名單(從技能威脅回報聚合)
72
82
  */
73
83
  private handleGetSkillBlacklist;
84
+ /** GET /api/audit-log?page=1&limit=50 (admin-only) */
85
+ private handleGetAuditLog;
74
86
  /** Anonymize IP by zeroing last octet / 匿名化 IP */
75
87
  private anonymizeIP;
76
- /** Serve admin dashboard HTML (requires admin key or returns login page) */
88
+ /** Serve admin dashboard HTML -- requires admin auth via query param or header */
77
89
  private serveAdminDashboard;
78
90
  /** Check admin API key for write-protected endpoints / 檢查管理員 API 金鑰 */
79
91
  private checkAdminAuth;
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AASH,OAAO,KAAK,EACV,YAAY,EAKb,MAAM,YAAY,CAAC;AAoBpB;;;GAGG;AACH,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,MAAM,CAAgD;IAC9D,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAgB;IACnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAe;IACtC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAqB;IACjD,OAAO,CAAC,cAAc,CAA+C;IACrE,OAAO,CAAC,UAAU,CAA0C;IAE5D,kDAAkD;IAClD,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,qBAAqB,CAAkB;gBAEnD,MAAM,EAAE,YAAY;IAQhC,+BAA+B;IACzB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAmD5B,8BAA8B;IACxB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAeb,aAAa;IA8K3B,0EAA0E;YAC5D,gBAAgB;IAuC9B,4EAA4E;IAC5E,OAAO,CAAC,cAAc;IAkBtB,wDAAwD;YAC1C,cAAc;IAoB5B,qBAAqB;IACrB,OAAO,CAAC,cAAc;IAMtB,uEAAuE;YACzD,qBAAqB;IA4CnC,8DAA8D;YAChD,qBAAqB;IAiBnC,+DAA+D;YACjD,qBAAqB;IA+BnC,0EAA0E;IAC1E,OAAO,CAAC,iBAAiB;IAQzB,yDAAyD;IACzD,OAAO,CAAC,kBAAkB;IAQ1B,oFAAoF;IACpF,OAAO,CAAC,oBAAoB;IAW5B,4FAA4F;IAC5F,OAAO,CAAC,wBAAwB;IAWhC,qEAAqE;YACvD,wBAAwB;IAqBtC,uEAAuE;IACvE,OAAO,CAAC,uBAAuB;IAK/B;;;;OAIG;IACH,OAAO,CAAC,uBAAuB;IAU/B,kDAAkD;IAClD,OAAO,CAAC,WAAW;IAiBnB,4EAA4E;IAC5E,OAAO,CAAC,mBAAmB;IAQ3B,uEAAuE;IACvE,OAAO,CAAC,cAAc;IAOtB,gCAAgC;IAChC,OAAO,CAAC,cAAc;IAWtB,wDAAwD;IACxD,OAAO,CAAC,QAAQ;IAoBhB,sCAAsC;IACtC,OAAO,CAAC,QAAQ;IAKhB;;;OAGG;IACH,OAAO,CAAC,eAAe;CAgHxB"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAUH,OAAO,KAAK,EACV,YAAY,EAKb,MAAM,YAAY,CAAC;AAkBpB;;;GAGG;AACH,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,MAAM,CAAgD;IAC9D,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAgB;IACnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAe;IACtC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAqB;IACjD,OAAO,CAAC,cAAc,CAA+C;IACrE,OAAO,CAAC,UAAU,CAA0C;IAC5D,OAAO,CAAC,qBAAqB,CAA+C;IAC5E,OAAO,CAAC,UAAU,CAAqD;IAEvE,kDAAkD;IAClD,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,qBAAqB,CAAkB;IAC/D,kCAAkC;IAClC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,kBAAkB,CAAU;gBAExC,MAAM,EAAE,YAAY;IAQhC,+BAA+B;IACzB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IA4D5B,8BAA8B;IACxB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAmBb,aAAa;IAmO3B,0EAA0E;YAC5D,gBAAgB;IA0C9B,4EAA4E;IAC5E,OAAO,CAAC,cAAc;IAmBtB,wDAAwD;YAC1C,cAAc;IAuB5B,kCAAkC;IAClC,OAAO,CAAC,cAAc;IAYtB,+DAA+D;IAC/D,OAAO,CAAC,gBAAgB;IAcxB,yDAAyD;IACzD,OAAO,CAAC,qBAAqB;IAO7B,mDAAmD;IACnD,OAAO,CAAC,qBAAqB;IAO7B,uEAAuE;YACzD,qBAAqB;IA4CnC,8DAA8D;YAChD,qBAAqB;IAiBnC,+DAA+D;YACjD,qBAAqB;IAmCnC,0EAA0E;IAC1E,OAAO,CAAC,iBAAiB;IASzB,yDAAyD;IACzD,OAAO,CAAC,kBAAkB;IAS1B,oFAAoF;IACpF,OAAO,CAAC,oBAAoB;IAW5B,4FAA4F;IAC5F,OAAO,CAAC,wBAAwB;IAWhC,qEAAqE;YACvD,wBAAwB;IAqBtC,uEAAuE;IACvE,OAAO,CAAC,uBAAuB;IAK/B;;;;OAIG;IACH,OAAO,CAAC,uBAAuB;IAU/B,sDAAsD;IACtD,OAAO,CAAC,iBAAiB;IAczB,kDAAkD;IAClD,OAAO,CAAC,WAAW;IAiBnB,kFAAkF;IAClF,OAAO,CAAC,mBAAmB;IAmB3B,uEAAuE;IACvE,OAAO,CAAC,cAAc;IAOtB,gCAAgC;IAChC,OAAO,CAAC,cAAc;IAWtB,wDAAwD;IACxD,OAAO,CAAC,QAAQ;IAoBhB,sCAAsC;IACtC,OAAO,CAAC,QAAQ;IAShB;;;OAGG;IACH,OAAO,CAAC,eAAe;CAgHxB"}
package/dist/server.js CHANGED
@@ -23,16 +23,17 @@ import { createServer } from 'node:http';
23
23
  import { readdirSync, readFileSync, statSync } from 'node:fs';
24
24
  import { join, basename, relative, dirname } from 'node:path';
25
25
  import { fileURLToPath } from 'node:url';
26
+ import { randomUUID } from 'node:crypto';
26
27
  import { ThreatCloudDB } from './database.js';
27
28
  import { LLMReviewer } from './llm-reviewer.js';
28
29
  import { getAdminHTML } from './admin-dashboard.js';
29
- /** Simple structured logger for threat-cloud (no core dependency) */
30
+ /** Structured JSON logger for threat-cloud */
30
31
  const log = {
31
- info: (msg) => {
32
- process.stdout.write(`[threat-cloud] ${msg}\n`);
32
+ info: (msg, extra) => {
33
+ process.stdout.write(JSON.stringify({ ts: new Date().toISOString(), level: 'info', msg, ...extra }) + '\n');
33
34
  },
34
- error: (msg, err) => {
35
- process.stderr.write(`[threat-cloud] ERROR ${msg}${err ? `: ${err instanceof Error ? err.message : String(err)}` : ''}\n`);
35
+ error: (msg, err, extra) => {
36
+ process.stderr.write(JSON.stringify({ ts: new Date().toISOString(), level: 'error', msg, error: err instanceof Error ? err.message : String(err), ...extra }) + '\n');
36
37
  },
37
38
  };
38
39
  /**
@@ -46,8 +47,12 @@ export class ThreatCloudServer {
46
47
  llmReviewer;
47
48
  promotionTimer = null;
48
49
  rateLimits = new Map();
50
+ rateLimitCleanupTimer = null;
51
+ statsCache = null;
49
52
  /** Promotion interval: 15 minutes / 推廣間隔:15 分鐘 */
50
53
  static PROMOTION_INTERVAL_MS = 15 * 60 * 1000;
54
+ /** Stats cache TTL: 60 seconds */
55
+ static STATS_CACHE_TTL_MS = 60_000;
51
56
  constructor(config) {
52
57
  this.config = config;
53
58
  this.db = new ThreatCloudDB(config.dbPath);
@@ -103,6 +108,14 @@ export class ThreatCloudServer {
103
108
  log.error('Promotion cycle failed', err);
104
109
  }
105
110
  }, ThreatCloudServer.PROMOTION_INTERVAL_MS);
111
+ // Rate limiter cleanup (every 60s, purge expired entries)
112
+ this.rateLimitCleanupTimer = setInterval(() => {
113
+ const now = Date.now();
114
+ for (const [ip, entry] of this.rateLimits) {
115
+ if (now > entry.resetAt)
116
+ this.rateLimits.delete(ip);
117
+ }
118
+ }, 60_000);
106
119
  resolve();
107
120
  });
108
121
  });
@@ -114,6 +127,10 @@ export class ThreatCloudServer {
114
127
  clearInterval(this.promotionTimer);
115
128
  this.promotionTimer = null;
116
129
  }
130
+ if (this.rateLimitCleanupTimer) {
131
+ clearInterval(this.rateLimitCleanupTimer);
132
+ this.rateLimitCleanupTimer = null;
133
+ }
117
134
  this.db.close();
118
135
  if (this.server) {
119
136
  this.server.close(() => resolve());
@@ -124,24 +141,31 @@ export class ThreatCloudServer {
124
141
  });
125
142
  }
126
143
  async handleRequest(req, res) {
127
- // Security headers
144
+ const startTime = Date.now();
145
+ const requestId = randomUUID();
146
+ // Security headers + request ID
128
147
  res.setHeader('X-Content-Type-Options', 'nosniff');
129
148
  res.setHeader('X-Frame-Options', 'DENY');
130
149
  res.setHeader('Content-Type', 'application/json');
150
+ res.setHeader('X-Request-Id', requestId);
131
151
  const clientIP = req.socket.remoteAddress ?? 'unknown';
132
152
  // Rate limiting
133
153
  if (!this.checkRateLimit(clientIP)) {
134
- this.sendJson(res, 429, { ok: false, error: 'Rate limit exceeded' });
154
+ this.sendJson(res, 429, { ok: false, error: 'Rate limit exceeded', request_id: requestId });
155
+ log.info('request', { method: req.method, path: req.url, status: 429, duration_ms: Date.now() - startTime, client_ip: clientIP, request_id: requestId });
135
156
  return;
136
157
  }
137
158
  // API key verification (skip for health check)
138
159
  const url = req.url ?? '/';
139
- const pathname = url.split('?')[0];
160
+ const rawPathname = url.split('?')[0];
161
+ // API versioning: strip /v1 prefix for backward compatibility
162
+ const pathname = rawPathname.startsWith('/v1/') ? rawPathname.slice(3) : rawPathname === '/v1' ? '/' : rawPathname;
140
163
  if (pathname !== '/health' && this.config.apiKeyRequired) {
141
164
  const authHeader = req.headers.authorization ?? '';
142
165
  const token = authHeader.replace('Bearer ', '');
143
166
  if (!this.config.apiKeys.includes(token)) {
144
- this.sendJson(res, 401, { ok: false, error: 'Invalid API key' });
167
+ this.sendJson(res, 401, { ok: false, error: 'Invalid API key', request_id: requestId });
168
+ log.info('request', { method: req.method, path: rawPathname, status: 401, duration_ms: Date.now() - startTime, client_ip: clientIP, request_id: requestId });
145
169
  return;
146
170
  }
147
171
  }
@@ -157,8 +181,11 @@ export class ThreatCloudServer {
157
181
  if (req.method === 'OPTIONS') {
158
182
  res.writeHead(204);
159
183
  res.end();
184
+ log.info('request', { method: 'OPTIONS', path: rawPathname, status: 204, duration_ms: Date.now() - startTime, client_ip: clientIP, request_id: requestId });
160
185
  return;
161
186
  }
187
+ // Store requestId on response for sendJson to include
188
+ res._requestId = requestId;
162
189
  try {
163
190
  switch (pathname) {
164
191
  case '/health':
@@ -171,7 +198,14 @@ export class ThreatCloudServer {
171
198
  this.serveAdminDashboard(req, res);
172
199
  break;
173
200
  case '/api/threats':
174
- if (req.method === 'POST') {
201
+ if (req.method === 'GET') {
202
+ if (!this.checkAdminAuth(req)) {
203
+ this.sendJson(res, 403, { ok: false, error: 'Admin API key required' });
204
+ break;
205
+ }
206
+ this.handleGetThreats(url, res);
207
+ }
208
+ else if (req.method === 'POST') {
175
209
  await this.handlePostThreat(req, res);
176
210
  }
177
211
  else {
@@ -205,7 +239,14 @@ export class ThreatCloudServer {
205
239
  }
206
240
  break;
207
241
  case '/api/atr-proposals':
208
- if (req.method === 'POST') {
242
+ if (req.method === 'GET') {
243
+ if (!this.checkAdminAuth(req)) {
244
+ this.sendJson(res, 403, { ok: false, error: 'Admin API key required' });
245
+ break;
246
+ }
247
+ this.handleGetATRProposals(url, res);
248
+ }
249
+ else if (req.method === 'POST') {
209
250
  await this.handlePostATRProposal(req, res);
210
251
  }
211
252
  else {
@@ -221,7 +262,14 @@ export class ThreatCloudServer {
221
262
  }
222
263
  break;
223
264
  case '/api/skill-threats':
224
- if (req.method === 'POST') {
265
+ if (req.method === 'GET') {
266
+ if (!this.checkAdminAuth(req)) {
267
+ this.sendJson(res, 403, { ok: false, error: 'Admin API key required' });
268
+ break;
269
+ }
270
+ this.handleGetSkillThreats(url, res);
271
+ }
272
+ else if (req.method === 'POST') {
225
273
  await this.handlePostSkillThreat(req, res);
226
274
  }
227
275
  else {
@@ -279,14 +327,35 @@ export class ThreatCloudServer {
279
327
  this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
280
328
  }
281
329
  break;
330
+ case '/api/audit-log':
331
+ if (req.method === 'GET') {
332
+ if (!this.checkAdminAuth(req)) {
333
+ this.sendJson(res, 403, { ok: false, error: 'Admin API key required' });
334
+ break;
335
+ }
336
+ this.handleGetAuditLog(url, res);
337
+ }
338
+ else {
339
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
340
+ }
341
+ break;
282
342
  default:
283
343
  this.sendJson(res, 404, { ok: false, error: 'Not found' });
284
344
  }
285
345
  }
286
346
  catch (err) {
287
- log.error('Request failed', err);
288
- this.sendJson(res, 500, { ok: false, error: 'Internal server error' });
347
+ log.error('Request failed', err, { request_id: requestId, path: rawPathname });
348
+ this.sendJson(res, 500, { ok: false, error: 'Internal server error', request_id: requestId });
289
349
  }
350
+ // Request logging
351
+ log.info('request', {
352
+ method: req.method,
353
+ path: rawPathname,
354
+ status: res.statusCode,
355
+ duration_ms: Date.now() - startTime,
356
+ client_ip: clientIP,
357
+ request_id: requestId,
358
+ });
290
359
  }
291
360
  /** POST /api/threats - Upload anonymized threat data (single or batch) */
292
361
  async handlePostThreat(req, res) {
@@ -314,6 +383,8 @@ export class ThreatCloudServer {
314
383
  data.attackSourceIP = this.anonymizeIP(data.attackSourceIP);
315
384
  this.db.insertThreat(data);
316
385
  }
386
+ const clientIP = req.socket.remoteAddress ?? 'unknown';
387
+ this.db.audit.logAction('client', 'threat.submit', 'threat', undefined, { count: events.length }, clientIP);
317
388
  this.sendJson(res, 201, {
318
389
  ok: true,
319
390
  data: { message: 'Threat data received', count: events.length },
@@ -333,7 +404,8 @@ export class ThreatCloudServer {
333
404
  const rules = since
334
405
  ? this.db.getRulesSince(since, filters)
335
406
  : this.db.getAllRules(5000, filters);
336
- this.sendJson(res, 200, rules);
407
+ const ruleList = Array.isArray(rules) ? rules : [];
408
+ this.sendJson(res, 200, { ok: true, data: ruleList, meta: { total: ruleList.length } });
337
409
  }
338
410
  /** POST /api/rules - Publish rules (single or batch) */
339
411
  async handlePostRule(req, res) {
@@ -350,14 +422,50 @@ export class ThreatCloudServer {
350
422
  this.db.upsertRule(rule);
351
423
  count++;
352
424
  }
425
+ const clientIP = req.socket.remoteAddress ?? 'unknown';
426
+ this.db.audit.logAction('admin', 'rule.create', 'rule', undefined, { count }, clientIP);
353
427
  this.sendJson(res, 201, { ok: true, data: { message: `${count} rule(s) published`, count } });
354
428
  }
355
- /** GET /api/stats */
429
+ /** GET /api/stats (cached 60s) */
356
430
  handleGetStats(res) {
357
- res.setHeader('Cache-Control', 'public, max-age=300, s-maxage=300');
431
+ res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=60');
432
+ const now = Date.now();
433
+ if (this.statsCache && now < this.statsCache.expiresAt) {
434
+ this.sendJson(res, 200, { ok: true, data: this.statsCache.data });
435
+ return;
436
+ }
358
437
  const stats = this.db.getStats();
438
+ this.statsCache = { data: stats, expiresAt: now + ThreatCloudServer.STATS_CACHE_TTL_MS };
359
439
  this.sendJson(res, 200, { ok: true, data: stats });
360
440
  }
441
+ /** GET /api/threats?page=1&limit=50 (admin-only, paginated) */
442
+ handleGetThreats(url, res) {
443
+ const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
444
+ const page = Math.max(1, parseInt(params.get('page') ?? '1', 10));
445
+ const limit = Math.min(200, Math.max(1, parseInt(params.get('limit') ?? '50', 10)));
446
+ const offset = (page - 1) * limit;
447
+ const threats = this.db.getThreats(limit, offset);
448
+ const total = this.db.getThreatCount();
449
+ this.sendJson(res, 200, {
450
+ ok: true,
451
+ data: threats,
452
+ meta: { total, page, limit, pages: Math.ceil(total / limit) },
453
+ });
454
+ }
455
+ /** GET /api/atr-proposals?status=pending (admin-only) */
456
+ handleGetATRProposals(url, res) {
457
+ const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
458
+ const status = params.get('status') ?? undefined;
459
+ const proposals = this.db.getATRProposals(status);
460
+ this.sendJson(res, 200, { ok: true, data: proposals });
461
+ }
462
+ /** GET /api/skill-threats?limit=50 (admin-only) */
463
+ handleGetSkillThreats(url, res) {
464
+ const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
465
+ const limit = Math.min(500, Math.max(1, parseInt(params.get('limit') ?? '50', 10)));
466
+ const threats = this.db.getSkillThreats(limit);
467
+ this.sendJson(res, 200, { ok: true, data: threats });
468
+ }
361
469
  /** POST /api/atr-proposals - Submit or confirm an ATR rule proposal */
362
470
  async handlePostATRProposal(req, res) {
363
471
  const body = await this.readBody(req);
@@ -438,6 +546,8 @@ export class ThreatCloudServer {
438
546
  clientId: clientId ?? data.clientId,
439
547
  };
440
548
  this.db.insertSkillThreat(submission);
549
+ const clientIP = req.socket.remoteAddress ?? 'unknown';
550
+ this.db.audit.logAction('client', 'skill_threat.submit', 'skill_threat', submission.skillHash, { skillName: submission.skillName, riskScore: submission.riskScore }, clientIP);
441
551
  this.sendJson(res, 201, { ok: true, data: { message: 'Skill threat received' } });
442
552
  }
443
553
  /** GET /api/atr-rules?since=<ISO> - Fetch confirmed/promoted ATR rules */
@@ -446,7 +556,8 @@ export class ThreatCloudServer {
446
556
  const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
447
557
  const since = params.get('since') ?? undefined;
448
558
  const rules = this.db.getConfirmedATRRules(since);
449
- this.sendJson(res, 200, rules);
559
+ const ruleList = Array.isArray(rules) ? rules : [];
560
+ this.sendJson(res, 200, { ok: true, data: ruleList, meta: { total: ruleList.length } });
450
561
  }
451
562
  /** GET /api/yara-rules?since=<ISO> - Fetch YARA rules */
452
563
  handleGetYaraRules(url, res) {
@@ -454,7 +565,8 @@ export class ThreatCloudServer {
454
565
  const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
455
566
  const since = params.get('since') ?? undefined;
456
567
  const rules = this.db.getRulesBySource('yara', since);
457
- this.sendJson(res, 200, rules);
568
+ const ruleList = Array.isArray(rules) ? rules : [];
569
+ this.sendJson(res, 200, { ok: true, data: ruleList, meta: { total: ruleList.length } });
458
570
  }
459
571
  /** GET /api/feeds/ip-blocklist?minReputation=70 - IP blocklist feed (plain text) */
460
572
  handleGetIPBlocklist(url, res) {
@@ -510,6 +622,20 @@ export class ThreatCloudServer {
510
622
  const blacklist = this.db.getSkillBlacklist(minReports, minAvgRisk);
511
623
  this.sendJson(res, 200, { ok: true, data: blacklist });
512
624
  }
625
+ /** GET /api/audit-log?page=1&limit=50 (admin-only) */
626
+ handleGetAuditLog(url, res) {
627
+ const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
628
+ const page = Math.max(1, parseInt(params.get('page') ?? '1', 10));
629
+ const limit = Math.min(200, Math.max(1, parseInt(params.get('limit') ?? '50', 10)));
630
+ const offset = (page - 1) * limit;
631
+ const entries = this.db.audit.getAuditLog(limit, offset);
632
+ const total = this.db.audit.getAuditLogCount();
633
+ this.sendJson(res, 200, {
634
+ ok: true,
635
+ data: entries,
636
+ meta: { total, page, limit, pages: Math.ceil(total / limit) },
637
+ });
638
+ }
513
639
  /** Anonymize IP by zeroing last octet / 匿名化 IP */
514
640
  anonymizeIP(ip) {
515
641
  if (ip.includes('.')) {
@@ -527,8 +653,19 @@ export class ThreatCloudServer {
527
653
  }
528
654
  return ip;
529
655
  }
530
- /** Serve admin dashboard HTML (requires admin key or returns login page) */
531
- serveAdminDashboard(_req, res) {
656
+ /** Serve admin dashboard HTML -- requires admin auth via query param or header */
657
+ serveAdminDashboard(req, res) {
658
+ // Server-side auth: require admin key via ?key= param or Authorization header
659
+ if (this.config.adminApiKey) {
660
+ const url = new URL(req.url ?? '/', `http://localhost:${this.config.port}`);
661
+ const queryKey = url.searchParams.get('key');
662
+ const headerKey = (req.headers.authorization ?? '').replace('Bearer ', '');
663
+ if (queryKey !== this.config.adminApiKey && headerKey !== this.config.adminApiKey) {
664
+ res.writeHead(401, { 'Content-Type': 'text/plain' });
665
+ res.end('Unauthorized: admin API key required. Use ?key=YOUR_KEY or Authorization header.');
666
+ return;
667
+ }
668
+ }
532
669
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
533
670
  res.setHeader('Cache-Control', 'no-store');
534
671
  res.setHeader('X-Robots-Tag', 'noindex, nofollow');
@@ -559,7 +696,7 @@ export class ThreatCloudServer {
559
696
  return new Promise((resolve, reject) => {
560
697
  const chunks = [];
561
698
  let size = 0;
562
- const MAX_BODY = 52_428_800; // 50MB (for batch rule uploads)
699
+ const MAX_BODY = 5_242_880; // 5MB (reasonable limit for JSON payloads)
563
700
  req.on('data', (chunk) => {
564
701
  size += chunk.length;
565
702
  if (size > MAX_BODY) {
@@ -575,8 +712,12 @@ export class ThreatCloudServer {
575
712
  }
576
713
  /** Send JSON response / 發送 JSON 回應 */
577
714
  sendJson(res, status, data) {
715
+ const requestId = res._requestId;
716
+ const payload = typeof data === 'object' && data !== null
717
+ ? { ...data, request_id: requestId }
718
+ : data;
578
719
  res.writeHead(status);
579
- res.end(JSON.stringify(data));
720
+ res.end(JSON.stringify(payload));
580
721
  }
581
722
  /**
582
723
  * Seed rules from bundled config/ directory on first startup.