@panguard-ai/threat-cloud 0.2.1 → 0.2.2

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/database.js CHANGED
@@ -39,6 +39,10 @@ export class ThreatCloudDB {
39
39
  rule_content TEXT NOT NULL,
40
40
  published_at TEXT NOT NULL,
41
41
  source TEXT NOT NULL,
42
+ category TEXT,
43
+ severity TEXT,
44
+ mitre_techniques TEXT,
45
+ tags TEXT,
42
46
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
43
47
  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
44
48
  );
@@ -104,6 +108,15 @@ export class ThreatCloudDB {
104
108
  last_reported TEXT DEFAULT (datetime('now'))
105
109
  );
106
110
 
111
+ -- Migration: add classification columns to existing rules table
112
+ -- SQLite allows ADD COLUMN on existing tables; IF NOT EXISTS not supported,
113
+ -- so we catch errors for already-existing columns in migrate().
114
+ `);
115
+ this.migrate();
116
+ this.db.exec(`
117
+ CREATE INDEX IF NOT EXISTS idx_rules_category ON rules(category);
118
+ CREATE INDEX IF NOT EXISTS idx_rules_severity ON rules(severity);
119
+ CREATE INDEX IF NOT EXISTS idx_rules_source ON rules(source);
107
120
  CREATE INDEX IF NOT EXISTS idx_atr_proposals_status ON atr_proposals(status);
108
121
  CREATE INDEX IF NOT EXISTS idx_atr_proposals_pattern ON atr_proposals(pattern_hash);
109
122
  CREATE INDEX IF NOT EXISTS idx_skill_threats_hash ON skill_threats(skill_hash);
@@ -114,6 +127,21 @@ export class ThreatCloudDB {
114
127
  CREATE INDEX IF NOT EXISTS idx_skill_whitelist_name ON skill_whitelist(normalized_name);
115
128
  `);
116
129
  }
130
+ /** Run schema migrations for existing databases / 執行既有資料庫的 schema 遷移 */
131
+ migrate() {
132
+ const addColumn = (table, column, type) => {
133
+ try {
134
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
135
+ }
136
+ catch {
137
+ // Column already exists — safe to ignore
138
+ }
139
+ };
140
+ addColumn('rules', 'category', 'TEXT');
141
+ addColumn('rules', 'severity', 'TEXT');
142
+ addColumn('rules', 'mitre_techniques', 'TEXT');
143
+ addColumn('rules', 'tags', 'TEXT');
144
+ }
117
145
  /** Insert anonymized threat data / 插入匿名化威脅數據 */
118
146
  insertThreat(data) {
119
147
  const stmt = this.db.prepare(`
@@ -122,38 +150,209 @@ export class ThreatCloudDB {
122
150
  `);
123
151
  stmt.run(data.attackSourceIP, data.attackType, data.mitreTechnique, data.sigmaRuleMatched, data.timestamp, data.industry ?? null, data.region);
124
152
  }
153
+ /** Extract classification metadata from rule content / 從規則內容提取分類元資料 */
154
+ extractMetadata(ruleContent, source) {
155
+ let category = 'unknown';
156
+ let severity = 'medium';
157
+ let mitreTechniques = '';
158
+ let tags = '';
159
+ try {
160
+ if (source === 'yara') {
161
+ // YARA rules: extract from meta section
162
+ const metaMatch = ruleContent.match(/meta\s*:\s*([\s\S]*?)(?:strings|condition)\s*:/);
163
+ if (metaMatch) {
164
+ const meta = metaMatch[1];
165
+ const catMatch = meta.match(/category\s*=\s*"([^"]+)"/);
166
+ if (catMatch)
167
+ category = catMatch[1].toLowerCase();
168
+ const sevMatch = meta.match(/severity\s*=\s*"([^"]+)"/);
169
+ if (sevMatch)
170
+ severity = sevMatch[1].toLowerCase();
171
+ const mitreMatch = meta.match(/mitre_att(?:ack|&ck)\s*=\s*"([^"]+)"/i);
172
+ if (mitreMatch)
173
+ mitreTechniques = mitreMatch[1];
174
+ }
175
+ // Fallback: infer category from rule content keywords
176
+ if (category === 'unknown') {
177
+ if (/malware|trojan|ransom|backdoor|rat_|infostealer/i.test(ruleContent))
178
+ category = 'malware';
179
+ else if (/exploit|cve-/i.test(ruleContent))
180
+ category = 'exploit';
181
+ else if (/hack_?tool|offensive|cobalt/i.test(ruleContent))
182
+ category = 'hacktool';
183
+ else if (/webshell/i.test(ruleContent))
184
+ category = 'webshell';
185
+ else if (/packer|obfusc|crypter/i.test(ruleContent))
186
+ category = 'packer';
187
+ }
188
+ }
189
+ else {
190
+ // Sigma / ATR: YAML-based extraction
191
+ // Extract level/severity
192
+ const sevMatch = ruleContent.match(/(?:^|\n)\s*(?:level|severity)\s*:\s*(\w+)/);
193
+ if (sevMatch)
194
+ severity = sevMatch[1].toLowerCase();
195
+ // Extract tags list
196
+ const tagMatch = ruleContent.match(/(?:^|\n)\s*tags\s*:\s*\n((?:\s+-\s*.+\n?)+)/);
197
+ if (tagMatch) {
198
+ const tagLines = tagMatch[1].match(/-\s*(.+)/g) ?? [];
199
+ const tagList = tagLines.map((t) => t.replace(/^-\s*/, '').trim());
200
+ tags = tagList.join(',');
201
+ // Derive MITRE techniques from tags (attack.tXXXX)
202
+ const mitreTags = tagList.filter((t) => /^attack\.t\d+/i.test(t));
203
+ if (mitreTags.length > 0) {
204
+ mitreTechniques = mitreTags
205
+ .map((t) => t.replace(/^attack\./i, '').toUpperCase())
206
+ .join(',');
207
+ }
208
+ // Derive category from MITRE ATT&CK tactic tags
209
+ const attackTags = tagList.filter((t) => t.startsWith('attack.'));
210
+ for (const tag of attackTags) {
211
+ if (/initial.access/i.test(tag)) {
212
+ category = 'initial-access';
213
+ break;
214
+ }
215
+ if (/execution/i.test(tag)) {
216
+ category = 'execution';
217
+ break;
218
+ }
219
+ if (/persistence/i.test(tag)) {
220
+ category = 'persistence';
221
+ break;
222
+ }
223
+ if (/privilege.escalation/i.test(tag)) {
224
+ category = 'privilege-escalation';
225
+ break;
226
+ }
227
+ if (/defense.evasion/i.test(tag)) {
228
+ category = 'defense-evasion';
229
+ break;
230
+ }
231
+ if (/credential.access/i.test(tag)) {
232
+ category = 'credential-access';
233
+ break;
234
+ }
235
+ if (/discovery/i.test(tag)) {
236
+ category = 'discovery';
237
+ break;
238
+ }
239
+ if (/lateral.movement/i.test(tag)) {
240
+ category = 'lateral-movement';
241
+ break;
242
+ }
243
+ if (/collection/i.test(tag)) {
244
+ category = 'collection';
245
+ break;
246
+ }
247
+ if (/exfiltration/i.test(tag)) {
248
+ category = 'exfiltration';
249
+ break;
250
+ }
251
+ if (/command.and.control|c2/i.test(tag)) {
252
+ category = 'command-and-control';
253
+ break;
254
+ }
255
+ if (/impact/i.test(tag)) {
256
+ category = 'impact';
257
+ break;
258
+ }
259
+ if (/resource.development/i.test(tag)) {
260
+ category = 'resource-development';
261
+ break;
262
+ }
263
+ if (/reconnaissance/i.test(tag)) {
264
+ category = 'reconnaissance';
265
+ break;
266
+ }
267
+ }
268
+ }
269
+ // Fallback: infer from logsource (Sigma)
270
+ if (category === 'unknown') {
271
+ const lsCatMatch = ruleContent.match(/(?:^|\n)\s*logsource\s*:\s*\n(?:\s+\w+\s*:.+\n)*?\s+category\s*:\s*(\S+)/);
272
+ if (lsCatMatch) {
273
+ category = lsCatMatch[1].toLowerCase();
274
+ }
275
+ else {
276
+ const lsProdMatch = ruleContent.match(/(?:^|\n)\s*logsource\s*:\s*\n(?:\s+\w+\s*:.+\n)*?\s+product\s*:\s*(\S+)/);
277
+ if (lsProdMatch)
278
+ category = lsProdMatch[1].toLowerCase();
279
+ }
280
+ }
281
+ }
282
+ }
283
+ catch {
284
+ // Extraction failed — keep defaults
285
+ }
286
+ // Normalize category to lowercase
287
+ category = category.toLowerCase();
288
+ severity = severity.toLowerCase();
289
+ return { category, severity, mitreTechniques, tags };
290
+ }
125
291
  /** Insert or update a community rule / 插入或更新社群規則 */
126
292
  upsertRule(rule) {
293
+ // Extract classification from content if not provided
294
+ const meta = this.extractMetadata(rule.ruleContent, rule.source);
295
+ const category = rule.category ?? meta.category;
296
+ const severity = rule.severity ?? meta.severity;
297
+ const mitreTechniques = rule.mitreTechniques ?? meta.mitreTechniques;
298
+ const tags = rule.tags ?? meta.tags;
127
299
  const stmt = this.db.prepare(`
128
- INSERT INTO rules (rule_id, rule_content, published_at, source)
129
- VALUES (?, ?, ?, ?)
300
+ INSERT INTO rules (rule_id, rule_content, published_at, source, category, severity, mitre_techniques, tags)
301
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
130
302
  ON CONFLICT(rule_id) DO UPDATE SET
131
303
  rule_content = excluded.rule_content,
132
304
  published_at = excluded.published_at,
133
305
  source = excluded.source,
306
+ category = excluded.category,
307
+ severity = excluded.severity,
308
+ mitre_techniques = excluded.mitre_techniques,
309
+ tags = excluded.tags,
134
310
  updated_at = datetime('now')
135
311
  `);
136
- stmt.run(rule.ruleId, rule.ruleContent, rule.publishedAt, rule.source);
312
+ stmt.run(rule.ruleId, rule.ruleContent, rule.publishedAt, rule.source, category, severity, mitreTechniques, tags);
137
313
  }
138
314
  /** Fetch rules published after a given timestamp / 取得指定時間後發佈的規則 */
139
- getRulesSince(since) {
140
- const stmt = this.db.prepare(`
141
- SELECT rule_id as ruleId, rule_content as ruleContent, published_at as publishedAt, source
142
- FROM rules
143
- WHERE published_at > ?
144
- ORDER BY published_at ASC
145
- `);
146
- return stmt.all(since);
315
+ getRulesSince(since, filters) {
316
+ let sql = `SELECT rule_id as ruleId, rule_content as ruleContent, published_at as publishedAt, source,
317
+ category, severity, mitre_techniques as mitreTechniques, tags
318
+ FROM rules WHERE published_at > ?`;
319
+ const params = [since];
320
+ if (filters?.category) {
321
+ sql += ' AND category = ?';
322
+ params.push(filters.category);
323
+ }
324
+ if (filters?.severity) {
325
+ sql += ' AND severity = ?';
326
+ params.push(filters.severity);
327
+ }
328
+ if (filters?.source) {
329
+ sql += ' AND source = ?';
330
+ params.push(filters.source);
331
+ }
332
+ sql += ' ORDER BY published_at ASC';
333
+ return this.db.prepare(sql).all(...params);
147
334
  }
148
335
  /** Fetch all rules with limit / 取得所有規則(含限制) */
149
- getAllRules(limit = 5000) {
150
- const stmt = this.db.prepare(`
151
- SELECT rule_id as ruleId, rule_content as ruleContent, published_at as publishedAt, source
152
- FROM rules
153
- ORDER BY published_at DESC
154
- LIMIT ?
155
- `);
156
- return stmt.all(limit);
336
+ getAllRules(limit = 5000, filters) {
337
+ let sql = `SELECT rule_id as ruleId, rule_content as ruleContent, published_at as publishedAt, source,
338
+ category, severity, mitre_techniques as mitreTechniques, tags
339
+ FROM rules WHERE 1=1`;
340
+ const params = [];
341
+ if (filters?.category) {
342
+ sql += ' AND category = ?';
343
+ params.push(filters.category);
344
+ }
345
+ if (filters?.severity) {
346
+ sql += ' AND severity = ?';
347
+ params.push(filters.severity);
348
+ }
349
+ if (filters?.source) {
350
+ sql += ' AND source = ?';
351
+ params.push(filters.source);
352
+ }
353
+ sql += ' ORDER BY published_at DESC LIMIT ?';
354
+ params.push(limit);
355
+ return this.db.prepare(sql).all(...params);
157
356
  }
158
357
  /** Insert ATR rule proposal / 插入 ATR 規則提案 */
159
358
  insertATRProposal(proposal) {
@@ -166,36 +365,48 @@ export class ThreatCloudDB {
166
365
  /** Get ATR proposals, optionally filtered by status / 取得 ATR 提案 */
167
366
  getATRProposals(status) {
168
367
  if (status) {
169
- return this.db.prepare('SELECT * FROM atr_proposals WHERE status = ? ORDER BY created_at DESC').all(status);
368
+ return this.db
369
+ .prepare('SELECT * FROM atr_proposals WHERE status = ? ORDER BY created_at DESC')
370
+ .all(status);
170
371
  }
171
372
  return this.db.prepare('SELECT * FROM atr_proposals ORDER BY created_at DESC').all();
172
373
  }
173
374
  /** Increment confirmations for a proposal; auto-confirm at >= 3 / 增加提案確認數 */
174
375
  confirmATRProposal(patternHash) {
175
- this.db.prepare(`
376
+ this.db
377
+ .prepare(`
176
378
  UPDATE atr_proposals
177
379
  SET confirmations = confirmations + 1,
178
380
  status = CASE WHEN confirmations + 1 >= 3 THEN 'confirmed' ELSE status END,
179
381
  updated_at = datetime('now')
180
382
  WHERE pattern_hash = ?
181
- `).run(patternHash);
383
+ `)
384
+ .run(patternHash);
182
385
  }
183
386
  /** Update LLM review verdict for a proposal / 更新 LLM 審查結果 */
184
387
  updateATRProposalLLMReview(patternHash, verdict) {
185
- this.db.prepare(`
388
+ this.db
389
+ .prepare(`
186
390
  UPDATE atr_proposals SET llm_review_verdict = ?, updated_at = datetime('now') WHERE pattern_hash = ?
187
- `).run(verdict, patternHash);
391
+ `)
392
+ .run(verdict, patternHash);
188
393
  }
189
394
  /** Insert ATR feedback / 插入 ATR 回饋 */
190
395
  insertATRFeedback(ruleId, isTruePositive, clientId) {
191
- this.db.prepare(`
396
+ this.db
397
+ .prepare(`
192
398
  INSERT INTO atr_feedback (rule_id, is_true_positive, client_id) VALUES (?, ?, ?)
193
- `).run(ruleId, isTruePositive ? 1 : 0, clientId ?? null);
399
+ `)
400
+ .run(ruleId, isTruePositive ? 1 : 0, clientId ?? null);
194
401
  }
195
402
  /** Get feedback stats for a rule / 取得規則回饋統計 */
196
403
  getATRFeedbackStats(ruleId) {
197
- const tp = this.db.prepare('SELECT COUNT(*) as count FROM atr_feedback WHERE rule_id = ? AND is_true_positive = 1').get(ruleId).count;
198
- const fp = this.db.prepare('SELECT COUNT(*) as count FROM atr_feedback WHERE rule_id = ? AND is_true_positive = 0').get(ruleId).count;
404
+ const tp = this.db
405
+ .prepare('SELECT COUNT(*) as count FROM atr_feedback WHERE rule_id = ? AND is_true_positive = 1')
406
+ .get(ruleId).count;
407
+ const fp = this.db
408
+ .prepare('SELECT COUNT(*) as count FROM atr_feedback WHERE rule_id = ? AND is_true_positive = 0')
409
+ .get(ruleId).count;
199
410
  return { truePositives: tp, falsePositives: fp };
200
411
  }
201
412
  /** Insert skill threat submission / 插入技能威脅提交 */
@@ -208,13 +419,21 @@ export class ThreatCloudDB {
208
419
  }
209
420
  /** Get recent skill threats / 取得最近技能威脅 */
210
421
  getSkillThreats(limit = 50) {
211
- return this.db.prepare('SELECT * FROM skill_threats ORDER BY created_at DESC LIMIT ?').all(limit);
422
+ return this.db
423
+ .prepare('SELECT * FROM skill_threats ORDER BY created_at DESC LIMIT ?')
424
+ .all(limit);
212
425
  }
213
426
  /** Get proposal statistics / 取得提案統計 */
214
427
  getProposalStats() {
215
- const pending = this.db.prepare("SELECT COUNT(*) as count FROM atr_proposals WHERE status = 'pending'").get().count;
216
- const confirmed = this.db.prepare("SELECT COUNT(*) as count FROM atr_proposals WHERE status = 'confirmed'").get().count;
217
- const rejected = this.db.prepare("SELECT COUNT(*) as count FROM atr_proposals WHERE status = 'rejected'").get().count;
428
+ const pending = this.db
429
+ .prepare("SELECT COUNT(*) as count FROM atr_proposals WHERE status = 'pending'")
430
+ .get().count;
431
+ const confirmed = this.db
432
+ .prepare("SELECT COUNT(*) as count FROM atr_proposals WHERE status = 'confirmed'")
433
+ .get().count;
434
+ const rejected = this.db
435
+ .prepare("SELECT COUNT(*) as count FROM atr_proposals WHERE status = 'rejected'")
436
+ .get().count;
218
437
  const total = this.db.prepare('SELECT COUNT(*) as count FROM atr_proposals').get().count;
219
438
  return { pending, confirmed, rejected, total };
220
439
  }
@@ -222,23 +441,48 @@ export class ThreatCloudDB {
222
441
  getStats() {
223
442
  const totalThreats = this.db.prepare('SELECT COUNT(*) as count FROM threats').get().count;
224
443
  const totalRules = this.db.prepare('SELECT COUNT(*) as count FROM rules').get().count;
225
- const last24h = this.db.prepare("SELECT COUNT(*) as count FROM threats WHERE received_at > datetime('now', '-1 day')").get().count;
226
- const topAttackTypes = this.db.prepare(`
444
+ const last24h = this.db
445
+ .prepare("SELECT COUNT(*) as count FROM threats WHERE received_at > datetime('now', '-1 day')")
446
+ .get().count;
447
+ const topAttackTypes = this.db
448
+ .prepare(`
227
449
  SELECT attack_type as type, COUNT(*) as count
228
450
  FROM threats
229
451
  GROUP BY attack_type
230
452
  ORDER BY count DESC
231
453
  LIMIT 10
232
- `).all();
233
- const topMitreTechniques = this.db.prepare(`
454
+ `)
455
+ .all();
456
+ const topMitreTechniques = this.db
457
+ .prepare(`
234
458
  SELECT mitre_technique as technique, COUNT(*) as count
235
459
  FROM threats
236
460
  GROUP BY mitre_technique
237
461
  ORDER BY count DESC
238
462
  LIMIT 10
239
- `).all();
463
+ `)
464
+ .all();
240
465
  const proposalStats = this.getProposalStats();
241
466
  const skillThreatsTotal = this.db.prepare('SELECT COUNT(*) as count FROM skill_threats').get().count;
467
+ const skillBlacklistTotal = this.getSkillBlacklist().length;
468
+ const rulesByCategory = this.db
469
+ .prepare(`
470
+ SELECT COALESCE(category, 'unknown') as category, COUNT(*) as count
471
+ FROM rules GROUP BY category ORDER BY count DESC LIMIT 20
472
+ `)
473
+ .all();
474
+ const rulesBySeverity = this.db
475
+ .prepare(`
476
+ SELECT COALESCE(severity, 'unknown') as severity, COUNT(*) as count
477
+ FROM rules GROUP BY severity ORDER BY count DESC
478
+ `)
479
+ .all();
480
+ const rulesBySource = this.db
481
+ .prepare(`
482
+ SELECT source, COUNT(*) as count
483
+ FROM rules GROUP BY source ORDER BY count DESC
484
+ `)
485
+ .all();
242
486
  return {
243
487
  totalThreats,
244
488
  totalRules,
@@ -247,40 +491,52 @@ export class ThreatCloudDB {
247
491
  last24hThreats: last24h,
248
492
  proposalStats,
249
493
  skillThreatsTotal,
494
+ skillBlacklistTotal,
495
+ rulesByCategory,
496
+ rulesBySeverity,
497
+ rulesBySource,
250
498
  };
251
499
  }
252
500
  /** Get confirmed/promoted ATR rules, optionally filtered by date / 取得已確認 ATR 規則 */
253
501
  getConfirmedATRRules(since) {
254
502
  if (since) {
255
- return this.db.prepare(`
503
+ return this.db
504
+ .prepare(`
256
505
  SELECT pattern_hash as ruleId, rule_content as ruleContent, updated_at as publishedAt, 'atr-community' as source
257
506
  FROM atr_proposals
258
507
  WHERE (status = 'confirmed' OR status = 'promoted') AND updated_at > ?
259
508
  ORDER BY updated_at ASC
260
- `).all(since);
509
+ `)
510
+ .all(since);
261
511
  }
262
- return this.db.prepare(`
512
+ return this.db
513
+ .prepare(`
263
514
  SELECT pattern_hash as ruleId, rule_content as ruleContent, updated_at as publishedAt, 'atr-community' as source
264
515
  FROM atr_proposals
265
516
  WHERE status = 'confirmed' OR status = 'promoted'
266
517
  ORDER BY updated_at ASC
267
- `).all();
518
+ `)
519
+ .all();
268
520
  }
269
521
  /** Get IP blocklist from IoC entries and aggregated threat data / 取得 IP 封鎖清單 */
270
522
  getIPBlocklist(minReputation) {
271
523
  // IoC entries with sufficient reputation
272
- const iocIPs = this.db.prepare(`
524
+ const iocIPs = this.db
525
+ .prepare(`
273
526
  SELECT value FROM ioc_entries
274
527
  WHERE type = 'ip' AND reputation >= ?
275
528
  ORDER BY reputation DESC
276
- `).all(minReputation);
529
+ `)
530
+ .all(minReputation);
277
531
  // Aggregate from threats table: distinct IPs with >= 3 occurrences
278
- const threatIPs = this.db.prepare(`
532
+ const threatIPs = this.db
533
+ .prepare(`
279
534
  SELECT attack_source_ip as value
280
535
  FROM threats
281
536
  GROUP BY attack_source_ip
282
537
  HAVING COUNT(*) >= 3
283
- `).all();
538
+ `)
539
+ .all();
284
540
  // Merge and deduplicate
285
541
  const ipSet = new Set();
286
542
  for (const row of iocIPs)
@@ -291,16 +547,19 @@ export class ThreatCloudDB {
291
547
  }
292
548
  /** Get domain blocklist from IoC entries / 取得域名封鎖清單 */
293
549
  getDomainBlocklist(minReputation) {
294
- const iocDomains = this.db.prepare(`
550
+ const iocDomains = this.db
551
+ .prepare(`
295
552
  SELECT value FROM ioc_entries
296
553
  WHERE type = 'domain' AND reputation >= ?
297
554
  ORDER BY reputation DESC
298
- `).all(minReputation);
555
+ `)
556
+ .all(minReputation);
299
557
  return iocDomains.map((row) => row.value);
300
558
  }
301
559
  /** Upsert an IoC entry / 插入或更新 IoC 條目 */
302
560
  upsertIoC(type, value, reputation, source) {
303
- this.db.prepare(`
561
+ this.db
562
+ .prepare(`
304
563
  INSERT INTO ioc_entries (type, value, reputation, source)
305
564
  VALUES (?, ?, ?, ?)
306
565
  ON CONFLICT(value) DO UPDATE SET
@@ -308,15 +567,18 @@ export class ThreatCloudDB {
308
567
  source = excluded.source,
309
568
  last_seen = datetime('now'),
310
569
  sighting_count = sighting_count + 1
311
- `).run(type, value, reputation, source);
570
+ `)
571
+ .run(type, value, reputation, source);
312
572
  }
313
573
  /** Promote confirmed proposals with approved LLM review to rules / 推廣已確認提案為規則 */
314
574
  promoteConfirmedProposals() {
315
- const proposals = this.db.prepare(`
575
+ const proposals = this.db
576
+ .prepare(`
316
577
  SELECT pattern_hash, rule_content, llm_review_verdict
317
578
  FROM atr_proposals
318
579
  WHERE status = 'confirmed' AND llm_review_verdict IS NOT NULL
319
- `).all();
580
+ `)
581
+ .all();
320
582
  let promoted = 0;
321
583
  for (const proposal of proposals) {
322
584
  try {
@@ -329,10 +591,12 @@ export class ThreatCloudDB {
329
591
  publishedAt: new Date().toISOString(),
330
592
  source: 'atr-community',
331
593
  });
332
- this.db.prepare(`
594
+ this.db
595
+ .prepare(`
333
596
  UPDATE atr_proposals SET status = 'promoted', updated_at = datetime('now')
334
597
  WHERE pattern_hash = ?
335
- `).run(proposal.pattern_hash);
598
+ `)
599
+ .run(proposal.pattern_hash);
336
600
  promoted++;
337
601
  }
338
602
  catch {
@@ -343,32 +607,41 @@ export class ThreatCloudDB {
343
607
  }
344
608
  /** Reject an ATR proposal / 拒絕 ATR 提案 */
345
609
  rejectATRProposal(patternHash) {
346
- this.db.prepare(`
610
+ this.db
611
+ .prepare(`
347
612
  UPDATE atr_proposals SET status = 'rejected', updated_at = datetime('now')
348
613
  WHERE pattern_hash = ?
349
- `).run(patternHash);
614
+ `)
615
+ .run(patternHash);
350
616
  }
351
617
  /** Get rules by source type, optionally filtered by date / 依來源取得規則 */
352
618
  getRulesBySource(source, since) {
353
619
  if (since) {
354
- return this.db.prepare(`
355
- SELECT rule_id as ruleId, rule_content as ruleContent, published_at as publishedAt, source
620
+ return this.db
621
+ .prepare(`
622
+ SELECT rule_id as ruleId, rule_content as ruleContent, published_at as publishedAt, source,
623
+ category, severity, mitre_techniques as mitreTechniques, tags
356
624
  FROM rules
357
625
  WHERE source = ? AND published_at > ?
358
626
  ORDER BY published_at ASC
359
- `).all(source, since);
627
+ `)
628
+ .all(source, since);
360
629
  }
361
- return this.db.prepare(`
362
- SELECT rule_id as ruleId, rule_content as ruleContent, published_at as publishedAt, source
630
+ return this.db
631
+ .prepare(`
632
+ SELECT rule_id as ruleId, rule_content as ruleContent, published_at as publishedAt, source,
633
+ category, severity, mitre_techniques as mitreTechniques, tags
363
634
  FROM rules
364
635
  WHERE source = ?
365
636
  ORDER BY published_at ASC
366
- `).all(source);
637
+ `)
638
+ .all(source);
367
639
  }
368
640
  /** Report a safe skill (increment confirmations, auto-confirm at 3+) / 回報安全 skill */
369
641
  reportSafeSkill(skillName, fingerprintHash) {
370
642
  const normalized = skillName.toLowerCase().trim().replace(/\s+/g, '-');
371
- this.db.prepare(`
643
+ this.db
644
+ .prepare(`
372
645
  INSERT INTO skill_whitelist (skill_name, normalized_name, fingerprint_hash)
373
646
  VALUES (?, ?, ?)
374
647
  ON CONFLICT(normalized_name) DO UPDATE SET
@@ -376,16 +649,58 @@ export class ThreatCloudDB {
376
649
  status = CASE WHEN confirmations + 1 >= 3 THEN 'confirmed' ELSE status END,
377
650
  fingerprint_hash = COALESCE(excluded.fingerprint_hash, fingerprint_hash),
378
651
  last_reported = datetime('now')
379
- `).run(skillName, normalized, fingerprintHash ?? null);
652
+ `)
653
+ .run(skillName, normalized, fingerprintHash ?? null);
380
654
  }
381
655
  /** Get confirmed community whitelist / 取得社群白名單 */
382
656
  getSkillWhitelist() {
383
- return this.db.prepare(`
657
+ return this.db
658
+ .prepare(`
384
659
  SELECT skill_name as name, fingerprint_hash as hash, confirmations
385
660
  FROM skill_whitelist
386
661
  WHERE status = 'confirmed'
387
662
  ORDER BY confirmations DESC
388
- `).all();
663
+ `)
664
+ .all();
665
+ }
666
+ /**
667
+ * Get skill blacklist: skills reported by 3+ distinct clients with avg risk >= 70
668
+ * 取得技能黑名單:3+ 不同客戶端回報且平均風險 >= 70 的技能
669
+ */
670
+ getSkillBlacklist(minReports = 3, minAvgRisk = 70) {
671
+ return this.db
672
+ .prepare(`
673
+ SELECT
674
+ skill_hash as skillHash,
675
+ skill_name as skillName,
676
+ ROUND(AVG(risk_score)) as avgRiskScore,
677
+ MAX(risk_level) as maxRiskLevel,
678
+ COUNT(DISTINCT COALESCE(client_id, 'anonymous')) as reportCount,
679
+ MIN(created_at) as firstReported,
680
+ MAX(created_at) as lastReported
681
+ FROM skill_threats
682
+ GROUP BY skill_hash
683
+ HAVING reportCount >= ? AND AVG(risk_score) >= ?
684
+ ORDER BY avgRiskScore DESC
685
+ `)
686
+ .all(minReports, minAvgRisk);
687
+ }
688
+ /** Backfill classification for rules with NULL category / 回填缺少分類的規則 */
689
+ backfillClassification() {
690
+ const unclassified = this.db
691
+ .prepare(`SELECT rule_id, rule_content, source FROM rules WHERE category IS NULL`)
692
+ .all();
693
+ let updated = 0;
694
+ const stmt = this.db.prepare(`
695
+ UPDATE rules SET category = ?, severity = ?, mitre_techniques = ?, tags = ?, updated_at = datetime('now')
696
+ WHERE rule_id = ?
697
+ `);
698
+ for (const row of unclassified) {
699
+ const meta = this.extractMetadata(row.rule_content, row.source);
700
+ stmt.run(meta.category, meta.severity, meta.mitreTechniques, meta.tags, row.rule_id);
701
+ updated++;
702
+ }
703
+ return updated;
389
704
  }
390
705
  /** Close the database / 關閉資料庫 */
391
706
  close() {