@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/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: Promote confirmed proposals to rules
123
- const promoted = this.db.promoteConfirmedProposals();
124
- if (promoted > 0) {
125
- 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)`);
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
- 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) {
212
253
  const authHeader = req.headers.authorization ?? '';
213
254
  const token = authHeader.replace('Bearer ', '');
214
- 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) {
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: { status: 'healthy', uptime: process.uptime() },
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
- 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 } });
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
- // If LLM reviewer is available, use it for high-quality rule generation
839
- if (this.llmReviewer?.isAvailable()) {
840
- // 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)
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 available scaffold a basic pattern-based proposal
855
- const severity = agg.maxRiskLevel === 'CRITICAL' ? 'critical' : 'high';
856
- const date = new Date().toISOString().slice(0, 10).replace(/-/g, '/');
857
- const findingSummary = agg.findings.slice(0, 5).join('; ');
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 confirmed/promoted ATR rules */
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
- 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
+ });
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 - Fetch community-confirmed safe skills */
956
- handleGetSkillWhitelist(res) {
957
- 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);
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
- res.setHeader('Cache-Control', 'public, max-age=1800, s-maxage=1800');
970
- 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);
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
- 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)`, {
1071
1919
  packages: skills.map((s) => s.package),
1072
1920
  });
1073
- const results = await this.llmReviewer.analyzeSkills(skills);
1074
- const proposalsCreated = results.reduce((sum, r) => sum + r.proposals.length, 0);
1075
- 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)`);
1076
2052
  this.sendJson(res, 200, {
1077
2053
  ok: true,
1078
2054
  data: {
1079
- analyzed: results.length,
2055
+ analyzed: allResults.length,
1080
2056
  proposalsCreated,
1081
- results: results.map((r) => ({
1082
- package: r.package,
1083
- threatsFound: r.threatsFound,
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');