diff --git a/.gitignore b/.gitignore index 1eef178a..93a51c6e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ build/oscal-cli .DS_Store # Delivery Tooling delivery-toolkit/artifacts +# Temporary backup during website/scripts/GenerateReleaseCatalogs.ts (flat controls normalization) +**/controls.yaml.backup .env/ # CFI Test Results diff --git a/website/scripts/GenerateReleaseCatalogs.ts b/website/scripts/GenerateReleaseCatalogs.ts index 7b0b7749..8d315a0e 100644 --- a/website/scripts/GenerateReleaseCatalogs.ts +++ b/website/scripts/GenerateReleaseCatalogs.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { exec } from 'child_process'; import { promisify } from 'util'; +import * as yaml from 'js-yaml'; const execAsync = promisify(exec); @@ -32,6 +33,146 @@ interface GenerationResult { generatedFiles: string[]; } +const CONTROLS_BACKUP_NAME = 'controls.yaml.backup'; + +/** + * True when controls.yaml uses a top-level `controls` list (service-native controls) + * and no `control-families` block. Gemara / delivery-toolkit only ingests control-families. + */ +function shouldNormalizeFlatControls(doc: Record): boolean { + const controls = doc.controls; + const families = doc['control-families']; + const hasFlatControls = Array.isArray(controls) && controls.length > 0; + const hasControlFamilies = Array.isArray(families) && families.length > 0; + return hasFlatControls && !hasControlFamilies; +} + +function familyDisplayTitle(familyId: string): string { + const last = familyId.split('.').pop() || familyId; + if (last.toUpperCase() === 'IAM') { + return 'IAM'; + } + if (!last) { + return familyId; + } + return last.charAt(0).toUpperCase() + last.slice(1).toLowerCase(); +} + +/** + * Converts flat `controls` (each with `family`) into `control-families` and drops `controls`. + * Preserves `imported-controls` and any other top-level keys except `controls`. + */ +function flatControlsDocToFamilies(doc: Record): Record { + const flat = doc.controls as Array>; + const byFamily = new Map[]>(); + + for (const ctrl of flat) { + const fam = ctrl.family; + if (typeof fam !== 'string' || !fam.trim()) { + const id = typeof ctrl.id === 'string' ? ctrl.id : '(unknown id)'; + throw new Error(`controls.yaml: control "${id}" must have a non-empty string "family" for flat format`); + } + if (!byFamily.has(fam)) { + byFamily.set(fam, []); + } + const { family: _drop, ...rest } = ctrl; + byFamily.get(fam)!.push(rest); + } + + const controlFamilies: Record[] = []; + const orderedFamilyIds = [...byFamily.keys()].sort(); + for (const familyId of orderedFamilyIds) { + controlFamilies.push({ + id: familyId, + title: familyDisplayTitle(familyId), + description: '', + controls: byFamily.get(familyId), + }); + } + + const out: Record = {}; + for (const key of Object.keys(doc)) { + if (key === 'controls') { + continue; + } + out[key] = doc[key]; + } + out['control-families'] = controlFamilies; + return out; +} + +/** + * If needed, rewrites controls.yaml to control-families format and backs up the original. + */ +function normalizeControlsYamlInPlace(catalogPath: string): boolean { + const controlsPath = path.join(catalogPath, 'controls.yaml'); + const backupPath = path.join(catalogPath, CONTROLS_BACKUP_NAME); + + if (!fs.existsSync(controlsPath)) { + return false; + } + + const raw = fs.readFileSync(controlsPath, 'utf8'); + const doc = yaml.load(raw) as Record; + if (!doc || typeof doc !== 'object') { + return false; + } + + if (!shouldNormalizeFlatControls(doc)) { + return false; + } + + if (fs.existsSync(backupPath)) { + throw new Error( + `Refusing to overwrite stale backup: ${backupPath}. Remove it and retry.` + ); + } + + const normalized = flatControlsDocToFamilies(doc); + const outYaml = yaml.dump(normalized, { + lineWidth: -1, + noRefs: true, + sortKeys: false, + }); + + fs.writeFileSync(backupPath, raw, 'utf8'); + try { + fs.writeFileSync(controlsPath, outYaml, 'utf8'); + } catch (writeErr) { + try { + fs.writeFileSync(controlsPath, raw, 'utf8'); + fs.unlinkSync(backupPath); + } catch { + /* best-effort restore */ + } + throw writeErr; + } + console.log(` ๐Ÿ“ Normalized flat controls.yaml -> control-families (backup: ${CONTROLS_BACKUP_NAME})`); + return true; +} + +/** + * Restores original controls.yaml after a normalization backup. + */ +function restoreControlsYamlBackup(catalogPath: string): void { + const controlsPath = path.join(catalogPath, 'controls.yaml'); + const backupPath = path.join(catalogPath, CONTROLS_BACKUP_NAME); + + if (!fs.existsSync(backupPath)) { + return; + } + try { + const original = fs.readFileSync(backupPath, 'utf8'); + fs.writeFileSync(controlsPath, original, 'utf8'); + fs.unlinkSync(backupPath); + console.log(` ๐Ÿงน Restored original controls.yaml`); + } catch (error) { + console.log( + ` โš ๏ธ Failed to restore controls.yaml from backup: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + /** * Creates a temporary DEV release-details.yaml file for catalogs without one * Also creates a backup of the original metadata.yaml and creates a DEV version @@ -193,6 +334,7 @@ async function generateCatalogArtifacts(catalog: CatalogDirectory): Promise