@panguard-ai/threat-cloud 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit-logger.d.ts +1 -1
- package/dist/audit-logger.d.ts.map +1 -1
- package/dist/audit-logger.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/database.d.ts +5 -1
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +99 -24
- package/dist/database.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/llm-reviewer.d.ts +28 -0
- package/dist/llm-reviewer.d.ts.map +1 -1
- package/dist/llm-reviewer.js +194 -19
- package/dist/llm-reviewer.js.map +1 -1
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +71 -4
- package/dist/migrations.js.map +1 -1
- package/dist/server.d.ts +21 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +259 -85
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +34 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -2
package/dist/server.js
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
* - GET /api/feeds/ip-blocklist IP blocklist feed (text/plain, ?minReputation=)
|
|
16
16
|
* - GET /api/feeds/domain-blocklist Domain blocklist feed (text/plain, ?minReputation=)
|
|
17
17
|
* - GET /api/skill-blacklist Community skill blacklist (aggregated threats)
|
|
18
|
+
* - POST /api/analyze-skills Submit scan results for server-side LLM analysis
|
|
19
|
+
* - GET /api/audit-log Admin audit log (paginated, admin-only)
|
|
20
|
+
* - POST /api/scan-events Report scan event from any source (bulk/CLI/web)
|
|
21
|
+
* - GET /api/metrics Aggregated metrics across all sources (public, cached 60s)
|
|
18
22
|
* - GET /health Health check
|
|
19
23
|
*
|
|
20
24
|
* @module @panguard-ai/threat-cloud/server
|
|
@@ -23,17 +27,25 @@ import { createServer } from 'node:http';
|
|
|
23
27
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
24
28
|
import { join, basename, relative, dirname } from 'node:path';
|
|
25
29
|
import { fileURLToPath } from 'node:url';
|
|
26
|
-
import { randomUUID } from 'node:crypto';
|
|
30
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
27
31
|
import { ThreatCloudDB } from './database.js';
|
|
28
32
|
import { LLMReviewer } from './llm-reviewer.js';
|
|
29
33
|
import { getAdminHTML } from './admin-dashboard.js';
|
|
34
|
+
import { tryValidateInput, ThreatDataSchema, RulePublishSchema, ATRProposalSchema, ATRFeedbackSchema, SkillThreatSchema, SkillWhitelistItemSchema, } from '@panguard-ai/core';
|
|
35
|
+
import { z } from 'zod';
|
|
30
36
|
/** Structured JSON logger for threat-cloud */
|
|
31
37
|
const log = {
|
|
32
38
|
info: (msg, extra) => {
|
|
33
39
|
process.stdout.write(JSON.stringify({ ts: new Date().toISOString(), level: 'info', msg, ...extra }) + '\n');
|
|
34
40
|
},
|
|
35
41
|
error: (msg, err, extra) => {
|
|
36
|
-
process.stderr.write(JSON.stringify({
|
|
42
|
+
process.stderr.write(JSON.stringify({
|
|
43
|
+
ts: new Date().toISOString(),
|
|
44
|
+
level: 'error',
|
|
45
|
+
msg,
|
|
46
|
+
error: err instanceof Error ? err.message : String(err),
|
|
47
|
+
...extra,
|
|
48
|
+
}) + '\n');
|
|
37
49
|
},
|
|
38
50
|
};
|
|
39
51
|
/**
|
|
@@ -152,20 +164,38 @@ export class ThreatCloudServer {
|
|
|
152
164
|
// Rate limiting
|
|
153
165
|
if (!this.checkRateLimit(clientIP)) {
|
|
154
166
|
this.sendJson(res, 429, { ok: false, error: 'Rate limit exceeded', request_id: requestId });
|
|
155
|
-
log.info('request', {
|
|
167
|
+
log.info('request', {
|
|
168
|
+
method: req.method,
|
|
169
|
+
path: req.url,
|
|
170
|
+
status: 429,
|
|
171
|
+
duration_ms: Date.now() - startTime,
|
|
172
|
+
client_ip: clientIP,
|
|
173
|
+
request_id: requestId,
|
|
174
|
+
});
|
|
156
175
|
return;
|
|
157
176
|
}
|
|
158
177
|
// API key verification (skip for health check)
|
|
159
178
|
const url = req.url ?? '/';
|
|
160
|
-
const rawPathname = url.split('?')[0];
|
|
179
|
+
const rawPathname = url.split('?')[0] ?? '/';
|
|
161
180
|
// API versioning: strip /v1 prefix for backward compatibility
|
|
162
|
-
const pathname = rawPathname.startsWith('/v1/')
|
|
181
|
+
const pathname = rawPathname.startsWith('/v1/')
|
|
182
|
+
? rawPathname.slice(3)
|
|
183
|
+
: rawPathname === '/v1'
|
|
184
|
+
? '/'
|
|
185
|
+
: rawPathname;
|
|
163
186
|
if (pathname !== '/health' && this.config.apiKeyRequired) {
|
|
164
187
|
const authHeader = req.headers.authorization ?? '';
|
|
165
188
|
const token = authHeader.replace('Bearer ', '');
|
|
166
189
|
if (!this.config.apiKeys.includes(token)) {
|
|
167
190
|
this.sendJson(res, 401, { ok: false, error: 'Invalid API key', request_id: requestId });
|
|
168
|
-
log.info('request', {
|
|
191
|
+
log.info('request', {
|
|
192
|
+
method: req.method,
|
|
193
|
+
path: rawPathname,
|
|
194
|
+
status: 401,
|
|
195
|
+
duration_ms: Date.now() - startTime,
|
|
196
|
+
client_ip: clientIP,
|
|
197
|
+
request_id: requestId,
|
|
198
|
+
});
|
|
169
199
|
return;
|
|
170
200
|
}
|
|
171
201
|
}
|
|
@@ -181,7 +211,14 @@ export class ThreatCloudServer {
|
|
|
181
211
|
if (req.method === 'OPTIONS') {
|
|
182
212
|
res.writeHead(204);
|
|
183
213
|
res.end();
|
|
184
|
-
log.info('request', {
|
|
214
|
+
log.info('request', {
|
|
215
|
+
method: 'OPTIONS',
|
|
216
|
+
path: rawPathname,
|
|
217
|
+
status: 204,
|
|
218
|
+
duration_ms: Date.now() - startTime,
|
|
219
|
+
client_ip: clientIP,
|
|
220
|
+
request_id: requestId,
|
|
221
|
+
});
|
|
185
222
|
return;
|
|
186
223
|
}
|
|
187
224
|
// Store requestId on response for sendJson to include
|
|
@@ -327,6 +364,14 @@ export class ThreatCloudServer {
|
|
|
327
364
|
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
328
365
|
}
|
|
329
366
|
break;
|
|
367
|
+
case '/api/analyze-skills':
|
|
368
|
+
if (req.method === 'POST') {
|
|
369
|
+
await this.handleAnalyzeSkills(req, res);
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
373
|
+
}
|
|
374
|
+
break;
|
|
330
375
|
case '/api/audit-log':
|
|
331
376
|
if (req.method === 'GET') {
|
|
332
377
|
if (!this.checkAdminAuth(req)) {
|
|
@@ -339,6 +384,22 @@ export class ThreatCloudServer {
|
|
|
339
384
|
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
340
385
|
}
|
|
341
386
|
break;
|
|
387
|
+
case '/api/scan-events':
|
|
388
|
+
if (req.method === 'POST') {
|
|
389
|
+
await this.handlePostScanEvent(req, res);
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
393
|
+
}
|
|
394
|
+
break;
|
|
395
|
+
case '/api/metrics':
|
|
396
|
+
if (req.method === 'GET') {
|
|
397
|
+
this.handleGetMetrics(res);
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
401
|
+
}
|
|
402
|
+
break;
|
|
342
403
|
default:
|
|
343
404
|
this.sendJson(res, 404, { ok: false, error: 'Not found' });
|
|
344
405
|
}
|
|
@@ -360,34 +421,36 @@ export class ThreatCloudServer {
|
|
|
360
421
|
/** POST /api/threats - Upload anonymized threat data (single or batch) */
|
|
361
422
|
async handlePostThreat(req, res) {
|
|
362
423
|
const body = await this.readBody(req);
|
|
363
|
-
|
|
424
|
+
let raw;
|
|
425
|
+
try {
|
|
426
|
+
raw = JSON.parse(body);
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
this.sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
364
432
|
// Support both single object and batch { events: [...] } format
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
for (const
|
|
369
|
-
|
|
370
|
-
if (!
|
|
371
|
-
|
|
372
|
-
!data.mitreTechnique ||
|
|
373
|
-
!data.sigmaRuleMatched ||
|
|
374
|
-
!data.timestamp ||
|
|
375
|
-
!data.region) {
|
|
376
|
-
this.sendJson(res, 400, {
|
|
377
|
-
ok: false,
|
|
378
|
-
error: 'Missing required fields: attackSourceIP, attackType, mitreTechnique, sigmaRuleMatched, timestamp, region',
|
|
379
|
-
});
|
|
433
|
+
const rawObj = raw;
|
|
434
|
+
const rawEvents = 'events' in rawObj && Array.isArray(rawObj['events']) ? rawObj['events'] : [raw];
|
|
435
|
+
const validated = [];
|
|
436
|
+
for (const event of rawEvents) {
|
|
437
|
+
const result = tryValidateInput(ThreatDataSchema, event);
|
|
438
|
+
if (!result.ok) {
|
|
439
|
+
this.sendJson(res, 400, { ok: false, error: result.error });
|
|
380
440
|
return;
|
|
381
441
|
}
|
|
382
|
-
|
|
383
|
-
|
|
442
|
+
const mutable = { ...result.data };
|
|
443
|
+
mutable.attackSourceIP = this.anonymizeIP(mutable.attackSourceIP);
|
|
444
|
+
validated.push(mutable);
|
|
445
|
+
}
|
|
446
|
+
for (const data of validated) {
|
|
384
447
|
this.db.insertThreat(data);
|
|
385
448
|
}
|
|
386
449
|
const clientIP = req.socket.remoteAddress ?? 'unknown';
|
|
387
|
-
this.db.audit.logAction('client', 'threat.submit', 'threat', undefined, { count:
|
|
450
|
+
this.db.audit.logAction('client', 'threat.submit', 'threat', undefined, { count: validated.length }, clientIP);
|
|
388
451
|
this.sendJson(res, 201, {
|
|
389
452
|
ok: true,
|
|
390
|
-
data: { message: 'Threat data received', count:
|
|
453
|
+
data: { message: 'Threat data received', count: validated.length },
|
|
391
454
|
});
|
|
392
455
|
}
|
|
393
456
|
/** GET /api/rules?since=<ISO>&category=<cat>&severity=<sev>&source=<src> */
|
|
@@ -410,16 +473,28 @@ export class ThreatCloudServer {
|
|
|
410
473
|
/** POST /api/rules - Publish rules (single or batch) */
|
|
411
474
|
async handlePostRule(req, res) {
|
|
412
475
|
const body = await this.readBody(req);
|
|
413
|
-
|
|
476
|
+
let raw;
|
|
477
|
+
try {
|
|
478
|
+
raw = JSON.parse(body);
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
this.sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
414
484
|
// Support both single object and batch { rules: [...] } format
|
|
415
|
-
const
|
|
485
|
+
const rawObj = raw;
|
|
486
|
+
const rawRules = 'rules' in rawObj && Array.isArray(rawObj['rules']) ? rawObj['rules'] : [raw];
|
|
416
487
|
const now = new Date().toISOString();
|
|
417
488
|
let count = 0;
|
|
418
|
-
for (const
|
|
419
|
-
|
|
489
|
+
for (const rawRule of rawRules) {
|
|
490
|
+
const result = tryValidateInput(RulePublishSchema, rawRule);
|
|
491
|
+
if (!result.ok)
|
|
420
492
|
continue;
|
|
421
|
-
|
|
422
|
-
|
|
493
|
+
const ruleData = {
|
|
494
|
+
...result.data,
|
|
495
|
+
publishedAt: result.data.publishedAt || now,
|
|
496
|
+
};
|
|
497
|
+
this.db.upsertRule(ruleData);
|
|
423
498
|
count++;
|
|
424
499
|
}
|
|
425
500
|
const clientIP = req.socket.remoteAddress ?? 'unknown';
|
|
@@ -468,82 +543,57 @@ export class ThreatCloudServer {
|
|
|
468
543
|
}
|
|
469
544
|
/** POST /api/atr-proposals - Submit or confirm an ATR rule proposal */
|
|
470
545
|
async handlePostATRProposal(req, res) {
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
const clientId = req.headers['x-panguard-client-id'] ?? undefined;
|
|
474
|
-
if (!data.patternHash || !data.ruleContent) {
|
|
475
|
-
this.sendJson(res, 400, {
|
|
476
|
-
ok: false,
|
|
477
|
-
error: 'Missing required fields: patternHash, ruleContent',
|
|
478
|
-
});
|
|
546
|
+
const data = await this.parseAndValidate(req, res, ATRProposalSchema);
|
|
547
|
+
if (!data)
|
|
479
548
|
return;
|
|
480
|
-
|
|
549
|
+
const clientId = req.headers['x-panguard-client-id'] ?? undefined;
|
|
481
550
|
// Check if a proposal with the same patternHash already exists
|
|
482
551
|
const proposals = this.db.getATRProposals();
|
|
483
552
|
const existing = proposals.find((p) => p['pattern_hash'] === data.patternHash);
|
|
553
|
+
const { patternHash, ruleContent } = data;
|
|
484
554
|
if (existing) {
|
|
485
|
-
this.db.confirmATRProposal(
|
|
555
|
+
this.db.confirmATRProposal(patternHash);
|
|
486
556
|
this.sendJson(res, 200, {
|
|
487
557
|
ok: true,
|
|
488
|
-
data: { message: 'Proposal confirmed', patternHash
|
|
558
|
+
data: { message: 'Proposal confirmed', patternHash },
|
|
489
559
|
});
|
|
490
560
|
}
|
|
491
561
|
else {
|
|
492
562
|
const proposal = {
|
|
493
563
|
...data,
|
|
494
|
-
clientId
|
|
564
|
+
clientId,
|
|
495
565
|
};
|
|
496
566
|
this.db.insertATRProposal(proposal);
|
|
497
567
|
// Fire-and-forget LLM review on first submission only
|
|
498
568
|
if (this.llmReviewer?.isAvailable()) {
|
|
499
|
-
void this.llmReviewer.reviewProposal(
|
|
500
|
-
log.error(`LLM review failed for ${
|
|
569
|
+
void this.llmReviewer.reviewProposal(patternHash, ruleContent).catch((err) => {
|
|
570
|
+
log.error(`LLM review failed for ${patternHash}`, err);
|
|
501
571
|
});
|
|
502
572
|
}
|
|
503
573
|
this.sendJson(res, 201, {
|
|
504
574
|
ok: true,
|
|
505
|
-
data: { message: 'Proposal submitted', patternHash
|
|
575
|
+
data: { message: 'Proposal submitted', patternHash },
|
|
506
576
|
});
|
|
507
577
|
}
|
|
508
578
|
}
|
|
509
579
|
/** POST /api/atr-feedback - Submit feedback on an ATR rule */
|
|
510
580
|
async handlePostATRFeedback(req, res) {
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
const clientId = req.headers['x-panguard-client-id'] ?? undefined;
|
|
514
|
-
if (!data.ruleId || typeof data.isTruePositive !== 'boolean') {
|
|
515
|
-
this.sendJson(res, 400, {
|
|
516
|
-
ok: false,
|
|
517
|
-
error: 'Missing required fields: ruleId (string), isTruePositive (boolean)',
|
|
518
|
-
});
|
|
581
|
+
const data = await this.parseAndValidate(req, res, ATRFeedbackSchema);
|
|
582
|
+
if (!data)
|
|
519
583
|
return;
|
|
520
|
-
|
|
584
|
+
const clientId = req.headers['x-panguard-client-id'] ?? undefined;
|
|
521
585
|
this.db.insertATRFeedback(data.ruleId, data.isTruePositive, clientId);
|
|
522
586
|
this.sendJson(res, 201, { ok: true, data: { message: 'Feedback received' } });
|
|
523
587
|
}
|
|
524
588
|
/** POST /api/skill-threats - Submit skill threat from audit */
|
|
525
589
|
async handlePostSkillThreat(req, res) {
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
const clientId = req.headers['x-panguard-client-id'] ?? undefined;
|
|
529
|
-
if (!data.skillHash || !data.skillName) {
|
|
530
|
-
this.sendJson(res, 400, {
|
|
531
|
-
ok: false,
|
|
532
|
-
error: 'Missing required fields: skillHash, skillName',
|
|
533
|
-
});
|
|
534
|
-
return;
|
|
535
|
-
}
|
|
536
|
-
if (typeof data.riskScore !== 'number' || data.riskScore < 0 || data.riskScore > 100) {
|
|
537
|
-
this.sendJson(res, 400, { ok: false, error: 'riskScore must be a number between 0 and 100' });
|
|
590
|
+
const data = await this.parseAndValidate(req, res, SkillThreatSchema);
|
|
591
|
+
if (!data)
|
|
538
592
|
return;
|
|
539
|
-
|
|
540
|
-
if (!data.riskLevel || typeof data.riskLevel !== 'string') {
|
|
541
|
-
this.sendJson(res, 400, { ok: false, error: 'riskLevel is required and must be a string' });
|
|
542
|
-
return;
|
|
543
|
-
}
|
|
593
|
+
const clientId = req.headers['x-panguard-client-id'] ?? undefined;
|
|
544
594
|
const submission = {
|
|
545
595
|
...data,
|
|
546
|
-
clientId
|
|
596
|
+
clientId,
|
|
547
597
|
};
|
|
548
598
|
this.db.insertSkillThreat(submission);
|
|
549
599
|
const clientIP = req.socket.remoteAddress ?? 'unknown';
|
|
@@ -591,15 +641,24 @@ export class ThreatCloudServer {
|
|
|
591
641
|
/** POST /api/skill-whitelist - Report a safe skill (audit passed) */
|
|
592
642
|
async handlePostSkillWhitelist(req, res) {
|
|
593
643
|
const body = await this.readBody(req);
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
644
|
+
let raw;
|
|
645
|
+
try {
|
|
646
|
+
raw = JSON.parse(body);
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
this.sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const rawObj = raw;
|
|
653
|
+
const skills = 'skills' in rawObj && Array.isArray(rawObj['skills'])
|
|
654
|
+
? rawObj['skills']
|
|
655
|
+
: [raw];
|
|
598
656
|
let count = 0;
|
|
599
657
|
for (const skill of skills) {
|
|
600
|
-
|
|
658
|
+
const result = SkillWhitelistItemSchema.safeParse(skill);
|
|
659
|
+
if (!result.success)
|
|
601
660
|
continue;
|
|
602
|
-
this.db.reportSafeSkill(
|
|
661
|
+
this.db.reportSafeSkill(result.data.skillName, result.data.fingerprintHash);
|
|
603
662
|
count++;
|
|
604
663
|
}
|
|
605
664
|
this.sendJson(res, 201, { ok: true, data: { message: `${count} skill(s) reported`, count } });
|
|
@@ -622,6 +681,59 @@ export class ThreatCloudServer {
|
|
|
622
681
|
const blacklist = this.db.getSkillBlacklist(minReports, minAvgRisk);
|
|
623
682
|
this.sendJson(res, 200, { ok: true, data: blacklist });
|
|
624
683
|
}
|
|
684
|
+
/**
|
|
685
|
+
* POST /api/analyze-skills - Submit scan results for server-side LLM analysis
|
|
686
|
+
* 提交掃描結果讓伺服器端 LLM 分析語義威脅並自動產出 ATR proposals
|
|
687
|
+
*
|
|
688
|
+
* Body: { skills: [{ package: string, tools: [{ name, description }] }] }
|
|
689
|
+
* Response: { ok: true, data: { analyzed, proposalsCreated, results } }
|
|
690
|
+
*/
|
|
691
|
+
async handleAnalyzeSkills(req, res) {
|
|
692
|
+
if (!this.llmReviewer?.isAvailable()) {
|
|
693
|
+
this.sendJson(res, 503, {
|
|
694
|
+
ok: false,
|
|
695
|
+
error: 'LLM reviewer not available — ANTHROPIC_API_KEY not configured on server',
|
|
696
|
+
});
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const AnalyzeSkillsSchema = z.object({
|
|
700
|
+
skills: z
|
|
701
|
+
.array(z.object({
|
|
702
|
+
package: z.string().min(1),
|
|
703
|
+
tools: z.array(z.object({
|
|
704
|
+
name: z.string().min(1),
|
|
705
|
+
description: z.string(),
|
|
706
|
+
})),
|
|
707
|
+
}))
|
|
708
|
+
.min(1, 'skills array must not be empty')
|
|
709
|
+
.max(10, 'Maximum 10 skills per request'),
|
|
710
|
+
});
|
|
711
|
+
const data = await this.parseAndValidate(req, res, AnalyzeSkillsSchema);
|
|
712
|
+
if (!data)
|
|
713
|
+
return;
|
|
714
|
+
const skills = data.skills;
|
|
715
|
+
log.info(`Analyzing ${skills.length} skills with LLM`, {
|
|
716
|
+
packages: skills.map((s) => s.package),
|
|
717
|
+
});
|
|
718
|
+
const results = await this.llmReviewer.analyzeSkills(skills);
|
|
719
|
+
const proposalsCreated = results.reduce((sum, r) => sum + r.proposals.length, 0);
|
|
720
|
+
log.info(`LLM analysis complete: ${proposalsCreated} proposals from ${skills.length} skills`);
|
|
721
|
+
this.sendJson(res, 200, {
|
|
722
|
+
ok: true,
|
|
723
|
+
data: {
|
|
724
|
+
analyzed: results.length,
|
|
725
|
+
proposalsCreated,
|
|
726
|
+
results: results.map((r) => ({
|
|
727
|
+
package: r.package,
|
|
728
|
+
threatsFound: r.threatsFound,
|
|
729
|
+
proposalCount: r.proposals.length,
|
|
730
|
+
patternHashes: r.proposals.map((p) => p.patternHash),
|
|
731
|
+
status: r.status,
|
|
732
|
+
...(r.errorReason ? { errorReason: r.errorReason } : {}),
|
|
733
|
+
})),
|
|
734
|
+
},
|
|
735
|
+
});
|
|
736
|
+
}
|
|
625
737
|
/** GET /api/audit-log?page=1&limit=50 (admin-only) */
|
|
626
738
|
handleGetAuditLog(url, res) {
|
|
627
739
|
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
@@ -636,6 +748,32 @@ export class ThreatCloudServer {
|
|
|
636
748
|
meta: { total, page, limit, pages: Math.ceil(total / limit) },
|
|
637
749
|
});
|
|
638
750
|
}
|
|
751
|
+
/** POST /api/scan-events - Report a scan event from any source */
|
|
752
|
+
async handlePostScanEvent(req, res) {
|
|
753
|
+
const ScanEventSchema = z.object({
|
|
754
|
+
source: z.enum(['bulk-pipeline', 'cli-user', 'web-scanner']),
|
|
755
|
+
skillsScanned: z.number().int().min(0),
|
|
756
|
+
findingsCount: z.number().int().min(0),
|
|
757
|
+
confirmedMalicious: z.number().int().min(0).default(0),
|
|
758
|
+
highlySuspicious: z.number().int().min(0).default(0),
|
|
759
|
+
generalSuspicious: z.number().int().min(0).default(0),
|
|
760
|
+
cleanCount: z.number().int().min(0).default(0),
|
|
761
|
+
deviceHash: z.string().optional(),
|
|
762
|
+
});
|
|
763
|
+
const data = await this.parseAndValidate(req, res, ScanEventSchema);
|
|
764
|
+
if (!data)
|
|
765
|
+
return;
|
|
766
|
+
this.db.insertScanEvent(data);
|
|
767
|
+
const clientIP = req.socket.remoteAddress ?? 'unknown';
|
|
768
|
+
this.db.audit.logAction('client', 'scan_event.submit', 'scan_event', undefined, { source: data.source, skillsScanned: data.skillsScanned }, clientIP);
|
|
769
|
+
this.sendJson(res, 201, { ok: true, data: { message: 'Scan event recorded' } });
|
|
770
|
+
}
|
|
771
|
+
/** GET /api/metrics - Aggregated metrics across all sources (public, cached 60s) */
|
|
772
|
+
handleGetMetrics(res) {
|
|
773
|
+
res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=60');
|
|
774
|
+
const metrics = this.db.getAggregatedMetrics();
|
|
775
|
+
this.sendJson(res, 200, { ok: true, data: metrics });
|
|
776
|
+
}
|
|
639
777
|
/** Anonymize IP by zeroing last octet / 匿名化 IP */
|
|
640
778
|
anonymizeIP(ip) {
|
|
641
779
|
if (ip.includes('.')) {
|
|
@@ -660,7 +798,12 @@ export class ThreatCloudServer {
|
|
|
660
798
|
const url = new URL(req.url ?? '/', `http://localhost:${this.config.port}`);
|
|
661
799
|
const queryKey = url.searchParams.get('key');
|
|
662
800
|
const headerKey = (req.headers.authorization ?? '').replace('Bearer ', '');
|
|
663
|
-
|
|
801
|
+
const keyMatch = (candidate) => {
|
|
802
|
+
if (!candidate || candidate.length !== this.config.adminApiKey.length)
|
|
803
|
+
return false;
|
|
804
|
+
return timingSafeEqual(Buffer.from(candidate, 'utf-8'), Buffer.from(this.config.adminApiKey, 'utf-8'));
|
|
805
|
+
};
|
|
806
|
+
if (!keyMatch(queryKey) && !keyMatch(headerKey)) {
|
|
664
807
|
res.writeHead(401, { 'Content-Type': 'text/plain' });
|
|
665
808
|
res.end('Unauthorized: admin API key required. Use ?key=YOUR_KEY or Authorization header.');
|
|
666
809
|
return;
|
|
@@ -674,11 +817,21 @@ export class ThreatCloudServer {
|
|
|
674
817
|
}
|
|
675
818
|
/** Check admin API key for write-protected endpoints / 檢查管理員 API 金鑰 */
|
|
676
819
|
checkAdminAuth(req) {
|
|
677
|
-
if (!this.config.adminApiKey)
|
|
678
|
-
|
|
820
|
+
if (!this.config.adminApiKey) {
|
|
821
|
+
// No admin key configured — only allow from loopback addresses
|
|
822
|
+
const remoteAddr = req.socket.remoteAddress ?? '';
|
|
823
|
+
const isLoopback = remoteAddr === '127.0.0.1' || remoteAddr === '::1' || remoteAddr === '::ffff:127.0.0.1';
|
|
824
|
+
if (!isLoopback) {
|
|
825
|
+
log.info(`Admin access denied: no TC_ADMIN_API_KEY configured and request from non-loopback address`, { remoteAddr });
|
|
826
|
+
}
|
|
827
|
+
return isLoopback;
|
|
828
|
+
}
|
|
679
829
|
const authHeader = req.headers.authorization ?? '';
|
|
680
830
|
const token = authHeader.replace('Bearer ', '');
|
|
681
|
-
|
|
831
|
+
// Use timing-safe comparison to prevent timing attacks
|
|
832
|
+
if (token.length !== this.config.adminApiKey.length)
|
|
833
|
+
return false;
|
|
834
|
+
return timingSafeEqual(Buffer.from(token, 'utf-8'), Buffer.from(this.config.adminApiKey, 'utf-8'));
|
|
682
835
|
}
|
|
683
836
|
/** Rate limit check / 速率限制檢查 */
|
|
684
837
|
checkRateLimit(ip) {
|
|
@@ -691,6 +844,27 @@ export class ThreatCloudServer {
|
|
|
691
844
|
entry.count++;
|
|
692
845
|
return entry.count <= this.config.rateLimitPerMinute;
|
|
693
846
|
}
|
|
847
|
+
/**
|
|
848
|
+
* Parse JSON body and validate against a Zod schema.
|
|
849
|
+
* Returns validated data or null (sends 400 on failure).
|
|
850
|
+
*/
|
|
851
|
+
async parseAndValidate(req, res, schema) {
|
|
852
|
+
const body = await this.readBody(req);
|
|
853
|
+
let raw;
|
|
854
|
+
try {
|
|
855
|
+
raw = JSON.parse(body);
|
|
856
|
+
}
|
|
857
|
+
catch {
|
|
858
|
+
this.sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
const result = tryValidateInput(schema, raw);
|
|
862
|
+
if (!result.ok) {
|
|
863
|
+
this.sendJson(res, 400, { ok: false, error: result.error });
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
return result.data;
|
|
867
|
+
}
|
|
694
868
|
/** Read request body with size limit / 讀取請求主體(含大小限制) */
|
|
695
869
|
readBody(req) {
|
|
696
870
|
return new Promise((resolve, reject) => {
|