korext 0.9.6 → 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 +780 -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,22 +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 <packIds>', 'Policy Pack ID(s) to enforce (comma-separated, e.g. web,pci-dss-v1)', '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 packInput = options.pack;
264
- // Multi-pack: split on comma, trim whitespace
265
- const packIds = packInput.split(',').map(p => p.trim()).filter(Boolean);
266
- const pack = packIds.length === 1 ? packIds[0] : packIds;
267
776
  const format = options.format.toLowerCase();
268
777
  const isText = format === 'text';
269
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
+
270
906
  let forceOffline = options.offline;
271
907
  let localDefinitions = null;
272
908
 
@@ -328,7 +964,8 @@ program
328
964
  packIds,
329
965
  directory: dir,
330
966
  summary: { totalFiles: 0, scannedFiles: 0, skippedFiles: 0, errorFiles: 0, critical: 0, high: 0, medium: 0, low: 0, totalViolations: 0 },
331
- results: []
967
+ results: [],
968
+ bundles: []
332
969
  };
333
970
 
334
971
  let files;
@@ -387,14 +1024,15 @@ program
387
1024
  method: 'POST',
388
1025
  headers: {
389
1026
  'Content-Type': 'application/json',
390
- ...(token && { 'Authorization': `Bearer ${token}` })
1027
+ ...(token && { 'Authorization': `Bearer ${token}` }),
1028
+ ...(options.sovereigntyRegion && { 'X-Korext-Region': options.sovereigntyRegion })
391
1029
  },
392
1030
  body: JSON.stringify({
393
1031
  fileContent,
394
1032
  language,
395
- fileName: file,
1033
+ fileName: path.basename(file),
396
1034
  packId: pack,
397
- requestSignature: false,
1035
+ requestSignature: !!options.sign,
398
1036
  asyncExplanations: false
399
1037
  }),
400
1038
  signal: controller.signal
@@ -415,6 +1053,17 @@ program
415
1053
 
416
1054
  fileViolations = result.violations || [];
417
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
+
418
1067
  // Cache rule definitions on first successful server response (opportunistic sync)
419
1068
  if (!localDefinitions && i === 0) {
420
1069
  fetchAndCacheRules().catch(() => {});
@@ -468,7 +1117,7 @@ program
468
1117
  }
469
1118
 
470
1119
  if (format === 'json') {
471
- console.log(JSON.stringify(report, null, 2));
1120
+ console.log(JSON.stringify({ ...report, bundles: report.bundles }, null, 2));
472
1121
  } else if (format === 'sarif') {
473
1122
  // Build SARIF results
474
1123
  const sarifResults = [];
@@ -497,12 +1146,19 @@ program
497
1146
  shortDescription: { text: id.replace(/-/g, ' ') }
498
1147
  }));
499
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
+ }
500
1155
  const sarif = {
501
1156
  version: "2.1.0",
502
1157
  $schema: "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json",
503
1158
  runs: [{
504
1159
  tool: { driver: { name: "Korext", version, rules } },
505
- results: sarifResults
1160
+ results: sarifResults,
1161
+ ...(Object.keys(sarifProperties).length > 0 && { properties: sarifProperties })
506
1162
  }]
507
1163
  };
508
1164
  console.log(JSON.stringify(sarif, null, 2));
@@ -539,6 +1195,18 @@ program
539
1195
  }
540
1196
  });
541
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
+ }
542
1210
  try {
543
1211
  fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md, 'utf-8');
544
1212
  } catch (e) {
@@ -594,6 +1262,96 @@ rulesCmd
594
1262
  }
595
1263
  });
596
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
+
597
1355
  // ─── POLICY COMMANDS ─────────────────────────────────────────────────────────
598
1356
 
599
1357
  const LOCAL_DRAFT_DIR = path.join(process.cwd(), '.korext');
@@ -1289,7 +2047,7 @@ program
1289
2047
  'Content-Type': 'application/json',
1290
2048
  ...(token && { 'Authorization': `Bearer ${token}` })
1291
2049
  },
1292
- 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 })
1293
2051
  });
1294
2052
  if (res.ok) {
1295
2053
  const result = await res.json();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "korext",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
4
4
  "description": "Korext Command Line Interface",
5
5
  "type": "module",
6
6
  "main": "bin/korext.js",