chainlesschain 0.47.7 → 0.47.9

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.
@@ -3,6 +3,7 @@
3
3
  * chainlesschain compliance evidence|report|classify|scan|policies|check-access
4
4
  */
5
5
 
6
+ import fs from "fs";
6
7
  import chalk from "chalk";
7
8
  import { logger } from "../lib/logger.js";
8
9
  import { bootstrap, shutdown } from "../runtime/bootstrap.js";
@@ -16,6 +17,69 @@ import {
16
17
  addPolicy,
17
18
  checkAccess,
18
19
  } from "../lib/compliance-manager.js";
20
+ import {
21
+ generateFrameworkReport,
22
+ listFrameworks as listReporterFrameworks,
23
+ getFrameworkTemplate,
24
+ } from "../lib/compliance-framework-reporter.js";
25
+ import {
26
+ ensureThreatIntelTables,
27
+ importStixFile,
28
+ listIndicators,
29
+ matchObservable,
30
+ getStats as getThreatIntelStats,
31
+ removeIndicator,
32
+ } from "../lib/threat-intel.js";
33
+ import { IOC_TYPES } from "../lib/stix-parser.js";
34
+ import {
35
+ ensureUebaTables,
36
+ buildBaseline,
37
+ saveBaselines,
38
+ loadBaseline,
39
+ loadAllBaselines,
40
+ detectAnomalies,
41
+ rankEntities,
42
+ scoreEvent,
43
+ } from "../lib/ueba.js";
44
+
45
+ function _loadEvidenceFromDb(db, framework) {
46
+ return db
47
+ .prepare(
48
+ `SELECT id, framework, type, description, source, status, collected_at
49
+ FROM compliance_evidence
50
+ WHERE framework = ?`,
51
+ )
52
+ .all(framework)
53
+ .map((r) => ({
54
+ id: r.id,
55
+ framework: r.framework,
56
+ type: r.type,
57
+ description: r.description,
58
+ source: r.source,
59
+ status: r.status,
60
+ collectedAt: r.collected_at,
61
+ }));
62
+ }
63
+
64
+ function _loadPoliciesFromDb(db, framework) {
65
+ return db
66
+ .prepare(
67
+ `SELECT id, name, type, framework, rules, enabled, severity, created_at
68
+ FROM compliance_policies
69
+ WHERE framework = ? AND enabled = 1`,
70
+ )
71
+ .all(framework)
72
+ .map((r) => ({
73
+ id: r.id,
74
+ name: r.name,
75
+ type: r.type,
76
+ framework: r.framework,
77
+ rules: r.rules ? JSON.parse(r.rules) : {},
78
+ enabled: !!r.enabled,
79
+ severity: r.severity,
80
+ createdAt: r.created_at,
81
+ }));
82
+ }
19
83
 
20
84
  export function registerComplianceCommand(program) {
21
85
  const compliance = program
@@ -63,9 +127,21 @@ export function registerComplianceCommand(program) {
63
127
  // compliance report
64
128
  compliance
65
129
  .command("report <framework>")
66
- .description("Generate compliance report")
130
+ .description(
131
+ "Generate compliance report (frameworks: soc2, iso27001, gdpr, hipaa)",
132
+ )
67
133
  .option("-t, --title <title>", "Report title")
68
- .option("--json", "Output as JSON")
134
+ .option(
135
+ "-f, --format <fmt>",
136
+ "Output format: summary | md | html | json",
137
+ "summary",
138
+ )
139
+ .option("-o, --output <path>", "Write report to file instead of stdout")
140
+ .option(
141
+ "--detailed",
142
+ "Use framework-aware template reporter (SOC2/ISO27001/GDPR)",
143
+ )
144
+ .option("--json", "Alias for --format=json (backwards-compat)")
69
145
  .action(async (framework, options) => {
70
146
  try {
71
147
  const ctx = await bootstrap({ verbose: program.opts().verbose });
@@ -76,15 +152,56 @@ export function registerComplianceCommand(program) {
76
152
  const db = ctx.db.getDatabase();
77
153
  ensureComplianceTables(db);
78
154
 
79
- const result = generateReport(db, framework, options.title);
80
- if (options.json) {
81
- console.log(JSON.stringify(result, null, 2));
82
- } else {
155
+ const fmt = options.json ? "json" : options.format;
156
+ const useDetailed =
157
+ options.detailed ||
158
+ fmt === "md" ||
159
+ fmt === "markdown" ||
160
+ fmt === "html" ||
161
+ !!options.output;
162
+
163
+ // Fast path — legacy generic report for backwards-compat.
164
+ if (!useDetailed && fmt === "summary") {
165
+ const result = generateReport(db, framework, options.title);
83
166
  logger.success("Report generated");
84
167
  logger.log(` ${chalk.bold("ID:")} ${chalk.cyan(result.id)}`);
85
168
  logger.log(` ${chalk.bold("Title:")} ${result.title}`);
86
169
  logger.log(` ${chalk.bold("Score:")} ${result.score}`);
87
170
  logger.log(` ${chalk.bold("Summary:")} ${result.summary}`);
171
+ await shutdown();
172
+ return;
173
+ }
174
+
175
+ if (!getFrameworkTemplate(framework)) {
176
+ logger.error(
177
+ `Framework "${framework}" has no detailed template. ` +
178
+ `Available: ${listReporterFrameworks().join(", ")}.`,
179
+ );
180
+ process.exit(1);
181
+ }
182
+
183
+ const evidence = _loadEvidenceFromDb(db, framework);
184
+ const policies = _loadPoliciesFromDb(db, framework);
185
+
186
+ const { analysis, body, format } = generateFrameworkReport(framework, {
187
+ evidence,
188
+ policies,
189
+ format: fmt === "summary" ? "markdown" : fmt,
190
+ });
191
+
192
+ if (options.output) {
193
+ fs.writeFileSync(options.output, body, "utf-8");
194
+ logger.success(
195
+ `Report written to ${chalk.cyan(options.output)} ` +
196
+ `(${format}, ${body.length} bytes)`,
197
+ );
198
+ logger.log(
199
+ ` ${chalk.bold("Score:")} ${analysis.score}/100 ` +
200
+ chalk.dim(`(${analysis.summary})`),
201
+ );
202
+ } else {
203
+ process.stdout.write(body);
204
+ if (!body.endsWith("\n")) process.stdout.write("\n");
88
205
  }
89
206
 
90
207
  await shutdown();
@@ -94,6 +211,34 @@ export function registerComplianceCommand(program) {
94
211
  }
95
212
  });
96
213
 
214
+ // compliance frameworks (list templates)
215
+ compliance
216
+ .command("frameworks")
217
+ .description("List supported report frameworks")
218
+ .option("--json", "Output as JSON")
219
+ .action(async (options) => {
220
+ const frameworks = listReporterFrameworks().map((id) => {
221
+ const t = getFrameworkTemplate(id);
222
+ return {
223
+ id,
224
+ name: t.name,
225
+ version: t.version,
226
+ category: t.category,
227
+ controlCount: t.controls.length,
228
+ };
229
+ });
230
+ if (options.json) {
231
+ console.log(JSON.stringify(frameworks, null, 2));
232
+ } else {
233
+ for (const f of frameworks) {
234
+ logger.log(
235
+ ` ${chalk.cyan(f.id.padEnd(10))} ${f.name} ` +
236
+ chalk.dim(`(${f.version}, ${f.controlCount} controls)`),
237
+ );
238
+ }
239
+ }
240
+ });
241
+
97
242
  // compliance classify
98
243
  compliance
99
244
  .command("classify <content>")
@@ -213,4 +358,450 @@ export function registerComplianceCommand(program) {
213
358
  process.exit(1);
214
359
  }
215
360
  });
361
+
362
+ // compliance threat-intel
363
+ const threat = compliance
364
+ .command("threat-intel")
365
+ .description(
366
+ "Threat-intelligence IoC store — STIX 2.1 import, list, match",
367
+ );
368
+
369
+ threat
370
+ .command("import <file>")
371
+ .description("Import a STIX 2.1 bundle JSON file")
372
+ .option("--json", "Output as JSON")
373
+ .action(async (file, options) => {
374
+ try {
375
+ const ctx = await bootstrap({ verbose: program.opts().verbose });
376
+ if (!ctx.db) {
377
+ logger.error("Database not available");
378
+ process.exit(1);
379
+ }
380
+ const db = ctx.db.getDatabase();
381
+ ensureThreatIntelTables(db);
382
+
383
+ const result = importStixFile(db, file);
384
+ if (options.json) {
385
+ console.log(JSON.stringify(result, null, 2));
386
+ } else {
387
+ logger.success(
388
+ `Imported ${chalk.cyan(result.imported)} new, ` +
389
+ `${chalk.cyan(result.updated)} updated, ` +
390
+ `${chalk.dim(result.skipped)} skipped ` +
391
+ chalk.dim(`(of ${result.total} indicators)`),
392
+ );
393
+ }
394
+ await shutdown();
395
+ } catch (err) {
396
+ logger.error(`Failed: ${err.message}`);
397
+ process.exit(1);
398
+ }
399
+ });
400
+
401
+ threat
402
+ .command("list")
403
+ .description("List stored indicators")
404
+ .option("-t, --type <type>", `Filter by IOC type (${IOC_TYPES.join("|")})`)
405
+ .option("--limit <n>", "Max rows", "100")
406
+ .option("--json", "Output as JSON")
407
+ .action(async (options) => {
408
+ try {
409
+ const ctx = await bootstrap({ verbose: program.opts().verbose });
410
+ if (!ctx.db) {
411
+ logger.error("Database not available");
412
+ process.exit(1);
413
+ }
414
+ const db = ctx.db.getDatabase();
415
+ ensureThreatIntelTables(db);
416
+
417
+ const rows = listIndicators(db, {
418
+ type: options.type,
419
+ limit: Number(options.limit) || 100,
420
+ });
421
+ if (options.json) {
422
+ console.log(JSON.stringify(rows, null, 2));
423
+ } else if (rows.length === 0) {
424
+ logger.info("No indicators stored.");
425
+ } else {
426
+ for (const r of rows) {
427
+ const labels = r.labels.length ? ` [${r.labels.join(",")}]` : "";
428
+ logger.log(
429
+ ` ${chalk.cyan(r.type.padEnd(12))} ${r.value}` +
430
+ chalk.dim(labels) +
431
+ (r.sourceName ? chalk.dim(` ← ${r.sourceName}`) : ""),
432
+ );
433
+ }
434
+ logger.log(chalk.dim(` (${rows.length} shown)`));
435
+ }
436
+ await shutdown();
437
+ } catch (err) {
438
+ logger.error(`Failed: ${err.message}`);
439
+ process.exit(1);
440
+ }
441
+ });
442
+
443
+ threat
444
+ .command("match <observable>")
445
+ .description("Check whether a value matches a stored indicator")
446
+ .option("--json", "Output as JSON")
447
+ .action(async (observable, options) => {
448
+ try {
449
+ const ctx = await bootstrap({ verbose: program.opts().verbose });
450
+ if (!ctx.db) {
451
+ logger.error("Database not available");
452
+ process.exit(1);
453
+ }
454
+ const db = ctx.db.getDatabase();
455
+ ensureThreatIntelTables(db);
456
+
457
+ const result = matchObservable(db, observable);
458
+ if (options.json) {
459
+ console.log(JSON.stringify(result, null, 2));
460
+ } else if (result.matched) {
461
+ logger.log(
462
+ ` ${chalk.red("⚠ MATCH")} ` +
463
+ `${chalk.cyan(result.type)} ${observable}` +
464
+ (result.indicator.sourceName
465
+ ? chalk.dim(` ← ${result.indicator.sourceName}`)
466
+ : ""),
467
+ );
468
+ if (result.indicator.labels.length) {
469
+ logger.log(
470
+ chalk.dim(` labels: ${result.indicator.labels.join(", ")}`),
471
+ );
472
+ }
473
+ } else if (result.type === "unknown") {
474
+ logger.warn(`Observable type could not be classified.`);
475
+ // match result already includes matched:false via JSON path
476
+ } else {
477
+ logger.log(
478
+ ` ${chalk.green("✓ clean")} ${chalk.cyan(result.type)} ${observable}`,
479
+ );
480
+ }
481
+ await shutdown();
482
+ if (result.matched) process.exit(2);
483
+ } catch (err) {
484
+ logger.error(`Failed: ${err.message}`);
485
+ process.exit(1);
486
+ }
487
+ });
488
+
489
+ threat
490
+ .command("stats")
491
+ .description("Show indicator counts per type")
492
+ .option("--json", "Output as JSON")
493
+ .action(async (options) => {
494
+ try {
495
+ const ctx = await bootstrap({ verbose: program.opts().verbose });
496
+ if (!ctx.db) {
497
+ logger.error("Database not available");
498
+ process.exit(1);
499
+ }
500
+ const db = ctx.db.getDatabase();
501
+ ensureThreatIntelTables(db);
502
+
503
+ const stats = getThreatIntelStats(db);
504
+ if (options.json) {
505
+ console.log(JSON.stringify(stats, null, 2));
506
+ } else {
507
+ logger.log(` ${chalk.bold("Total:")} ${stats.total}`);
508
+ for (const [t, n] of Object.entries(stats.byType)) {
509
+ logger.log(` ${chalk.cyan(t.padEnd(12))} ${n}`);
510
+ }
511
+ }
512
+ await shutdown();
513
+ } catch (err) {
514
+ logger.error(`Failed: ${err.message}`);
515
+ process.exit(1);
516
+ }
517
+ });
518
+
519
+ threat
520
+ .command("remove <type> <value>")
521
+ .description("Remove a single indicator")
522
+ .option("--json", "Output as JSON")
523
+ .action(async (type, value, options) => {
524
+ try {
525
+ const ctx = await bootstrap({ verbose: program.opts().verbose });
526
+ if (!ctx.db) {
527
+ logger.error("Database not available");
528
+ process.exit(1);
529
+ }
530
+ const db = ctx.db.getDatabase();
531
+ ensureThreatIntelTables(db);
532
+
533
+ const removed = removeIndicator(db, type, value);
534
+ if (options.json) {
535
+ console.log(JSON.stringify({ removed }, null, 2));
536
+ } else if (removed) {
537
+ logger.success(`Removed ${chalk.cyan(type)} ${value}`);
538
+ } else {
539
+ logger.info(`No indicator matched ${type} ${value}`);
540
+ }
541
+ await shutdown();
542
+ } catch (err) {
543
+ logger.error(`Failed: ${err.message}`);
544
+ process.exit(1);
545
+ }
546
+ });
547
+
548
+ // ── UEBA ────────────────────────────────────────────────────
549
+ const ueba = compliance
550
+ .command("ueba")
551
+ .description("User and Entity Behavior Analytics over audit_log events");
552
+
553
+ const _loadAuditEvents = (db, { entity, days } = {}) => {
554
+ const sql = entity
555
+ ? `SELECT actor, operation, target, success, created_at
556
+ FROM audit_log
557
+ WHERE actor = ?`
558
+ : `SELECT actor, operation, target, success, created_at
559
+ FROM audit_log`;
560
+ const rows = entity ? db.prepare(sql).all(entity) : db.prepare(sql).all();
561
+
562
+ const cutoff = days
563
+ ? Date.now() - Number(days) * 24 * 60 * 60 * 1000
564
+ : null;
565
+
566
+ return rows
567
+ .filter((r) => {
568
+ if (!cutoff) return true;
569
+ const t = new Date(r.created_at).getTime();
570
+ return Number.isFinite(t) && t >= cutoff;
571
+ })
572
+ .map((r) => ({
573
+ entity: r.actor,
574
+ action: r.operation,
575
+ resource: r.target,
576
+ timestamp: r.created_at,
577
+ success: r.success === 1 || r.success === true,
578
+ }));
579
+ };
580
+
581
+ ueba
582
+ .command("baseline")
583
+ .description("Build and persist per-entity baselines from audit_log")
584
+ .option("-e, --entity <entity>", "Only baseline this entity")
585
+ .option("-d, --days <n>", "Limit to events from last N days")
586
+ .option("--json", "Output as JSON")
587
+ .action(async (options) => {
588
+ try {
589
+ const ctx = await bootstrap({ verbose: program.opts().verbose });
590
+ if (!ctx.db) {
591
+ logger.error("Database not available");
592
+ process.exit(1);
593
+ }
594
+ const db = ctx.db.getDatabase();
595
+ ensureUebaTables(db);
596
+
597
+ const events = _loadAuditEvents(db, {
598
+ entity: options.entity,
599
+ days: options.days,
600
+ });
601
+ const baselineMap = buildBaseline(events);
602
+ const saved = saveBaselines(db, baselineMap);
603
+
604
+ if (options.json) {
605
+ console.log(
606
+ JSON.stringify(
607
+ {
608
+ saved,
609
+ entities: [...baselineMap.keys()],
610
+ events: events.length,
611
+ },
612
+ null,
613
+ 2,
614
+ ),
615
+ );
616
+ } else {
617
+ logger.success(
618
+ `Built ${saved} baseline(s) from ${events.length} events.`,
619
+ );
620
+ for (const [entity, b] of baselineMap) {
621
+ logger.log(
622
+ ` ${chalk.cyan(entity.padEnd(24))} ` +
623
+ `events=${b.eventCount} failures=${b.failureCount} ` +
624
+ `actions=${b.uniqueActions} resources=${b.uniqueResources}`,
625
+ );
626
+ }
627
+ }
628
+ await shutdown();
629
+ } catch (err) {
630
+ logger.error(`Failed: ${err.message}`);
631
+ process.exit(1);
632
+ }
633
+ });
634
+
635
+ ueba
636
+ .command("analyze")
637
+ .description(
638
+ "Score recent audit_log events against stored baselines; print anomalies",
639
+ )
640
+ .option("-e, --entity <entity>", "Only analyze this entity")
641
+ .option("-t, --threshold <n>", "Anomaly score threshold (default 0.7)")
642
+ .option("-d, --days <n>", "Candidate window in days (default 1)")
643
+ .option("--json", "Output as JSON")
644
+ .action(async (options) => {
645
+ try {
646
+ const ctx = await bootstrap({ verbose: program.opts().verbose });
647
+ if (!ctx.db) {
648
+ logger.error("Database not available");
649
+ process.exit(1);
650
+ }
651
+ const db = ctx.db.getDatabase();
652
+ ensureUebaTables(db);
653
+
654
+ const threshold = options.threshold ? Number(options.threshold) : 0.7;
655
+ const candidates = _loadAuditEvents(db, {
656
+ entity: options.entity,
657
+ days: options.days || 1,
658
+ });
659
+
660
+ const baselineMap = options.entity
661
+ ? (() => {
662
+ const m = new Map();
663
+ const b = loadBaseline(db, options.entity);
664
+ if (b) m.set(options.entity, b);
665
+ return m;
666
+ })()
667
+ : loadAllBaselines(db);
668
+
669
+ if (baselineMap.size === 0) {
670
+ logger.warn(
671
+ "No saved baselines found. Run `cc compliance ueba baseline` first.",
672
+ );
673
+ if (options.json) console.log(JSON.stringify([]));
674
+ await shutdown();
675
+ return;
676
+ }
677
+
678
+ const hits = detectAnomalies(baselineMap, candidates, { threshold });
679
+ if (options.json) {
680
+ console.log(JSON.stringify(hits, null, 2));
681
+ } else if (hits.length === 0) {
682
+ logger.info(
683
+ `No anomalies above ${threshold} (scanned ${candidates.length} events).`,
684
+ );
685
+ } else {
686
+ logger.log(
687
+ `${chalk.yellow(`⚠ ${hits.length} anomal${hits.length === 1 ? "y" : "ies"}`)}` +
688
+ ` (threshold=${threshold}, scanned ${candidates.length}):`,
689
+ );
690
+ for (const h of hits) {
691
+ const score = h.score.toFixed(2);
692
+ logger.log(
693
+ ` ${chalk.red(score)} ${chalk.cyan(h.event.entity.padEnd(18))}` +
694
+ ` ${h.event.action} ${h.event.resource || ""}` +
695
+ chalk.dim(` @ ${h.event.timestamp}`),
696
+ );
697
+ for (const reason of h.reasons) {
698
+ logger.log(chalk.dim(` · ${reason}`));
699
+ }
700
+ }
701
+ }
702
+ await shutdown();
703
+ // CI hook: non-zero exit on anomaly hit (mirrors threat-intel match).
704
+ if (hits && hits.length > 0) process.exit(2);
705
+ } catch (err) {
706
+ logger.error(`Failed: ${err.message}`);
707
+ process.exit(1);
708
+ }
709
+ });
710
+
711
+ ueba
712
+ .command("top")
713
+ .description(
714
+ "Rank entities by composite risk score (direct over audit_log)",
715
+ )
716
+ .option("-k, --top-k <n>", "Return top K entities (default 10)")
717
+ .option("-d, --days <n>", "Window in days")
718
+ .option("--json", "Output as JSON")
719
+ .action(async (options) => {
720
+ try {
721
+ const ctx = await bootstrap({ verbose: program.opts().verbose });
722
+ if (!ctx.db) {
723
+ logger.error("Database not available");
724
+ process.exit(1);
725
+ }
726
+ const db = ctx.db.getDatabase();
727
+
728
+ const events = _loadAuditEvents(db, { days: options.days });
729
+ const topK = options.topK ? Number(options.topK) : 10;
730
+ const rows = rankEntities(events, { topK });
731
+
732
+ if (options.json) {
733
+ console.log(JSON.stringify(rows, null, 2));
734
+ } else if (rows.length === 0) {
735
+ logger.info("No events found in the selected window.");
736
+ } else {
737
+ logger.log(
738
+ `${chalk.bold("Top risky entities")} (scored over ${events.length} events):`,
739
+ );
740
+ for (const r of rows) {
741
+ logger.log(
742
+ ` ${chalk.red(String(r.riskScore).padStart(6))} ` +
743
+ `${chalk.cyan(r.entity.padEnd(20))} ` +
744
+ `events=${r.eventCount} failRate=${r.failureRate.toFixed(2)} ` +
745
+ `actions=${r.uniqueActions} resources=${r.uniqueResources}`,
746
+ );
747
+ }
748
+ }
749
+ await shutdown();
750
+ } catch (err) {
751
+ logger.error(`Failed: ${err.message}`);
752
+ process.exit(1);
753
+ }
754
+ });
755
+
756
+ ueba
757
+ .command("show <entity>")
758
+ .description("Show a stored baseline for one entity")
759
+ .option("--json", "Output as JSON")
760
+ .action(async (entity, options) => {
761
+ try {
762
+ const ctx = await bootstrap({ verbose: program.opts().verbose });
763
+ if (!ctx.db) {
764
+ logger.error("Database not available");
765
+ process.exit(1);
766
+ }
767
+ const db = ctx.db.getDatabase();
768
+ ensureUebaTables(db);
769
+
770
+ const b = loadBaseline(db, entity);
771
+ if (!b) {
772
+ if (options.json) console.log(JSON.stringify(null));
773
+ else logger.warn(`No baseline saved for ${entity}.`);
774
+ await shutdown();
775
+ return;
776
+ }
777
+
778
+ if (options.json) {
779
+ // Strip runtime-only Maps for JSON.
780
+ const { actionCounts: _a, resourceCounts: _r, ...rest } = b;
781
+ console.log(JSON.stringify(rest, null, 2));
782
+ } else {
783
+ logger.log(`${chalk.bold(entity)}`);
784
+ logger.log(` events : ${b.eventCount}`);
785
+ logger.log(
786
+ ` failures : ${b.failureCount} (rate ${b.failureRate.toFixed(2)})`,
787
+ );
788
+ logger.log(` unique acts : ${b.uniqueActions}`);
789
+ logger.log(` unique res : ${b.uniqueResources}`);
790
+ if (b.firstSeen) {
791
+ logger.log(
792
+ ` first seen : ${new Date(b.firstSeen).toISOString()}`,
793
+ );
794
+ }
795
+ if (b.lastSeen) {
796
+ logger.log(
797
+ ` last seen : ${new Date(b.lastSeen).toISOString()}`,
798
+ );
799
+ }
800
+ }
801
+ await shutdown();
802
+ } catch (err) {
803
+ logger.error(`Failed: ${err.message}`);
804
+ process.exit(1);
805
+ }
806
+ });
216
807
  }