Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
158 changes: 158 additions & 0 deletions website/scripts/GenerateReleaseCatalogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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<string, unknown>): 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<string, unknown>): Record<string, unknown> {
const flat = doc.controls as Array<Record<string, unknown>>;
const byFamily = new Map<string, Record<string, unknown>[]>();

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<string, unknown>[] = [];
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<string, unknown> = {};
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<string, unknown>;
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
Expand Down Expand Up @@ -193,13 +334,27 @@ async function generateCatalogArtifacts(catalog: CatalogDirectory): Promise<Gene
console.log(`\n🔨 Generating artifacts for ${buildTarget}...`);

let tempFileInfo = { releaseDetailsCreated: false, metadataBackedUp: false };
let controlsYamlNormalized = false;

try {
// Create temporary DEV release details if needed
if (catalog.needsDevReleaseDetails) {
tempFileInfo = createDevReleaseDetails(catalog.fullPath);
}

try {
controlsYamlNormalized = normalizeControlsYamlInPlace(catalog.fullPath);
} catch (normError) {
const msg = normError instanceof Error ? normError.message : String(normError);
console.log(` ❌ controls.yaml normalization failed: ${msg}`);
return {
catalogPath: buildTarget,
success: false,
error: msg,
generatedFiles: [],
};
}

// Ensure output directory exists
fs.mkdirSync(outputDir, { recursive: true });

Expand Down Expand Up @@ -249,6 +404,9 @@ async function generateCatalogArtifacts(catalog: CatalogDirectory): Promise<Gene
generatedFiles: []
};
} finally {
if (controlsYamlNormalized) {
restoreControlsYamlBackup(catalog.fullPath);
}
// Clean up temporary files if we created them
if (tempFileInfo.releaseDetailsCreated && catalog.needsDevReleaseDetails) {
removeDevReleaseDetails(catalog.fullPath, tempFileInfo.metadataBackedUp);
Expand Down
Loading