@panguard-ai/threat-cloud 1.5.6 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -27,11 +27,12 @@
27
27
  *
28
28
  * @module @panguard-ai/threat-cloud/server
29
29
  */
30
+ import * as Sentry from '@sentry/node';
30
31
  import { createServer } from 'node:http';
31
32
  import { readdirSync, readFileSync, statSync } from 'node:fs';
32
33
  import { join, relative, dirname } from 'node:path';
33
34
  import { fileURLToPath } from 'node:url';
34
- import { randomUUID, timingSafeEqual } from 'node:crypto';
35
+ import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
35
36
  import { createRequire } from 'node:module';
36
37
  // Read package.json version (for /api/version endpoint, deploy verification)
37
38
  const _require = createRequire(import.meta.url);
@@ -45,13 +46,43 @@ import { ThreatCloudDB } from './database.js';
45
46
  import { createBadgeRouter } from './badge-api.js';
46
47
  import { LLMReviewer } from './llm-reviewer.js';
47
48
  import { getAdminHTML } from './admin-dashboard.js';
49
+ import { resolveTier, isTcTier, TIER_LIMITS, } from './auth/tier-resolver.js';
48
50
  import { tryValidateInput, ThreatDataSchema, RulePublishSchema, ATRProposalSchema, ATRFeedbackSchema, SkillThreatSchema, SkillWhitelistItemSchema, } from '@panguard-ai/core';
51
+ import { parseATRRule, validateRuleMeetsStandard } from '@panguard-ai/atr/quality';
49
52
  import { z } from 'zod';
53
+ // Sentry init — no-op when SENTRY_DSN is unset so paid customers can opt out.
54
+ // Must run before request handlers register so early errors are captured.
55
+ if (process.env['SENTRY_DSN']) {
56
+ Sentry.init({
57
+ dsn: process.env['SENTRY_DSN'],
58
+ tracesSampleRate: 0.1,
59
+ release: process.env['GIT_SHA'] ?? 'dev',
60
+ environment: process.env['NODE_ENV'] ?? 'production',
61
+ });
62
+ }
63
+ /**
64
+ * Grace window for vote weight of pre-migration anonymous client keys.
65
+ *
66
+ * Migration v15 introduced the trust-tier model and gave all NEW anonymous
67
+ * keys (`/api/clients/register` without GitHub binding) a vote weight of 0,
68
+ * which would have stalled every in-flight community proposal at the moment
69
+ * an operator deployed the upgrade. To smooth the transition, every key
70
+ * that existed when v15 ran was retagged as `anonymous_legacy` and counts
71
+ * for 0.25 weight per vote — until this date. After this date, legacy and
72
+ * new anonymous keys behave identically (weight 0).
73
+ *
74
+ * The window is 90 days from the v15 deploy date. After the window ends,
75
+ * removing this constant entirely is safe — the weight will already be 0.
76
+ */
77
+ const LEGACY_ANONYMOUS_GRACE_END_ISO = '2026-08-19T00:00:00Z';
50
78
  /** Structured JSON logger for threat-cloud */
51
79
  const log = {
52
80
  info: (msg, extra) => {
53
81
  process.stdout.write(JSON.stringify({ ts: new Date().toISOString(), level: 'info', msg, ...extra }) + '\n');
54
82
  },
83
+ warn: (msg, extra) => {
84
+ process.stderr.write(JSON.stringify({ ts: new Date().toISOString(), level: 'warn', msg, ...extra }) + '\n');
85
+ },
55
86
  error: (msg, err, extra) => {
56
87
  process.stderr.write(JSON.stringify({
57
88
  ts: new Date().toISOString(),
@@ -128,6 +159,45 @@ export class ThreatCloudServer {
128
159
  catch (err) {
129
160
  log.error('Classification backfill failed', err);
130
161
  }
162
+ // Reject malformed promoted proposals (one-time on startup, idempotent).
163
+ // 2026-05-26 the PR-back cron surfaced 14 promoted rows that didn't
164
+ // meet the Cisco-quality bar; subsequent measurement showed 41 such
165
+ // rows across the entire DB. They landed before the POST gate
166
+ // existed (339fd410). The GET filter (33dc2c56) already hides them
167
+ // from consumers; this pass marks the DB row as 'rejected' so the
168
+ // forensic record is preserved while keeping promoted/* clean.
169
+ //
170
+ // Uses the same parseATRRule + validateRuleMeetsStandard('experimental')
171
+ // gate POST and GET enforce — idempotent (re-running rejects 0).
172
+ // Wrapped in try/catch so a parse failure can't crash boot.
173
+ try {
174
+ const promotedList = this.db.getConfirmedATRRules();
175
+ let rejected = 0;
176
+ const rejectedIds = [];
177
+ for (const rule of promotedList) {
178
+ try {
179
+ const metadata = parseATRRule(rule.ruleContent);
180
+ const gate = validateRuleMeetsStandard(metadata, 'experimental');
181
+ if (!gate.passed) {
182
+ this.db.rejectATRProposal(rule.ruleId);
183
+ rejected += 1;
184
+ rejectedIds.push(rule.ruleId);
185
+ }
186
+ }
187
+ catch {
188
+ // Unparseable YAML — same outcome as failing the gate.
189
+ this.db.rejectATRProposal(rule.ruleId);
190
+ rejected += 1;
191
+ rejectedIds.push(rule.ruleId);
192
+ }
193
+ }
194
+ if (rejected > 0) {
195
+ log.info(`Malformed-proposal cleanup: rejected ${rejected} of ${promotedList.length} promoted row(s)`, { sampleIds: rejectedIds.slice(0, 10) });
196
+ }
197
+ }
198
+ catch (err) {
199
+ log.error('Malformed-proposal cleanup failed', err);
200
+ }
131
201
  // Start promotion + review cron (every 15 minutes)
132
202
  this.promotionTimer = setInterval(() => {
133
203
  try {
@@ -219,19 +289,6 @@ export class ThreatCloudServer {
219
289
  res.setHeader('Content-Type', 'application/json');
220
290
  res.setHeader('X-Request-Id', requestId);
221
291
  const clientIP = req.socket.remoteAddress ?? 'unknown';
222
- // Rate limiting
223
- if (!this.checkRateLimit(clientIP)) {
224
- this.sendJson(res, 429, { ok: false, error: 'Rate limit exceeded', request_id: requestId });
225
- log.info('request', {
226
- method: req.method,
227
- path: req.url,
228
- status: 429,
229
- duration_ms: Date.now() - startTime,
230
- client_ip: clientIP,
231
- request_id: requestId,
232
- });
233
- return;
234
- }
235
292
  // API key verification (skip for health check)
236
293
  const url = req.url ?? '/';
237
294
  const rawPathname = url.split('?')[0] ?? '/';
@@ -244,17 +301,27 @@ export class ThreatCloudServer {
244
301
  // Public endpoints that don't require API key authentication.
245
302
  // Read-only data endpoints are public. Write endpoints require auth.
246
303
  // 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']);
304
+ const publicPaths = new Set([
305
+ '/health',
306
+ '/api/stats',
307
+ '/api/metrics',
308
+ '/api/clients/register',
309
+ '/api/clients/register-github',
310
+ ]);
248
311
  const isPublicRead = publicPaths.has(pathname) || (pathname === '/api/rules' && req.method === 'GET');
249
312
  // Track the role that authenticated this request so route handlers can
250
313
  // enforce scope (e.g. L5 partner endpoints only accept role=partner|admin).
251
314
  let authRole = 'anonymous';
315
+ // Per-token rate-limit key. For authenticated requests, this is the bearer
316
+ // token so each key (and thereby each workspace) has its own bucket.
317
+ // Anonymous/public-read requests fall back to the historical per-IP key.
318
+ let rateLimitKey = `ip:${clientIP}`;
319
+ let clientKeyInfo = null;
252
320
  if (!isPublicRead && this.config.apiKeyRequired) {
253
321
  const authHeader = req.headers.authorization ?? '';
254
322
  const token = authHeader.replace('Bearer ', '');
255
323
  const isValidApiKey = this.config.apiKeys.includes(token);
256
324
  const isAdminKey = this.config.adminApiKey ? token === this.config.adminApiKey : false;
257
- let clientKeyInfo = null;
258
325
  if (!isValidApiKey && !isAdminKey && token.length > 0) {
259
326
  clientKeyInfo = this.db.getClientKeyInfo(token);
260
327
  if (clientKeyInfo)
@@ -279,6 +346,40 @@ export class ThreatCloudServer {
279
346
  : clientKeyInfo?.role === 'partner'
280
347
  ? 'client-partner'
281
348
  : 'client-guard';
349
+ // Authenticated requests are rate-limited per token, not per IP.
350
+ // Falling back to `ip:<addr>` only here would defeat tier quotas:
351
+ // every shared NAT egress would collide. token is unique per key.
352
+ rateLimitKey = `token:${token}`;
353
+ }
354
+ // Rate limiting (tier-aware). Run AFTER auth so we know which tier this
355
+ // request belongs to. Anonymous public reads still go through the per-IP
356
+ // limiter at the community budget (120/min) — same as before this change.
357
+ const tier = resolveTier(authRole, clientKeyInfo);
358
+ const rl = this.checkRateLimit(rateLimitKey, tier);
359
+ res.setHeader('X-RateLimit-Tier', tier);
360
+ res.setHeader('X-RateLimit-Limit', String(rl.limit));
361
+ res.setHeader('X-RateLimit-Remaining', String(rl.remaining));
362
+ res.setHeader('X-RateLimit-Reset', String(rl.resetAt));
363
+ if (!rl.allowed) {
364
+ res.setHeader('Retry-After', String(Math.ceil((rl.resetAt - Date.now()) / 1000)));
365
+ this.sendJson(res, 429, {
366
+ ok: false,
367
+ error: 'rate_limited',
368
+ tier,
369
+ limit: rl.limit,
370
+ retry_after_ms: Math.max(0, rl.resetAt - Date.now()),
371
+ request_id: requestId,
372
+ });
373
+ log.info('request', {
374
+ method: req.method,
375
+ path: req.url,
376
+ status: 429,
377
+ duration_ms: Date.now() - startTime,
378
+ client_ip: clientIP,
379
+ request_id: requestId,
380
+ rate_limit_tier: tier,
381
+ });
382
+ return;
282
383
  }
283
384
  // L5 partner-sync: /api/atr-rules/live is role-gated. Only admin or
284
385
  // partner-tier client keys can reach it. Guard auto-provisioned keys
@@ -503,6 +604,14 @@ export class ThreatCloudServer {
503
604
  this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
504
605
  }
505
606
  break;
607
+ case '/api/clients/register-github':
608
+ if (req.method === 'POST') {
609
+ await this.handleClientRegisterGitHub(req, res, clientIP);
610
+ }
611
+ else {
612
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
613
+ }
614
+ break;
506
615
  case '/api/admin/client-keys':
507
616
  if (!this.checkAdminAuth(req)) {
508
617
  this.sendJson(res, 403, { ok: false, error: 'Admin API key required' });
@@ -829,6 +938,53 @@ export class ThreatCloudServer {
829
938
  }
830
939
  break;
831
940
  default: {
941
+ // Admin tier-management endpoint: change workspace tier for a
942
+ // client key. Drives the per-token rate limiter. Admin-only;
943
+ // path param is the clientId (the same value returned by
944
+ // /api/admin/client-keys). Tier change takes effect on the NEXT
945
+ // 60-second rate-limit window for that token.
946
+ const tierMatch = pathname.match(/^\/api\/admin\/client-keys\/([^/]+)\/tier$/);
947
+ if (tierMatch) {
948
+ if (!this.checkAdminAuth(req)) {
949
+ this.sendJson(res, 401, { ok: false, error: 'Admin API key required' });
950
+ break;
951
+ }
952
+ if (req.method !== 'POST') {
953
+ this.sendJson(res, 405, { ok: false, error: 'Method not allowed' });
954
+ break;
955
+ }
956
+ const clientId = decodeURIComponent(tierMatch[1]);
957
+ const body = await this.readBody(req);
958
+ let parsed;
959
+ try {
960
+ parsed = JSON.parse(body);
961
+ }
962
+ catch {
963
+ this.sendJson(res, 400, { ok: false, error: 'Invalid JSON' });
964
+ break;
965
+ }
966
+ if (!isTcTier(parsed.tier)) {
967
+ this.sendJson(res, 400, {
968
+ ok: false,
969
+ error: 'tier must be one of community|pilot|enterprise',
970
+ });
971
+ break;
972
+ }
973
+ const updated = this.db.setClientKeyTier(clientId, parsed.tier);
974
+ if (updated === 0) {
975
+ this.sendJson(res, 404, {
976
+ ok: false,
977
+ error: 'No active client keys found for clientId',
978
+ });
979
+ break;
980
+ }
981
+ this.db.audit.logAction('admin', 'client_key.tier_update', 'client_key', clientId, { tier: parsed.tier, updated }, clientIP);
982
+ this.sendJson(res, 200, {
983
+ ok: true,
984
+ data: { clientId, tier: parsed.tier, updated },
985
+ });
986
+ break;
987
+ }
832
988
  // Org fleet/policy routes with path params
833
989
  // Threat Model: Fleet view (#6), Policy engine (#1, #6)
834
990
  const orgDevicesMatch = pathname.match(/^\/api\/orgs\/([^/]+)\/devices$/);
@@ -897,6 +1053,12 @@ export class ThreatCloudServer {
897
1053
  }
898
1054
  catch (err) {
899
1055
  log.error('Request failed', err, { request_id: requestId, path: rawPathname });
1056
+ // Additive Sentry capture — keeps existing logger intact.
1057
+ if (process.env['SENTRY_DSN']) {
1058
+ Sentry.captureException(err, {
1059
+ tags: { route: req.url ?? 'unknown', requestId },
1060
+ });
1061
+ }
900
1062
  this.sendJson(res, 500, { ok: false, error: 'Internal server error', request_id: requestId });
901
1063
  }
902
1064
  // Request logging
@@ -1408,12 +1570,93 @@ export class ThreatCloudServer {
1408
1570
  },
1409
1571
  });
1410
1572
  }
1411
- /** POST /api/atr-proposals - Submit or confirm an ATR rule proposal */
1573
+ /**
1574
+ * POST /api/atr-proposals - Submit or confirm an ATR rule proposal.
1575
+ *
1576
+ * Vote weighting (defends the corpus against Sybil promotion attacks):
1577
+ * - anonymous client (legacy /api/clients/register) → weight 0.0
1578
+ * - github_new (< 30 days since OAuth registration) → weight 0.5
1579
+ * - github_verified (>= 30 days) → weight 1.0
1580
+ *
1581
+ * Promotion threshold: cumulative `confirmation_weight >= 3.0`. Anonymous
1582
+ * submissions are still accepted (the corpus stays community-friendly) but
1583
+ * they cannot single-handedly promote a proposal.
1584
+ *
1585
+ * GitHub-bound clients additionally face a per-user daily submission cap
1586
+ * (default 10) so a single compromised contributor account cannot flood
1587
+ * the queue.
1588
+ */
1412
1589
  async handlePostATRProposal(req, res) {
1413
1590
  const data = await this.parseAndValidate(req, res, ATRProposalSchema);
1414
1591
  if (!data)
1415
1592
  return;
1593
+ // Structural quality gate — the same one the internal LLM crystallization
1594
+ // paths (analyzeSkills, draftRuleFromPayload) apply before storing into
1595
+ // atr_proposals. Without this, external POSTs can stuff arbitrary strings
1596
+ // into the rule_content field; they pass the 24h canary trivially
1597
+ // (malformed rules can't trigger anything, so accrue zero negative
1598
+ // feedback) and graduate to "promoted", then flood the PR-back pipeline.
1599
+ // Observed 2026-05-26: PR-back fetched 14 promoted rules, all 14 skipped
1600
+ // for missing every required top-level field. Root cause was this gap.
1601
+ // Reject at submission so garbage never enters the pipeline.
1602
+ try {
1603
+ const metadata = parseATRRule(data.ruleContent);
1604
+ const gateResult = validateRuleMeetsStandard(metadata, 'experimental');
1605
+ if (!gateResult.passed) {
1606
+ this.sendJson(res, 400, {
1607
+ ok: false,
1608
+ error: 'rule_quality_gate_failed',
1609
+ message: 'Proposed rule does not meet the experimental quality bar. ' +
1610
+ 'See https://agentthreatrule.org/spec for required structure.',
1611
+ issues: gateResult.issues,
1612
+ });
1613
+ return;
1614
+ }
1615
+ }
1616
+ catch (parseErr) {
1617
+ this.sendJson(res, 400, {
1618
+ ok: false,
1619
+ error: 'rule_parse_failed',
1620
+ message: 'Proposed rule_content failed to parse as a valid ATR YAML rule. ' +
1621
+ 'See https://agentthreatrule.org/spec for required structure.',
1622
+ detail: parseErr instanceof Error ? parseErr.message : String(parseErr),
1623
+ });
1624
+ return;
1625
+ }
1416
1626
  const clientId = req.headers['x-panguard-client-id'] ?? undefined;
1627
+ // Look up the caller's trust tier from the client key (if any). The
1628
+ // bearer token is already authenticated upstream — we only need its
1629
+ // trust attributes here.
1630
+ const rawKey = this.extractClientKey(req);
1631
+ const trustInfo = rawKey ? this.db.getClientKeyTrustInfo(rawKey) : null;
1632
+ const trustTier = trustInfo?.trustTier ?? 'anonymous';
1633
+ const githubUserId = trustInfo?.githubUserId ?? null;
1634
+ // Per-github-user daily submission cap. Skipped for anonymous keys
1635
+ // (those already contribute weight 0 so flooding is less attractive
1636
+ // but still useful to prevent corpus inflation).
1637
+ if (githubUserId !== null && this.db.hasGitHubProposalCapReached(githubUserId)) {
1638
+ this.sendJson(res, 429, {
1639
+ ok: false,
1640
+ error: 'Daily proposal submission cap reached for this GitHub user',
1641
+ });
1642
+ return;
1643
+ }
1644
+ // Vote weight by trust tier. anonymous_legacy gets a non-zero weight
1645
+ // during the grace window so pre-migration community votes don't all
1646
+ // stall; after the grace end, it collapses to the same weight as
1647
+ // anonymous (zero). Constant is module-scoped — see top of file.
1648
+ let voteWeight;
1649
+ if (trustTier === 'github_verified')
1650
+ voteWeight = 1.0;
1651
+ else if (trustTier === 'github_new')
1652
+ voteWeight = 0.5;
1653
+ else if (trustTier === 'anonymous_legacy') {
1654
+ const now = Date.now();
1655
+ const graceEnd = Date.parse(LEGACY_ANONYMOUS_GRACE_END_ISO);
1656
+ voteWeight = now < graceEnd ? 0.25 : 0.0;
1657
+ }
1658
+ else
1659
+ voteWeight = 0.0;
1417
1660
  const { patternHash, ruleContent } = data;
1418
1661
  // Check if a proposal with the same patternHash already exists
1419
1662
  const existing = this.db.getATRProposalByHash(patternHash);
@@ -1426,16 +1669,31 @@ export class ThreatCloudServer {
1426
1669
  message: 'Already submitted',
1427
1670
  patternHash,
1428
1671
  confirmations: existing.confirmations,
1672
+ trustTier,
1673
+ voteWeight,
1429
1674
  },
1430
1675
  });
1431
1676
  return;
1432
1677
  }
1433
- // Different client_id: increment confirmation counter
1434
- this.db.confirmATRProposal(patternHash);
1435
- const updatedConfirmations = existing.confirmations + 1;
1678
+ // Different client_id: confirm with weight derived from trust tier.
1679
+ // Anonymous confirmations still increment the raw `confirmations`
1680
+ // counter for visibility but contribute 0 to `confirmation_weight`
1681
+ // so they cannot reach the promotion threshold on their own.
1682
+ const newWeight = this.db.confirmATRProposalWeighted(patternHash, voteWeight);
1683
+ if (githubUserId !== null)
1684
+ this.db.recordGitHubProposalSubmission(githubUserId);
1436
1685
  this.sendJson(res, 200, {
1437
1686
  ok: true,
1438
- data: { message: 'Proposal confirmed', patternHash, confirmations: updatedConfirmations },
1687
+ data: {
1688
+ message: trustTier === 'anonymous'
1689
+ ? 'Proposal acknowledged (anonymous vote — weight 0, will not promote)'
1690
+ : 'Proposal confirmed',
1691
+ patternHash,
1692
+ confirmations: existing.confirmations + 1,
1693
+ confirmationWeight: newWeight,
1694
+ trustTier,
1695
+ voteWeight,
1696
+ },
1439
1697
  });
1440
1698
  }
1441
1699
  else {
@@ -1444,6 +1702,8 @@ export class ThreatCloudServer {
1444
1702
  clientId,
1445
1703
  };
1446
1704
  this.db.insertATRProposal(proposal);
1705
+ if (githubUserId !== null)
1706
+ this.db.recordGitHubProposalSubmission(githubUserId);
1447
1707
  // Fire-and-forget LLM review on first submission only
1448
1708
  if (this.llmReviewer?.isAvailable()) {
1449
1709
  void this.llmReviewer.reviewProposal(patternHash, ruleContent).catch((err) => {
@@ -1452,10 +1712,26 @@ export class ThreatCloudServer {
1452
1712
  }
1453
1713
  this.sendJson(res, 201, {
1454
1714
  ok: true,
1455
- data: { message: 'Proposal submitted', patternHash },
1715
+ data: {
1716
+ message: 'Proposal submitted',
1717
+ patternHash,
1718
+ trustTier,
1719
+ voteWeight,
1720
+ },
1456
1721
  });
1457
1722
  }
1458
1723
  }
1724
+ /**
1725
+ * Extract the raw client key from the Authorization header. Returns null
1726
+ * when no bearer token is present.
1727
+ */
1728
+ extractClientKey(req) {
1729
+ const auth = req.headers['authorization'];
1730
+ if (typeof auth !== 'string')
1731
+ return null;
1732
+ const match = auth.match(/^Bearer\s+(\S+)\s*$/i);
1733
+ return match?.[1] ?? null;
1734
+ }
1459
1735
  /** POST /api/atr-feedback - Submit feedback on an ATR rule */
1460
1736
  async handlePostATRFeedback(req, res) {
1461
1737
  const data = await this.parseAndValidate(req, res, ATRFeedbackSchema);
@@ -1546,6 +1822,124 @@ export class ThreatCloudServer {
1546
1822
  this.db.audit.logAction('system', 'client_key.register', 'client_key', clientId, { ip: clientIP }, clientIP);
1547
1823
  this.sendJson(res, 201, { ok: true, data: { clientKey: result.clientKey } });
1548
1824
  }
1825
+ /**
1826
+ * POST /api/clients/register-github — issue a client key bound to a verified
1827
+ * GitHub identity. Used to defend the ATR proposal corpus against Sybil
1828
+ * attacks via anonymous registration spam.
1829
+ *
1830
+ * Flow:
1831
+ * 1. Client sends `{ githubToken: "ghp_..." | "gho_..." }` in body
1832
+ * 2. Server calls GitHub API `GET /user` with that token to verify
1833
+ * 3. On success, issues a client key tied to the numeric GitHub user ID
1834
+ *
1835
+ * The github_user_id is GitHub's stable numeric identifier — `login` can be
1836
+ * renamed, so we key trust off the ID. Trust tier starts at 'github_new'
1837
+ * and matures to 'github_verified' 30 days after first registration (see
1838
+ * promoteMatureGitHubClients).
1839
+ *
1840
+ * Vote weighting at ATR proposal time:
1841
+ * - anonymous (legacy /api/clients/register key) → 0 (can submit, can't promote)
1842
+ * - github_new (< 30 days) → 0.5
1843
+ * - github_verified (>= 30 days) → 1.0
1844
+ * Promotion threshold: cumulative weight >= 3.0
1845
+ */
1846
+ async handleClientRegisterGitHub(req, res, clientIP) {
1847
+ // Reuse the same per-IP registration rate limit as the anonymous endpoint
1848
+ // to avoid Sybil registration via rotated tokens from a single IP.
1849
+ const entry = this.registrationRateLimits.get(clientIP);
1850
+ const now = Date.now();
1851
+ if (entry && now < entry.resetAt) {
1852
+ if (entry.count >= 10) {
1853
+ this.sendJson(res, 429, {
1854
+ ok: false,
1855
+ error: 'Registration rate limit exceeded. Try again later.',
1856
+ });
1857
+ return;
1858
+ }
1859
+ entry.count++;
1860
+ }
1861
+ else {
1862
+ this.registrationRateLimits.set(clientIP, { count: 1, resetAt: now + 3_600_000 });
1863
+ }
1864
+ const body = await this.readBody(req);
1865
+ if (!body) {
1866
+ this.sendJson(res, 400, { ok: false, error: 'Request body required' });
1867
+ return;
1868
+ }
1869
+ let parsed;
1870
+ try {
1871
+ parsed = JSON.parse(body);
1872
+ }
1873
+ catch {
1874
+ this.sendJson(res, 400, { ok: false, error: 'Invalid JSON' });
1875
+ return;
1876
+ }
1877
+ const githubToken = parsed.githubToken;
1878
+ if (!githubToken ||
1879
+ typeof githubToken !== 'string' ||
1880
+ githubToken.length < 20 ||
1881
+ githubToken.length > 300) {
1882
+ this.sendJson(res, 400, {
1883
+ ok: false,
1884
+ error: 'githubToken must be a GitHub access token (20-300 chars)',
1885
+ });
1886
+ return;
1887
+ }
1888
+ // Verify the token by calling GitHub's /user endpoint. Use a short
1889
+ // timeout so a slow/unreachable GitHub does not pin a TC connection.
1890
+ let githubUser = null;
1891
+ try {
1892
+ const controller = new AbortController();
1893
+ const timeout = setTimeout(() => controller.abort(), 5_000);
1894
+ const ghRes = await fetch('https://api.github.com/user', {
1895
+ method: 'GET',
1896
+ headers: {
1897
+ Authorization: `Bearer ${githubToken}`,
1898
+ Accept: 'application/vnd.github+json',
1899
+ 'User-Agent': 'panguard-threat-cloud',
1900
+ },
1901
+ signal: controller.signal,
1902
+ });
1903
+ clearTimeout(timeout);
1904
+ if (!ghRes.ok) {
1905
+ this.sendJson(res, 401, {
1906
+ ok: false,
1907
+ error: `GitHub token rejected (HTTP ${ghRes.status})`,
1908
+ });
1909
+ return;
1910
+ }
1911
+ const data = (await ghRes.json());
1912
+ if (typeof data.id !== 'number' ||
1913
+ typeof data.login !== 'string' ||
1914
+ data.login.length === 0) {
1915
+ this.sendJson(res, 502, {
1916
+ ok: false,
1917
+ error: 'GitHub /user response missing required fields',
1918
+ });
1919
+ return;
1920
+ }
1921
+ githubUser = { id: data.id, login: data.login };
1922
+ }
1923
+ catch (err) {
1924
+ log.error(`GitHub token verification failed`, err);
1925
+ this.sendJson(res, 502, {
1926
+ ok: false,
1927
+ error: 'Failed to verify GitHub token (timeout or upstream error)',
1928
+ });
1929
+ return;
1930
+ }
1931
+ const result = this.db.registerGitHubClientKey(githubUser.id, githubUser.login, clientIP);
1932
+ this.db.audit.logAction('system', 'client_key.register_github', 'client_key', result.clientId, { ip: clientIP, github_user_id: githubUser.id, github_login: githubUser.login }, clientIP);
1933
+ this.sendJson(res, 201, {
1934
+ ok: true,
1935
+ data: {
1936
+ clientKey: result.clientKey,
1937
+ clientId: result.clientId,
1938
+ trustTier: result.trustTier,
1939
+ githubLogin: githubUser.login,
1940
+ },
1941
+ });
1942
+ }
1549
1943
  /** POST /api/admin/client-keys/revoke — revoke client keys */
1550
1944
  async handleClientKeyRevoke(req, res) {
1551
1945
  const body = await this.readBody(req);
@@ -1727,10 +2121,45 @@ export class ThreatCloudServer {
1727
2121
  const canaryRules = this.db.getCanaryATRRules();
1728
2122
  ruleList.push(...canaryRules);
1729
2123
  }
2124
+ // Defense-in-depth: re-validate every rule against the same structural
2125
+ // quality gate POST /api/atr-proposals enforces (added 2026-05-27 in
2126
+ // 339fd410). The DB may contain malformed rows that landed BEFORE the
2127
+ // gate was in place — filtering at read time hides them from all
2128
+ // consumers (notably the PR-back cron job, which previously processed
2129
+ // 14 garbage rows every 6h). Dropped rule IDs are logged so they can
2130
+ // be hard-deleted via admin endpoint later if desired.
2131
+ const droppedIds = [];
2132
+ const validRules = ruleList.filter((rule) => {
2133
+ try {
2134
+ const metadata = parseATRRule(rule.ruleContent);
2135
+ const gateResult = validateRuleMeetsStandard(metadata, 'experimental');
2136
+ if (!gateResult.passed) {
2137
+ droppedIds.push(rule.ruleId);
2138
+ return false;
2139
+ }
2140
+ return true;
2141
+ }
2142
+ catch {
2143
+ droppedIds.push(rule.ruleId);
2144
+ return false;
2145
+ }
2146
+ });
2147
+ if (droppedIds.length > 0) {
2148
+ // Use log.info — this logger object exports only .info and .error.
2149
+ // The malformed-rule case is operational (something old in the DB),
2150
+ // not an error path, so info is the right level.
2151
+ log.info(`[atr-rules] dropped ${droppedIds.length} malformed rule(s) from response`, {
2152
+ ruleIds: droppedIds.slice(0, 20),
2153
+ });
2154
+ }
1730
2155
  this.sendJson(res, 200, {
1731
2156
  ok: true,
1732
- data: ruleList,
1733
- meta: { total: ruleList.length, includesCanary: includeCanary },
2157
+ data: validRules,
2158
+ meta: {
2159
+ total: validRules.length,
2160
+ includesCanary: includeCanary,
2161
+ droppedMalformed: droppedIds.length,
2162
+ },
1734
2163
  });
1735
2164
  }
1736
2165
  /** GET /api/feeds/ip-blocklist?minReputation=70 - IP blocklist feed (plain text) */
@@ -2204,11 +2633,21 @@ export class ThreatCloudServer {
2204
2633
  // Admin HTML is safe to serve publicly — it contains no sensitive data.
2205
2634
  // Authentication happens client-side: the login form collects the API key,
2206
2635
  // which is then sent as Authorization header on every API call.
2636
+ const nonce = randomBytes(16).toString('base64');
2207
2637
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
2208
2638
  res.setHeader('Cache-Control', 'no-store');
2209
2639
  res.setHeader('X-Robots-Tag', 'noindex, nofollow');
2640
+ res.setHeader('X-Content-Type-Options', 'nosniff');
2641
+ res.setHeader('X-Frame-Options', 'DENY');
2642
+ res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
2643
+ res.setHeader('Content-Security-Policy', `default-src 'self'; ` +
2644
+ `script-src 'nonce-${nonce}' 'strict-dynamic'; ` +
2645
+ `style-src 'self' 'unsafe-inline'; ` +
2646
+ `object-src 'none'; ` +
2647
+ `frame-ancestors 'none'; ` +
2648
+ `base-uri 'self'`);
2210
2649
  res.writeHead(200);
2211
- res.end(getAdminHTML());
2650
+ res.end(getAdminHTML(nonce));
2212
2651
  }
2213
2652
  /** Check admin API key for write-protected endpoints / 檢查管理員 API 金鑰 */
2214
2653
  checkAdminAuth(req) {
@@ -2223,21 +2662,56 @@ export class ThreatCloudServer {
2223
2662
  }
2224
2663
  const authHeader = req.headers.authorization ?? '';
2225
2664
  const token = authHeader.replace('Bearer ', '');
2226
- // Use timing-safe comparison to prevent timing attacks
2227
- if (token.length !== this.config.adminApiKey.length)
2228
- return false;
2229
- return timingSafeEqual(Buffer.from(token, 'utf-8'), Buffer.from(this.config.adminApiKey, 'utf-8'));
2665
+ const expected = this.config.adminApiKey;
2666
+ // Pad both sides to a fixed 128-byte buffer before comparing so the
2667
+ // early-return branch that leaks key length via timing is eliminated.
2668
+ const a = Buffer.alloc(128);
2669
+ const b = Buffer.alloc(128);
2670
+ Buffer.from(token, 'utf-8').copy(a, 0, 0, Math.min(token.length, 128));
2671
+ Buffer.from(expected, 'utf-8').copy(b, 0, 0, Math.min(expected.length, 128));
2672
+ // timingSafeEqual guards byte content; the explicit length check is kept
2673
+ // separate so both sides always go through the constant-time comparison
2674
+ // regardless of length difference.
2675
+ return timingSafeEqual(a, b) && token.length === expected.length;
2230
2676
  }
2231
- /** Rate limit check / 速率限制檢查 */
2232
- checkRateLimit(ip) {
2677
+ /**
2678
+ * Tier-aware rate-limit check / 階層感知的速率限制檢查
2679
+ *
2680
+ * @param key Bucket key — `token:<bearer>` for authenticated requests
2681
+ * or `ip:<addr>` for anonymous/public reads.
2682
+ * @param tier Workspace tier resolved from the auth gate. Drives the
2683
+ * requests-per-minute budget via TIER_LIMITS.
2684
+ *
2685
+ * Returns the bucket state for header emission + allow/deny decision.
2686
+ * `limit` is captured at window-init time; mid-window tier changes only
2687
+ * take effect on the next window.
2688
+ */
2689
+ checkRateLimit(key, tier) {
2233
2690
  const now = Date.now();
2234
- const entry = this.rateLimits.get(ip);
2235
- if (!entry || now > entry.resetAt) {
2236
- this.rateLimits.set(ip, { count: 1, resetAt: now + 60_000 });
2237
- return true;
2691
+ const tierLimit = TIER_LIMITS[tier];
2692
+ const entry = this.rateLimits.get(key);
2693
+ if (!entry || now >= entry.resetAt) {
2694
+ const resetAt = now + 60_000;
2695
+ this.rateLimits.set(key, { count: 1, resetAt, limit: tierLimit });
2696
+ return { allowed: true, limit: tierLimit, remaining: tierLimit - 1, resetAt };
2697
+ }
2698
+ if (entry.count >= entry.limit) {
2699
+ // Bucket exhausted — do not increment further (avoids unbounded growth
2700
+ // when a misbehaving client keeps hammering after 429).
2701
+ return {
2702
+ allowed: false,
2703
+ limit: entry.limit,
2704
+ remaining: 0,
2705
+ resetAt: entry.resetAt,
2706
+ };
2238
2707
  }
2239
2708
  entry.count++;
2240
- return entry.count <= this.config.rateLimitPerMinute;
2709
+ return {
2710
+ allowed: true,
2711
+ limit: entry.limit,
2712
+ remaining: Math.max(0, entry.limit - entry.count),
2713
+ resetAt: entry.resetAt,
2714
+ };
2241
2715
  }
2242
2716
  /**
2243
2717
  * Parse JSON body and validate against a Zod schema.