Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
});
});
131 changes: 131 additions & 0 deletions packages/analyzers/soroban/auth/tree/auth-depth-analyzer.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
1 change: 1 addition & 0 deletions packages/analyzers/soroban/auth/tree/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './auth-depth-analyzer';
Original file line number Diff line number Diff line change
@@ -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',
]);
});
});
1 change: 1 addition & 0 deletions packages/analyzers/soroban/tokens/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './token-interface-checker';
Loading
Loading