@panguard-ai/threat-cloud 1.4.2 → 1.5.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/LICENSE +21 -0
- 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 +236 -2
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +603 -51
- package/dist/database.js.map +1 -1
- package/dist/llm-reviewer-tools.d.ts +110 -0
- package/dist/llm-reviewer-tools.d.ts.map +1 -0
- package/dist/llm-reviewer-tools.js +446 -0
- package/dist/llm-reviewer-tools.js.map +1 -0
- package/dist/llm-reviewer.d.ts +54 -0
- package/dist/llm-reviewer.d.ts.map +1 -1
- package/dist/llm-reviewer.js +708 -64
- package/dist/llm-reviewer.js.map +1 -1
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +215 -0
- package/dist/migrations.js.map +1 -1
- package/dist/migrator-crystallization.d.ts +80 -0
- package/dist/migrator-crystallization.d.ts.map +1 -0
- package/dist/migrator-crystallization.js +108 -0
- package/dist/migrator-crystallization.js.map +1 -0
- package/dist/server.d.ts +69 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1093 -91
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +31 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +15 -12
package/dist/server.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* - GET /api/stats Get threat statistics
|
|
10
10
|
* - POST /api/atr-proposals Submit or confirm ATR rule proposal
|
|
11
11
|
* - POST /api/atr-feedback Submit feedback on ATR rule
|
|
12
|
+
* - POST /api/rule-feedback Submit rule feedback with auto-quarantine
|
|
12
13
|
* - POST /api/skill-threats Submit skill threat from audit
|
|
13
14
|
* - GET /api/atr-rules Fetch confirmed ATR rules (?since= filter)
|
|
14
15
|
* - GET /api/feeds/ip-blocklist IP blocklist feed (text/plain, ?minReputation=)
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
* - POST /api/telemetry Record anonymous telemetry event from CLI
|
|
20
21
|
* - POST /api/scan-events Report scan event from any source (bulk/CLI/web)
|
|
21
22
|
* - GET /api/metrics Aggregated metrics across all sources (public, cached 60s)
|
|
23
|
+
* - GET /api/version Build/deploy info: version, commit, uptime (public, cached 30s)
|
|
22
24
|
* - GET /api/badge/:author/:skill ATR Scanned SVG badge for a skill
|
|
23
25
|
* - GET /api/badge/stats Badge statistics (JSON)
|
|
24
26
|
* - GET /health Health check
|
|
@@ -30,6 +32,15 @@ import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
|
30
32
|
import { join, relative, dirname } from 'node:path';
|
|
31
33
|
import { fileURLToPath } from 'node:url';
|
|
32
34
|
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
35
|
+
import { createRequire } from 'node:module';
|
|
36
|
+
// Read package.json version (for /api/version endpoint, deploy verification)
|
|
37
|
+
const _require = createRequire(import.meta.url);
|
|
38
|
+
const _pkg = _require('../package.json');
|
|
39
|
+
const TC_VERSION = _pkg.version;
|
|
40
|
+
// Server-process startup timestamp for uptime reporting in /api/version.
|
|
41
|
+
// This is module-scoped so it captures the actual import-time of the process,
|
|
42
|
+
// not the time the first request is handled.
|
|
43
|
+
const SERVER_START_TIME = new Date();
|
|
33
44
|
import { ThreatCloudDB } from './database.js';
|
|
34
45
|
import { createBadgeRouter } from './badge-api.js';
|
|
35
46
|
import { LLMReviewer } from './llm-reviewer.js';
|
|
@@ -63,6 +74,7 @@ export class ThreatCloudServer {
|
|
|
63
74
|
badgeRouter;
|
|
64
75
|
promotionTimer = null;
|
|
65
76
|
rateLimits = new Map();
|
|
77
|
+
registrationRateLimits = new Map();
|
|
66
78
|
rateLimitCleanupTimer = null;
|
|
67
79
|
statsCache = null;
|
|
68
80
|
/** Promotion interval: 2 minutes / 推廣間隔:2 分鐘 */
|
|
@@ -119,10 +131,22 @@ export class ThreatCloudServer {
|
|
|
119
131
|
// Start promotion + review cron (every 15 minutes)
|
|
120
132
|
this.promotionTimer = setInterval(() => {
|
|
121
133
|
try {
|
|
122
|
-
// Step 1:
|
|
123
|
-
const
|
|
124
|
-
if (
|
|
125
|
-
log.info(`Promotion cycle: ${
|
|
134
|
+
// Step 1: Move confirmed proposals to canary staging
|
|
135
|
+
const toCanary = this.db.promoteConfirmedProposals();
|
|
136
|
+
if (toCanary > 0) {
|
|
137
|
+
log.info(`Promotion cycle: ${toCanary} proposal(s) moved to canary staging`);
|
|
138
|
+
}
|
|
139
|
+
// Step 1b: Promote canary rules that survived 24hr observation
|
|
140
|
+
const canaryResult = this.db.promoteCanaryRules();
|
|
141
|
+
if (canaryResult.promoted > 0 || canaryResult.quarantined > 0) {
|
|
142
|
+
log.info(`Canary cycle: ${canaryResult.promoted} promoted, ${canaryResult.quarantined} quarantined`);
|
|
143
|
+
}
|
|
144
|
+
// Step 1c: Self-heal orphaned promoted proposals whose upsertRule
|
|
145
|
+
// call failed during a previous cycle (status=promoted but no
|
|
146
|
+
// corresponding row in rules table). Idempotent.
|
|
147
|
+
const healed = this.db.healOrphanedPromotedProposals();
|
|
148
|
+
if (healed > 0) {
|
|
149
|
+
log.info(`Self-heal: re-upserted ${healed} orphaned promoted proposal(s)`);
|
|
126
150
|
}
|
|
127
151
|
// Step 2: Retry LLM review for proposals that haven't been reviewed yet
|
|
128
152
|
if (this.llmReviewer?.isAvailable()) {
|
|
@@ -130,6 +154,11 @@ export class ThreatCloudServer {
|
|
|
130
154
|
log.error('Review retry failed', err);
|
|
131
155
|
});
|
|
132
156
|
}
|
|
157
|
+
// Step 3: Purge expired verdict cache entries
|
|
158
|
+
const purged = this.db.purgeExpiredVerdictCache();
|
|
159
|
+
if (purged > 0) {
|
|
160
|
+
log.info(`Verdict cache: purged ${purged} expired entries`);
|
|
161
|
+
}
|
|
133
162
|
}
|
|
134
163
|
catch (err) {
|
|
135
164
|
log.error('Promotion cycle failed', err);
|
|
@@ -142,6 +171,10 @@ export class ThreatCloudServer {
|
|
|
142
171
|
if (now > entry.resetAt)
|
|
143
172
|
this.rateLimits.delete(ip);
|
|
144
173
|
}
|
|
174
|
+
for (const [ip, entry] of this.registrationRateLimits) {
|
|
175
|
+
if (now > entry.resetAt)
|
|
176
|
+
this.registrationRateLimits.delete(ip);
|
|
177
|
+
}
|
|
145
178
|
// Aggregate old telemetry events into hourly buckets
|
|
146
179
|
try {
|
|
147
180
|
const cleaned = this.db.cleanupTelemetryEvents();
|
|
@@ -208,10 +241,26 @@ export class ThreatCloudServer {
|
|
|
208
241
|
: rawPathname === '/v1'
|
|
209
242
|
? '/'
|
|
210
243
|
: rawPathname;
|
|
211
|
-
|
|
244
|
+
// Public endpoints that don't require API key authentication.
|
|
245
|
+
// Read-only data endpoints are public. Write endpoints require auth.
|
|
246
|
+
// GET /api/rules is public so Guard can pull open-source ATR rules without a key.
|
|
247
|
+
const publicPaths = new Set(['/health', '/api/stats', '/api/metrics', '/api/clients/register']);
|
|
248
|
+
const isPublicRead = publicPaths.has(pathname) || (pathname === '/api/rules' && req.method === 'GET');
|
|
249
|
+
// Track the role that authenticated this request so route handlers can
|
|
250
|
+
// enforce scope (e.g. L5 partner endpoints only accept role=partner|admin).
|
|
251
|
+
let authRole = 'anonymous';
|
|
252
|
+
if (!isPublicRead && this.config.apiKeyRequired) {
|
|
212
253
|
const authHeader = req.headers.authorization ?? '';
|
|
213
254
|
const token = authHeader.replace('Bearer ', '');
|
|
214
|
-
|
|
255
|
+
const isValidApiKey = this.config.apiKeys.includes(token);
|
|
256
|
+
const isAdminKey = this.config.adminApiKey ? token === this.config.adminApiKey : false;
|
|
257
|
+
let clientKeyInfo = null;
|
|
258
|
+
if (!isValidApiKey && !isAdminKey && token.length > 0) {
|
|
259
|
+
clientKeyInfo = this.db.getClientKeyInfo(token);
|
|
260
|
+
if (clientKeyInfo)
|
|
261
|
+
this.db.validateClientKey(token); // bump last_used_at
|
|
262
|
+
}
|
|
263
|
+
if (!isValidApiKey && !isAdminKey && !clientKeyInfo) {
|
|
215
264
|
this.sendJson(res, 401, { ok: false, error: 'Invalid API key', request_id: requestId });
|
|
216
265
|
log.info('request', {
|
|
217
266
|
method: req.method,
|
|
@@ -223,6 +272,36 @@ export class ThreatCloudServer {
|
|
|
223
272
|
});
|
|
224
273
|
return;
|
|
225
274
|
}
|
|
275
|
+
authRole = isAdminKey
|
|
276
|
+
? 'admin'
|
|
277
|
+
: isValidApiKey
|
|
278
|
+
? 'static'
|
|
279
|
+
: clientKeyInfo?.role === 'partner'
|
|
280
|
+
? 'client-partner'
|
|
281
|
+
: 'client-guard';
|
|
282
|
+
}
|
|
283
|
+
// L5 partner-sync: /api/atr-rules/live is role-gated. Only admin or
|
|
284
|
+
// partner-tier client keys can reach it. Guard auto-provisioned keys
|
|
285
|
+
// are scoped to telemetry upload only; they must NOT be able to exfiltrate
|
|
286
|
+
// TC crystallization data.
|
|
287
|
+
if (pathname === '/api/atr-rules/live' && req.method === 'GET') {
|
|
288
|
+
if (authRole !== 'admin' && authRole !== 'static' && authRole !== 'client-partner') {
|
|
289
|
+
this.sendJson(res, 403, {
|
|
290
|
+
ok: false,
|
|
291
|
+
error: 'Partner key required for L5 live-sync endpoint',
|
|
292
|
+
docs: 'https://agentthreatrule.org/partner-sync',
|
|
293
|
+
request_id: requestId,
|
|
294
|
+
});
|
|
295
|
+
log.info('request', {
|
|
296
|
+
method: req.method,
|
|
297
|
+
path: rawPathname,
|
|
298
|
+
status: 403,
|
|
299
|
+
duration_ms: Date.now() - startTime,
|
|
300
|
+
client_ip: clientIP,
|
|
301
|
+
request_id: requestId,
|
|
302
|
+
});
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
226
305
|
}
|
|
227
306
|
// CORS — restrict to known origins
|
|
228
307
|
const allowedOrigins = (process.env['CORS_ALLOWED_ORIGINS'] ??
|
|
@@ -253,7 +332,11 @@ export class ThreatCloudServer {
|
|
|
253
332
|
case '/health':
|
|
254
333
|
this.sendJson(res, 200, {
|
|
255
334
|
ok: true,
|
|
256
|
-
data: {
|
|
335
|
+
data: {
|
|
336
|
+
status: 'healthy',
|
|
337
|
+
uptime: process.uptime(),
|
|
338
|
+
schemaVersion: this.db.getSchemaVersion(),
|
|
339
|
+
},
|
|
257
340
|
});
|
|
258
341
|
break;
|
|
259
342
|
case '/admin':
|
|
@@ -274,9 +357,25 @@ export class ThreatCloudServer {
|
|
|
274
357
|
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
275
358
|
}
|
|
276
359
|
break;
|
|
360
|
+
case '/api/atr-rules/live':
|
|
361
|
+
// L5 runtime sync — public, cacheable, partner-facing alias of /api/rules
|
|
362
|
+
// scoped to confirmed ATR rules only (source IN atr, atr-community).
|
|
363
|
+
// Accepts ?since=<ISO> for incremental pulls; responds with ETag +
|
|
364
|
+
// Last-Modified so partners can cheap-poll every N minutes.
|
|
365
|
+
if (req.method === 'GET') {
|
|
366
|
+
const u = new URL(url, `http://localhost:${this.config.port}`);
|
|
367
|
+
if (!u.searchParams.has('source')) {
|
|
368
|
+
u.searchParams.set('source', 'atr');
|
|
369
|
+
}
|
|
370
|
+
this.handleGetRules(u.pathname + '?' + u.searchParams.toString(), res, req);
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
374
|
+
}
|
|
375
|
+
break;
|
|
277
376
|
case '/api/rules':
|
|
278
377
|
if (req.method === 'GET') {
|
|
279
|
-
this.handleGetRules(url, res);
|
|
378
|
+
this.handleGetRules(url, res, req);
|
|
280
379
|
}
|
|
281
380
|
else if (req.method === 'POST') {
|
|
282
381
|
if (!this.checkAdminAuth(req)) {
|
|
@@ -292,6 +391,45 @@ export class ThreatCloudServer {
|
|
|
292
391
|
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
293
392
|
}
|
|
294
393
|
break;
|
|
394
|
+
case '/api/rules/sync':
|
|
395
|
+
// Admin-only: ATR repo CI syncs rules via admin key
|
|
396
|
+
if (req.method === 'POST') {
|
|
397
|
+
if (!this.checkAdminAuth(req)) {
|
|
398
|
+
this.sendJson(res, 403, { ok: false, error: 'Admin API key required for rule sync' });
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
await this.handleSyncATRRules(req, res);
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
405
|
+
}
|
|
406
|
+
break;
|
|
407
|
+
case '/api/rules/by-source':
|
|
408
|
+
// Admin-only: DELETE /api/rules/by-source?source=yara to purge rules by source
|
|
409
|
+
if (req.method === 'DELETE') {
|
|
410
|
+
if (!this.checkAdminAuth(req)) {
|
|
411
|
+
this.sendJson(res, 403, { ok: false, error: 'Admin API key required' });
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
await this.handleDeleteRulesBySource(url, res);
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
418
|
+
}
|
|
419
|
+
break;
|
|
420
|
+
case '/api/rules/bulk-delete':
|
|
421
|
+
// Admin-only: POST /api/rules/bulk-delete { ruleIds: [...] }
|
|
422
|
+
if (req.method === 'POST') {
|
|
423
|
+
if (!this.checkAdminAuth(req)) {
|
|
424
|
+
this.sendJson(res, 403, { ok: false, error: 'Admin API key required' });
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
await this.handleBulkDeleteRules(req, res);
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
431
|
+
}
|
|
432
|
+
break;
|
|
295
433
|
case '/api/stats':
|
|
296
434
|
if (req.method === 'GET') {
|
|
297
435
|
this.handleGetStats(res);
|
|
@@ -322,6 +460,25 @@ export class ThreatCloudServer {
|
|
|
322
460
|
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
323
461
|
}
|
|
324
462
|
break;
|
|
463
|
+
case '/api/atr-proposals/from-payload':
|
|
464
|
+
// External red-team input (garak, human pentest, etc.) — takes a
|
|
465
|
+
// raw attack payload, runs the TC drafter tool-use loop, returns
|
|
466
|
+
// the generated ATR YAML. Admin or static key only; partner keys
|
|
467
|
+
// intentionally cannot call this (LLM cost management).
|
|
468
|
+
if (req.method === 'POST') {
|
|
469
|
+
if (authRole !== 'admin' && authRole !== 'static') {
|
|
470
|
+
this.sendJson(res, 403, {
|
|
471
|
+
ok: false,
|
|
472
|
+
error: 'Admin or static API key required for drafter endpoint',
|
|
473
|
+
});
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
await this.handleDraftProposalFromPayload(req, res);
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
480
|
+
}
|
|
481
|
+
break;
|
|
325
482
|
case '/api/atr-feedback':
|
|
326
483
|
if (req.method === 'POST') {
|
|
327
484
|
await this.handlePostATRFeedback(req, res);
|
|
@@ -330,6 +487,64 @@ export class ThreatCloudServer {
|
|
|
330
487
|
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
331
488
|
}
|
|
332
489
|
break;
|
|
490
|
+
case '/api/rule-feedback':
|
|
491
|
+
if (req.method === 'POST') {
|
|
492
|
+
await this.handlePostRuleFeedback(req, res);
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
496
|
+
}
|
|
497
|
+
break;
|
|
498
|
+
case '/api/clients/register':
|
|
499
|
+
if (req.method === 'POST') {
|
|
500
|
+
await this.handleClientRegister(req, res, clientIP);
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
504
|
+
}
|
|
505
|
+
break;
|
|
506
|
+
case '/api/admin/client-keys':
|
|
507
|
+
if (!this.checkAdminAuth(req)) {
|
|
508
|
+
this.sendJson(res, 403, { ok: false, error: 'Admin API key required' });
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
if (req.method === 'GET') {
|
|
512
|
+
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
513
|
+
const limit = Math.min(200, Math.max(1, parseInt(params.get('limit') ?? '50', 10)));
|
|
514
|
+
const offset = Math.max(0, parseInt(params.get('offset') ?? '0', 10));
|
|
515
|
+
this.sendJson(res, 200, { ok: true, data: this.db.listClientKeys(limit, offset) });
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
519
|
+
}
|
|
520
|
+
break;
|
|
521
|
+
case '/api/admin/client-keys/revoke':
|
|
522
|
+
if (!this.checkAdminAuth(req)) {
|
|
523
|
+
this.sendJson(res, 403, { ok: false, error: 'Admin API key required' });
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
if (req.method === 'POST') {
|
|
527
|
+
await this.handleClientKeyRevoke(req, res);
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
531
|
+
}
|
|
532
|
+
break;
|
|
533
|
+
case '/api/admin/partner-keys':
|
|
534
|
+
// L5 partner provisioning. Admin-only. Issues a partner-tier
|
|
535
|
+
// client key that can access /api/atr-rules/live. Raw key is
|
|
536
|
+
// returned exactly once — the caller must store it.
|
|
537
|
+
if (!this.checkAdminAuth(req)) {
|
|
538
|
+
this.sendJson(res, 403, { ok: false, error: 'Admin API key required' });
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
if (req.method === 'POST') {
|
|
542
|
+
await this.handlePartnerKeyIssue(req, res);
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
546
|
+
}
|
|
547
|
+
break;
|
|
333
548
|
case '/api/skill-threats':
|
|
334
549
|
if (req.method === 'GET') {
|
|
335
550
|
if (!this.checkAdminAuth(req)) {
|
|
@@ -347,7 +562,7 @@ export class ThreatCloudServer {
|
|
|
347
562
|
break;
|
|
348
563
|
case '/api/atr-rules':
|
|
349
564
|
if (req.method === 'GET') {
|
|
350
|
-
this.handleGetATRRules(url, res);
|
|
565
|
+
this.handleGetATRRules(url, res, req);
|
|
351
566
|
}
|
|
352
567
|
else {
|
|
353
568
|
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
@@ -371,7 +586,7 @@ export class ThreatCloudServer {
|
|
|
371
586
|
break;
|
|
372
587
|
case '/api/skill-whitelist':
|
|
373
588
|
if (req.method === 'GET') {
|
|
374
|
-
this.handleGetSkillWhitelist(res);
|
|
589
|
+
this.handleGetSkillWhitelist(url, res);
|
|
375
590
|
}
|
|
376
591
|
else if (req.method === 'POST') {
|
|
377
592
|
await this.handlePostSkillWhitelist(req, res);
|
|
@@ -458,6 +673,14 @@ export class ThreatCloudServer {
|
|
|
458
673
|
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
459
674
|
}
|
|
460
675
|
break;
|
|
676
|
+
case '/api/version':
|
|
677
|
+
if (req.method === 'GET') {
|
|
678
|
+
this.handleGetVersion(res);
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
682
|
+
}
|
|
683
|
+
break;
|
|
461
684
|
case '/api/contributors':
|
|
462
685
|
if (req.method === 'GET') {
|
|
463
686
|
this.handleGetContributors(res);
|
|
@@ -482,6 +705,39 @@ export class ThreatCloudServer {
|
|
|
482
705
|
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
483
706
|
}
|
|
484
707
|
break;
|
|
708
|
+
case '/api/migrator/telemetry':
|
|
709
|
+
// Migrator-specific telemetry (per-rule fingerprints, no rule body).
|
|
710
|
+
// POST is open (any partner / install can submit); GET is admin-only.
|
|
711
|
+
if (req.method === 'POST') {
|
|
712
|
+
await this.handlePostMigratorTelemetry(req, res);
|
|
713
|
+
}
|
|
714
|
+
else if (req.method === 'GET') {
|
|
715
|
+
if (!this.checkAdminAuth(req)) {
|
|
716
|
+
this.sendJson(res, 403, { ok: false, error: 'Admin API key required' });
|
|
717
|
+
break;
|
|
718
|
+
}
|
|
719
|
+
await this.handleGetMigratorTelemetryStats(res);
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
723
|
+
}
|
|
724
|
+
break;
|
|
725
|
+
case '/api/migrator/crystallization-candidates':
|
|
726
|
+
// Surfaces fingerprints recurring across N+ tenants. Admin only.
|
|
727
|
+
// Output is metadata only (no rule body); admin uses this to
|
|
728
|
+
// identify which Migrator-derived rules to outreach for opt-in
|
|
729
|
+
// contribution back to ATR mainline.
|
|
730
|
+
if (req.method === 'GET') {
|
|
731
|
+
if (!this.checkAdminAuth(req)) {
|
|
732
|
+
this.sendJson(res, 403, { ok: false, error: 'Admin API key required' });
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
await this.handleGetMigratorCrystallizationCandidates(url, res);
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
739
|
+
}
|
|
740
|
+
break;
|
|
485
741
|
case '/api/usage':
|
|
486
742
|
if (req.method === 'POST') {
|
|
487
743
|
await this.handlePostUsageEvent(req, res);
|
|
@@ -498,6 +754,46 @@ export class ThreatCloudServer {
|
|
|
498
754
|
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
499
755
|
}
|
|
500
756
|
break;
|
|
757
|
+
case '/api/activations':
|
|
758
|
+
if (req.method === 'POST') {
|
|
759
|
+
const body = await this.readBody(req);
|
|
760
|
+
const data = JSON.parse(body);
|
|
761
|
+
if (!data.clientId) {
|
|
762
|
+
this.sendJson(res, 400, { ok: false, error: 'clientId required' });
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
this.db.recordActivation({
|
|
766
|
+
clientId: data.clientId,
|
|
767
|
+
platform: data.platform ?? 'unknown',
|
|
768
|
+
osType: data.osType ?? 'unknown',
|
|
769
|
+
panguardVersion: data.panguardVersion ?? 'unknown',
|
|
770
|
+
nodeVersion: data.nodeVersion ?? 'unknown',
|
|
771
|
+
});
|
|
772
|
+
this.sendJson(res, 200, { ok: true });
|
|
773
|
+
}
|
|
774
|
+
else if (req.method === 'GET') {
|
|
775
|
+
if (!this.checkAdminAuth(req)) {
|
|
776
|
+
this.sendJson(res, 403, { ok: false, error: 'Admin API key required' });
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
const activationStats = this.db.getActivationStats();
|
|
780
|
+
this.sendJson(res, 200, { ok: true, data: activationStats });
|
|
781
|
+
}
|
|
782
|
+
else {
|
|
783
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
784
|
+
}
|
|
785
|
+
break;
|
|
786
|
+
// ─── Org / Device / Policy endpoints (Threat Model #1, #6) ───
|
|
787
|
+
case '/api/devices/heartbeat':
|
|
788
|
+
// Requires API key (enforced by general auth gate above).
|
|
789
|
+
// Guard sends periodic heartbeats with device metadata.
|
|
790
|
+
if (req.method === 'POST') {
|
|
791
|
+
await this.handleDeviceHeartbeat(req, res);
|
|
792
|
+
}
|
|
793
|
+
else {
|
|
794
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
795
|
+
}
|
|
796
|
+
break;
|
|
501
797
|
case '/api/admin/reset-rules':
|
|
502
798
|
if (req.method === 'POST') {
|
|
503
799
|
if (!this.checkAdminAuth(req)) {
|
|
@@ -532,12 +828,71 @@ export class ThreatCloudServer {
|
|
|
532
828
|
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
533
829
|
}
|
|
534
830
|
break;
|
|
535
|
-
default:
|
|
831
|
+
default: {
|
|
832
|
+
// Org fleet/policy routes with path params
|
|
833
|
+
// Threat Model: Fleet view (#6), Policy engine (#1, #6)
|
|
834
|
+
const orgDevicesMatch = pathname.match(/^\/api\/orgs\/([^/]+)\/devices$/);
|
|
835
|
+
if (orgDevicesMatch) {
|
|
836
|
+
const orgId = decodeURIComponent(orgDevicesMatch[1]);
|
|
837
|
+
if (req.method === 'GET') {
|
|
838
|
+
if (!this.checkAdminAuth(req)) {
|
|
839
|
+
this.sendJson(res, 403, { ok: false, error: 'Admin API key required' });
|
|
840
|
+
break;
|
|
841
|
+
}
|
|
842
|
+
const devices = this.db.getDevicesByOrg(orgId);
|
|
843
|
+
const count = this.db.getDeviceCount(orgId);
|
|
844
|
+
this.sendJson(res, 200, { ok: true, data: { devices, total: count } });
|
|
845
|
+
}
|
|
846
|
+
else {
|
|
847
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
848
|
+
}
|
|
849
|
+
break;
|
|
850
|
+
}
|
|
851
|
+
const orgPoliciesMatch = pathname.match(/^\/api\/orgs\/([^/]+)\/policies$/);
|
|
852
|
+
if (orgPoliciesMatch) {
|
|
853
|
+
const orgId = decodeURIComponent(orgPoliciesMatch[1]);
|
|
854
|
+
if (!this.checkAdminAuth(req)) {
|
|
855
|
+
this.sendJson(res, 403, { ok: false, error: 'Admin API key required' });
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
if (req.method === 'GET') {
|
|
859
|
+
const policies = this.db.getOrgPolicies(orgId);
|
|
860
|
+
this.sendJson(res, 200, { ok: true, data: policies });
|
|
861
|
+
}
|
|
862
|
+
else if (req.method === 'POST') {
|
|
863
|
+
const body = await this.readBody(req);
|
|
864
|
+
const data = JSON.parse(body);
|
|
865
|
+
if (!data.category || !data.action || !['allow', 'block'].includes(data.action)) {
|
|
866
|
+
this.sendJson(res, 400, {
|
|
867
|
+
ok: false,
|
|
868
|
+
error: 'category and action (allow|block) required',
|
|
869
|
+
});
|
|
870
|
+
break;
|
|
871
|
+
}
|
|
872
|
+
this.db.setOrgPolicy(orgId, data.category, data.action);
|
|
873
|
+
this.sendJson(res, 200, { ok: true });
|
|
874
|
+
}
|
|
875
|
+
else if (req.method === 'DELETE') {
|
|
876
|
+
const body = await this.readBody(req);
|
|
877
|
+
const data = JSON.parse(body);
|
|
878
|
+
if (!data.category) {
|
|
879
|
+
this.sendJson(res, 400, { ok: false, error: 'category required' });
|
|
880
|
+
break;
|
|
881
|
+
}
|
|
882
|
+
const deleted = this.db.deleteOrgPolicy(orgId, data.category);
|
|
883
|
+
this.sendJson(res, 200, { ok: true, deleted });
|
|
884
|
+
}
|
|
885
|
+
else {
|
|
886
|
+
this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
887
|
+
}
|
|
888
|
+
break;
|
|
889
|
+
}
|
|
536
890
|
// Badge API handles /api/badge/* paths with dynamic segments
|
|
537
891
|
if (this.badgeRouter.handleRequest(pathname, req.method ?? 'GET', res)) {
|
|
538
892
|
break;
|
|
539
893
|
}
|
|
540
894
|
this.sendJson(res, 404, { ok: false, error: 'Not found' });
|
|
895
|
+
}
|
|
541
896
|
}
|
|
542
897
|
}
|
|
543
898
|
catch (err) {
|
|
@@ -590,6 +945,101 @@ export class ThreatCloudServer {
|
|
|
590
945
|
this.sendJson(res, 400, { ok: false, error: 'Invalid request body' });
|
|
591
946
|
}
|
|
592
947
|
}
|
|
948
|
+
/**
|
|
949
|
+
* POST /api/migrator/telemetry — record a Migrator run summary.
|
|
950
|
+
* Body shape matches MigratorTelemetryEvent from
|
|
951
|
+
* @panguard/migrator/telemetry/tc-reporter:
|
|
952
|
+
* { schema_version, install_id, migrator_version, run, rules[], frameworks }
|
|
953
|
+
* One event yields N rows in migrator_telemetry (one per rule).
|
|
954
|
+
* Carries fingerprints only — never rule body, never customer ID.
|
|
955
|
+
*/
|
|
956
|
+
async handlePostMigratorTelemetry(req, res) {
|
|
957
|
+
try {
|
|
958
|
+
const body = await this.readBody(req);
|
|
959
|
+
const data = JSON.parse(body);
|
|
960
|
+
if (!data.install_id || !Array.isArray(data.rules) || data.rules.length === 0) {
|
|
961
|
+
this.sendJson(res, 400, {
|
|
962
|
+
ok: false,
|
|
963
|
+
error: 'install_id and rules[] are required',
|
|
964
|
+
});
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const { recordMigratorTelemetry } = await import('./migrator-crystallization.js');
|
|
968
|
+
const result = recordMigratorTelemetry(this.db.getRawDb(), {
|
|
969
|
+
install_id: data.install_id,
|
|
970
|
+
migrator_version: data.migrator_version ?? 'unknown',
|
|
971
|
+
source_kind: data.run?.source_kind ?? 'sigma',
|
|
972
|
+
rules: data.rules.map((r) => ({
|
|
973
|
+
atr_id: r.atr_id,
|
|
974
|
+
category: r.category ?? 'unknown',
|
|
975
|
+
severity: r.severity ?? 'low',
|
|
976
|
+
has_agent_analogue: r.has_agent_analogue === true,
|
|
977
|
+
condition_hash: r.condition_hash,
|
|
978
|
+
framework_count: r.framework_count ?? 0,
|
|
979
|
+
})),
|
|
980
|
+
...(data.frameworks !== undefined ? { frameworks: data.frameworks } : {}),
|
|
981
|
+
});
|
|
982
|
+
this.sendJson(res, 200, { ok: true, data: { rows_inserted: result.rows_inserted } });
|
|
983
|
+
}
|
|
984
|
+
catch (err) {
|
|
985
|
+
this.sendJson(res, 400, {
|
|
986
|
+
ok: false,
|
|
987
|
+
error: err instanceof Error ? err.message : 'Invalid request body',
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
/** GET /api/migrator/telemetry — admin stats. */
|
|
992
|
+
async handleGetMigratorTelemetryStats(res) {
|
|
993
|
+
try {
|
|
994
|
+
const { getMigratorTelemetryStats } = await import('./migrator-crystallization.js');
|
|
995
|
+
const stats = getMigratorTelemetryStats(this.db.getRawDb());
|
|
996
|
+
this.sendJson(res, 200, { ok: true, data: stats });
|
|
997
|
+
}
|
|
998
|
+
catch (err) {
|
|
999
|
+
this.sendJson(res, 500, {
|
|
1000
|
+
ok: false,
|
|
1001
|
+
error: err instanceof Error ? err.message : 'failed',
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* GET /api/migrator/crystallization-candidates — admin only.
|
|
1007
|
+
* Query params:
|
|
1008
|
+
* - minTenants (default 3): minimum distinct install_ids on a fingerprint
|
|
1009
|
+
* - windowDays (default 30): observation window
|
|
1010
|
+
* - limit (default 100)
|
|
1011
|
+
*/
|
|
1012
|
+
async handleGetMigratorCrystallizationCandidates(rawUrl, res) {
|
|
1013
|
+
try {
|
|
1014
|
+
const { findCrystallizationCandidates } = await import('./migrator-crystallization.js');
|
|
1015
|
+
const parsed = new URL(rawUrl, 'http://localhost');
|
|
1016
|
+
const minTenants = Number(parsed.searchParams.get('minTenants') ?? '3');
|
|
1017
|
+
const windowDays = Number(parsed.searchParams.get('windowDays') ?? '30');
|
|
1018
|
+
const limit = Number(parsed.searchParams.get('limit') ?? '100');
|
|
1019
|
+
const minTenantCount = Number.isFinite(minTenants) ? minTenants : 3;
|
|
1020
|
+
const winDays = Number.isFinite(windowDays) ? windowDays : 30;
|
|
1021
|
+
const lim = Number.isFinite(limit) ? limit : 100;
|
|
1022
|
+
const candidates = findCrystallizationCandidates(this.db.getRawDb(), {
|
|
1023
|
+
minTenantCount,
|
|
1024
|
+
windowDays: winDays,
|
|
1025
|
+
limit: lim,
|
|
1026
|
+
});
|
|
1027
|
+
this.sendJson(res, 200, {
|
|
1028
|
+
ok: true,
|
|
1029
|
+
data: {
|
|
1030
|
+
candidates,
|
|
1031
|
+
query: { minTenants: minTenantCount, windowDays: winDays, limit: lim },
|
|
1032
|
+
total: candidates.length,
|
|
1033
|
+
},
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
catch (err) {
|
|
1037
|
+
this.sendJson(res, 500, {
|
|
1038
|
+
ok: false,
|
|
1039
|
+
error: err instanceof Error ? err.message : 'failed',
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
593
1043
|
/** POST /api/usage - Record usage event (scan, cli_install, etc.) */
|
|
594
1044
|
async handlePostUsageEvent(req, res) {
|
|
595
1045
|
try {
|
|
@@ -649,7 +1099,7 @@ export class ThreatCloudServer {
|
|
|
649
1099
|
});
|
|
650
1100
|
}
|
|
651
1101
|
/** GET /api/rules?since=<ISO>&category=<cat>&severity=<sev>&source=<src> */
|
|
652
|
-
handleGetRules(url, res) {
|
|
1102
|
+
handleGetRules(url, res, req) {
|
|
653
1103
|
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
654
1104
|
const since = params.get('since');
|
|
655
1105
|
const filters = {
|
|
@@ -663,7 +1113,32 @@ export class ThreatCloudServer {
|
|
|
663
1113
|
? this.db.getRulesSince(since, filters)
|
|
664
1114
|
: this.db.getAllRules(5000, filters);
|
|
665
1115
|
const ruleList = Array.isArray(rules) ? rules : [];
|
|
666
|
-
|
|
1116
|
+
// L5 runtime sync support: ETag + Last-Modified so partners can cheap-poll.
|
|
1117
|
+
// ETag = hash of (count + latest publishedAt) — cheap and changes when content does.
|
|
1118
|
+
// Last-Modified = max(publishedAt) in result set.
|
|
1119
|
+
let latestPublishedAt = '';
|
|
1120
|
+
for (const r of ruleList) {
|
|
1121
|
+
const p = typeof r.publishedAt === 'string' ? r.publishedAt : '';
|
|
1122
|
+
if (p > latestPublishedAt)
|
|
1123
|
+
latestPublishedAt = p;
|
|
1124
|
+
}
|
|
1125
|
+
const etag = `W/"${ruleList.length}-${latestPublishedAt}"`;
|
|
1126
|
+
res.setHeader('ETag', etag);
|
|
1127
|
+
if (latestPublishedAt) {
|
|
1128
|
+
try {
|
|
1129
|
+
res.setHeader('Last-Modified', new Date(latestPublishedAt).toUTCString());
|
|
1130
|
+
}
|
|
1131
|
+
catch {
|
|
1132
|
+
/* ignore malformed date */
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
const ifNoneMatch = req?.headers['if-none-match'];
|
|
1136
|
+
if (ifNoneMatch && ifNoneMatch === etag) {
|
|
1137
|
+
res.writeHead(304);
|
|
1138
|
+
res.end();
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
this.sendJson(res, 200, { ok: true, data: ruleList, meta: { total: ruleList.length, etag } });
|
|
667
1142
|
}
|
|
668
1143
|
/** POST /api/rules - Publish rules (single or batch) */
|
|
669
1144
|
async handlePostRule(req, res) {
|
|
@@ -696,6 +1171,131 @@ export class ThreatCloudServer {
|
|
|
696
1171
|
this.db.audit.logAction('admin', 'rule.create', 'rule', undefined, { count }, clientIP);
|
|
697
1172
|
this.sendJson(res, 201, { ok: true, data: { message: `${count} rule(s) published`, count } });
|
|
698
1173
|
}
|
|
1174
|
+
/**
|
|
1175
|
+
* POST /api/rules/sync — Admin-only endpoint for ATR repo CI to sync rules.
|
|
1176
|
+
* Requires admin API key. Only accepts source='atr' (community rules use POST /api/rules).
|
|
1177
|
+
* Body: { rules: [{ ruleId, ruleContent, source }] }. Max 200 per request.
|
|
1178
|
+
*/
|
|
1179
|
+
async handleSyncATRRules(req, res) {
|
|
1180
|
+
const body = await this.readBody(req);
|
|
1181
|
+
let raw;
|
|
1182
|
+
try {
|
|
1183
|
+
raw = JSON.parse(body);
|
|
1184
|
+
}
|
|
1185
|
+
catch {
|
|
1186
|
+
this.sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
const rawObj = raw;
|
|
1190
|
+
const rawRules = 'rules' in rawObj && Array.isArray(rawObj['rules']) ? rawObj['rules'] : [raw];
|
|
1191
|
+
if (rawRules.length > 200) {
|
|
1192
|
+
this.sendJson(res, 400, { ok: false, error: 'Maximum 200 rules per sync request' });
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
const now = new Date().toISOString();
|
|
1196
|
+
let count = 0;
|
|
1197
|
+
let skipped = 0;
|
|
1198
|
+
for (const rawRule of rawRules) {
|
|
1199
|
+
const result = tryValidateInput(RulePublishSchema, rawRule);
|
|
1200
|
+
if (!result.ok) {
|
|
1201
|
+
skipped++;
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1204
|
+
// Only allow source='atr' — community rules require admin key
|
|
1205
|
+
if (result.data.source !== 'atr') {
|
|
1206
|
+
skipped++;
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
const ruleData = {
|
|
1210
|
+
...result.data,
|
|
1211
|
+
publishedAt: result.data.publishedAt || now,
|
|
1212
|
+
};
|
|
1213
|
+
this.db.upsertRule(ruleData);
|
|
1214
|
+
count++;
|
|
1215
|
+
}
|
|
1216
|
+
const clientIP = req.socket.remoteAddress ?? 'unknown';
|
|
1217
|
+
this.db.audit.logAction('system', 'rule.sync', 'rule', undefined, { count, skipped }, clientIP);
|
|
1218
|
+
this.sendJson(res, 200, {
|
|
1219
|
+
ok: true,
|
|
1220
|
+
data: { message: `${count} rule(s) synced, ${skipped} skipped`, count, skipped },
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
/** POST /api/rules/bulk-delete — Admin-only delete by rule IDs */
|
|
1224
|
+
async handleBulkDeleteRules(req, res) {
|
|
1225
|
+
const body = await this.readBody(req);
|
|
1226
|
+
let raw;
|
|
1227
|
+
try {
|
|
1228
|
+
raw = JSON.parse(body);
|
|
1229
|
+
}
|
|
1230
|
+
catch {
|
|
1231
|
+
this.sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
const rawObj = raw;
|
|
1235
|
+
const ruleIds = rawObj['ruleIds'];
|
|
1236
|
+
if (!Array.isArray(ruleIds) || ruleIds.length === 0) {
|
|
1237
|
+
this.sendJson(res, 400, { ok: false, error: 'Missing or empty ruleIds array' });
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
if (ruleIds.length > 500) {
|
|
1241
|
+
this.sendJson(res, 400, { ok: false, error: 'Maximum 500 rule IDs per request' });
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
const count = this.db.deleteRulesByIds(ruleIds);
|
|
1245
|
+
const clientIP = req.socket.remoteAddress ?? 'unknown';
|
|
1246
|
+
this.db.audit.logAction('admin', 'rule.bulk-delete', 'rule', undefined, { count, requested: ruleIds.length }, clientIP);
|
|
1247
|
+
this.sendJson(res, 200, { ok: true, data: { message: `Deleted ${count} rule(s)`, count } });
|
|
1248
|
+
}
|
|
1249
|
+
/** DELETE /api/rules/by-source?source=yara — Admin-only bulk purge */
|
|
1250
|
+
/** POST /api/devices/heartbeat — Guard sends periodic device metadata */
|
|
1251
|
+
async handleDeviceHeartbeat(req, res) {
|
|
1252
|
+
const body = await this.readBody(req);
|
|
1253
|
+
let data;
|
|
1254
|
+
try {
|
|
1255
|
+
data = JSON.parse(body);
|
|
1256
|
+
}
|
|
1257
|
+
catch {
|
|
1258
|
+
this.sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
const deviceId = typeof data['deviceId'] === 'string' ? data['deviceId'].slice(0, 128) : '';
|
|
1262
|
+
const orgId = typeof data['orgId'] === 'string' ? data['orgId'].slice(0, 128) : '';
|
|
1263
|
+
if (!deviceId || !orgId) {
|
|
1264
|
+
this.sendJson(res, 400, { ok: false, error: 'deviceId and orgId required' });
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
// Auto-create org if it doesn't exist (first device creates the org)
|
|
1268
|
+
if (!this.db.getOrg(orgId)) {
|
|
1269
|
+
this.db.createOrg(orgId, orgId);
|
|
1270
|
+
}
|
|
1271
|
+
this.db.upsertDevice({
|
|
1272
|
+
deviceId,
|
|
1273
|
+
orgId,
|
|
1274
|
+
hostname: typeof data['hostname'] === 'string' ? data['hostname'].slice(0, 256) : undefined,
|
|
1275
|
+
osType: typeof data['osType'] === 'string' ? data['osType'].slice(0, 64) : undefined,
|
|
1276
|
+
agentCount: typeof data['agentCount'] === 'number' ? data['agentCount'] : undefined,
|
|
1277
|
+
guardVersion: typeof data['guardVersion'] === 'string' ? data['guardVersion'].slice(0, 32) : undefined,
|
|
1278
|
+
});
|
|
1279
|
+
this.sendJson(res, 200, { ok: true });
|
|
1280
|
+
}
|
|
1281
|
+
async handleDeleteRulesBySource(url, res) {
|
|
1282
|
+
const params = new URLSearchParams(url.split('?')[1] ?? '');
|
|
1283
|
+
const source = params.get('source');
|
|
1284
|
+
if (!source) {
|
|
1285
|
+
this.sendJson(res, 400, { ok: false, error: 'Missing ?source= parameter' });
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
// Safety: refuse to delete ATR rules via this endpoint
|
|
1289
|
+
if (source === 'atr' || source === 'atr-community') {
|
|
1290
|
+
this.sendJson(res, 400, { ok: false, error: 'Cannot purge ATR rules via this endpoint' });
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
const count = this.db.deleteRulesBySource(source);
|
|
1294
|
+
this.sendJson(res, 200, {
|
|
1295
|
+
ok: true,
|
|
1296
|
+
data: { message: `Deleted ${count} ${source} rule(s)`, count },
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
699
1299
|
/** GET /api/stats (cached 60s) */
|
|
700
1300
|
handleGetStats(res) {
|
|
701
1301
|
res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=60');
|
|
@@ -736,6 +1336,78 @@ export class ThreatCloudServer {
|
|
|
736
1336
|
const threats = this.db.getSkillThreats(limit);
|
|
737
1337
|
this.sendJson(res, 200, { ok: true, data: threats });
|
|
738
1338
|
}
|
|
1339
|
+
/**
|
|
1340
|
+
* POST /api/atr-proposals/from-payload — drafter endpoint for external
|
|
1341
|
+
* red-team input. Runs the TC tool-use drafter on the supplied attack
|
|
1342
|
+
* payload and returns the generated ATR YAML. Admin or static key only.
|
|
1343
|
+
*/
|
|
1344
|
+
async handleDraftProposalFromPayload(req, res) {
|
|
1345
|
+
if (!this.llmReviewer?.isAvailable()) {
|
|
1346
|
+
this.sendJson(res, 503, {
|
|
1347
|
+
ok: false,
|
|
1348
|
+
error: 'Drafter unavailable (LLM reviewer not configured — check ANTHROPIC_API_KEY and billing)',
|
|
1349
|
+
});
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
const body = await this.readBody(req);
|
|
1353
|
+
if (!body) {
|
|
1354
|
+
this.sendJson(res, 400, { ok: false, error: 'Request body required' });
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
let parsed;
|
|
1358
|
+
try {
|
|
1359
|
+
parsed = JSON.parse(body);
|
|
1360
|
+
}
|
|
1361
|
+
catch {
|
|
1362
|
+
this.sendJson(res, 400, { ok: false, error: 'Invalid JSON' });
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
const payload = typeof parsed.payload === 'string' ? parsed.payload : '';
|
|
1366
|
+
if (payload.length < 16) {
|
|
1367
|
+
this.sendJson(res, 400, {
|
|
1368
|
+
ok: false,
|
|
1369
|
+
error: 'payload required (min 16 chars, max 8000 — longer prompts are truncated)',
|
|
1370
|
+
});
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
if (payload.length > 8000) {
|
|
1374
|
+
this.sendJson(res, 400, {
|
|
1375
|
+
ok: false,
|
|
1376
|
+
error: 'payload too long (max 8000 chars)',
|
|
1377
|
+
});
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
const drafterResult = await this.llmReviewer.draftRuleFromPayload(payload, {
|
|
1381
|
+
probe: typeof parsed.probe === 'string' ? parsed.probe.slice(0, 128) : undefined,
|
|
1382
|
+
detector: typeof parsed.detector === 'string' ? parsed.detector.slice(0, 128) : undefined,
|
|
1383
|
+
targetModel: typeof parsed.targetModel === 'string' ? parsed.targetModel.slice(0, 128) : undefined,
|
|
1384
|
+
partnerName: typeof parsed.partnerName === 'string' ? parsed.partnerName.slice(0, 64) : undefined,
|
|
1385
|
+
severity: typeof parsed.severity === 'string' ? parsed.severity.slice(0, 16) : undefined,
|
|
1386
|
+
});
|
|
1387
|
+
if (!drafterResult) {
|
|
1388
|
+
// Not an error per se — drafter may legitimately decline (NO_THREATS_FOUND,
|
|
1389
|
+
// duplicate of existing coverage, quality gate failure). Surface that as
|
|
1390
|
+
// 200 + drafted:false so the caller can distinguish from auth/input errors.
|
|
1391
|
+
this.sendJson(res, 200, {
|
|
1392
|
+
ok: true,
|
|
1393
|
+
data: {
|
|
1394
|
+
drafted: false,
|
|
1395
|
+
reason: 'Drafter declined (NO_THREATS_FOUND, duplicate of existing rule, or failed quality gate / self-test — see server logs for specifics)',
|
|
1396
|
+
},
|
|
1397
|
+
});
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
this.sendJson(res, 201, {
|
|
1401
|
+
ok: true,
|
|
1402
|
+
data: {
|
|
1403
|
+
drafted: true,
|
|
1404
|
+
patternHash: drafterResult.patternHash,
|
|
1405
|
+
ruleContent: drafterResult.ruleContent,
|
|
1406
|
+
toolCalls: drafterResult.toolCalls,
|
|
1407
|
+
nextSteps: 'Proposal entered atr_proposals as pending. LLM self-review runs async; admin-approve or community consensus moves it to canary; safety gate + auto-merge publish to npm.',
|
|
1408
|
+
},
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
739
1411
|
/** POST /api/atr-proposals - Submit or confirm an ATR rule proposal */
|
|
740
1412
|
async handlePostATRProposal(req, res) {
|
|
741
1413
|
const data = await this.parseAndValidate(req, res, ATRProposalSchema);
|
|
@@ -793,7 +1465,154 @@ export class ThreatCloudServer {
|
|
|
793
1465
|
this.db.insertATRFeedback(data.ruleId, data.isTruePositive, clientId);
|
|
794
1466
|
this.sendJson(res, 201, { ok: true, data: { message: 'Feedback received' } });
|
|
795
1467
|
}
|
|
1468
|
+
/** POST /api/rule-feedback - Submit negative feedback on a canary/active rule, auto-quarantine at threshold */
|
|
1469
|
+
async handlePostRuleFeedback(req, res) {
|
|
1470
|
+
const body = await this.readBody(req);
|
|
1471
|
+
let raw;
|
|
1472
|
+
try {
|
|
1473
|
+
raw = JSON.parse(body);
|
|
1474
|
+
}
|
|
1475
|
+
catch {
|
|
1476
|
+
this.sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
const schema = z.object({
|
|
1480
|
+
ruleId: z.string().min(1),
|
|
1481
|
+
isTruePositive: z.boolean(),
|
|
1482
|
+
});
|
|
1483
|
+
const parsed = schema.safeParse(raw);
|
|
1484
|
+
if (!parsed.success) {
|
|
1485
|
+
this.sendJson(res, 400, { ok: false, error: parsed.error.message });
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
const clientId = req.headers['x-panguard-client-id'] ?? undefined;
|
|
1489
|
+
this.db.insertATRFeedback(parsed.data.ruleId, parsed.data.isTruePositive, clientId);
|
|
1490
|
+
// Auto-quarantine canary rules with > 3 negative reports
|
|
1491
|
+
if (!parsed.data.isTruePositive) {
|
|
1492
|
+
const negCount = this.db.getNegativeFeedbackCount(parsed.data.ruleId);
|
|
1493
|
+
if (negCount >= 3) {
|
|
1494
|
+
this.db.quarantineProposal(parsed.data.ruleId);
|
|
1495
|
+
log.info(`Rule ${parsed.data.ruleId} auto-quarantined after ${negCount} negative reports`);
|
|
1496
|
+
this.sendJson(res, 201, {
|
|
1497
|
+
ok: true,
|
|
1498
|
+
data: { message: 'Feedback received, rule quarantined', quarantined: true },
|
|
1499
|
+
});
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
this.sendJson(res, 201, {
|
|
1504
|
+
ok: true,
|
|
1505
|
+
data: { message: 'Feedback received', quarantined: false },
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
796
1508
|
/** POST /api/skill-threats - Submit skill threat from audit */
|
|
1509
|
+
/** POST /api/clients/register — auto-provision client API key */
|
|
1510
|
+
async handleClientRegister(req, res, clientIP) {
|
|
1511
|
+
// Rate limit: 10 registrations per hour per IP
|
|
1512
|
+
const entry = this.registrationRateLimits.get(clientIP);
|
|
1513
|
+
const now = Date.now();
|
|
1514
|
+
if (entry && now < entry.resetAt) {
|
|
1515
|
+
if (entry.count >= 10) {
|
|
1516
|
+
this.sendJson(res, 429, {
|
|
1517
|
+
ok: false,
|
|
1518
|
+
error: 'Registration rate limit exceeded. Try again later.',
|
|
1519
|
+
});
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
entry.count++;
|
|
1523
|
+
}
|
|
1524
|
+
else {
|
|
1525
|
+
this.registrationRateLimits.set(clientIP, { count: 1, resetAt: now + 3_600_000 });
|
|
1526
|
+
}
|
|
1527
|
+
const body = await this.readBody(req);
|
|
1528
|
+
if (!body) {
|
|
1529
|
+
this.sendJson(res, 400, { ok: false, error: 'Request body required' });
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
let parsed;
|
|
1533
|
+
try {
|
|
1534
|
+
parsed = JSON.parse(body);
|
|
1535
|
+
}
|
|
1536
|
+
catch {
|
|
1537
|
+
this.sendJson(res, 400, { ok: false, error: 'Invalid JSON' });
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
const clientId = parsed.clientId;
|
|
1541
|
+
if (!clientId || typeof clientId !== 'string' || clientId.length < 8 || clientId.length > 128) {
|
|
1542
|
+
this.sendJson(res, 400, { ok: false, error: 'clientId must be a string (8-128 chars)' });
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
const result = this.db.registerClientKey(clientId, clientIP);
|
|
1546
|
+
this.db.audit.logAction('system', 'client_key.register', 'client_key', clientId, { ip: clientIP }, clientIP);
|
|
1547
|
+
this.sendJson(res, 201, { ok: true, data: { clientKey: result.clientKey } });
|
|
1548
|
+
}
|
|
1549
|
+
/** POST /api/admin/client-keys/revoke — revoke client keys */
|
|
1550
|
+
async handleClientKeyRevoke(req, res) {
|
|
1551
|
+
const body = await this.readBody(req);
|
|
1552
|
+
if (!body) {
|
|
1553
|
+
this.sendJson(res, 400, { ok: false, error: 'Request body required' });
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
let parsed;
|
|
1557
|
+
try {
|
|
1558
|
+
parsed = JSON.parse(body);
|
|
1559
|
+
}
|
|
1560
|
+
catch {
|
|
1561
|
+
this.sendJson(res, 400, { ok: false, error: 'Invalid JSON' });
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
if (!parsed.clientId || typeof parsed.clientId !== 'string') {
|
|
1565
|
+
this.sendJson(res, 400, { ok: false, error: 'clientId required' });
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
const revoked = this.db.revokeClientKey(parsed.clientId);
|
|
1569
|
+
const clientIP = req.socket.remoteAddress ?? 'unknown';
|
|
1570
|
+
this.db.audit.logAction('admin', 'client_key.revoke', 'client_key', parsed.clientId, { revokedCount: revoked }, clientIP);
|
|
1571
|
+
this.sendJson(res, 200, { ok: true, data: { revoked } });
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* Admin-only: issue a partner-tier client key for L5 live-sync access.
|
|
1575
|
+
* Body: { partnerName: string, issuedBy?: string }
|
|
1576
|
+
* Returns raw key once — never retrievable again.
|
|
1577
|
+
*/
|
|
1578
|
+
async handlePartnerKeyIssue(req, res) {
|
|
1579
|
+
const body = await this.readBody(req);
|
|
1580
|
+
if (!body) {
|
|
1581
|
+
this.sendJson(res, 400, { ok: false, error: 'Request body required' });
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
let parsed;
|
|
1585
|
+
try {
|
|
1586
|
+
parsed = JSON.parse(body);
|
|
1587
|
+
}
|
|
1588
|
+
catch {
|
|
1589
|
+
this.sendJson(res, 400, { ok: false, error: 'Invalid JSON' });
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
const partnerName = typeof parsed.partnerName === 'string' ? parsed.partnerName.trim() : '';
|
|
1593
|
+
// Slug-like validation — no spaces, no colons (we already use `partner:` prefix internally)
|
|
1594
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}$/.test(partnerName)) {
|
|
1595
|
+
this.sendJson(res, 400, {
|
|
1596
|
+
ok: false,
|
|
1597
|
+
error: 'partnerName must be 2-64 chars, alphanumeric + dash/underscore only',
|
|
1598
|
+
});
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
const issuedBy = typeof parsed.issuedBy === 'string' ? parsed.issuedBy.slice(0, 64) : 'admin';
|
|
1602
|
+
const { clientKey } = this.db.registerPartnerKey(partnerName, issuedBy);
|
|
1603
|
+
const clientIP = req.socket.remoteAddress ?? 'unknown';
|
|
1604
|
+
this.db.audit.logAction('admin', 'partner_key.issue', 'client_key', `partner:${partnerName}`, { issuedBy }, clientIP);
|
|
1605
|
+
// The key is returned exactly once. The caller MUST store it.
|
|
1606
|
+
this.sendJson(res, 201, {
|
|
1607
|
+
ok: true,
|
|
1608
|
+
data: {
|
|
1609
|
+
partnerName,
|
|
1610
|
+
clientId: `partner:${partnerName}`,
|
|
1611
|
+
clientKey,
|
|
1612
|
+
note: 'Store this key now. It will not be retrievable again. Use as `Authorization: Bearer <key>` header on /api/atr-rules/live.',
|
|
1613
|
+
},
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
797
1616
|
async handlePostSkillThreat(req, res) {
|
|
798
1617
|
const data = await this.parseAndValidate(req, res, SkillThreatSchema);
|
|
799
1618
|
if (!data)
|
|
@@ -835,15 +1654,46 @@ export class ThreatCloudServer {
|
|
|
835
1654
|
.slice(0, 16);
|
|
836
1655
|
if (this.db.hasATRProposal(patternHash))
|
|
837
1656
|
return;
|
|
838
|
-
//
|
|
839
|
-
|
|
840
|
-
|
|
1657
|
+
// Fetch actual ATR rule detection patterns that triggered on this skill.
|
|
1658
|
+
// This is the key input for LLM: real regex patterns, not just finding titles.
|
|
1659
|
+
const triggeredRules = agg.atrRuleIds.length > 0 ? this.db.getRuleContentByIds(agg.atrRuleIds) : [];
|
|
1660
|
+
// Extract detection sections from rule YAML for LLM context
|
|
1661
|
+
const detectionPatterns = triggeredRules.map((r) => {
|
|
1662
|
+
// Extract detection block from YAML
|
|
1663
|
+
const detMatch = r.ruleContent.match(/detection:[\s\S]*?(?=\n[a-z]|\n---|$)/);
|
|
1664
|
+
const titleMatch = r.ruleContent.match(/title:\s*['"]?([^'"\n]+)/);
|
|
1665
|
+
return {
|
|
1666
|
+
ruleId: r.ruleId,
|
|
1667
|
+
title: titleMatch?.[1]?.trim() ?? r.ruleId,
|
|
1668
|
+
detection: detMatch?.[0]?.trim() ?? '(detection not parseable)',
|
|
1669
|
+
};
|
|
1670
|
+
});
|
|
1671
|
+
if (this.llmReviewer?.isAvailable() && detectionPatterns.length > 0) {
|
|
1672
|
+
// Build rich context: actual attack patterns, not just finding titles
|
|
1673
|
+
const patternContext = detectionPatterns
|
|
1674
|
+
.map((p) => `### ${p.ruleId}: ${p.title}\n${p.detection}`)
|
|
1675
|
+
.join('\n\n');
|
|
1676
|
+
const toolDescriptions = [
|
|
1677
|
+
{
|
|
1678
|
+
name: skillName,
|
|
1679
|
+
description: `This skill was flagged ${agg.reportCount} times (avg risk: ${Math.round(agg.avgRiskScore)}) by ${agg.atrRuleIds.length} distinct ATR rules.\n\nTriggered rules and their detection patterns:\n\n${patternContext}\n\nFinding titles: ${agg.findings.join('; ')}`,
|
|
1680
|
+
},
|
|
1681
|
+
];
|
|
1682
|
+
log.info(`Flywheel bridge: LLM generating ATR proposal for ${skillName} ` +
|
|
1683
|
+
`(${agg.reportCount} reports, ${agg.atrRuleIds.length} ATR rules: ${agg.atrRuleIds.join(', ')})`);
|
|
1684
|
+
void this.llmReviewer
|
|
1685
|
+
.analyzeSkills([{ package: skillName, tools: toolDescriptions }])
|
|
1686
|
+
.catch((err) => {
|
|
1687
|
+
log.error(`LLM analysis for skill bridge failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
else if (this.llmReviewer?.isAvailable()) {
|
|
1691
|
+
// LLM available but no rule patterns — pass findings as-is (legacy path)
|
|
841
1692
|
const toolDescriptions = agg.findings.map((f, i) => ({
|
|
842
1693
|
name: `finding_${i}`,
|
|
843
1694
|
description: f,
|
|
844
1695
|
}));
|
|
845
|
-
log.info(`Flywheel bridge: generating ATR proposal for ${skillName} (${agg.reportCount} reports
|
|
846
|
-
`飛輪橋接: 為 ${skillName} 產生 ATR 提案 (${agg.reportCount} 個報告)`);
|
|
1696
|
+
log.info(`Flywheel bridge: LLM generating ATR proposal for ${skillName} (${agg.reportCount} reports, no rule context)`);
|
|
847
1697
|
void this.llmReviewer
|
|
848
1698
|
.analyzeSkills([{ package: skillName, tools: toolDescriptions }])
|
|
849
1699
|
.catch((err) => {
|
|
@@ -851,61 +1701,37 @@ export class ThreatCloudServer {
|
|
|
851
1701
|
});
|
|
852
1702
|
}
|
|
853
1703
|
else {
|
|
854
|
-
// No LLM
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
const ruleContent = `title: "Community Consensus: ${agg.findings[0]?.slice(0, 60) ?? skillName}"
|
|
859
|
-
id: ATR-2026-DRAFT-${patternHash.slice(0, 8)}
|
|
860
|
-
status: draft
|
|
861
|
-
description: |
|
|
862
|
-
Auto-generated from ${agg.reportCount} independent threat reports for skill "${skillName}".
|
|
863
|
-
Avg risk score: ${Math.round(agg.avgRiskScore)}.
|
|
864
|
-
Findings: ${findingSummary.slice(0, 300)}
|
|
865
|
-
author: "Threat Cloud Auto-Bridge"
|
|
866
|
-
date: "${date}"
|
|
867
|
-
schema_version: "0.1"
|
|
868
|
-
detection_tier: community
|
|
869
|
-
maturity: experimental
|
|
870
|
-
severity: ${severity}
|
|
871
|
-
tags:
|
|
872
|
-
category: skill-compromise
|
|
873
|
-
subcategory: community-consensus
|
|
874
|
-
confidence: high
|
|
875
|
-
detection:
|
|
876
|
-
conditions:
|
|
877
|
-
- field: skill_manifest
|
|
878
|
-
operator: contains
|
|
879
|
-
value: "${skillName}"
|
|
880
|
-
description: "Skill reported by ${agg.reportCount} independent sources"
|
|
881
|
-
condition: any
|
|
882
|
-
response:
|
|
883
|
-
actions: [alert, snapshot]`;
|
|
884
|
-
this.db.insertATRProposal({
|
|
885
|
-
patternHash,
|
|
886
|
-
ruleContent,
|
|
887
|
-
llmProvider: 'community-bridge',
|
|
888
|
-
llmModel: 'aggregation',
|
|
889
|
-
selfReviewVerdict: JSON.stringify({
|
|
890
|
-
approved: true,
|
|
891
|
-
source: 'skill-threat-bridge',
|
|
892
|
-
skillName,
|
|
893
|
-
reportCount: agg.reportCount,
|
|
894
|
-
avgRiskScore: Math.round(agg.avgRiskScore),
|
|
895
|
-
}),
|
|
896
|
-
});
|
|
897
|
-
log.info(`Flywheel bridge: basic ATR proposal created for ${skillName} (${agg.reportCount} reports) / ` +
|
|
898
|
-
`飛輪橋接: 基礎 ATR 提案已建立 ${skillName}`);
|
|
1704
|
+
// No LLM — add to blacklist directly, don't generate garbage rules
|
|
1705
|
+
log.info(`Flywheel bridge: no LLM available, skipping rule generation for ${skillName} ` +
|
|
1706
|
+
`(${agg.reportCount} reports, avg risk ${Math.round(agg.avgRiskScore)}). ` +
|
|
1707
|
+
`Skill is already blacklisted via threat reports.`);
|
|
899
1708
|
}
|
|
900
1709
|
}
|
|
901
|
-
/** GET /api/atr-rules?since=<ISO> - Fetch
|
|
902
|
-
handleGetATRRules(url, res) {
|
|
1710
|
+
/** GET /api/atr-rules?since=<ISO> - Fetch promoted ATR rules (+ canary for 10% of clients) */
|
|
1711
|
+
handleGetATRRules(url, res, req) {
|
|
903
1712
|
res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=3600');
|
|
904
1713
|
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
905
1714
|
const since = params.get('since') ?? undefined;
|
|
906
1715
|
const rules = this.db.getConfirmedATRRules(since);
|
|
907
1716
|
const ruleList = Array.isArray(rules) ? rules : [];
|
|
908
|
-
|
|
1717
|
+
// Include canary rules for ~10% of clients based on client ID hash
|
|
1718
|
+
let includeCanary = false;
|
|
1719
|
+
if (req) {
|
|
1720
|
+
const clientId = req.headers['x-panguard-client-id'];
|
|
1721
|
+
if (clientId) {
|
|
1722
|
+
const hash = clientId.split('').reduce((acc, ch) => acc + ch.charCodeAt(0), 0);
|
|
1723
|
+
includeCanary = hash % 10 === 0;
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
if (includeCanary) {
|
|
1727
|
+
const canaryRules = this.db.getCanaryATRRules();
|
|
1728
|
+
ruleList.push(...canaryRules);
|
|
1729
|
+
}
|
|
1730
|
+
this.sendJson(res, 200, {
|
|
1731
|
+
ok: true,
|
|
1732
|
+
data: ruleList,
|
|
1733
|
+
meta: { total: ruleList.length, includesCanary: includeCanary },
|
|
1734
|
+
});
|
|
909
1735
|
}
|
|
910
1736
|
/** GET /api/feeds/ip-blocklist?minReputation=70 - IP blocklist feed (plain text) */
|
|
911
1737
|
handleGetIPBlocklist(url, res) {
|
|
@@ -952,9 +1778,11 @@ response:
|
|
|
952
1778
|
}
|
|
953
1779
|
this.sendJson(res, 201, { ok: true, data: { message: `${count} skill(s) reported`, count } });
|
|
954
1780
|
}
|
|
955
|
-
/** GET /api/skill-whitelist
|
|
956
|
-
handleGetSkillWhitelist(res) {
|
|
957
|
-
const
|
|
1781
|
+
/** GET /api/skill-whitelist?since=ISO — Fetch community-confirmed safe skills (incremental) */
|
|
1782
|
+
handleGetSkillWhitelist(url, res) {
|
|
1783
|
+
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
1784
|
+
const since = params.get('since') ?? undefined;
|
|
1785
|
+
const whitelist = this.db.getSkillWhitelist(since);
|
|
958
1786
|
this.sendJson(res, 200, { ok: true, data: whitelist });
|
|
959
1787
|
}
|
|
960
1788
|
/**
|
|
@@ -966,8 +1794,10 @@ response:
|
|
|
966
1794
|
const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
|
|
967
1795
|
const minReports = Number(params.get('minReports') ?? '3');
|
|
968
1796
|
const minAvgRisk = Number(params.get('minAvgRisk') ?? '70');
|
|
969
|
-
|
|
970
|
-
|
|
1797
|
+
const since = params.get('since') ?? undefined;
|
|
1798
|
+
// Shorter cache for incremental queries
|
|
1799
|
+
res.setHeader('Cache-Control', since ? 'public, max-age=60' : 'public, max-age=1800, s-maxage=1800');
|
|
1800
|
+
const blacklist = this.db.getSkillBlacklist(minReports, minAvgRisk, since);
|
|
971
1801
|
this.sendJson(res, 200, { ok: true, data: blacklist });
|
|
972
1802
|
}
|
|
973
1803
|
/** PATCH /api/atr-proposals - Admin approve/reject a proposal */
|
|
@@ -1013,6 +1843,19 @@ response:
|
|
|
1013
1843
|
async handlePostSkillBlacklist(req, res) {
|
|
1014
1844
|
const body = await this.readBody(req);
|
|
1015
1845
|
const data = JSON.parse(body);
|
|
1846
|
+
// Support bulk upload: { skills: [{ skillName, reason }] }
|
|
1847
|
+
if (Array.isArray(data.skills)) {
|
|
1848
|
+
let added = 0;
|
|
1849
|
+
for (const entry of data.skills) {
|
|
1850
|
+
if (entry.skillName) {
|
|
1851
|
+
this.db.addToBlacklist(entry.skillName, entry.reason || 'Bulk admin block');
|
|
1852
|
+
added++;
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
this.sendJson(res, 201, { ok: true, data: { added, total: data.skills.length } });
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
// Single skill upload
|
|
1016
1859
|
const { skillName, reason } = data;
|
|
1017
1860
|
if (!skillName) {
|
|
1018
1861
|
this.sendJson(res, 400, { ok: false, error: 'skillName required' });
|
|
@@ -1044,13 +1887,6 @@ response:
|
|
|
1044
1887
|
* Response: { ok: true, data: { analyzed, proposalsCreated, results } }
|
|
1045
1888
|
*/
|
|
1046
1889
|
async handleAnalyzeSkills(req, res) {
|
|
1047
|
-
if (!this.llmReviewer?.isAvailable()) {
|
|
1048
|
-
this.sendJson(res, 503, {
|
|
1049
|
-
ok: false,
|
|
1050
|
-
error: 'LLM reviewer not available — ANTHROPIC_API_KEY not configured on server',
|
|
1051
|
-
});
|
|
1052
|
-
return;
|
|
1053
|
-
}
|
|
1054
1890
|
const AnalyzeSkillsSchema = z.object({
|
|
1055
1891
|
skills: z
|
|
1056
1892
|
.array(z.object({
|
|
@@ -1066,26 +1902,161 @@ response:
|
|
|
1066
1902
|
const data = await this.parseAndValidate(req, res, AnalyzeSkillsSchema);
|
|
1067
1903
|
if (!data)
|
|
1068
1904
|
return;
|
|
1905
|
+
const { createHash } = await import('node:crypto');
|
|
1069
1906
|
const skills = data.skills;
|
|
1070
|
-
|
|
1907
|
+
// Compute content hash for each skill (deterministic: sorted tools, null-byte separated)
|
|
1908
|
+
const computeHash = (tools) => {
|
|
1909
|
+
const canonical = [...tools]
|
|
1910
|
+
.map((t) => `${t.name}\n${t.description}`)
|
|
1911
|
+
.sort()
|
|
1912
|
+
.join('\0');
|
|
1913
|
+
return createHash('sha256').update(canonical).digest('hex');
|
|
1914
|
+
};
|
|
1915
|
+
const allResults = [];
|
|
1916
|
+
const uncachedSkills = [];
|
|
1917
|
+
const uncachedMeta = [];
|
|
1918
|
+
log.info(`Analyzing ${skills.length} skills (checking cache + rug pull)`, {
|
|
1071
1919
|
packages: skills.map((s) => s.package),
|
|
1072
1920
|
});
|
|
1073
|
-
const
|
|
1074
|
-
|
|
1075
|
-
|
|
1921
|
+
for (const skill of skills) {
|
|
1922
|
+
const contentHash = computeHash(skill.tools);
|
|
1923
|
+
let rugPullDetected = false;
|
|
1924
|
+
// ── Rug pull check: has this skill's content changed? ──
|
|
1925
|
+
const previousHash = this.db.getLatestSkillHash(skill.package);
|
|
1926
|
+
// Save old verdict BEFORE marking superseded (getLatestSkillHash won't find it after)
|
|
1927
|
+
const previousVerdict = previousHash?.scanVerdict ?? null;
|
|
1928
|
+
if (previousHash && previousHash.contentHash !== contentHash) {
|
|
1929
|
+
rugPullDetected = true;
|
|
1930
|
+
this.db.markSkillHashSuperseded(skill.package, previousHash.contentHash, contentHash);
|
|
1931
|
+
log.info(`[RUG PULL] Candidate: ${skill.package} hash changed`, {
|
|
1932
|
+
oldHash: previousHash.contentHash.slice(0, 12),
|
|
1933
|
+
newHash: contentHash.slice(0, 12),
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1936
|
+
// ── Verdict cache check (skip if rug pull — force rescan) ──
|
|
1937
|
+
if (!rugPullDetected) {
|
|
1938
|
+
const cached = this.db.getVerdictCache(contentHash);
|
|
1939
|
+
if (cached) {
|
|
1940
|
+
let verdict;
|
|
1941
|
+
try {
|
|
1942
|
+
verdict = JSON.parse(cached.verdict);
|
|
1943
|
+
}
|
|
1944
|
+
catch {
|
|
1945
|
+
// Corrupted cache entry — invalidate and fall through to LLM scan
|
|
1946
|
+
this.db.invalidateVerdictCache(contentHash);
|
|
1947
|
+
uncachedSkills.push(skill);
|
|
1948
|
+
uncachedMeta.push({ contentHash, rugPull: false, previousVerdict: null });
|
|
1949
|
+
continue;
|
|
1950
|
+
}
|
|
1951
|
+
allResults.push({
|
|
1952
|
+
package: skill.package,
|
|
1953
|
+
threatsFound: verdict.threatsFound,
|
|
1954
|
+
proposalCount: verdict.proposalCount,
|
|
1955
|
+
patternHashes: verdict.patternHashes,
|
|
1956
|
+
status: verdict.status,
|
|
1957
|
+
cached: true,
|
|
1958
|
+
rugPullDetected: false,
|
|
1959
|
+
});
|
|
1960
|
+
// Update last_seen in hash history
|
|
1961
|
+
this.db.recordSkillHash({ skillName: skill.package, contentHash });
|
|
1962
|
+
continue;
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
// Cache miss or rug pull — needs LLM analysis
|
|
1966
|
+
uncachedSkills.push(skill);
|
|
1967
|
+
uncachedMeta.push({ contentHash, rugPull: rugPullDetected, previousVerdict });
|
|
1968
|
+
}
|
|
1969
|
+
// ── Run LLM analysis on uncached skills ──
|
|
1970
|
+
let proposalsCreated = 0;
|
|
1971
|
+
if (uncachedSkills.length > 0) {
|
|
1972
|
+
if (!this.llmReviewer?.isAvailable()) {
|
|
1973
|
+
// No LLM but we can still return cached results + record hashes
|
|
1974
|
+
for (let i = 0; i < uncachedSkills.length; i++) {
|
|
1975
|
+
const skill = uncachedSkills[i];
|
|
1976
|
+
const meta = uncachedMeta[i];
|
|
1977
|
+
this.db.recordSkillHash({
|
|
1978
|
+
skillName: skill.package,
|
|
1979
|
+
contentHash: meta.contentHash,
|
|
1980
|
+
rugPullFlag: meta.rugPull,
|
|
1981
|
+
});
|
|
1982
|
+
allResults.push({
|
|
1983
|
+
package: skill.package,
|
|
1984
|
+
threatsFound: false,
|
|
1985
|
+
proposalCount: 0,
|
|
1986
|
+
patternHashes: [],
|
|
1987
|
+
status: 'skipped_no_llm',
|
|
1988
|
+
cached: false,
|
|
1989
|
+
rugPullDetected: meta.rugPull,
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
else {
|
|
1994
|
+
const llmResults = await this.llmReviewer.analyzeSkills(uncachedSkills);
|
|
1995
|
+
for (let i = 0; i < llmResults.length; i++) {
|
|
1996
|
+
const r = llmResults[i];
|
|
1997
|
+
const meta = uncachedMeta[i];
|
|
1998
|
+
const verdictData = {
|
|
1999
|
+
threatsFound: r.threatsFound,
|
|
2000
|
+
proposalCount: r.proposals.length,
|
|
2001
|
+
patternHashes: r.proposals.map((p) => p.patternHash),
|
|
2002
|
+
status: r.status,
|
|
2003
|
+
};
|
|
2004
|
+
// Store in verdict cache
|
|
2005
|
+
this.db.insertVerdictCache({
|
|
2006
|
+
contentHash: meta.contentHash,
|
|
2007
|
+
skillName: r.package,
|
|
2008
|
+
verdict: JSON.stringify(verdictData),
|
|
2009
|
+
});
|
|
2010
|
+
// Record hash history
|
|
2011
|
+
this.db.recordSkillHash({
|
|
2012
|
+
skillName: r.package,
|
|
2013
|
+
contentHash: meta.contentHash,
|
|
2014
|
+
scanVerdict: JSON.stringify(verdictData),
|
|
2015
|
+
rugPullFlag: meta.rugPull,
|
|
2016
|
+
});
|
|
2017
|
+
// Rug pull HIGH alert: old was clean, new has threats
|
|
2018
|
+
if (meta.rugPull && r.threatsFound) {
|
|
2019
|
+
let prevClean = true;
|
|
2020
|
+
if (meta.previousVerdict) {
|
|
2021
|
+
try {
|
|
2022
|
+
prevClean = !JSON.parse(meta.previousVerdict)
|
|
2023
|
+
.threatsFound;
|
|
2024
|
+
}
|
|
2025
|
+
catch {
|
|
2026
|
+
/* treat as clean if unparseable */
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
if (prevClean) {
|
|
2030
|
+
log.info(`[RUG PULL CONFIRMED] ${r.package} went from clean to malicious`, {
|
|
2031
|
+
contentHash: meta.contentHash.slice(0, 12),
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
proposalsCreated += r.proposals.length;
|
|
2036
|
+
allResults.push({
|
|
2037
|
+
package: r.package,
|
|
2038
|
+
threatsFound: r.threatsFound,
|
|
2039
|
+
proposalCount: r.proposals.length,
|
|
2040
|
+
patternHashes: r.proposals.map((p) => p.patternHash),
|
|
2041
|
+
status: r.status,
|
|
2042
|
+
cached: false,
|
|
2043
|
+
rugPullDetected: meta.rugPull,
|
|
2044
|
+
...(r.errorReason ? { errorReason: r.errorReason } : {}),
|
|
2045
|
+
});
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
const cachedCount = allResults.filter((r) => r.cached).length;
|
|
2050
|
+
const rugPullCount = allResults.filter((r) => r.rugPullDetected).length;
|
|
2051
|
+
log.info(`Analysis complete: ${allResults.length} skills (${cachedCount} cached, ${rugPullCount} rug pull, ${proposalsCreated} proposals)`);
|
|
1076
2052
|
this.sendJson(res, 200, {
|
|
1077
2053
|
ok: true,
|
|
1078
2054
|
data: {
|
|
1079
|
-
analyzed:
|
|
2055
|
+
analyzed: allResults.length,
|
|
1080
2056
|
proposalsCreated,
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
proposalCount: r.proposals.length,
|
|
1085
|
-
patternHashes: r.proposals.map((p) => p.patternHash),
|
|
1086
|
-
status: r.status,
|
|
1087
|
-
...(r.errorReason ? { errorReason: r.errorReason } : {}),
|
|
1088
|
-
})),
|
|
2057
|
+
cachedCount,
|
|
2058
|
+
rugPullCount,
|
|
2059
|
+
results: allResults,
|
|
1089
2060
|
},
|
|
1090
2061
|
});
|
|
1091
2062
|
}
|
|
@@ -1174,6 +2145,37 @@ response:
|
|
|
1174
2145
|
data: { ...metrics, npmDownloads },
|
|
1175
2146
|
});
|
|
1176
2147
|
}
|
|
2148
|
+
/**
|
|
2149
|
+
* GET /api/version — public deploy verification endpoint.
|
|
2150
|
+
*
|
|
2151
|
+
* Reports the package version, server start time, uptime, Node version,
|
|
2152
|
+
* and (if running on Railway) the Railway deployment metadata. The
|
|
2153
|
+
* commit SHA is read from `RAILWAY_GIT_COMMIT_SHA` if Railway sets it,
|
|
2154
|
+
* or `APP_COMMIT_SHA` if the build pipeline injects it manually.
|
|
2155
|
+
*
|
|
2156
|
+
* Used by external tooling and CI to verify which commit is actually
|
|
2157
|
+
* running in production. Without this endpoint, deploy verification
|
|
2158
|
+
* relied on inspecting Railway's dashboard, which is not scriptable.
|
|
2159
|
+
*
|
|
2160
|
+
* Public, no auth, no rate-limit. Cached for 30 seconds.
|
|
2161
|
+
*/
|
|
2162
|
+
handleGetVersion(res) {
|
|
2163
|
+
res.setHeader('Cache-Control', 'public, max-age=30, s-maxage=30');
|
|
2164
|
+
const now = Date.now();
|
|
2165
|
+
const uptimeSeconds = Math.floor((now - SERVER_START_TIME.getTime()) / 1000);
|
|
2166
|
+
this.sendJson(res, 200, {
|
|
2167
|
+
ok: true,
|
|
2168
|
+
data: {
|
|
2169
|
+
version: TC_VERSION,
|
|
2170
|
+
commit: process.env['RAILWAY_GIT_COMMIT_SHA'] ?? process.env['APP_COMMIT_SHA'] ?? 'unknown',
|
|
2171
|
+
deploymentId: process.env['RAILWAY_DEPLOYMENT_ID'] ?? 'unknown',
|
|
2172
|
+
environment: process.env['RAILWAY_ENVIRONMENT_NAME'] ?? process.env['NODE_ENV'] ?? 'unknown',
|
|
2173
|
+
startedAt: SERVER_START_TIME.toISOString(),
|
|
2174
|
+
uptimeSeconds,
|
|
2175
|
+
nodeVersion: process.version,
|
|
2176
|
+
},
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
1177
2179
|
/** GET /api/contributors - Public leaderboard (hashed IDs, no PII) */
|
|
1178
2180
|
handleGetContributors(res) {
|
|
1179
2181
|
res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=3600');
|