@panguard-ai/panguard 0.3.3 → 0.3.5
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/cli/auth-guard.d.ts +15 -50
- package/dist/cli/auth-guard.d.ts.map +1 -1
- package/dist/cli/auth-guard.js +22 -126
- package/dist/cli/auth-guard.js.map +1 -1
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/audit.js +18 -11
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +15 -12
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/guard.d.ts.map +1 -1
- package/dist/cli/commands/guard.js +11 -8
- package/dist/cli/commands/guard.js.map +1 -1
- package/dist/cli/commands/hacktivity.d.ts.map +1 -1
- package/dist/cli/commands/hacktivity.js +1 -3
- package/dist/cli/commands/hacktivity.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/manager.d.ts.map +1 -1
- package/dist/cli/commands/manager.js.map +1 -1
- package/dist/cli/commands/scan.js +4 -1
- package/dist/cli/commands/scan.js.map +1 -1
- package/dist/cli/commands/serve.d.ts.map +1 -1
- package/dist/cli/commands/serve.js +766 -32
- package/dist/cli/commands/serve.js.map +1 -1
- package/dist/cli/commands/setup-skill-scan.d.ts.map +1 -1
- package/dist/cli/commands/setup-skill-scan.js +31 -11
- package/dist/cli/commands/setup-skill-scan.js.map +1 -1
- package/dist/cli/commands/setup.d.ts.map +1 -1
- package/dist/cli/commands/setup.js +164 -40
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/commands/threat.d.ts.map +1 -1
- package/dist/cli/commands/threat.js +221 -0
- package/dist/cli/commands/threat.js.map +1 -1
- package/dist/cli/commands/upgrade.d.ts +2 -4
- package/dist/cli/commands/upgrade.d.ts.map +1 -1
- package/dist/cli/commands/upgrade.js +14 -22
- package/dist/cli/commands/upgrade.js.map +1 -1
- package/dist/cli/commands/whoami.d.ts.map +1 -1
- package/dist/cli/commands/whoami.js +5 -1
- package/dist/cli/commands/whoami.js.map +1 -1
- package/dist/cli/index.js +22 -21
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/interactive.d.ts.map +1 -1
- package/dist/cli/interactive.js +95 -40
- package/dist/cli/interactive.js.map +1 -1
- package/dist/init/steps.d.ts.map +1 -1
- package/dist/init/steps.js.map +1 -1
- package/dist/init/wizard-runner.d.ts.map +1 -1
- package/dist/init/wizard-runner.js +27 -13
- package/dist/init/wizard-runner.js.map +1 -1
- package/package.json +27 -20
- package/LICENSE +0 -21
|
@@ -11,7 +11,7 @@ import { homedir } from 'node:os';
|
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { c, banner } from '@panguard-ai/core';
|
|
13
13
|
import { PANGUARD_VERSION } from '../../index.js';
|
|
14
|
-
import { AuthDB, createAuthHandlers, sendExpirationWarningEmail, initErrorTracking, captureRequestError, generateOpenApiSpec, generateSwaggerHtml, ManagerProxy, } from '@panguard-ai/panguard-auth';
|
|
14
|
+
import { AuthDB, createAuthHandlers, sendExpirationWarningEmail, initErrorTracking, captureRequestError, generateOpenApiSpec, generateSwaggerHtml, ManagerProxy, authenticateRequest, requireAdmin, } from '@panguard-ai/panguard-auth';
|
|
15
15
|
import { ManagerServer, DEFAULT_MANAGER_CONFIG } from '@panguard-ai/manager';
|
|
16
16
|
export function serveCommand() {
|
|
17
17
|
return new Command('serve')
|
|
@@ -31,9 +31,6 @@ export function serveCommand() {
|
|
|
31
31
|
const errors = [];
|
|
32
32
|
const warnings = [];
|
|
33
33
|
// Critical in production
|
|
34
|
-
if (isProd && !process.env['JWT_SECRET']) {
|
|
35
|
-
errors.push('JWT_SECRET not set in production — refusing to start with fallback key');
|
|
36
|
-
}
|
|
37
34
|
if (isProd && !process.env['PANGUARD_BASE_URL']) {
|
|
38
35
|
errors.push('PANGUARD_BASE_URL not set in production — OAuth and email links will break');
|
|
39
36
|
}
|
|
@@ -50,15 +47,18 @@ export function serveCommand() {
|
|
|
50
47
|
if (!process.env['RESEND_API_KEY'] && !process.env['SMTP_HOST']) {
|
|
51
48
|
warnings.push('No email config (RESEND_API_KEY or SMTP_HOST) — password reset and waitlist emails disabled');
|
|
52
49
|
}
|
|
53
|
-
if (!isProd && !process.env['JWT_SECRET']) {
|
|
54
|
-
warnings.push('JWT_SECRET not set — using fallback key (OK for dev, NOT for production)');
|
|
55
|
-
}
|
|
56
50
|
if (!process.env['SENTRY_DSN']) {
|
|
57
51
|
warnings.push('SENTRY_DSN not set — error tracking disabled');
|
|
58
52
|
}
|
|
59
|
-
if (!process.env['MANAGER_AUTH_TOKEN']) {
|
|
53
|
+
if (isProd && !process.env['MANAGER_AUTH_TOKEN']) {
|
|
54
|
+
errors.push('MANAGER_AUTH_TOKEN not set in production — Manager API would allow unauthenticated access');
|
|
55
|
+
}
|
|
56
|
+
if (!isProd && !process.env['MANAGER_AUTH_TOKEN']) {
|
|
60
57
|
warnings.push('MANAGER_AUTH_TOKEN not set — Manager API allows unauthenticated access (OK for dev)');
|
|
61
58
|
}
|
|
59
|
+
if (!process.env['TC_API_KEY']) {
|
|
60
|
+
warnings.push('TC_API_KEY not set — Threat Cloud write API disabled in production, open in dev');
|
|
61
|
+
}
|
|
62
62
|
if (errors.length > 0) {
|
|
63
63
|
console.error(` ${c.critical('FATAL — Missing required environment variables:')}`);
|
|
64
64
|
for (const e of errors) {
|
|
@@ -79,6 +79,93 @@ export function serveCommand() {
|
|
|
79
79
|
await initErrorTracking();
|
|
80
80
|
// Initialize database
|
|
81
81
|
const db = new AuthDB(options.db);
|
|
82
|
+
// Initialize Threat Cloud database (optional — graceful if unavailable)
|
|
83
|
+
let threatDb = null;
|
|
84
|
+
try {
|
|
85
|
+
const tcMod = '@panguard-ai/threat-cloud';
|
|
86
|
+
const tc = await import(/* webpackIgnore: true */ tcMod);
|
|
87
|
+
const threatDbPath = join(dirname(options.db), 'threat-cloud.db');
|
|
88
|
+
threatDb = new tc.ThreatCloudDB(threatDbPath);
|
|
89
|
+
console.log(` ${c.safe('Threat Cloud DB')} initialized at ${c.dim(threatDbPath)}`);
|
|
90
|
+
// Auto-seed rules on first startup if rules table is empty
|
|
91
|
+
const stats = threatDb.getStats();
|
|
92
|
+
if (stats.totalRules === 0) {
|
|
93
|
+
console.log(` ${c.sage('Seeding rules...')} (first startup detected)`);
|
|
94
|
+
const seeded = await seedRulesFromBundled(threatDb);
|
|
95
|
+
console.log(` ${c.safe(`Seeded ${seeded} rules`)} into Threat Cloud DB`);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
console.log(` ${c.dim(`Threat Cloud: ${stats.totalRules} rules, ${stats.totalThreats} threats`)}`);
|
|
99
|
+
}
|
|
100
|
+
console.log('');
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
104
|
+
console.error(` [ERROR] Threat Cloud initialization failed: ${msg}`);
|
|
105
|
+
if (err instanceof Error && err.stack) {
|
|
106
|
+
console.error(` ${err.stack}`);
|
|
107
|
+
}
|
|
108
|
+
console.log(` ${c.dim('Threat Cloud API routes disabled due to error above')}`);
|
|
109
|
+
console.log('');
|
|
110
|
+
}
|
|
111
|
+
// Initialize LLM Reviewer for ATR proposals (optional — needs ANTHROPIC_API_KEY)
|
|
112
|
+
let llmReviewer = null;
|
|
113
|
+
if (threatDb && process.env['ANTHROPIC_API_KEY']) {
|
|
114
|
+
try {
|
|
115
|
+
const tcMod = '@panguard-ai/threat-cloud';
|
|
116
|
+
const tc = await import(/* webpackIgnore: true */ tcMod);
|
|
117
|
+
llmReviewer = new tc.LLMReviewer(process.env['ANTHROPIC_API_KEY'], threatDb);
|
|
118
|
+
console.log(` ${c.safe('LLM Reviewer')} enabled for ATR proposal review`);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
console.log(` ${c.dim('LLM Reviewer not available')}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Promotion cron: every 15 minutes, promote confirmed + LLM-approved proposals to rules
|
|
125
|
+
let promotionTimer = null;
|
|
126
|
+
if (threatDb) {
|
|
127
|
+
promotionTimer = setInterval(() => {
|
|
128
|
+
try {
|
|
129
|
+
const promoted = threatDb.promoteConfirmedProposals();
|
|
130
|
+
if (promoted > 0) {
|
|
131
|
+
console.log(` [Promotion] ${promoted} proposal(s) promoted to rules`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
console.error(` [Promotion] Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
136
|
+
}
|
|
137
|
+
}, 15 * 60 * 1000);
|
|
138
|
+
}
|
|
139
|
+
// Periodic database backup (every 6 hours)
|
|
140
|
+
let backupTimer = null;
|
|
141
|
+
if (threatDb) {
|
|
142
|
+
try {
|
|
143
|
+
const tcMod2 = '@panguard-ai/threat-cloud';
|
|
144
|
+
const tc2 = await import(/* webpackIgnore: true */ tcMod2);
|
|
145
|
+
const backupDir = join(dirname(options.db), 'backups');
|
|
146
|
+
const threatBackup = new tc2.BackupManager(join(dirname(options.db), 'threat-cloud.db'), backupDir, 7);
|
|
147
|
+
const authBackup = new tc2.BackupManager(options.db, backupDir, 7);
|
|
148
|
+
const runBackups = () => {
|
|
149
|
+
try {
|
|
150
|
+
const r1 = threatBackup.backup();
|
|
151
|
+
const r2 = authBackup.backup();
|
|
152
|
+
console.log(` [Backup] threat-cloud.db (${tc2.BackupManager.formatSize(r1.sizeBytes)}), auth.db (${tc2.BackupManager.formatSize(r2.sizeBytes)})`);
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
console.error(` [Backup] Failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
// Initial backup on startup
|
|
159
|
+
runBackups();
|
|
160
|
+
// Every 6 hours
|
|
161
|
+
backupTimer = setInterval(runBackups, 6 * 60 * 60 * 1000);
|
|
162
|
+
if (backupTimer.unref)
|
|
163
|
+
backupTimer.unref();
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
console.log(` ${c.dim('Backup manager not available — auto-backups disabled')}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
82
169
|
// Build config from environment
|
|
83
170
|
// Prefer Resend API when RESEND_API_KEY is set; fall back to raw SMTP
|
|
84
171
|
const emailConfig = process.env['RESEND_API_KEY']
|
|
@@ -144,7 +231,7 @@ export function serveCommand() {
|
|
|
144
231
|
];
|
|
145
232
|
const adminDir = adminDirs.find((d) => existsSync(d));
|
|
146
233
|
const server = createServer((req, res) => {
|
|
147
|
-
void handleRequest(req, res, handlers, db, adminDir, managerProxy);
|
|
234
|
+
void handleRequest(req, res, handlers, db, adminDir, managerProxy, threatDb, llmReviewer);
|
|
148
235
|
});
|
|
149
236
|
// Build Manager config from environment / 從環境變數建構 Manager 設定
|
|
150
237
|
const managerPort = parseInt(options.managerPort, 10);
|
|
@@ -174,6 +261,11 @@ export function serveCommand() {
|
|
|
174
261
|
console.log(` ${c.dim('/openapi.json')} OpenAPI 3.0 Spec`);
|
|
175
262
|
console.log(` ${c.dim('/health')} Health check`);
|
|
176
263
|
console.log(` ${c.dim('/api/manager/*')} Manager API (port ${managerPort})`);
|
|
264
|
+
if (threatDb) {
|
|
265
|
+
console.log(` ${c.dim('/api/threats')} Threat Cloud API (POST)`);
|
|
266
|
+
console.log(` ${c.dim('/api/rules')} Rule Distribution (GET/POST)`);
|
|
267
|
+
console.log(` ${c.dim('/api/stats')} Threat Statistics (GET)`);
|
|
268
|
+
}
|
|
177
269
|
console.log('');
|
|
178
270
|
console.log(` Services:`);
|
|
179
271
|
console.log(` Email: ${emailConfig ? ('apiKey' in emailConfig ? c.safe('Resend API') : c.safe('SMTP')) : c.caution('Not configured')}`);
|
|
@@ -183,10 +275,13 @@ export function serveCommand() {
|
|
|
183
275
|
console.log(` Manager: ${c.safe(`port ${managerPort}`)}${process.env['MANAGER_AUTH_TOKEN'] ? '' : c.dim(' (no auth)')}`);
|
|
184
276
|
console.log('');
|
|
185
277
|
// Start Manager server after auth server is listening / 在 Auth 伺服器啟動後啟動 Manager 伺服器
|
|
186
|
-
managerServer
|
|
278
|
+
managerServer
|
|
279
|
+
.start()
|
|
280
|
+
.then(() => {
|
|
187
281
|
console.log(` ${c.safe('Manager server started')} on ${c.bold(`http://${host}:${managerPort}`)}`);
|
|
188
282
|
console.log('');
|
|
189
|
-
})
|
|
283
|
+
})
|
|
284
|
+
.catch((err) => {
|
|
190
285
|
const message = err instanceof Error ? err.message : String(err);
|
|
191
286
|
console.log(` ${c.caution('Manager server failed to start:')} ${message}`);
|
|
192
287
|
console.log(` ${c.dim('Auth server continues running without Manager')}`);
|
|
@@ -221,9 +316,15 @@ export function serveCommand() {
|
|
|
221
316
|
const shutdown = () => {
|
|
222
317
|
console.log('\n Shutting down...');
|
|
223
318
|
clearInterval(planCheckTimer);
|
|
319
|
+
if (backupTimer)
|
|
320
|
+
clearInterval(backupTimer);
|
|
321
|
+
if (promotionTimer)
|
|
322
|
+
clearInterval(promotionTimer);
|
|
224
323
|
managerServer.stop().catch(() => { });
|
|
225
324
|
server.close(() => {
|
|
226
325
|
db.close();
|
|
326
|
+
if (threatDb)
|
|
327
|
+
threatDb.close();
|
|
227
328
|
process.exit(0);
|
|
228
329
|
});
|
|
229
330
|
};
|
|
@@ -231,7 +332,7 @@ export function serveCommand() {
|
|
|
231
332
|
process.on('SIGTERM', shutdown);
|
|
232
333
|
});
|
|
233
334
|
}
|
|
234
|
-
async function handleRequest(req, res, handlers, _db, adminDir, managerProxy) {
|
|
335
|
+
async function handleRequest(req, res, handlers, _db, adminDir, managerProxy, threatDb, llmReviewer) {
|
|
235
336
|
const url = req.url ?? '/';
|
|
236
337
|
const pathname = url.split('?')[0] ?? '/';
|
|
237
338
|
// Security headers
|
|
@@ -275,43 +376,461 @@ async function handleRequest(req, res, handlers, _db, adminDir, managerProxy) {
|
|
|
275
376
|
res.end(html);
|
|
276
377
|
return;
|
|
277
378
|
}
|
|
278
|
-
// Health check (
|
|
379
|
+
// Health check (minimal public response — detailed status behind /api/admin/health)
|
|
279
380
|
if (pathname === '/health') {
|
|
280
|
-
const mem = process.memoryUsage();
|
|
281
|
-
const services = {
|
|
282
|
-
email: !!(process.env['RESEND_API_KEY'] || process.env['SMTP_HOST']),
|
|
283
|
-
oauth: !!process.env['GOOGLE_CLIENT_ID'],
|
|
284
|
-
billing: !!process.env['LEMON_SQUEEZY_API_KEY'],
|
|
285
|
-
errorTracking: !!process.env['SENTRY_DSN'],
|
|
286
|
-
};
|
|
287
381
|
try {
|
|
288
382
|
_db.healthCheck();
|
|
289
383
|
sendJson(res, 200, {
|
|
290
384
|
ok: true,
|
|
291
385
|
data: {
|
|
292
386
|
status: 'healthy',
|
|
293
|
-
version: process.env['npm_package_version'] ?? '0.0.0',
|
|
294
387
|
uptime: Math.round(process.uptime()),
|
|
295
388
|
db: 'connected',
|
|
296
|
-
|
|
297
|
-
rss: Math.round(mem.rss / 1024 / 1024),
|
|
298
|
-
heapUsed: Math.round(mem.heapUsed / 1024 / 1024),
|
|
299
|
-
},
|
|
300
|
-
services,
|
|
389
|
+
threatCloud: threatDb ? 'connected' : 'unavailable',
|
|
301
390
|
},
|
|
302
391
|
});
|
|
303
392
|
}
|
|
304
393
|
catch {
|
|
305
394
|
sendJson(res, 503, {
|
|
306
395
|
ok: false,
|
|
307
|
-
data: {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
396
|
+
data: { status: 'unhealthy', db: 'disconnected' },
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// Detailed health (admin-only) — includes memory, services, threat stats
|
|
402
|
+
if (pathname === '/api/admin/health' && req.method === 'GET') {
|
|
403
|
+
const mem = process.memoryUsage();
|
|
404
|
+
const services = {
|
|
405
|
+
email: !!(process.env['RESEND_API_KEY'] || process.env['SMTP_HOST']),
|
|
406
|
+
oauth: !!process.env['GOOGLE_CLIENT_ID'],
|
|
407
|
+
billing: !!process.env['LEMON_SQUEEZY_API_KEY'],
|
|
408
|
+
errorTracking: !!process.env['SENTRY_DSN'],
|
|
409
|
+
threatCloud: !!threatDb,
|
|
410
|
+
tcApiKey: !!process.env['TC_API_KEY'],
|
|
411
|
+
};
|
|
412
|
+
let threatStats = null;
|
|
413
|
+
if (threatDb) {
|
|
414
|
+
try {
|
|
415
|
+
const s = threatDb.getStats();
|
|
416
|
+
threatStats = { rules: s.totalRules, threats: s.totalThreats };
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
/* ignore */
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
sendJson(res, 200, {
|
|
423
|
+
ok: true,
|
|
424
|
+
data: {
|
|
425
|
+
status: 'healthy',
|
|
426
|
+
version: process.env['npm_package_version'] ?? '0.0.0',
|
|
427
|
+
uptime: Math.round(process.uptime()),
|
|
428
|
+
db: 'connected',
|
|
429
|
+
threatStats,
|
|
430
|
+
memory: {
|
|
431
|
+
rss: Math.round(mem.rss / 1024 / 1024),
|
|
432
|
+
heapUsed: Math.round(mem.heapUsed / 1024 / 1024),
|
|
312
433
|
},
|
|
434
|
+
services,
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
// ── Threat Cloud API Routes ────────────────────────────────────
|
|
440
|
+
// Security: rate limiting, auth, input validation
|
|
441
|
+
// Rate limit for Threat Cloud endpoints (per-IP, shared state)
|
|
442
|
+
if (threatDb &&
|
|
443
|
+
pathname.startsWith('/api/') &&
|
|
444
|
+
[
|
|
445
|
+
'/api/threats',
|
|
446
|
+
'/api/rules',
|
|
447
|
+
'/api/stats',
|
|
448
|
+
'/api/atr-proposals',
|
|
449
|
+
'/api/atr-feedback',
|
|
450
|
+
'/api/skill-threats',
|
|
451
|
+
'/api/atr-rules',
|
|
452
|
+
'/api/yara-rules',
|
|
453
|
+
'/api/feeds/ip-blocklist',
|
|
454
|
+
'/api/feeds/domain-blocklist',
|
|
455
|
+
].some((p) => pathname === p)) {
|
|
456
|
+
const clientIP = req.socket.remoteAddress ?? 'unknown';
|
|
457
|
+
if (!checkTCRateLimit(clientIP)) {
|
|
458
|
+
sendJson(res, 429, { ok: false, error: 'Rate limit exceeded. Try again later.' });
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
// POST /api/threats - Upload anonymized threat data
|
|
463
|
+
if (pathname === '/api/threats' && req.method === 'POST') {
|
|
464
|
+
if (!threatDb) {
|
|
465
|
+
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (!requireTCWriteAuth(req, res))
|
|
469
|
+
return;
|
|
470
|
+
if (!requireJsonContentType(req, res))
|
|
471
|
+
return;
|
|
472
|
+
const body = await readRequestBody(req);
|
|
473
|
+
let data;
|
|
474
|
+
try {
|
|
475
|
+
data = JSON.parse(body);
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (!data['attackSourceIP'] ||
|
|
482
|
+
!data['attackType'] ||
|
|
483
|
+
!data['mitreTechnique'] ||
|
|
484
|
+
!data['sigmaRuleMatched'] ||
|
|
485
|
+
!data['timestamp'] ||
|
|
486
|
+
!data['region']) {
|
|
487
|
+
sendJson(res, 400, { ok: false, error: 'Missing required fields' });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
// Anonymize IP (zero last octet)
|
|
491
|
+
const ip = String(data['attackSourceIP']);
|
|
492
|
+
if (ip.includes('.')) {
|
|
493
|
+
const parts = ip.split('.');
|
|
494
|
+
if (parts.length === 4) {
|
|
495
|
+
parts[3] = '0';
|
|
496
|
+
data['attackSourceIP'] = parts.join('.');
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
threatDb.insertThreat(data);
|
|
500
|
+
sendJson(res, 201, { ok: true, data: { message: 'Threat data received' } });
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
// GET /api/rules - Fetch rules (optional ?since= filter, paginated)
|
|
504
|
+
if (pathname === '/api/rules' && req.method === 'GET') {
|
|
505
|
+
if (!threatDb) {
|
|
506
|
+
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const urlObj = new URL(url, `http://${req.headers.host ?? 'localhost'}`);
|
|
510
|
+
const since = urlObj.searchParams.get('since');
|
|
511
|
+
// Validate since parameter format (ISO 8601)
|
|
512
|
+
if (since && !/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?/.test(since)) {
|
|
513
|
+
sendJson(res, 400, { ok: false, error: 'Invalid since parameter: must be ISO 8601' });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const rawLimit = parseInt(urlObj.searchParams.get('limit') ?? '1000', 10);
|
|
517
|
+
const limit = isNaN(rawLimit) || rawLimit < 1 ? 1000 : Math.min(rawLimit, 5000);
|
|
518
|
+
const rules = since ? threatDb.getRulesSince(since) : threatDb.getAllRules(limit);
|
|
519
|
+
sendJson(res, 200, { ok: true, data: rules });
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
// POST /api/rules - Publish a new community rule
|
|
523
|
+
if (pathname === '/api/rules' && req.method === 'POST') {
|
|
524
|
+
if (!threatDb) {
|
|
525
|
+
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (!requireTCWriteAuth(req, res))
|
|
529
|
+
return;
|
|
530
|
+
if (!requireJsonContentType(req, res))
|
|
531
|
+
return;
|
|
532
|
+
const body = await readRequestBody(req);
|
|
533
|
+
let rule;
|
|
534
|
+
try {
|
|
535
|
+
rule = JSON.parse(body);
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (!rule['ruleId'] || !rule['ruleContent'] || !rule['source']) {
|
|
542
|
+
sendJson(res, 400, {
|
|
543
|
+
ok: false,
|
|
544
|
+
error: 'Missing required fields: ruleId, ruleContent, source',
|
|
545
|
+
});
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
// Field-level size limits
|
|
549
|
+
if (String(rule['ruleContent']).length > 65_536) {
|
|
550
|
+
sendJson(res, 400, { ok: false, error: 'ruleContent exceeds maximum size of 64KB' });
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (String(rule['ruleId']).length > 256) {
|
|
554
|
+
sendJson(res, 400, { ok: false, error: 'ruleId exceeds maximum length of 256' });
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
rule['publishedAt'] = rule['publishedAt'] || new Date().toISOString();
|
|
558
|
+
threatDb.upsertRule(rule);
|
|
559
|
+
sendJson(res, 201, { ok: true, data: { message: 'Rule published', ruleId: rule['ruleId'] } });
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
// GET /api/stats - Threat statistics
|
|
563
|
+
if (pathname === '/api/stats' && req.method === 'GET') {
|
|
564
|
+
if (!threatDb) {
|
|
565
|
+
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const stats = threatDb.getStats();
|
|
569
|
+
sendJson(res, 200, { ok: true, data: stats });
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
// POST /api/atr-proposals - Submit ATR rule proposal
|
|
573
|
+
if (pathname === '/api/atr-proposals' && req.method === 'POST') {
|
|
574
|
+
if (!threatDb) {
|
|
575
|
+
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (!requireTCWriteAuth(req, res))
|
|
579
|
+
return;
|
|
580
|
+
if (!requireJsonContentType(req, res))
|
|
581
|
+
return;
|
|
582
|
+
const body = await readRequestBody(req);
|
|
583
|
+
let proposal;
|
|
584
|
+
try {
|
|
585
|
+
proposal = JSON.parse(body);
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (!proposal['patternHash'] ||
|
|
592
|
+
!proposal['ruleContent'] ||
|
|
593
|
+
!proposal['llmProvider'] ||
|
|
594
|
+
!proposal['llmModel'] ||
|
|
595
|
+
!proposal['selfReviewVerdict']) {
|
|
596
|
+
sendJson(res, 400, { ok: false, error: 'Missing required fields' });
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
// Validate and sanitize client ID
|
|
600
|
+
const rawClientId = req.headers['x-panguard-client-id'];
|
|
601
|
+
const clientId = typeof rawClientId === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(rawClientId)
|
|
602
|
+
? rawClientId
|
|
603
|
+
: null;
|
|
604
|
+
proposal['clientId'] = clientId;
|
|
605
|
+
// Check if this pattern already has a proposal - if so, increment confirmation
|
|
606
|
+
const pHash = String(proposal['patternHash']);
|
|
607
|
+
const existing = threatDb
|
|
608
|
+
.getATRProposals()
|
|
609
|
+
.find((p) => p['pattern_hash'] === pHash);
|
|
610
|
+
if (existing) {
|
|
611
|
+
threatDb.confirmATRProposal(pHash);
|
|
612
|
+
sendJson(res, 200, {
|
|
613
|
+
ok: true,
|
|
614
|
+
data: { message: 'Confirmation recorded', patternHash: pHash },
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
threatDb.insertATRProposal(proposal);
|
|
619
|
+
// Fire-and-forget LLM review on first submission
|
|
620
|
+
if (llmReviewer?.isAvailable()) {
|
|
621
|
+
void llmReviewer
|
|
622
|
+
.reviewProposal(pHash, String(proposal['ruleContent']))
|
|
623
|
+
.catch((err) => {
|
|
624
|
+
console.error(`LLM review error for ${pHash}:`, err);
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
sendJson(res, 201, {
|
|
628
|
+
ok: true,
|
|
629
|
+
data: { message: 'Proposal submitted', patternHash: pHash },
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
// GET /api/atr-proposals - List proposals (admin-only)
|
|
635
|
+
if (pathname === '/api/atr-proposals' && req.method === 'GET') {
|
|
636
|
+
if (!threatDb) {
|
|
637
|
+
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (!requireTCAdminAuth(req, res, _db))
|
|
641
|
+
return;
|
|
642
|
+
const urlObj = new URL(url, `http://${req.headers.host ?? 'localhost'}`);
|
|
643
|
+
const status = urlObj.searchParams.get('status') ?? undefined;
|
|
644
|
+
const proposals = threatDb.getATRProposals(status);
|
|
645
|
+
sendJson(res, 200, { ok: true, data: proposals });
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
// POST /api/atr-feedback - Report ATR rule match feedback
|
|
649
|
+
if (pathname === '/api/atr-feedback' && req.method === 'POST') {
|
|
650
|
+
if (!threatDb) {
|
|
651
|
+
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (!requireTCWriteAuth(req, res))
|
|
655
|
+
return;
|
|
656
|
+
if (!requireJsonContentType(req, res))
|
|
657
|
+
return;
|
|
658
|
+
const body = await readRequestBody(req);
|
|
659
|
+
let feedback;
|
|
660
|
+
try {
|
|
661
|
+
feedback = JSON.parse(body);
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (!feedback['ruleId'] || typeof feedback['isTruePositive'] !== 'boolean') {
|
|
668
|
+
sendJson(res, 400, {
|
|
669
|
+
ok: false,
|
|
670
|
+
error: 'Missing or invalid fields: ruleId (string), isTruePositive (boolean)',
|
|
313
671
|
});
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const rawCid = req.headers['x-panguard-client-id'];
|
|
675
|
+
const cid = typeof rawCid === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(rawCid) ? rawCid : null;
|
|
676
|
+
threatDb.insertATRFeedback(String(feedback['ruleId']), feedback['isTruePositive'], cid);
|
|
677
|
+
sendJson(res, 201, { ok: true, data: { message: 'Feedback recorded' } });
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
// POST /api/skill-threats - Submit skill audit result
|
|
681
|
+
if (pathname === '/api/skill-threats' && req.method === 'POST') {
|
|
682
|
+
if (!threatDb) {
|
|
683
|
+
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
if (!requireTCWriteAuth(req, res))
|
|
687
|
+
return;
|
|
688
|
+
if (!requireJsonContentType(req, res))
|
|
689
|
+
return;
|
|
690
|
+
const body = await readRequestBody(req);
|
|
691
|
+
let submission;
|
|
692
|
+
try {
|
|
693
|
+
submission = JSON.parse(body);
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const VALID_RISK_LEVELS = new Set(['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
|
|
700
|
+
if (!submission['skillHash'] || !submission['skillName']) {
|
|
701
|
+
sendJson(res, 400, { ok: false, error: 'Missing required fields: skillHash, skillName' });
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const riskScore = submission['riskScore'];
|
|
705
|
+
if (typeof riskScore !== 'number' ||
|
|
706
|
+
!isFinite(riskScore) ||
|
|
707
|
+
riskScore < 0 ||
|
|
708
|
+
riskScore > 100) {
|
|
709
|
+
sendJson(res, 400, { ok: false, error: 'riskScore must be a number between 0 and 100' });
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (!VALID_RISK_LEVELS.has(String(submission['riskLevel']))) {
|
|
713
|
+
sendJson(res, 400, {
|
|
714
|
+
ok: false,
|
|
715
|
+
error: 'riskLevel must be one of: LOW, MEDIUM, HIGH, CRITICAL',
|
|
716
|
+
});
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const rawCid2 = req.headers['x-panguard-client-id'];
|
|
720
|
+
submission['clientId'] =
|
|
721
|
+
typeof rawCid2 === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(rawCid2) ? rawCid2 : null;
|
|
722
|
+
threatDb.insertSkillThreat(submission);
|
|
723
|
+
sendJson(res, 201, { ok: true, data: { message: 'Skill threat recorded' } });
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
// GET /api/skill-threats - List skill threats (admin-only)
|
|
727
|
+
if (pathname === '/api/skill-threats' && req.method === 'GET') {
|
|
728
|
+
if (!threatDb) {
|
|
729
|
+
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (!requireTCAdminAuth(req, res, _db))
|
|
733
|
+
return;
|
|
734
|
+
const urlObj = new URL(url, `http://${req.headers.host ?? 'localhost'}`);
|
|
735
|
+
const rawLimit = parseInt(urlObj.searchParams.get('limit') ?? '50', 10);
|
|
736
|
+
const limit = isNaN(rawLimit) || rawLimit < 1 ? 50 : Math.min(rawLimit, 500);
|
|
737
|
+
const threats = threatDb.getSkillThreats(limit);
|
|
738
|
+
sendJson(res, 200, { ok: true, data: threats });
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
// GET /api/atr-rules - Fetch confirmed ATR rules (for Guard sync)
|
|
742
|
+
if (pathname === '/api/atr-rules' && req.method === 'GET') {
|
|
743
|
+
if (!threatDb) {
|
|
744
|
+
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
const urlObj = new URL(url, `http://${req.headers.host ?? 'localhost'}`);
|
|
748
|
+
const since = urlObj.searchParams.get('since') ?? undefined;
|
|
749
|
+
const rules = threatDb.getConfirmedATRRules(since);
|
|
750
|
+
sendJson(res, 200, { ok: true, data: rules });
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
// GET /api/yara-rules - Fetch YARA rules (for Guard sync)
|
|
754
|
+
if (pathname === '/api/yara-rules' && req.method === 'GET') {
|
|
755
|
+
if (!threatDb) {
|
|
756
|
+
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const urlObj = new URL(url, `http://${req.headers.host ?? 'localhost'}`);
|
|
760
|
+
const since = urlObj.searchParams.get('since') ?? undefined;
|
|
761
|
+
const rules = threatDb.getRulesBySource('yara', since);
|
|
762
|
+
sendJson(res, 200, { ok: true, data: rules });
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
// GET /api/feeds/ip-blocklist - IP blocklist feed (plain text)
|
|
766
|
+
if (pathname === '/api/feeds/ip-blocklist' && req.method === 'GET') {
|
|
767
|
+
if (!threatDb) {
|
|
768
|
+
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const urlObj = new URL(url, `http://${req.headers.host ?? 'localhost'}`);
|
|
772
|
+
const minReputation = Number(urlObj.searchParams.get('minReputation') ?? '70');
|
|
773
|
+
const ips = threatDb.getIPBlocklist(minReputation);
|
|
774
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
775
|
+
res.writeHead(200);
|
|
776
|
+
res.end(ips.join('\n'));
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
// GET /api/feeds/domain-blocklist - Domain blocklist feed (plain text)
|
|
780
|
+
if (pathname === '/api/feeds/domain-blocklist' && req.method === 'GET') {
|
|
781
|
+
if (!threatDb) {
|
|
782
|
+
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const urlObj = new URL(url, `http://${req.headers.host ?? 'localhost'}`);
|
|
786
|
+
const minReputation = Number(urlObj.searchParams.get('minReputation') ?? '70');
|
|
787
|
+
const domains = threatDb.getDomainBlocklist(minReputation);
|
|
788
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
789
|
+
res.writeHead(200);
|
|
790
|
+
res.end(domains.join('\n'));
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
// POST /api/skill-whitelist - Report safe skill (audit passed)
|
|
794
|
+
if (pathname === '/api/skill-whitelist' && req.method === 'POST') {
|
|
795
|
+
if (!threatDb) {
|
|
796
|
+
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
if (!requireTCWriteAuth(req, res))
|
|
800
|
+
return;
|
|
801
|
+
if (!requireJsonContentType(req, res))
|
|
802
|
+
return;
|
|
803
|
+
const body = await readRequestBody(req);
|
|
804
|
+
let data;
|
|
805
|
+
try {
|
|
806
|
+
data = JSON.parse(body);
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
const skills = 'skills' in data && Array.isArray(data['skills'])
|
|
813
|
+
? data['skills']
|
|
814
|
+
: [data];
|
|
815
|
+
let count = 0;
|
|
816
|
+
for (const skill of skills) {
|
|
817
|
+
const name = skill['skillName'];
|
|
818
|
+
if (!name || typeof name !== 'string')
|
|
819
|
+
continue;
|
|
820
|
+
threatDb.reportSafeSkill(name, typeof skill['fingerprintHash'] === 'string' ? skill['fingerprintHash'] : undefined);
|
|
821
|
+
count++;
|
|
822
|
+
}
|
|
823
|
+
sendJson(res, 201, { ok: true, data: { message: `${count} skill(s) reported`, count } });
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
// GET /api/skill-whitelist - Fetch community whitelist
|
|
827
|
+
if (pathname === '/api/skill-whitelist' && req.method === 'GET') {
|
|
828
|
+
if (!threatDb) {
|
|
829
|
+
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
830
|
+
return;
|
|
314
831
|
}
|
|
832
|
+
const whitelist = threatDb.getSkillWhitelist();
|
|
833
|
+
sendJson(res, 200, { ok: true, data: whitelist });
|
|
315
834
|
return;
|
|
316
835
|
}
|
|
317
836
|
// Auth API routes
|
|
@@ -545,7 +1064,6 @@ async function handleRequest(req, res, handlers, _db, adminDir, managerProxy) {
|
|
|
545
1064
|
smtp: !!process.env['SMTP_HOST'],
|
|
546
1065
|
},
|
|
547
1066
|
security: {
|
|
548
|
-
jwtSecret: !!process.env['JWT_SECRET'],
|
|
549
1067
|
totpEnabled: true,
|
|
550
1068
|
},
|
|
551
1069
|
threatCloud: {
|
|
@@ -765,4 +1283,220 @@ function sendJson(res, status, data) {
|
|
|
765
1283
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
766
1284
|
res.end(JSON.stringify(data));
|
|
767
1285
|
}
|
|
1286
|
+
// ── Threat Cloud Security Helpers ──────────────────────────────
|
|
1287
|
+
/** Timing-safe string comparison to prevent side-channel attacks */
|
|
1288
|
+
function timingSafeCompare(a, b) {
|
|
1289
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1290
|
+
const { timingSafeEqual } = require('node:crypto');
|
|
1291
|
+
const ab = Buffer.from(a);
|
|
1292
|
+
const bb = Buffer.from(b);
|
|
1293
|
+
if (ab.length !== bb.length) {
|
|
1294
|
+
// Compare against self to maintain constant time
|
|
1295
|
+
timingSafeEqual(ab, ab);
|
|
1296
|
+
return false;
|
|
1297
|
+
}
|
|
1298
|
+
return timingSafeEqual(ab, bb);
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Require TC_API_KEY auth for write endpoints.
|
|
1302
|
+
* In production: BLOCK if TC_API_KEY not set (refuse unauthenticated writes).
|
|
1303
|
+
* In dev: allow passthrough with warning.
|
|
1304
|
+
*/
|
|
1305
|
+
function requireTCWriteAuth(req, res) {
|
|
1306
|
+
const tcApiKey = process.env['TC_API_KEY'];
|
|
1307
|
+
if (!tcApiKey) {
|
|
1308
|
+
if (process.env['NODE_ENV'] === 'production') {
|
|
1309
|
+
sendJson(res, 503, {
|
|
1310
|
+
ok: false,
|
|
1311
|
+
error: 'Threat Cloud write API not configured (TC_API_KEY missing)',
|
|
1312
|
+
});
|
|
1313
|
+
return false;
|
|
1314
|
+
}
|
|
1315
|
+
return true; // dev passthrough
|
|
1316
|
+
}
|
|
1317
|
+
const authHeader = req.headers.authorization ?? '';
|
|
1318
|
+
const token = authHeader.replace('Bearer ', '');
|
|
1319
|
+
if (!timingSafeCompare(token, tcApiKey)) {
|
|
1320
|
+
sendJson(res, 401, { ok: false, error: 'Invalid API key' });
|
|
1321
|
+
return false;
|
|
1322
|
+
}
|
|
1323
|
+
return true;
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Require admin session auth for admin-only GET endpoints.
|
|
1327
|
+
* Verifies the Bearer token is a valid session with admin role.
|
|
1328
|
+
*/
|
|
1329
|
+
function requireTCAdminAuth(req, res, db) {
|
|
1330
|
+
const user = authenticateRequest(req, db);
|
|
1331
|
+
if (!user) {
|
|
1332
|
+
sendJson(res, 401, { ok: false, error: 'Authentication required' });
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1335
|
+
if (!requireAdmin(user)) {
|
|
1336
|
+
sendJson(res, 403, { ok: false, error: 'Admin access required' });
|
|
1337
|
+
return false;
|
|
1338
|
+
}
|
|
1339
|
+
return true;
|
|
1340
|
+
}
|
|
1341
|
+
/** Validate Content-Type is application/json for POST requests */
|
|
1342
|
+
function requireJsonContentType(req, res) {
|
|
1343
|
+
const ct = req.headers['content-type'] ?? '';
|
|
1344
|
+
if (!ct.includes('application/json')) {
|
|
1345
|
+
sendJson(res, 400, { ok: false, error: 'Content-Type must be application/json' });
|
|
1346
|
+
return false;
|
|
1347
|
+
}
|
|
1348
|
+
return true;
|
|
1349
|
+
}
|
|
1350
|
+
/** Per-IP rate limiter for Threat Cloud endpoints (120 req/min) */
|
|
1351
|
+
const tcRateLimits = new Map();
|
|
1352
|
+
function checkTCRateLimit(ip) {
|
|
1353
|
+
const now = Date.now();
|
|
1354
|
+
const entry = tcRateLimits.get(ip);
|
|
1355
|
+
if (!entry || now > entry.resetAt) {
|
|
1356
|
+
tcRateLimits.set(ip, { count: 1, resetAt: now + 60_000 });
|
|
1357
|
+
return true;
|
|
1358
|
+
}
|
|
1359
|
+
entry.count++;
|
|
1360
|
+
return entry.count <= 120;
|
|
1361
|
+
}
|
|
1362
|
+
/** Read request body with 1MB size limit */
|
|
1363
|
+
function readRequestBody(req) {
|
|
1364
|
+
return new Promise((resolve, reject) => {
|
|
1365
|
+
const chunks = [];
|
|
1366
|
+
let size = 0;
|
|
1367
|
+
const MAX_BODY = 1_048_576; // 1MB
|
|
1368
|
+
req.on('data', (chunk) => {
|
|
1369
|
+
size += chunk.length;
|
|
1370
|
+
if (size > MAX_BODY) {
|
|
1371
|
+
req.destroy();
|
|
1372
|
+
reject(new Error('Request body too large'));
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
chunks.push(chunk);
|
|
1376
|
+
});
|
|
1377
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
1378
|
+
req.on('error', reject);
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Seed rules from bundled config/ directory into Threat Cloud DB.
|
|
1383
|
+
* Reads Sigma YAML, YARA, and ATR YAML files.
|
|
1384
|
+
* Returns count of rules seeded.
|
|
1385
|
+
*/
|
|
1386
|
+
async function seedRulesFromBundled(threatDb) {
|
|
1387
|
+
const { readdirSync, readFileSync: readFs, statSync } = await import('node:fs');
|
|
1388
|
+
const { join: joinPath, basename, relative } = await import('node:path');
|
|
1389
|
+
let seeded = 0;
|
|
1390
|
+
const now = new Date().toISOString();
|
|
1391
|
+
// Resolve config directory (Docker: /app/config, monorepo: ../../config)
|
|
1392
|
+
const configDirs = [
|
|
1393
|
+
join(process.cwd(), 'config'),
|
|
1394
|
+
join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', '..', 'config'),
|
|
1395
|
+
];
|
|
1396
|
+
const configDir = configDirs.find((d) => {
|
|
1397
|
+
try {
|
|
1398
|
+
return statSync(d).isDirectory();
|
|
1399
|
+
}
|
|
1400
|
+
catch {
|
|
1401
|
+
return false;
|
|
1402
|
+
}
|
|
1403
|
+
});
|
|
1404
|
+
if (!configDir) {
|
|
1405
|
+
console.log(` ${c.dim(' No config/ directory found — skipping rule seeding')}`);
|
|
1406
|
+
console.log(` ${c.dim(` Searched: ${configDirs.join(', ')}`)}`);
|
|
1407
|
+
return 0;
|
|
1408
|
+
}
|
|
1409
|
+
console.log(` ${c.dim(` Using config directory: ${configDir}`)}`);
|
|
1410
|
+
/** Recursively collect files matching extensions */
|
|
1411
|
+
function collectFiles(dir, extensions) {
|
|
1412
|
+
const results = [];
|
|
1413
|
+
try {
|
|
1414
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
1415
|
+
const fullPath = joinPath(dir, entry.name);
|
|
1416
|
+
if (entry.isDirectory()) {
|
|
1417
|
+
results.push(...collectFiles(fullPath, extensions));
|
|
1418
|
+
}
|
|
1419
|
+
else if (extensions.some((ext) => entry.name.endsWith(ext))) {
|
|
1420
|
+
results.push(fullPath);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
catch (err) {
|
|
1425
|
+
console.error(` [WARN] Cannot read directory ${dir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1426
|
+
}
|
|
1427
|
+
return results;
|
|
1428
|
+
}
|
|
1429
|
+
// 1. Sigma rules (.yml, .yaml)
|
|
1430
|
+
const sigmaDir = joinPath(configDir, 'sigma-rules');
|
|
1431
|
+
try {
|
|
1432
|
+
const sigmaFiles = collectFiles(sigmaDir, ['.yml', '.yaml']);
|
|
1433
|
+
for (const file of sigmaFiles) {
|
|
1434
|
+
const content = readFs(file, 'utf-8');
|
|
1435
|
+
const ruleId = `sigma:${relative(sigmaDir, file).replace(/\//g, ':')}`;
|
|
1436
|
+
threatDb.upsertRule({ ruleId, ruleContent: content, publishedAt: now, source: 'sigma' });
|
|
1437
|
+
seeded++;
|
|
1438
|
+
}
|
|
1439
|
+
console.log(` ${c.dim(` Sigma: ${sigmaFiles.length} files processed`)}`);
|
|
1440
|
+
}
|
|
1441
|
+
catch (err) {
|
|
1442
|
+
console.error(` [WARN] Sigma rule seeding failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1443
|
+
}
|
|
1444
|
+
// 2. YARA rules (.yar, .yara)
|
|
1445
|
+
const yaraDir = joinPath(configDir, 'yara-rules');
|
|
1446
|
+
try {
|
|
1447
|
+
const yaraFiles = collectFiles(yaraDir, ['.yar', '.yara']);
|
|
1448
|
+
for (const file of yaraFiles) {
|
|
1449
|
+
const content = readFs(file, 'utf-8');
|
|
1450
|
+
// Split multi-rule YARA files
|
|
1451
|
+
const ruleMatches = content.match(/rule\s+\w+/g);
|
|
1452
|
+
if (ruleMatches && ruleMatches.length > 1) {
|
|
1453
|
+
// Multi-rule file: store each rule name as sub-ID
|
|
1454
|
+
for (const match of ruleMatches) {
|
|
1455
|
+
const ruleName = match.replace('rule ', '');
|
|
1456
|
+
const ruleId = `yara:${basename(file, '.yar').replace('.yara', '')}:${ruleName}`;
|
|
1457
|
+
threatDb.upsertRule({ ruleId, ruleContent: content, publishedAt: now, source: 'yara' });
|
|
1458
|
+
seeded++;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
else {
|
|
1462
|
+
const ruleId = `yara:${relative(yaraDir, file).replace(/\//g, ':')}`;
|
|
1463
|
+
threatDb.upsertRule({ ruleId, ruleContent: content, publishedAt: now, source: 'yara' });
|
|
1464
|
+
seeded++;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
console.log(` ${c.dim(` YARA: ${yaraFiles.length} files processed`)}`);
|
|
1468
|
+
}
|
|
1469
|
+
catch (err) {
|
|
1470
|
+
console.error(` [WARN] YARA rule seeding failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1471
|
+
}
|
|
1472
|
+
// 3. ATR rules (.yaml, .yml) from atr package
|
|
1473
|
+
const atrDirs = [
|
|
1474
|
+
joinPath(process.cwd(), 'node_modules', 'agent-threat-rules', 'rules'),
|
|
1475
|
+
joinPath(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', '..', 'packages', 'atr', 'rules'),
|
|
1476
|
+
];
|
|
1477
|
+
const atrDir = atrDirs.find((d) => {
|
|
1478
|
+
try {
|
|
1479
|
+
return statSync(d).isDirectory();
|
|
1480
|
+
}
|
|
1481
|
+
catch {
|
|
1482
|
+
return false;
|
|
1483
|
+
}
|
|
1484
|
+
});
|
|
1485
|
+
if (atrDir) {
|
|
1486
|
+
try {
|
|
1487
|
+
const atrFiles = collectFiles(atrDir, ['.yaml', '.yml']);
|
|
1488
|
+
for (const file of atrFiles) {
|
|
1489
|
+
const content = readFs(file, 'utf-8');
|
|
1490
|
+
const ruleId = `atr:${relative(atrDir, file).replace(/\//g, ':')}`;
|
|
1491
|
+
threatDb.upsertRule({ ruleId, ruleContent: content, publishedAt: now, source: 'atr' });
|
|
1492
|
+
seeded++;
|
|
1493
|
+
}
|
|
1494
|
+
console.log(` ${c.dim(` ATR: ${atrFiles.length} files processed`)}`);
|
|
1495
|
+
}
|
|
1496
|
+
catch (err) {
|
|
1497
|
+
console.error(` [WARN] ATR rule seeding failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
return seeded;
|
|
1501
|
+
}
|
|
768
1502
|
//# sourceMappingURL=serve.js.map
|