@panguard-ai/panguard 0.3.7 → 0.4.1
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/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/audit.js +28 -1
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/cli/commands/serve-admin.d.ts +11 -0
- package/dist/cli/commands/serve-admin.d.ts.map +1 -0
- package/dist/cli/commands/serve-admin.js +302 -0
- package/dist/cli/commands/serve-admin.js.map +1 -0
- package/dist/cli/commands/serve-auth.d.ts +11 -0
- package/dist/cli/commands/serve-auth.d.ts.map +1 -0
- package/dist/cli/commands/serve-auth.js +119 -0
- package/dist/cli/commands/serve-auth.js.map +1 -0
- package/dist/cli/commands/serve-core.d.ts +25 -0
- package/dist/cli/commands/serve-core.d.ts.map +1 -0
- package/dist/cli/commands/serve-core.js +258 -0
- package/dist/cli/commands/serve-core.js.map +1 -0
- package/dist/cli/commands/serve-tc.d.ts +12 -0
- package/dist/cli/commands/serve-tc.d.ts.map +1 -0
- package/dist/cli/commands/serve-tc.js +296 -0
- package/dist/cli/commands/serve-tc.js.map +1 -0
- package/dist/cli/commands/serve-types.d.ts +38 -0
- package/dist/cli/commands/serve-types.d.ts.map +1 -0
- package/dist/cli/commands/serve-types.js +108 -0
- package/dist/cli/commands/serve-types.js.map +1 -0
- package/dist/cli/commands/serve.d.ts +6 -0
- package/dist/cli/commands/serve.d.ts.map +1 -1
- package/dist/cli/commands/serve.js +41 -1144
- package/dist/cli/commands/serve.js.map +1 -1
- package/dist/cli/commands/setup.js +2 -2
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/interactive/actions/audit.d.ts +7 -0
- package/dist/cli/interactive/actions/audit.d.ts.map +1 -0
- package/dist/cli/interactive/actions/audit.js +198 -0
- package/dist/cli/interactive/actions/audit.js.map +1 -0
- package/dist/cli/interactive/actions/demo.d.ts +7 -0
- package/dist/cli/interactive/actions/demo.d.ts.map +1 -0
- package/dist/cli/interactive/actions/demo.js +113 -0
- package/dist/cli/interactive/actions/demo.js.map +1 -0
- package/dist/cli/interactive/actions/guard.d.ts +7 -0
- package/dist/cli/interactive/actions/guard.d.ts.map +1 -0
- package/dist/cli/interactive/actions/guard.js +164 -0
- package/dist/cli/interactive/actions/guard.js.map +1 -0
- package/dist/cli/interactive/actions/misc.d.ts +13 -0
- package/dist/cli/interactive/actions/misc.d.ts.map +1 -0
- package/dist/cli/interactive/actions/misc.js +209 -0
- package/dist/cli/interactive/actions/misc.js.map +1 -0
- package/dist/cli/interactive/actions/scan.d.ts +7 -0
- package/dist/cli/interactive/actions/scan.d.ts.map +1 -0
- package/dist/cli/interactive/actions/scan.js +143 -0
- package/dist/cli/interactive/actions/scan.js.map +1 -0
- package/dist/cli/interactive/actions/setup.d.ts +8 -0
- package/dist/cli/interactive/actions/setup.d.ts.map +1 -0
- package/dist/cli/interactive/actions/setup.js +130 -0
- package/dist/cli/interactive/actions/setup.js.map +1 -0
- package/dist/cli/interactive/lang.d.ts +11 -0
- package/dist/cli/interactive/lang.d.ts.map +1 -0
- package/dist/cli/interactive/lang.js +51 -0
- package/dist/cli/interactive/lang.js.map +1 -0
- package/dist/cli/interactive/menu-defs.d.ts +17 -0
- package/dist/cli/interactive/menu-defs.d.ts.map +1 -0
- package/dist/cli/interactive/menu-defs.js +106 -0
- package/dist/cli/interactive/menu-defs.js.map +1 -0
- package/dist/cli/interactive/render.d.ts +16 -0
- package/dist/cli/interactive/render.d.ts.map +1 -0
- package/dist/cli/interactive/render.js +145 -0
- package/dist/cli/interactive/render.js.map +1 -0
- package/dist/cli/interactive.d.ts.map +1 -1
- package/dist/cli/interactive.js +64 -1322
- package/dist/cli/interactive.js.map +1 -1
- package/package.json +8 -7
|
@@ -2,17 +2,28 @@
|
|
|
2
2
|
* panguard serve - Unified HTTP server gateway
|
|
3
3
|
*
|
|
4
4
|
* Serves auth API, admin API, admin dashboard UI, and health check.
|
|
5
|
+
* Route handling is delegated to focused sub-modules:
|
|
6
|
+
* serve-core.ts -- middleware, health, static files, rule seeding
|
|
7
|
+
* serve-auth.ts -- auth, waitlist, usage routes
|
|
8
|
+
* serve-admin.ts -- admin dashboard + manager proxy routes
|
|
9
|
+
* serve-tc.ts -- Threat Cloud API routes
|
|
10
|
+
* serve-types.ts -- shared types and utilities
|
|
5
11
|
*/
|
|
6
12
|
import { Command } from 'commander';
|
|
7
13
|
import { createServer } from 'node:http';
|
|
8
|
-
import { join, dirname
|
|
9
|
-
import { existsSync
|
|
14
|
+
import { join, dirname } from 'node:path';
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
10
16
|
import { homedir } from 'node:os';
|
|
11
17
|
import { fileURLToPath } from 'node:url';
|
|
12
18
|
import { c, banner } from '@panguard-ai/core';
|
|
13
19
|
import { PANGUARD_VERSION } from '../../index.js';
|
|
14
|
-
import { AuthDB, createAuthHandlers, sendExpirationWarningEmail, initErrorTracking, captureRequestError,
|
|
20
|
+
import { AuthDB, createAuthHandlers, sendExpirationWarningEmail, initErrorTracking, captureRequestError, ManagerProxy, } from '@panguard-ai/panguard-auth';
|
|
15
21
|
import { ManagerServer, DEFAULT_MANAGER_CONFIG } from '@panguard-ai/manager';
|
|
22
|
+
import { sendJson } from './serve-types.js';
|
|
23
|
+
import { applyMiddleware, handleCoreRoutes, seedRulesFromBundled } from './serve-core.js';
|
|
24
|
+
import { handleAuthRoutes } from './serve-auth.js';
|
|
25
|
+
import { handleAdminRoutes } from './serve-admin.js';
|
|
26
|
+
import { handleTCRoutes } from './serve-tc.js';
|
|
16
27
|
export function serveCommand() {
|
|
17
28
|
return new Command('serve')
|
|
18
29
|
.description('Start unified HTTP server / 啟動統一 HTTP 伺服器')
|
|
@@ -79,7 +90,7 @@ export function serveCommand() {
|
|
|
79
90
|
await initErrorTracking();
|
|
80
91
|
// Initialize database
|
|
81
92
|
const db = new AuthDB(options.db);
|
|
82
|
-
// Initialize Threat Cloud database (optional
|
|
93
|
+
// Initialize Threat Cloud database (optional -- graceful if unavailable)
|
|
83
94
|
let threatDb = null;
|
|
84
95
|
try {
|
|
85
96
|
const tcMod = '@panguard-ai/threat-cloud';
|
|
@@ -108,7 +119,7 @@ export function serveCommand() {
|
|
|
108
119
|
console.log(` ${c.dim('Threat Cloud API routes disabled due to error above')}`);
|
|
109
120
|
console.log('');
|
|
110
121
|
}
|
|
111
|
-
// Initialize LLM Reviewer for ATR proposals (optional
|
|
122
|
+
// Initialize LLM Reviewer for ATR proposals (optional -- needs ANTHROPIC_API_KEY)
|
|
112
123
|
let llmReviewer = null;
|
|
113
124
|
if (threatDb && process.env['ANTHROPIC_API_KEY']) {
|
|
114
125
|
try {
|
|
@@ -214,17 +225,25 @@ export function serveCommand() {
|
|
|
214
225
|
// Initialize Manager proxy for agent/event admin API routes
|
|
215
226
|
const managerProxy = new ManagerProxy(process.env['MANAGER_URL'], process.env['MANAGER_AUTH_TOKEN']);
|
|
216
227
|
// Resolve admin static directory
|
|
217
|
-
// Try multiple locations: sibling package, or relative to CWD
|
|
218
228
|
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
219
229
|
const adminDirs = [
|
|
220
230
|
join(process.cwd(), 'packages', 'admin'),
|
|
221
231
|
join(thisDir, '..', '..', '..', '..', 'admin'),
|
|
222
232
|
];
|
|
223
233
|
const adminDir = adminDirs.find((d) => existsSync(d));
|
|
234
|
+
// Build shared route context (immutable after creation)
|
|
235
|
+
const routeCtx = {
|
|
236
|
+
handlers,
|
|
237
|
+
db,
|
|
238
|
+
adminDir,
|
|
239
|
+
managerProxy,
|
|
240
|
+
threatDb,
|
|
241
|
+
llmReviewer,
|
|
242
|
+
};
|
|
224
243
|
const server = createServer((req, res) => {
|
|
225
|
-
void handleRequest(req, res,
|
|
244
|
+
void handleRequest(req, res, routeCtx);
|
|
226
245
|
});
|
|
227
|
-
// Build Manager config from environment
|
|
246
|
+
// Build Manager config from environment
|
|
228
247
|
const managerPort = parseInt(options.managerPort, 10);
|
|
229
248
|
const managerConfig = {
|
|
230
249
|
...DEFAULT_MANAGER_CONFIG,
|
|
@@ -261,7 +280,7 @@ export function serveCommand() {
|
|
|
261
280
|
console.log(` Sheets: ${sheets ? c.safe('Google Sheets') : c.dim('Not configured')}`);
|
|
262
281
|
console.log(` Manager: ${c.safe(`port ${managerPort}`)}${process.env['MANAGER_AUTH_TOKEN'] ? '' : c.dim(' (no auth)')}`);
|
|
263
282
|
console.log('');
|
|
264
|
-
// Start Manager server after auth server is listening
|
|
283
|
+
// Start Manager server after auth server is listening
|
|
265
284
|
managerServer
|
|
266
285
|
.start()
|
|
267
286
|
.then(() => {
|
|
@@ -299,7 +318,7 @@ export function serveCommand() {
|
|
|
299
318
|
const planCheckTimer = setInterval(runPlanCheck, 60 * 60 * 1000);
|
|
300
319
|
if (planCheckTimer.unref)
|
|
301
320
|
planCheckTimer.unref();
|
|
302
|
-
// Graceful shutdown
|
|
321
|
+
// Graceful shutdown
|
|
303
322
|
const shutdown = () => {
|
|
304
323
|
console.log('\n Shutting down...');
|
|
305
324
|
clearInterval(planCheckTimer);
|
|
@@ -319,885 +338,26 @@ export function serveCommand() {
|
|
|
319
338
|
process.on('SIGTERM', shutdown);
|
|
320
339
|
});
|
|
321
340
|
}
|
|
322
|
-
|
|
341
|
+
// ── Request Router ─────────────────────────────────────────────
|
|
342
|
+
async function handleRequest(req, res, ctx) {
|
|
323
343
|
const url = req.url ?? '/';
|
|
324
344
|
const pathname = url.split('?')[0] ?? '/';
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
328
|
-
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
329
|
-
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
330
|
-
res.setHeader('X-XSS-Protection', '0');
|
|
331
|
-
if (process.env['NODE_ENV'] === 'production') {
|
|
332
|
-
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
|
|
333
|
-
}
|
|
334
|
-
// CORS — default to same-origin only; set CORS_ALLOWED_ORIGINS to allow cross-origin
|
|
335
|
-
const corsEnv = process.env['CORS_ALLOWED_ORIGINS'] ?? '';
|
|
336
|
-
const allowedOrigins = corsEnv ? corsEnv.split(',').map((o) => o.trim()) : [];
|
|
337
|
-
const origin = req.headers.origin ?? '';
|
|
338
|
-
if (allowedOrigins.includes('*') && process.env['NODE_ENV'] !== 'production') {
|
|
339
|
-
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
|
340
|
-
}
|
|
341
|
-
else if (origin && allowedOrigins.includes(origin)) {
|
|
342
|
-
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
343
|
-
}
|
|
344
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS');
|
|
345
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
346
|
-
if (req.method === 'OPTIONS') {
|
|
347
|
-
res.writeHead(204);
|
|
348
|
-
res.end();
|
|
345
|
+
// Apply security headers and CORS; handle OPTIONS preflight
|
|
346
|
+
if (applyMiddleware(req, res))
|
|
349
347
|
return;
|
|
350
|
-
}
|
|
351
348
|
try {
|
|
352
|
-
// OpenAPI
|
|
353
|
-
if (pathname
|
|
354
|
-
const spec = generateOpenApiSpec(process.env['PANGUARD_BASE_URL'] ?? `http://${req.headers.host ?? 'localhost'}`);
|
|
355
|
-
sendJson(res, 200, spec);
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
// Swagger UI
|
|
359
|
-
if (pathname === '/docs/api' || pathname === '/docs/api/') {
|
|
360
|
-
const specUrl = '/openapi.json';
|
|
361
|
-
const html = generateSwaggerHtml(specUrl);
|
|
362
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
363
|
-
res.end(html);
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
// Health check (minimal public response — detailed status behind /api/admin/health)
|
|
367
|
-
if (pathname === '/health') {
|
|
368
|
-
try {
|
|
369
|
-
_db.healthCheck();
|
|
370
|
-
sendJson(res, 200, {
|
|
371
|
-
ok: true,
|
|
372
|
-
data: {
|
|
373
|
-
status: 'healthy',
|
|
374
|
-
uptime: Math.round(process.uptime()),
|
|
375
|
-
db: 'connected',
|
|
376
|
-
threatCloud: threatDb ? 'connected' : 'unavailable',
|
|
377
|
-
},
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
catch {
|
|
381
|
-
sendJson(res, 503, {
|
|
382
|
-
ok: false,
|
|
383
|
-
data: { status: 'unhealthy', db: 'disconnected' },
|
|
384
|
-
});
|
|
385
|
-
}
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
// Detailed health (admin-only) — includes memory, services, threat stats
|
|
389
|
-
if (pathname === '/api/admin/health' && req.method === 'GET') {
|
|
390
|
-
const mem = process.memoryUsage();
|
|
391
|
-
const services = {
|
|
392
|
-
email: !!(process.env['RESEND_API_KEY'] || process.env['SMTP_HOST']),
|
|
393
|
-
oauth: !!process.env['GOOGLE_CLIENT_ID'],
|
|
394
|
-
errorTracking: !!process.env['SENTRY_DSN'],
|
|
395
|
-
threatCloud: !!threatDb,
|
|
396
|
-
tcApiKey: !!process.env['TC_API_KEY'],
|
|
397
|
-
};
|
|
398
|
-
let threatStats = null;
|
|
399
|
-
if (threatDb) {
|
|
400
|
-
try {
|
|
401
|
-
const s = threatDb.getStats();
|
|
402
|
-
threatStats = { rules: s.totalRules, threats: s.totalThreats };
|
|
403
|
-
}
|
|
404
|
-
catch {
|
|
405
|
-
/* ignore */
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
sendJson(res, 200, {
|
|
409
|
-
ok: true,
|
|
410
|
-
data: {
|
|
411
|
-
status: 'healthy',
|
|
412
|
-
version: process.env['npm_package_version'] ?? '0.0.0',
|
|
413
|
-
uptime: Math.round(process.uptime()),
|
|
414
|
-
db: 'connected',
|
|
415
|
-
threatStats,
|
|
416
|
-
memory: {
|
|
417
|
-
rss: Math.round(mem.rss / 1024 / 1024),
|
|
418
|
-
heapUsed: Math.round(mem.heapUsed / 1024 / 1024),
|
|
419
|
-
},
|
|
420
|
-
services,
|
|
421
|
-
},
|
|
422
|
-
});
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
// ── Threat Cloud API Routes ────────────────────────────────────
|
|
426
|
-
// Security: rate limiting, auth, input validation
|
|
427
|
-
// Rate limit for Threat Cloud endpoints (per-IP, shared state)
|
|
428
|
-
if (threatDb &&
|
|
429
|
-
pathname.startsWith('/api/') &&
|
|
430
|
-
[
|
|
431
|
-
'/api/threats',
|
|
432
|
-
'/api/rules',
|
|
433
|
-
'/api/stats',
|
|
434
|
-
'/api/atr-proposals',
|
|
435
|
-
'/api/atr-feedback',
|
|
436
|
-
'/api/skill-threats',
|
|
437
|
-
'/api/atr-rules',
|
|
438
|
-
'/api/yara-rules',
|
|
439
|
-
'/api/feeds/ip-blocklist',
|
|
440
|
-
'/api/feeds/domain-blocklist',
|
|
441
|
-
].some((p) => pathname === p)) {
|
|
442
|
-
const clientIP = req.socket.remoteAddress ?? 'unknown';
|
|
443
|
-
if (!checkTCRateLimit(clientIP)) {
|
|
444
|
-
sendJson(res, 429, { ok: false, error: 'Rate limit exceeded. Try again later.' });
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
// POST /api/threats - Upload anonymized threat data
|
|
449
|
-
if (pathname === '/api/threats' && req.method === 'POST') {
|
|
450
|
-
if (!threatDb) {
|
|
451
|
-
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
if (!requireTCWriteAuth(req, res))
|
|
455
|
-
return;
|
|
456
|
-
if (!requireJsonContentType(req, res))
|
|
457
|
-
return;
|
|
458
|
-
const body = await readRequestBody(req);
|
|
459
|
-
let data;
|
|
460
|
-
try {
|
|
461
|
-
data = JSON.parse(body);
|
|
462
|
-
}
|
|
463
|
-
catch {
|
|
464
|
-
sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
467
|
-
if (!data['attackSourceIP'] ||
|
|
468
|
-
!data['attackType'] ||
|
|
469
|
-
!data['mitreTechnique'] ||
|
|
470
|
-
!data['sigmaRuleMatched'] ||
|
|
471
|
-
!data['timestamp'] ||
|
|
472
|
-
!data['region']) {
|
|
473
|
-
sendJson(res, 400, { ok: false, error: 'Missing required fields' });
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
// Anonymize IP (zero last octet)
|
|
477
|
-
const ip = String(data['attackSourceIP']);
|
|
478
|
-
if (ip.includes('.')) {
|
|
479
|
-
const parts = ip.split('.');
|
|
480
|
-
if (parts.length === 4) {
|
|
481
|
-
parts[3] = '0';
|
|
482
|
-
data['attackSourceIP'] = parts.join('.');
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
threatDb.insertThreat(data);
|
|
486
|
-
sendJson(res, 201, { ok: true, data: { message: 'Threat data received' } });
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
489
|
-
// GET /api/rules - Fetch rules (optional ?since= filter, paginated)
|
|
490
|
-
if (pathname === '/api/rules' && req.method === 'GET') {
|
|
491
|
-
if (!threatDb) {
|
|
492
|
-
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
493
|
-
return;
|
|
494
|
-
}
|
|
495
|
-
const urlObj = new URL(url, `http://${req.headers.host ?? 'localhost'}`);
|
|
496
|
-
const since = urlObj.searchParams.get('since');
|
|
497
|
-
// Validate since parameter format (ISO 8601)
|
|
498
|
-
if (since && !/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?/.test(since)) {
|
|
499
|
-
sendJson(res, 400, { ok: false, error: 'Invalid since parameter: must be ISO 8601' });
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
const rawLimit = parseInt(urlObj.searchParams.get('limit') ?? '1000', 10);
|
|
503
|
-
const limit = isNaN(rawLimit) || rawLimit < 1 ? 1000 : Math.min(rawLimit, 5000);
|
|
504
|
-
const rules = since ? threatDb.getRulesSince(since) : threatDb.getAllRules(limit);
|
|
505
|
-
sendJson(res, 200, { ok: true, data: rules });
|
|
506
|
-
return;
|
|
507
|
-
}
|
|
508
|
-
// POST /api/rules - Publish a new community rule
|
|
509
|
-
if (pathname === '/api/rules' && req.method === 'POST') {
|
|
510
|
-
if (!threatDb) {
|
|
511
|
-
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
512
|
-
return;
|
|
513
|
-
}
|
|
514
|
-
if (!requireTCWriteAuth(req, res))
|
|
515
|
-
return;
|
|
516
|
-
if (!requireJsonContentType(req, res))
|
|
517
|
-
return;
|
|
518
|
-
const body = await readRequestBody(req);
|
|
519
|
-
let rule;
|
|
520
|
-
try {
|
|
521
|
-
rule = JSON.parse(body);
|
|
522
|
-
}
|
|
523
|
-
catch {
|
|
524
|
-
sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
525
|
-
return;
|
|
526
|
-
}
|
|
527
|
-
if (!rule['ruleId'] || !rule['ruleContent'] || !rule['source']) {
|
|
528
|
-
sendJson(res, 400, {
|
|
529
|
-
ok: false,
|
|
530
|
-
error: 'Missing required fields: ruleId, ruleContent, source',
|
|
531
|
-
});
|
|
532
|
-
return;
|
|
533
|
-
}
|
|
534
|
-
// Field-level size limits
|
|
535
|
-
if (String(rule['ruleContent']).length > 65_536) {
|
|
536
|
-
sendJson(res, 400, { ok: false, error: 'ruleContent exceeds maximum size of 64KB' });
|
|
537
|
-
return;
|
|
538
|
-
}
|
|
539
|
-
if (String(rule['ruleId']).length > 256) {
|
|
540
|
-
sendJson(res, 400, { ok: false, error: 'ruleId exceeds maximum length of 256' });
|
|
541
|
-
return;
|
|
542
|
-
}
|
|
543
|
-
rule['publishedAt'] = rule['publishedAt'] || new Date().toISOString();
|
|
544
|
-
threatDb.upsertRule(rule);
|
|
545
|
-
sendJson(res, 201, { ok: true, data: { message: 'Rule published', ruleId: rule['ruleId'] } });
|
|
546
|
-
return;
|
|
547
|
-
}
|
|
548
|
-
// GET /api/stats - Threat statistics
|
|
549
|
-
if (pathname === '/api/stats' && req.method === 'GET') {
|
|
550
|
-
if (!threatDb) {
|
|
551
|
-
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
552
|
-
return;
|
|
553
|
-
}
|
|
554
|
-
const stats = threatDb.getStats();
|
|
555
|
-
sendJson(res, 200, { ok: true, data: stats });
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
558
|
-
// POST /api/atr-proposals - Submit ATR rule proposal
|
|
559
|
-
if (pathname === '/api/atr-proposals' && req.method === 'POST') {
|
|
560
|
-
if (!threatDb) {
|
|
561
|
-
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
562
|
-
return;
|
|
563
|
-
}
|
|
564
|
-
if (!requireTCWriteAuth(req, res))
|
|
565
|
-
return;
|
|
566
|
-
if (!requireJsonContentType(req, res))
|
|
567
|
-
return;
|
|
568
|
-
const body = await readRequestBody(req);
|
|
569
|
-
let proposal;
|
|
570
|
-
try {
|
|
571
|
-
proposal = JSON.parse(body);
|
|
572
|
-
}
|
|
573
|
-
catch {
|
|
574
|
-
sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
575
|
-
return;
|
|
576
|
-
}
|
|
577
|
-
if (!proposal['patternHash'] ||
|
|
578
|
-
!proposal['ruleContent'] ||
|
|
579
|
-
!proposal['llmProvider'] ||
|
|
580
|
-
!proposal['llmModel'] ||
|
|
581
|
-
!proposal['selfReviewVerdict']) {
|
|
582
|
-
sendJson(res, 400, { ok: false, error: 'Missing required fields' });
|
|
583
|
-
return;
|
|
584
|
-
}
|
|
585
|
-
// Validate and sanitize client ID
|
|
586
|
-
const rawClientId = req.headers['x-panguard-client-id'];
|
|
587
|
-
const clientId = typeof rawClientId === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(rawClientId)
|
|
588
|
-
? rawClientId
|
|
589
|
-
: null;
|
|
590
|
-
proposal['clientId'] = clientId;
|
|
591
|
-
// Check if this pattern already has a proposal - if so, increment confirmation
|
|
592
|
-
const pHash = String(proposal['patternHash']);
|
|
593
|
-
const existing = threatDb
|
|
594
|
-
.getATRProposals()
|
|
595
|
-
.find((p) => p['pattern_hash'] === pHash);
|
|
596
|
-
if (existing) {
|
|
597
|
-
threatDb.confirmATRProposal(pHash);
|
|
598
|
-
sendJson(res, 200, {
|
|
599
|
-
ok: true,
|
|
600
|
-
data: { message: 'Confirmation recorded', patternHash: pHash },
|
|
601
|
-
});
|
|
602
|
-
}
|
|
603
|
-
else {
|
|
604
|
-
threatDb.insertATRProposal(proposal);
|
|
605
|
-
// Fire-and-forget LLM review on first submission
|
|
606
|
-
if (llmReviewer?.isAvailable()) {
|
|
607
|
-
void llmReviewer
|
|
608
|
-
.reviewProposal(pHash, String(proposal['ruleContent']))
|
|
609
|
-
.catch((err) => {
|
|
610
|
-
console.error(`LLM review error for ${pHash}:`, err);
|
|
611
|
-
});
|
|
612
|
-
}
|
|
613
|
-
sendJson(res, 201, {
|
|
614
|
-
ok: true,
|
|
615
|
-
data: { message: 'Proposal submitted', patternHash: pHash },
|
|
616
|
-
});
|
|
617
|
-
}
|
|
618
|
-
return;
|
|
619
|
-
}
|
|
620
|
-
// GET /api/atr-proposals - List proposals (admin-only)
|
|
621
|
-
if (pathname === '/api/atr-proposals' && req.method === 'GET') {
|
|
622
|
-
if (!threatDb) {
|
|
623
|
-
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
624
|
-
return;
|
|
625
|
-
}
|
|
626
|
-
if (!requireTCAdminAuth(req, res, _db))
|
|
627
|
-
return;
|
|
628
|
-
const urlObj = new URL(url, `http://${req.headers.host ?? 'localhost'}`);
|
|
629
|
-
const status = urlObj.searchParams.get('status') ?? undefined;
|
|
630
|
-
const proposals = threatDb.getATRProposals(status);
|
|
631
|
-
sendJson(res, 200, { ok: true, data: proposals });
|
|
632
|
-
return;
|
|
633
|
-
}
|
|
634
|
-
// POST /api/atr-feedback - Report ATR rule match feedback
|
|
635
|
-
if (pathname === '/api/atr-feedback' && req.method === 'POST') {
|
|
636
|
-
if (!threatDb) {
|
|
637
|
-
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
638
|
-
return;
|
|
639
|
-
}
|
|
640
|
-
if (!requireTCWriteAuth(req, res))
|
|
641
|
-
return;
|
|
642
|
-
if (!requireJsonContentType(req, res))
|
|
643
|
-
return;
|
|
644
|
-
const body = await readRequestBody(req);
|
|
645
|
-
let feedback;
|
|
646
|
-
try {
|
|
647
|
-
feedback = JSON.parse(body);
|
|
648
|
-
}
|
|
649
|
-
catch {
|
|
650
|
-
sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
651
|
-
return;
|
|
652
|
-
}
|
|
653
|
-
if (!feedback['ruleId'] || typeof feedback['isTruePositive'] !== 'boolean') {
|
|
654
|
-
sendJson(res, 400, {
|
|
655
|
-
ok: false,
|
|
656
|
-
error: 'Missing or invalid fields: ruleId (string), isTruePositive (boolean)',
|
|
657
|
-
});
|
|
658
|
-
return;
|
|
659
|
-
}
|
|
660
|
-
const rawCid = req.headers['x-panguard-client-id'];
|
|
661
|
-
const cid = typeof rawCid === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(rawCid) ? rawCid : null;
|
|
662
|
-
threatDb.insertATRFeedback(String(feedback['ruleId']), feedback['isTruePositive'], cid);
|
|
663
|
-
sendJson(res, 201, { ok: true, data: { message: 'Feedback recorded' } });
|
|
664
|
-
return;
|
|
665
|
-
}
|
|
666
|
-
// POST /api/skill-threats - Submit skill audit result
|
|
667
|
-
if (pathname === '/api/skill-threats' && req.method === 'POST') {
|
|
668
|
-
if (!threatDb) {
|
|
669
|
-
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
670
|
-
return;
|
|
671
|
-
}
|
|
672
|
-
if (!requireTCWriteAuth(req, res))
|
|
673
|
-
return;
|
|
674
|
-
if (!requireJsonContentType(req, res))
|
|
675
|
-
return;
|
|
676
|
-
const body = await readRequestBody(req);
|
|
677
|
-
let submission;
|
|
678
|
-
try {
|
|
679
|
-
submission = JSON.parse(body);
|
|
680
|
-
}
|
|
681
|
-
catch {
|
|
682
|
-
sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
683
|
-
return;
|
|
684
|
-
}
|
|
685
|
-
const VALID_RISK_LEVELS = new Set(['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
|
|
686
|
-
if (!submission['skillHash'] || !submission['skillName']) {
|
|
687
|
-
sendJson(res, 400, { ok: false, error: 'Missing required fields: skillHash, skillName' });
|
|
688
|
-
return;
|
|
689
|
-
}
|
|
690
|
-
const riskScore = submission['riskScore'];
|
|
691
|
-
if (typeof riskScore !== 'number' ||
|
|
692
|
-
!isFinite(riskScore) ||
|
|
693
|
-
riskScore < 0 ||
|
|
694
|
-
riskScore > 100) {
|
|
695
|
-
sendJson(res, 400, { ok: false, error: 'riskScore must be a number between 0 and 100' });
|
|
696
|
-
return;
|
|
697
|
-
}
|
|
698
|
-
if (!VALID_RISK_LEVELS.has(String(submission['riskLevel']))) {
|
|
699
|
-
sendJson(res, 400, {
|
|
700
|
-
ok: false,
|
|
701
|
-
error: 'riskLevel must be one of: LOW, MEDIUM, HIGH, CRITICAL',
|
|
702
|
-
});
|
|
703
|
-
return;
|
|
704
|
-
}
|
|
705
|
-
const rawCid2 = req.headers['x-panguard-client-id'];
|
|
706
|
-
submission['clientId'] =
|
|
707
|
-
typeof rawCid2 === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(rawCid2) ? rawCid2 : null;
|
|
708
|
-
threatDb.insertSkillThreat(submission);
|
|
709
|
-
sendJson(res, 201, { ok: true, data: { message: 'Skill threat recorded' } });
|
|
710
|
-
return;
|
|
711
|
-
}
|
|
712
|
-
// GET /api/skill-threats - List skill threats (admin-only)
|
|
713
|
-
if (pathname === '/api/skill-threats' && req.method === 'GET') {
|
|
714
|
-
if (!threatDb) {
|
|
715
|
-
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
716
|
-
return;
|
|
717
|
-
}
|
|
718
|
-
if (!requireTCAdminAuth(req, res, _db))
|
|
719
|
-
return;
|
|
720
|
-
const urlObj = new URL(url, `http://${req.headers.host ?? 'localhost'}`);
|
|
721
|
-
const rawLimit = parseInt(urlObj.searchParams.get('limit') ?? '50', 10);
|
|
722
|
-
const limit = isNaN(rawLimit) || rawLimit < 1 ? 50 : Math.min(rawLimit, 500);
|
|
723
|
-
const threats = threatDb.getSkillThreats(limit);
|
|
724
|
-
sendJson(res, 200, { ok: true, data: threats });
|
|
725
|
-
return;
|
|
726
|
-
}
|
|
727
|
-
// GET /api/atr-rules - Fetch confirmed ATR rules (for Guard sync)
|
|
728
|
-
if (pathname === '/api/atr-rules' && req.method === 'GET') {
|
|
729
|
-
if (!threatDb) {
|
|
730
|
-
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
731
|
-
return;
|
|
732
|
-
}
|
|
733
|
-
const urlObj = new URL(url, `http://${req.headers.host ?? 'localhost'}`);
|
|
734
|
-
const since = urlObj.searchParams.get('since') ?? undefined;
|
|
735
|
-
const rules = threatDb.getConfirmedATRRules(since);
|
|
736
|
-
sendJson(res, 200, { ok: true, data: rules });
|
|
737
|
-
return;
|
|
738
|
-
}
|
|
739
|
-
// GET /api/yara-rules - Fetch YARA rules (for Guard sync)
|
|
740
|
-
if (pathname === '/api/yara-rules' && req.method === 'GET') {
|
|
741
|
-
if (!threatDb) {
|
|
742
|
-
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
743
|
-
return;
|
|
744
|
-
}
|
|
745
|
-
const urlObj = new URL(url, `http://${req.headers.host ?? 'localhost'}`);
|
|
746
|
-
const since = urlObj.searchParams.get('since') ?? undefined;
|
|
747
|
-
const rules = threatDb.getRulesBySource('yara', since);
|
|
748
|
-
sendJson(res, 200, { ok: true, data: rules });
|
|
749
|
-
return;
|
|
750
|
-
}
|
|
751
|
-
// GET /api/feeds/ip-blocklist - IP blocklist feed (plain text)
|
|
752
|
-
if (pathname === '/api/feeds/ip-blocklist' && req.method === 'GET') {
|
|
753
|
-
if (!threatDb) {
|
|
754
|
-
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
755
|
-
return;
|
|
756
|
-
}
|
|
757
|
-
const urlObj = new URL(url, `http://${req.headers.host ?? 'localhost'}`);
|
|
758
|
-
const minReputation = Number(urlObj.searchParams.get('minReputation') ?? '70');
|
|
759
|
-
const ips = threatDb.getIPBlocklist(minReputation);
|
|
760
|
-
res.setHeader('Content-Type', 'text/plain');
|
|
761
|
-
res.writeHead(200);
|
|
762
|
-
res.end(ips.join('\n'));
|
|
763
|
-
return;
|
|
764
|
-
}
|
|
765
|
-
// GET /api/feeds/domain-blocklist - Domain blocklist feed (plain text)
|
|
766
|
-
if (pathname === '/api/feeds/domain-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 domains = threatDb.getDomainBlocklist(minReputation);
|
|
774
|
-
res.setHeader('Content-Type', 'text/plain');
|
|
775
|
-
res.writeHead(200);
|
|
776
|
-
res.end(domains.join('\n'));
|
|
777
|
-
return;
|
|
778
|
-
}
|
|
779
|
-
// POST /api/skill-whitelist - Report safe skill (audit passed)
|
|
780
|
-
if (pathname === '/api/skill-whitelist' && req.method === 'POST') {
|
|
781
|
-
if (!threatDb) {
|
|
782
|
-
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
783
|
-
return;
|
|
784
|
-
}
|
|
785
|
-
if (!requireTCWriteAuth(req, res))
|
|
786
|
-
return;
|
|
787
|
-
if (!requireJsonContentType(req, res))
|
|
788
|
-
return;
|
|
789
|
-
const body = await readRequestBody(req);
|
|
790
|
-
let data;
|
|
791
|
-
try {
|
|
792
|
-
data = JSON.parse(body);
|
|
793
|
-
}
|
|
794
|
-
catch {
|
|
795
|
-
sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
796
|
-
return;
|
|
797
|
-
}
|
|
798
|
-
const skills = 'skills' in data && Array.isArray(data['skills'])
|
|
799
|
-
? data['skills']
|
|
800
|
-
: [data];
|
|
801
|
-
let count = 0;
|
|
802
|
-
for (const skill of skills) {
|
|
803
|
-
const name = skill['skillName'];
|
|
804
|
-
if (!name || typeof name !== 'string')
|
|
805
|
-
continue;
|
|
806
|
-
threatDb.reportSafeSkill(name, typeof skill['fingerprintHash'] === 'string' ? skill['fingerprintHash'] : undefined);
|
|
807
|
-
count++;
|
|
808
|
-
}
|
|
809
|
-
sendJson(res, 201, { ok: true, data: { message: `${count} skill(s) reported`, count } });
|
|
810
|
-
return;
|
|
811
|
-
}
|
|
812
|
-
// GET /api/skill-whitelist - Fetch community whitelist
|
|
813
|
-
if (pathname === '/api/skill-whitelist' && req.method === 'GET') {
|
|
814
|
-
if (!threatDb) {
|
|
815
|
-
sendJson(res, 503, { ok: false, error: 'Threat Cloud not available' });
|
|
816
|
-
return;
|
|
817
|
-
}
|
|
818
|
-
const whitelist = threatDb.getSkillWhitelist();
|
|
819
|
-
sendJson(res, 200, { ok: true, data: whitelist });
|
|
820
|
-
return;
|
|
821
|
-
}
|
|
822
|
-
// Auth API routes
|
|
823
|
-
if (pathname === '/api/auth/register') {
|
|
824
|
-
await handlers.handleRegister(req, res);
|
|
825
|
-
return;
|
|
826
|
-
}
|
|
827
|
-
if (pathname === '/api/auth/login') {
|
|
828
|
-
await handlers.handleLogin(req, res);
|
|
829
|
-
return;
|
|
830
|
-
}
|
|
831
|
-
if (pathname === '/api/auth/logout') {
|
|
832
|
-
handlers.handleLogout(req, res);
|
|
833
|
-
return;
|
|
834
|
-
}
|
|
835
|
-
if (pathname === '/api/auth/me') {
|
|
836
|
-
handlers.handleMe(req, res);
|
|
837
|
-
return;
|
|
838
|
-
}
|
|
839
|
-
if (pathname === '/api/auth/delete-account') {
|
|
840
|
-
await handlers.handleDeleteAccount(req, res);
|
|
841
|
-
return;
|
|
842
|
-
}
|
|
843
|
-
if (pathname === '/api/auth/export-data') {
|
|
844
|
-
handlers.handleExportData(req, res);
|
|
845
|
-
return;
|
|
846
|
-
}
|
|
847
|
-
if (pathname === '/api/auth/totp/setup') {
|
|
848
|
-
handlers.handleTotpSetup(req, res);
|
|
849
|
-
return;
|
|
850
|
-
}
|
|
851
|
-
if (pathname === '/api/auth/totp/verify') {
|
|
852
|
-
await handlers.handleTotpVerify(req, res);
|
|
853
|
-
return;
|
|
854
|
-
}
|
|
855
|
-
if (pathname === '/api/auth/totp/disable') {
|
|
856
|
-
await handlers.handleTotpDisable(req, res);
|
|
857
|
-
return;
|
|
858
|
-
}
|
|
859
|
-
if (pathname === '/api/auth/totp/status') {
|
|
860
|
-
handlers.handleTotpStatus(req, res);
|
|
861
|
-
return;
|
|
862
|
-
}
|
|
863
|
-
if (pathname === '/api/auth/forgot-password') {
|
|
864
|
-
await handlers.handleForgotPassword(req, res);
|
|
865
|
-
return;
|
|
866
|
-
}
|
|
867
|
-
if (pathname === '/api/auth/reset-password') {
|
|
868
|
-
await handlers.handleResetPassword(req, res);
|
|
869
|
-
return;
|
|
870
|
-
}
|
|
871
|
-
if (pathname === '/api/auth/google') {
|
|
872
|
-
handlers.handleGoogleAuth(req, res);
|
|
873
|
-
return;
|
|
874
|
-
}
|
|
875
|
-
if (pathname.startsWith('/api/auth/google/callback')) {
|
|
876
|
-
const urlObj = new URL(url, `http://${req.headers.host ?? 'localhost'}`);
|
|
877
|
-
const code = urlObj.searchParams.get('code') ?? '';
|
|
878
|
-
const state = urlObj.searchParams.get('state');
|
|
879
|
-
await handlers.handleGoogleCallback(req, res, code, state);
|
|
880
|
-
return;
|
|
881
|
-
}
|
|
882
|
-
if (pathname === '/api/auth/oauth/exchange') {
|
|
883
|
-
await handlers.handleOAuthExchange(req, res);
|
|
884
|
-
return;
|
|
885
|
-
}
|
|
886
|
-
if (pathname === '/api/auth/cli') {
|
|
887
|
-
handlers.handleCliAuth(req, res);
|
|
888
|
-
return;
|
|
889
|
-
}
|
|
890
|
-
if (pathname === '/api/auth/cli/exchange') {
|
|
891
|
-
await handlers.handleCliExchange(req, res);
|
|
892
|
-
return;
|
|
893
|
-
}
|
|
894
|
-
// Waitlist API routes
|
|
895
|
-
if (pathname === '/api/waitlist/join') {
|
|
896
|
-
await handlers.handleWaitlistJoin(req, res);
|
|
897
|
-
return;
|
|
898
|
-
}
|
|
899
|
-
if (pathname.startsWith('/api/waitlist/verify/')) {
|
|
900
|
-
const token = pathname.split('/api/waitlist/verify/')[1];
|
|
901
|
-
handlers.handleWaitlistVerify(req, res, token ?? '');
|
|
902
|
-
return;
|
|
903
|
-
}
|
|
904
|
-
if (pathname === '/api/waitlist/stats') {
|
|
905
|
-
handlers.handleWaitlistStats(req, res);
|
|
906
|
-
return;
|
|
907
|
-
}
|
|
908
|
-
if (pathname === '/api/waitlist/list') {
|
|
909
|
-
handlers.handleWaitlistList(req, res);
|
|
910
|
-
return;
|
|
911
|
-
}
|
|
912
|
-
// Usage / Quota API routes
|
|
913
|
-
if (pathname === '/api/usage') {
|
|
914
|
-
handlers.handleUsageSummary(req, res);
|
|
915
|
-
return;
|
|
916
|
-
}
|
|
917
|
-
if (pathname === '/api/usage/limits') {
|
|
918
|
-
handlers.handleUsageLimits(req, res);
|
|
919
|
-
return;
|
|
920
|
-
}
|
|
921
|
-
if (pathname === '/api/usage/check') {
|
|
922
|
-
await handlers.handleUsageCheck(req, res);
|
|
923
|
-
return;
|
|
924
|
-
}
|
|
925
|
-
if (pathname === '/api/usage/record') {
|
|
926
|
-
await handlers.handleUsageRecord(req, res);
|
|
927
|
-
return;
|
|
928
|
-
}
|
|
929
|
-
// Admin API routes
|
|
930
|
-
if (pathname === '/api/admin/dashboard') {
|
|
931
|
-
handlers.handleAdminDashboard(req, res);
|
|
932
|
-
return;
|
|
933
|
-
}
|
|
934
|
-
if (pathname === '/api/admin/users/search') {
|
|
935
|
-
handlers.handleAdminUsersSearch(req, res);
|
|
936
|
-
return;
|
|
937
|
-
}
|
|
938
|
-
if (pathname === '/api/admin/users') {
|
|
939
|
-
handlers.handleAdminUsers(req, res);
|
|
349
|
+
// Core routes: health, OpenAPI, static files
|
|
350
|
+
if (handleCoreRoutes(req, res, pathname, ctx.db, ctx.threatDb, ctx.adminDir))
|
|
940
351
|
return;
|
|
941
|
-
|
|
942
|
-
if (pathname
|
|
943
|
-
handlers.handleAdminStats(req, res);
|
|
944
|
-
return;
|
|
945
|
-
}
|
|
946
|
-
if (pathname === '/api/admin/sessions') {
|
|
947
|
-
if (req.method === 'GET') {
|
|
948
|
-
handlers.handleAdminSessions(req, res);
|
|
949
|
-
}
|
|
950
|
-
else {
|
|
951
|
-
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
952
|
-
}
|
|
953
|
-
return;
|
|
954
|
-
}
|
|
955
|
-
if (pathname === '/api/admin/activity') {
|
|
956
|
-
handlers.handleAdminActivity(req, res);
|
|
957
|
-
return;
|
|
958
|
-
}
|
|
959
|
-
if (pathname === '/api/admin/audit/actions') {
|
|
960
|
-
handlers.handleAdminAuditActions(req, res);
|
|
961
|
-
return;
|
|
962
|
-
}
|
|
963
|
-
if (pathname === '/api/admin/audit') {
|
|
964
|
-
handlers.handleAdminAuditLog(req, res);
|
|
965
|
-
return;
|
|
966
|
-
}
|
|
967
|
-
if (pathname === '/api/admin/usage') {
|
|
968
|
-
handlers.handleAdminUsageOverview(req, res);
|
|
969
|
-
return;
|
|
970
|
-
}
|
|
971
|
-
if (pathname === '/api/admin/users/bulk-action') {
|
|
972
|
-
await handlers.handleAdminBulkAction(req, res);
|
|
973
|
-
return;
|
|
974
|
-
}
|
|
975
|
-
// /api/admin/sessions/:id (DELETE)
|
|
976
|
-
const sessionRevokeMatch = pathname.match(/^\/api\/admin\/sessions\/(\d+)$/);
|
|
977
|
-
if (sessionRevokeMatch) {
|
|
978
|
-
handlers.handleAdminSessionRevoke(req, res, sessionRevokeMatch[1]);
|
|
979
|
-
return;
|
|
980
|
-
}
|
|
981
|
-
// /api/admin/users/:id/tier
|
|
982
|
-
const tierMatch = pathname.match(/^\/api\/admin\/users\/(\d+)\/tier$/);
|
|
983
|
-
if (tierMatch) {
|
|
984
|
-
await handlers.handleAdminUpdateTier(req, res, tierMatch[1]);
|
|
985
|
-
return;
|
|
986
|
-
}
|
|
987
|
-
// /api/admin/users/:id/role
|
|
988
|
-
const roleMatch = pathname.match(/^\/api\/admin\/users\/(\d+)\/role$/);
|
|
989
|
-
if (roleMatch) {
|
|
990
|
-
await handlers.handleAdminUpdateRole(req, res, roleMatch[1]);
|
|
991
|
-
return;
|
|
992
|
-
}
|
|
993
|
-
// /api/admin/waitlist/:id/approve
|
|
994
|
-
const approveMatch = pathname.match(/^\/api\/admin\/waitlist\/(\d+)\/approve$/);
|
|
995
|
-
if (approveMatch) {
|
|
996
|
-
await handlers.handleAdminWaitlistApprove(req, res, approveMatch[1]);
|
|
997
|
-
return;
|
|
998
|
-
}
|
|
999
|
-
// /api/admin/waitlist/:id/reject
|
|
1000
|
-
const rejectMatch = pathname.match(/^\/api\/admin\/waitlist\/(\d+)\/reject$/);
|
|
1001
|
-
if (rejectMatch) {
|
|
1002
|
-
await handlers.handleAdminWaitlistReject(req, res, rejectMatch[1]);
|
|
1003
|
-
return;
|
|
1004
|
-
}
|
|
1005
|
-
// /api/admin/users/:id/suspend
|
|
1006
|
-
const suspendMatch = pathname.match(/^\/api\/admin\/users\/(\d+)\/suspend$/);
|
|
1007
|
-
if (suspendMatch) {
|
|
1008
|
-
await handlers.handleAdminUserSuspend(req, res, suspendMatch[1]);
|
|
1009
|
-
return;
|
|
1010
|
-
}
|
|
1011
|
-
// /api/admin/usage/:userId
|
|
1012
|
-
const usageUserMatch = pathname.match(/^\/api\/admin\/usage\/(\d+)$/);
|
|
1013
|
-
if (usageUserMatch) {
|
|
1014
|
-
handlers.handleAdminUsageUser(req, res, usageUserMatch[1]);
|
|
1015
|
-
return;
|
|
1016
|
-
}
|
|
1017
|
-
// /api/admin/users/:id (GET — user detail)
|
|
1018
|
-
const userDetailMatch = pathname.match(/^\/api\/admin\/users\/(\d+)$/);
|
|
1019
|
-
if (userDetailMatch && req.method === 'GET') {
|
|
1020
|
-
handlers.handleAdminUserDetail(req, res, userDetailMatch[1]);
|
|
1021
|
-
return;
|
|
1022
|
-
}
|
|
1023
|
-
// /api/admin/settings (GET — environment config status)
|
|
1024
|
-
if (pathname === '/api/admin/settings' && req.method === 'GET') {
|
|
1025
|
-
sendJson(res, 200, {
|
|
1026
|
-
ok: true,
|
|
1027
|
-
data: {
|
|
1028
|
-
oauth: {
|
|
1029
|
-
google: !!process.env['GOOGLE_CLIENT_ID'],
|
|
1030
|
-
},
|
|
1031
|
-
email: {
|
|
1032
|
-
resend: !!process.env['RESEND_API_KEY'],
|
|
1033
|
-
smtp: !!process.env['SMTP_HOST'],
|
|
1034
|
-
},
|
|
1035
|
-
security: {
|
|
1036
|
-
totpEnabled: true,
|
|
1037
|
-
},
|
|
1038
|
-
threatCloud: {
|
|
1039
|
-
endpoint: process.env['THREAT_CLOUD_ENDPOINT'] || null,
|
|
1040
|
-
apiKey: !!process.env['TC_API_KEY'],
|
|
1041
|
-
},
|
|
1042
|
-
notifications: {
|
|
1043
|
-
telegram: !!process.env['TELEGRAM_BOT_TOKEN'],
|
|
1044
|
-
slack: !!process.env['SLACK_WEBHOOK_URL'],
|
|
1045
|
-
email: !!process.env['RESEND_API_KEY'] || !!process.env['SMTP_HOST'],
|
|
1046
|
-
},
|
|
1047
|
-
manager: {
|
|
1048
|
-
key: !!process.env['PANGUARD_MANAGER_KEY'],
|
|
1049
|
-
corsOrigins: process.env['MANAGER_CORS_ORIGINS'] || '',
|
|
1050
|
-
},
|
|
1051
|
-
},
|
|
1052
|
-
});
|
|
1053
|
-
return;
|
|
1054
|
-
}
|
|
1055
|
-
// ── Manager Proxy Routes ────────────────────────────────────────
|
|
1056
|
-
// The admin frontend calls PG.managerFetch('/api/agents') which maps
|
|
1057
|
-
// to /api/manager/api/agents when MANAGER_URL is not set. These routes
|
|
1058
|
-
// proxy the request to the Manager server and return the response.
|
|
1059
|
-
// GET /api/manager/api/overview
|
|
1060
|
-
if (pathname === '/api/manager/api/overview' && req.method === 'GET') {
|
|
1061
|
-
const result = await managerProxy.getOverview();
|
|
1062
|
-
if (result.ok) {
|
|
1063
|
-
sendJson(res, 200, result.data);
|
|
1064
|
-
}
|
|
1065
|
-
else {
|
|
1066
|
-
sendJson(res, 503, { ok: false, error: result.error });
|
|
1067
|
-
}
|
|
1068
|
-
return;
|
|
1069
|
-
}
|
|
1070
|
-
// GET /api/manager/api/agents
|
|
1071
|
-
if (pathname === '/api/manager/api/agents' && req.method === 'GET') {
|
|
1072
|
-
const result = await managerProxy.getAgents();
|
|
1073
|
-
if (result.ok) {
|
|
1074
|
-
sendJson(res, 200, result.data);
|
|
1075
|
-
}
|
|
1076
|
-
else {
|
|
1077
|
-
sendJson(res, 503, { ok: false, error: result.error });
|
|
1078
|
-
}
|
|
1079
|
-
return;
|
|
1080
|
-
}
|
|
1081
|
-
// GET /api/manager/api/agents/:id
|
|
1082
|
-
const managerAgentMatch = pathname.match(/^\/api\/manager\/api\/agents\/([^/]+)$/);
|
|
1083
|
-
if (managerAgentMatch && req.method === 'GET') {
|
|
1084
|
-
const result = await managerProxy.getAgent(managerAgentMatch[1]);
|
|
1085
|
-
if (result.ok) {
|
|
1086
|
-
sendJson(res, 200, result.data);
|
|
1087
|
-
}
|
|
1088
|
-
else {
|
|
1089
|
-
const status = result.error === 'Manager service unavailable' ? 503 : 404;
|
|
1090
|
-
sendJson(res, status, { ok: false, error: result.error });
|
|
1091
|
-
}
|
|
1092
|
-
return;
|
|
1093
|
-
}
|
|
1094
|
-
// GET /api/manager/api/events
|
|
1095
|
-
if (pathname === '/api/manager/api/events' && req.method === 'GET') {
|
|
1096
|
-
const urlObj = new URL(url, `http://${req.headers.host ?? 'localhost'}`);
|
|
1097
|
-
const limit = parseInt(urlObj.searchParams.get('limit') ?? '50', 10) || 50;
|
|
1098
|
-
const offset = parseInt(urlObj.searchParams.get('offset') ?? '0', 10) || 0;
|
|
1099
|
-
const since = urlObj.searchParams.get('since') ?? undefined;
|
|
1100
|
-
const result = await managerProxy.getEvents({ limit, offset, since });
|
|
1101
|
-
if (result.ok) {
|
|
1102
|
-
sendJson(res, 200, result.data);
|
|
1103
|
-
}
|
|
1104
|
-
else {
|
|
1105
|
-
sendJson(res, 503, { ok: false, error: result.error });
|
|
1106
|
-
}
|
|
1107
|
-
return;
|
|
1108
|
-
}
|
|
1109
|
-
// GET /api/manager/api/threats/summary
|
|
1110
|
-
if (pathname === '/api/manager/api/threats/summary' && req.method === 'GET') {
|
|
1111
|
-
const result = await managerProxy.getThreatSummary();
|
|
1112
|
-
if (result.ok) {
|
|
1113
|
-
sendJson(res, 200, result.data);
|
|
1114
|
-
}
|
|
1115
|
-
else {
|
|
1116
|
-
sendJson(res, 503, { ok: false, error: result.error });
|
|
1117
|
-
}
|
|
1118
|
-
return;
|
|
1119
|
-
}
|
|
1120
|
-
// GET /api/manager/api/threats
|
|
1121
|
-
if (pathname === '/api/manager/api/threats' && req.method === 'GET') {
|
|
1122
|
-
const urlObj = new URL(url, `http://${req.headers.host ?? 'localhost'}`);
|
|
1123
|
-
const since = urlObj.searchParams.get('since') ?? undefined;
|
|
1124
|
-
const result = await managerProxy.getEvents({ since });
|
|
1125
|
-
if (result.ok) {
|
|
1126
|
-
sendJson(res, 200, result.data);
|
|
1127
|
-
}
|
|
1128
|
-
else {
|
|
1129
|
-
sendJson(res, 503, { ok: false, error: result.error });
|
|
1130
|
-
}
|
|
1131
|
-
return;
|
|
1132
|
-
}
|
|
1133
|
-
// ── Additional Admin Proxy Routes (without /api/manager prefix) ──
|
|
1134
|
-
// These support the routes listed in the task specification.
|
|
1135
|
-
// GET /api/admin/agents
|
|
1136
|
-
if (pathname === '/api/admin/agents' && req.method === 'GET') {
|
|
1137
|
-
const result = await managerProxy.getAgents();
|
|
1138
|
-
if (result.ok) {
|
|
1139
|
-
sendJson(res, 200, { ok: true, data: result.data });
|
|
1140
|
-
}
|
|
1141
|
-
else {
|
|
1142
|
-
sendJson(res, 503, { ok: false, error: result.error });
|
|
1143
|
-
}
|
|
1144
|
-
return;
|
|
1145
|
-
}
|
|
1146
|
-
// GET /api/admin/agents/:id
|
|
1147
|
-
const adminAgentMatch = pathname.match(/^\/api\/admin\/agents\/([^/]+)$/);
|
|
1148
|
-
if (adminAgentMatch && req.method === 'GET') {
|
|
1149
|
-
const result = await managerProxy.getAgent(adminAgentMatch[1]);
|
|
1150
|
-
if (result.ok) {
|
|
1151
|
-
sendJson(res, 200, { ok: true, data: result.data });
|
|
1152
|
-
}
|
|
1153
|
-
else {
|
|
1154
|
-
const status = result.error === 'Manager service unavailable' ? 503 : 404;
|
|
1155
|
-
sendJson(res, status, { ok: false, error: result.error });
|
|
1156
|
-
}
|
|
1157
|
-
return;
|
|
1158
|
-
}
|
|
1159
|
-
// GET /api/admin/events
|
|
1160
|
-
if (pathname === '/api/admin/events' && req.method === 'GET') {
|
|
1161
|
-
const urlObj = new URL(url, `http://${req.headers.host ?? 'localhost'}`);
|
|
1162
|
-
const limit = parseInt(urlObj.searchParams.get('limit') ?? '50', 10) || 50;
|
|
1163
|
-
const offset = parseInt(urlObj.searchParams.get('offset') ?? '0', 10) || 0;
|
|
1164
|
-
const since = urlObj.searchParams.get('since') ?? undefined;
|
|
1165
|
-
const result = await managerProxy.getEvents({ limit, offset, since });
|
|
1166
|
-
if (result.ok) {
|
|
1167
|
-
sendJson(res, 200, { ok: true, data: result.data });
|
|
1168
|
-
}
|
|
1169
|
-
else {
|
|
1170
|
-
sendJson(res, 503, { ok: false, error: result.error });
|
|
1171
|
-
}
|
|
352
|
+
// Threat Cloud API routes (rate-limited, auth-gated)
|
|
353
|
+
if (await handleTCRoutes(req, res, url, pathname, ctx))
|
|
1172
354
|
return;
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
if (pathname === '/api/admin/threats' && req.method === 'GET') {
|
|
1176
|
-
const result = await managerProxy.getThreatSummary();
|
|
1177
|
-
if (result.ok) {
|
|
1178
|
-
sendJson(res, 200, { ok: true, data: result.data });
|
|
1179
|
-
}
|
|
1180
|
-
else {
|
|
1181
|
-
sendJson(res, 503, { ok: false, error: result.error });
|
|
1182
|
-
}
|
|
355
|
+
// Auth, waitlist, and usage routes
|
|
356
|
+
if (await handleAuthRoutes(req, res, url, pathname, ctx))
|
|
1183
357
|
return;
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
if (pathname === '/api/admin/overview' && req.method === 'GET') {
|
|
1187
|
-
const result = await managerProxy.getOverview();
|
|
1188
|
-
if (result.ok) {
|
|
1189
|
-
sendJson(res, 200, { ok: true, data: result.data });
|
|
1190
|
-
}
|
|
1191
|
-
else {
|
|
1192
|
-
sendJson(res, 503, { ok: false, error: result.error });
|
|
1193
|
-
}
|
|
1194
|
-
return;
|
|
1195
|
-
}
|
|
1196
|
-
// Admin static files
|
|
1197
|
-
if (adminDir && pathname.startsWith('/admin')) {
|
|
1198
|
-
serveStaticFile(req, res, adminDir, pathname);
|
|
358
|
+
// Admin and Manager proxy routes
|
|
359
|
+
if (await handleAdminRoutes(req, res, url, pathname, ctx))
|
|
1199
360
|
return;
|
|
1200
|
-
}
|
|
1201
361
|
sendJson(res, 404, { ok: false, error: 'Not found' });
|
|
1202
362
|
}
|
|
1203
363
|
catch (err) {
|
|
@@ -1205,267 +365,4 @@ async function handleRequest(req, res, handlers, _db, adminDir, managerProxy, th
|
|
|
1205
365
|
sendJson(res, 500, { ok: false, error: 'Internal server error' });
|
|
1206
366
|
}
|
|
1207
367
|
}
|
|
1208
|
-
function serveStaticFile(_req, res, adminDir, pathname) {
|
|
1209
|
-
// Map /admin -> /admin/index.html
|
|
1210
|
-
const resolvedAdminDir = resolve(adminDir);
|
|
1211
|
-
let filePath;
|
|
1212
|
-
if (pathname === '/admin' || pathname === '/admin/') {
|
|
1213
|
-
filePath = join(resolvedAdminDir, 'index.html');
|
|
1214
|
-
}
|
|
1215
|
-
else {
|
|
1216
|
-
// Strip /admin prefix and leading slash
|
|
1217
|
-
const relative = pathname.slice('/admin'.length).replace(/^\//, '');
|
|
1218
|
-
filePath = join(resolvedAdminDir, relative);
|
|
1219
|
-
// If no extension, try .html
|
|
1220
|
-
if (!relative.includes('.')) {
|
|
1221
|
-
filePath = join(resolvedAdminDir, relative + '.html');
|
|
1222
|
-
if (!existsSync(filePath)) {
|
|
1223
|
-
filePath = join(resolvedAdminDir, relative, 'index.html');
|
|
1224
|
-
}
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
// Prevent path traversal: resolved path must be within admin directory
|
|
1228
|
-
if (!filePath.startsWith(resolvedAdminDir)) {
|
|
1229
|
-
sendJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
1230
|
-
return;
|
|
1231
|
-
}
|
|
1232
|
-
if (!existsSync(filePath)) {
|
|
1233
|
-
sendJson(res, 404, { ok: false, error: 'Not found' });
|
|
1234
|
-
return;
|
|
1235
|
-
}
|
|
1236
|
-
const ext = filePath.split('.').pop() ?? '';
|
|
1237
|
-
const mimeTypes = {
|
|
1238
|
-
html: 'text/html',
|
|
1239
|
-
css: 'text/css',
|
|
1240
|
-
js: 'application/javascript',
|
|
1241
|
-
json: 'application/json',
|
|
1242
|
-
png: 'image/png',
|
|
1243
|
-
svg: 'image/svg+xml',
|
|
1244
|
-
ico: 'image/x-icon',
|
|
1245
|
-
};
|
|
1246
|
-
const contentType = mimeTypes[ext] ?? 'application/octet-stream';
|
|
1247
|
-
const content = readFileSync(filePath);
|
|
1248
|
-
res.writeHead(200, { 'Content-Type': contentType });
|
|
1249
|
-
res.end(content);
|
|
1250
|
-
}
|
|
1251
|
-
function sendJson(res, status, data) {
|
|
1252
|
-
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
1253
|
-
res.end(JSON.stringify(data));
|
|
1254
|
-
}
|
|
1255
|
-
// ── Threat Cloud Security Helpers ──────────────────────────────
|
|
1256
|
-
/** Timing-safe string comparison to prevent side-channel attacks */
|
|
1257
|
-
function timingSafeCompare(a, b) {
|
|
1258
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1259
|
-
const { timingSafeEqual } = require('node:crypto');
|
|
1260
|
-
const ab = Buffer.from(a);
|
|
1261
|
-
const bb = Buffer.from(b);
|
|
1262
|
-
if (ab.length !== bb.length) {
|
|
1263
|
-
// Compare against self to maintain constant time
|
|
1264
|
-
timingSafeEqual(ab, ab);
|
|
1265
|
-
return false;
|
|
1266
|
-
}
|
|
1267
|
-
return timingSafeEqual(ab, bb);
|
|
1268
|
-
}
|
|
1269
|
-
/**
|
|
1270
|
-
* Require TC_API_KEY auth for write endpoints.
|
|
1271
|
-
* In production: BLOCK if TC_API_KEY not set (refuse unauthenticated writes).
|
|
1272
|
-
* In dev: allow passthrough with warning.
|
|
1273
|
-
*/
|
|
1274
|
-
function requireTCWriteAuth(req, res) {
|
|
1275
|
-
const tcApiKey = process.env['TC_API_KEY'];
|
|
1276
|
-
if (!tcApiKey) {
|
|
1277
|
-
if (process.env['NODE_ENV'] === 'production') {
|
|
1278
|
-
sendJson(res, 503, {
|
|
1279
|
-
ok: false,
|
|
1280
|
-
error: 'Threat Cloud write API not configured (TC_API_KEY missing)',
|
|
1281
|
-
});
|
|
1282
|
-
return false;
|
|
1283
|
-
}
|
|
1284
|
-
return true; // dev passthrough
|
|
1285
|
-
}
|
|
1286
|
-
const authHeader = req.headers.authorization ?? '';
|
|
1287
|
-
const token = authHeader.replace('Bearer ', '');
|
|
1288
|
-
if (!timingSafeCompare(token, tcApiKey)) {
|
|
1289
|
-
sendJson(res, 401, { ok: false, error: 'Invalid API key' });
|
|
1290
|
-
return false;
|
|
1291
|
-
}
|
|
1292
|
-
return true;
|
|
1293
|
-
}
|
|
1294
|
-
/**
|
|
1295
|
-
* Require admin session auth for admin-only GET endpoints.
|
|
1296
|
-
* Verifies the Bearer token is a valid session with admin role.
|
|
1297
|
-
*/
|
|
1298
|
-
function requireTCAdminAuth(req, res, db) {
|
|
1299
|
-
const user = authenticateRequest(req, db);
|
|
1300
|
-
if (!user) {
|
|
1301
|
-
sendJson(res, 401, { ok: false, error: 'Authentication required' });
|
|
1302
|
-
return false;
|
|
1303
|
-
}
|
|
1304
|
-
if (!requireAdmin(user)) {
|
|
1305
|
-
sendJson(res, 403, { ok: false, error: 'Admin access required' });
|
|
1306
|
-
return false;
|
|
1307
|
-
}
|
|
1308
|
-
return true;
|
|
1309
|
-
}
|
|
1310
|
-
/** Validate Content-Type is application/json for POST requests */
|
|
1311
|
-
function requireJsonContentType(req, res) {
|
|
1312
|
-
const ct = req.headers['content-type'] ?? '';
|
|
1313
|
-
if (!ct.includes('application/json')) {
|
|
1314
|
-
sendJson(res, 400, { ok: false, error: 'Content-Type must be application/json' });
|
|
1315
|
-
return false;
|
|
1316
|
-
}
|
|
1317
|
-
return true;
|
|
1318
|
-
}
|
|
1319
|
-
/** Per-IP rate limiter for Threat Cloud endpoints (120 req/min) */
|
|
1320
|
-
const tcRateLimits = new Map();
|
|
1321
|
-
function checkTCRateLimit(ip) {
|
|
1322
|
-
const now = Date.now();
|
|
1323
|
-
const entry = tcRateLimits.get(ip);
|
|
1324
|
-
if (!entry || now > entry.resetAt) {
|
|
1325
|
-
tcRateLimits.set(ip, { count: 1, resetAt: now + 60_000 });
|
|
1326
|
-
return true;
|
|
1327
|
-
}
|
|
1328
|
-
entry.count++;
|
|
1329
|
-
return entry.count <= 120;
|
|
1330
|
-
}
|
|
1331
|
-
/** Read request body with 1MB size limit */
|
|
1332
|
-
function readRequestBody(req) {
|
|
1333
|
-
return new Promise((resolve, reject) => {
|
|
1334
|
-
const chunks = [];
|
|
1335
|
-
let size = 0;
|
|
1336
|
-
const MAX_BODY = 1_048_576; // 1MB
|
|
1337
|
-
req.on('data', (chunk) => {
|
|
1338
|
-
size += chunk.length;
|
|
1339
|
-
if (size > MAX_BODY) {
|
|
1340
|
-
req.destroy();
|
|
1341
|
-
reject(new Error('Request body too large'));
|
|
1342
|
-
return;
|
|
1343
|
-
}
|
|
1344
|
-
chunks.push(chunk);
|
|
1345
|
-
});
|
|
1346
|
-
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
1347
|
-
req.on('error', reject);
|
|
1348
|
-
});
|
|
1349
|
-
}
|
|
1350
|
-
/**
|
|
1351
|
-
* Seed rules from bundled config/ directory into Threat Cloud DB.
|
|
1352
|
-
* Reads Sigma YAML, YARA, and ATR YAML files.
|
|
1353
|
-
* Returns count of rules seeded.
|
|
1354
|
-
*/
|
|
1355
|
-
async function seedRulesFromBundled(threatDb) {
|
|
1356
|
-
const { readdirSync, readFileSync: readFs, statSync } = await import('node:fs');
|
|
1357
|
-
const { join: joinPath, basename, relative } = await import('node:path');
|
|
1358
|
-
let seeded = 0;
|
|
1359
|
-
const now = new Date().toISOString();
|
|
1360
|
-
// Resolve config directory (Docker: /app/config, monorepo: ../../config)
|
|
1361
|
-
const configDirs = [
|
|
1362
|
-
join(process.cwd(), 'config'),
|
|
1363
|
-
join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', '..', 'config'),
|
|
1364
|
-
];
|
|
1365
|
-
const configDir = configDirs.find((d) => {
|
|
1366
|
-
try {
|
|
1367
|
-
return statSync(d).isDirectory();
|
|
1368
|
-
}
|
|
1369
|
-
catch {
|
|
1370
|
-
return false;
|
|
1371
|
-
}
|
|
1372
|
-
});
|
|
1373
|
-
if (!configDir) {
|
|
1374
|
-
console.log(` ${c.dim(' No config/ directory found — skipping rule seeding')}`);
|
|
1375
|
-
console.log(` ${c.dim(` Searched: ${configDirs.join(', ')}`)}`);
|
|
1376
|
-
return 0;
|
|
1377
|
-
}
|
|
1378
|
-
console.log(` ${c.dim(` Using config directory: ${configDir}`)}`);
|
|
1379
|
-
/** Recursively collect files matching extensions */
|
|
1380
|
-
function collectFiles(dir, extensions) {
|
|
1381
|
-
const results = [];
|
|
1382
|
-
try {
|
|
1383
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
1384
|
-
const fullPath = joinPath(dir, entry.name);
|
|
1385
|
-
if (entry.isDirectory()) {
|
|
1386
|
-
results.push(...collectFiles(fullPath, extensions));
|
|
1387
|
-
}
|
|
1388
|
-
else if (extensions.some((ext) => entry.name.endsWith(ext))) {
|
|
1389
|
-
results.push(fullPath);
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
}
|
|
1393
|
-
catch (err) {
|
|
1394
|
-
console.error(` [WARN] Cannot read directory ${dir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1395
|
-
}
|
|
1396
|
-
return results;
|
|
1397
|
-
}
|
|
1398
|
-
// 1. Sigma rules (.yml, .yaml)
|
|
1399
|
-
const sigmaDir = joinPath(configDir, 'sigma-rules');
|
|
1400
|
-
try {
|
|
1401
|
-
const sigmaFiles = collectFiles(sigmaDir, ['.yml', '.yaml']);
|
|
1402
|
-
for (const file of sigmaFiles) {
|
|
1403
|
-
const content = readFs(file, 'utf-8');
|
|
1404
|
-
const ruleId = `sigma:${relative(sigmaDir, file).replace(/\//g, ':')}`;
|
|
1405
|
-
threatDb.upsertRule({ ruleId, ruleContent: content, publishedAt: now, source: 'sigma' });
|
|
1406
|
-
seeded++;
|
|
1407
|
-
}
|
|
1408
|
-
console.log(` ${c.dim(` Sigma: ${sigmaFiles.length} files processed`)}`);
|
|
1409
|
-
}
|
|
1410
|
-
catch (err) {
|
|
1411
|
-
console.error(` [WARN] Sigma rule seeding failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1412
|
-
}
|
|
1413
|
-
// 2. YARA rules (.yar, .yara)
|
|
1414
|
-
const yaraDir = joinPath(configDir, 'yara-rules');
|
|
1415
|
-
try {
|
|
1416
|
-
const yaraFiles = collectFiles(yaraDir, ['.yar', '.yara']);
|
|
1417
|
-
for (const file of yaraFiles) {
|
|
1418
|
-
const content = readFs(file, 'utf-8');
|
|
1419
|
-
// Split multi-rule YARA files
|
|
1420
|
-
const ruleMatches = content.match(/rule\s+\w+/g);
|
|
1421
|
-
if (ruleMatches && ruleMatches.length > 1) {
|
|
1422
|
-
// Multi-rule file: store each rule name as sub-ID
|
|
1423
|
-
for (const match of ruleMatches) {
|
|
1424
|
-
const ruleName = match.replace('rule ', '');
|
|
1425
|
-
const ruleId = `yara:${basename(file, '.yar').replace('.yara', '')}:${ruleName}`;
|
|
1426
|
-
threatDb.upsertRule({ ruleId, ruleContent: content, publishedAt: now, source: 'yara' });
|
|
1427
|
-
seeded++;
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
else {
|
|
1431
|
-
const ruleId = `yara:${relative(yaraDir, file).replace(/\//g, ':')}`;
|
|
1432
|
-
threatDb.upsertRule({ ruleId, ruleContent: content, publishedAt: now, source: 'yara' });
|
|
1433
|
-
seeded++;
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
console.log(` ${c.dim(` YARA: ${yaraFiles.length} files processed`)}`);
|
|
1437
|
-
}
|
|
1438
|
-
catch (err) {
|
|
1439
|
-
console.error(` [WARN] YARA rule seeding failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1440
|
-
}
|
|
1441
|
-
// 3. ATR rules (.yaml, .yml) from atr package
|
|
1442
|
-
const atrDirs = [
|
|
1443
|
-
joinPath(process.cwd(), 'node_modules', 'agent-threat-rules', 'rules'),
|
|
1444
|
-
joinPath(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', '..', 'packages', 'atr', 'rules'),
|
|
1445
|
-
];
|
|
1446
|
-
const atrDir = atrDirs.find((d) => {
|
|
1447
|
-
try {
|
|
1448
|
-
return statSync(d).isDirectory();
|
|
1449
|
-
}
|
|
1450
|
-
catch {
|
|
1451
|
-
return false;
|
|
1452
|
-
}
|
|
1453
|
-
});
|
|
1454
|
-
if (atrDir) {
|
|
1455
|
-
try {
|
|
1456
|
-
const atrFiles = collectFiles(atrDir, ['.yaml', '.yml']);
|
|
1457
|
-
for (const file of atrFiles) {
|
|
1458
|
-
const content = readFs(file, 'utf-8');
|
|
1459
|
-
const ruleId = `atr:${relative(atrDir, file).replace(/\//g, ':')}`;
|
|
1460
|
-
threatDb.upsertRule({ ruleId, ruleContent: content, publishedAt: now, source: 'atr' });
|
|
1461
|
-
seeded++;
|
|
1462
|
-
}
|
|
1463
|
-
console.log(` ${c.dim(` ATR: ${atrFiles.length} files processed`)}`);
|
|
1464
|
-
}
|
|
1465
|
-
catch (err) {
|
|
1466
|
-
console.error(` [WARN] ATR rule seeding failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1467
|
-
}
|
|
1468
|
-
}
|
|
1469
|
-
return seeded;
|
|
1470
|
-
}
|
|
1471
368
|
//# sourceMappingURL=serve.js.map
|