From a95dbc41122cfc3fb561e36a15863e192c2a83c5 Mon Sep 17 00:00:00 2001 From: Assad Isah Date: Sat, 27 Jun 2026 07:31:24 +0000 Subject: [PATCH] feat: implement Soroban Contract Lifecycle Analyzer (#498) - Detect initialization flows with re-initialization guard checks - Detect upgrade flows with access control validation - Generate lifecycle reports with severity-ranked issues - 6 tests covering init flows, upgrade flows, and report generation --- src/analysis/stellar/lifecycle/index.ts | 6 + .../lifecycle/lifecycle-analyzer.spec.ts | 114 ++++++++++++++ .../stellar/lifecycle/lifecycle-analyzer.ts | 149 ++++++++++++++++++ src/analysis/stellar/lifecycle/types.ts | 27 ++++ 4 files changed, 296 insertions(+) create mode 100644 src/analysis/stellar/lifecycle/index.ts create mode 100644 src/analysis/stellar/lifecycle/lifecycle-analyzer.spec.ts create mode 100644 src/analysis/stellar/lifecycle/lifecycle-analyzer.ts create mode 100644 src/analysis/stellar/lifecycle/types.ts diff --git a/src/analysis/stellar/lifecycle/index.ts b/src/analysis/stellar/lifecycle/index.ts new file mode 100644 index 0000000..2dad144 --- /dev/null +++ b/src/analysis/stellar/lifecycle/index.ts @@ -0,0 +1,6 @@ +/** + * Soroban Contract Lifecycle Analysis Module + */ + +export { StellarLifecycleAnalyzer } from './lifecycle-analyzer'; +export type { InitFlow, UpgradeFlow, LifecycleIssue, LifecycleReport } from './types'; diff --git a/src/analysis/stellar/lifecycle/lifecycle-analyzer.spec.ts b/src/analysis/stellar/lifecycle/lifecycle-analyzer.spec.ts new file mode 100644 index 0000000..f4991eb --- /dev/null +++ b/src/analysis/stellar/lifecycle/lifecycle-analyzer.spec.ts @@ -0,0 +1,114 @@ +/** + * Tests for Soroban Contract Lifecycle Analyzer + */ + +import { describe, it, expect } from '@jest/globals'; +import { StellarLifecycleAnalyzer } from './lifecycle-analyzer'; + +const guardedInitContract = ` +use soroban_sdk::{contract, contractimpl, Address, Env}; + +pub struct TokenContract { + pub owner: Address, +} + +impl TokenContract { + pub fn initialize(env: Env, owner: Address) { + if env.storage().has(&"initialized") { + panic!("already initialized"); + } + env.storage().set(&"initialized", &true); + env.storage().set(&"owner", &owner); + } +} +`; + +const ungardedInitContract = ` +pub struct SimpleContract; + +impl SimpleContract { + pub fn init(owner: Address) { + storage().set("owner", owner); + } +} +`; + +const upgradeContract = ` +pub struct UpgradeableContract { + pub admin: Address, +} + +impl UpgradeableContract { + pub fn upgrade(env: Env, wasm_hash: BytesN<32>) { + self.admin.require_auth(); + env.deployer().update_current_contract_wasm(wasm_hash); + } +} +`; + +const unsafeUpgradeContract = ` +pub struct UnsafeContract; + +impl UnsafeContract { + pub fn upgrade(env: Env, wasm_hash: BytesN<32>) { + env.deployer().update_current_contract_wasm(wasm_hash); + } +} +`; + +describe('StellarLifecycleAnalyzer', () => { + describe('initialization flows', () => { + it('detects guarded init flow', () => { + const a = new StellarLifecycleAnalyzer(guardedInitContract, 'test.rs'); + const r = a.analyze(); + expect(r.initFlows).toHaveLength(1); + expect(r.initFlows[0].functionName).toBe('initialize'); + expect(r.initFlows[0].hasGuard).toBe(true); + }); + + it('detects unguarded init and raises high-severity issue', () => { + const a = new StellarLifecycleAnalyzer(ungardedInitContract, 'test.rs'); + const r = a.analyze(); + expect(r.initFlows[0].hasGuard).toBe(false); + const high = r.issues.filter(i => i.severity === 'high' && i.message.includes('init')); + expect(high.length).toBeGreaterThan(0); + }); + }); + + describe('upgrade flows', () => { + it('detects access-controlled upgrade', () => { + const a = new StellarLifecycleAnalyzer(upgradeContract, 'test.rs'); + const r = a.analyze(); + expect(r.upgradeFlows).toHaveLength(1); + expect(r.upgradeFlows[0].hasAccessControl).toBe(true); + expect(r.upgradeFlows[0].isWasm).toBe(true); + }); + + it('flags upgrade without access control', () => { + const a = new StellarLifecycleAnalyzer(unsafeUpgradeContract, 'test.rs'); + const r = a.analyze(); + expect(r.upgradeFlows[0].hasAccessControl).toBe(false); + const issue = r.issues.find(i => i.message.includes('upgrade')); + expect(issue).toBeDefined(); + expect(issue!.severity).toBe('high'); + }); + }); + + describe('report generation', () => { + it('generates a lifecycle report with summary', () => { + const a = new StellarLifecycleAnalyzer(guardedInitContract, 'test.rs'); + const r = a.analyze(); + expect(r.contractName).toBeTruthy(); + expect(r.summary).toContain(r.contractName); + expect(r.summary).toContain('init flow'); + }); + + it('raises medium issue when no init flow present', () => { + const noInit = `pub struct Empty; impl Empty {}`; + const a = new StellarLifecycleAnalyzer(noInit, 'test.rs'); + const r = a.analyze(); + const medium = r.issues.find(i => i.severity === 'medium'); + expect(medium).toBeDefined(); + }); + }); +}); diff --git a/src/analysis/stellar/lifecycle/lifecycle-analyzer.ts b/src/analysis/stellar/lifecycle/lifecycle-analyzer.ts new file mode 100644 index 0000000..26b128c --- /dev/null +++ b/src/analysis/stellar/lifecycle/lifecycle-analyzer.ts @@ -0,0 +1,149 @@ +/** + * Soroban Contract Lifecycle Analyzer + * + * Detects initialization and upgrade flows in Soroban contracts, + * flags missing guards, and generates lifecycle reports. + */ + +import { InitFlow, LifecycleIssue, LifecycleReport, UpgradeFlow } from './types'; + +export class StellarLifecycleAnalyzer { + private source: string; + private filePath: string; + + constructor(source: string, filePath: string) { + this.source = source; + this.filePath = filePath; + } + + analyze(): LifecycleReport { + const contractName = this.extractContractName(); + const initFlows = this.extractInitFlows(); + const upgradeFlows = this.extractUpgradeFlows(); + const issues = this.detectIssues(initFlows, upgradeFlows); + + return { + contractName, + initFlows, + upgradeFlows, + issues, + summary: this.buildSummary(contractName, initFlows, upgradeFlows, issues), + }; + } + + private extractContractName(): string { + const match = this.source.match(/pub struct (\w+)/) || this.source.match(/impl\s+(\w+)/); + return match ? match[1] : 'UnknownContract'; + } + + private extractInitFlows(): InitFlow[] { + const flows: InitFlow[] = []; + const initRegex = /fn\s+(init(?:ialize)?|setup|constructor)\s*\([^)]*\)/g; + let match; + + while ((match = initRegex.exec(this.source)) !== null) { + const fnName = match[1]; + const line = this.lineOf(match.index); + const bodyStart = this.source.indexOf('{', match.index); + const body = bodyStart !== -1 ? this.extractBlock(bodyStart + 1) : ''; + + const hasGuard = + body.includes('require_auth') || + body.includes('panic!') || + body.includes('storage().has') || + /if\s+.*already/.test(body); + + const storageKeys = [...body.matchAll(/storage\(\)\.\w+\s*\(\s*["']?(\w+)["']?/g)].map(m => m[1]); + + flows.push({ functionName: fnName, hasGuard, storageKeys, line }); + } + return flows; + } + + private extractUpgradeFlows(): UpgradeFlow[] { + const flows: UpgradeFlow[] = []; + const upgradeRegex = /fn\s+(upgrade|update(?:_wasm)?|migrate)\s*\([^)]*\)/g; + let match; + + while ((match = upgradeRegex.exec(this.source)) !== null) { + const fnName = match[1]; + const line = this.lineOf(match.index); + const bodyStart = this.source.indexOf('{', match.index); + const body = bodyStart !== -1 ? this.extractBlock(bodyStart + 1) : ''; + + const hasAccessControl = + body.includes('require_auth') || + body.includes('assert_admin') || + /if\s+.*!=\s*admin/.test(body); + + const isWasm = + body.includes('update_current_contract_wasm') || + body.includes('wasm') || + fnName.includes('wasm'); + + flows.push({ functionName: fnName, hasAccessControl, isWasm, line }); + } + return flows; + } + + private detectIssues(initFlows: InitFlow[], upgradeFlows: UpgradeFlow[]): LifecycleIssue[] { + const issues: LifecycleIssue[] = []; + + for (const flow of initFlows) { + if (!flow.hasGuard) { + issues.push({ + severity: 'high', + message: `Initialization function '${flow.functionName}' lacks a re-initialization guard`, + line: flow.line, + }); + } + } + + for (const flow of upgradeFlows) { + if (!flow.hasAccessControl) { + issues.push({ + severity: 'high', + message: `Upgrade function '${flow.functionName}' missing access control`, + line: flow.line, + }); + } + } + + if (initFlows.length === 0) { + issues.push({ severity: 'medium', message: 'No initialization flow detected' }); + } + + return issues; + } + + private buildSummary( + contractName: string, + initFlows: InitFlow[], + upgradeFlows: UpgradeFlow[], + issues: LifecycleIssue[], + ): string { + const high = issues.filter(i => i.severity === 'high').length; + return ( + `Contract '${contractName}': ` + + `${initFlows.length} init flow(s), ` + + `${upgradeFlows.length} upgrade flow(s), ` + + `${high} high-severity issue(s).` + ); + } + + private extractBlock(start: number): string { + let depth = 1; + let result = ''; + for (let i = start; i < this.source.length && depth > 0; i++) { + const ch = this.source[i]; + if (ch === '{') depth++; + else if (ch === '}') { depth--; if (depth === 0) break; } + result += ch; + } + return result; + } + + private lineOf(offset: number): number { + return (this.source.slice(0, offset).match(/\n/g) ?? []).length + 1; + } +} diff --git a/src/analysis/stellar/lifecycle/types.ts b/src/analysis/stellar/lifecycle/types.ts new file mode 100644 index 0000000..f5674b0 --- /dev/null +++ b/src/analysis/stellar/lifecycle/types.ts @@ -0,0 +1,27 @@ +export interface InitFlow { + functionName: string; + hasGuard: boolean; + storageKeys: string[]; + line: number; +} + +export interface UpgradeFlow { + functionName: string; + hasAccessControl: boolean; + isWasm: boolean; + line: number; +} + +export interface LifecycleIssue { + severity: 'low' | 'medium' | 'high'; + message: string; + line?: number; +} + +export interface LifecycleReport { + contractName: string; + initFlows: InitFlow[]; + upgradeFlows: UpgradeFlow[]; + issues: LifecycleIssue[]; + summary: string; +}