diff --git a/packages/analyzers/soroban/auth/tree/__tests__/auth-depth-analyzer.spec.ts b/packages/analyzers/soroban/auth/tree/__tests__/auth-depth-analyzer.spec.ts new file mode 100644 index 00000000..9a361438 --- /dev/null +++ b/packages/analyzers/soroban/auth/tree/__tests__/auth-depth-analyzer.spec.ts @@ -0,0 +1,88 @@ +import { measureAuthDepth } from '../auth-depth-analyzer'; + +describe('Authorization Depth Analyzer (#918)', () => { + const SHALLOW_AUTH = ` + pub fn transfer(env: Env, from: Address, to: Address, amount: i128) { + from.require_auth(); + // transfer logic + } + `; + + const DEEP_AUTH = ` + pub fn complex_transfer(env: Env, master: Address, sub: Address, to: Address, amount: i128) { + master.require_auth(); + sub.require_auth(); + admin.authorize_as_parent(); + vault.check_auth(); + } + `; + + const NO_AUTH = ` + pub fn view_balance(env: Env, account: Address) -> i128 { + storage::get(&account) + } + `; + + const MIXED = ` + pub fn simple(env: Env, user: Address) { + user.require_auth(); + } + + pub fn complex(env: Env, a: Address, b: Address, c: Address) { + a.require_auth(); + b.authorize_as_parent(); + c.check_auth(); + } + `; + + test('measureAuthDepth returns zero depth for functions without auth', () => { + const report = measureAuthDepth(NO_AUTH); + expect(report.results.length).toBe(1); + expect(report.results[0].depth).toBe(0); + expect(report.maxDepth).toBe(0); + expect(report.violations).toHaveLength(0); + }); + + test('measureAuthDepth counts single auth call', () => { + const report = measureAuthDepth(SHALLOW_AUTH); + expect(report.results[0].depth).toBe(1); + expect(report.maxDepth).toBe(1); + expect(report.violations).toHaveLength(0); + }); + + test('measureAuthDepth detects deep auth nesting', () => { + const report = measureAuthDepth(DEEP_AUTH, 3); + expect(report.results[0].depth).toBe(4); + expect(report.maxDepth).toBe(4); + expect(report.violations.length).toBeGreaterThanOrEqual(1); + expect(report.violations[0].functionName).toBe('complex_transfer'); + }); + + test('measureAuthDepth identifies deepest function', () => { + const report = measureAuthDepth(MIXED, 2); + expect(report.deepestFunction).toBeDefined(); + expect(report.deepestFunction!.functionName).toBe('complex'); + expect(report.deepestFunction!.depth).toBe(3); + }); + + test('measureAuthDepth generates recommendations for violations', () => { + const report = measureAuthDepth(DEEP_AUTH, 2); + expect(report.recommendations.length).toBeGreaterThan(0); + expect(report.recommendations.some((r) => r.includes('authorization depth'))).toBe(true); + }); + + test('measureAuthDepth is deterministic', () => { + const r1 = measureAuthDepth(MIXED, 2); + const r2 = measureAuthDepth(MIXED, 2); + expect(r1.maxDepth).toBe(r2.maxDepth); + expect(r1.violations.length).toBe(r2.violations.length); + }); + + test('measureAuthDepth respects configurable threshold', () => { + const strict = measureAuthDepth(SHALLOW_AUTH, 1); + expect(strict.violations).toHaveLength(1); + + const relaxed = measureAuthDepth(SHALLOW_AUTH, 5); + expect(relaxed.violations).toHaveLength(0); + }); +}); diff --git a/packages/analyzers/soroban/auth/tree/auth-depth-analyzer.ts b/packages/analyzers/soroban/auth/tree/auth-depth-analyzer.ts new file mode 100644 index 00000000..6aa845ce --- /dev/null +++ b/packages/analyzers/soroban/auth/tree/auth-depth-analyzer.ts @@ -0,0 +1,131 @@ +import { maskNonCode, extractFunctions } from '../../common/source-utils'; + +export interface AuthDepthResult { + functionName: string; + depth: number; + path: string[]; + line: number; +} + +export interface AuthorizationTreeReport { + results: AuthDepthResult[]; + deepestFunction: AuthDepthResult | null; + maxDepth: number; + threshold: number; + violations: AuthDepthResult[]; + recommendations: string[]; +} + +const DEFAULT_THRESHOLD = 3; + +function countAuthNesting(source: string, fnBodyStart: number, fnBodyEnd: number): { depth: number; path: string[] } { + const body = source.slice(fnBodyStart, fnBodyEnd); + let depth = 0; + let maxDepth = 0; + const path: string[] = []; + const stack: number[] = []; + + for (let i = 0; i < body.length; i++) { + if (body[i] === '{') { + stack.push(depth); + depth++; + if (depth > maxDepth) maxDepth = depth; + } else if (body[i] === '}') { + depth = stack.pop() ?? 0; + } + + const rest = body.slice(i); + const authMatch = rest.match( + /^(\w+::)?(require_auth|authorize_as_parent|authorized|check_auth)\s*\(/, + ); + if (authMatch && i === body.indexOf(authMatch[0])) { + const label = authMatch[2]; + if (!path.includes(label)) { + path.push(label); + } + } + } + + return { depth: maxDepth, path }; +} + +function findAuthCalls(source: string): { name: string; line: number }[] { + const masked = maskNonCode(source); + const results: { name: string; line: number }[] = []; + const authPatterns = [ + /(\w+::)?require_auth\s*\(/g, + /(\w+::)?authorize_as_parent\s*\(/g, + /(\w+::)?check_auth\s*\(/g, + /(\w+::)?authorized\s*\(/g, + ]; + + let lineNum = 1; + for (let i = 0; i < masked.length; i++) { + if (masked[i] === '\n') lineNum++; + + for (const pattern of authPatterns) { + pattern.lastIndex = i; + const match = pattern.exec(masked); + if (match && match.index === i) { + results.push({ name: match[2] || match[0], line: lineNum }); + } + } + } + + return results; +} + +export function measureAuthDepth(source: string, threshold: number = DEFAULT_THRESHOLD): AuthorizationTreeReport { + const masked = maskNonCode(source); + const fns = extractFunctions(masked, source); + const results: AuthDepthResult[] = []; + + for (const fn of fns) { + const authCalls = findAuthCalls(masked.slice(fn.bodyStart, fn.bodyEnd)); + const depth = authCalls.length; + const path = authCalls.map((c) => c.name); + + results.push({ + functionName: fn.name, + depth, + path, + line: fn.line, + }); + } + + const deepest = results.reduce( + (max, r) => (r.depth > (max?.depth ?? -1) ? r : max), + null as AuthDepthResult | null, + ); + + const maxDepth = deepest?.depth ?? 0; + const violations = results.filter((r) => r.depth >= threshold); + + const recommendations: string[] = []; + if (violations.length > 0) { + recommendations.push( + `${violations.length} function(s) have authorization depth >= ${threshold}. Consider flattening auth checks.`, + ); + } + for (const v of violations) { + if (v.depth >= threshold + 2) { + recommendations.push( + `'${v.functionName}' has critically deep auth nesting (${v.depth}). Extract nested auth into separate authorization helpers.`, + ); + } + } + if (maxDepth >= threshold) { + recommendations.push( + `Deep authorization chains increase execution cost and complicate security review. Use composite auth patterns instead.`, + ); + } + + return { + results, + deepestFunction: deepest, + maxDepth, + threshold, + violations, + recommendations, + }; +} diff --git a/packages/analyzers/soroban/auth/tree/index.ts b/packages/analyzers/soroban/auth/tree/index.ts new file mode 100644 index 00000000..1b52168a --- /dev/null +++ b/packages/analyzers/soroban/auth/tree/index.ts @@ -0,0 +1 @@ +export * from './auth-depth-analyzer'; diff --git a/packages/analyzers/soroban/tokens/interfaces/__tests__/token-interface-checker.spec.ts b/packages/analyzers/soroban/tokens/interfaces/__tests__/token-interface-checker.spec.ts new file mode 100644 index 00000000..da6ce27c --- /dev/null +++ b/packages/analyzers/soroban/tokens/interfaces/__tests__/token-interface-checker.spec.ts @@ -0,0 +1,125 @@ +import { + extractTokenFunctionSignatures, + checkTokenInterface, +} from '../token-interface-checker'; + +describe('Soroban Token Interface Compatibility Checker (#922)', () => { + const FULL_TOKEN_CONTRACT = ` + pub fn balance(env: Env, account: Address) -> i128 { + storage::get(&account) + } + + pub fn transfer(env: Env, from: Address, to: Address, amount: i128) -> i128 { + // transfer logic + amount + } + + pub fn transfer_from(env: Env, spender: Address, from: Address, to: Address, amount: i128) -> i128 { + amount + } + + pub fn approve(env: Env, owner: Address, spender: Address, amount: i128, live_until_ledger: u32) -> i128 { + amount + } + + pub fn allowance(env: Env, owner: Address, spender: Address) -> i128 { + 0 + } + + pub fn approve_from(env: Env, from: Address, owner: Address, spender: Address, amount: i128, live_until_ledger: u32) -> i128 { + amount + } + `; + + const PARTIAL_TOKEN_CONTRACT = ` + pub fn balance(env: Env, account: Address) -> i128 { + 0 + } + + pub fn transfer(env: Env, from: Address, to: Address, amount: i128) -> i128 { + amount + } + `; + + const NON_TOKEN_CONTRACT = ` + pub fn initialize(env: Env, admin: Address) { + storage::set(&admin); + } + + pub fn mint(env: Env, to: Address, amount: i128) { + // mint logic + } + `; + + const CONTRACT_WITH_EXTRA = ` + pub fn balance(env: Env, account: Address) -> i128 { 0 } + pub fn transfer(env: Env, from: Address, to: Address, amount: i128) -> i128 { amount } + pub fn transfer_from(env: Env, spender: Address, from: Address, to: Address, amount: i128) -> i128 { amount } + pub fn approve(env: Env, owner: Address, spender: Address, amount: i128, live_until_ledger: u32) -> i128 { amount } + pub fn allowance(env: Env, owner: Address, spender: Address) -> i128 { 0 } + pub fn approve_from(env: Env, from: Address, owner: Address, spender: Address, amount: i128, live_until_ledger: u32) -> i128 { amount } + pub fn custom_burn(env: Env, from: Address, amount: i128) { } + `; + + test('extractTokenFunctionSignatures finds all pub fn signatures', () => { + const sigs = extractTokenFunctionSignatures(FULL_TOKEN_CONTRACT); + expect(sigs.length).toBe(6); + expect(sigs.map((s) => s.name)).toContain('balance'); + expect(sigs.map((s) => s.name)).toContain('transfer'); + expect(sigs.map((s) => s.name)).toContain('transfer_from'); + expect(sigs.map((s) => s.name)).toContain('approve'); + expect(sigs.map((s) => s.name)).toContain('allowance'); + expect(sigs.map((s) => s.name)).toContain('approve_from'); + }); + + test('extractTokenFunctionSignatures strips env parameter', () => { + const sigs = extractTokenFunctionSignatures(FULL_TOKEN_CONTRACT); + const balance = sigs.find((s) => s.name === 'balance'); + expect(balance).toBeDefined(); + expect(balance!.params).toEqual(['Address']); + }); + + test('checkTokenInterface reports full compatibility for complete token', () => { + const result = checkTokenInterface(FULL_TOKEN_CONTRACT); + expect(result.isFullyCompatible).toBe(true); + expect(result.missingMethods).toEqual([]); + expect(result.coveragePercent).toBe(100); + expect(result.issues.filter((i) => i.severity === 'error')).toHaveLength(0); + }); + + test('checkTokenInterface detects missing methods', () => { + const result = checkTokenInterface(PARTIAL_TOKEN_CONTRACT); + expect(result.isFullyCompatible).toBe(false); + expect(result.missingMethods).toContain('transfer_from'); + expect(result.missingMethods).toContain('approve'); + expect(result.missingMethods).toContain('allowance'); + expect(result.missingMethods).toContain('approve_from'); + expect(result.coveragePercent).toBe(33); + }); + + test('checkTokenInterface detects no standard methods in non-token contract', () => { + const result = checkTokenInterface(NON_TOKEN_CONTRACT); + expect(result.isFullyCompatible).toBe(false); + expect(result.missingMethods).toEqual(expect.arrayContaining([ + 'balance', 'transfer', 'transfer_from', 'approve', 'allowance', 'approve_from', + ])); + expect(result.coveragePercent).toBe(0); + expect(result.issues.some((i) => i.severity === 'error')).toBe(true); + }); + + test('checkTokenInterface reports extra non-standard methods as info', () => { + const result = checkTokenInterface(CONTRACT_WITH_EXTRA); + expect(result.isFullyCompatible).toBe(true); + expect(result.extraMethods).toContain('custom_burn'); + expect(result.issues.some((i) => i.severity === 'info' && i.message.includes('custom_burn'))).toBe(true); + }); + + test('checkTokenInterface returns deterministic expected methods list', () => { + const r1 = checkTokenInterface(FULL_TOKEN_CONTRACT); + const r2 = checkTokenInterface(PARTIAL_TOKEN_CONTRACT); + expect(r1.expectedMethods).toEqual(r2.expectedMethods); + expect(r1.expectedMethods).toEqual([ + 'balance', 'transfer', 'transfer_from', 'approve', 'allowance', 'approve_from', + ]); + }); +}); diff --git a/packages/analyzers/soroban/tokens/interfaces/index.ts b/packages/analyzers/soroban/tokens/interfaces/index.ts new file mode 100644 index 00000000..26df8012 --- /dev/null +++ b/packages/analyzers/soroban/tokens/interfaces/index.ts @@ -0,0 +1 @@ +export * from './token-interface-checker'; diff --git a/packages/analyzers/soroban/tokens/interfaces/token-interface-checker.ts b/packages/analyzers/soroban/tokens/interfaces/token-interface-checker.ts new file mode 100644 index 00000000..e48e9326 --- /dev/null +++ b/packages/analyzers/soroban/tokens/interfaces/token-interface-checker.ts @@ -0,0 +1,184 @@ +import { maskNonCode, extractFunctions } from '../../common/source-utils'; + +export interface TokenMethodSignature { + name: string; + params: string[]; + returnType: string; + line: number; +} + +export interface CompatibilityIssue { + severity: 'error' | 'warning' | 'info'; + method: string; + message: string; + line: number; +} + +export interface InterfaceCheckResult { + foundMethods: TokenMethodSignature[]; + expectedMethods: string[]; + missingMethods: string[]; + extraMethods: string[]; + issues: CompatibilityIssue[]; + isFullyCompatible: boolean; + coveragePercent: number; +} + +const STANDARD_TOKEN_METHODS: Record = { + balance: { params: ['Address'], returnType: 'i128' }, + transfer: { params: ['Address', 'Address', 'i128'], returnType: 'i128' }, + transfer_from: { params: ['Address', 'Address', 'Address', 'i128'], returnType: 'i128' }, + approve: { params: ['Address', 'Address', 'i128', 'u32'], returnType: 'i128' }, + allowance: { params: ['Address', 'Address'], returnType: 'i128' }, + approve_from: { params: ['Address', 'Address', 'Address', 'i128', 'u32'], returnType: 'i128' }, +}; + +const STANDARD_METHOD_NAMES = Object.keys(STANDARD_TOKEN_METHODS); + +function parseFunctionSignature(line: string): TokenMethodSignature | null { + const match = line.match( + /pub\s+fn\s+(\w+)\s*\(([^)]*)\)\s*(?:->\s*(\S+))?/, + ); + if (!match) return null; + + const name = match[1]; + const rawParams = match[2] + .split(',') + .map((p) => p.trim()) + .filter(Boolean); + + const params = rawParams + .filter((p) => !p.startsWith('env') && !p.startsWith('&env')) + .map((p) => { + const parts = p.split(':'); + return parts.length > 1 ? parts[parts.length - 1].trim().replace('&', '') : 'unknown'; + }); + + const returnType = match[3] ?? 'void'; + return { name, params, returnType, line: 0 }; +} + +export function extractTokenFunctionSignatures(source: string): TokenMethodSignature[] { + const masked = maskNonCode(source); + const fns = extractFunctions(masked, source); + const results: TokenMethodSignature[] = []; + + for (const fn of fns) { + const preceding = source.slice(Math.max(0, fn.bodyStart - 300), fn.bodyStart + 1); + const sigMatch = preceding.match( + /pub\s+fn\s+(\w+)\s*\(([^)]*)\)\s*(?:->\s*(\S+))?\s*\{?\s*$/, + ); + if (!sigMatch) continue; + + const rawParams = sigMatch[2] + .split(',') + .map((p) => p.trim()) + .filter(Boolean); + + const params = rawParams + .filter((p) => !p.match(/^env\s*:/i) && !p.match(/^&?env\b/i)) + .map((p) => { + const parts = p.split(':'); + return parts.length > 1 + ? parts[parts.length - 1].trim().replace(/&/g, '') + : 'unknown'; + }); + + const returnType = sigMatch[3] ?? 'void'; + results.push({ name: fn.name, params, returnType, line: fn.line }); + } + + return results; +} + +function checkParamCompatibility( + found: string[], + expected: string[], + methodName: string, +): CompatibilityIssue[] { + const issues: CompatibilityIssue[] = []; + + if (found.length !== expected.length) { + issues.push({ + severity: 'warning', + method: methodName, + message: `Expected ${expected.length} parameter(s) but found ${found.length}`, + line: 0, + }); + } + + const len = Math.min(found.length, expected.length); + for (let i = 0; i < len; i++) { + if (found[i] !== expected[i]) { + issues.push({ + severity: 'warning', + method: methodName, + message: `Parameter ${i + 1}: expected '${expected[i]}' but found '${found[i]}'`, + line: 0, + }); + } + } + + return issues; +} + +export function checkTokenInterface(source: string): InterfaceCheckResult { + const methods = extractTokenFunctionSignatures(source); + const methodNames = methods.map((m) => m.name); + const foundStandard = methodNames.filter((n) => STANDARD_METHOD_NAMES.includes(n)); + const missing = STANDARD_METHOD_NAMES.filter((n) => !methodNames.includes(n)); + const extra = methodNames.filter((n) => !STANDARD_METHOD_NAMES.includes(n)); + + const issues: CompatibilityIssue[] = []; + + for (const method of methods) { + const expected = STANDARD_TOKEN_METHODS[method.name]; + if (expected) { + issues.push(...checkParamCompatibility(method.params, expected.params, method.name)); + + if (method.returnType !== expected.returnType) { + issues.push({ + severity: 'warning', + method: method.name, + message: `Return type: expected '${expected.returnType}' but found '${method.returnType}'`, + line: method.line, + }); + } + } + } + + for (const m of missing) { + const expected = STANDARD_TOKEN_METHODS[m]; + issues.push({ + severity: 'error', + method: m, + message: `Missing standard token method '${m}'(${expected.params.join(', ')}): ${expected.returnType}`, + line: 0, + }); + } + + if (extra.length > 0) { + issues.push({ + severity: 'info', + method: extra.join(', '), + message: `Non-standard method(s) found: ${extra.join(', ')}`, + line: 0, + }); + } + + const errorCount = issues.filter((i) => i.severity === 'error').length; + const coveragePercent = + STANDARD_METHOD_NAMES.length > 0 + ? Math.round((foundStandard.length / STANDARD_METHOD_NAMES.length) * 100) + : 0; + + return { + foundMethods: methods, + expectedMethods: STANDARD_METHOD_NAMES, + missingMethods: missing, + extraMethods: extra, + issues, + isFullyCompatible: missing.length === 0 && errorCount === 0, + coveragePercent, + }; +}