@panguard-ai/threat-cloud 1.4.2 → 1.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/audit-logger.d.ts +1 -1
- package/dist/audit-logger.d.ts.map +1 -1
- package/dist/audit-logger.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/database.d.ts +236 -2
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +603 -51
- package/dist/database.js.map +1 -1
- package/dist/llm-reviewer-tools.d.ts +110 -0
- package/dist/llm-reviewer-tools.d.ts.map +1 -0
- package/dist/llm-reviewer-tools.js +446 -0
- package/dist/llm-reviewer-tools.js.map +1 -0
- package/dist/llm-reviewer.d.ts +54 -0
- package/dist/llm-reviewer.d.ts.map +1 -1
- package/dist/llm-reviewer.js +708 -64
- package/dist/llm-reviewer.js.map +1 -1
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +215 -0
- package/dist/migrations.js.map +1 -1
- package/dist/migrator-crystallization.d.ts +80 -0
- package/dist/migrator-crystallization.d.ts.map +1 -0
- package/dist/migrator-crystallization.js +108 -0
- package/dist/migrator-crystallization.js.map +1 -0
- package/dist/server.d.ts +69 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1093 -91
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +31 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +15 -12
package/dist/database.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @module @panguard-ai/threat-cloud/database
|
|
8
8
|
*/
|
|
9
9
|
import Database from 'better-sqlite3';
|
|
10
|
-
import { createHmac, createHash } from 'node:crypto';
|
|
10
|
+
import { createHmac, createHash, randomUUID } from 'node:crypto';
|
|
11
11
|
import { runMigrations } from './migrations.js';
|
|
12
12
|
import { AuditLogger } from './audit-logger.js';
|
|
13
13
|
/**
|
|
@@ -24,6 +24,14 @@ export class ThreatCloudDB {
|
|
|
24
24
|
this.initialize();
|
|
25
25
|
this.audit = new AuditLogger(this.db);
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Escape hatch for callers that need direct DB access (e.g. modules
|
|
29
|
+
* defined outside this class that perform table-specific queries).
|
|
30
|
+
* Prefer adding a typed method here when the access pattern stabilises.
|
|
31
|
+
*/
|
|
32
|
+
getRawDb() {
|
|
33
|
+
return this.db;
|
|
34
|
+
}
|
|
27
35
|
/** Create tables if they don't exist / 建立資料表 */
|
|
28
36
|
initialize() {
|
|
29
37
|
this.db.exec(`
|
|
@@ -149,13 +157,57 @@ export class ThreatCloudDB {
|
|
|
149
157
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_telemetry_hourly_unique
|
|
150
158
|
ON telemetry_hourly_aggregates(hour_bucket, event_type, platform);
|
|
151
159
|
|
|
160
|
+
CREATE TABLE IF NOT EXISTS activations (
|
|
161
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
162
|
+
client_id TEXT NOT NULL UNIQUE,
|
|
163
|
+
platform TEXT NOT NULL DEFAULT 'unknown',
|
|
164
|
+
os_type TEXT NOT NULL DEFAULT 'unknown',
|
|
165
|
+
panguard_version TEXT NOT NULL DEFAULT 'unknown',
|
|
166
|
+
node_version TEXT NOT NULL DEFAULT 'unknown',
|
|
167
|
+
activated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
168
|
+
);
|
|
169
|
+
|
|
152
170
|
-- Migrations are handled by the numbered migration system in migrations.ts
|
|
171
|
+
|
|
172
|
+
-- Org/Device/Policy tables (migration v8, duplicated here as safety net
|
|
173
|
+
-- because CREATE TABLE IF NOT EXISTS is idempotent)
|
|
174
|
+
CREATE TABLE IF NOT EXISTS orgs (
|
|
175
|
+
id TEXT PRIMARY KEY,
|
|
176
|
+
name TEXT NOT NULL,
|
|
177
|
+
api_key_hash TEXT,
|
|
178
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
179
|
+
);
|
|
180
|
+
CREATE TABLE IF NOT EXISTS devices (
|
|
181
|
+
id TEXT PRIMARY KEY,
|
|
182
|
+
org_id TEXT NOT NULL REFERENCES orgs(id),
|
|
183
|
+
hostname TEXT,
|
|
184
|
+
os_type TEXT,
|
|
185
|
+
agent_count INTEGER NOT NULL DEFAULT 0,
|
|
186
|
+
guard_version TEXT,
|
|
187
|
+
last_seen TEXT NOT NULL DEFAULT (datetime('now')),
|
|
188
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
189
|
+
);
|
|
190
|
+
CREATE INDEX IF NOT EXISTS idx_devices_org ON devices(org_id);
|
|
191
|
+
CREATE INDEX IF NOT EXISTS idx_devices_last_seen ON devices(last_seen);
|
|
192
|
+
CREATE TABLE IF NOT EXISTS org_policies (
|
|
193
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
194
|
+
org_id TEXT NOT NULL REFERENCES orgs(id),
|
|
195
|
+
category TEXT NOT NULL,
|
|
196
|
+
action TEXT NOT NULL CHECK(action IN ('allow', 'block')),
|
|
197
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
198
|
+
UNIQUE(org_id, category)
|
|
199
|
+
);
|
|
153
200
|
`);
|
|
154
201
|
try {
|
|
155
|
-
runMigrations(this.db);
|
|
202
|
+
const applied = runMigrations(this.db);
|
|
203
|
+
if (applied > 0) {
|
|
204
|
+
console.log(`[threat-cloud] ${applied} migration(s) applied`);
|
|
205
|
+
}
|
|
156
206
|
}
|
|
157
207
|
catch (err) {
|
|
158
|
-
console.error('[threat-cloud]
|
|
208
|
+
console.error('[threat-cloud] MIGRATION FAILED:', err instanceof Error ? err.message : String(err));
|
|
209
|
+
// Re-throw — migrations failing means the DB is in a broken state
|
|
210
|
+
throw err;
|
|
159
211
|
}
|
|
160
212
|
this.db.exec(`
|
|
161
213
|
CREATE INDEX IF NOT EXISTS idx_rules_category ON rules(category);
|
|
@@ -351,6 +403,19 @@ export class ThreatCloudDB {
|
|
|
351
403
|
`);
|
|
352
404
|
stmt.run(rule.ruleId, rule.ruleContent, rule.publishedAt, rule.source, category, severity, mitreTechniques, tags);
|
|
353
405
|
}
|
|
406
|
+
/** Delete all rules with a given source. Returns number of deleted rows. */
|
|
407
|
+
deleteRulesBySource(source) {
|
|
408
|
+
const stmt = this.db.prepare('DELETE FROM rules WHERE source = ?');
|
|
409
|
+
const result = stmt.run(source);
|
|
410
|
+
return result.changes;
|
|
411
|
+
}
|
|
412
|
+
/** Delete rules by a list of rule IDs. Returns number of deleted rows. */
|
|
413
|
+
deleteRulesByIds(ruleIds) {
|
|
414
|
+
const placeholders = ruleIds.map(() => '?').join(',');
|
|
415
|
+
const stmt = this.db.prepare(`DELETE FROM rules WHERE rule_id IN (${placeholders})`);
|
|
416
|
+
const result = stmt.run(...ruleIds);
|
|
417
|
+
return result.changes;
|
|
418
|
+
}
|
|
354
419
|
/** Fetch rules published after a given timestamp / 取得指定時間後發佈的規則 */
|
|
355
420
|
getRulesSince(since, filters) {
|
|
356
421
|
let sql = `SELECT rule_id as ruleId, rule_content as ruleContent, published_at as publishedAt, source,
|
|
@@ -466,9 +531,10 @@ export class ThreatCloudDB {
|
|
|
466
531
|
const findingRows = this.db
|
|
467
532
|
.prepare(`SELECT finding_summaries FROM skill_threats
|
|
468
533
|
WHERE skill_name = ? AND finding_summaries IS NOT NULL
|
|
469
|
-
ORDER BY created_at DESC LIMIT
|
|
534
|
+
ORDER BY created_at DESC LIMIT 10`)
|
|
470
535
|
.all(skillName);
|
|
471
536
|
const findings = [];
|
|
537
|
+
const ruleIdSet = new Set();
|
|
472
538
|
for (const fr of findingRows) {
|
|
473
539
|
try {
|
|
474
540
|
const parsed = JSON.parse(fr.finding_summaries);
|
|
@@ -476,6 +542,9 @@ export class ThreatCloudDB {
|
|
|
476
542
|
if (f.title && !findings.includes(f.title)) {
|
|
477
543
|
findings.push(f.title);
|
|
478
544
|
}
|
|
545
|
+
if (f.id && f.id.startsWith('ATR-')) {
|
|
546
|
+
ruleIdSet.add(f.id);
|
|
547
|
+
}
|
|
479
548
|
}
|
|
480
549
|
}
|
|
481
550
|
catch {
|
|
@@ -487,8 +556,19 @@ export class ThreatCloudDB {
|
|
|
487
556
|
avgRiskScore: row?.avg_score ?? 0,
|
|
488
557
|
maxRiskLevel: row?.max_level ?? 'LOW',
|
|
489
558
|
findings: findings.slice(0, 10),
|
|
559
|
+
atrRuleIds: [...ruleIdSet],
|
|
490
560
|
};
|
|
491
561
|
}
|
|
562
|
+
/** Fetch rule content by IDs. Returns the YAML detection patterns for crystallization. */
|
|
563
|
+
getRuleContentByIds(ruleIds) {
|
|
564
|
+
if (ruleIds.length === 0)
|
|
565
|
+
return [];
|
|
566
|
+
const placeholders = ruleIds.map(() => '?').join(',');
|
|
567
|
+
const rows = this.db
|
|
568
|
+
.prepare(`SELECT rule_id, rule_content FROM rules WHERE rule_id IN (${placeholders})`)
|
|
569
|
+
.all(...ruleIds);
|
|
570
|
+
return rows.map((r) => ({ ruleId: r.rule_id, ruleContent: r.rule_content }));
|
|
571
|
+
}
|
|
492
572
|
/** Check if an ATR proposal already exists for a pattern hash */
|
|
493
573
|
hasATRProposal(patternHash) {
|
|
494
574
|
const row = this.db
|
|
@@ -502,6 +582,75 @@ export class ThreatCloudDB {
|
|
|
502
582
|
.prepare('SELECT client_id, confirmations FROM atr_proposals WHERE pattern_hash = ? LIMIT 1')
|
|
503
583
|
.get(patternHash);
|
|
504
584
|
}
|
|
585
|
+
// -------------------------------------------------------------------------
|
|
586
|
+
// Payload fingerprint dedup — avoids paying Anthropic API for duplicate
|
|
587
|
+
// garak prompts. Typical hit rate is 90%+ on public corpora.
|
|
588
|
+
// 提示詞指紋去重 — 避免為重複的 garak 提示付 Anthropic API 錢。
|
|
589
|
+
// -------------------------------------------------------------------------
|
|
590
|
+
/**
|
|
591
|
+
* Look up a previously-judged payload fingerprint. Caller should call this
|
|
592
|
+
* BEFORE invoking the LLM drafter; on hit, skip the Anthropic call entirely
|
|
593
|
+
* and reuse the cached verdict.
|
|
594
|
+
*
|
|
595
|
+
* 查詢先前已判斷過的 payload fingerprint。呼叫端應在叫用 LLM drafter 之前查
|
|
596
|
+
* 這個表;命中時直接跳過 Anthropic 呼叫,回覆之前的結果。
|
|
597
|
+
*/
|
|
598
|
+
getPayloadFingerprint(fingerprint) {
|
|
599
|
+
const row = this.db
|
|
600
|
+
.prepare(`SELECT result, rule_id, pattern_hash, hit_count
|
|
601
|
+
FROM payload_fingerprints WHERE fingerprint = ? LIMIT 1`)
|
|
602
|
+
.get(fingerprint);
|
|
603
|
+
if (!row)
|
|
604
|
+
return null;
|
|
605
|
+
const result = row.result;
|
|
606
|
+
return {
|
|
607
|
+
result,
|
|
608
|
+
ruleId: row.rule_id,
|
|
609
|
+
patternHash: row.pattern_hash,
|
|
610
|
+
hitCount: row.hit_count,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Insert or bump a payload fingerprint verdict. UPSERT by fingerprint:
|
|
615
|
+
* - First time: insert with hit_count=1
|
|
616
|
+
* - Repeat: increment hit_count, refresh last_seen_at
|
|
617
|
+
*
|
|
618
|
+
* 新增或累計 payload fingerprint 判斷結果。
|
|
619
|
+
*/
|
|
620
|
+
recordPayloadFingerprint(fingerprint, result, details) {
|
|
621
|
+
this.db
|
|
622
|
+
.prepare(`INSERT INTO payload_fingerprints
|
|
623
|
+
(fingerprint, result, rule_id, pattern_hash, first_seen_at, last_seen_at, hit_count)
|
|
624
|
+
VALUES (?, ?, ?, ?, datetime('now'), datetime('now'), 1)
|
|
625
|
+
ON CONFLICT(fingerprint) DO UPDATE SET
|
|
626
|
+
hit_count = hit_count + 1,
|
|
627
|
+
last_seen_at = datetime('now')`)
|
|
628
|
+
.run(fingerprint, result, details?.ruleId ?? null, details?.patternHash ?? null);
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Stats for the fingerprint cache. Surfaced in admin dashboard so you can
|
|
632
|
+
* see the hit rate and confirm the cost-saving is real.
|
|
633
|
+
* 指紋快取統計。供 admin dashboard 顯示命中率,確認省錢有效。
|
|
634
|
+
*/
|
|
635
|
+
getPayloadFingerprintStats() {
|
|
636
|
+
const totals = this.db
|
|
637
|
+
.prepare(`SELECT
|
|
638
|
+
COUNT(*) as total,
|
|
639
|
+
COALESCE(SUM(CASE WHEN result='novel' THEN 1 ELSE 0 END), 0) as novel,
|
|
640
|
+
COALESCE(SUM(CASE WHEN result='duplicate' THEN 1 ELSE 0 END), 0) as duplicate,
|
|
641
|
+
COALESCE(SUM(CASE WHEN result='rejected' THEN 1 ELSE 0 END), 0) as rejected,
|
|
642
|
+
COALESCE(SUM(hit_count), 0) as total_hits
|
|
643
|
+
FROM payload_fingerprints`)
|
|
644
|
+
.get();
|
|
645
|
+
return {
|
|
646
|
+
total: totals.total,
|
|
647
|
+
novel: totals.novel,
|
|
648
|
+
duplicate: totals.duplicate,
|
|
649
|
+
rejected: totals.rejected,
|
|
650
|
+
totalHits: totals.total_hits,
|
|
651
|
+
cacheHits: Math.max(0, totals.total_hits - totals.total),
|
|
652
|
+
};
|
|
653
|
+
}
|
|
505
654
|
/** Get recent skill threats / 取得最近技能威脅 */
|
|
506
655
|
getSkillThreats(limit = 50) {
|
|
507
656
|
return this.db
|
|
@@ -510,17 +659,18 @@ export class ThreatCloudDB {
|
|
|
510
659
|
}
|
|
511
660
|
/** Get proposal statistics / 取得提案統計 */
|
|
512
661
|
getProposalStats() {
|
|
513
|
-
const
|
|
514
|
-
.prepare(
|
|
515
|
-
.get().count;
|
|
516
|
-
const confirmed = this.db
|
|
517
|
-
.prepare("SELECT COUNT(*) as count FROM atr_proposals WHERE status = 'confirmed'")
|
|
518
|
-
.get().count;
|
|
519
|
-
const rejected = this.db
|
|
520
|
-
.prepare("SELECT COUNT(*) as count FROM atr_proposals WHERE status = 'rejected'")
|
|
521
|
-
.get().count;
|
|
662
|
+
const countByStatus = (status) => this.db
|
|
663
|
+
.prepare('SELECT COUNT(*) as count FROM atr_proposals WHERE status = ?')
|
|
664
|
+
.get(status).count;
|
|
522
665
|
const total = this.db.prepare('SELECT COUNT(*) as count FROM atr_proposals').get().count;
|
|
523
|
-
return {
|
|
666
|
+
return {
|
|
667
|
+
pending: countByStatus('pending'),
|
|
668
|
+
confirmed: countByStatus('confirmed'),
|
|
669
|
+
canary: countByStatus('canary'),
|
|
670
|
+
quarantined: countByStatus('quarantined'),
|
|
671
|
+
rejected: countByStatus('rejected'),
|
|
672
|
+
total,
|
|
673
|
+
};
|
|
524
674
|
}
|
|
525
675
|
/** Get threat statistics / 取得威脅統計 */
|
|
526
676
|
// ---------------------------------------------------------------------------
|
|
@@ -647,6 +797,18 @@ export class ThreatCloudDB {
|
|
|
647
797
|
const proposalsDeleted = this.db.prepare('DELETE FROM atr_proposals').run().changes;
|
|
648
798
|
return rulesDeleted + proposalsDeleted;
|
|
649
799
|
}
|
|
800
|
+
/** Get current schema version for health diagnostics */
|
|
801
|
+
getSchemaVersion() {
|
|
802
|
+
try {
|
|
803
|
+
const row = this.db
|
|
804
|
+
.prepare('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1')
|
|
805
|
+
.get();
|
|
806
|
+
return row?.version ?? -1;
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
return -1;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
650
812
|
getStats() {
|
|
651
813
|
const totalThreats = this.db.prepare('SELECT COUNT(*) as count FROM threats').get().count;
|
|
652
814
|
const totalRules = this.db.prepare('SELECT COUNT(*) as count FROM rules').get().count;
|
|
@@ -716,7 +878,7 @@ export class ThreatCloudDB {
|
|
|
716
878
|
.prepare(`
|
|
717
879
|
SELECT pattern_hash as ruleId, rule_content as ruleContent, updated_at as publishedAt, 'atr-community' as source
|
|
718
880
|
FROM atr_proposals
|
|
719
|
-
WHERE
|
|
881
|
+
WHERE status = 'promoted' ${since ? 'AND updated_at > ?' : ''}
|
|
720
882
|
ORDER BY updated_at ASC
|
|
721
883
|
`)
|
|
722
884
|
.all(...sinceParams);
|
|
@@ -784,15 +946,17 @@ export class ThreatCloudDB {
|
|
|
784
946
|
}
|
|
785
947
|
/** Promote proposals to rules based on community consensus and/or LLM approval / 推廣提案為規則 */
|
|
786
948
|
promoteConfirmedProposals() {
|
|
787
|
-
// Path 1: LLM approved (
|
|
788
|
-
// Path 2: Community consensus (3+ confirmations,
|
|
949
|
+
// Path 1: LLM approved via self-review (JSON verdict with "approved":true)
|
|
950
|
+
// Path 2: Community consensus (3+ confirmations, no LLM required)
|
|
789
951
|
// — This ensures the flywheel works even without ANTHROPIC_API_KEY
|
|
790
|
-
// Path 3:
|
|
952
|
+
// Path 3: Admin manually approved via dashboard
|
|
953
|
+
// (status='approved' set by admin-approve endpoint; verdict stored as
|
|
954
|
+
// literal 'admin-approved' or a similar opaque marker — not JSON)
|
|
791
955
|
const proposals = this.db
|
|
792
956
|
.prepare(`
|
|
793
957
|
SELECT pattern_hash, rule_content, llm_review_verdict, confirmations, status
|
|
794
958
|
FROM atr_proposals
|
|
795
|
-
WHERE status IN ('confirmed', 'pending')
|
|
959
|
+
WHERE status IN ('confirmed', 'pending', 'approved')
|
|
796
960
|
AND status != 'rejected'
|
|
797
961
|
AND (
|
|
798
962
|
-- Path 1: LLM approved
|
|
@@ -800,10 +964,16 @@ export class ThreatCloudDB {
|
|
|
800
964
|
OR
|
|
801
965
|
-- Path 2: Community consensus (3+ confirmations, even without LLM)
|
|
802
966
|
(confirmations >= 3)
|
|
967
|
+
OR
|
|
968
|
+
-- Path 3: Admin dashboard approval (status promoted to 'approved'
|
|
969
|
+
-- by a human reviewer; the verdict field may be a plain marker
|
|
970
|
+
-- like 'admin-approved' rather than a JSON blob, so trust the
|
|
971
|
+
-- status column)
|
|
972
|
+
(status = 'approved')
|
|
803
973
|
)
|
|
804
974
|
`)
|
|
805
975
|
.all();
|
|
806
|
-
let
|
|
976
|
+
let moved = 0;
|
|
807
977
|
for (const proposal of proposals) {
|
|
808
978
|
// If LLM reviewed, check it's approved (don't promote LLM-rejected proposals)
|
|
809
979
|
if (proposal.llm_review_verdict) {
|
|
@@ -816,6 +986,83 @@ export class ThreatCloudDB {
|
|
|
816
986
|
// Unparseable verdict — allow community consensus to override
|
|
817
987
|
}
|
|
818
988
|
}
|
|
989
|
+
// Move to canary staging instead of direct promotion
|
|
990
|
+
this.db
|
|
991
|
+
.prepare(`
|
|
992
|
+
UPDATE atr_proposals
|
|
993
|
+
SET status = 'canary', canary_started_at = datetime('now'), updated_at = datetime('now')
|
|
994
|
+
WHERE pattern_hash = ?
|
|
995
|
+
`)
|
|
996
|
+
.run(proposal.pattern_hash);
|
|
997
|
+
moved++;
|
|
998
|
+
}
|
|
999
|
+
return moved;
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Self-heal: find proposals that are status='promoted' but have no
|
|
1003
|
+
* corresponding rule in the rules table, and re-upsert them. This
|
|
1004
|
+
* handles historical bugs where promoteCanaryRules set the status but
|
|
1005
|
+
* the upsertRule call failed silently (e.g., during a prior schema or
|
|
1006
|
+
* constraint mismatch). Idempotent — safe to run every promotion cycle.
|
|
1007
|
+
* Returns the number of rules restored.
|
|
1008
|
+
*/
|
|
1009
|
+
healOrphanedPromotedProposals() {
|
|
1010
|
+
const orphans = this.db
|
|
1011
|
+
.prepare(`
|
|
1012
|
+
SELECT p.pattern_hash, p.rule_content, p.updated_at
|
|
1013
|
+
FROM atr_proposals p
|
|
1014
|
+
LEFT JOIN rules r ON r.rule_id = p.pattern_hash
|
|
1015
|
+
WHERE p.status = 'promoted' AND r.rule_id IS NULL
|
|
1016
|
+
`)
|
|
1017
|
+
.all();
|
|
1018
|
+
let healed = 0;
|
|
1019
|
+
for (const o of orphans) {
|
|
1020
|
+
try {
|
|
1021
|
+
this.upsertRule({
|
|
1022
|
+
ruleId: o.pattern_hash,
|
|
1023
|
+
ruleContent: o.rule_content,
|
|
1024
|
+
publishedAt: o.updated_at || new Date().toISOString(),
|
|
1025
|
+
source: 'atr-community',
|
|
1026
|
+
});
|
|
1027
|
+
healed++;
|
|
1028
|
+
}
|
|
1029
|
+
catch {
|
|
1030
|
+
/* skip individual errors — next cycle will retry */
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
return healed;
|
|
1034
|
+
}
|
|
1035
|
+
/** Canary period in milliseconds (24 hours) / Canary 觀察期(24 小時) */
|
|
1036
|
+
static CANARY_PERIOD_MS = 24 * 60 * 60 * 1000;
|
|
1037
|
+
/**
|
|
1038
|
+
* Promote canary rules that have survived the 24-hour observation period
|
|
1039
|
+
* without negative feedback. Quarantine rules with negative feedback.
|
|
1040
|
+
* 推廣存活 24 小時且無負面回饋的 canary 規則。隔離有負面回饋的規則。
|
|
1041
|
+
*/
|
|
1042
|
+
promoteCanaryRules() {
|
|
1043
|
+
const canaryProposals = this.db
|
|
1044
|
+
.prepare(`
|
|
1045
|
+
SELECT pattern_hash, rule_content, canary_started_at
|
|
1046
|
+
FROM atr_proposals
|
|
1047
|
+
WHERE status = 'canary'
|
|
1048
|
+
AND canary_started_at IS NOT NULL
|
|
1049
|
+
`)
|
|
1050
|
+
.all();
|
|
1051
|
+
let promoted = 0;
|
|
1052
|
+
let quarantined = 0;
|
|
1053
|
+
for (const proposal of canaryProposals) {
|
|
1054
|
+
const negativeFeedback = this.getNegativeFeedbackCount(proposal.pattern_hash);
|
|
1055
|
+
if (negativeFeedback >= 3) {
|
|
1056
|
+
// Too much negative feedback — quarantine
|
|
1057
|
+
this.quarantineProposal(proposal.pattern_hash);
|
|
1058
|
+
quarantined++;
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
const elapsed = Date.now() - new Date(proposal.canary_started_at + 'Z').getTime();
|
|
1062
|
+
if (elapsed < ThreatCloudDB.CANARY_PERIOD_MS) {
|
|
1063
|
+
continue; // Still in canary period
|
|
1064
|
+
}
|
|
1065
|
+
// Survived canary period — promote to rule
|
|
819
1066
|
this.upsertRule({
|
|
820
1067
|
ruleId: proposal.pattern_hash,
|
|
821
1068
|
ruleContent: proposal.rule_content,
|
|
@@ -823,14 +1070,34 @@ export class ThreatCloudDB {
|
|
|
823
1070
|
source: 'atr-community',
|
|
824
1071
|
});
|
|
825
1072
|
this.db
|
|
826
|
-
.prepare(`
|
|
827
|
-
|
|
828
|
-
WHERE pattern_hash = ?
|
|
829
|
-
`)
|
|
1073
|
+
.prepare(`UPDATE atr_proposals SET status = 'promoted', updated_at = datetime('now')
|
|
1074
|
+
WHERE pattern_hash = ?`)
|
|
830
1075
|
.run(proposal.pattern_hash);
|
|
831
1076
|
promoted++;
|
|
832
1077
|
}
|
|
833
|
-
return promoted;
|
|
1078
|
+
return { promoted, quarantined };
|
|
1079
|
+
}
|
|
1080
|
+
/** Get count of negative feedback for a rule / 取得規則負面回饋數 */
|
|
1081
|
+
getNegativeFeedbackCount(ruleId) {
|
|
1082
|
+
return this.db
|
|
1083
|
+
.prepare('SELECT COUNT(*) as count FROM atr_feedback WHERE rule_id = ? AND is_true_positive = 0')
|
|
1084
|
+
.get(ruleId).count;
|
|
1085
|
+
}
|
|
1086
|
+
/** Quarantine a proposal / 隔離提案 */
|
|
1087
|
+
quarantineProposal(patternHash) {
|
|
1088
|
+
this.db
|
|
1089
|
+
.prepare(`UPDATE atr_proposals SET status = 'quarantined', updated_at = datetime('now')
|
|
1090
|
+
WHERE pattern_hash = ?`)
|
|
1091
|
+
.run(patternHash);
|
|
1092
|
+
}
|
|
1093
|
+
/** Get canary rules (for 10% client sampling) / 取得 canary 規則(供 10% 客戶端抽樣) */
|
|
1094
|
+
getCanaryATRRules() {
|
|
1095
|
+
return this.db
|
|
1096
|
+
.prepare(`SELECT pattern_hash as ruleId, rule_content as ruleContent, canary_started_at as publishedAt, 'atr-canary' as source
|
|
1097
|
+
FROM atr_proposals
|
|
1098
|
+
WHERE status = 'canary'
|
|
1099
|
+
ORDER BY canary_started_at ASC`)
|
|
1100
|
+
.all();
|
|
834
1101
|
}
|
|
835
1102
|
/** Reject an ATR proposal / 拒絕 ATR 提案 */
|
|
836
1103
|
rejectATRProposal(patternHash) {
|
|
@@ -952,36 +1219,57 @@ export class ThreatCloudDB {
|
|
|
952
1219
|
.run(skillName, normalized, fingerprintHash ?? null);
|
|
953
1220
|
}
|
|
954
1221
|
/** Get confirmed community whitelist / 取得社群白名單 */
|
|
955
|
-
getSkillWhitelist() {
|
|
1222
|
+
getSkillWhitelist(since) {
|
|
1223
|
+
if (since) {
|
|
1224
|
+
return this.db
|
|
1225
|
+
.prepare(`SELECT skill_name as name, fingerprint_hash as hash, confirmations
|
|
1226
|
+
FROM skill_whitelist
|
|
1227
|
+
WHERE status = 'confirmed' AND last_reported > ?
|
|
1228
|
+
ORDER BY last_reported DESC`)
|
|
1229
|
+
.all(since);
|
|
1230
|
+
}
|
|
956
1231
|
return this.db
|
|
957
|
-
.prepare(`
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
ORDER BY confirmations DESC
|
|
962
|
-
`)
|
|
1232
|
+
.prepare(`SELECT skill_name as name, fingerprint_hash as hash, confirmations
|
|
1233
|
+
FROM skill_whitelist
|
|
1234
|
+
WHERE status = 'confirmed'
|
|
1235
|
+
ORDER BY confirmations DESC`)
|
|
963
1236
|
.all();
|
|
964
1237
|
}
|
|
965
1238
|
/**
|
|
966
1239
|
* Get skill blacklist: skills reported by 3+ distinct clients with avg risk >= 70
|
|
967
1240
|
* 取得技能黑名單:3+ 不同客戶端回報且平均風險 >= 70 的技能
|
|
968
1241
|
*/
|
|
969
|
-
getSkillBlacklist(minReports = 3, minAvgRisk = 70) {
|
|
1242
|
+
getSkillBlacklist(minReports = 3, minAvgRisk = 70, since) {
|
|
1243
|
+
if (since) {
|
|
1244
|
+
// Incremental: only entries with new reports since the given timestamp
|
|
1245
|
+
return this.db
|
|
1246
|
+
.prepare(`SELECT
|
|
1247
|
+
skill_hash as skillHash,
|
|
1248
|
+
skill_name as skillName,
|
|
1249
|
+
ROUND(AVG(risk_score)) as avgRiskScore,
|
|
1250
|
+
MAX(risk_level) as maxRiskLevel,
|
|
1251
|
+
COUNT(DISTINCT COALESCE(client_id, 'anonymous')) as reportCount,
|
|
1252
|
+
MIN(created_at) as firstReported,
|
|
1253
|
+
MAX(created_at) as lastReported
|
|
1254
|
+
FROM skill_threats
|
|
1255
|
+
GROUP BY skill_hash
|
|
1256
|
+
HAVING reportCount >= ? AND AVG(risk_score) >= ? AND MAX(created_at) > ?
|
|
1257
|
+
ORDER BY lastReported DESC`)
|
|
1258
|
+
.all(minReports, minAvgRisk, since);
|
|
1259
|
+
}
|
|
970
1260
|
return this.db
|
|
971
|
-
.prepare(`
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
ORDER BY avgRiskScore DESC
|
|
984
|
-
`)
|
|
1261
|
+
.prepare(`SELECT
|
|
1262
|
+
skill_hash as skillHash,
|
|
1263
|
+
skill_name as skillName,
|
|
1264
|
+
ROUND(AVG(risk_score)) as avgRiskScore,
|
|
1265
|
+
MAX(risk_level) as maxRiskLevel,
|
|
1266
|
+
COUNT(DISTINCT COALESCE(client_id, 'anonymous')) as reportCount,
|
|
1267
|
+
MIN(created_at) as firstReported,
|
|
1268
|
+
MAX(created_at) as lastReported
|
|
1269
|
+
FROM skill_threats
|
|
1270
|
+
GROUP BY skill_hash
|
|
1271
|
+
HAVING reportCount >= ? AND AVG(risk_score) >= ?
|
|
1272
|
+
ORDER BY avgRiskScore DESC`)
|
|
985
1273
|
.all(minReports, minAvgRisk);
|
|
986
1274
|
}
|
|
987
1275
|
/** Backfill classification for rules with NULL category / 回填缺少分類的規則 */
|
|
@@ -1007,17 +1295,21 @@ export class ThreatCloudDB {
|
|
|
1007
1295
|
* 取得尚未被 LLM 審查的待處理提案(用於重試)
|
|
1008
1296
|
*/
|
|
1009
1297
|
getUnreviewedProposals(limit = 5) {
|
|
1298
|
+
// Retry pending proposals that either (a) have never been reviewed, or
|
|
1299
|
+
// (b) previously hit a transient error (rate limit, timeout, network).
|
|
1300
|
+
// A legitimate `approved: false` verdict is NOT retried — that's a terminal
|
|
1301
|
+
// rejection and handled by rejectATRProposal() in the reviewer; leaving it
|
|
1302
|
+
// in the retry pool would loop forever and waste LLM API quota.
|
|
1010
1303
|
return this.db
|
|
1011
1304
|
.prepare(`SELECT pattern_hash as patternHash, rule_content as ruleContent
|
|
1012
1305
|
FROM atr_proposals
|
|
1013
1306
|
WHERE status = 'pending'
|
|
1014
1307
|
AND (llm_review_verdict IS NULL
|
|
1015
|
-
OR llm_review_verdict LIKE '%
|
|
1016
|
-
OR llm_review_verdict LIKE '%failed%'
|
|
1017
|
-
OR llm_review_verdict LIKE '%error%'
|
|
1308
|
+
OR llm_review_verdict LIKE '%LLM review failed%'
|
|
1018
1309
|
OR llm_review_verdict LIKE '%rate_limit%'
|
|
1019
1310
|
OR llm_review_verdict LIKE '%429%'
|
|
1020
|
-
OR llm_review_verdict LIKE '%timed out%'
|
|
1311
|
+
OR llm_review_verdict LIKE '%timed out%'
|
|
1312
|
+
OR llm_review_verdict LIKE '%503%')
|
|
1021
1313
|
ORDER BY created_at ASC
|
|
1022
1314
|
LIMIT ?`)
|
|
1023
1315
|
.all(limit);
|
|
@@ -1029,6 +1321,34 @@ export class ThreatCloudDB {
|
|
|
1029
1321
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1030
1322
|
.run(event.source, event.skillsScanned, event.findingsCount, event.confirmedMalicious, event.highlySuspicious, event.generalSuspicious, event.cleanCount, event.deviceHash ?? null);
|
|
1031
1323
|
}
|
|
1324
|
+
/** Record a one-time activation event / 記錄一次性啟動事件 */
|
|
1325
|
+
recordActivation(activation) {
|
|
1326
|
+
try {
|
|
1327
|
+
this.db
|
|
1328
|
+
.prepare(`INSERT OR IGNORE INTO activations (client_id, platform, os_type, panguard_version, node_version)
|
|
1329
|
+
VALUES (?, ?, ?, ?, ?)`)
|
|
1330
|
+
.run(activation.clientId, activation.platform, activation.osType, activation.panguardVersion, activation.nodeVersion);
|
|
1331
|
+
return true;
|
|
1332
|
+
}
|
|
1333
|
+
catch {
|
|
1334
|
+
return false;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
/** Get activation stats / 取得啟動統計 */
|
|
1338
|
+
getActivationStats() {
|
|
1339
|
+
const total = this.db.prepare(`SELECT COUNT(*) as count FROM activations`).get().count;
|
|
1340
|
+
const byPlatform = this.db
|
|
1341
|
+
.prepare(`SELECT platform, COUNT(*) as count FROM activations GROUP BY platform ORDER BY count DESC`)
|
|
1342
|
+
.all();
|
|
1343
|
+
const byOs = this.db
|
|
1344
|
+
.prepare(`SELECT os_type as osType, COUNT(*) as count FROM activations GROUP BY os_type ORDER BY count DESC`)
|
|
1345
|
+
.all();
|
|
1346
|
+
const recent = this.db
|
|
1347
|
+
.prepare(`SELECT activated_at as activatedAt, platform, os_type as osType, panguard_version as panguardVersion
|
|
1348
|
+
FROM activations ORDER BY activated_at DESC LIMIT 20`)
|
|
1349
|
+
.all();
|
|
1350
|
+
return { total, byPlatform, byOs, recent };
|
|
1351
|
+
}
|
|
1032
1352
|
/** Get contributor leaderboard (hashed IDs, no PII) / 取得貢獻者排行榜 */
|
|
1033
1353
|
getContributorLeaderboard(limit = 20) {
|
|
1034
1354
|
// Hash client_id with SHA-256 for privacy
|
|
@@ -1131,6 +1451,238 @@ export class ThreatCloudDB {
|
|
|
1131
1451
|
lastUpdated: new Date().toISOString(),
|
|
1132
1452
|
};
|
|
1133
1453
|
}
|
|
1454
|
+
// ── Verdict Cache / 判定快取 ──────────────────────────────────
|
|
1455
|
+
/** Look up a cached verdict by content hash. Returns null if miss or expired. */
|
|
1456
|
+
getVerdictCache(contentHash) {
|
|
1457
|
+
const row = this.db
|
|
1458
|
+
.prepare(`SELECT content_hash, skill_name, verdict, scanned_at, scan_count
|
|
1459
|
+
FROM verdict_cache
|
|
1460
|
+
WHERE content_hash = ? AND expires_at > datetime('now')`)
|
|
1461
|
+
.get(contentHash);
|
|
1462
|
+
if (!row)
|
|
1463
|
+
return null;
|
|
1464
|
+
// Increment scan_count on cache hit
|
|
1465
|
+
this.db
|
|
1466
|
+
.prepare(`UPDATE verdict_cache SET scan_count = scan_count + 1 WHERE content_hash = ?`)
|
|
1467
|
+
.run(contentHash);
|
|
1468
|
+
return {
|
|
1469
|
+
contentHash: row.content_hash,
|
|
1470
|
+
skillName: row.skill_name,
|
|
1471
|
+
verdict: row.verdict,
|
|
1472
|
+
scannedAt: row.scanned_at,
|
|
1473
|
+
scanCount: row.scan_count + 1,
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
/** Insert or replace a verdict cache entry with 30-day TTL. */
|
|
1477
|
+
insertVerdictCache(entry) {
|
|
1478
|
+
this.db
|
|
1479
|
+
.prepare(`INSERT OR REPLACE INTO verdict_cache (content_hash, skill_name, verdict, scanned_at, expires_at, scan_count)
|
|
1480
|
+
VALUES (?, ?, ?, datetime('now'), datetime('now', '+30 days'), 1)`)
|
|
1481
|
+
.run(entry.contentHash, entry.skillName, entry.verdict);
|
|
1482
|
+
}
|
|
1483
|
+
/** Delete a specific cache entry. Returns true if a row was deleted. */
|
|
1484
|
+
invalidateVerdictCache(contentHash) {
|
|
1485
|
+
const result = this.db
|
|
1486
|
+
.prepare(`DELETE FROM verdict_cache WHERE content_hash = ?`)
|
|
1487
|
+
.run(contentHash);
|
|
1488
|
+
return result.changes > 0;
|
|
1489
|
+
}
|
|
1490
|
+
/** Purge all expired cache entries. Returns count deleted. */
|
|
1491
|
+
purgeExpiredVerdictCache() {
|
|
1492
|
+
const result = this.db
|
|
1493
|
+
.prepare(`DELETE FROM verdict_cache WHERE expires_at <= datetime('now')`)
|
|
1494
|
+
.run();
|
|
1495
|
+
return result.changes;
|
|
1496
|
+
}
|
|
1497
|
+
// ── Skill Hash History / 技能雜湊歷史 ────────────────────────
|
|
1498
|
+
/** Get the latest (non-superseded) hash entry for a skill. */
|
|
1499
|
+
getLatestSkillHash(skillName) {
|
|
1500
|
+
const row = this.db
|
|
1501
|
+
.prepare(`SELECT content_hash, scan_verdict
|
|
1502
|
+
FROM skill_hash_history
|
|
1503
|
+
WHERE skill_name = ? AND superseded_by IS NULL
|
|
1504
|
+
ORDER BY last_seen DESC
|
|
1505
|
+
LIMIT 1`)
|
|
1506
|
+
.get(skillName);
|
|
1507
|
+
if (!row)
|
|
1508
|
+
return null;
|
|
1509
|
+
return { contentHash: row.content_hash, scanVerdict: row.scan_verdict };
|
|
1510
|
+
}
|
|
1511
|
+
/** Record a skill hash observation. Creates or updates last_seen. */
|
|
1512
|
+
recordSkillHash(entry) {
|
|
1513
|
+
this.db
|
|
1514
|
+
.prepare(`INSERT INTO skill_hash_history (skill_name, content_hash, scan_verdict, rug_pull_flag)
|
|
1515
|
+
VALUES (?, ?, ?, ?)
|
|
1516
|
+
ON CONFLICT(skill_name, content_hash) DO UPDATE SET
|
|
1517
|
+
last_seen = datetime('now'),
|
|
1518
|
+
scan_verdict = COALESCE(excluded.scan_verdict, scan_verdict)`)
|
|
1519
|
+
.run(entry.skillName, entry.contentHash, entry.scanVerdict ?? null, entry.rugPullFlag ? 1 : 0);
|
|
1520
|
+
}
|
|
1521
|
+
/** Mark a previous hash as superseded by a new hash. */
|
|
1522
|
+
markSkillHashSuperseded(skillName, oldHash, newHash) {
|
|
1523
|
+
this.db
|
|
1524
|
+
.prepare(`UPDATE skill_hash_history
|
|
1525
|
+
SET superseded_by = ?
|
|
1526
|
+
WHERE skill_name = ? AND content_hash = ?`)
|
|
1527
|
+
.run(newHash, skillName, oldHash);
|
|
1528
|
+
}
|
|
1529
|
+
/** Get full hash history for a skill (audit trail). */
|
|
1530
|
+
getSkillHashHistory(skillName) {
|
|
1531
|
+
const rows = this.db
|
|
1532
|
+
.prepare(`SELECT content_hash, first_seen, last_seen, scan_verdict, superseded_by, rug_pull_flag
|
|
1533
|
+
FROM skill_hash_history
|
|
1534
|
+
WHERE skill_name = ?
|
|
1535
|
+
ORDER BY first_seen ASC`)
|
|
1536
|
+
.all(skillName);
|
|
1537
|
+
return rows.map((r) => ({
|
|
1538
|
+
contentHash: r.content_hash,
|
|
1539
|
+
firstSeen: r.first_seen,
|
|
1540
|
+
lastSeen: r.last_seen,
|
|
1541
|
+
scanVerdict: r.scan_verdict,
|
|
1542
|
+
supersededBy: r.superseded_by,
|
|
1543
|
+
rugPullFlag: r.rug_pull_flag === 1,
|
|
1544
|
+
}));
|
|
1545
|
+
}
|
|
1546
|
+
// ─── Org / Device / Policy (Threat Model #1, #6) ───────────────
|
|
1547
|
+
/** Create an organization / 建立組織 */
|
|
1548
|
+
createOrg(id, name) {
|
|
1549
|
+
this.db.prepare('INSERT OR IGNORE INTO orgs (id, name) VALUES (?, ?)').run(id, name);
|
|
1550
|
+
}
|
|
1551
|
+
/** Get org by ID / 取得組織 */
|
|
1552
|
+
getOrg(id) {
|
|
1553
|
+
return this.db.prepare('SELECT * FROM orgs WHERE id = ?').get(id);
|
|
1554
|
+
}
|
|
1555
|
+
/** Upsert device heartbeat / 更新裝置心跳
|
|
1556
|
+
* Threat Model: Fleet view (#6 Scope Escalation — org-level visibility) */
|
|
1557
|
+
upsertDevice(data) {
|
|
1558
|
+
this.db
|
|
1559
|
+
.prepare(`INSERT INTO devices (id, org_id, hostname, os_type, agent_count, guard_version, last_seen)
|
|
1560
|
+
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
|
1561
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1562
|
+
org_id = excluded.org_id,
|
|
1563
|
+
hostname = COALESCE(excluded.hostname, devices.hostname),
|
|
1564
|
+
os_type = COALESCE(excluded.os_type, devices.os_type),
|
|
1565
|
+
agent_count = excluded.agent_count,
|
|
1566
|
+
guard_version = COALESCE(excluded.guard_version, devices.guard_version),
|
|
1567
|
+
last_seen = datetime('now')`)
|
|
1568
|
+
.run(data.deviceId, data.orgId, data.hostname ?? null, data.osType ?? null, data.agentCount ?? 0, data.guardVersion ?? null);
|
|
1569
|
+
}
|
|
1570
|
+
/** List devices for an org / 列出組織下的裝置
|
|
1571
|
+
* Threat Model: Fleet view (#6) */
|
|
1572
|
+
getDevicesByOrg(orgId, limit = 100, offset = 0) {
|
|
1573
|
+
return this.db
|
|
1574
|
+
.prepare('SELECT id, hostname, os_type, agent_count, guard_version, last_seen, created_at FROM devices WHERE org_id = ? ORDER BY last_seen DESC LIMIT ? OFFSET ?')
|
|
1575
|
+
.all(orgId, limit, offset);
|
|
1576
|
+
}
|
|
1577
|
+
/** Get device count for an org / 取得組織裝置數 */
|
|
1578
|
+
getDeviceCount(orgId) {
|
|
1579
|
+
return this.db.prepare('SELECT COUNT(*) as count FROM devices WHERE org_id = ?').get(orgId).count;
|
|
1580
|
+
}
|
|
1581
|
+
/** Set org policy / 設定組織策略
|
|
1582
|
+
* Threat Model: Policy engine (#1 Supply Chain, #6 Scope Escalation) */
|
|
1583
|
+
setOrgPolicy(orgId, category, action) {
|
|
1584
|
+
this.db
|
|
1585
|
+
.prepare(`INSERT INTO org_policies (org_id, category, action)
|
|
1586
|
+
VALUES (?, ?, ?)
|
|
1587
|
+
ON CONFLICT(org_id, category) DO UPDATE SET action = excluded.action`)
|
|
1588
|
+
.run(orgId, category, action);
|
|
1589
|
+
}
|
|
1590
|
+
/** Get org policies / 取得組織策略 */
|
|
1591
|
+
getOrgPolicies(orgId) {
|
|
1592
|
+
return this.db
|
|
1593
|
+
.prepare('SELECT category, action, created_at FROM org_policies WHERE org_id = ? ORDER BY category')
|
|
1594
|
+
.all(orgId);
|
|
1595
|
+
}
|
|
1596
|
+
/** Delete org policy / 刪除組織策略 */
|
|
1597
|
+
deleteOrgPolicy(orgId, category) {
|
|
1598
|
+
const result = this.db
|
|
1599
|
+
.prepare('DELETE FROM org_policies WHERE org_id = ? AND category = ?')
|
|
1600
|
+
.run(orgId, category);
|
|
1601
|
+
return result.changes > 0;
|
|
1602
|
+
}
|
|
1603
|
+
// ---------------------------------------------------------------------------
|
|
1604
|
+
// Client Keys / 客戶端金鑰
|
|
1605
|
+
// ---------------------------------------------------------------------------
|
|
1606
|
+
/** Register a new client key. Returns the raw key (only time it's visible). */
|
|
1607
|
+
registerClientKey(clientId, ipAddress) {
|
|
1608
|
+
// Limit to 5 active keys per clientId to prevent DB flooding
|
|
1609
|
+
const existing = this.db
|
|
1610
|
+
.prepare('SELECT COUNT(*) as cnt FROM client_keys WHERE client_id = ? AND revoked = 0')
|
|
1611
|
+
.get(clientId);
|
|
1612
|
+
if (existing.cnt >= 5) {
|
|
1613
|
+
// Revoke oldest key to make room
|
|
1614
|
+
this.db
|
|
1615
|
+
.prepare(`UPDATE client_keys SET revoked = 1, revoked_at = datetime('now')
|
|
1616
|
+
WHERE id = (SELECT id FROM client_keys WHERE client_id = ? AND revoked = 0 ORDER BY created_at ASC LIMIT 1)`)
|
|
1617
|
+
.run(clientId);
|
|
1618
|
+
}
|
|
1619
|
+
const clientKey = randomUUID();
|
|
1620
|
+
const hash = createHash('sha256').update(clientKey).digest('hex');
|
|
1621
|
+
this.db
|
|
1622
|
+
.prepare('INSERT INTO client_keys (client_id, client_key_hash, ip_address) VALUES (?, ?, ?)')
|
|
1623
|
+
.run(clientId, hash, ipAddress);
|
|
1624
|
+
return { clientKey };
|
|
1625
|
+
}
|
|
1626
|
+
/** Validate a raw client key. Updates last_used_at on success. */
|
|
1627
|
+
validateClientKey(rawKey) {
|
|
1628
|
+
const hash = createHash('sha256').update(rawKey).digest('hex');
|
|
1629
|
+
const row = this.db
|
|
1630
|
+
.prepare('SELECT id FROM client_keys WHERE client_key_hash = ? AND revoked = 0')
|
|
1631
|
+
.get(hash);
|
|
1632
|
+
if (!row)
|
|
1633
|
+
return false;
|
|
1634
|
+
this.db
|
|
1635
|
+
.prepare("UPDATE client_keys SET last_used_at = datetime('now') WHERE id = ?")
|
|
1636
|
+
.run(row.id);
|
|
1637
|
+
return true;
|
|
1638
|
+
}
|
|
1639
|
+
/**
|
|
1640
|
+
* Look up role + clientId for a raw client key. Returns null if invalid/revoked.
|
|
1641
|
+
* Does NOT update last_used_at (caller should use validateClientKey for that).
|
|
1642
|
+
* Used by L5 partner-sync endpoints to enforce role-based access.
|
|
1643
|
+
*/
|
|
1644
|
+
getClientKeyInfo(rawKey) {
|
|
1645
|
+
const hash = createHash('sha256').update(rawKey).digest('hex');
|
|
1646
|
+
const row = this.db
|
|
1647
|
+
.prepare('SELECT client_id as clientId, COALESCE(key_role, ?) as role FROM client_keys WHERE client_key_hash = ? AND revoked = 0')
|
|
1648
|
+
.get('guard', hash);
|
|
1649
|
+
return row ?? null;
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Issue a partner key. Partner keys are manually issued by admin and can
|
|
1653
|
+
* access L5 live-sync endpoints (e.g. /api/atr-rules/live). Returns the
|
|
1654
|
+
* raw key once — caller must store it securely.
|
|
1655
|
+
*/
|
|
1656
|
+
registerPartnerKey(partnerName, issuedBy) {
|
|
1657
|
+
const clientId = `partner:${partnerName}`;
|
|
1658
|
+
const clientKey = randomUUID();
|
|
1659
|
+
const hash = createHash('sha256').update(clientKey).digest('hex');
|
|
1660
|
+
this.db
|
|
1661
|
+
.prepare('INSERT INTO client_keys (client_id, client_key_hash, ip_address, key_role) VALUES (?, ?, ?, ?)')
|
|
1662
|
+
.run(clientId, hash, `issued-by:${issuedBy}`, 'partner');
|
|
1663
|
+
return { clientKey };
|
|
1664
|
+
}
|
|
1665
|
+
/** Revoke all keys for a client. Returns number of keys revoked. */
|
|
1666
|
+
revokeClientKey(clientId) {
|
|
1667
|
+
const result = this.db
|
|
1668
|
+
.prepare("UPDATE client_keys SET revoked = 1, revoked_at = datetime('now') WHERE client_id = ? AND revoked = 0")
|
|
1669
|
+
.run(clientId);
|
|
1670
|
+
return result.changes;
|
|
1671
|
+
}
|
|
1672
|
+
/** List client keys (admin). Never returns raw keys. */
|
|
1673
|
+
listClientKeys(limit = 50, offset = 0) {
|
|
1674
|
+
const rows = this.db
|
|
1675
|
+
.prepare('SELECT id, client_id, created_at, last_used_at, revoked, ip_address FROM client_keys ORDER BY created_at DESC LIMIT ? OFFSET ?')
|
|
1676
|
+
.all(limit, offset);
|
|
1677
|
+
return rows.map((r) => ({
|
|
1678
|
+
id: r.id,
|
|
1679
|
+
clientId: r.client_id,
|
|
1680
|
+
createdAt: r.created_at,
|
|
1681
|
+
lastUsedAt: r.last_used_at,
|
|
1682
|
+
revoked: r.revoked === 1,
|
|
1683
|
+
ipAddress: r.ip_address,
|
|
1684
|
+
}));
|
|
1685
|
+
}
|
|
1134
1686
|
/** Close the database / 關閉資料庫 */
|
|
1135
1687
|
close() {
|
|
1136
1688
|
this.db.close();
|