Skip to main content

scope/contract/
access.rs

1//! # Access Control Mapping
2//!
3//! Analyzes smart contract source code to identify and map access control
4//! patterns, privileged functions, ownership, and authorization mechanisms.
5//!
6//! ## Detected Patterns
7//!
8//! - **Ownable** - OpenZeppelin Ownable (onlyOwner modifier)
9//! - **AccessControl** - Role-based access (OpenZeppelin AccessControl)
10//! - **tx.origin** - Dangerous authorization via tx.origin
11//! - **Custom modifiers** - Any modifier that gates function access
12//! - **Renounced ownership** - renounceOwnership() calls detected
13//! - **Multisig patterns** - Multiple signature requirements
14
15use crate::contract::source::ContractSource;
16use regex::Regex;
17use serde::{Deserialize, Serialize};
18
19/// Complete access control analysis for a contract.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct AccessControlMap {
22    /// Owner address pattern (if Ownable).
23    pub ownership_pattern: Option<String>,
24    /// Whether ownership has been renounced.
25    pub has_renounced_ownership: bool,
26    /// Whether role-based access control is used.
27    pub has_role_based_access: bool,
28    /// Whether tx.origin is used for authorization (dangerous).
29    pub uses_tx_origin: bool,
30    /// tx.origin usage locations.
31    pub tx_origin_locations: Vec<SourceLocation>,
32    /// Detected custom access modifiers.
33    pub modifiers: Vec<AccessModifier>,
34    /// Functions with access restrictions.
35    pub privileged_functions: Vec<PrivilegedFunction>,
36    /// Defined roles (AccessControl pattern).
37    pub roles: Vec<String>,
38    /// msg.sender vs tx.origin comparison.
39    pub auth_analysis: AuthAnalysis,
40}
41
42/// A location in source code.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct SourceLocation {
45    /// File path or contract name.
46    pub file: String,
47    /// Approximate line number (0 if unknown).
48    pub line: usize,
49    /// The relevant code snippet.
50    pub snippet: String,
51}
52
53/// An access control modifier.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct AccessModifier {
56    /// Modifier name (e.g., "onlyOwner", "onlyRole").
57    pub name: String,
58    /// What the modifier checks.
59    pub check_type: ModifierCheckType,
60    /// Number of functions using this modifier.
61    pub usage_count: usize,
62}
63
64/// Type of access check performed by a modifier.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub enum ModifierCheckType {
67    /// Checks msg.sender == owner.
68    OwnerOnly,
69    /// Checks hasRole(role, msg.sender).
70    RoleBased,
71    /// Checks tx.origin (dangerous).
72    TxOrigin,
73    /// Custom/unknown check.
74    Custom,
75}
76
77/// A function with access control restrictions.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct PrivilegedFunction {
80    /// Function name.
81    pub name: String,
82    /// Access modifier(s) applied.
83    pub modifiers: Vec<String>,
84    /// What this function can do (e.g., "mint tokens", "pause contract").
85    pub capability: String,
86    /// Risk level of this privileged operation.
87    pub risk: PrivilegeRisk,
88}
89
90/// Risk level for a privileged operation.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub enum PrivilegeRisk {
93    /// Can drain funds or destroy contract.
94    Critical,
95    /// Can modify key parameters or pause.
96    High,
97    /// Can change configuration.
98    Medium,
99    /// Administrative but low risk.
100    Low,
101}
102
103/// Authorization mechanism analysis.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct AuthAnalysis {
106    /// Number of msg.sender checks found.
107    pub msg_sender_checks: usize,
108    /// Number of tx.origin checks found (should be 0 ideally).
109    pub tx_origin_checks: usize,
110    /// Whether require(tx.origin == msg.sender) pattern is used.
111    pub has_origin_sender_comparison: bool,
112    /// Summary of authorization approach.
113    pub summary: String,
114}
115
116/// Analyze access control patterns in contract source code.
117pub fn analyze_access_control(source: &ContractSource) -> AccessControlMap {
118    let code = &source.source_code;
119
120    let ownership_pattern = detect_ownership_pattern(code);
121    let has_renounced_ownership = code.contains("renounceOwnership");
122    let has_role_based_access =
123        code.contains("AccessControl") || code.contains("hasRole") || code.contains("grantRole");
124    let uses_tx_origin = code.contains("tx.origin");
125    let tx_origin_locations = find_tx_origin_usage(code);
126    let modifiers = detect_modifiers(code);
127    let privileged_functions = detect_privileged_functions(code, &source.parsed_abi);
128    let roles = detect_roles(code);
129    let auth_analysis = analyze_auth_mechanisms(code);
130
131    AccessControlMap {
132        ownership_pattern,
133        has_renounced_ownership,
134        has_role_based_access,
135        uses_tx_origin,
136        tx_origin_locations,
137        modifiers,
138        privileged_functions,
139        roles,
140        auth_analysis,
141    }
142}
143
144fn detect_ownership_pattern(code: &str) -> Option<String> {
145    if code.contains("Ownable") {
146        Some("OpenZeppelin Ownable".to_string())
147    } else if code.contains("owner()") || code.contains("_owner") {
148        Some("Custom owner pattern".to_string())
149    } else if code.contains("AccessControl") {
150        Some("Role-based (AccessControl)".to_string())
151    } else {
152        None
153    }
154}
155
156fn find_tx_origin_usage(code: &str) -> Vec<SourceLocation> {
157    let mut locations = Vec::new();
158    for (line_num, line) in code.lines().enumerate() {
159        if line.contains("tx.origin") {
160            locations.push(SourceLocation {
161                file: String::new(),
162                line: line_num + 1,
163                snippet: line.trim().to_string(),
164            });
165        }
166    }
167    locations
168}
169
170fn detect_modifiers(code: &str) -> Vec<AccessModifier> {
171    let mut modifiers = Vec::new();
172
173    // Match modifier definitions: `modifier name(...) {`
174    let re = Regex::new(r"modifier\s+(\w+)").unwrap();
175    for cap in re.captures_iter(code) {
176        let name = cap[1].to_string();
177
178        let check_type = if name.contains("onlyOwner") || name.contains("only_owner") {
179            ModifierCheckType::OwnerOnly
180        } else if name.contains("onlyRole")
181            || name.contains("only_role")
182            || name.contains("onlyAdmin")
183        {
184            ModifierCheckType::RoleBased
185        } else {
186            ModifierCheckType::Custom
187        };
188
189        // Count usage of this modifier in function declarations
190        let usage_pattern = format!(r"\b{}\b", regex::escape(&name));
191        let usage_re = Regex::new(&usage_pattern).unwrap();
192        let usage_count = usage_re.find_iter(code).count().saturating_sub(1); // Subtract definition
193
194        modifiers.push(AccessModifier {
195            name,
196            check_type,
197            usage_count,
198        });
199    }
200
201    // Detect onlyOwner even if defined in imported contract
202    if code.contains("onlyOwner") && !modifiers.iter().any(|m| m.name == "onlyOwner") {
203        let usage_re = Regex::new(r"\bonlyOwner\b").unwrap();
204        let usage_count = usage_re.find_iter(code).count();
205        modifiers.push(AccessModifier {
206            name: "onlyOwner".to_string(),
207            check_type: ModifierCheckType::OwnerOnly,
208            usage_count,
209        });
210    }
211
212    modifiers
213}
214
215fn detect_privileged_functions(
216    code: &str,
217    abi: &[crate::contract::source::AbiEntry],
218) -> Vec<PrivilegedFunction> {
219    let mut functions = Vec::new();
220
221    // Detect common privileged function patterns
222    let patterns: Vec<(&str, &str, PrivilegeRisk)> = vec![
223        ("mint", "Mint/create new tokens", PrivilegeRisk::Critical),
224        ("burn", "Burn/destroy tokens", PrivilegeRisk::High),
225        ("pause", "Pause contract operations", PrivilegeRisk::High),
226        (
227            "unpause",
228            "Unpause contract operations",
229            PrivilegeRisk::High,
230        ),
231        ("setFee", "Modify fee parameters", PrivilegeRisk::Medium),
232        ("setPrice", "Modify price parameters", PrivilegeRisk::Medium),
233        (
234            "withdraw",
235            "Withdraw funds from contract",
236            PrivilegeRisk::Critical,
237        ),
238        (
239            "transferOwnership",
240            "Transfer contract ownership",
241            PrivilegeRisk::Critical,
242        ),
243        (
244            "upgradeTo",
245            "Upgrade contract implementation",
246            PrivilegeRisk::Critical,
247        ),
248        ("selfdestruct", "Destroy contract", PrivilegeRisk::Critical),
249        ("blacklist", "Blacklist addresses", PrivilegeRisk::High),
250        ("whitelist", "Whitelist addresses", PrivilegeRisk::Medium),
251        ("setOracle", "Change price oracle", PrivilegeRisk::Critical),
252        ("setRouter", "Change DEX router", PrivilegeRisk::Critical),
253    ];
254
255    let fn_name_re = Regex::new(r"function\s+(\w+)").unwrap();
256
257    for (pattern, capability, risk) in &patterns {
258        // Check source for function + modifier combination
259        let fn_re = Regex::new(&format!(
260            r"function\s+\w*{}\w*\s*\([^)]*\)[^{{]*\b(onlyOwner|onlyRole|onlyAdmin|whenNotPaused)\b",
261            regex::escape(pattern)
262        ));
263        if let Ok(re) = fn_re {
264            for cap in re.captures_iter(code) {
265                let full_match = cap.get(0).map_or("", |m| m.as_str());
266                if let Some(fn_cap) = fn_name_re.captures(full_match) {
267                    let fn_name = fn_cap[1].to_string();
268                    let modifier = cap[1].to_string();
269                    functions.push(PrivilegedFunction {
270                        name: fn_name,
271                        modifiers: vec![modifier],
272                        capability: capability.to_string(),
273                        risk: risk.clone(),
274                    });
275                }
276            }
277        }
278
279        // Also check ABI for state-changing functions matching the pattern
280        let pattern_lower = pattern.to_lowercase();
281        for entry in abi {
282            if entry.entry_type == "function"
283                && entry.name.to_lowercase().contains(&pattern_lower)
284                && entry.is_state_changing()
285                && !functions.iter().any(|f| f.name == entry.name)
286            {
287                functions.push(PrivilegedFunction {
288                    name: entry.name.clone(),
289                    modifiers: vec!["(from ABI)".to_string()],
290                    capability: capability.to_string(),
291                    risk: risk.clone(),
292                });
293            }
294        }
295    }
296
297    functions
298}
299
300fn detect_roles(code: &str) -> Vec<String> {
301    let mut roles = Vec::new();
302
303    // Match bytes32 constant role definitions
304    // e.g., bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
305    let re = Regex::new(r#"(?:bytes32|constant)\s+.*?(\w+_ROLE)\s*="#).unwrap();
306    for cap in re.captures_iter(code) {
307        roles.push(cap[1].to_string());
308    }
309
310    // Common role patterns
311    for role in &[
312        "DEFAULT_ADMIN_ROLE",
313        "MINTER_ROLE",
314        "PAUSER_ROLE",
315        "BURNER_ROLE",
316        "UPGRADER_ROLE",
317    ] {
318        if code.contains(role) && !roles.contains(&role.to_string()) {
319            roles.push(role.to_string());
320        }
321    }
322
323    roles
324}
325
326fn analyze_auth_mechanisms(code: &str) -> AuthAnalysis {
327    let msg_sender_re = Regex::new(r"msg\.sender").unwrap();
328    let tx_origin_re = Regex::new(r"tx\.origin").unwrap();
329    let origin_sender_re =
330        Regex::new(r"(?:tx\.origin\s*==\s*msg\.sender|msg\.sender\s*==\s*tx\.origin)").unwrap();
331
332    let msg_sender_checks = msg_sender_re.find_iter(code).count();
333    let tx_origin_checks = tx_origin_re.find_iter(code).count();
334    let has_origin_sender_comparison = origin_sender_re.is_match(code);
335
336    let summary = if tx_origin_checks > 0 && !has_origin_sender_comparison {
337        format!(
338            "DANGER: Uses tx.origin ({} occurrence(s)) without msg.sender comparison. \
339             This is vulnerable to phishing attacks via malicious contracts.",
340            tx_origin_checks
341        )
342    } else if has_origin_sender_comparison {
343        "Uses tx.origin == msg.sender comparison (anti-contract-call guard). \
344         Less risky but blocks legitimate contract interactions."
345            .to_string()
346    } else if msg_sender_checks > 0 {
347        format!(
348            "Uses msg.sender for authorization ({} check(s)). This is the recommended approach.",
349            msg_sender_checks
350        )
351    } else {
352        "No explicit authorization checks detected.".to_string()
353    };
354
355    AuthAnalysis {
356        msg_sender_checks,
357        tx_origin_checks,
358        has_origin_sender_comparison,
359        summary,
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::contract::source::ContractSource;
367
368    fn make_source(code: &str) -> ContractSource {
369        ContractSource {
370            contract_name: "Test".to_string(),
371            source_code: code.to_string(),
372            abi: "[]".to_string(),
373            compiler_version: "v0.8.19".to_string(),
374            optimization_used: true,
375            optimization_runs: 200,
376            evm_version: "paris".to_string(),
377            license_type: "MIT".to_string(),
378            is_proxy: false,
379            implementation_address: None,
380            constructor_arguments: String::new(),
381            library: String::new(),
382            swarm_source: String::new(),
383            parsed_abi: vec![],
384        }
385    }
386
387    #[test]
388    fn test_detect_ownable() {
389        let src = make_source("contract Token is Ownable { function mint() onlyOwner {} }");
390        let ac = analyze_access_control(&src);
391        assert_eq!(
392            ac.ownership_pattern,
393            Some("OpenZeppelin Ownable".to_string())
394        );
395        assert!(!ac.has_renounced_ownership);
396    }
397
398    #[test]
399    fn test_detect_renounced_ownership() {
400        let src = make_source("contract Token { function renounceOwnership() {} }");
401        let ac = analyze_access_control(&src);
402        assert!(ac.has_renounced_ownership);
403    }
404
405    #[test]
406    fn test_detect_tx_origin() {
407        let src = make_source("require(tx.origin == owner, 'not owner');");
408        let ac = analyze_access_control(&src);
409        assert!(ac.uses_tx_origin);
410        assert_eq!(ac.tx_origin_locations.len(), 1);
411    }
412
413    #[test]
414    fn test_detect_roles() {
415        let src = make_source(
416            "bytes32 public constant MINTER_ROLE = keccak256('MINTER_ROLE');\n\
417             bytes32 public constant PAUSER_ROLE = keccak256('PAUSER_ROLE');",
418        );
419        let ac = analyze_access_control(&src);
420        assert!(ac.roles.contains(&"MINTER_ROLE".to_string()));
421        assert!(ac.roles.contains(&"PAUSER_ROLE".to_string()));
422    }
423
424    #[test]
425    fn test_detect_access_control() {
426        let src = make_source(
427            "import AccessControl; contract Token is AccessControl { \
428             function mint() onlyRole(MINTER_ROLE) {} }",
429        );
430        let ac = analyze_access_control(&src);
431        assert!(ac.has_role_based_access);
432    }
433
434    #[test]
435    fn test_auth_analysis_safe() {
436        let src = make_source("require(msg.sender == owner);");
437        let ac = analyze_access_control(&src);
438        assert_eq!(ac.auth_analysis.msg_sender_checks, 1);
439        assert_eq!(ac.auth_analysis.tx_origin_checks, 0);
440        assert!(ac.auth_analysis.summary.contains("recommended approach"));
441    }
442
443    #[test]
444    fn test_auth_analysis_dangerous() {
445        let src = make_source("require(tx.origin == owner);");
446        let ac = analyze_access_control(&src);
447        assert!(ac.auth_analysis.summary.contains("DANGER"));
448    }
449
450    #[test]
451    fn test_detect_ownership_pattern_custom_owner() {
452        let result = detect_ownership_pattern(
453            "function owner() public view returns (address) { return _owner; }",
454        );
455        assert_eq!(result, Some("Custom owner pattern".to_string()));
456    }
457
458    #[test]
459    fn test_detect_ownership_pattern_none() {
460        let result = detect_ownership_pattern("contract SimpleToken { function transfer() {} }");
461        assert_eq!(result, None);
462    }
463
464    #[test]
465    fn test_detect_modifiers_custom() {
466        let code = "modifier onlyValidator() { require(isValidator[msg.sender]); _; }\nfunction doThing() onlyValidator() {}";
467        let modifiers = detect_modifiers(code);
468        assert!(modifiers.iter().any(|m| m.name == "onlyValidator"));
469        let validator_mod = modifiers
470            .iter()
471            .find(|m| m.name == "onlyValidator")
472            .unwrap();
473        assert!(matches!(
474            validator_mod.check_type,
475            ModifierCheckType::Custom
476        ));
477    }
478
479    #[test]
480    fn test_detect_modifiers_role_based() {
481        let code = "modifier onlyRole(bytes32 role) { _checkRole(role); _; }\nfunction mint() onlyRole(MINTER) {}";
482        let modifiers = detect_modifiers(code);
483        assert!(modifiers.iter().any(|m| m.name == "onlyRole"));
484        let role_mod = modifiers.iter().find(|m| m.name == "onlyRole").unwrap();
485        assert!(matches!(role_mod.check_type, ModifierCheckType::RoleBased));
486    }
487
488    #[test]
489    fn test_detect_modifiers_admin() {
490        let code = "modifier onlyAdmin() { require(msg.sender == admin); _; }";
491        let modifiers = detect_modifiers(code);
492        assert!(modifiers.iter().any(|m| m.name == "onlyAdmin"));
493        let admin_mod = modifiers.iter().find(|m| m.name == "onlyAdmin").unwrap();
494        assert!(matches!(admin_mod.check_type, ModifierCheckType::RoleBased));
495    }
496
497    #[test]
498    fn test_detect_modifiers_imported_only_owner() {
499        let code =
500            "function mint() onlyOwner { tokens[msg.sender] += 1; }\nfunction burn() onlyOwner {}";
501        let modifiers = detect_modifiers(code);
502        assert!(modifiers.iter().any(|m| m.name == "onlyOwner"));
503        let owner_mod = modifiers.iter().find(|m| m.name == "onlyOwner").unwrap();
504        assert!(matches!(owner_mod.check_type, ModifierCheckType::OwnerOnly));
505        assert!(owner_mod.usage_count >= 2);
506    }
507
508    #[test]
509    fn test_detect_privileged_functions_with_abi() {
510        use crate::contract::source::{AbiEntry, AbiParam};
511        let code = "function mint(address to) onlyOwner { _mint(to); }";
512        let abi = vec![
513            AbiEntry {
514                entry_type: "function".to_string(),
515                name: "mint".to_string(),
516                inputs: vec![AbiParam {
517                    name: "to".to_string(),
518                    param_type: "address".to_string(),
519                    indexed: false,
520                    components: vec![],
521                }],
522                outputs: vec![],
523                state_mutability: "nonpayable".to_string(),
524            },
525            AbiEntry {
526                entry_type: "function".to_string(),
527                name: "pause".to_string(),
528                inputs: vec![],
529                outputs: vec![],
530                state_mutability: "nonpayable".to_string(),
531            },
532        ];
533        let fns = detect_privileged_functions(code, &abi);
534        assert!(!fns.is_empty());
535        assert!(fns.iter().any(|f| f.name == "mint"));
536    }
537
538    #[test]
539    fn test_detect_privileged_functions_abi_only() {
540        use crate::contract::source::{AbiEntry, AbiParam};
541        let code = "contract Token {}";
542        let abi = vec![AbiEntry {
543            entry_type: "function".to_string(),
544            name: "setFeeRecipient".to_string(),
545            inputs: vec![AbiParam {
546                name: "r".to_string(),
547                param_type: "address".to_string(),
548                indexed: false,
549                components: vec![],
550            }],
551            outputs: vec![],
552            state_mutability: "nonpayable".to_string(),
553        }];
554        let fns = detect_privileged_functions(code, &abi);
555        assert!(fns.iter().any(|f| f.name == "setFeeRecipient"));
556    }
557
558    #[test]
559    fn test_detect_roles_common_patterns() {
560        let code = "contract Token {\n\
561            bytes32 public constant UPGRADER_ROLE = keccak256('UPGRADER_ROLE');\n\
562            DEFAULT_ADMIN_ROLE;\n\
563            BURNER_ROLE;\n\
564        }";
565        let roles = detect_roles(code);
566        assert!(roles.contains(&"UPGRADER_ROLE".to_string()));
567        assert!(roles.contains(&"DEFAULT_ADMIN_ROLE".to_string()));
568        assert!(roles.contains(&"BURNER_ROLE".to_string()));
569    }
570
571    #[test]
572    fn test_analyze_auth_tx_origin_with_msg_sender() {
573        let code = "require(tx.origin == msg.sender, 'no contracts');";
574        let auth = analyze_auth_mechanisms(code);
575        assert!(auth.has_origin_sender_comparison);
576        assert!(auth.summary.contains("anti-contract-call"));
577    }
578
579    #[test]
580    fn test_analyze_auth_no_checks() {
581        let code = "contract Token { function transfer() {} }";
582        let auth = analyze_auth_mechanisms(code);
583        assert_eq!(auth.msg_sender_checks, 0);
584        assert_eq!(auth.tx_origin_checks, 0);
585        assert!(auth.summary.contains("No explicit authorization"));
586    }
587
588    #[test]
589    fn test_find_tx_origin_usage_multiple() {
590        let code = "require(tx.origin == owner);\nrequire(tx.origin != address(0));";
591        let locations = find_tx_origin_usage(code);
592        assert_eq!(locations.len(), 2);
593        assert_eq!(locations[0].line, 1);
594        assert_eq!(locations[1].line, 2);
595    }
596
597    #[test]
598    fn test_privilege_risk_debug() {
599        assert_eq!(format!("{:?}", PrivilegeRisk::Critical), "Critical");
600        assert_eq!(format!("{:?}", PrivilegeRisk::High), "High");
601        assert_eq!(format!("{:?}", PrivilegeRisk::Medium), "Medium");
602        assert_eq!(format!("{:?}", PrivilegeRisk::Low), "Low");
603    }
604
605    #[test]
606    fn test_modifier_check_type_debug() {
607        assert_eq!(format!("{:?}", ModifierCheckType::OwnerOnly), "OwnerOnly");
608        assert_eq!(format!("{:?}", ModifierCheckType::RoleBased), "RoleBased");
609        assert_eq!(format!("{:?}", ModifierCheckType::Custom), "Custom");
610    }
611}