diff --git a/src/reporting/evidence/stellar/evidence-generator.spec.ts b/src/reporting/evidence/stellar/evidence-generator.spec.ts new file mode 100644 index 0000000..def03a1 --- /dev/null +++ b/src/reporting/evidence/stellar/evidence-generator.spec.ts @@ -0,0 +1,65 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { SorobanEvidenceGenerator } from './evidence-generator'; +import { Severity } from '@engine/core'; + +describe('SorobanEvidenceGenerator', () => { + const tempFile = path.join(__dirname, 'dummy-contract.rs'); + const exportPath = path.join(__dirname, 'test-output', 'evidence.json'); + + beforeAll(() => { + fs.writeFileSync(tempFile, `fn main() {\n let x = 1;\n let y = 2;\n // unsafe operation\n let z = x + y;\n return z;\n}\n`, 'utf8'); + }); + + afterAll(() => { + if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile); + if (fs.existsSync(exportPath)) fs.unlinkSync(exportPath); + if (fs.existsSync(path.dirname(exportPath))) fs.rmdirSync(path.dirname(exportPath)); + }); + + it('should generate evidence with code snippets', () => { + const generator = new SorobanEvidenceGenerator({ contextLines: 1 }); + const findings = [ + { + ruleId: 'SOR-001', + severity: Severity.HIGH, + message: 'Unsafe operation detected', + location: { + file: tempFile, + startLine: 4, + endLine: 4 + } + } + ]; + + const evidence = generator.generateEvidence(findings); + expect(evidence).toHaveLength(1); + expect(evidence[0].ruleId).toBe('SOR-001'); + expect(evidence[0].codeSnippet).toBeDefined(); + expect(evidence[0].codeSnippet?.startLine).toBe(3); + expect(evidence[0].codeSnippet?.endLine).toBe(5); + }); + + it('should export evidence to a JSON file', () => { + const generator = new SorobanEvidenceGenerator({ contextLines: 1 }); + const findings = [ + { + ruleId: 'SOR-001', + severity: Severity.HIGH, + message: 'Unsafe operation detected', + location: { + file: tempFile, + startLine: 4, + endLine: 4 + } + } + ]; + + generator.generateAndExportEvidence(findings, exportPath); + expect(fs.existsSync(exportPath)).toBe(true); + + const data = JSON.parse(fs.readFileSync(exportPath, 'utf8')); + expect(Array.isArray(data)).toBe(true); + expect(data[0].ruleId).toBe('SOR-001'); + }); +}); diff --git a/src/reporting/evidence/stellar/evidence-generator.ts b/src/reporting/evidence/stellar/evidence-generator.ts new file mode 100644 index 0000000..2fc5874 --- /dev/null +++ b/src/reporting/evidence/stellar/evidence-generator.ts @@ -0,0 +1,101 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { Finding } from '@engine/core'; +import { stellarKB } from '../../../knowledge-base/stellar/kb'; +import { Evidence, EvidenceGeneratorOptions, CodeSnippet } from './types'; + +export class SorobanEvidenceGenerator { + private fileCache: Map = new Map(); + private options: Required; + + constructor(options?: EvidenceGeneratorOptions) { + this.options = { + contextLines: options?.contextLines ?? 2, + }; + } + + /** + * Extracts a code snippet from a file, adding context lines. + * Uses an internal cache to prevent redundant disk reads for the same file. + */ + public extractCodeSnippet(filePath: string, startLine: number, endLine: number): CodeSnippet | null { + if (!fs.existsSync(filePath)) { + return null; + } + + let lines = this.fileCache.get(filePath); + if (!lines) { + try { + const content = fs.readFileSync(filePath, 'utf8'); + lines = content.split('\n'); + this.fileCache.set(filePath, lines); + } catch (error) { + // Fallback for permissions or unreadable files + return null; + } + } + + // Convert 1-based line numbers to 0-based index + const contextLines = this.options.contextLines; + const extractStart = Math.max(0, startLine - 1 - contextLines); + const extractEnd = Math.min(lines.length, endLine + contextLines); + + const extractedLines = lines.slice(extractStart, extractEnd); + + return { + filePath, + startLine: extractStart + 1, // Convert back to 1-based + endLine: extractEnd, + content: extractedLines.join('\n'), + }; + } + + /** + * Generates supporting evidence for a single finding. + */ + public generateForFinding(finding: Finding): Evidence { + const rule = stellarKB.getRule(finding.ruleId); + + let codeSnippet: CodeSnippet | null = null; + if (finding.location && finding.location.file) { + codeSnippet = this.extractCodeSnippet( + finding.location.file, + finding.location.startLine, + finding.location.endLine + ); + } + + return { + ruleId: finding.ruleId, + severity: finding.severity, + description: rule ? rule.description : finding.message, + explanation: rule ? rule.explanation : 'No detailed explanation available.', + codeSnippet, + documentationUrl: rule?.documentationUrl, + generatedAt: new Date().toISOString(), + }; + } + + /** + * Generates evidence for an array of findings. + */ + public generateEvidence(findings: Finding[]): Evidence[] { + return findings.map((finding) => this.generateForFinding(finding)); + } + + /** + * Generates evidence and exports it as a JSON artifact. + */ + public generateAndExportEvidence(findings: Finding[], outputPath: string): Evidence[] { + const evidence = this.generateEvidence(findings); + + // Ensure the output directory exists + const dir = path.dirname(outputPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(outputPath, JSON.stringify(evidence, null, 2), 'utf8'); + return evidence; + } +} diff --git a/src/reporting/evidence/stellar/index.ts b/src/reporting/evidence/stellar/index.ts new file mode 100644 index 0000000..d45b959 --- /dev/null +++ b/src/reporting/evidence/stellar/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './evidence-generator'; diff --git a/src/reporting/evidence/stellar/types.ts b/src/reporting/evidence/stellar/types.ts new file mode 100644 index 0000000..e752441 --- /dev/null +++ b/src/reporting/evidence/stellar/types.ts @@ -0,0 +1,27 @@ +import { Finding } from '@engine/core'; + +export interface CodeSnippet { + filePath: string; + startLine: number; + endLine: number; + content: string; +} + +export interface Evidence { + findingId?: string; // Optional unique identifier for the finding instance + ruleId: string; + severity: string; + description: string; + explanation: string; + codeSnippet: CodeSnippet | null; + documentationUrl?: string; + generatedAt: string; +} + +export interface EvidenceGeneratorOptions { + /** + * Number of lines to include before and after the finding for context. + * Default is usually 2. + */ + contextLines?: number; +} diff --git a/src/tsconfig.json b/src/tsconfig.json new file mode 100644 index 0000000..b8160fa --- /dev/null +++ b/src/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "include": ["**/*.ts"], + "exclude": [], + "compilerOptions": { + "types": ["node", "jest"] + } +} diff --git a/tsconfig.json b/tsconfig.json index 5f45ca6..f6674a3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -55,6 +55,7 @@ "@api/*": ["apps/api/src/*"] } }, + "include": ["apps/**/*", "libs/**/*", "packages/**/*", "src/**/*"], "include": ["apps/**/*", "libs/**/*", "packages/**/*", "rules/**/*", "src/rules/**/*"], "exclude": [ "node_modules",