korext 0.9.5 → 0.9.7

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.
Files changed (2) hide show
  1. package/bin/korext.js +788 -22
  2. package/package.json +1 -1
package/bin/korext.js CHANGED
@@ -6,6 +6,7 @@ import { Command } from 'commander';
6
6
  import chalk from 'chalk';
7
7
  import ora from 'ora';
8
8
  import fetch from 'node-fetch';
9
+ import { createInterface } from 'readline';
9
10
 
10
11
  // Load version
11
12
  const pkgUrl = new URL('../package.json', import.meta.url);
@@ -29,9 +30,9 @@ function getConfig() {
29
30
 
30
31
  function saveConfig(config) {
31
32
  if (!fs.existsSync(CONFIG_DIR)) {
32
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
33
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
33
34
  }
34
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
35
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { encoding: 'utf-8', mode: 0o600 });
35
36
  }
36
37
 
37
38
  let _deprecationWarned = false;
@@ -57,6 +58,203 @@ function getLanguageFromExt(ext) {
57
58
  return map[ext] || 'plaintext';
58
59
  }
59
60
 
61
+ // ─── TAXONOMY BUILDER ─────────────────────────────────────────────────────────
62
+ // Standalone copy of derivation logic from src/lib/packTaxonomy.ts.
63
+ // Operates on cached rule definitions (packs with tags).
64
+
65
+ const INDUSTRY_LABELS = {
66
+ finance: 'Finance',
67
+ healthcare: 'Healthcare',
68
+ defense: 'Defense',
69
+ government: 'Government',
70
+ aerospace: 'Aerospace',
71
+ energy: 'Energy',
72
+ ecommerce: 'E-Commerce',
73
+ technology: 'Technology',
74
+ education: 'Education and Research',
75
+ insurance: 'Insurance',
76
+ automotive: 'Automotive',
77
+ telecom: 'Telecom',
78
+ transport: 'Transport and Maritime',
79
+ manufacturing: 'Manufacturing',
80
+ lifesciences: 'Life Sciences',
81
+ critical_infrastructure: 'Critical Infrastructure',
82
+ legal: 'Legal',
83
+ general: 'General',
84
+ };
85
+
86
+ const INDUSTRY_DESCRIPTIONS = {
87
+ finance: 'PCI-DSS, DORA, Basel III, SWIFT',
88
+ healthcare: 'HIPAA, FDA, FHIR, GxP',
89
+ defense: 'CMMC, NIST 800-171, ITAR',
90
+ government: 'FedRAMP, FISMA, CISA',
91
+ aerospace: 'DO-178C, NASA, EASA, ICAO',
92
+ energy: 'NERC CIP, IEC 62443, NRC',
93
+ critical_infrastructure: 'IEC 62443, NERC, NIST CSF',
94
+ manufacturing: 'IEC 62443, EN Standards',
95
+ ecommerce: 'PCI-DSS, GDPR Public',
96
+ lifesciences: 'GxP, ICH, EMA',
97
+ education: 'FERPA, UKRI',
98
+ insurance: 'NAIC, Solvency II',
99
+ automotive: 'ISO 26262, WP.29',
100
+ telecom: '3GPP, Ofcom',
101
+ transport: 'IMO Maritime, ITS Transport',
102
+ legal: 'SRA Compliance',
103
+ technology: 'Age Encryption, ASP.NET',
104
+ general: 'OWASP, Web Security, AI Safety',
105
+ };
106
+
107
+ const REGION_LABELS = {
108
+ global: 'Global',
109
+ us: 'United States',
110
+ eu: 'European Union',
111
+ uk: 'United Kingdom',
112
+ };
113
+
114
+ const INDUSTRY_MERGES = {
115
+ maritime: 'transport',
116
+ research: 'education',
117
+ };
118
+
119
+ const RECOMMENDED_PACKS = {
120
+ finance: ['pci-dss-v1', 'dora-financial-v1', 'swift-cscf-v1'],
121
+ healthcare: ['hipaa-hitech-v1', 'fda-21cfr-v1', 'hl7-fhir-v1'],
122
+ defense: ['cmmc-level2-v1', 'nist-sp800-171-v1', 'itar-ear-v1'],
123
+ government: ['fedramp-moderate-v1', 'fisma-v1', 'nist-csf2-v1'],
124
+ aerospace: ['do-178c-v1', 'nasa-7150-v1', 'easa-part21-v1'],
125
+ energy: ['nerc-cip-v1', 'iec-62443-v1', 'ofgem-v1'],
126
+ critical_infrastructure: ['iec-62443-v1', 'nerc-cip-v1', 'nist-csf2-v1'],
127
+ manufacturing: ['iec-62443-v1', 'en-standards-v1'],
128
+ ecommerce: ['pci-dss-v1', 'gdpr-public-v1'],
129
+ lifesciences: ['gxp-pharma-v1', 'ich-pharma-v1'],
130
+ education: ['ferpa-education-v1', 'ukri-research-v1'],
131
+ insurance: ['naic-insurance-v1', 'solvency-v1'],
132
+ automotive: ['iso-26262-v1', 'wp29-automotive-v1'],
133
+ telecom: ['3gpp-telecom-v1', 'ofcom-telecom-v1'],
134
+ transport: ['its-transport-v1', 'imo-maritime-v1'],
135
+ legal: ['sra-legal-v1'],
136
+ technology: ['age-encryption-v1', 'asp-security-v1'],
137
+ general: ['web', 'security'],
138
+ };
139
+
140
+ const CROSS_CUTTING_DEFAULT_ON = ['web'];
141
+ const CROSS_CUTTING_AVAILABLE = [
142
+ 'browser-gov-v1', 'euai-act-v2', 'gdpr-privacy-v1', 'gpc-privacy-v1',
143
+ 'iso-27001-v1', 'nist-csf2-v1', 'quantum-safe-v1',
144
+ ];
145
+
146
+ function resolveIndustryTag(tag) {
147
+ return INDUSTRY_MERGES[tag] || tag;
148
+ }
149
+
150
+ function buildTaxonomyFromPacks(packs) {
151
+ const industries = {};
152
+ const regions = {};
153
+
154
+ for (const key of Object.keys(INDUSTRY_LABELS)) {
155
+ industries[key] = { label: INDUSTRY_LABELS[key], description: INDUSTRY_DESCRIPTIONS[key] || '', packIds: [], recommended: [] };
156
+ }
157
+ for (const key of Object.keys(REGION_LABELS)) {
158
+ regions[key] = { label: REGION_LABELS[key], packIds: [] };
159
+ }
160
+
161
+ const crossCuttingSet = new Set([...CROSS_CUTTING_DEFAULT_ON, ...CROSS_CUTTING_AVAILABLE]);
162
+
163
+ for (const [packId, pack] of Object.entries(packs)) {
164
+ const rawIndustries = pack.tags?.industries || [];
165
+ const rawRegions = pack.tags?.regions || [];
166
+
167
+ if (crossCuttingSet.has(packId)) {
168
+ const pr = rawRegions.length > 0 ? rawRegions : ['global'];
169
+ for (const r of pr) {
170
+ if (!regions[r]) regions[r] = { label: REGION_LABELS[r] || r, packIds: [] };
171
+ if (!regions[r].packIds.includes(packId)) regions[r].packIds.push(packId);
172
+ }
173
+ continue;
174
+ }
175
+
176
+ let resolved;
177
+ if (rawIndustries.length === 0) {
178
+ resolved = ['general'];
179
+ } else {
180
+ const filtered = rawIndustries.filter(t => t !== 'all').map(resolveIndustryTag);
181
+ resolved = [...new Set(filtered)];
182
+ if (resolved.length === 0) resolved = ['general'];
183
+ }
184
+
185
+ for (const ind of resolved) {
186
+ if (!industries[ind]) {
187
+ industries[ind] = { label: INDUSTRY_LABELS[ind] || ind, description: INDUSTRY_DESCRIPTIONS[ind] || '', packIds: [], recommended: [] };
188
+ }
189
+ if (!industries[ind].packIds.includes(packId)) industries[ind].packIds.push(packId);
190
+ }
191
+
192
+ const pr = rawRegions.length > 0 ? rawRegions : ['global'];
193
+ for (const r of pr) {
194
+ if (!regions[r]) regions[r] = { label: REGION_LABELS[r] || r, packIds: [] };
195
+ if (!regions[r].packIds.includes(packId)) regions[r].packIds.push(packId);
196
+ }
197
+ }
198
+
199
+ // Apply recommended
200
+ for (const [ind, recList] of Object.entries(RECOMMENDED_PACKS)) {
201
+ if (industries[ind]) {
202
+ industries[ind].recommended = recList.filter(pid => industries[ind].packIds.includes(pid));
203
+ }
204
+ }
205
+
206
+ // Remove empties
207
+ for (const key of Object.keys(industries)) {
208
+ if (industries[key].packIds.length === 0) delete industries[key];
209
+ }
210
+ for (const key of Object.keys(regions)) {
211
+ if (regions[key].packIds.length === 0) delete regions[key];
212
+ }
213
+
214
+ // Sort
215
+ for (const g of Object.values(industries)) g.packIds.sort();
216
+ for (const g of Object.values(regions)) g.packIds.sort();
217
+
218
+ return {
219
+ industries,
220
+ regions,
221
+ crossCutting: {
222
+ defaultOn: CROSS_CUTTING_DEFAULT_ON.filter(pid => pid in packs),
223
+ available: CROSS_CUTTING_AVAILABLE.filter(pid => pid in packs),
224
+ },
225
+ };
226
+ }
227
+
228
+ function resolvePacksForIndustryRegion(industryFilter, regionFilter, taxonomy) {
229
+ const matchedPacks = new Set();
230
+ const industryIds = industryFilter.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
231
+ const regionIds = regionFilter ? regionFilter.split(',').map(s => s.trim().toLowerCase()).filter(Boolean) : [];
232
+
233
+ for (const indId of industryIds) {
234
+ const group = taxonomy.industries[indId];
235
+ if (!group) continue;
236
+ for (const pid of group.packIds) {
237
+ if (regionIds.length === 0) {
238
+ matchedPacks.add(pid);
239
+ } else {
240
+ // Only include packs whose region overlaps with the filter
241
+ const packRegions = taxonomy.regions;
242
+ let inRegion = false;
243
+ for (const r of regionIds) {
244
+ if (packRegions[r] && packRegions[r].packIds.includes(pid)) inRegion = true;
245
+ if (packRegions['global'] && packRegions['global'].packIds.includes(pid)) inRegion = true;
246
+ }
247
+ if (inRegion) matchedPacks.add(pid);
248
+ }
249
+ }
250
+ }
251
+
252
+ // Include cross-cutting defaults
253
+ for (const pid of taxonomy.crossCutting.defaultOn) matchedPacks.add(pid);
254
+
255
+ return [...matchedPacks];
256
+ }
257
+
60
258
  // ─── LOCAL ENGINE: Rule Definition Caching + Offline Analysis ─────────────────
61
259
 
62
260
  const RULES_CACHE_FILE = path.join(CONFIG_DIR, 'rule-definitions.json');
@@ -223,7 +421,9 @@ program
223
421
  program
224
422
  .command('packs list')
225
423
  .description('List available policy packs from the server')
226
- .action(async () => {
424
+ .option('--industry <name>', 'Filter packs by industry (e.g. finance)')
425
+ .option('--region <name>', 'Filter packs by region (e.g. us, eu)')
426
+ .action(async (_sub, options) => {
227
427
  const token = getToken();
228
428
  const spinner = ora('Fetching policy packs...').start();
229
429
  try {
@@ -233,15 +433,62 @@ program
233
433
  if (res.ok) {
234
434
  const data = await res.json();
235
435
  spinner.stop();
236
- console.log(chalk.bold.hex('#F27D26')('\n▲ AVAILABLE KOREXT POLICY PACKS'));
436
+
437
+ let packs = data.packs || [];
438
+
439
+ // Apply industry/region filter from taxonomy if cached definitions exist
440
+ if (options.industry || options.region) {
441
+ const defs = getRuleDefinitionsCache();
442
+ if (defs && defs.packs) {
443
+ const taxonomy = buildTaxonomyFromPacks(defs.packs);
444
+ const matchedIds = resolvePacksForIndustryRegion(
445
+ options.industry || Object.keys(taxonomy.industries).join(','),
446
+ options.region || null,
447
+ taxonomy
448
+ );
449
+ const matchedSet = new Set(matchedIds);
450
+ packs = packs.filter(p => matchedSet.has(p.packId));
451
+ } else {
452
+ console.log(chalk.yellow('No cached rule definitions found. Run korext rules sync for industry/region filtering.'));
453
+ }
454
+ }
455
+
456
+ const filterStr = [options.industry, options.region].filter(Boolean).join(', ');
457
+ console.log(chalk.bold.hex('#F27D26')(`\n\u25b2 AVAILABLE KOREXT POLICY PACKS${filterStr ? ` (filtered: ${filterStr})` : ''}`));
237
458
  console.log(chalk.dim('======================================='));
238
- if (data.packs && data.packs.length > 0) {
239
- data.packs.forEach((p) => {
240
- console.log(`${chalk.bold(p.packId)} - ${p.packName || p.packId} ${p.isEnterprise ? chalk.bgRed.white(' ENTERPRISE ') : ''}`);
241
- console.log(chalk.dim(` ${p.description || 'No description provided'}\n`));
242
- });
459
+
460
+ if (packs.length > 0) {
461
+ // Group by industry if taxonomy is available
462
+ const defs = getRuleDefinitionsCache();
463
+ if (defs && defs.packs) {
464
+ const taxonomy = buildTaxonomyFromPacks(defs.packs);
465
+ const recSets = {};
466
+ for (const [indId, group] of Object.entries(taxonomy.industries)) {
467
+ recSets[indId] = new Set(group.recommended);
468
+ }
469
+
470
+ for (const p of packs) {
471
+ // Find industries this pack belongs to
472
+ const packIndustries = [];
473
+ for (const [indId, group] of Object.entries(taxonomy.industries)) {
474
+ if (group.packIds.includes(p.packId)) packIndustries.push(indId);
475
+ }
476
+ const indLabel = packIndustries.map(i => INDUSTRY_LABELS[i] || i).join(', ') || 'Cross-cutting';
477
+ const isRec = packIndustries.some(i => recSets[i]?.has(p.packId));
478
+ const recTag = isRec ? chalk.green(' [recommended]') : '';
479
+ console.log(`${chalk.bold(p.packId)} ${chalk.dim('|')} ${p.packName || p.packId} ${chalk.dim('|')} ${chalk.cyan(indLabel)}${recTag}${p.isEnterprise ? chalk.bgRed.white(' ENTERPRISE ') : ''}`);
480
+ console.log(chalk.dim(` ${p.description || 'No description provided'}\n`));
481
+ }
482
+ } else {
483
+ packs.forEach((p) => {
484
+ console.log(`${chalk.bold(p.packId)} - ${p.packName || p.packId} ${p.isEnterprise ? chalk.bgRed.white(' ENTERPRISE ') : ''}`);
485
+ console.log(chalk.dim(` ${p.description || 'No description provided'}\n`));
486
+ });
487
+ }
488
+
489
+ console.log(chalk.dim(`Total: ${packs.length} packs`));
243
490
  } else {
244
- console.log(chalk.dim('No policy packs available.'));
491
+ console.log(chalk.dim('No policy packs match the filters.'));
245
492
  }
246
493
  } else {
247
494
  spinner.fail(chalk.red('Failed to fetch packs. Check authentication.'));
@@ -251,19 +498,411 @@ program
251
498
  }
252
499
  });
253
500
 
501
+ // ─── KOREXT INDUSTRIES COMMAND ─────────────────────────────────────────────────
502
+
503
+ program
504
+ .command('industries')
505
+ .description('List all supported industries and their associated policy packs')
506
+ .action(async () => {
507
+ const defs = getRuleDefinitionsCache();
508
+ if (!defs || !defs.packs) {
509
+ const spinner = ora('Fetching rule definitions...').start();
510
+ try {
511
+ await fetchAndCacheRules();
512
+ spinner.succeed('Rule definitions cached.');
513
+ } catch (e) {
514
+ spinner.fail(chalk.red('Failed to fetch rule definitions. Run korext rules sync while online.'));
515
+ process.exit(1);
516
+ }
517
+ }
518
+
519
+ const cachedDefs = getRuleDefinitionsCache();
520
+ const taxonomy = buildTaxonomyFromPacks(cachedDefs.packs);
521
+
522
+ console.log(chalk.bold.hex('#F27D26')('\n\u25b2 KOREXT INDUSTRY TAXONOMY'));
523
+ console.log(chalk.dim('=======================================\n'));
524
+
525
+ const sortedIndustries = Object.entries(taxonomy.industries)
526
+ .sort(([, a], [, b]) => a.label.localeCompare(b.label));
527
+
528
+ for (const [id, group] of sortedIndustries) {
529
+ const desc = INDUSTRY_DESCRIPTIONS[id] || '';
530
+ const recCount = group.recommended.length;
531
+ console.log(` ${chalk.bold.cyan(group.label)} ${chalk.dim(`(${id})`)}`);
532
+ console.log(` ${chalk.dim(desc)}`);
533
+ console.log(` ${chalk.white(`${group.packIds.length} packs`)}${recCount > 0 ? chalk.green(` (${recCount} recommended)`) : ''}`);
534
+ console.log(` ${chalk.dim(group.packIds.join(', '))}\n`);
535
+ }
536
+
537
+ // Show regions
538
+ console.log(chalk.bold('\n Regions:'));
539
+ for (const [id, group] of Object.entries(taxonomy.regions)) {
540
+ console.log(` ${chalk.cyan(group.label)} ${chalk.dim(`(${id})`)} ${chalk.dim(`${group.packIds.length} packs`)}`);
541
+ }
542
+
543
+ // Show cross-cutting
544
+ console.log(chalk.bold('\n Cross-cutting packs:'));
545
+ console.log(` Default: ${chalk.green(taxonomy.crossCutting.defaultOn.join(', ') || 'none')}`);
546
+ console.log(` Available: ${chalk.dim(taxonomy.crossCutting.available.join(', ') || 'none')}`);
547
+
548
+ console.log(`\n ${chalk.dim('Use:')} ${chalk.green('korext enforce . --industry finance')} ${chalk.dim('to enforce all finance packs.')}`);
549
+ console.log(` ${chalk.dim('Use:')} ${chalk.green('korext enforce . --industry finance --region eu')} ${chalk.dim('to filter by region.\n')}`);
550
+ });
551
+
552
+ // ─── KOREXT INIT COMMAND ───────────────────────────────────────────────────────
553
+
554
+ program
555
+ .command('init')
556
+ .description('Initialize a korext.json configuration file for your project')
557
+ .option('--non-interactive', 'Skip prompts and use defaults', false)
558
+ .action(async (options) => {
559
+ console.log(chalk.bold.hex('#F27D26')('\n\u25b2 KOREXT PROJECT INIT'));
560
+ console.log(chalk.dim('=======================================\n'));
561
+
562
+ const outputPath = path.resolve('korext.json');
563
+
564
+ if (fs.existsSync(outputPath)) {
565
+ console.log(chalk.yellow(`korext.json already exists at ${outputPath}`));
566
+ console.log(chalk.dim('Delete it manually to re-initialize.\n'));
567
+ process.exit(0);
568
+ }
569
+
570
+ // Ensure rule definitions are cached
571
+ let defs = getRuleDefinitionsCache();
572
+ if (!defs || !defs.packs) {
573
+ const spinner = ora('Fetching rule definitions...').start();
574
+ try {
575
+ defs = await fetchAndCacheRules();
576
+ spinner.succeed('Rule definitions cached.');
577
+ } catch (e) {
578
+ spinner.fail(chalk.red('Failed to fetch rule definitions. Unable to resolve industries.'));
579
+ process.exit(1);
580
+ }
581
+ }
582
+
583
+ const taxonomy = buildTaxonomyFromPacks(defs.packs);
584
+
585
+ if (options.nonInteractive) {
586
+ // Non-interactive: default to web pack
587
+ const config = {
588
+ targetPacks: ['web'],
589
+ exclude: ['node_modules', 'dist', 'build', '.next']
590
+ };
591
+ fs.writeFileSync(outputPath, JSON.stringify(config, null, 2));
592
+ console.log(chalk.green(`Created ${outputPath} with default pack: web\n`));
593
+ process.exit(0);
594
+ }
595
+
596
+ // Interactive prompts
597
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
598
+ const ask = (q) => new Promise(resolve => rl.question(q, resolve));
599
+
600
+ try {
601
+ // Show industries
602
+ console.log(chalk.bold(' Available industries:\n'));
603
+ const sortedIndustries = Object.entries(taxonomy.industries)
604
+ .filter(([id]) => id !== 'general')
605
+ .sort(([, a], [, b]) => a.label.localeCompare(b.label));
606
+
607
+ sortedIndustries.forEach(([id, group], i) => {
608
+ console.log(` ${chalk.dim(`${i + 1}.`)} ${chalk.cyan(group.label)} ${chalk.dim(`(${id})`)} ${chalk.dim(INDUSTRY_DESCRIPTIONS[id] || '')}`);
609
+ });
610
+
611
+ console.log();
612
+ const industryInput = await ask(chalk.bold(' Select industries (comma-separated numbers or IDs): '));
613
+
614
+ // Parse user input (numbers or IDs)
615
+ const selectedIndustries = industryInput.split(',').map(s => s.trim()).filter(Boolean).map(s => {
616
+ const num = parseInt(s, 10);
617
+ if (!isNaN(num) && num >= 1 && num <= sortedIndustries.length) {
618
+ return sortedIndustries[num - 1][0];
619
+ }
620
+ return s.toLowerCase();
621
+ }).filter(id => taxonomy.industries[id]);
622
+
623
+ if (selectedIndustries.length === 0) {
624
+ console.log(chalk.yellow('\n No valid industries selected. Using default: web\n'));
625
+ const config = {
626
+ targetPacks: ['web'],
627
+ exclude: ['node_modules', 'dist', 'build', '.next']
628
+ };
629
+ fs.writeFileSync(outputPath, JSON.stringify(config, null, 2));
630
+ console.log(chalk.green(` Created ${outputPath}\n`));
631
+ rl.close();
632
+ process.exit(0);
633
+ }
634
+
635
+ // Show regions
636
+ console.log(chalk.bold('\n Available regions:\n'));
637
+ const regionEntries = Object.entries(taxonomy.regions).sort(([a], [b]) => a.localeCompare(b));
638
+ regionEntries.forEach(([id, group], i) => {
639
+ console.log(` ${chalk.dim(`${i + 1}.`)} ${chalk.cyan(group.label)} ${chalk.dim(`(${id})`)}`);
640
+ });
641
+ console.log(` ${chalk.dim('0.')} All regions`);
642
+ console.log();
643
+ const regionInput = await ask(chalk.bold(' Select region (number or ID, default: all): '));
644
+
645
+ let selectedRegion = null;
646
+ if (regionInput.trim()) {
647
+ const num = parseInt(regionInput, 10);
648
+ if (num === 0) {
649
+ selectedRegion = null;
650
+ } else if (!isNaN(num) && num >= 1 && num <= regionEntries.length) {
651
+ selectedRegion = regionEntries[num - 1][0];
652
+ } else {
653
+ const lower = regionInput.trim().toLowerCase();
654
+ if (taxonomy.regions[lower]) selectedRegion = lower;
655
+ }
656
+ }
657
+
658
+ // Resolve packs
659
+ const resolvedPacks = resolvePacksForIndustryRegion(
660
+ selectedIndustries.join(','),
661
+ selectedRegion,
662
+ taxonomy
663
+ );
664
+
665
+ console.log(chalk.green(`\n Resolved ${resolvedPacks.length} packs for ${selectedIndustries.map(id => INDUSTRY_LABELS[id]).join(', ')}${selectedRegion ? ` (${REGION_LABELS[selectedRegion] || selectedRegion})` : ''}:`));
666
+ for (const pid of resolvedPacks) {
667
+ console.log(` ${chalk.cyan(pid)}`);
668
+ }
669
+
670
+ const config = {
671
+ industry: selectedIndustries.join(','),
672
+ ...(selectedRegion && { region: selectedRegion }),
673
+ targetPacks: resolvedPacks,
674
+ exclude: ['node_modules', 'dist', 'build', '.next']
675
+ };
676
+
677
+ fs.writeFileSync(outputPath, JSON.stringify(config, null, 2));
678
+ console.log(chalk.green(`\n Created ${outputPath}\n`));
679
+ console.log(chalk.dim(` Run: ${chalk.green('korext enforce .')} to enforce these packs.\n`));
680
+
681
+ rl.close();
682
+ } catch (e) {
683
+ rl.close();
684
+ console.error(chalk.red(`Init failed: ${e.message}`));
685
+ process.exit(1);
686
+ }
687
+ });
688
+
689
+ // ─── KOREXT POLICY COMMAND ────────────────────────────────────────────────────
690
+
691
+ program
692
+ .command('org-policy')
693
+ .description('Display the organisation governance policy for your account')
694
+ .action(async () => {
695
+ console.log(chalk.bold.hex('#F27D26')('\n\u25b2 KOREXT ORGANISATION POLICY'));
696
+ console.log(chalk.dim('=======================================\n'));
697
+
698
+ const token = getToken();
699
+ if (!token) {
700
+ console.log(chalk.yellow('Not authenticated. Run korext auth to sign in.'));
701
+ process.exit(1);
702
+ }
703
+
704
+ const spinner = ora('Fetching org policy...').start();
705
+ try {
706
+ const res = await fetch(`${API_URL}/api/org/policy`, {
707
+ headers: { Authorization: `Bearer ${token}` },
708
+ });
709
+ if (!res.ok) {
710
+ spinner.fail(chalk.red(`Server returned ${res.status}: ${res.statusText}`));
711
+ process.exit(1);
712
+ }
713
+ const data = await res.json();
714
+ spinner.succeed('Organisation policy loaded.\n');
715
+
716
+ const hasMandated = data.mandatedPacks && data.mandatedPacks.length > 0;
717
+ const hasRecommended = data.recommendedPacks && data.recommendedPacks.length > 0;
718
+ const hasAllowed = data.allowedPacks && data.allowedPacks.length > 0;
719
+
720
+ if (!hasMandated && !hasRecommended && !hasAllowed && !data.industry) {
721
+ console.log(chalk.dim(' No organisation policy configured.'));
722
+ console.log(chalk.dim(' Set one at https://app.korext.com under Org Policy (ADMIN only).\n'));
723
+ return;
724
+ }
725
+
726
+ if (data.industry) console.log(` ${chalk.bold('Industry:')} ${data.industry}`);
727
+ if (data.region) console.log(` ${chalk.bold('Region:')} ${data.region}`);
728
+ if (data.updatedAt) console.log(` ${chalk.bold('Updated:')} ${data.updatedAt}`);
729
+ console.log('');
730
+
731
+ if (hasMandated) {
732
+ console.log(chalk.red.bold(' MANDATED PACKS (locked):'));
733
+ for (const pid of data.mandatedPacks) {
734
+ console.log(` ${chalk.red('\u25cf')} ${pid}`);
735
+ }
736
+ console.log('');
737
+ }
738
+
739
+ if (hasRecommended) {
740
+ console.log(chalk.green.bold(' RECOMMENDED PACKS:'));
741
+ for (const pid of data.recommendedPacks) {
742
+ console.log(` ${chalk.green('\u25cb')} ${pid}`);
743
+ }
744
+ console.log('');
745
+ }
746
+
747
+ if (hasAllowed) {
748
+ console.log(chalk.yellow.bold(' ALLOWED PACKS:'));
749
+ for (const pid of data.allowedPacks) {
750
+ console.log(` ${chalk.yellow('\u25cb')} ${pid}`);
751
+ }
752
+ console.log('');
753
+ } else {
754
+ console.log(chalk.dim(' Allowed: all packs (no restrictions)\n'));
755
+ }
756
+ } catch (e) {
757
+ spinner.fail(chalk.red(`Failed: ${e.message}`));
758
+ process.exit(1);
759
+ }
760
+ });
761
+
762
+
254
763
  program
255
764
  .command('enforce [dir]')
256
765
  .description('Statically analyze files in a directory against Korext policies')
257
- .option('-p, --pack <packId>', 'Policy Pack ID to enforce', 'web')
766
+ .option('-p, --pack <packIds>', 'Policy Pack ID(s) to enforce (comma-separated, e.g. web,pci-dss-v1)')
258
767
  .option('-f, --format <format>', 'Output format (text, json, sarif)', 'text')
768
+ .option('--industry <industries>', 'Industry filter (comma separated, e.g. finance,healthcare)')
769
+ .option('--region <regions>', 'Region filter (comma separated, e.g. us,eu)')
259
770
  .option('--offline', 'Force local-only analysis using cached rule definitions (no server calls)', false)
260
771
  .option('--sync-rules', 'Fetch and cache latest rule definitions before running analysis', false)
772
+ .option('--sign', 'Request HMAC signed proof bundles (requires authentication)', false)
773
+ .option('--sovereignty-region <region>', 'Data sovereignty region: us, eu, or apac (routes data to regional database)')
261
774
  .action(async (dirArg, options) => {
262
775
  const dir = dirArg || '.';
263
- const pack = options.pack;
264
776
  const format = options.format.toLowerCase();
265
777
  const isText = format === 'text';
266
778
 
779
+ // ── Workspace config reader ──────────────────────────────────────────────
780
+ function loadWorkspaceConfig(targetDir) {
781
+ const configPaths = [
782
+ path.join(targetDir, 'korext.json'),
783
+ path.join(targetDir, '.korextrc'),
784
+ path.join(process.cwd(), 'korext.json'),
785
+ path.join(process.cwd(), '.korextrc'),
786
+ ];
787
+ // Deduplicate paths (targetDir might equal cwd)
788
+ const seen = new Set();
789
+ for (const p of configPaths) {
790
+ const resolved = path.resolve(p);
791
+ if (seen.has(resolved)) continue;
792
+ seen.add(resolved);
793
+ if (fs.existsSync(resolved)) {
794
+ try {
795
+ const raw = JSON.parse(fs.readFileSync(resolved, 'utf-8'));
796
+ return raw;
797
+ } catch (e) {
798
+ console.warn(`[KOREXT] Invalid config at ${resolved}: ${e.message}`);
799
+ return null;
800
+ }
801
+ }
802
+ }
803
+ return null;
804
+ }
805
+
806
+ // ── Pack resolution priority chain ───────────────────────────────────────
807
+ // 1. --pack flag (highest, explicit)
808
+ // 2. --industry + --region flags (resolved via taxonomy)
809
+ // 3. korext.json targetPacks (workspace config)
810
+ // 4. korext.json industry + region (workspace config, resolved)
811
+ // 5. Default 'web' (fallback)
812
+ let packIds;
813
+ let packSource = 'default';
814
+
815
+ // Helper: try to build taxonomy from cached definitions
816
+ const tryBuildTaxonomy = () => {
817
+ const defs = getRuleDefinitionsCache();
818
+ if (!defs || !defs.packs) return null;
819
+ return buildTaxonomyFromPacks(defs.packs);
820
+ };
821
+
822
+ if (options.pack) {
823
+ // Priority 1: User explicitly passed --pack
824
+ packIds = options.pack.split(',').map(s => s.trim()).filter(Boolean);
825
+ packSource = 'flag';
826
+ } else if (options.industry) {
827
+ // Priority 2: --industry (and optional --region) flags
828
+ const taxonomy = tryBuildTaxonomy();
829
+ if (!taxonomy) {
830
+ if (isText) console.error(chalk.red('\n\u2716 --industry requires cached rule definitions.'));
831
+ if (isText) console.error(chalk.dim(` Run ${chalk.green('korext rules sync')} first to cache pack tags.`));
832
+ process.exit(1);
833
+ }
834
+ packIds = resolvePacksForIndustryRegion(options.industry, options.region || null, taxonomy);
835
+ packSource = 'industry-flag';
836
+ if (packIds.length === 0) {
837
+ if (isText) console.error(chalk.red(`\n\u2716 No packs found for industry '${options.industry}'${options.region ? ` in region '${options.region}'` : ''}.`));
838
+ if (isText) console.error(chalk.dim(` Run ${chalk.green('korext industries')} to see valid industry names.`));
839
+ process.exit(1);
840
+ }
841
+ if (isText) console.log(`[KOREXT] Resolved ${packIds.length} packs for industry: ${options.industry}${options.region ? ` (region: ${options.region})` : ''}`);
842
+ } else {
843
+ // Priority 3-5: workspace config or default
844
+ const config = loadWorkspaceConfig(dirArg || '.');
845
+ if (config) {
846
+ if (Array.isArray(config.targetPacks) && config.targetPacks.length > 0) {
847
+ // Priority 3: korext.json targetPacks
848
+ packIds = config.targetPacks;
849
+ packSource = 'config';
850
+ if (isText) console.log(`[KOREXT] Using packs from korext.json: ${packIds.join(', ')}`);
851
+ } else if (config.industry) {
852
+ // Priority 4: korext.json industry + region
853
+ const taxonomy = tryBuildTaxonomy();
854
+ if (taxonomy) {
855
+ packIds = resolvePacksForIndustryRegion(config.industry, config.region || null, taxonomy);
856
+ packSource = 'config-industry';
857
+ if (packIds.length > 0) {
858
+ if (isText) console.log(`[KOREXT] Resolved ${packIds.length} packs from korext.json industry: ${config.industry}`);
859
+ } else {
860
+ if (isText) console.log(chalk.yellow(`[KOREXT] No packs matched industry '${config.industry}' in korext.json. Falling back to 'web'.`));
861
+ packIds = ['web'];
862
+ }
863
+ } else {
864
+ if (isText) console.log(chalk.yellow('[KOREXT] industry config found but no cached rule definitions. Run korext rules sync. Falling back to web.'));
865
+ packIds = ['web'];
866
+ }
867
+ } else {
868
+ packIds = ['web'];
869
+ }
870
+ // Note: config.exclude is acknowledged but not wired up in this version.
871
+ // The enforce command uses hardcoded excludes in findFiles() (node_modules, .git, dist, build, .next).
872
+ } else {
873
+ packIds = ['web'];
874
+ }
875
+ }
876
+
877
+ // ── Org policy merge (highest priority: mandated packs always included) ──
878
+ const orgToken = getToken();
879
+ if (orgToken && !options.offline) {
880
+ try {
881
+ const policyRes = await fetch(`${API_URL}/api/org/policy/packs`, {
882
+ headers: { Authorization: `Bearer ${orgToken}` },
883
+ });
884
+ if (policyRes.ok) {
885
+ const policyData = await policyRes.json();
886
+ if (policyData.mandated && policyData.mandated.length > 0) {
887
+ const before = packIds.length;
888
+ const mandatedSet = new Set(policyData.mandated);
889
+ // Add mandated packs that are not already selected
890
+ for (const mid of policyData.mandated) {
891
+ if (!packIds.includes(mid)) packIds.push(mid);
892
+ }
893
+ if (isText && packIds.length > before) {
894
+ console.log(chalk.hex('#F27D26')(`[KOREXT] Org policy: ${policyData.mandated.length} mandated pack(s) injected: ${policyData.mandated.join(', ')}`));
895
+ }
896
+ }
897
+ }
898
+ } catch {
899
+ // No org, not authenticated, or server error: continue without org policy
900
+ }
901
+ }
902
+
903
+ const packInput = packIds.join(',');
904
+ const pack = packIds.length === 1 ? packIds[0] : packIds;
905
+
267
906
  let forceOffline = options.offline;
268
907
  let localDefinitions = null;
269
908
 
@@ -321,10 +960,12 @@ program
321
960
 
322
961
  const report = {
323
962
  version,
324
- packId: pack,
963
+ packId: Array.isArray(pack) ? pack[0] : pack,
964
+ packIds,
325
965
  directory: dir,
326
966
  summary: { totalFiles: 0, scannedFiles: 0, skippedFiles: 0, errorFiles: 0, critical: 0, high: 0, medium: 0, low: 0, totalViolations: 0 },
327
- results: []
967
+ results: [],
968
+ bundles: []
328
969
  };
329
970
 
330
971
  let files;
@@ -343,7 +984,7 @@ program
343
984
  process.exit(0);
344
985
  }
345
986
 
346
- if (isText) console.log(`Found ${files.length} files. Starting analysis with pack: ${chalk.cyan(pack)}...\n`);
987
+ if (isText) console.log(`Found ${files.length} files. Starting analysis with pack${packIds.length > 1 ? 's' : ''}: ${chalk.cyan(packIds.join(', '))}...\n`);
347
988
 
348
989
  let usedLocalEngine = false;
349
990
 
@@ -377,21 +1018,26 @@ program
377
1018
  } else {
378
1019
  // Online mode: try server, fall back to local on failure
379
1020
  try {
1021
+ const controller = new AbortController();
1022
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
380
1023
  const res = await fetch(`${API_URL}/api/ide/analyze`, {
381
1024
  method: 'POST',
382
1025
  headers: {
383
1026
  'Content-Type': 'application/json',
384
- ...(token && { 'Authorization': `Bearer ${token}` })
1027
+ ...(token && { 'Authorization': `Bearer ${token}` }),
1028
+ ...(options.sovereigntyRegion && { 'X-Korext-Region': options.sovereigntyRegion })
385
1029
  },
386
1030
  body: JSON.stringify({
387
1031
  fileContent,
388
1032
  language,
389
- fileName: file,
1033
+ fileName: path.basename(file),
390
1034
  packId: pack,
391
- requestSignature: false,
1035
+ requestSignature: !!options.sign,
392
1036
  asyncExplanations: false
393
- })
1037
+ }),
1038
+ signal: controller.signal
394
1039
  });
1040
+ clearTimeout(timeoutId);
395
1041
 
396
1042
  if (!res.ok) {
397
1043
  throw new Error(`HTTP ${res.status}`);
@@ -407,6 +1053,17 @@ program
407
1053
 
408
1054
  fileViolations = result.violations || [];
409
1055
 
1056
+ // Capture proof bundle if present
1057
+ if (result.proofBundle) {
1058
+ report.bundles.push({
1059
+ file: displayPath,
1060
+ bundleId: result.proofBundle.bundleId,
1061
+ decision: result.proofBundle.decision,
1062
+ signed: !!result.proofBundle.hmacSignature,
1063
+ verifyUrl: `https://app.korext.com/verify/${result.proofBundle.bundleId}`,
1064
+ });
1065
+ }
1066
+
410
1067
  // Cache rule definitions on first successful server response (opportunistic sync)
411
1068
  if (!localDefinitions && i === 0) {
412
1069
  fetchAndCacheRules().catch(() => {});
@@ -460,7 +1117,7 @@ program
460
1117
  }
461
1118
 
462
1119
  if (format === 'json') {
463
- console.log(JSON.stringify(report, null, 2));
1120
+ console.log(JSON.stringify({ ...report, bundles: report.bundles }, null, 2));
464
1121
  } else if (format === 'sarif') {
465
1122
  // Build SARIF results
466
1123
  const sarifResults = [];
@@ -489,12 +1146,19 @@ program
489
1146
  shortDescription: { text: id.replace(/-/g, ' ') }
490
1147
  }));
491
1148
 
1149
+ const sarifProperties = {};
1150
+ if (report.bundles.length > 0) {
1151
+ sarifProperties['korext:bundleIds'] = report.bundles.map(b => b.bundleId);
1152
+ sarifProperties['korext:bundleCount'] = report.bundles.length;
1153
+ sarifProperties['korext:bundlesSigned'] = report.bundles.filter(b => b.signed).length;
1154
+ }
492
1155
  const sarif = {
493
1156
  version: "2.1.0",
494
1157
  $schema: "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json",
495
1158
  runs: [{
496
1159
  tool: { driver: { name: "Korext", version, rules } },
497
- results: sarifResults
1160
+ results: sarifResults,
1161
+ ...(Object.keys(sarifProperties).length > 0 && { properties: sarifProperties })
498
1162
  }]
499
1163
  };
500
1164
  console.log(JSON.stringify(sarif, null, 2));
@@ -531,6 +1195,18 @@ program
531
1195
  }
532
1196
  });
533
1197
  }
1198
+
1199
+ // Add proof bundle info
1200
+ if (report.bundles.length > 0) {
1201
+ const signedCount = report.bundles.filter(b => b.signed).length;
1202
+ md += `### Proof Bundles\n\n`;
1203
+ md += `**Generated:** ${report.bundles.length} | **Signed:** ${signedCount}\n\n`;
1204
+ md += `| File | Bundle ID | Decision | Signed | Verify |\n| --- | --- | --- | --- | --- |\n`;
1205
+ for (const b of report.bundles) {
1206
+ md += `| \`${b.file || ''}\` | \`${b.bundleId}\` | ${b.decision} | ${b.signed ? 'Yes' : 'No'} | [Verify](${b.verifyUrl}) |\n`;
1207
+ }
1208
+ md += '\n';
1209
+ }
534
1210
  try {
535
1211
  fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md, 'utf-8');
536
1212
  } catch (e) {
@@ -586,6 +1262,96 @@ rulesCmd
586
1262
  }
587
1263
  });
588
1264
 
1265
+ // ─── BUNDLE COMMANDS ─────────────────────────────────────────────────────────
1266
+
1267
+ const bundleCmd = program.command('bundle').description('Proof bundle management commands');
1268
+
1269
+ bundleCmd
1270
+ .command('list')
1271
+ .description('List your recent proof bundles (requires authentication)')
1272
+ .action(async () => {
1273
+ const token = getToken();
1274
+ if (!token) {
1275
+ console.log(chalk.red('Not authenticated. Run korext login <token> to sign in.'));
1276
+ process.exit(1);
1277
+ }
1278
+
1279
+ const spinner = ora('Fetching proof bundles...').start();
1280
+ try {
1281
+ const res = await fetch(`${API_URL}/api/ide/bundles`, {
1282
+ headers: { Authorization: `Bearer ${token}` }
1283
+ });
1284
+ if (!res.ok) {
1285
+ spinner.fail(chalk.red(`Server returned ${res.status}: ${res.statusText}`));
1286
+ process.exit(1);
1287
+ }
1288
+ const data = await res.json();
1289
+ spinner.stop();
1290
+
1291
+ const bundles = data.bundles || [];
1292
+ if (bundles.length === 0) {
1293
+ console.log(chalk.dim('No proof bundles found. Run korext enforce with --sign to generate bundles.'));
1294
+ return;
1295
+ }
1296
+
1297
+ console.log(chalk.bold.hex('#F27D26')('\n\u25b2 KOREXT PROOF BUNDLES'));
1298
+ console.log(chalk.dim('=======================================\n'));
1299
+
1300
+ for (const b of bundles) {
1301
+ const signedTag = b.isSigned ? chalk.green(' [SIGNED]') : chalk.dim(' [unsigned]');
1302
+ const decisionTag = b.decision === 'PASS' ? chalk.green(b.decision) : (b.decision === 'BLOCK' ? chalk.red(b.decision) : chalk.yellow(b.decision));
1303
+ console.log(` ${chalk.cyan(b.bundleId)} ${decisionTag}${signedTag}`);
1304
+ console.log(chalk.dim(` Pack: ${b.policyPackId} | Violations: ${b.violationCount} | ${b.ts}`));
1305
+ console.log(chalk.dim(` Verify: ${b.verifyUrl}\n`));
1306
+ }
1307
+
1308
+ console.log(chalk.dim(` Total: ${bundles.length} bundles\n`));
1309
+ } catch (e) {
1310
+ spinner.fail(chalk.red(`Failed: ${e.message}`));
1311
+ process.exit(1);
1312
+ }
1313
+ });
1314
+
1315
+ bundleCmd
1316
+ .command('verify <bundleId>')
1317
+ .description('Verify a proof bundle by ID')
1318
+ .action(async (bundleId) => {
1319
+ const spinner = ora('Verifying bundle...').start();
1320
+ try {
1321
+ const res = await fetch(`${API_URL}/api/verify/${bundleId}`);
1322
+ if (!res.ok) {
1323
+ spinner.fail(chalk.red(`Bundle not found or verification failed (${res.status}).`));
1324
+ process.exit(1);
1325
+ }
1326
+ const data = await res.json();
1327
+ spinner.stop();
1328
+
1329
+ console.log(chalk.bold.hex('#F27D26')('\n\u25b2 KOREXT BUNDLE VERIFICATION'));
1330
+ console.log(chalk.dim('=======================================\n'));
1331
+ console.log(` Bundle ID: ${chalk.cyan(data.bundleId || bundleId)}`);
1332
+ console.log(` Decision: ${data.decision === 'PASS' ? chalk.green(data.decision) : chalk.red(data.decision)}`);
1333
+ console.log(` Violations: ${data.violationCount ?? 'N/A'}`);
1334
+ console.log(` Policy Pack: ${data.policyPackId || data.policy || 'N/A'}`);
1335
+ console.log(` Signed: ${data.signatureValid ? chalk.green('Valid') : (data.hmacSignature ? chalk.yellow('Unverified') : chalk.dim('No'))}`);
1336
+ console.log(` Timestamp: ${data.ts || 'N/A'}`);
1337
+ console.log('');
1338
+ } catch (e) {
1339
+ spinner.fail(chalk.red(`Failed: ${e.message}`));
1340
+ process.exit(1);
1341
+ }
1342
+ });
1343
+
1344
+ bundleCmd
1345
+ .command('open <bundleId>')
1346
+ .description('Open a proof bundle verification page in your browser')
1347
+ .action(async (bundleId) => {
1348
+ const url = `https://app.korext.com/verify/${bundleId}`;
1349
+ console.log(chalk.dim(`Opening ${url}`));
1350
+ const { exec } = await import('child_process');
1351
+ const cmd = process.platform === 'darwin' ? 'open' : (process.platform === 'win32' ? 'start' : 'xdg-open');
1352
+ exec(`${cmd} "${url}"`);
1353
+ });
1354
+
589
1355
  // ─── POLICY COMMANDS ─────────────────────────────────────────────────────────
590
1356
 
591
1357
  const LOCAL_DRAFT_DIR = path.join(process.cwd(), '.korext');
@@ -1281,7 +2047,7 @@ program
1281
2047
  'Content-Type': 'application/json',
1282
2048
  ...(token && { 'Authorization': `Bearer ${token}` })
1283
2049
  },
1284
- body: JSON.stringify({ fileContent, language, fileName: filePath, packId: pack, requestSignature: false, asyncExplanations: false })
2050
+ body: JSON.stringify({ fileContent, language, fileName: path.basename(filePath), packId: pack, requestSignature: false, asyncExplanations: false })
1285
2051
  });
1286
2052
  if (res.ok) {
1287
2053
  const result = await res.json();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "korext",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "description": "Korext Command Line Interface",
5
5
  "type": "module",
6
6
  "main": "bin/korext.js",