#![allow(dead_code)]
use crate::auth_context::{AuthSession, Authenticator, LoginCredentials};
use crate::crawler::CrawlResults;
use crate::headless_crawler::{HeadlessCrawler, HeadlessCrawlerConfig};
use crate::http_client::{HttpClient, HttpResponse};
use crate::types::{Confidence, Severity, Vulnerability};
use anyhow::{Context, Result};
use chrono::Utc;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Mutex, RwLock, Semaphore};
use tracing::{error, info, warn};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum PermissionLevel {
Guest = 0,
User = 1,
Moderator = 2,
Admin = 3,
SuperAdmin = 4,
}
impl Default for PermissionLevel {
fn default() -> Self {
PermissionLevel::Guest
}
}
impl std::fmt::Display for PermissionLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PermissionLevel::Guest => write!(f, "guest"),
PermissionLevel::User => write!(f, "user"),
PermissionLevel::Moderator => write!(f, "moderator"),
PermissionLevel::Admin => write!(f, "admin"),
PermissionLevel::SuperAdmin => write!(f, "superadmin"),
}
}
}
impl PermissionLevel {
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"guest" | "anonymous" | "unauthenticated" => PermissionLevel::Guest,
"user" | "basic" | "member" => PermissionLevel::User,
"moderator" | "mod" | "power" => PermissionLevel::Moderator,
"admin" | "administrator" => PermissionLevel::Admin,
"superadmin" | "super_admin" | "root" | "system" => PermissionLevel::SuperAdmin,
_ => PermissionLevel::User,
}
}
pub fn is_higher_than(&self, other: &PermissionLevel) -> bool {
(*self as u8) > (*other as u8)
}
pub fn is_lower_than(&self, other: &PermissionLevel) -> bool {
(*self as u8) < (*other as u8)
}
}
#[derive(Debug, Clone)]
pub struct UserRole {
pub name: String,
pub description: Option<String>,
pub credentials: LoginCredentials,
pub permission_level: PermissionLevel,
pub owned_resources: HashSet<String>,
pub user_identifier: Option<String>,
pub extra_headers: HashMap<String, String>,
}
impl UserRole {
pub fn new(name: &str, username: &str, password: &str) -> Self {
Self {
name: name.to_string(),
description: None,
credentials: LoginCredentials::new(username, password),
permission_level: PermissionLevel::User,
owned_resources: HashSet::new(),
user_identifier: Some(username.to_string()),
extra_headers: HashMap::new(),
}
}
pub fn guest() -> Self {
Self {
name: "guest".to_string(),
description: Some("Unauthenticated user".to_string()),
credentials: LoginCredentials::new("", ""),
permission_level: PermissionLevel::Guest,
owned_resources: HashSet::new(),
user_identifier: None,
extra_headers: HashMap::new(),
}
}
pub fn with_permission_level(mut self, level: PermissionLevel) -> Self {
self.permission_level = level;
self
}
pub fn with_description(mut self, desc: &str) -> Self {
self.description = Some(desc.to_string());
self
}
pub fn with_login_url(mut self, url: &str) -> Self {
self.credentials = self.credentials.with_login_url(url);
self
}
pub fn with_owned_resource(mut self, resource: &str) -> Self {
self.owned_resources.insert(resource.to_string());
self
}
pub fn with_owned_resources(mut self, resources: Vec<&str>) -> Self {
for resource in resources {
self.owned_resources.insert(resource.to_string());
}
self
}
pub fn with_user_identifier(mut self, id: &str) -> Self {
self.user_identifier = Some(id.to_string());
self
}
pub fn with_header(mut self, key: &str, value: &str) -> Self {
self.extra_headers
.insert(key.to_string(), value.to_string());
self
}
pub fn is_authenticated(&self) -> bool {
self.permission_level != PermissionLevel::Guest
}
}
#[derive(Debug, Clone)]
pub struct RoleSession {
pub role: UserRole,
pub auth_session: AuthSession,
pub is_active: bool,
pub created_at: std::time::Instant,
pub crawled_urls: HashSet<String>,
pub responses: HashMap<String, ResponseMetadata>,
}
#[derive(Debug, Clone)]
pub struct ResponseMetadata {
pub url: String,
pub status_code: u16,
pub content_length: usize,
pub content_type: Option<String>,
pub contains_user_data: bool,
pub response_hash: u64,
pub found_identifiers: HashSet<String>,
pub is_error: bool,
pub redirect_location: Option<String>,
}
impl ResponseMetadata {
pub fn from_response(response: &HttpResponse, url: &str) -> Self {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
response.body.hash(&mut hasher);
let response_hash = hasher.finish();
let content_type = response.headers.get("content-type").cloned();
let is_error = response.status_code >= 400;
let redirect_location = if response.status_code >= 300 && response.status_code < 400 {
response.headers.get("location").cloned()
} else {
None
};
Self {
url: url.to_string(),
status_code: response.status_code,
content_length: response.body.len(),
content_type,
contains_user_data: Self::detect_user_data(&response.body),
response_hash,
found_identifiers: Self::extract_identifiers(&response.body),
is_error,
redirect_location,
}
}
fn detect_user_data(body: &str) -> bool {
let patterns = [
"\"email\":",
"\"username\":",
"\"name\":",
"\"profile\":",
"\"account\":",
"\"user_id\":",
"\"userId\":",
"\"id\":",
"\"phone\":",
"\"address\":",
"\"balance\":",
"\"credit\":",
"\"ssn\":",
"\"password\":",
"\"token\":",
"\"apiKey\":",
];
patterns.iter().any(|p| body.contains(p))
}
fn extract_identifiers(body: &str) -> HashSet<String> {
let mut identifiers = HashSet::new();
let email_regex = regex::Regex::new(r#""[^"]*@[^"]+\.[^"]+""#).ok();
if let Some(re) = email_regex {
for cap in re.captures_iter(body) {
if let Some(m) = cap.get(0) {
identifiers.insert(m.as_str().trim_matches('"').to_string());
}
}
}
let id_regex = regex::Regex::new(r#""(?:id|user_id|userId|account_id)"\s*:\s*(\d+)"#).ok();
if let Some(re) = id_regex {
for cap in re.captures_iter(body) {
if let Some(m) = cap.get(1) {
identifiers.insert(format!("id:{}", m.as_str()));
}
}
}
let uuid_regex =
regex::Regex::new(r#"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"#)
.ok();
if let Some(re) = uuid_regex {
for cap in re.captures_iter(body) {
if let Some(m) = cap.get(0) {
identifiers.insert(format!("uuid:{}", m.as_str()));
}
}
}
identifiers
}
pub fn indicates_access(&self) -> bool {
self.status_code >= 200 && self.status_code < 300 && !self.is_error
}
pub fn indicates_forbidden(&self) -> bool {
self.status_code == 403 || self.status_code == 401
}
}
impl RoleSession {
pub fn new(role: UserRole, auth_session: AuthSession) -> Self {
Self {
role,
is_active: auth_session.is_authenticated,
auth_session,
created_at: std::time::Instant::now(),
crawled_urls: HashSet::new(),
responses: HashMap::new(),
}
}
pub fn guest() -> Self {
Self::new(UserRole::guest(), AuthSession::empty())
}
pub fn add_response(&mut self, url: &str, response: &HttpResponse) {
let metadata = ResponseMetadata::from_response(response, url);
self.responses.insert(url.to_string(), metadata);
self.crawled_urls.insert(url.to_string());
}
pub fn get_response(&self, url: &str) -> Option<&ResponseMetadata> {
self.responses.get(url)
}
pub fn has_accessed(&self, url: &str) -> bool {
self.crawled_urls.contains(url)
}
pub fn get_accessible_urls(&self) -> HashSet<String> {
self.responses
.iter()
.filter(|(_, meta)| meta.indicates_access())
.map(|(url, _)| url.clone())
.collect()
}
pub fn get_forbidden_urls(&self) -> HashSet<String> {
self.responses
.iter()
.filter(|(_, meta)| meta.indicates_forbidden())
.map(|(url, _)| url.clone())
.collect()
}
}
#[derive(Debug, Clone)]
pub struct RoleComparison {
pub role_a: String,
pub role_b: String,
pub level_a: PermissionLevel,
pub level_b: PermissionLevel,
pub exclusive_to_a: HashSet<String>,
pub exclusive_to_b: HashSet<String>,
pub shared_access: HashSet<String>,
pub vertical_escalations: Vec<EscalationFinding>,
pub horizontal_escalations: Vec<EscalationFinding>,
pub access_matrix: Vec<AccessMatrixEntry>,
}
#[derive(Debug, Clone)]
pub struct AccessMatrixEntry {
pub url: String,
pub role_access: HashMap<String, AccessResult>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccessResult {
Granted,
Denied,
Redirect(String),
Error(u16),
NotTested,
}
impl std::fmt::Display for AccessResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AccessResult::Granted => write!(f, "GRANTED"),
AccessResult::Denied => write!(f, "DENIED"),
AccessResult::Redirect(loc) => write!(f, "REDIRECT({})", loc),
AccessResult::Error(code) => write!(f, "ERROR({})", code),
AccessResult::NotTested => write!(f, "NOT_TESTED"),
}
}
}
#[derive(Debug, Clone)]
pub struct EscalationFinding {
pub escalation_type: EscalationType,
pub resource_url: String,
pub accessor_role: String,
pub owner_role: String,
pub confidence: Confidence,
pub evidence: String,
pub found_other_user_data: bool,
pub leaked_identifiers: HashSet<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EscalationType {
Vertical,
Horizontal,
Both,
}
impl std::fmt::Display for EscalationType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EscalationType::Vertical => write!(f, "Vertical"),
EscalationType::Horizontal => write!(f, "Horizontal"),
EscalationType::Both => write!(f, "Vertical+Horizontal"),
}
}
}
impl RoleComparison {
pub fn new(
role_a: &str,
level_a: PermissionLevel,
role_b: &str,
level_b: PermissionLevel,
) -> Self {
Self {
role_a: role_a.to_string(),
role_b: role_b.to_string(),
level_a,
level_b,
exclusive_to_a: HashSet::new(),
exclusive_to_b: HashSet::new(),
shared_access: HashSet::new(),
vertical_escalations: Vec::new(),
horizontal_escalations: Vec::new(),
access_matrix: Vec::new(),
}
}
pub fn has_findings(&self) -> bool {
!self.vertical_escalations.is_empty() || !self.horizontal_escalations.is_empty()
}
pub fn finding_count(&self) -> usize {
self.vertical_escalations.len() + self.horizontal_escalations.len()
}
pub fn to_vulnerabilities(&self) -> Vec<Vulnerability> {
let mut vulns = Vec::new();
for finding in &self.vertical_escalations {
vulns.push(self.escalation_to_vulnerability(finding));
}
for finding in &self.horizontal_escalations {
vulns.push(self.escalation_to_vulnerability(finding));
}
vulns
}
fn escalation_to_vulnerability(&self, finding: &EscalationFinding) -> Vulnerability {
let (vuln_type, description, cwe, cvss) = match finding.escalation_type {
EscalationType::Vertical => (
"Vertical Privilege Escalation".to_string(),
format!(
"Role '{}' (permission level: {}) can access resources belonging to higher-privileged role '{}'. \
This indicates missing authorization checks that allow lower-privileged users to access \
administrative or elevated functions.",
finding.accessor_role, self.level_a, finding.owner_role
),
"CWE-269".to_string(),
8.8,
),
EscalationType::Horizontal => (
"Horizontal Privilege Escalation (IDOR)".to_string(),
format!(
"Role '{}' can access resources belonging to another user '{}' at the same permission level. \
This indicates broken object-level authorization allowing users to access other users' data.",
finding.accessor_role, finding.owner_role
),
"CWE-639".to_string(),
8.1,
),
EscalationType::Both => (
"Multi-Vector Privilege Escalation".to_string(),
format!(
"Role '{}' can access resources belonging to '{}' through combined vertical and horizontal \
privilege escalation. This represents a severe authorization bypass.",
finding.accessor_role, finding.owner_role
),
"CWE-863".to_string(),
9.1,
),
};
let severity = if cvss >= 9.0 {
Severity::Critical
} else if cvss >= 7.0 {
Severity::High
} else {
Severity::Medium
};
let mut evidence_details = finding.evidence.clone();
if finding.found_other_user_data && !finding.leaked_identifiers.is_empty() {
evidence_details.push_str(&format!(
"\n\nLeaked identifiers found in response: {:?}",
finding.leaked_identifiers
));
}
Vulnerability {
id: format!("priv_esc_{}", uuid::Uuid::new_v4()),
vuln_type,
severity,
confidence: finding.confidence.clone(),
category: "Authorization".to_string(),
url: finding.resource_url.clone(),
parameter: None,
payload: String::new(),
description,
evidence: Some(evidence_details),
cwe,
cvss,
verified: true,
false_positive: false,
remediation: "1. Implement proper authorization checks at every endpoint\n\
2. Verify object ownership before returning data\n\
3. Use role-based access control (RBAC) or attribute-based access control (ABAC)\n\
4. Implement object-level authorization for all data access\n\
5. Use indirect references instead of direct object IDs\n\
6. Log and monitor for suspicious access patterns\n\
7. Implement the principle of least privilege"
.to_string(),
discovered_at: Utc::now().to_rfc3339(),
ml_data: None,
}
}
}
#[derive(Debug, Clone)]
pub struct MultiRoleConfig {
pub max_concurrent_browsers: usize,
pub browser_timeout_secs: u64,
pub max_urls_per_role: usize,
pub compare_response_content: bool,
pub mandatory_test_urls: Vec<String>,
pub skip_url_patterns: Vec<String>,
pub enable_headless_crawling: bool,
pub request_delay_ms: u64,
pub test_api_endpoints: bool,
}
impl Default for MultiRoleConfig {
fn default() -> Self {
Self {
max_concurrent_browsers: 4,
browser_timeout_secs: 60,
max_urls_per_role: 100,
compare_response_content: true,
mandatory_test_urls: Vec::new(),
skip_url_patterns: vec![
r"\.css$".to_string(),
r"\.js$".to_string(),
r"\.png$".to_string(),
r"\.jpg$".to_string(),
r"\.gif$".to_string(),
r"\.svg$".to_string(),
r"\.ico$".to_string(),
r"\.woff".to_string(),
r"/static/".to_string(),
r"/assets/".to_string(),
],
enable_headless_crawling: true,
request_delay_ms: 100,
test_api_endpoints: true,
}
}
}
pub struct MultiRoleOrchestrator {
config: MultiRoleConfig,
http_client: Arc<HttpClient>,
authenticator: Authenticator,
sessions: Arc<RwLock<HashMap<String, RoleSession>>>,
browser_semaphore: Arc<Semaphore>,
discovered_urls: Arc<Mutex<HashSet<String>>>,
discovered_api_endpoints: Arc<Mutex<HashSet<String>>>,
}
impl MultiRoleOrchestrator {
pub fn new(http_client: Arc<HttpClient>, config: MultiRoleConfig) -> Self {
let browser_semaphore = Arc::new(Semaphore::new(config.max_concurrent_browsers));
Self {
authenticator: Authenticator::new(config.browser_timeout_secs),
config,
http_client,
sessions: Arc::new(RwLock::new(HashMap::new())),
browser_semaphore,
discovered_urls: Arc::new(Mutex::new(HashSet::new())),
discovered_api_endpoints: Arc::new(Mutex::new(HashSet::new())),
}
}
pub async fn initialize_sessions(&self, base_url: &str, roles: Vec<UserRole>) -> Result<()> {
info!("[MultiRole] Initializing {} role sessions", roles.len());
let mut handles = Vec::new();
for role in roles {
let authenticator = Authenticator::new(self.config.browser_timeout_secs);
let base_url = base_url.to_string();
let sessions = self.sessions.clone();
let semaphore = self.browser_semaphore.clone();
let handle = tokio::spawn(async move {
let _permit = semaphore.acquire().await.ok();
let session = if role.permission_level == PermissionLevel::Guest {
info!("[MultiRole] Creating guest session (no login required)");
RoleSession::guest()
} else {
info!("[MultiRole] Authenticating role: {}", role.name);
match authenticator.login(&base_url, &role.credentials).await {
Ok(auth_session) => {
if auth_session.is_authenticated {
info!("[MultiRole] Successfully authenticated role: {}", role.name);
RoleSession::new(role.clone(), auth_session)
} else {
warn!("[MultiRole] Authentication failed for role: {}", role.name);
let mut session = RoleSession::new(role.clone(), auth_session);
session.is_active = false;
session
}
}
Err(e) => {
error!("[MultiRole] Login error for role {}: {}", role.name, e);
let mut session = RoleSession::new(role.clone(), AuthSession::empty());
session.is_active = false;
session
}
}
};
let role_name = session.role.name.clone();
sessions.write().await.insert(role_name, session);
});
handles.push(handle);
}
for handle in handles {
let _ = handle.await;
}
let active_count = self
.sessions
.read()
.await
.values()
.filter(|s| s.is_active)
.count();
info!(
"[MultiRole] Session initialization complete: {} active sessions",
active_count
);
Ok(())
}
pub async fn synchronized_crawl(
&self,
base_url: &str,
) -> Result<HashMap<String, CrawlResults>> {
info!("[MultiRole] Starting synchronized crawl from {}", base_url);
let sessions = self.sessions.read().await;
let active_roles: Vec<String> = sessions
.iter()
.filter(|(_, s)| s.is_active || s.role.permission_level == PermissionLevel::Guest)
.map(|(name, _)| name.clone())
.collect();
drop(sessions);
if active_roles.is_empty() {
warn!("[MultiRole] No active sessions for crawling");
return Ok(HashMap::new());
}
let mut results: HashMap<String, CrawlResults> = HashMap::new();
let mut urls_to_test: HashSet<String> = HashSet::new();
urls_to_test.insert(base_url.to_string());
urls_to_test.extend(self.config.mandatory_test_urls.iter().cloned());
let highest_role = self.get_highest_privileged_role().await;
if let Some(role_name) = highest_role {
info!(
"[MultiRole] Using '{}' role for initial URL discovery",
role_name
);
let discovered = self.crawl_for_role(&role_name, base_url).await?;
urls_to_test.extend(discovered.links.iter().cloned());
urls_to_test.extend(discovered.api_endpoints.iter().cloned());
{
let mut global_urls = self.discovered_urls.lock().await;
global_urls.extend(urls_to_test.iter().cloned());
}
results.insert(role_name, discovered);
}
let urls_to_test: Vec<String> = urls_to_test
.into_iter()
.filter(|url| !self.should_skip_url(url))
.take(self.config.max_urls_per_role)
.collect();
info!(
"[MultiRole] Testing {} URLs across {} roles",
urls_to_test.len(),
active_roles.len()
);
for role_name in &active_roles {
if results.contains_key(role_name) {
continue; }
let role_results = self.test_urls_for_role(role_name, &urls_to_test).await?;
results.insert(role_name.clone(), role_results);
}
self.update_sessions_with_results(&results).await;
info!("[MultiRole] Synchronized crawl complete");
Ok(results)
}
async fn get_highest_privileged_role(&self) -> Option<String> {
let sessions = self.sessions.read().await;
sessions
.iter()
.filter(|(_, s)| s.is_active)
.max_by_key(|(_, s)| s.role.permission_level as u8)
.map(|(name, _)| name.clone())
}
async fn crawl_for_role(&self, role_name: &str, base_url: &str) -> Result<CrawlResults> {
let sessions = self.sessions.read().await;
let session = sessions.get(role_name).context("Role session not found")?;
let mut results = CrawlResults::new();
results.crawled_urls.insert(base_url.to_string());
if self.config.enable_headless_crawling && session.is_active {
let _permit = self.browser_semaphore.acquire().await?;
let token = session.auth_session.find_jwt();
let mut headless_headers = session
.auth_session
.auth_headers()
.into_iter()
.collect::<HashMap<_, _>>();
headless_headers.extend(session.role.extra_headers.clone());
let crawler = HeadlessCrawler::with_headers_and_config(
self.config.browser_timeout_secs,
token,
headless_headers,
HeadlessCrawlerConfig {
max_pages: self.config.max_urls_per_role,
..Default::default()
},
);
if let Ok(forms) = crawler.extract_forms(base_url).await {
results.forms = forms;
}
}
let auth_headers = session.auth_session.auth_headers();
drop(sessions);
let response = self
.http_client
.get_with_headers(base_url, auth_headers)
.await?;
self.extract_links_from_response(&response.body, base_url, &mut results);
Ok(results)
}
async fn test_urls_for_role(&self, role_name: &str, urls: &[String]) -> Result<CrawlResults> {
let sessions = self.sessions.read().await;
let session = sessions.get(role_name).context("Role session not found")?;
let auth_headers = session.auth_session.auth_headers();
let extra_headers: Vec<(String, String)> = session
.role
.extra_headers
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
drop(sessions);
let mut results = CrawlResults::new();
let mut all_headers = auth_headers;
all_headers.extend(extra_headers);
for url in urls {
if let Ok(response) = self
.http_client
.get_with_headers(url, all_headers.clone())
.await
{
results.crawled_urls.insert(url.clone());
let mut sessions = self.sessions.write().await;
if let Some(session) = sessions.get_mut(role_name) {
session.add_response(url, &response);
}
self.extract_links_from_response(&response.body, url, &mut results);
}
if self.config.request_delay_ms > 0 {
tokio::time::sleep(Duration::from_millis(self.config.request_delay_ms)).await;
}
}
Ok(results)
}
fn extract_links_from_response(&self, body: &str, base_url: &str, results: &mut CrawlResults) {
let base = match url::Url::parse(base_url) {
Ok(u) => u,
Err(_) => return,
};
let href_regex = regex::Regex::new(r#"href=["']([^"']+)["']"#).ok();
if let Some(re) = href_regex {
for cap in re.captures_iter(body) {
if let Some(m) = cap.get(1) {
if let Ok(absolute) = base.join(m.as_str()) {
if absolute.host() == base.host() {
results.links.insert(absolute.to_string());
}
}
}
}
}
let api_regex = regex::Regex::new(r#"["'](/api/[^"']+)["']"#).ok();
if let Some(re) = api_regex {
for cap in re.captures_iter(body) {
if let Some(m) = cap.get(1) {
if let Ok(absolute) = base.join(m.as_str()) {
results.api_endpoints.insert(absolute.to_string());
}
}
}
}
}
async fn update_sessions_with_results(&self, results: &HashMap<String, CrawlResults>) {
let mut sessions = self.sessions.write().await;
for (role_name, crawl_results) in results {
if let Some(session) = sessions.get_mut(role_name) {
session
.crawled_urls
.extend(crawl_results.crawled_urls.iter().cloned());
}
}
}
fn should_skip_url(&self, url: &str) -> bool {
for pattern in &self.config.skip_url_patterns {
if let Ok(re) = regex::Regex::new(pattern) {
if re.is_match(url) {
return true;
}
}
}
false
}
pub async fn compare_all_roles(&self) -> Result<Vec<RoleComparison>> {
info!("[MultiRole] Comparing access patterns between roles");
let sessions = self.sessions.read().await;
let roles: Vec<&RoleSession> = sessions.values().collect();
if roles.len() < 2 {
warn!("[MultiRole] Need at least 2 roles for comparison");
return Ok(Vec::new());
}
let mut comparisons = Vec::new();
for i in 0..roles.len() {
for j in (i + 1)..roles.len() {
let role_a = &roles[i];
let role_b = &roles[j];
let comparison = self.compare_two_roles(role_a, role_b);
if comparison.has_findings() {
comparisons.push(comparison);
}
}
}
info!(
"[MultiRole] Role comparison complete: {} comparisons with findings",
comparisons.len()
);
Ok(comparisons)
}
fn compare_two_roles(&self, role_a: &RoleSession, role_b: &RoleSession) -> RoleComparison {
let mut comparison = RoleComparison::new(
&role_a.role.name,
role_a.role.permission_level,
&role_b.role.name,
role_b.role.permission_level,
);
let accessible_a = role_a.get_accessible_urls();
let accessible_b = role_b.get_accessible_urls();
comparison.exclusive_to_a = accessible_a.difference(&accessible_b).cloned().collect();
comparison.exclusive_to_b = accessible_b.difference(&accessible_a).cloned().collect();
comparison.shared_access = accessible_a.intersection(&accessible_b).cloned().collect();
let all_urls: HashSet<String> = role_a
.crawled_urls
.union(&role_b.crawled_urls)
.cloned()
.collect();
for url in &all_urls {
let mut entry = AccessMatrixEntry {
url: url.clone(),
role_access: HashMap::new(),
};
entry.role_access.insert(
role_a.role.name.clone(),
self.get_access_result(role_a.get_response(url)),
);
entry.role_access.insert(
role_b.role.name.clone(),
self.get_access_result(role_b.get_response(url)),
);
comparison.access_matrix.push(entry);
}
self.detect_vertical_escalation(&mut comparison, role_a, role_b);
self.detect_vertical_escalation(&mut comparison, role_b, role_a);
self.detect_horizontal_escalation(&mut comparison, role_a, role_b);
comparison
}
fn get_access_result(&self, metadata: Option<&ResponseMetadata>) -> AccessResult {
match metadata {
None => AccessResult::NotTested,
Some(meta) => {
if meta.indicates_access() {
AccessResult::Granted
} else if meta.indicates_forbidden() {
AccessResult::Denied
} else if let Some(ref loc) = meta.redirect_location {
AccessResult::Redirect(loc.clone())
} else {
AccessResult::Error(meta.status_code)
}
}
}
}
fn detect_vertical_escalation(
&self,
comparison: &mut RoleComparison,
lower_role: &RoleSession,
higher_role: &RoleSession,
) {
if !lower_role
.role
.permission_level
.is_lower_than(&higher_role.role.permission_level)
{
return;
}
let higher_accessible = higher_role.get_accessible_urls();
let higher_forbidden_for_lower: HashSet<String> = higher_role
.responses
.iter()
.filter(|(url, meta)| {
meta.indicates_access()
&& lower_role
.get_response(url)
.map(|m| m.indicates_forbidden())
.unwrap_or(true)
})
.map(|(url, _)| url.clone())
.collect();
for url in &higher_accessible {
if let Some(lower_meta) = lower_role.get_response(url) {
if lower_meta.indicates_access() {
let is_sensitive = self.is_sensitive_admin_endpoint(url);
if is_sensitive || self.config.compare_response_content {
let higher_meta = higher_role.get_response(url);
let content_matches = higher_meta.map(|h| {
h.response_hash == lower_meta.response_hash
|| h.content_length.abs_diff(lower_meta.content_length) < 100
});
if content_matches.unwrap_or(false) {
let confidence = if is_sensitive {
Confidence::High
} else {
Confidence::Medium
};
comparison.vertical_escalations.push(EscalationFinding {
escalation_type: EscalationType::Vertical,
resource_url: url.clone(),
accessor_role: lower_role.role.name.clone(),
owner_role: higher_role.role.name.clone(),
confidence,
evidence: format!(
"Role '{}' (level: {}) can access '{}' which appears to be \
restricted to '{}' (level: {}). Response similarity indicates \
same content is returned.",
lower_role.role.name,
lower_role.role.permission_level,
url,
higher_role.role.name,
higher_role.role.permission_level
),
found_other_user_data: false,
leaked_identifiers: HashSet::new(),
});
}
}
}
}
}
}
fn is_sensitive_admin_endpoint(&self, url: &str) -> bool {
let sensitive_patterns = [
"/admin",
"/dashboard",
"/manage",
"/settings",
"/config",
"/users",
"/roles",
"/permissions",
"/audit",
"/logs",
"/system",
"/internal",
"/debug",
"/api/admin",
"/api/v1/admin",
"/api/v2/admin",
];
let url_lower = url.to_lowercase();
sensitive_patterns.iter().any(|p| url_lower.contains(p))
}
fn detect_horizontal_escalation(
&self,
comparison: &mut RoleComparison,
role_a: &RoleSession,
role_b: &RoleSession,
) {
if role_a.role.permission_level != role_b.role.permission_level {
return;
}
for (url, meta_a) in &role_a.responses {
if !meta_a.indicates_access() || !meta_a.contains_user_data {
continue;
}
if let Some(user_id_b) = &role_b.role.user_identifier {
let contains_other_user_data = meta_a.found_identifiers.iter().any(|id| {
id.contains(user_id_b)
|| role_b.role.owned_resources.iter().any(|r| id.contains(r))
});
if contains_other_user_data {
if let Some(meta_b) = role_b.get_response(url) {
if meta_b.indicates_access() && meta_b.contains_user_data {
comparison.horizontal_escalations.push(EscalationFinding {
escalation_type: EscalationType::Horizontal,
resource_url: url.clone(),
accessor_role: role_a.role.name.clone(),
owner_role: role_b.role.name.clone(),
confidence: Confidence::High,
evidence: format!(
"Role '{}' response at '{}' contains identifiers belonging to \
role '{}'. This indicates broken object-level authorization \
allowing access to other users' data.",
role_a.role.name, url, role_b.role.name
),
found_other_user_data: true,
leaked_identifiers: meta_a.found_identifiers.clone(),
});
}
}
}
}
}
}
pub async fn generate_access_matrix(&self) -> AccessMatrix {
let sessions = self.sessions.read().await;
let roles: Vec<String> = sessions.keys().cloned().collect();
let all_urls: HashSet<String> = sessions
.values()
.flat_map(|s| s.crawled_urls.iter().cloned())
.collect();
let mut entries = Vec::new();
for url in &all_urls {
let mut role_access = HashMap::new();
for (role_name, session) in sessions.iter() {
let result = self.get_access_result(session.get_response(url));
role_access.insert(role_name.clone(), result);
}
entries.push(AccessMatrixEntry {
url: url.clone(),
role_access,
});
}
entries.sort_by(|a, b| a.url.cmp(&b.url));
AccessMatrix { roles, entries }
}
pub async fn analyze_authorization(&self, base_url: &str) -> Result<Vec<Vulnerability>> {
info!("[MultiRole] Starting full authorization analysis");
if !crate::license::verify_scan_authorized() {
info!("[SKIP] Multi-role authorization testing requires valid license");
return Ok(Vec::new());
}
let _crawl_results = self.synchronized_crawl(base_url).await?;
let comparisons = self.compare_all_roles().await?;
let mut vulnerabilities = Vec::new();
for comparison in comparisons {
vulnerabilities.extend(comparison.to_vulnerabilities());
}
info!(
"[MultiRole] Authorization analysis complete: {} vulnerabilities found",
vulnerabilities.len()
);
Ok(vulnerabilities)
}
pub async fn get_statistics(&self) -> MultiRoleStatistics {
let sessions = self.sessions.read().await;
let total_roles = sessions.len();
let active_roles = sessions.values().filter(|s| s.is_active).count();
let total_urls_tested: usize = sessions.values().map(|s| s.crawled_urls.len()).sum();
let unique_urls: HashSet<String> = sessions
.values()
.flat_map(|s| s.crawled_urls.iter().cloned())
.collect();
MultiRoleStatistics {
total_roles,
active_roles,
total_urls_tested,
unique_urls_tested: unique_urls.len(),
urls_per_role: sessions
.iter()
.map(|(name, s)| (name.clone(), s.crawled_urls.len()))
.collect(),
}
}
}
#[derive(Debug, Clone)]
pub struct AccessMatrix {
pub roles: Vec<String>,
pub entries: Vec<AccessMatrixEntry>,
}
impl AccessMatrix {
fn sanitize_csv_field(field: &str) -> String {
let field = field.replace(',', "%2C").replace('"', "\"\"");
if field.starts_with('=')
|| field.starts_with('+')
|| field.starts_with('-')
|| field.starts_with('@')
|| field.starts_with('\t')
|| field.starts_with('\r')
|| field.starts_with('\n')
{
format!("'{}", field)
} else {
field
}
}
pub fn to_csv(&self) -> String {
let mut csv = String::new();
csv.push_str("URL");
for role in &self.roles {
csv.push(',');
csv.push_str(&Self::sanitize_csv_field(role));
}
csv.push('\n');
for entry in &self.entries {
csv.push_str(&Self::sanitize_csv_field(&entry.url));
for role in &self.roles {
csv.push(',');
let result = entry
.role_access
.get(role)
.unwrap_or(&AccessResult::NotTested);
csv.push_str(&Self::sanitize_csv_field(&result.to_string()));
}
csv.push('\n');
}
csv
}
pub fn find_anomalies(&self) -> Vec<String> {
let mut anomalies = Vec::new();
for entry in &self.entries {
let granted_roles: Vec<&String> = entry
.role_access
.iter()
.filter(|(_, r)| **r == AccessResult::Granted)
.map(|(name, _)| name)
.collect();
let denied_roles: Vec<&String> = entry
.role_access
.iter()
.filter(|(_, r)| **r == AccessResult::Denied)
.map(|(name, _)| name)
.collect();
if granted_roles.iter().any(|r| r.to_lowercase() == "guest")
&& denied_roles.iter().any(|r| r.to_lowercase() != "guest")
{
anomalies.push(format!(
"Guest can access {} but some authenticated roles cannot",
entry.url
));
}
if !granted_roles.is_empty() && !denied_roles.is_empty() && granted_roles.len() > 1 {
anomalies.push(format!(
"Inconsistent access to {}: granted to {:?}, denied to {:?}",
entry.url, granted_roles, denied_roles
));
}
}
anomalies
}
}
#[derive(Debug, Clone)]
pub struct MultiRoleStatistics {
pub total_roles: usize,
pub active_roles: usize,
pub total_urls_tested: usize,
pub unique_urls_tested: usize,
pub urls_per_role: HashMap<String, usize>,
}
pub struct MultiRoleBuilder {
roles: Vec<UserRole>,
config: MultiRoleConfig,
}
impl MultiRoleBuilder {
pub fn new() -> Self {
Self {
roles: Vec::new(),
config: MultiRoleConfig::default(),
}
}
pub fn add_role(mut self, name: &str, username: &str, password: &str) -> Self {
self.roles.push(UserRole::new(name, username, password));
self
}
pub fn add_guest(mut self) -> Self {
self.roles.push(UserRole::guest());
self
}
pub fn add_admin(mut self, username: &str, password: &str) -> Self {
let role = UserRole::new("admin", username, password)
.with_permission_level(PermissionLevel::Admin)
.with_description("Administrator account");
self.roles.push(role);
self
}
pub fn add_custom_role(mut self, role: UserRole) -> Self {
self.roles.push(role);
self
}
pub fn max_browsers(mut self, count: usize) -> Self {
self.config.max_concurrent_browsers = count;
self
}
pub fn browser_timeout(mut self, secs: u64) -> Self {
self.config.browser_timeout_secs = secs;
self
}
pub fn max_urls(mut self, count: usize) -> Self {
self.config.max_urls_per_role = count;
self
}
pub fn test_urls(mut self, urls: Vec<String>) -> Self {
self.config.mandatory_test_urls = urls;
self
}
pub fn headless(mut self, enabled: bool) -> Self {
self.config.enable_headless_crawling = enabled;
self
}
pub fn request_delay(mut self, ms: u64) -> Self {
self.config.request_delay_ms = ms;
self
}
pub fn build(self, http_client: Arc<HttpClient>) -> (MultiRoleOrchestrator, Vec<UserRole>) {
(
MultiRoleOrchestrator::new(http_client, self.config),
self.roles,
)
}
}
impl Default for MultiRoleBuilder {
fn default() -> Self {
Self::new()
}
}
pub async fn compare_user_admin(
http_client: Arc<HttpClient>,
base_url: &str,
user_creds: (&str, &str),
admin_creds: (&str, &str),
) -> Result<Vec<Vulnerability>> {
let (orchestrator, roles) = MultiRoleBuilder::new()
.add_role("user", user_creds.0, user_creds.1)
.add_admin(admin_creds.0, admin_creds.1)
.build(http_client);
orchestrator.initialize_sessions(base_url, roles).await?;
orchestrator.analyze_authorization(base_url).await
}
pub async fn compare_guest_user_admin(
http_client: Arc<HttpClient>,
base_url: &str,
user_creds: (&str, &str),
admin_creds: (&str, &str),
) -> Result<Vec<Vulnerability>> {
let (orchestrator, roles) = MultiRoleBuilder::new()
.add_guest()
.add_role("user", user_creds.0, user_creds.1)
.add_admin(admin_creds.0, admin_creds.1)
.build(http_client);
orchestrator.initialize_sessions(base_url, roles).await?;
orchestrator.analyze_authorization(base_url).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_permission_level_ordering() {
assert!(PermissionLevel::Admin.is_higher_than(&PermissionLevel::User));
assert!(PermissionLevel::User.is_higher_than(&PermissionLevel::Guest));
assert!(PermissionLevel::SuperAdmin.is_higher_than(&PermissionLevel::Admin));
assert!(PermissionLevel::Guest.is_lower_than(&PermissionLevel::User));
assert!(!PermissionLevel::User.is_higher_than(&PermissionLevel::Admin));
}
#[test]
fn test_permission_level_from_str() {
assert_eq!(PermissionLevel::from_str("guest"), PermissionLevel::Guest);
assert_eq!(PermissionLevel::from_str("ADMIN"), PermissionLevel::Admin);
assert_eq!(
PermissionLevel::from_str("superadmin"),
PermissionLevel::SuperAdmin
);
assert_eq!(PermissionLevel::from_str("unknown"), PermissionLevel::User);
}
#[test]
fn test_user_role_creation() {
let role = UserRole::new("test_user", "user@example.com", "password123")
.with_permission_level(PermissionLevel::User)
.with_description("Test user account")
.with_owned_resource("/api/users/123");
assert_eq!(role.name, "test_user");
assert_eq!(role.permission_level, PermissionLevel::User);
assert!(role.owned_resources.contains("/api/users/123"));
assert!(role.is_authenticated());
}
#[test]
fn test_guest_role() {
let guest = UserRole::guest();
assert_eq!(guest.permission_level, PermissionLevel::Guest);
assert!(!guest.is_authenticated());
}
#[test]
fn test_response_metadata_user_data_detection() {
let body_with_user_data = r#"{"email": "user@example.com", "name": "John"}"#;
let body_without_user_data = r#"{"status": "ok"}"#;
assert!(ResponseMetadata::detect_user_data(body_with_user_data));
assert!(!ResponseMetadata::detect_user_data(body_without_user_data));
}
#[test]
fn test_response_metadata_identifier_extraction() {
let body = r#"{"userId": 12345, "email": "test@example.com", "uuid": "550e8400-e29b-41d4-a716-446655440000"}"#;
let identifiers = ResponseMetadata::extract_identifiers(body);
assert!(identifiers.iter().any(|id| id.contains("12345")));
assert!(identifiers.iter().any(|id| id.contains("test@example.com")));
assert!(identifiers.iter().any(|id| id.contains("550e8400")));
}
#[test]
fn test_access_result_display() {
assert_eq!(format!("{}", AccessResult::Granted), "GRANTED");
assert_eq!(format!("{}", AccessResult::Denied), "DENIED");
assert_eq!(
format!("{}", AccessResult::Redirect("/login".to_string())),
"REDIRECT(/login)"
);
assert_eq!(format!("{}", AccessResult::Error(500)), "ERROR(500)");
}
#[test]
fn test_role_comparison_creation() {
let comparison = RoleComparison::new(
"user",
PermissionLevel::User,
"admin",
PermissionLevel::Admin,
);
assert_eq!(comparison.role_a, "user");
assert_eq!(comparison.role_b, "admin");
assert!(!comparison.has_findings());
}
#[test]
fn test_multi_role_builder() {
let builder = MultiRoleBuilder::new()
.add_guest()
.add_role("user", "user@test.com", "pass123")
.add_admin("admin@test.com", "adminpass")
.max_browsers(2)
.max_urls(50);
assert_eq!(builder.roles.len(), 3);
assert_eq!(builder.config.max_concurrent_browsers, 2);
assert_eq!(builder.config.max_urls_per_role, 50);
}
#[test]
fn test_access_matrix_csv_export() {
let matrix = AccessMatrix {
roles: vec!["user".to_string(), "admin".to_string()],
entries: vec![
AccessMatrixEntry {
url: "/api/users".to_string(),
role_access: {
let mut map = HashMap::new();
map.insert("user".to_string(), AccessResult::Granted);
map.insert("admin".to_string(), AccessResult::Granted);
map
},
},
AccessMatrixEntry {
url: "/api/admin".to_string(),
role_access: {
let mut map = HashMap::new();
map.insert("user".to_string(), AccessResult::Denied);
map.insert("admin".to_string(), AccessResult::Granted);
map
},
},
],
};
let csv = matrix.to_csv();
assert!(csv.contains("URL,user,admin"));
assert!(csv.contains("/api/users,GRANTED,GRANTED"));
assert!(csv.contains("/api/admin,DENIED,GRANTED"));
}
#[test]
fn test_escalation_type_display() {
assert_eq!(format!("{}", EscalationType::Vertical), "Vertical");
assert_eq!(format!("{}", EscalationType::Horizontal), "Horizontal");
assert_eq!(format!("{}", EscalationType::Both), "Vertical+Horizontal");
}
#[test]
fn test_sensitive_endpoint_detection() {
let config = MultiRoleConfig::default();
let http_client = Arc::new(HttpClient::new(30, 3).unwrap());
let orchestrator = MultiRoleOrchestrator::new(http_client, config);
assert!(orchestrator.is_sensitive_admin_endpoint("/admin/users"));
assert!(orchestrator.is_sensitive_admin_endpoint("/api/admin/settings"));
assert!(orchestrator.is_sensitive_admin_endpoint("/dashboard"));
assert!(!orchestrator.is_sensitive_admin_endpoint("/api/public/data"));
assert!(!orchestrator.is_sensitive_admin_endpoint("/home"));
}
#[test]
fn test_url_skip_patterns() {
let config = MultiRoleConfig::default();
let http_client = Arc::new(HttpClient::new(30, 3).unwrap());
let orchestrator = MultiRoleOrchestrator::new(http_client, config);
assert!(orchestrator.should_skip_url("https://example.com/style.css"));
assert!(orchestrator.should_skip_url("https://example.com/app.js"));
assert!(orchestrator.should_skip_url("https://example.com/static/image.png"));
assert!(!orchestrator.should_skip_url("https://example.com/api/users"));
assert!(!orchestrator.should_skip_url("https://example.com/login"));
}
}