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
79 changes: 79 additions & 0 deletions rules/stellar/access-control/detect-weak-role-hierarchies.ts
Original file line number Diff line number Diff line change
@@ -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.',
};
}
106 changes: 106 additions & 0 deletions tests/rules/detect-weak-role-hierarchies.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
25 changes: 25 additions & 0 deletions tests/rules/fixtures/stellar-weak-role-hierarchy.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
Loading