Skip to content

Commit 87edfbd

Browse files
authored
Merge pull request #526 from TochukwuJustice/evidence-generator
feat(reporting): fix #504 by implementing Soroban Audit Evidence Generator
2 parents f1e4bc9 + ba2c62e commit 87edfbd

6 files changed

Lines changed: 204 additions & 0 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import { SorobanEvidenceGenerator } from './evidence-generator';
4+
import { Severity } from '@engine/core';
5+
6+
describe('SorobanEvidenceGenerator', () => {
7+
const tempFile = path.join(__dirname, 'dummy-contract.rs');
8+
const exportPath = path.join(__dirname, 'test-output', 'evidence.json');
9+
10+
beforeAll(() => {
11+
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');
12+
});
13+
14+
afterAll(() => {
15+
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
16+
if (fs.existsSync(exportPath)) fs.unlinkSync(exportPath);
17+
if (fs.existsSync(path.dirname(exportPath))) fs.rmdirSync(path.dirname(exportPath));
18+
});
19+
20+
it('should generate evidence with code snippets', () => {
21+
const generator = new SorobanEvidenceGenerator({ contextLines: 1 });
22+
const findings = [
23+
{
24+
ruleId: 'SOR-001',
25+
severity: Severity.HIGH,
26+
message: 'Unsafe operation detected',
27+
location: {
28+
file: tempFile,
29+
startLine: 4,
30+
endLine: 4
31+
}
32+
}
33+
];
34+
35+
const evidence = generator.generateEvidence(findings);
36+
expect(evidence).toHaveLength(1);
37+
expect(evidence[0].ruleId).toBe('SOR-001');
38+
expect(evidence[0].codeSnippet).toBeDefined();
39+
expect(evidence[0].codeSnippet?.startLine).toBe(3);
40+
expect(evidence[0].codeSnippet?.endLine).toBe(5);
41+
});
42+
43+
it('should export evidence to a JSON file', () => {
44+
const generator = new SorobanEvidenceGenerator({ contextLines: 1 });
45+
const findings = [
46+
{
47+
ruleId: 'SOR-001',
48+
severity: Severity.HIGH,
49+
message: 'Unsafe operation detected',
50+
location: {
51+
file: tempFile,
52+
startLine: 4,
53+
endLine: 4
54+
}
55+
}
56+
];
57+
58+
generator.generateAndExportEvidence(findings, exportPath);
59+
expect(fs.existsSync(exportPath)).toBe(true);
60+
61+
const data = JSON.parse(fs.readFileSync(exportPath, 'utf8'));
62+
expect(Array.isArray(data)).toBe(true);
63+
expect(data[0].ruleId).toBe('SOR-001');
64+
});
65+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import { Finding } from '@engine/core';
4+
import { stellarKB } from '../../../knowledge-base/stellar/kb';
5+
import { Evidence, EvidenceGeneratorOptions, CodeSnippet } from './types';
6+
7+
export class SorobanEvidenceGenerator {
8+
private fileCache: Map<string, string[]> = new Map();
9+
private options: Required<EvidenceGeneratorOptions>;
10+
11+
constructor(options?: EvidenceGeneratorOptions) {
12+
this.options = {
13+
contextLines: options?.contextLines ?? 2,
14+
};
15+
}
16+
17+
/**
18+
* Extracts a code snippet from a file, adding context lines.
19+
* Uses an internal cache to prevent redundant disk reads for the same file.
20+
*/
21+
public extractCodeSnippet(filePath: string, startLine: number, endLine: number): CodeSnippet | null {
22+
if (!fs.existsSync(filePath)) {
23+
return null;
24+
}
25+
26+
let lines = this.fileCache.get(filePath);
27+
if (!lines) {
28+
try {
29+
const content = fs.readFileSync(filePath, 'utf8');
30+
lines = content.split('\n');
31+
this.fileCache.set(filePath, lines);
32+
} catch (error) {
33+
// Fallback for permissions or unreadable files
34+
return null;
35+
}
36+
}
37+
38+
// Convert 1-based line numbers to 0-based index
39+
const contextLines = this.options.contextLines;
40+
const extractStart = Math.max(0, startLine - 1 - contextLines);
41+
const extractEnd = Math.min(lines.length, endLine + contextLines);
42+
43+
const extractedLines = lines.slice(extractStart, extractEnd);
44+
45+
return {
46+
filePath,
47+
startLine: extractStart + 1, // Convert back to 1-based
48+
endLine: extractEnd,
49+
content: extractedLines.join('\n'),
50+
};
51+
}
52+
53+
/**
54+
* Generates supporting evidence for a single finding.
55+
*/
56+
public generateForFinding(finding: Finding): Evidence {
57+
const rule = stellarKB.getRule(finding.ruleId);
58+
59+
let codeSnippet: CodeSnippet | null = null;
60+
if (finding.location && finding.location.file) {
61+
codeSnippet = this.extractCodeSnippet(
62+
finding.location.file,
63+
finding.location.startLine,
64+
finding.location.endLine
65+
);
66+
}
67+
68+
return {
69+
ruleId: finding.ruleId,
70+
severity: finding.severity,
71+
description: rule ? rule.description : finding.message,
72+
explanation: rule ? rule.explanation : 'No detailed explanation available.',
73+
codeSnippet,
74+
documentationUrl: rule?.documentationUrl,
75+
generatedAt: new Date().toISOString(),
76+
};
77+
}
78+
79+
/**
80+
* Generates evidence for an array of findings.
81+
*/
82+
public generateEvidence(findings: Finding[]): Evidence[] {
83+
return findings.map((finding) => this.generateForFinding(finding));
84+
}
85+
86+
/**
87+
* Generates evidence and exports it as a JSON artifact.
88+
*/
89+
public generateAndExportEvidence(findings: Finding[], outputPath: string): Evidence[] {
90+
const evidence = this.generateEvidence(findings);
91+
92+
// Ensure the output directory exists
93+
const dir = path.dirname(outputPath);
94+
if (!fs.existsSync(dir)) {
95+
fs.mkdirSync(dir, { recursive: true });
96+
}
97+
98+
fs.writeFileSync(outputPath, JSON.stringify(evidence, null, 2), 'utf8');
99+
return evidence;
100+
}
101+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './types';
2+
export * from './evidence-generator';
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Finding } from '@engine/core';
2+
3+
export interface CodeSnippet {
4+
filePath: string;
5+
startLine: number;
6+
endLine: number;
7+
content: string;
8+
}
9+
10+
export interface Evidence {
11+
findingId?: string; // Optional unique identifier for the finding instance
12+
ruleId: string;
13+
severity: string;
14+
description: string;
15+
explanation: string;
16+
codeSnippet: CodeSnippet | null;
17+
documentationUrl?: string;
18+
generatedAt: string;
19+
}
20+
21+
export interface EvidenceGeneratorOptions {
22+
/**
23+
* Number of lines to include before and after the finding for context.
24+
* Default is usually 2.
25+
*/
26+
contextLines?: number;
27+
}

src/tsconfig.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../tsconfig.json",
3+
"include": ["**/*.ts"],
4+
"exclude": [],
5+
"compilerOptions": {
6+
"types": ["node", "jest"]
7+
}
8+
}

tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"@api/*": ["apps/api/src/*"]
5656
}
5757
},
58+
"include": ["apps/**/*", "libs/**/*", "packages/**/*", "src/**/*"],
5859
"include": ["apps/**/*", "libs/**/*", "packages/**/*", "rules/**/*", "src/rules/**/*"],
5960
"exclude": [
6061
"node_modules",

0 commit comments

Comments
 (0)