@panguard-ai/threat-cloud 1.4.1 → 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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/dist/admin-dashboard.js +5 -5
  3. package/dist/audit-logger.d.ts +1 -1
  4. package/dist/audit-logger.d.ts.map +1 -1
  5. package/dist/audit-logger.js.map +1 -1
  6. package/dist/badge-api.d.ts +58 -0
  7. package/dist/badge-api.d.ts.map +1 -0
  8. package/dist/badge-api.js +248 -0
  9. package/dist/badge-api.js.map +1 -0
  10. package/dist/cli.js +1 -1
  11. package/dist/cli.js.map +1 -1
  12. package/dist/database.d.ts +254 -2
  13. package/dist/database.d.ts.map +1 -1
  14. package/dist/database.js +769 -72
  15. package/dist/database.js.map +1 -1
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/llm-reviewer-tools.d.ts +110 -0
  21. package/dist/llm-reviewer-tools.d.ts.map +1 -0
  22. package/dist/llm-reviewer-tools.js +446 -0
  23. package/dist/llm-reviewer-tools.js.map +1 -0
  24. package/dist/llm-reviewer.d.ts +54 -0
  25. package/dist/llm-reviewer.d.ts.map +1 -1
  26. package/dist/llm-reviewer.js +708 -64
  27. package/dist/llm-reviewer.js.map +1 -1
  28. package/dist/migrations.d.ts.map +1 -1
  29. package/dist/migrations.js +215 -0
  30. package/dist/migrations.js.map +1 -1
  31. package/dist/migrator-crystallization.d.ts +80 -0
  32. package/dist/migrator-crystallization.d.ts.map +1 -0
  33. package/dist/migrator-crystallization.js +108 -0
  34. package/dist/migrator-crystallization.js.map +1 -0
  35. package/dist/server.d.ts +75 -2
  36. package/dist/server.d.ts.map +1 -1
  37. package/dist/server.js +1249 -130
  38. package/dist/server.js.map +1 -1
  39. package/dist/types.d.ts +33 -0
  40. package/dist/types.d.ts.map +1 -1
  41. 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=)
@@ -16,8 +17,12 @@
16
17
  * - GET /api/skill-blacklist Community skill blacklist (aggregated threats)
17
18
  * - POST /api/analyze-skills Submit scan results for server-side LLM analysis
18
19
  * - GET /api/audit-log Admin audit log (paginated, admin-only)
20
+ * - POST /api/telemetry Record anonymous telemetry event from CLI
19
21
  * - POST /api/scan-events Report scan event from any source (bulk/CLI/web)
20
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)
24
+ * - GET /api/badge/:author/:skill ATR Scanned SVG badge for a skill
25
+ * - GET /api/badge/stats Badge statistics (JSON)
21
26
  * - GET /health Health check
22
27
  *
23
28
  * @module @panguard-ai/threat-cloud/server
@@ -27,7 +32,17 @@ import { readdirSync, readFileSync, statSync } from 'node:fs';
27
32
  import { join, relative, dirname } from 'node:path';
28
33
  import { fileURLToPath } from 'node:url';
29
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();
30
44
  import { ThreatCloudDB } from './database.js';
45
+ import { createBadgeRouter } from './badge-api.js';
31
46
  import { LLMReviewer } from './llm-reviewer.js';
32
47
  import { getAdminHTML } from './admin-dashboard.js';
33
48
  import { tryValidateInput, ThreatDataSchema, RulePublishSchema, ATRProposalSchema, ATRFeedbackSchema, SkillThreatSchema, SkillWhitelistItemSchema, } from '@panguard-ai/core';
@@ -56,8 +71,10 @@ export class ThreatCloudServer {
56
71
  db;
57
72
  config;
58
73
  llmReviewer;
74
+ badgeRouter;
59
75
  promotionTimer = null;
60
76
  rateLimits = new Map();
77
+ registrationRateLimits = new Map();
61
78
  rateLimitCleanupTimer = null;
62
79
  statsCache = null;
63
80
  /** Promotion interval: 2 minutes / 推廣間隔:2 分鐘 */
@@ -70,6 +87,10 @@ export class ThreatCloudServer {
70
87
  this.llmReviewer = config.anthropicApiKey
71
88
  ? new LLMReviewer(config.anthropicApiKey, this.db)
72
89
  : null;
90
+ // Badge API: reads ecosystem-report.csv from ATR data directory
91
+ const badgeCsvPath = process.env['ATR_ECOSYSTEM_CSV'] ??
92
+ join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', 'agent-threat-rules', 'data', 'clawhub-scan', 'ecosystem-report.csv');
93
+ this.badgeRouter = createBadgeRouter(badgeCsvPath);
73
94
  }
74
95
  /** Start the server / 啟動伺服器 */
75
96
  async start() {
@@ -82,19 +103,19 @@ export class ThreatCloudServer {
82
103
  if (this.llmReviewer) {
83
104
  log.info('LLM reviewer enabled for ATR proposal review');
84
105
  }
85
- // Auto-seed rules from bundled config/ if DB is empty (first startup)
106
+ // Auto-seed/update rules from bundled config/ on every startup
86
107
  const stats = this.db.getStats();
87
- if (stats.totalRules === 0) {
88
- log.info('First startup detected — seeding bundled rules...');
89
- try {
90
- const seeded = this.seedFromBundled();
91
- log.info(`Seeded ${seeded} rules into database`);
108
+ try {
109
+ const seeded = this.seedFromBundled();
110
+ if (seeded > 0) {
111
+ log.info(`Rules synced: ${seeded} upserted (was ${stats.totalRules})`);
92
112
  }
93
- catch (err) {
94
- log.error('Rule seeding failed', err);
113
+ else {
114
+ log.info(`Database: ${stats.totalRules} rules, ${stats.totalThreats} threats`);
95
115
  }
96
116
  }
97
- else {
117
+ catch (err) {
118
+ log.error('Rule seeding failed', err);
98
119
  log.info(`Database: ${stats.totalRules} rules, ${stats.totalThreats} threats`);
99
120
  }
100
121
  // Backfill classification for existing unclassified rules (one-time on startup)
@@ -110,10 +131,22 @@ export class ThreatCloudServer {
110
131
  // Start promotion + review cron (every 15 minutes)
111
132
  this.promotionTimer = setInterval(() => {
112
133
  try {
113
- // Step 1: Promote confirmed proposals to rules
114
- const promoted = this.db.promoteConfirmedProposals();
115
- if (promoted > 0) {
116
- log.info(`Promotion cycle: ${promoted} proposal(s) promoted to rules`);
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)`);
117
150
  }
118
151
  // Step 2: Retry LLM review for proposals that haven't been reviewed yet
119
152
  if (this.llmReviewer?.isAvailable()) {
@@ -121,18 +154,37 @@ export class ThreatCloudServer {
121
154
  log.error('Review retry failed', err);
122
155
  });
123
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
+ }
124
162
  }
125
163
  catch (err) {
126
164
  log.error('Promotion cycle failed', err);
127
165
  }
128
166
  }, ThreatCloudServer.PROMOTION_INTERVAL_MS);
129
- // Rate limiter cleanup (every 60s, purge expired entries)
167
+ // Rate limiter cleanup (every 60s, purge expired entries) + telemetry aggregation
130
168
  this.rateLimitCleanupTimer = setInterval(() => {
131
169
  const now = Date.now();
132
170
  for (const [ip, entry] of this.rateLimits) {
133
171
  if (now > entry.resetAt)
134
172
  this.rateLimits.delete(ip);
135
173
  }
174
+ for (const [ip, entry] of this.registrationRateLimits) {
175
+ if (now > entry.resetAt)
176
+ this.registrationRateLimits.delete(ip);
177
+ }
178
+ // Aggregate old telemetry events into hourly buckets
179
+ try {
180
+ const cleaned = this.db.cleanupTelemetryEvents();
181
+ if (cleaned > 0) {
182
+ log.info(`Telemetry cleanup: aggregated and deleted ${cleaned} raw events`);
183
+ }
184
+ }
185
+ catch (err) {
186
+ log.error('Telemetry cleanup failed', err);
187
+ }
136
188
  }, 60_000);
137
189
  resolve();
138
190
  });
@@ -189,10 +241,26 @@ export class ThreatCloudServer {
189
241
  : rawPathname === '/v1'
190
242
  ? '/'
191
243
  : rawPathname;
192
- if (pathname !== '/health' && this.config.apiKeyRequired) {
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) {
193
253
  const authHeader = req.headers.authorization ?? '';
194
254
  const token = authHeader.replace('Bearer ', '');
195
- if (!this.config.apiKeys.includes(token)) {
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) {
196
264
  this.sendJson(res, 401, { ok: false, error: 'Invalid API key', request_id: requestId });
197
265
  log.info('request', {
198
266
  method: req.method,
@@ -204,6 +272,36 @@ export class ThreatCloudServer {
204
272
  });
205
273
  return;
206
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
+ }
207
305
  }
208
306
  // CORS — restrict to known origins
209
307
  const allowedOrigins = (process.env['CORS_ALLOWED_ORIGINS'] ??
@@ -234,7 +332,11 @@ export class ThreatCloudServer {
234
332
  case '/health':
235
333
  this.sendJson(res, 200, {
236
334
  ok: true,
237
- data: { status: 'healthy', uptime: process.uptime() },
335
+ data: {
336
+ status: 'healthy',
337
+ uptime: process.uptime(),
338
+ schemaVersion: this.db.getSchemaVersion(),
339
+ },
238
340
  });
239
341
  break;
240
342
  case '/admin':
@@ -255,9 +357,25 @@ export class ThreatCloudServer {
255
357
  this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
256
358
  }
257
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;
258
376
  case '/api/rules':
259
377
  if (req.method === 'GET') {
260
- this.handleGetRules(url, res);
378
+ this.handleGetRules(url, res, req);
261
379
  }
262
380
  else if (req.method === 'POST') {
263
381
  if (!this.checkAdminAuth(req)) {
@@ -273,6 +391,45 @@ export class ThreatCloudServer {
273
391
  this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
274
392
  }
275
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;
276
433
  case '/api/stats':
277
434
  if (req.method === 'GET') {
278
435
  this.handleGetStats(res);
@@ -303,6 +460,25 @@ export class ThreatCloudServer {
303
460
  this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
304
461
  }
305
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;
306
482
  case '/api/atr-feedback':
307
483
  if (req.method === 'POST') {
308
484
  await this.handlePostATRFeedback(req, res);
@@ -311,6 +487,64 @@ export class ThreatCloudServer {
311
487
  this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
312
488
  }
313
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;
314
548
  case '/api/skill-threats':
315
549
  if (req.method === 'GET') {
316
550
  if (!this.checkAdminAuth(req)) {
@@ -328,7 +562,7 @@ export class ThreatCloudServer {
328
562
  break;
329
563
  case '/api/atr-rules':
330
564
  if (req.method === 'GET') {
331
- this.handleGetATRRules(url, res);
565
+ this.handleGetATRRules(url, res, req);
332
566
  }
333
567
  else {
334
568
  this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
@@ -352,7 +586,7 @@ export class ThreatCloudServer {
352
586
  break;
353
587
  case '/api/skill-whitelist':
354
588
  if (req.method === 'GET') {
355
- this.handleGetSkillWhitelist(res);
589
+ this.handleGetSkillWhitelist(url, res);
356
590
  }
357
591
  else if (req.method === 'POST') {
358
592
  await this.handlePostSkillWhitelist(req, res);
@@ -439,6 +673,14 @@ export class ThreatCloudServer {
439
673
  this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
440
674
  }
441
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;
442
684
  case '/api/contributors':
443
685
  if (req.method === 'GET') {
444
686
  this.handleGetContributors(res);
@@ -447,6 +689,55 @@ export class ThreatCloudServer {
447
689
  this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
448
690
  }
449
691
  break;
692
+ case '/api/telemetry':
693
+ if (req.method === 'POST') {
694
+ await this.handlePostTelemetry(req, res);
695
+ }
696
+ else if (req.method === 'GET') {
697
+ if (!this.checkAdminAuth(req)) {
698
+ this.sendJson(res, 403, { ok: false, error: 'Admin API key required' });
699
+ break;
700
+ }
701
+ const telemetryStats = this.db.getTelemetryStats();
702
+ this.sendJson(res, 200, { ok: true, data: telemetryStats });
703
+ }
704
+ else {
705
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
706
+ }
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;
450
741
  case '/api/usage':
451
742
  if (req.method === 'POST') {
452
743
  await this.handlePostUsageEvent(req, res);
@@ -463,6 +754,46 @@ export class ThreatCloudServer {
463
754
  this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
464
755
  }
465
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;
466
797
  case '/api/admin/reset-rules':
467
798
  if (req.method === 'POST') {
468
799
  if (!this.checkAdminAuth(req)) {
@@ -471,14 +802,97 @@ export class ThreatCloudServer {
471
802
  }
472
803
  const deleted = this.db.clearAllRules();
473
804
  log.info(`Admin reset: cleared ${deleted} rules and proposals`);
474
- this.sendJson(res, 200, { ok: true, data: { deleted, message: 'All rules and proposals cleared. Re-seed required.' } });
805
+ this.sendJson(res, 200, {
806
+ ok: true,
807
+ data: { deleted, message: 'All rules and proposals cleared. Re-seed required.' },
808
+ });
809
+ }
810
+ else {
811
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
812
+ }
813
+ break;
814
+ case '/api/admin/reseed':
815
+ if (req.method === 'POST') {
816
+ if (!this.checkAdminAuth(req)) {
817
+ this.sendJson(res, 403, { ok: false, error: 'Admin API key required' });
818
+ break;
819
+ }
820
+ const seeded = this.seedFromBundled();
821
+ log.info(`Admin reseed: ${seeded} rules upserted from bundled config`);
822
+ this.sendJson(res, 200, {
823
+ ok: true,
824
+ data: { seeded, message: `${seeded} rules upserted (new + updated)` },
825
+ });
475
826
  }
476
827
  else {
477
828
  this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
478
829
  }
479
830
  break;
480
- 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
+ }
890
+ // Badge API handles /api/badge/* paths with dynamic segments
891
+ if (this.badgeRouter.handleRequest(pathname, req.method ?? 'GET', res)) {
892
+ break;
893
+ }
481
894
  this.sendJson(res, 404, { ok: false, error: 'Not found' });
895
+ }
482
896
  }
483
897
  }
484
898
  catch (err) {
@@ -495,6 +909,137 @@ export class ThreatCloudServer {
495
909
  request_id: requestId,
496
910
  });
497
911
  }
912
+ /** POST /api/telemetry - Record anonymous telemetry event from CLI */
913
+ async handlePostTelemetry(req, res) {
914
+ try {
915
+ const body = await this.readBody(req);
916
+ const data = JSON.parse(body);
917
+ const eventType = data.event ?? 'unknown';
918
+ const allowedEvents = [
919
+ 'scan_local',
920
+ 'scan_local_json',
921
+ 'scan_remote',
922
+ 'scan_remote_json',
923
+ 'guard_audit',
924
+ 'guard_start',
925
+ 'skill_install',
926
+ 'skill_audit',
927
+ ];
928
+ if (!allowedEvents.includes(eventType)) {
929
+ this.sendJson(res, 400, {
930
+ ok: false,
931
+ error: `Unknown event. Allowed: ${allowedEvents.join(', ')}`,
932
+ });
933
+ return;
934
+ }
935
+ this.db.recordTelemetryEvent({
936
+ eventType,
937
+ platform: data.platform ?? 'unknown',
938
+ skillCount: data.skillCount ?? 0,
939
+ findingCount: data.findingCount ?? 0,
940
+ severity: data.severity ?? 'LOW',
941
+ });
942
+ this.sendJson(res, 200, { ok: true, data: { recorded: true } });
943
+ }
944
+ catch {
945
+ this.sendJson(res, 400, { ok: false, error: 'Invalid request body' });
946
+ }
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
+ }
498
1043
  /** POST /api/usage - Record usage event (scan, cli_install, etc.) */
499
1044
  async handlePostUsageEvent(req, res) {
500
1045
  try {
@@ -505,7 +1050,10 @@ export class ThreatCloudServer {
505
1050
  // Only allow known event types
506
1051
  const allowed = ['scan', 'cli_install', 'cli_setup', 'cli_scan', 'guard_start', 'page_view'];
507
1052
  if (!allowed.includes(eventType)) {
508
- this.sendJson(res, 400, { ok: false, error: `Unknown event_type. Allowed: ${allowed.join(', ')}` });
1053
+ this.sendJson(res, 400, {
1054
+ ok: false,
1055
+ error: `Unknown event_type. Allowed: ${allowed.join(', ')}`,
1056
+ });
509
1057
  return;
510
1058
  }
511
1059
  this.db.recordUsageEvent(eventType, source, data.metadata);
@@ -551,7 +1099,7 @@ export class ThreatCloudServer {
551
1099
  });
552
1100
  }
553
1101
  /** GET /api/rules?since=<ISO>&category=<cat>&severity=<sev>&source=<src> */
554
- handleGetRules(url, res) {
1102
+ handleGetRules(url, res, req) {
555
1103
  const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
556
1104
  const since = params.get('since');
557
1105
  const filters = {
@@ -565,7 +1113,32 @@ export class ThreatCloudServer {
565
1113
  ? this.db.getRulesSince(since, filters)
566
1114
  : this.db.getAllRules(5000, filters);
567
1115
  const ruleList = Array.isArray(rules) ? rules : [];
568
- this.sendJson(res, 200, { ok: true, data: ruleList, meta: { total: ruleList.length } });
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 } });
569
1142
  }
570
1143
  /** POST /api/rules - Publish rules (single or batch) */
571
1144
  async handlePostRule(req, res) {
@@ -598,10 +1171,135 @@ export class ThreatCloudServer {
598
1171
  this.db.audit.logAction('admin', 'rule.create', 'rule', undefined, { count }, clientIP);
599
1172
  this.sendJson(res, 201, { ok: true, data: { message: `${count} rule(s) published`, count } });
600
1173
  }
601
- /** GET /api/stats (cached 60s) */
602
- handleGetStats(res) {
603
- res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=60');
604
- const now = Date.now();
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
+ }
1299
+ /** GET /api/stats (cached 60s) */
1300
+ handleGetStats(res) {
1301
+ res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=60');
1302
+ const now = Date.now();
605
1303
  if (this.statsCache && now < this.statsCache.expiresAt) {
606
1304
  this.sendJson(res, 200, { ok: true, data: this.statsCache.data });
607
1305
  return;
@@ -638,6 +1336,78 @@ export class ThreatCloudServer {
638
1336
  const threats = this.db.getSkillThreats(limit);
639
1337
  this.sendJson(res, 200, { ok: true, data: threats });
640
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
+ }
641
1411
  /** POST /api/atr-proposals - Submit or confirm an ATR rule proposal */
642
1412
  async handlePostATRProposal(req, res) {
643
1413
  const data = await this.parseAndValidate(req, res, ATRProposalSchema);
@@ -695,7 +1465,154 @@ export class ThreatCloudServer {
695
1465
  this.db.insertATRFeedback(data.ruleId, data.isTruePositive, clientId);
696
1466
  this.sendJson(res, 201, { ok: true, data: { message: 'Feedback received' } });
697
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
+ }
698
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
+ }
699
1616
  async handlePostSkillThreat(req, res) {
700
1617
  const data = await this.parseAndValidate(req, res, SkillThreatSchema);
701
1618
  if (!data)
@@ -737,15 +1654,46 @@ export class ThreatCloudServer {
737
1654
  .slice(0, 16);
738
1655
  if (this.db.hasATRProposal(patternHash))
739
1656
  return;
740
- // If LLM reviewer is available, use it for high-quality rule generation
741
- if (this.llmReviewer?.isAvailable()) {
742
- // Build a synthetic tool description from aggregated findings
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)
743
1692
  const toolDescriptions = agg.findings.map((f, i) => ({
744
1693
  name: `finding_${i}`,
745
1694
  description: f,
746
1695
  }));
747
- log.info(`Flywheel bridge: generating ATR proposal for ${skillName} (${agg.reportCount} reports) / ` +
748
- `飛輪橋接: 為 ${skillName} 產生 ATR 提案 (${agg.reportCount} 個報告)`);
1696
+ log.info(`Flywheel bridge: LLM generating ATR proposal for ${skillName} (${agg.reportCount} reports, no rule context)`);
749
1697
  void this.llmReviewer
750
1698
  .analyzeSkills([{ package: skillName, tools: toolDescriptions }])
751
1699
  .catch((err) => {
@@ -753,61 +1701,37 @@ export class ThreatCloudServer {
753
1701
  });
754
1702
  }
755
1703
  else {
756
- // No LLM available scaffold a basic pattern-based proposal
757
- const severity = agg.maxRiskLevel === 'CRITICAL' ? 'critical' : 'high';
758
- const date = new Date().toISOString().slice(0, 10).replace(/-/g, '/');
759
- const findingSummary = agg.findings.slice(0, 5).join('; ');
760
- const ruleContent = `title: "Community Consensus: ${agg.findings[0]?.slice(0, 60) ?? skillName}"
761
- id: ATR-2026-DRAFT-${patternHash.slice(0, 8)}
762
- status: draft
763
- description: |
764
- Auto-generated from ${agg.reportCount} independent threat reports for skill "${skillName}".
765
- Avg risk score: ${Math.round(agg.avgRiskScore)}.
766
- Findings: ${findingSummary.slice(0, 300)}
767
- author: "Threat Cloud Auto-Bridge"
768
- date: "${date}"
769
- schema_version: "0.1"
770
- detection_tier: community
771
- maturity: experimental
772
- severity: ${severity}
773
- tags:
774
- category: skill-compromise
775
- subcategory: community-consensus
776
- confidence: high
777
- detection:
778
- conditions:
779
- - field: skill_manifest
780
- operator: contains
781
- value: "${skillName}"
782
- description: "Skill reported by ${agg.reportCount} independent sources"
783
- condition: any
784
- response:
785
- actions: [alert, snapshot]`;
786
- this.db.insertATRProposal({
787
- patternHash,
788
- ruleContent,
789
- llmProvider: 'community-bridge',
790
- llmModel: 'aggregation',
791
- selfReviewVerdict: JSON.stringify({
792
- approved: true,
793
- source: 'skill-threat-bridge',
794
- skillName,
795
- reportCount: agg.reportCount,
796
- avgRiskScore: Math.round(agg.avgRiskScore),
797
- }),
798
- });
799
- log.info(`Flywheel bridge: basic ATR proposal created for ${skillName} (${agg.reportCount} reports) / ` +
800
- `飛輪橋接: 基礎 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.`);
801
1708
  }
802
1709
  }
803
- /** GET /api/atr-rules?since=<ISO> - Fetch confirmed/promoted ATR rules */
804
- handleGetATRRules(url, res) {
1710
+ /** GET /api/atr-rules?since=<ISO> - Fetch promoted ATR rules (+ canary for 10% of clients) */
1711
+ handleGetATRRules(url, res, req) {
805
1712
  res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=3600');
806
1713
  const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
807
1714
  const since = params.get('since') ?? undefined;
808
1715
  const rules = this.db.getConfirmedATRRules(since);
809
1716
  const ruleList = Array.isArray(rules) ? rules : [];
810
- this.sendJson(res, 200, { ok: true, data: ruleList, meta: { total: ruleList.length } });
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
+ });
811
1735
  }
812
1736
  /** GET /api/feeds/ip-blocklist?minReputation=70 - IP blocklist feed (plain text) */
813
1737
  handleGetIPBlocklist(url, res) {
@@ -854,9 +1778,11 @@ response:
854
1778
  }
855
1779
  this.sendJson(res, 201, { ok: true, data: { message: `${count} skill(s) reported`, count } });
856
1780
  }
857
- /** GET /api/skill-whitelist - Fetch community-confirmed safe skills */
858
- handleGetSkillWhitelist(res) {
859
- const whitelist = this.db.getSkillWhitelist();
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);
860
1786
  this.sendJson(res, 200, { ok: true, data: whitelist });
861
1787
  }
862
1788
  /**
@@ -868,8 +1794,10 @@ response:
868
1794
  const params = new URL(url, `http://localhost:${this.config.port}`).searchParams;
869
1795
  const minReports = Number(params.get('minReports') ?? '3');
870
1796
  const minAvgRisk = Number(params.get('minAvgRisk') ?? '70');
871
- res.setHeader('Cache-Control', 'public, max-age=1800, s-maxage=1800');
872
- const blacklist = this.db.getSkillBlacklist(minReports, minAvgRisk);
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);
873
1801
  this.sendJson(res, 200, { ok: true, data: blacklist });
874
1802
  }
875
1803
  /** PATCH /api/atr-proposals - Admin approve/reject a proposal */
@@ -883,7 +1811,10 @@ response:
883
1811
  }
884
1812
  if (action === 'approve') {
885
1813
  const ok = this.db.approveATRProposal(patternHash);
886
- this.sendJson(res, ok ? 200 : 404, { ok, data: { message: ok ? 'Proposal approved and promoted' : 'Proposal not found' } });
1814
+ this.sendJson(res, ok ? 200 : 404, {
1815
+ ok,
1816
+ data: { message: ok ? 'Proposal approved and promoted' : 'Proposal not found' },
1817
+ });
887
1818
  }
888
1819
  else if (action === 'reject') {
889
1820
  this.db.rejectATRProposal(patternHash);
@@ -903,12 +1834,28 @@ response:
903
1834
  return;
904
1835
  }
905
1836
  const ok = this.db.removeFromWhitelist(skillName);
906
- this.sendJson(res, ok ? 200 : 404, { ok, data: { message: ok ? 'Removed from whitelist' : 'Not found' } });
1837
+ this.sendJson(res, ok ? 200 : 404, {
1838
+ ok,
1839
+ data: { message: ok ? 'Removed from whitelist' : 'Not found' },
1840
+ });
907
1841
  }
908
1842
  /** POST /api/skill-blacklist - Admin add a skill to blacklist */
909
1843
  async handlePostSkillBlacklist(req, res) {
910
1844
  const body = await this.readBody(req);
911
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
912
1859
  const { skillName, reason } = data;
913
1860
  if (!skillName) {
914
1861
  this.sendJson(res, 400, { ok: false, error: 'skillName required' });
@@ -927,7 +1874,10 @@ response:
927
1874
  return;
928
1875
  }
929
1876
  const ok = this.db.removeFromBlacklist(skillHash);
930
- this.sendJson(res, ok ? 200 : 404, { ok, data: { message: ok ? 'Removed from blacklist' : 'Not found' } });
1877
+ this.sendJson(res, ok ? 200 : 404, {
1878
+ ok,
1879
+ data: { message: ok ? 'Removed from blacklist' : 'Not found' },
1880
+ });
931
1881
  }
932
1882
  /**
933
1883
  * POST /api/analyze-skills - Submit scan results for server-side LLM analysis
@@ -937,13 +1887,6 @@ response:
937
1887
  * Response: { ok: true, data: { analyzed, proposalsCreated, results } }
938
1888
  */
939
1889
  async handleAnalyzeSkills(req, res) {
940
- if (!this.llmReviewer?.isAvailable()) {
941
- this.sendJson(res, 503, {
942
- ok: false,
943
- error: 'LLM reviewer not available — ANTHROPIC_API_KEY not configured on server',
944
- });
945
- return;
946
- }
947
1890
  const AnalyzeSkillsSchema = z.object({
948
1891
  skills: z
949
1892
  .array(z.object({
@@ -959,26 +1902,161 @@ response:
959
1902
  const data = await this.parseAndValidate(req, res, AnalyzeSkillsSchema);
960
1903
  if (!data)
961
1904
  return;
1905
+ const { createHash } = await import('node:crypto');
962
1906
  const skills = data.skills;
963
- log.info(`Analyzing ${skills.length} skills with LLM`, {
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)`, {
964
1919
  packages: skills.map((s) => s.package),
965
1920
  });
966
- const results = await this.llmReviewer.analyzeSkills(skills);
967
- const proposalsCreated = results.reduce((sum, r) => sum + r.proposals.length, 0);
968
- log.info(`LLM analysis complete: ${proposalsCreated} proposals from ${skills.length} skills`);
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)`);
969
2052
  this.sendJson(res, 200, {
970
2053
  ok: true,
971
2054
  data: {
972
- analyzed: results.length,
2055
+ analyzed: allResults.length,
973
2056
  proposalsCreated,
974
- results: results.map((r) => ({
975
- package: r.package,
976
- threatsFound: r.threatsFound,
977
- proposalCount: r.proposals.length,
978
- patternHashes: r.proposals.map((p) => p.patternHash),
979
- status: r.status,
980
- ...(r.errorReason ? { errorReason: r.errorReason } : {}),
981
- })),
2057
+ cachedCount,
2058
+ rugPullCount,
2059
+ results: allResults,
982
2060
  },
983
2061
  });
984
2062
  }
@@ -1039,10 +2117,64 @@ response:
1039
2117
  this.sendJson(res, 201, { ok: true, data: { message: 'Scan event recorded' } });
1040
2118
  }
1041
2119
  /** GET /api/metrics - Aggregated metrics across all sources (public, cached 60s) */
1042
- handleGetMetrics(res) {
2120
+ async handleGetMetrics(res) {
1043
2121
  res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=60');
1044
- const metrics = this.db.getAggregatedMetrics();
1045
- this.sendJson(res, 200, { ok: true, data: metrics });
2122
+ let metrics;
2123
+ try {
2124
+ metrics = this.db.getAggregatedMetrics();
2125
+ }
2126
+ catch (err) {
2127
+ log.error('getAggregatedMetrics failed', err);
2128
+ this.sendJson(res, 500, { ok: false, error: 'metrics query failed' });
2129
+ return;
2130
+ }
2131
+ // Fetch npm downloads (cached in statsCache to avoid hammering npm API)
2132
+ let npmDownloads = 0;
2133
+ try {
2134
+ const npmRes = await fetch('https://api.npmjs.org/downloads/point/last-month/@panguard-ai/panguard', { signal: AbortSignal.timeout(3000) });
2135
+ if (npmRes.ok) {
2136
+ const npmData = (await npmRes.json());
2137
+ npmDownloads = npmData.downloads ?? 0;
2138
+ }
2139
+ }
2140
+ catch {
2141
+ // npm API unreachable — use 0
2142
+ }
2143
+ this.sendJson(res, 200, {
2144
+ ok: true,
2145
+ data: { ...metrics, npmDownloads },
2146
+ });
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
+ });
1046
2178
  }
1047
2179
  /** GET /api/contributors - Public leaderboard (hashed IDs, no PII) */
1048
2180
  handleGetContributors(res) {
@@ -1068,23 +2200,10 @@ response:
1068
2200
  return ip;
1069
2201
  }
1070
2202
  /** Serve admin dashboard HTML -- requires admin auth via query param or header */
1071
- serveAdminDashboard(req, res) {
1072
- // Server-side auth: require admin key via ?key= param or Authorization header
1073
- if (this.config.adminApiKey) {
1074
- const url = new URL(req.url ?? '/', `http://localhost:${this.config.port}`);
1075
- const queryKey = url.searchParams.get('key');
1076
- const headerKey = (req.headers.authorization ?? '').replace('Bearer ', '');
1077
- const keyMatch = (candidate) => {
1078
- if (!candidate || candidate.length !== this.config.adminApiKey.length)
1079
- return false;
1080
- return timingSafeEqual(Buffer.from(candidate, 'utf-8'), Buffer.from(this.config.adminApiKey, 'utf-8'));
1081
- };
1082
- if (!keyMatch(queryKey) && !keyMatch(headerKey)) {
1083
- res.writeHead(401, { 'Content-Type': 'text/plain' });
1084
- res.end('Unauthorized: admin API key required. Use ?key=YOUR_KEY or Authorization header.');
1085
- return;
1086
- }
1087
- }
2203
+ serveAdminDashboard(_req, res) {
2204
+ // Admin HTML is safe to serve publicly it contains no sensitive data.
2205
+ // Authentication happens client-side: the login form collects the API key,
2206
+ // which is then sent as Authorization header on every API call.
1088
2207
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
1089
2208
  res.setHeader('Cache-Control', 'no-store');
1090
2209
  res.setHeader('X-Robots-Tag', 'noindex, nofollow');