@panguard-ai/threat-cloud 1.5.5 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin-dashboard.d.ts +1 -1
- package/dist/admin-dashboard.d.ts.map +1 -1
- package/dist/admin-dashboard.js +123 -24
- package/dist/admin-dashboard.js.map +1 -1
- package/dist/audit-logger.d.ts +1 -1
- package/dist/audit-logger.d.ts.map +1 -1
- package/dist/audit-logger.js.map +1 -1
- package/dist/auth/tier-resolver.d.ts +46 -0
- package/dist/auth/tier-resolver.d.ts.map +1 -0
- package/dist/auth/tier-resolver.js +57 -0
- package/dist/auth/tier-resolver.js.map +1 -0
- package/dist/cli.js +50 -10
- package/dist/cli.js.map +1 -1
- package/dist/database.d.ts +72 -5
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +193 -19
- package/dist/database.js.map +1 -1
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +90 -0
- package/dist/migrations.js.map +1 -1
- package/dist/server.d.ts +55 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +510 -36
- package/dist/server.js.map +1 -1
- package/package.json +4 -3
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([
|
|
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
|
-
/**
|
|
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:
|
|
1434
|
-
|
|
1435
|
-
|
|
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: {
|
|
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: {
|
|
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:
|
|
1733
|
-
meta: {
|
|
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
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
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
|
-
/**
|
|
2232
|
-
|
|
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
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
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
|
|
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.
|