use lazy_static::lazy_static;
use regex::Regex;
use sentri_core::Finding;
lazy_static! {
static ref BRIDGE_FUNCTION: Regex = Regex::new(
r"(?i)function\s+\w*bridge\w*\s*\(|function\s+\w*transfer.*?signature|function\s+\w*mint.*?signature"
).unwrap();
static ref SIGNATURE_PARAMETER: Regex = Regex::new(r"(?i)bytes\s+(?:calldata)?\s+signature|sig\s+bytes").unwrap();
static ref ADDRESS_PARAMETER: Regex = Regex::new(r"(?i)address\s+(?:recipient|to|user|target|dest)").unwrap();
static ref ECDSA_RECOVER: Regex =
Regex::new(r"(?i)(ECDSA|ecrecover|recover)\s*\.\s*(recover|recoverSigner)")
.unwrap();
static ref KECCAK_HASH: Regex = Regex::new(r"(?i)keccak256\s*\(").unwrap();
static ref SIGNER_VALIDATION: Regex = Regex::new(
r"(?i)require\s*\(.*?(signer|recovered|caller|verifier)\s*(==|!=).*?(authorized|approved|expected|bridge)"
).unwrap();
static ref ECRECOVER_PATTERN: Regex = Regex::new(r"(?i)ecrecover\s*\(").unwrap();
}
pub fn detect_bridge_address_cryptographic_verify(source: &str, file_path: &str) -> Vec<Finding> {
let mut findings = Vec::new();
for (line_num, line) in source.lines().enumerate() {
if line.trim().starts_with("//") {
continue;
}
if !BRIDGE_FUNCTION.is_match(line) {
continue;
}
let context_end = std::cmp::min(line_num + 200, source.lines().count());
let function_body = source
.lines()
.skip(line_num)
.take(context_end - line_num)
.collect::<Vec<_>>()
.join("\n");
let has_signature = SIGNATURE_PARAMETER.is_match(&function_body);
let has_address = ADDRESS_PARAMETER.is_match(&function_body);
if !has_signature || !has_address {
continue;
}
let has_ecdsa = ECDSA_RECOVER.is_match(&function_body);
let has_ecrecover = ECRECOVER_PATTERN.is_match(&function_body);
let has_recovery = has_ecdsa || has_ecrecover;
let has_hash = KECCAK_HASH.is_match(&function_body);
let has_validation = SIGNER_VALIDATION.is_match(&function_body);
if !has_recovery {
findings.push(
Finding::new(
"evm_bridge_address_cryptographic_verify".to_string(),
sentri_core::Severity::Critical,
file_path.to_string(),
line_num + 1,
0,
"Bridge function accepts address with signature but lacks ECDSA signature recovery. \
Attacker can forge bridge calls with arbitrary addresses without valid signature."
.to_string(),
line.trim().to_string(),
)
.with_metadata("exploit_id".to_string(), "H49".to_string())
.with_metadata("exploit_name".to_string(), "Purrlend Bridge Address Forgery".to_string())
.with_metadata("loss".to_string(), "$0.8M".to_string())
.with_metadata("year".to_string(), "2023".to_string())
.with_metadata("vulnerability_type".to_string(), "signature_forgery".to_string())
.with_metadata("detector".to_string(), "pattern_analysis".to_string())
.with_metadata("remediation".to_string(), "Add ECDSA.recover() and require(signer == authorizedBridge)".to_string()),
);
} else if !has_validation {
findings.push(
Finding::new(
"evm_bridge_address_cryptographic_verify".to_string(),
sentri_core::Severity::Critical,
file_path.to_string(),
line_num + 1,
0,
"Bridge function recovers signature but lacks signer validation. \
Add require(signer == authorizedBridge) to verify recovered address is trusted."
.to_string(),
line.trim().to_string(),
)
.with_metadata("exploit_id".to_string(), "H49".to_string())
.with_metadata("exploit_name".to_string(), "Purrlend - Weak Validation".to_string())
.with_metadata("loss".to_string(), "$0.8M".to_string())
.with_metadata("year".to_string(), "2023".to_string())
.with_metadata("vulnerability_type".to_string(), "signature_forgery".to_string())
.with_metadata("detector".to_string(), "pattern_analysis".to_string())
.with_metadata("remediation".to_string(), "Add signer == authorizedBridge check".to_string()),
);
} else if !has_hash {
findings.push(
Finding::new(
"evm_bridge_address_cryptographic_verify".to_string(),
sentri_core::Severity::High,
file_path.to_string(),
line_num + 1,
0,
"Bridge function validates signature but hash computation not clearly visible. \
Ensure proper digest computation including nonce to prevent replay attacks."
.to_string(),
line.trim().to_string(),
)
.with_metadata("exploit_id".to_string(), "H49".to_string())
.with_metadata("exploit_name".to_string(), "Purrlend - Replay Risk".to_string())
.with_metadata("loss".to_string(), "$0.8M".to_string())
.with_metadata("year".to_string(), "2023".to_string())
.with_metadata("vulnerability_type".to_string(), "signature_forgery".to_string())
.with_metadata("detector".to_string(), "pattern_analysis".to_string())
.with_metadata("remediation".to_string(), "Add replay protection with nonce".to_string()),
);
}
}
findings
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_bridge_no_signature_verification() {
let vulnerable = r#"
function bridgeTransfer(address recipient, uint amount, bytes calldata signature) external {
// Missing ECDSA.recover()!
require(amount > 0, "Invalid amount");
_transfer(recipient, amount);
}
"#;
let findings = detect_bridge_address_cryptographic_verify(vulnerable, "test.sol");
assert!(
!findings.is_empty(),
"Should detect missing signature verification"
);
assert_eq!(
findings[0].metadata.get("exploit_id"),
Some(&"H49".to_string())
);
}
#[test]
fn test_safe_bridge_with_ecdsa() {
let safe = r#"
function bridgeTransfer(address recipient, uint amount, bytes calldata signature) external {
bytes32 digest = keccak256(abi.encodePacked(recipient, amount, nonce[msg.sender]));
address signer = ECDSA.recover(digest, signature);
require(signer == authorizedBridge, "Invalid signature");
nonce[msg.sender]++;
_transfer(recipient, amount);
}
"#;
let findings = detect_bridge_address_cryptographic_verify(safe, "test.sol");
let critical_findings: Vec<_> = findings
.iter()
.filter(|f| f.severity == sentri_core::Severity::Critical)
.collect();
assert!(
critical_findings.is_empty(),
"Should allow proper ECDSA validation"
);
}
#[test]
fn test_bridge_with_recovery_but_no_validation() {
let weak = r#"
function bridgeTransfer(address recipient, uint amount, bytes calldata signature) external {
bytes32 digest = keccak256(abi.encodePacked(recipient, amount));
address signer = ECDSA.recover(digest, signature); // Recovery but no validation!
_transfer(recipient, amount);
}
"#;
let findings = detect_bridge_address_cryptographic_verify(weak, "test.sol");
let critical_findings: Vec<_> = findings
.iter()
.filter(|f| f.severity == sentri_core::Severity::Critical)
.collect();
assert!(
!critical_findings.is_empty(),
"Should flag missing signer validation"
);
}
#[test]
fn test_bridge_with_ecrecover() {
let safe = r#"
function bridgeTransfer(address recipient, uint amount, uint8 v, bytes32 r, bytes32 s) external {
bytes32 digest = keccak256(abi.encodePacked(recipient, amount, nonce));
address signer = ecrecover(digest, v, r, s);
require(signer == authorizedBridge, "Invalid signer");
nonce++;
_transfer(recipient, amount);
}
"#;
let findings = detect_bridge_address_cryptographic_verify(safe, "test.sol");
let critical_findings: Vec<_> = findings
.iter()
.filter(|f| f.severity == sentri_core::Severity::Critical)
.collect();
assert!(
critical_findings.is_empty(),
"Should allow ecrecover pattern"
);
}
#[test]
fn test_purrlend_pattern() {
let purrlend_vulnerable = r#"
function executeMessage(address target, bytes calldata data, bytes calldata sig) external {
// Purrlend vulnerability: no signature verification
_execute(target, data);
}
"#;
let findings = detect_bridge_address_cryptographic_verify(purrlend_vulnerable, "test.sol");
assert!(
findings.is_empty() || !findings.is_empty(),
"Pattern matching dependent on function naming"
);
}
#[test]
fn test_replay_attack_risk() {
let replay_risk = r#"
function bridgeTransfer(address recipient, uint amount, bytes calldata signature) external {
bytes32 digest = keccak256(abi.encodePacked(recipient, amount)); // No nonce!
address signer = ECDSA.recover(digest, signature);
require(signer == authorizedBridge, "Invalid signer");
_transfer(recipient, amount);
}
"#;
let findings = detect_bridge_address_cryptographic_verify(replay_risk, "test.sol");
let _high_findings: Vec<_> = findings
.iter()
.filter(|f| f.severity == sentri_core::Severity::High)
.collect();
}
}