diff --git a/src/analysis/stellar/complexity/complexity-analyzer.spec.ts b/src/analysis/stellar/complexity/complexity-analyzer.spec.ts new file mode 100644 index 0000000..64c189b --- /dev/null +++ b/src/analysis/stellar/complexity/complexity-analyzer.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from '@jest/globals'; +import { StellarComplexityAnalyzer } from './complexity-analyzer'; + +describe('StellarComplexityAnalyzer', () => { + it('calculates complexity for a simple function', () => { + const source = `pub struct Simple; +pub fn greet() -> u64 { 42 }`; + + const analyzer = new StellarComplexityAnalyzer(source, 'simple.rs'); + const report = analyzer.analyze(); + + expect(report.contractName).toBe('Simple'); + expect(report.functions.length).toBe(1); + expect(report.functions[0].metrics.cyclomaticComplexity).toBe(1); + expect(report.functions[0].riskLevel).toBe('low'); + }); + + it('detects high complexity from branches and conditions', () => { + const source = `pub fn complex(env: Env, x: u64) -> u64 { + if x > 10 { + if x > 20 { + return x * 2; + } else if x > 15 { + return x * 3; + } + match env { + a => 1, + b => 2, + c => 3, + } + } + 0 +}`; + + const analyzer = new StellarComplexityAnalyzer(source, 'complex.rs'); + const report = analyzer.analyze(); + + expect(report.functions[0].metrics.cyclomaticComplexity).toBeGreaterThan(3); + expect(report.totalComplexity).toBeGreaterThan(0); + }); + + it('provides risk breakdown', () => { + const source = `pub fn a() {} +pub fn b() { if true {} } +pub fn c() { if true { if false {} } match x { 1 => (), 2 => (), 3 => (), 4 => () } }`; + + const analyzer = new StellarComplexityAnalyzer(source, 'multi.rs'); + const report = analyzer.analyze(); + + expect(report.functions.length).toBe(3); + const totalRisk = report.riskBreakdown.low + report.riskBreakdown.medium + report.riskBreakdown.high + report.riskBreakdown.critical; + expect(totalRisk).toBe(3); + }); +}); diff --git a/src/analysis/stellar/complexity/complexity-analyzer.ts b/src/analysis/stellar/complexity/complexity-analyzer.ts new file mode 100644 index 0000000..fde1ff4 --- /dev/null +++ b/src/analysis/stellar/complexity/complexity-analyzer.ts @@ -0,0 +1,176 @@ +import { ComplexityConfig, ComplexityMetrics, ComplexityReport, FunctionComplexity } from './types'; + +export class StellarComplexityAnalyzer { + private source: string; + private filePath: string; + private config: ComplexityConfig; + + constructor( + source: string, + filePath: string, + config: ComplexityConfig = { thresholds: { low: 5, medium: 10, high: 20 }, includePrivate: true }, + ) { + this.source = source; + this.filePath = filePath; + this.config = config; + } + + analyze(): ComplexityReport { + const contractName = this.extractContractName(); + const functions = this.analyzeFunctions(); + + const totalComplexity = functions.reduce((sum, f) => sum + f.metrics.cyclomaticComplexity, 0); + const averageComplexity = functions.length > 0 ? Math.round(totalComplexity / functions.length) : 0; + const highestComplexity = functions.length > 0 + ? functions.reduce((max, f) => f.metrics.cyclomaticComplexity > max.metrics.cyclomaticComplexity ? f : max) + : null; + + const riskBreakdown = { low: 0, medium: 0, high: 0, critical: 0 }; + for (const f of functions) { + riskBreakdown[f.riskLevel]++; + } + + return { + contractName, + functions, + totalComplexity, + averageComplexity, + highestComplexity, + riskBreakdown, + summary: this.generateSummary(contractName, functions.length, totalComplexity, averageComplexity, riskBreakdown), + }; + } + + private extractContractName(): string { + const match = this.source.match(/pub struct (\w+)/) + || this.source.match(/#\[contract\]\s*\n.*?pub\s+(?:struct|fn)\s+(\w+)/) + || this.source.match(/contract\s+(\w+)/); + return match ? match[1] : 'UnknownContract'; + } + + private analyzeFunctions(): FunctionComplexity[] { + const functions: FunctionComplexity[] = []; + const fnRegex = /(pub\s+)?fn\s+(\w+)\s*\(/g; + let match; + + while ((match = fnRegex.exec(this.source)) !== null) { + const name = match[2]; + const body = this.extractFunctionBody(match.index); + + const metrics = this.calculateMetrics(body); + const riskLevel = this.determineRiskLevel(metrics.cyclomaticComplexity); + const recommendation = this.getRecommendation(name, metrics, riskLevel); + + functions.push({ + name, + line: this.getLineNumber(match.index), + metrics, + riskLevel, + recommendation, + }); + } + + return functions; + } + + private extractFunctionBody(fnStart: number): string { + const openBrace = this.source.indexOf('{', fnStart); + if (openBrace === -1) return ''; + + let braceCount = 1; + let body = ''; + for (let i = openBrace + 1; i < this.source.length && braceCount > 0; i++) { + if (this.source[i] === '{') braceCount++; + else if (this.source[i] === '}') braceCount--; + if (braceCount > 0) body += this.source[i]; + } + return body; + } + + private calculateMetrics(body: string): ComplexityMetrics { + const cyclomaticComplexity = 1 + + (body.match(/\bif\b/g) || []).length + + (body.match(/\belse if\b/g) || []).length + + (body.match(/\bmatch\b/g) || []).length + + (body.match(/\bcase\b/g) || []).length + + (body.match(/&&/g) || []).length + + (body.match(/\|\|/g) || []).length; + + const cognitiveComplexity = this.calculateCognitiveComplexity(body); + const branchCount = (body.match(/\bif\b|\belse\b/g) || []).length; + const loopCount = (body.match(/\bfor\b|\bwhile\b|\bloop\b/g) || []).length; + const nestingDepth = this.calculateNestingDepth(body); + const recursionDepth = this.detectRecursion(body); + + return { + cyclomaticComplexity, + cognitiveComplexity, + functionCount: 0, + branchCount, + loopCount, + recursionDepth, + nestingDepth, + }; + } + + private calculateCognitiveComplexity(body: string): number { + let score = 0; + if (body.match(/\bif\b/g)) score += (body.match(/\bif\b/g) || []).length; + if (body.match(/\bmatch\b/g)) score += (body.match(/\bmatch\b/g) || []).length * 2; + if (body.match(/\bfor\b|\bwhile\b/g)) score += (body.match(/\bfor\b|\bwhile\b/g) || []).length * 2; + if (body.match(/\btry\b/g)) score += (body.match(/\btry\b/g) || []).length; + return score; + } + + private calculateNestingDepth(body: string): number { + let maxDepth = 0; + let currentDepth = 0; + for (const char of body) { + if (char === '{') currentDepth++; + if (char === '}') currentDepth--; + maxDepth = Math.max(maxDepth, currentDepth); + } + return maxDepth; + } + + private detectRecursion(body: string): number { + return 0; + } + + private determineRiskLevel(complexity: number): FunctionComplexity['riskLevel'] { + if (complexity > this.config.thresholds.high) return 'critical'; + if (complexity > this.config.thresholds.medium) return 'high'; + if (complexity > this.config.thresholds.low) return 'medium'; + return 'low'; + } + + private getRecommendation(name: string, metrics: ComplexityMetrics, riskLevel: string): string { + if (riskLevel === 'critical') { + return `Function "${name}" has critical complexity (${metrics.cyclomaticComplexity}). Consider splitting into smaller helper functions.`; + } + if (riskLevel === 'high') { + return `Function "${name}" has high complexity (${metrics.cyclomaticComplexity}). Review for potential simplification.`; + } + if (metrics.nestingDepth > 4) { + return `Function "${name}" has deep nesting (depth ${metrics.nestingDepth}). Consider early returns or guard clauses.`; + } + return 'Complexity is within acceptable range.'; + } + + private getLineNumber(offset: number): number { + return (this.source.substring(0, offset).match(/\n/g) || []).length + 1; + } + + private generateSummary( + name: string, + fnCount: number, + totalComplexity: number, + avgComplexity: number, + riskBreakdown: { low: number; medium: number; high: number; critical: number }, + ): string { + const critical = riskBreakdown.critical; + const high = riskBreakdown.high; + return `Contract "${name}": ${fnCount} function(s), total complexity ${totalComplexity} (avg ${avgComplexity}). ` + + `${riskBreakdown.low} low, ${riskBreakdown.medium} medium, ${high} high, ${critical} critical.`; + } +} diff --git a/src/analysis/stellar/complexity/index.ts b/src/analysis/stellar/complexity/index.ts new file mode 100644 index 0000000..d90ac1c --- /dev/null +++ b/src/analysis/stellar/complexity/index.ts @@ -0,0 +1,2 @@ +export { StellarComplexityAnalyzer } from './complexity-analyzer'; +export type { ComplexityMetrics, FunctionComplexity, ComplexityReport, ComplexityConfig } from './types'; diff --git a/src/analysis/stellar/complexity/types.ts b/src/analysis/stellar/complexity/types.ts new file mode 100644 index 0000000..be2ead9 --- /dev/null +++ b/src/analysis/stellar/complexity/types.ts @@ -0,0 +1,32 @@ +export interface ComplexityMetrics { + cyclomaticComplexity: number; + cognitiveComplexity: number; + functionCount: number; + branchCount: number; + loopCount: number; + recursionDepth: number; + nestingDepth: number; +} + +export interface FunctionComplexity { + name: string; + line: number; + metrics: ComplexityMetrics; + riskLevel: 'low' | 'medium' | 'high' | 'critical'; + recommendation: string; +} + +export interface ComplexityReport { + contractName: string; + functions: FunctionComplexity[]; + totalComplexity: number; + averageComplexity: number; + highestComplexity: FunctionComplexity | null; + riskBreakdown: { low: number; medium: number; high: number; critical: number }; + summary: string; +} + +export interface ComplexityConfig { + thresholds: { low: number; medium: number; high: number }; + includePrivate: boolean; +} diff --git a/src/analysis/stellar/dependencies/dependency-scanner.spec.ts b/src/analysis/stellar/dependencies/dependency-scanner.spec.ts new file mode 100644 index 0000000..59797d2 --- /dev/null +++ b/src/analysis/stellar/dependencies/dependency-scanner.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from '@jest/globals'; +import { StellarDependencyScanner } from './dependency-scanner'; + +describe('StellarDependencyScanner', () => { + it('extracts dependencies from use statements', () => { + const source = `pub struct Token; +use soroban_sdk::Env;`; + + const scanner = new StellarDependencyScanner(source, 'token.rs'); + const result = scanner.scan(); + + expect(result.contractName).toBe('Token'); + expect(result.nodes.length).toBe(0); + expect(result.directCount).toBe(0); + }); + + it('reports unused dependencies', () => { + const source = `pub struct Test; +use some_unused_crate::Something; +use soroban_sdk::Env; +pub fn hello(env: Env) {}`; + + const scanner = new StellarDependencyScanner(source, 'test.rs'); + const result = scanner.scan(); + + expect(result.nodes.some(n => n.name === 'some_unused_crate')).toBeTruthy(); + expect(result.unusedDependencies).toContain('some_unused_crate'); + }); + + it('detects no circular dependencies in simple chain', () => { + const source = `pub struct Linear; +use crate::a::A; +use soroban_sdk::Env;`; + + const scanner = new StellarDependencyScanner(source, 'linear.rs'); + const result = scanner.scan(); + + expect(result.circularDependencies.length).toBe(0); + }); + + it('summarises scan results', () => { + const source = `pub struct Summary; +use soroban_sdk::Env; +pub fn run(env: Env) {}`; + + const scanner = new StellarDependencyScanner(source, 'summary.rs'); + const result = scanner.scan(); + + expect(result.summary).toContain('Summary'); + expect(result.directCount).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/src/analysis/stellar/dependencies/dependency-scanner.ts b/src/analysis/stellar/dependencies/dependency-scanner.ts new file mode 100644 index 0000000..0b2d594 --- /dev/null +++ b/src/analysis/stellar/dependencies/dependency-scanner.ts @@ -0,0 +1,227 @@ +import { DependencyEdge, DependencyNode, DependencyScanResult, ScanConfig } from './types'; + +export class StellarDependencyScanner { + private source: string; + private filePath: string; + private config: ScanConfig; + + private stdLibSymbols = new Set([ + 'Env', 'Address', 'Bytes', 'BytesN', 'String', 'Symbol', 'Vec', 'Map', + 'I128', 'U128', 'I256', 'U256', 'i128', 'u128', 'i256', 'u256', + 'IntoVal', 'FromVal', 'TryFromVal', 'TryIntoVal', + 'require_auth', 'authorized_invocation', + ]); + + constructor( + source: string, + filePath: string, + config: ScanConfig = { maxDepth: 3, includeTransitive: true, detectCircular: true }, + ) { + this.source = source; + this.filePath = filePath; + this.config = config; + } + + scan(): DependencyScanResult { + const contractName = this.extractContractName(); + const nodes = this.extractDependencies(); + const edges = this.extractEdges(nodes); + + const directCount = nodes.filter(n => n.isDirect).length; + const transitiveCount = nodes.filter(n => !n.isDirect).length; + + const unusedDependencies = nodes + .filter(n => n.isDirect && n.usedSymbols.length === 0) + .map(n => n.name); + + const circularDependencies = this.config.detectCircular + ? this.detectCircular(nodes, edges) + : []; + + return { + contractName, + nodes, + edges, + directCount, + transitiveCount, + unusedDependencies, + circularDependencies, + summary: this.generateSummary(contractName, nodes.length, directCount, transitiveCount, unusedDependencies.length, circularDependencies.length), + }; + } + + private extractContractName(): string { + const match = this.source.match(/pub struct (\w+)/) + || this.source.match(/#\[contract\]\s*\n.*?pub\s+(?:struct|fn)\s+(\w+)/); + return match ? match[1] : 'UnknownContract'; + } + + private extractDependencies(): DependencyNode[] { + const nodes: DependencyNode[] = []; + const importSet = new Set(); + + const useRegex = /use\s+([^;]+);/g; + let match; + while ((match = useRegex.exec(this.source)) !== null) { + const importPath = match[1].trim(); + const parts = importPath.split('::'); + const crateName = parts[0]; + + if (this.isSdkImport(crateName) || importSet.has(crateName)) continue; + importSet.add(crateName); + + const usedSymbols = this.findUsedSymbols(crateName, importPath, parts[parts.length - 1]); + + if (this.isStdCrate(crateName)) { + nodes.push({ + name: crateName, + version: '*', + source: 'stellar-sdk', + isDirect: false, + dependencies: [], + usedSymbols, + }); + } else { + nodes.push({ + name: crateName, + version: '*', + source: 'external', + isDirect: true, + dependencies: [], + usedSymbols, + }); + } + } + + const modDeclRegex = /mod\s+(\w+);|mod\s+(\w+)\s*\{/g; + while ((match = modDeclRegex.exec(this.source)) !== null) { + const modName = match[1] || match[2]; + if (!importSet.has(modName)) { + importSet.add(modName); + nodes.push({ + name: modName, + version: 'internal', + source: 'local', + isDirect: true, + dependencies: [], + usedSymbols: [], + }); + } + } + + return nodes; + } + + private isSdkImport(crateName: string): boolean { + const sdkCrates = ['soroban_sdk', 'stellar_sdk', 'stella']; + return sdkCrates.includes(crateName); + } + + private isStdCrate(crateName: string): boolean { + const stdCrates = ['soroban_sdk', 'core', 'alloc', 'std', 'stellar_sdk']; + return stdCrates.includes(crateName); + } + + private findUsedSymbols(crateName: string, importPath: string, symbol: string): string[] { + const symbols: string[] = []; + + const sourceWithoutUse = this.source.replace(/use\s+[^;]+;/g, ''); + + const symbolRegex = new RegExp(`\\b${symbol}\\b`, 'g'); + if (symbolRegex.test(sourceWithoutUse)) { + symbols.push(symbol); + } + + const fnCallRegex = new RegExp(`\\b${crateName}::\\w+`, 'g'); + if (fnCallRegex.test(sourceWithoutUse)) { + const calls = this.source.match(fnCallRegex) || []; + symbols.push(...calls.map(c => c.trim())); + } + + return symbols; + } + + private extractEdges(nodes: DependencyNode[]): DependencyEdge[] { + const edges: DependencyEdge[] = []; + + for (const node of nodes) { + edges.push({ + from: 'contract', + to: node.name, + kind: 'import', + }); + } + + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + if (nodes[i].usedSymbols.some(s => nodes[j].usedSymbols.includes(s))) { + edges.push({ + from: nodes[i].name, + to: nodes[j].name, + kind: 'type_ref', + }); + } + } + } + + return edges; + } + + private detectCircular(nodes: DependencyNode[], edges: DependencyEdge[]): string[][] { + const adjList = new Map(); + for (const node of nodes) { + adjList.set(node.name, []); + } + for (const edge of edges) { + const neighbors = adjList.get(edge.from) || []; + neighbors.push(edge.to); + adjList.set(edge.from, neighbors); + } + + const cycles: string[][] = []; + const visited = new Set(); + const recStack = new Set(); + const path: string[] = []; + + const dfs = (node: string) => { + visited.add(node); + recStack.add(node); + path.push(node); + + const neighbors = adjList.get(node) || []; + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + dfs(neighbor); + } else if (recStack.has(neighbor)) { + const cycle = path.slice(path.indexOf(neighbor)); + cycles.push(cycle); + } + } + + path.pop(); + recStack.delete(node); + }; + + for (const node of nodes) { + if (!visited.has(node.name)) { + dfs(node.name); + } + } + + return cycles; + } + + private generateSummary( + name: string, + totalDeps: number, + direct: number, + transitive: number, + unused: number, + cycles: number, + ): string { + let summary = `Contract "${name}": ${totalDeps} dependencies (${direct} direct, ${transitive} transitive).`; + if (unused > 0) summary += ` ${unused} unused import(s) detected.`; + if (cycles > 0) summary += ` ${cycles} circular dependenc(ies) found.`; + return summary; + } +} diff --git a/src/analysis/stellar/dependencies/index.ts b/src/analysis/stellar/dependencies/index.ts new file mode 100644 index 0000000..881526a --- /dev/null +++ b/src/analysis/stellar/dependencies/index.ts @@ -0,0 +1,2 @@ +export { StellarDependencyScanner } from './dependency-scanner'; +export type { DependencyNode, DependencyEdge, DependencyScanResult, ScanConfig } from './types'; diff --git a/src/analysis/stellar/dependencies/types.ts b/src/analysis/stellar/dependencies/types.ts new file mode 100644 index 0000000..fc3a3d8 --- /dev/null +++ b/src/analysis/stellar/dependencies/types.ts @@ -0,0 +1,31 @@ +export interface DependencyNode { + name: string; + version: string; + source: string; + isDirect: boolean; + dependencies: string[]; + usedSymbols: string[]; +} + +export interface DependencyEdge { + from: string; + to: string; + kind: 'import' | 'use' | 'fn_call' | 'type_ref'; +} + +export interface DependencyScanResult { + contractName: string; + nodes: DependencyNode[]; + edges: DependencyEdge[]; + directCount: number; + transitiveCount: number; + unusedDependencies: string[]; + circularDependencies: string[][]; + summary: string; +} + +export interface ScanConfig { + maxDepth: number; + includeTransitive: boolean; + detectCircular: boolean; +} diff --git a/src/profiles/security/stellar/index.ts b/src/profiles/security/stellar/index.ts new file mode 100644 index 0000000..5f6c885 --- /dev/null +++ b/src/profiles/security/stellar/index.ts @@ -0,0 +1,2 @@ +export { StellarSecurityProfile } from './security-profile'; +export type { SecurityCheck, SecurityProfileResult, SecurityProfileConfig } from './types'; diff --git a/src/profiles/security/stellar/security-profile.spec.ts b/src/profiles/security/stellar/security-profile.spec.ts new file mode 100644 index 0000000..9461268 --- /dev/null +++ b/src/profiles/security/stellar/security-profile.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from '@jest/globals'; +import { StellarSecurityProfile } from './security-profile'; + +describe('StellarSecurityProfile', () => { + it('flags missing authorization as critical', () => { + const source = `pub struct NoAuth; +pub fn transfer(env: Env, to: Address, amount: u64) { + env.storage().set(to, amount); +}`; + + const profile = new StellarSecurityProfile(source, 'noauth.rs'); + const result = profile.run(); + + const authCheck = result.checks.find(c => c.id === 'SEC-001'); + expect(authCheck).toBeDefined(); + expect(authCheck!.passed).toBe(false); + expect(authCheck!.severity).toBe('critical'); + expect(result.criticalIssues.length).toBeGreaterThanOrEqual(1); + }); + + it('passes when authorization is present', () => { + const source = `pub struct Secure; +pub fn transfer(env: Env, to: Address, amount: u64) { + env.require_auth(); + env.storage().set(to, amount); +}`; + + const profile = new StellarSecurityProfile(source, 'secure.rs'); + const result = profile.run(); + + const authCheck = result.checks.find(c => c.id === 'SEC-001'); + expect(authCheck!.passed).toBe(true); + }); + + it('detects excessive unwrap usage', () => { + const source = `pub struct Risky; +pub fn process(env: Env) -> u64 { + let a = env.storage().get(&"a".into()).unwrap(); + let b = env.storage().get(&"b".into()).unwrap(); + let c = env.storage().get(&"c".into()).unwrap(); + let d = env.storage().get(&"d".into()).unwrap(); + a + b + c + d +}`; + + const profile = new StellarSecurityProfile(source, 'risky.rs'); + const result = profile.run(); + + const unwrapCheck = result.checks.find(c => c.id === 'SEC-005'); + expect(unwrapCheck!.passed).toBe(false); + }); + + it('calculates overall score', () => { + const source = `pub struct Test; +pub fn safe_fn(env: Env) { + env.require_auth(); +}`; + + const profile = new StellarSecurityProfile(source, 'test.rs', { strictMode: false, failOnCritical: true, customChecks: [] }); + const result = profile.run(); + + expect(result.passedCount).toBeGreaterThan(0); + expect(result.overallScore).toBeGreaterThan(50); + }); +}); diff --git a/src/profiles/security/stellar/security-profile.ts b/src/profiles/security/stellar/security-profile.ts new file mode 100644 index 0000000..36e1a60 --- /dev/null +++ b/src/profiles/security/stellar/security-profile.ts @@ -0,0 +1,188 @@ +import { SecurityCheck, SecurityProfileConfig, SecurityProfileResult } from './types'; + +export class StellarSecurityProfile { + private source: string; + private filePath: string; + private config: SecurityProfileConfig; + + constructor( + source: string, + filePath: string, + config: SecurityProfileConfig = { strictMode: false, failOnCritical: true, customChecks: [] }, + ) { + this.source = source; + this.filePath = filePath; + this.config = config; + } + + run(): SecurityProfileResult { + const contractName = this.extractContractName(); + const builtinChecks = this.runBuiltinChecks(); + const checks = [...builtinChecks, ...this.config.customChecks]; + + const passedCount = checks.filter(c => c.passed).length; + const failedCount = checks.filter(c => !c.passed).length; + const criticalIssues = checks.filter(c => !c.passed && c.severity === 'critical'); + const overallScore = checks.length > 0 ? Math.round((passedCount / checks.length) * 100) : 0; + + return { + profileName: 'Soroban Security Baseline', + contractName, + checks, + passedCount, + failedCount, + overallScore, + criticalIssues, + summary: this.generateSummary(contractName, passedCount, failedCount, overallScore, criticalIssues.length), + }; + } + + private extractContractName(): string { + const match = this.source.match(/pub struct (\w+)/) + || this.source.match(/#\[contract\]\s*\n.*?pub\s+(?:struct|fn)\s+(\w+)/); + return match ? match[1] : 'UnknownContract'; + } + + private runBuiltinChecks(): SecurityCheck[] { + return [ + this.checkAuthorization(), + this.checkInputValidation(), + this.checkStorageSafety(), + this.checkPanicUsage(), + this.checkUnwrapUsage(), + this.checkIntegerSafety(), + ].filter(Boolean) as SecurityCheck[]; + } + + private checkAuthorization(): SecurityCheck { + const hasAuth = this.source.includes('require_auth') || this.source.includes('.require_auth()'); + const hasAdminFn = this.source.match(/fn\s+(set_owner|change_admin|add_admin|remove_admin)/); + + const passed = hasAuth; + return { + id: 'SEC-001', + name: 'Authorization Checks', + description: 'Contract should use require_auth for privileged operations', + severity: 'critical', + category: 'access-control', + passed, + details: passed + ? 'Authorization checks detected in contract' + : 'No require_auth calls found. Privileged operations may be unprotected.', + recommendation: passed + ? 'Authorization is properly enforced' + : 'Add require_auth() calls to all functions that modify sensitive state. Consider using a dedicated admin pattern.', + }; + } + + private checkInputValidation(): SecurityCheck { + const hasInputValidation = this.source.match(/if\s+\w+\s*(==|!=|<|>)\s*\w+/); + const passed = hasInputValidation !== null; + return { + id: 'SEC-002', + name: 'Input Validation', + description: 'Functions should validate input parameters before processing', + severity: 'high', + category: 'input-validation', + passed, + details: passed + ? 'Input validation patterns detected' + : 'No explicit input validation found. Missing validation may lead to unexpected behavior.', + recommendation: passed + ? 'Input validation is present' + : 'Add input validation at function entry points. Check bounds, ranges, and allowed values.', + }; + } + + private checkStorageSafety(): SecurityCheck { + const hasUnboundedStorage = this.source.match(/Vec|Map/) + && !this.source.includes('limit') && !this.source.includes('max'); + const passed = !hasUnboundedStorage; + return { + id: 'SEC-003', + name: 'Storage Safety', + description: 'Storage should have bounds on collection sizes to prevent bloat', + severity: 'medium', + category: 'storage', + passed, + details: passed + ? 'No unbounded storage patterns detected' + : 'Contract uses Vec or Map without size limits. This could lead to storage bloat.', + recommendation: passed + ? 'Storage patterns appear safe' + : 'Add maximum size limits to collections. Implement pagination for large data sets.', + }; + } + + private checkPanicUsage(): SecurityCheck { + const panicCount = (this.source.match(/\bpanic!\b/g) || []).length; + const passed = panicCount === 0; + return { + id: 'SEC-004', + name: 'Panic Usage', + description: 'Use Result types instead of panic for error handling', + severity: 'medium', + category: 'logic', + passed, + details: passed + ? 'No panic! calls detected' + : `Found ${panicCount} panic! call(s). These will abort execution unconditionally.`, + recommendation: passed + ? 'Error handling uses Result types' + : 'Replace panic! with Result<_, Error> for graceful error handling.', + }; + } + + private checkUnwrapUsage(): SecurityCheck { + const unwrapCount = (this.source.match(/\.unwrap\(\)/g) || []).length; + const passed = unwrapCount <= 2; + return { + id: 'SEC-005', + name: 'Safe Unwrapping', + description: 'Excessive .unwrap() can cause unexpected panics', + severity: 'high', + category: 'logic', + passed, + details: passed + ? `Found ${unwrapCount} .unwrap() call(s), within acceptable range` + : `Found ${unwrapCount} .unwrap() call(s). Each can panic if the value is None or Err.`, + recommendation: passed + ? 'Unwrap usage is acceptable' + : 'Replace .unwrap() with match or if-let patterns. Consider .unwrap_or() for defaults.', + }; + } + + private checkIntegerSafety(): SecurityCheck { + const hasArithmetic = this.source.match(/\w+\s*[+\-*/]\s*\w+/) !== null; + const hasCheckedMath = this.source.includes('checked_') || this.source.includes('overflow'); + const passed = !hasArithmetic || hasCheckedMath; + return { + id: 'SEC-006', + name: 'Integer Safety', + description: 'Arithmetic operations should use checked math to prevent overflow', + severity: 'high', + category: 'crypto', + passed, + details: passed + ? hasCheckedMath + ? 'Checked arithmetic detected' + : 'No arithmetic operations found' + : 'Arithmetic operations found without overflow protection', + recommendation: passed + ? 'Integer safety is adequate' + : 'Use checked_add, checked_mul, or similar safe arithmetic methods.', + }; + } + + private generateSummary( + name: string, + passed: number, + failed: number, + score: number, + criticalCount: number, + ): string { + const failedStr = failed > 0 ? ` ${failed} check(s) failed.` : ' All checks passed.'; + const criticalStr = criticalCount > 0 ? ` ${criticalCount} critical issue(s) found.` : ''; + return `Security baseline for "${name}": Score ${score}% (${passed} passed, ${failed} failed).${criticalStr}`; + } +} diff --git a/src/profiles/security/stellar/types.ts b/src/profiles/security/stellar/types.ts new file mode 100644 index 0000000..32141e6 --- /dev/null +++ b/src/profiles/security/stellar/types.ts @@ -0,0 +1,27 @@ +export interface SecurityCheck { + id: string; + name: string; + description: string; + severity: 'critical' | 'high' | 'medium' | 'low'; + category: 'access-control' | 'input-validation' | 'crypto' | 'storage' | 'logic' | 'dependency'; + passed: boolean; + details: string; + recommendation: string; +} + +export interface SecurityProfileResult { + profileName: string; + contractName: string; + checks: SecurityCheck[]; + passedCount: number; + failedCount: number; + overallScore: number; + criticalIssues: SecurityCheck[]; + summary: string; +} + +export interface SecurityProfileConfig { + strictMode: boolean; + failOnCritical: boolean; + customChecks: SecurityCheck[]; +} diff --git a/src/reporting/stellar/coverage/coverage-reporter.spec.ts b/src/reporting/stellar/coverage/coverage-reporter.spec.ts new file mode 100644 index 0000000..69c6f34 --- /dev/null +++ b/src/reporting/stellar/coverage/coverage-reporter.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from '@jest/globals'; +import { SorobanCoverageReporter } from './coverage-reporter'; +import { RuleCoverageEntry } from './types'; + +describe('SorobanCoverageReporter', () => { + const sampleRules: RuleCoverageEntry[] = [ + { ruleId: 'GG001', ruleName: 'Reentrancy Guard', category: 'security', executed: true, findingsCount: 2, executionTime: 45, suppressedCount: 0 }, + { ruleId: 'GG002', ruleName: 'Unchecked Transfer', category: 'security', executed: true, findingsCount: 1, executionTime: 30, suppressedCount: 1 }, + { ruleId: 'GG003', ruleName: 'Gas Loop Limit', category: 'gas', executed: false, findingsCount: 0, executionTime: 0, suppressedCount: 0 }, + { ruleId: 'GG004', ruleName: 'Storage Bloat', category: 'gas', executed: true, findingsCount: 3, executionTime: 55, suppressedCount: 0 }, + { ruleId: 'GG005', ruleName: 'Error Handling', category: 'quality', executed: true, findingsCount: 0, executionTime: 20, suppressedCount: 0 }, + ]; + + it('calculates overall coverage percentage', () => { + const reporter = new SorobanCoverageReporter(); + const report = reporter.generateReport(sampleRules); + + expect(report.totalRules).toBe(5); + expect(report.executedRules).toBe(4); + expect(report.coveragePercentage).toBe(80); + }); + + it('groups coverage by category', () => { + const reporter = new SorobanCoverageReporter(); + const report = reporter.generateReport(sampleRules); + + expect(report.byCategory.security.total).toBe(2); + expect(report.byCategory.security.executed).toBe(2); + expect(report.byCategory.gas.total).toBe(2); + expect(report.byCategory.gas.executed).toBe(1); + }); + + it('generates markdown output', () => { + const reporter = new SorobanCoverageReporter(); + const report = reporter.generateReport(sampleRules); + const md = reporter.formatMarkdown(report); + + expect(md).toContain('Soroban Rule Coverage Report'); + expect(md).toContain('80%'); + expect(md).toContain('GG001'); + }); + + it('handles empty rule set', () => { + const reporter = new SorobanCoverageReporter(); + const report = reporter.generateReport([]); + + expect(report.totalRules).toBe(0); + expect(report.coveragePercentage).toBe(0); + }); +}); diff --git a/src/reporting/stellar/coverage/coverage-reporter.ts b/src/reporting/stellar/coverage/coverage-reporter.ts new file mode 100644 index 0000000..0f9edaf --- /dev/null +++ b/src/reporting/stellar/coverage/coverage-reporter.ts @@ -0,0 +1,82 @@ +import { CoverageConfig, CoverageReport, RuleCoverageEntry } from './types'; + +export class SorobanCoverageReporter { + private config: CoverageConfig; + + constructor( + config: CoverageConfig = { minCoverageThreshold: 80, failOnLowCoverage: false, categories: ['security', 'gas', 'quality', 'best-practices'] }, + ) { + this.config = config; + } + + generateReport(rules: RuleCoverageEntry[]): CoverageReport { + const totalRules = rules.length; + const executedRules = rules.filter(r => r.executed).length; + const coveragePercentage = totalRules > 0 ? Math.round((executedRules / totalRules) * 100) : 0; + + const byCategory: Record = {}; + for (const entry of rules) { + if (!byCategory[entry.category]) { + byCategory[entry.category] = { total: 0, executed: 0, percentage: 0 }; + } + byCategory[entry.category].total++; + if (entry.executed) byCategory[entry.category].executed++; + } + for (const cat of Object.keys(byCategory)) { + byCategory[cat].percentage = Math.round((byCategory[cat].executed / byCategory[cat].total) * 100); + } + + return { + totalRules, + executedRules, + coveragePercentage, + entries: rules, + byCategory, + summary: this.generateSummary(totalRules, executedRules, coveragePercentage, byCategory), + }; + } + + private generateSummary( + total: number, + executed: number, + percentage: number, + byCategory: Record, + ): string { + const categories = Object.entries(byCategory) + .map(([cat, data]) => `${cat}: ${data.percentage}% (${data.executed}/${data.total})`) + .join(', '); + + const threshold = this.config.minCoverageThreshold; + const status = percentage >= threshold ? 'PASS' : 'FAIL'; + + return `Rule coverage: ${percentage}% (${executed}/${total}) — ${status}. Categories: ${categories}.`; + } + + formatMarkdown(report: CoverageReport): string { + const lines: string[] = []; + lines.push('# Soroban Rule Coverage Report'); + lines.push(''); + lines.push(`**Overall Coverage:** ${report.coveragePercentage}% (${report.executedRules}/${report.totalRules} rules)`); + lines.push(''); + + lines.push('## By Category'); + lines.push('| Category | Coverage | Executed/Total |'); + lines.push('|----------|----------|----------------|'); + for (const [cat, data] of Object.entries(report.byCategory)) { + lines.push(`| ${cat} | ${data.percentage}% | ${data.executed}/${data.total} |`); + } + lines.push(''); + + lines.push('## Rule Details'); + lines.push('| Rule ID | Name | Category | Executed | Findings |'); + lines.push('|---------|------|----------|----------|----------|'); + for (const entry of report.entries) { + lines.push(`| ${entry.ruleId} | ${entry.ruleName} | ${entry.category} | ${entry.executed ? 'Yes' : 'No'} | ${entry.findingsCount} |`); + } + lines.push(''); + lines.push('---'); + lines.push(report.summary); + + return lines.join('\n'); + } +} diff --git a/src/reporting/stellar/coverage/index.ts b/src/reporting/stellar/coverage/index.ts new file mode 100644 index 0000000..23f3d83 --- /dev/null +++ b/src/reporting/stellar/coverage/index.ts @@ -0,0 +1,2 @@ +export { SorobanCoverageReporter } from './coverage-reporter'; +export type { RuleCoverageEntry, CoverageReport, CoverageConfig } from './types'; diff --git a/src/reporting/stellar/coverage/types.ts b/src/reporting/stellar/coverage/types.ts new file mode 100644 index 0000000..1879ceb --- /dev/null +++ b/src/reporting/stellar/coverage/types.ts @@ -0,0 +1,24 @@ +export interface RuleCoverageEntry { + ruleId: string; + ruleName: string; + category: string; + executed: boolean; + findingsCount: number; + executionTime: number; + suppressedCount: number; +} + +export interface CoverageReport { + totalRules: number; + executedRules: number; + coveragePercentage: number; + entries: RuleCoverageEntry[]; + byCategory: Record; + summary: string; +} + +export interface CoverageConfig { + minCoverageThreshold: number; + failOnLowCoverage: boolean; + categories: string[]; +}