diff --git a/rules/stellar/access-control/detect-weak-role-hierarchies.ts b/rules/stellar/access-control/detect-weak-role-hierarchies.ts new file mode 100644 index 0000000..ea9b377 --- /dev/null +++ b/rules/stellar/access-control/detect-weak-role-hierarchies.ts @@ -0,0 +1,79 @@ +/** + * Detect Weak Role Hierarchies in Soroban Contracts + * Flags role-assignment functions that lack a superior-authority check, + * enabling any authenticated caller to escalate privileges. + */ + +export interface WeakRoleHierarchyResult { + detected: boolean; + weakRoles: string[]; + message: string; + suggestion: string; +} + +// Functions that grant, assign, or promote roles +const ROLE_ASSIGN_PATTERNS = [ + /fn\s+(grant_role|assign_role|set_role|add_role|promote|escalate_role|make_admin|add_admin|give_admin|grant_admin|set_admin|transfer_role)\s*\(/g, +]; + +// A proper hierarchy guard verifies the CALLER holds a superior role before granting any role +const HIERARCHY_GUARD = + /admin\.require_auth|only_admin|assert_admin|is_admin\(|require_role\s*\(\s*Role::Admin|env\.require_auth_for_admin|check_admin/; + +// Basic auth present but no hierarchy check — caller identity confirmed but tier not verified +const WEAK_AUTH_GUARD = /require_auth/; + +// Extract exactly one function body starting at startIdx by counting braces, +// avoiding false positives from adjacent function definitions. +function extractFunctionBody(code: string, startIdx: number): string { + let depth = 0; + let opened = false; + for (let i = startIdx; i < code.length; i++) { + if (code[i] === '{') { depth++; opened = true; } + else if (code[i] === '}') { + depth--; + if (opened && depth === 0) return code.slice(startIdx, i + 1); + } + } + return code.slice(startIdx, startIdx + 400); +} + +export function detectWeakRoleHierarchies(code: string): WeakRoleHierarchyResult { + const weakRoles: string[] = []; + + for (const pattern of ROLE_ASSIGN_PATTERNS) { + for (const match of code.matchAll(pattern)) { + const fnName = match[1]; + const fnBody = extractFunctionBody(code, match.index ?? 0); + + // Safe: a superior-authority guard validates the caller's role tier + if (HIERARCHY_GUARD.test(fnBody)) continue; + + // Weak: only basic auth (any authenticated user can assign roles) + // or no auth at all — both allow privilege escalation + weakRoles.push(fnName); + } + } + + if (weakRoles.length === 0) { + return { + detected: false, + weakRoles: [], + message: 'Role hierarchy properly enforced on all role-assignment functions.', + suggestion: '', + }; + } + + const hasWeakAuth = WEAK_AUTH_GUARD.test(code); + const escalationType = hasWeakAuth + ? 'only basic require_auth (any authenticated user can assign roles)' + : 'no authentication at all'; + + return { + detected: true, + weakRoles, + message: `Weak role hierarchy detected in: ${weakRoles.join(', ')}. Role-assignment functions use ${escalationType}.`, + suggestion: + 'Guard every role-assignment function with a superior-authority check such as `admin.require_auth()` or `assert_admin(&env)` before modifying any role.', + }; +} diff --git a/tests/rules/detect-weak-role-hierarchies.spec.ts b/tests/rules/detect-weak-role-hierarchies.spec.ts new file mode 100644 index 0000000..346d7f3 --- /dev/null +++ b/tests/rules/detect-weak-role-hierarchies.spec.ts @@ -0,0 +1,106 @@ +import { detectWeakRoleHierarchies } from '../../rules/stellar/access-control/detect-weak-role-hierarchies'; +import { FixtureLoader } from '../../libs/testing/src/fixture-loader'; + +describe('detectWeakRoleHierarchies', () => { + describe('detection cases', () => { + it('flags a grant_role function with only require_auth', () => { + const code = ` + pub fn grant_role(env: Env, caller: Address, target: Address, role: Role) { + caller.require_auth(); + env.storage().instance().set(&target, &role); + } + `; + const result = detectWeakRoleHierarchies(code); + expect(result.detected).toBe(true); + expect(result.weakRoles).toContain('grant_role'); + expect(result.message).toMatch(/grant_role/); + }); + + it('flags add_admin with no auth guard at all', () => { + const code = ` + pub fn add_admin(env: Env, new_admin: Address) { + env.storage().instance().set(&new_admin, &Role::Admin); + } + `; + const result = detectWeakRoleHierarchies(code); + expect(result.detected).toBe(true); + expect(result.weakRoles).toContain('add_admin'); + }); + + it('flags multiple weak role functions', () => { + const code = ` + pub fn grant_role(env: Env, caller: Address, target: Address, role: Role) { + caller.require_auth(); + env.storage().instance().set(&target, &role); + } + pub fn set_role(env: Env, caller: Address, target: Address, role: Role) { + caller.require_auth(); + env.storage().instance().set(&target, &role); + } + `; + const result = detectWeakRoleHierarchies(code); + expect(result.detected).toBe(true); + expect(result.weakRoles).toHaveLength(2); + expect(result.weakRoles).toContain('grant_role'); + expect(result.weakRoles).toContain('set_role'); + }); + }); + + describe('safe cases', () => { + it('does not flag a function guarded with admin.require_auth', () => { + const code = ` + pub fn grant_role(env: Env, admin: Address, target: Address, role: Role) { + admin.require_auth(); + assert_admin(&env, &admin); + env.storage().instance().set(&target, &role); + } + `; + const result = detectWeakRoleHierarchies(code); + expect(result.detected).toBe(false); + expect(result.weakRoles).toHaveLength(0); + }); + + it('does not flag when no role-assignment functions exist', () => { + const code = ` + pub fn get_balance(env: Env, address: Address) -> i128 { + env.storage().instance().get(&address).unwrap_or(0) + } + `; + const result = detectWeakRoleHierarchies(code); + expect(result.detected).toBe(false); + }); + + it('does not flag a function guarded with only_admin', () => { + const code = ` + pub fn add_admin(env: Env, caller: Address, new_admin: Address) { + only_admin(&env, &caller); + env.storage().instance().set(&new_admin, &Role::Admin); + } + `; + const result = detectWeakRoleHierarchies(code); + expect(result.detected).toBe(false); + }); + }); + + describe('fixture validation', () => { + it('fixture matches expected structure', () => { + const fixture = FixtureLoader.loadFixture( + './tests/rules/fixtures/stellar-weak-role-hierarchy.json' + ); + expect(fixture.id).toBe('stellar-weak-role-hierarchy-1'); + expect(fixture.expectedViolations).toHaveLength(2); + expect(fixture.metadata?.category).toBe('access-control'); + }); + + it('detector agrees with fixture violations', () => { + const fixture = FixtureLoader.loadFixture( + './tests/rules/fixtures/stellar-weak-role-hierarchy.json' + ); + const result = detectWeakRoleHierarchies(fixture.input); + expect(result.detected).toBe(true); + expect(result.weakRoles).toContain('grant_role'); + expect(result.weakRoles).toContain('add_admin'); + expect(result.weakRoles).not.toContain('safe_promote'); + }); + }); +}); diff --git a/tests/rules/fixtures/stellar-weak-role-hierarchy.json b/tests/rules/fixtures/stellar-weak-role-hierarchy.json new file mode 100644 index 0000000..dd4aff3 --- /dev/null +++ b/tests/rules/fixtures/stellar-weak-role-hierarchy.json @@ -0,0 +1,25 @@ +{ + "id": "stellar-weak-role-hierarchy-1", + "name": "Weak Role Hierarchy Detection", + "description": "Flags role-assignment functions that use only basic require_auth without a superior-authority check, enabling privilege escalation", + "input": "use soroban_sdk::{contract, contractimpl, contracttype, Address, Env};\n\n#[contracttype]\npub enum Role { Admin, Moderator, User }\n\n#[contractimpl]\nimpl AccessContract {\n pub fn grant_role(env: Env, caller: Address, target: Address, role: Role) {\n caller.require_auth();\n env.storage().instance().set(&target, &role);\n }\n\n pub fn add_admin(env: Env, caller: Address, new_admin: Address) {\n caller.require_auth();\n env.storage().instance().set(&new_admin, &Role::Admin);\n }\n\n pub fn safe_promote(env: Env, admin: Address, target: Address, role: Role) {\n admin.require_auth();\n assert_admin(&env, &admin);\n env.storage().instance().set(&target, &role);\n }\n}", + "expectedViolations": [ + { + "rule_name": "detect-weak-role-hierarchies", + "severity": "High", + "message_pattern": "grant_role", + "line_number": 8 + }, + { + "rule_name": "detect-weak-role-hierarchies", + "severity": "High", + "message_pattern": "add_admin", + "line_number": 13 + } + ], + "metadata": { + "language": "soroban", + "category": "access-control", + "tags": ["role-hierarchy", "privilege-escalation", "access-control"] + } +}