ssvc
Version:
TypeScript implementation of SSVC (Stakeholder-Specific Vulnerability Categorization). A prioritization framework to triage CVE vulnerabilities as an alternative or compliment to CVSS
402 lines (305 loc) • 12.2 kB
text/typescript
/**
* 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';
interface EnumDefinition {
[enumName: string]: string[];
}
interface DecisionNode {
type?: string;
children?: { [key: string]: DecisionNode | string };
}
interface PluginConfig {
name: string;
description: string;
version: string;
url?: string;
enums: EnumDefinition;
priorityMap: { [action: string]: string };
decisionTree: DecisionNode;
defaultAction?: string;
}
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
const 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 });
}
fs.writeFileSync(tsFile, tsCode);
// Generate markdown documentation
const markdownCode = this.generateMarkdownDocs(config, pluginName);
const docsFile = path.join(this.docsDir, `${pluginName}.md`);
if (!fs.existsSync(this.docsDir)) {
fs.mkdirSync(this.docsDir, { recursive: true });
}
fs.writeFileSync(docsFile, markdownCode);
console.log(`Generated ${tsFile} and ${docsFile}`);
}
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);
return `/**
* ${config.name} Plugin
*
* ${config.description}
* Generated from YAML configuration.
*/
${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 {
let actionEnum = '';
let priorityEnum = '';
for (const enumName of Object.keys(enums)) {
if (enumName.includes('ActionType') || enumName === 'ActionType') {
actionEnum = enumName;
} else if (enumName.includes('Priority') || enumName.endsWith('PriorityLevel')) {
priorityEnum = enumName;
}
}
if (!actionEnum || !priorityEnum) {
throw new Error(`Could not find ActionType and Priority enums. Available enums: ${Object.keys(enums).join(', ')}`);
}
const mappings: string[] = [];
for (const [action, priority] of Object.entries(priorityMap)) {
mappings.push(` [${actionEnum}.${action}]: ${priorityEnum}.${priority}`);
}
return `export const priorityMap = {\n${mappings.join(',\n')}\n};`;
}
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 (exclude ActionType and Priority)
const decisionEnums: string[] = [];
for (const enumName of Object.keys(config.enums)) {
if (!enumName.includes('ActionType') && !enumName.includes('Priority')) {
decisionEnums.push(enumName);
}
}
// 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;
}
${treeMethod}
}`;
}
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 generateMarkdownDocs(config: PluginConfig, pluginName: string): string {
const mermaidDiagram = this.generateMermaidDiagram(config.decisionTree, pluginName);
return `# ${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
\`\`\`typescript
import { Decision${this.toPascalCase(pluginName)} } from './plugins/${pluginName}';
const decision = new Decision${this.toPascalCase(pluginName)}({
// Add parameters based on methodology
});
const outcome = decision.evaluate();
console.log(outcome.action, outcome.priority);
\`\`\`
`;
}
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
nodes.push(` ${currentId}[${node}]`);
nodes.push(` ${currentId} --> ${currentId}_end((End))`);
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 TD\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 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();
}