@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.
- package/LICENSE +21 -0
- package/dist/admin-dashboard.d.ts.map +1 -1
- package/dist/admin-dashboard.js +72 -5
- package/dist/admin-dashboard.js.map +1 -1
- package/dist/audit-logger.d.ts +62 -0
- package/dist/audit-logger.d.ts.map +1 -0
- package/dist/audit-logger.js +79 -0
- package/dist/audit-logger.js.map +1 -0
- package/dist/database.d.ts +6 -2
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +16 -19
- package/dist/database.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/migrations.d.ts +31 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +116 -0
- package/dist/migrations.js.map +1 -0
- package/dist/server.d.ts +14 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +164 -23
- package/dist/server.js.map +1 -1
- package/package.json +10 -10
|
@@ -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
|
|
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;
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;
|
|
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
|
-
/**
|
|
30
|
+
/** Structured JSON logger for threat-cloud */
|
|
30
31
|
const log = {
|
|
31
|
-
info: (msg) => {
|
|
32
|
-
process.stdout.write(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 === '
|
|
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 === '
|
|
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 === '
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
531
|
-
serveAdminDashboard(
|
|
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 =
|
|
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(
|
|
720
|
+
res.end(JSON.stringify(payload));
|
|
580
721
|
}
|
|
581
722
|
/**
|
|
582
723
|
* Seed rules from bundled config/ directory on first startup.
|