@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.
- package/dist/admin-dashboard.d.ts +11 -0
- package/dist/admin-dashboard.d.ts.map +1 -0
- package/dist/admin-dashboard.js +482 -0
- package/dist/admin-dashboard.js.map +1 -0
- package/dist/backup.d.ts +40 -0
- package/dist/backup.d.ts.map +1 -0
- package/dist/backup.js +123 -0
- package/dist/backup.js.map +1 -0
- package/dist/cli.js +24 -64
- package/dist/cli.js.map +1 -1
- package/dist/database.d.ts +78 -37
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +590 -324
- package/dist/database.js.map +1 -1
- package/dist/index.d.ts +4 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -9
- package/dist/index.js.map +1 -1
- package/dist/llm-reviewer.d.ts +47 -0
- package/dist/llm-reviewer.d.ts.map +1 -0
- package/dist/llm-reviewer.js +203 -0
- package/dist/llm-reviewer.js.map +1 -0
- package/dist/server.d.ts +56 -63
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +525 -635
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +71 -301
- package/dist/types.d.ts.map +1 -1
- package/package.json +20 -18
- package/LICENSE +0 -21
- package/dist/audit-logger.d.ts +0 -46
- package/dist/audit-logger.d.ts.map +0 -1
- package/dist/audit-logger.js +0 -105
- package/dist/audit-logger.js.map +0 -1
- package/dist/correlation-engine.d.ts +0 -41
- package/dist/correlation-engine.d.ts.map +0 -1
- package/dist/correlation-engine.js +0 -313
- package/dist/correlation-engine.js.map +0 -1
- package/dist/feed-distributor.d.ts +0 -36
- package/dist/feed-distributor.d.ts.map +0 -1
- package/dist/feed-distributor.js +0 -125
- package/dist/feed-distributor.js.map +0 -1
- package/dist/ioc-store.d.ts +0 -83
- package/dist/ioc-store.d.ts.map +0 -1
- package/dist/ioc-store.js +0 -278
- package/dist/ioc-store.js.map +0 -1
- package/dist/query-handlers.d.ts +0 -40
- package/dist/query-handlers.d.ts.map +0 -1
- package/dist/query-handlers.js +0 -211
- package/dist/query-handlers.js.map +0 -1
- package/dist/reputation-engine.d.ts +0 -44
- package/dist/reputation-engine.d.ts.map +0 -1
- package/dist/reputation-engine.js +0 -169
- package/dist/reputation-engine.js.map +0 -1
- package/dist/rule-generator.d.ts +0 -47
- package/dist/rule-generator.d.ts.map +0 -1
- package/dist/rule-generator.js +0 -238
- package/dist/rule-generator.js.map +0 -1
- package/dist/scheduler.d.ts +0 -52
- package/dist/scheduler.d.ts.map +0 -1
- package/dist/scheduler.js +0 -143
- package/dist/scheduler.js.map +0 -1
- package/dist/sighting-store.d.ts +0 -61
- package/dist/sighting-store.d.ts.map +0 -1
- package/dist/sighting-store.js +0 -191
- 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
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
13
|
-
* - GET /
|
|
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 {
|
|
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 {
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
/**
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
67
|
-
if (this.
|
|
68
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
150
|
-
if (!
|
|
151
|
-
this.sendJson(res, 401, { ok: false, error: '
|
|
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'] ??
|
|
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 (
|
|
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
|
-
|
|
186
|
-
|
|
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()
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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.
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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: '
|
|
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
|
|
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
|
|
338
|
+
/** POST /api/rules - Publish rules (single or batch) */
|
|
339
|
+
async handlePostRule(req, res) {
|
|
502
340
|
const body = await this.readBody(req);
|
|
503
|
-
const
|
|
504
|
-
|
|
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:
|
|
369
|
+
error: 'Missing required fields: patternHash, ruleContent',
|
|
508
370
|
});
|
|
509
371
|
return;
|
|
510
372
|
}
|
|
511
|
-
//
|
|
512
|
-
const
|
|
513
|
-
|
|
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:
|
|
409
|
+
error: 'Missing required fields: ruleId (string), isTruePositive (boolean)',
|
|
517
410
|
});
|
|
518
411
|
return;
|
|
519
412
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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: '
|
|
424
|
+
error: 'Missing required fields: skillHash, skillName',
|
|
526
425
|
});
|
|
527
426
|
return;
|
|
528
427
|
}
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
567
|
-
|
|
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
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
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
|
|
592
|
-
const
|
|
593
|
-
this.sendJson(res, 200,
|
|
447
|
+
const since = params.get('since') ?? undefined;
|
|
448
|
+
const rules = this.db.getConfirmedATRRules(since);
|
|
449
|
+
this.sendJson(res, 200, rules);
|
|
594
450
|
}
|
|
595
|
-
|
|
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
|
|
598
|
-
this.
|
|
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
|
-
|
|
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
|
|
606
|
-
const
|
|
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(
|
|
467
|
+
res.end(ips.join('\n'));
|
|
610
468
|
}
|
|
611
|
-
|
|
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
|
|
614
|
-
const
|
|
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(
|
|
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
|
-
|
|
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
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
const
|
|
685
|
-
|
|
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
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
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
|
-
|
|
714
|
-
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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(
|
|
549
|
+
const entry = this.rateLimits.get(ip);
|
|
740
550
|
if (!entry || now > entry.resetAt) {
|
|
741
|
-
this.rateLimits.set(
|
|
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 <=
|
|
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 =
|
|
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
|