UNPKG

ssvc

Version:

TypeScript implementation of SSVC (Stakeholder-Specific Vulnerability Categorization). A prioritization framework to triage CVE vulnerabilities as an alternative or compliment to CVSS

715 lines (552 loc) 22.5 kB
#!/usr/bin/env ts-node /** * SSVC Plugin Generator for TypeScript * * Generates TypeScript plugin modules from YAML decision tree definitions. * Also generates markdown documentation with mermaid diagrams. */ import * as fs from 'fs'; import * as path from 'path'; import * as yaml from 'yaml'; import { createHash } from 'crypto'; import { execSync } from 'child_process'; interface EnumDefinition { [enumName: string]: string[]; } interface DecisionNode { type?: string; children?: { [key: string]: DecisionNode | string }; } interface VectorMetadata { prefix: string; version: string; parameterMappings: { [paramName: string]: { abbrev: string; enumType: string; valueMappings?: { [enumValue: string]: string }; }; }; } interface PluginConfig { name: string; description: string; version: string; url?: string; enums: EnumDefinition; priorityMap: { [action: string]: string }; decisionTree: DecisionNode; defaultAction?: string; vectorMetadata?: VectorMetadata; } class SSVCPluginGenerator { private yamlDir: string; private outputDir: string; private docsDir: string; constructor(yamlDir: string, outputDir: string, docsDir: string) { this.yamlDir = yamlDir; this.outputDir = outputDir; this.docsDir = docsDir; } generateAll(): void { const yamlFiles = fs.readdirSync(this.yamlDir) .filter(file => file.endsWith('.yaml') || file.endsWith('.yml')) .map(file => path.join(this.yamlDir, file)); for (const yamlFile of yamlFiles) { console.log(`Processing ${path.basename(yamlFile)}...`); this.generatePlugin(yamlFile); } } generatePlugin(yamlFile: string): void { const yamlContent = fs.readFileSync(yamlFile, 'utf8'); const config = yaml.parse(yamlContent) as PluginConfig; const pluginName = path.basename(yamlFile, path.extname(yamlFile)); // Generate TypeScript plugin let tsCode = this.generateTypeScriptPlugin(config, pluginName); const tsFile = path.join(this.outputDir, `${pluginName}-generated.ts`); if (!fs.existsSync(this.outputDir)) { fs.mkdirSync(this.outputDir, { recursive: true }); } // Format TypeScript code before writing and calculating checksum tsCode = this.formatTypeScript(tsCode); fs.writeFileSync(tsFile, tsCode); // Calculate checksum for formatted TypeScript file const tsChecksum = this.calculateSHA1(tsCode); // Generate markdown documentation with checksum let markdownCode = this.generateMarkdownDocs(config, pluginName, tsFile, tsChecksum); const docsFile = path.join(this.docsDir, `${pluginName}.md`); if (!fs.existsSync(this.docsDir)) { fs.mkdirSync(this.docsDir, { recursive: true }); } // Format markdown code before writing markdownCode = this.formatMarkdown(markdownCode); fs.writeFileSync(docsFile, markdownCode); console.log(`Generated ${tsFile} and ${docsFile}`); console.log(`TypeScript checksum: ${tsChecksum}`); } private generateTypeScriptPlugin(config: PluginConfig, pluginName: string): string { const enumsCode = this.generateEnums(config.enums); const priorityMapCode = this.generatePriorityMap(config.priorityMap, config.enums); const outcomeClassCode = this.generateOutcomeClass(pluginName); const decisionClassCode = this.generateDecisionClass(config, pluginName); const currentDate = new Date().toISOString(); const yamlFilePath = `methodologies/${pluginName}.yaml`; return `/** * ${config.name} Plugin * * ${config.description} * Generated from YAML configuration. * * DO NOT EDIT THIS FILE DIRECTLY * This file is auto-generated. To make changes: * 1. Edit the source YAML file: ${yamlFilePath} * 2. Run: yarn generate-plugins * * @generated true * @source ${yamlFilePath} * @generator scripts/generate-plugins.ts * @lastGenerated ${currentDate} */ ${enumsCode} ${priorityMapCode} ${outcomeClassCode} ${decisionClassCode} `; } private generateEnums(enums: EnumDefinition): string { const enumClasses: string[] = []; for (const [enumName, values] of Object.entries(enums)) { const enumValues: string[] = []; for (const value of values) { const enumValue = typeof value === 'boolean' ? (value ? 'YES' : 'NO') : value; const stringValue = typeof value === 'boolean' ? (value ? 'yes' : 'no') : value.toLowerCase(); enumValues.push(` ${enumValue} = "${stringValue}"`); } const enumClass = `export enum ${enumName} {\n${enumValues.join(',\n')}\n}`; enumClasses.push(enumClass); } return enumClasses.join('\n\n'); } private generatePriorityMap(priorityMap: { [action: string]: string }, enums: EnumDefinition): string { // Generate action and priority enums dynamically from priorityMap const actions = Object.keys(priorityMap); const priorities = [...new Set(Object.values(priorityMap))]; // Generate ActionType enum const actionEnumValues = actions.map(action => ` ${action} = '${action}'`).join(',\n'); const actionEnum = `export enum ActionType {\n${actionEnumValues}\n}`; // Generate PriorityLevel enum const priorityEnumValues = priorities.map(priority => ` ${priority.toUpperCase()} = '${priority}'`).join(',\n'); const priorityEnum = `export enum PriorityLevel {\n${priorityEnumValues}\n}`; // Generate priority mappings const mappings: string[] = []; for (const [action, priority] of Object.entries(priorityMap)) { mappings.push(` [ActionType.${action}]: PriorityLevel.${priority.toUpperCase()}`); } const priorityMapCode = `export const priorityMap = {\n${mappings.join(',\n')}\n};`; return `${actionEnum}\n\n${priorityEnum}\n\n${priorityMapCode}`; } private generateOutcomeClass(pluginName: string): string { const className = `Outcome${this.toPascalCase(pluginName)}`; return `export class ${className} { priority: string; action: string; constructor(action: any) { this.priority = (priorityMap as any)[action]; this.action = action; } }`; } private generateDecisionClass(config: PluginConfig, pluginName: string): string { const className = `Decision${this.toPascalCase(pluginName)}`; const outcomeClass = `Outcome${this.toPascalCase(pluginName)}`; // Get decision point enums by analyzing the decision tree const decisionEnums = this.collectDecisionPointEnums(config.decisionTree); // Generate constructor parameters interface const interfaceName = `${className}Options`; const interfaceProps: string[] = []; const typeConversions: string[] = []; const attributes: string[] = []; const validations: string[] = []; for (const enumName of decisionEnums) { const paramName = this.enumToParamName(enumName); interfaceProps.push(` ${paramName}?: ${enumName} | string;`); typeConversions.push(` if (typeof options.${paramName} === 'string') {`); typeConversions.push(` this.${paramName} = Object.values(${enumName}).find(v => v === options.${paramName}) as ${enumName} || undefined;`); typeConversions.push(` } else {`); typeConversions.push(` this.${paramName} = options.${paramName};`); typeConversions.push(` }`); attributes.push(` ${paramName}?: ${enumName};`); validations.push(`this.${paramName} !== undefined`); } // Generate decision tree traversal const treeMethod = this.generateDecisionTreeMethod(config.decisionTree, config.defaultAction || 'TRACK'); return `interface ${interfaceName} { ${interfaceProps.join('\n')} } export class ${className} { ${attributes.join('\n')} outcome?: ${outcomeClass}; constructor(options: ${interfaceName} = {}) { ${typeConversions.join('\n')} // Always try to evaluate if we have the minimum required parameters if (${validations.join(' && ')}) { this.outcome = this.evaluate(); } } evaluate(): ${outcomeClass} { const action = this.traverseTree(); this.outcome = new ${outcomeClass}(action); return this.outcome; } ${this.generateVectorMethods(config, className)} ${treeMethod} }`; } private generateVectorMethods(config: PluginConfig, className: string): string { if (!config.vectorMetadata) { return ''; } const vectorMeta = config.vectorMetadata; // Generate toVector method const toVectorParams: string[] = []; for (const [paramName, mapping] of Object.entries(vectorMeta.parameterMappings)) { const actualParamName = this.enumToParamName(mapping.enumType); const valueMappings = mapping.valueMappings || {}; const hasValueMappings = Object.keys(valueMappings).length > 0; if (hasValueMappings) { toVectorParams.push(` const ${paramName}Vector = ${JSON.stringify(valueMappings)}[this.${actualParamName}?.toString?.()?.toUpperCase?.() ?? ''] || this.${actualParamName} || '';`); } else { toVectorParams.push(` const ${paramName}Vector = this.${actualParamName} || '';`); } } const vectorSegments = Object.entries(vectorMeta.parameterMappings) .map(([paramName, mapping]) => `${mapping.abbrev}:\${${paramName}Vector}`) .join('/'); const fromVectorValidations: string[] = []; const fromVectorParams: string[] = []; for (const [paramName, mapping] of Object.entries(vectorMeta.parameterMappings)) { const actualParamName = this.enumToParamName(mapping.enumType); const reverseValueMappings = mapping.valueMappings ? Object.fromEntries( Object.entries(mapping.valueMappings).map(([k, v]) => [v, k]) ) : {}; fromVectorValidations.push(` const ${paramName}Match = params.get('${mapping.abbrev}');`); if (Object.keys(reverseValueMappings).length > 0) { fromVectorParams.push(` ${actualParamName}: ${JSON.stringify(reverseValueMappings)}[${paramName}Match || ''] || ${paramName}Match,`); } else { fromVectorParams.push(` ${actualParamName}: ${paramName}Match,`); } } return ` toVector(): string { if (!this.outcome) { this.evaluate(); } ${toVectorParams.join('\n')} const timestamp = new Date().toISOString(); return \`${vectorMeta.prefix}${vectorMeta.version}/${vectorSegments}/\${timestamp}/\`; } static fromVector(vectorString: string): ${className} { const regex = /^${vectorMeta.prefix}${vectorMeta.version}\\/(.+)\\/([0-9T:\\-\\.Z]+)\\/?$/; const match = vectorString.match(regex); if (!match) { throw new Error(\`Invalid vector string format for ${config.name}: \${vectorString}\`); } const paramsString = match[1]; const params = new Map<string, string>(); const paramPairs = paramsString.split('/'); for (const pair of paramPairs) { const [key, value] = pair.split(':'); if (key && value !== undefined) { params.set(key, value); } } ${fromVectorValidations.join('\n')} return new ${className}({ ${fromVectorParams.join('\n')} }); }`; } private collectDecisionPointEnums(tree: DecisionNode): string[] { const enumTypes = new Set<string>(); const traverse = (node: DecisionNode | string) => { if (typeof node === 'string') { return; // Leaf node } if (node.type) { enumTypes.add(node.type); } if (node.children) { for (const childNode of Object.values(node.children)) { traverse(childNode); } } }; traverse(tree); return Array.from(enumTypes); } private generateDecisionTreeMethod(tree: DecisionNode, defaultAction: string): string { const generateTraversalCode = (node: DecisionNode | string, depth: number = 2): string => { const indent = ' '.repeat(depth); if (typeof node === 'string') { // Leaf node - return action return `${indent}return ActionType.${node};`; } if (!node.type || !node.children) { return `${indent}return ActionType.${defaultAction};`; } const paramName = this.enumToParamName(node.type); const codeLines: string[] = []; const children = Object.entries(node.children); for (let i = 0; i < children.length; i++) { const [value, childNode] = children[i]; const condition = i === 0 ? 'if' : 'else if'; // Handle enum values correctly const enumValue = typeof value === 'boolean' ? (value ? 'YES' : 'NO') : value; codeLines.push(`${indent}${condition} (this.${paramName} === ${node.type}.${enumValue}) {`); if (typeof childNode === 'string') { // Direct action codeLines.push(`${indent} return ActionType.${childNode};`); } else { // Recursive traversal const childCode = generateTraversalCode(childNode, depth + 1); codeLines.push(childCode); } codeLines.push(`${indent}}`); } return codeLines.join('\n'); }; const traversalCode = generateTraversalCode(tree); return ` private traverseTree(): any { // Traverse the decision tree to determine the outcome ${traversalCode} // Default action for unmapped paths return ActionType.${defaultAction}; }`; } private calculateSHA1(content: string): string { return createHash('sha1').update(content).digest('hex'); } private generateMarkdownDocs(config: PluginConfig, pluginName: string, tsFile: string, tsChecksum: string): string { const mermaidDiagram = this.generateMermaidDiagram(config.decisionTree, pluginName); const currentDate = new Date().toISOString(); const yamlFilePath = `methodologies/${pluginName}.yaml`; return `--- generated: true source: ${yamlFilePath} generator: scripts/generate-plugins.ts lastGenerated: ${currentDate} generatedFiles: typescript: path: ${tsFile} checksum: ${tsChecksum} --- # ${config.name} ${config.description} **Version:** ${config.version} ${config.url ? `**URL:** ${config.url}` : ''} ## Decision Tree \`\`\`mermaid ${mermaidDiagram} \`\`\` ## Enums ${Object.entries(config.enums).map(([enumName, values]) => `### ${enumName}\n- ${values.join('\n- ')}` ).join('\n\n')} ## Priority Mapping ${Object.entries(config.priorityMap).map(([action, priority]) => `- **${action}** → ${priority}` ).join('\n')} ## Usage ### Direct Plugin Usage \`\`\`typescript import { Decision${this.toPascalCase(pluginName)} } from 'ssvc'; const decision = new Decision${this.toPascalCase(pluginName)}({ // Add parameters based on methodology }); const outcome = decision.evaluate(); console.log(outcome.action, outcome.priority); \`\`\` ### Using the Generic API \`\`\`typescript import { createDecision } from 'ssvc'; const decision = createDecision('${pluginName}', { // Add parameters based on methodology }); const outcome = decision.evaluate(); console.log(outcome.action, outcome.priority); \`\`\` ${this.generateVectorDocumentation(config, pluginName)} ## File Integrity Verification The generated files in this methodology have SHA1 checksums for verification: ### Checksum Verification Commands Verify the integrity of generated files using these commands: \`\`\`bash # Verify TypeScript plugin file echo "${tsChecksum} ${tsFile}" | sha1sum -c \`\`\` **Why This Matters**: Checksum verification ensures that generated files haven't been tampered with or corrupted. This is important for: - **Security**: Detecting unauthorized modifications to generated code - **Integrity**: Ensuring files match their expected content exactly - **Trust**: Providing cryptographic proof that files are authentic - **Debugging**: Confirming file corruption isn't causing unexpected behavior Always verify checksums before deploying or using generated files in production environments. `; } private generateVectorDocumentation(config: PluginConfig, pluginName: string): string { if (!config.vectorMetadata) { return ''; } const vectorMeta = config.vectorMetadata; const className = `Decision${this.toPascalCase(pluginName)}`; // Generate parameter abbreviations table const paramTable = Object.entries(vectorMeta.parameterMappings) .map(([paramName, mapping]) => { const valueMappings = mapping.valueMappings; if (valueMappings && Object.keys(valueMappings).length > 0) { const valueExamples = Object.entries(valueMappings) .map(([key, value]) => `${key}${value}`) .join(', '); return `| ${paramName} | ${mapping.abbrev} | ${valueExamples} |`; } return `| ${paramName} | ${mapping.abbrev} | Direct mapping |`; }) .join('\n'); // Generate example vector strings const firstParams = Object.keys(vectorMeta.parameterMappings); const exampleParams: string[] = []; const vectorSegments: string[] = []; for (const [paramName, mapping] of Object.entries(vectorMeta.parameterMappings)) { const firstEnumValue = Object.keys(mapping.valueMappings || {})[0]; if (firstEnumValue) { exampleParams.push(` ${paramName}: "${firstEnumValue}"`); vectorSegments.push(`${mapping.abbrev}:${mapping.valueMappings![firstEnumValue]}`); } else { exampleParams.push(` ${paramName}: "example"`); vectorSegments.push(`${mapping.abbrev}:example`); } } const exampleVectorString = `${vectorMeta.prefix}${vectorMeta.version}/${vectorSegments.join('/')}/2024-07-23T20:34:21.000Z/`; return `## Vector String Support This methodology supports SSVC vector strings for compact representation and interchange. ### Parameter Abbreviations | Parameter | Abbreviation | Value Mappings | |-----------|--------------|----------------| ${paramTable} ### Vector String Format \`\`\` ${vectorMeta.prefix}${vectorMeta.version}/[parameters]/[timestamp]/ \`\`\` ### Example Usage \`\`\`typescript import { ${className} } from 'ssvc'; // Generate vector string from decision const decision = new ${className}({ ${exampleParams.join(',\n')} }); const vectorString = decision.toVector(); console.log(vectorString); // Output: ${exampleVectorString} // Parse vector string to create decision const parsedDecision = ${className}.fromVector("${exampleVectorString}"); const outcome = parsedDecision.evaluate(); \`\`\``; } private generateMermaidDiagram(tree: DecisionNode, pluginName: string): string { let nodeId = 0; const nodes: string[] = []; const edges: string[] = []; const processNode = (node: DecisionNode | string, parentId?: number, edgeLabel?: string): number => { const currentId = nodeId++; if (typeof node === 'string') { // Leaf node (action) - terminal point, no need for additional end node nodes.push(` ${currentId}[${node}]`); if (parentId !== undefined && edgeLabel) { edges.push(` ${parentId} -->|${edgeLabel}| ${currentId}`); } return currentId; } if (!node.type || !node.children) { return currentId; } // Decision node nodes.push(` ${currentId}{${node.type}}`); if (parentId !== undefined && edgeLabel) { edges.push(` ${parentId} -->|${edgeLabel}| ${currentId}`); } // Process children for (const [value, childNode] of Object.entries(node.children)) { processNode(childNode, currentId, value); } return currentId; }; processNode(tree); return `flowchart LR\n${nodes.join('\n')}\n${edges.join('\n')}`; } private enumToParamName(enumName: string): string { // Remove common suffixes and convert to camelCase let paramName = enumName.replace(/Status$/, '').replace(/Level$/, '').replace(/_/g, ''); // Convert PascalCase to camelCase return paramName.charAt(0).toLowerCase() + paramName.slice(1); } private toPascalCase(str: string): string { return str.replace(/(^\w|_\w)/g, (match) => match.replace('_', '').toUpperCase()); } private formatTypeScript(code: string): string { try { // Write to temporary file for prettier formatting const tempFile = path.join(__dirname, 'temp.ts'); fs.writeFileSync(tempFile, code); // Format with prettier const formattedCode = execSync(`npx prettier --parser typescript "${tempFile}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'inherit'] }); // Clean up temp file if (fs.existsSync(tempFile)) { fs.unlinkSync(tempFile); } return formattedCode; } catch (error) { console.warn('Warning: Failed to format TypeScript code with prettier, using original code'); return code; } } private formatMarkdown(code: string): string { try { // Write to temporary file for prettier formatting const tempFile = path.join(__dirname, 'temp.md'); fs.writeFileSync(tempFile, code); // Format with prettier const formattedCode = execSync(`npx prettier --parser markdown "${tempFile}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'inherit'] }); // Clean up temp file if (fs.existsSync(tempFile)) { fs.unlinkSync(tempFile); } return formattedCode; } catch (error) { console.warn('Warning: Failed to format Markdown code with prettier, using original code'); return code; } } private getExportList(config: PluginConfig, pluginName: string): string { const exports: string[] = []; // Add all enums exports.push(...Object.keys(config.enums)); // Add priority map with alias to avoid conflicts exports.push(`priorityMap as ${pluginName}PriorityMap`); // Add outcome and decision classes exports.push(`Outcome${this.toPascalCase(pluginName)}`); exports.push(`Decision${this.toPascalCase(pluginName)}`); return exports.join(', '); } } // Main execution if (require.main === module) { const yamlDir = path.join(__dirname, '..', 'methodologies'); const outputDir = path.join(__dirname, '..', 'src', 'plugins'); const docsDir = path.join(__dirname, '..', 'docs'); const generator = new SSVCPluginGenerator(yamlDir, outputDir, docsDir); generator.generateAll(); }