diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 987ea5e..8cc3feb 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -16,7 +16,9 @@ use ocean::{ storage::{EvidenceQuery, SqliteStore, Store}, }; -use output::{print_evaluation_table, print_output, EvaluationResult, ModuleRunResult, OutputFormat}; +use output::{ + print_evaluation_table, print_output, EvaluationResult, ModuleRunResult, OutputFormat, +}; // --------------------------------------------------------------------------- // CLI structure @@ -280,9 +282,9 @@ pub fn run() -> Result<()> { } => { if target.is_some() || control.is_some() { let t = target.as_deref().unwrap_or("*"); - let p = control.as_deref().ok_or_else(|| { - anyhow!("--control/-c is required when using --target/-t") - })?; + let p = control + .as_deref() + .ok_or_else(|| anyhow!("--control/-c is required when using --target/-t"))?; cmd_observe_path(&mut out, format, &cli.db, t, p, &controls_dir, !no_store) } else if let Some(m) = module.as_deref() { cmd_observe(&mut out, format, &cli.db, m, !no_store) @@ -302,10 +304,19 @@ pub fn run() -> Result<()> { } => { if target.is_some() || control.is_some() { let t = target.as_deref().unwrap_or("*"); - let p = control.as_deref().ok_or_else(|| { - anyhow!("--control/-c is required when using --target/-t") - })?; - cmd_test_path(&mut out, format, &cli.db, t, p, &env, &controls_dir, !no_store) + let p = control + .as_deref() + .ok_or_else(|| anyhow!("--control/-c is required when using --target/-t"))?; + cmd_test_path( + &mut out, + format, + &cli.db, + t, + p, + &env, + &controls_dir, + !no_store, + ) } else if let Some(m) = module.as_deref() { cmd_test(&mut out, format, &cli.db, m, &env, !no_store) } else { @@ -329,12 +340,19 @@ pub fn run() -> Result<()> { } => { if target.is_some() || control_path.is_some() { let t = target.as_deref().unwrap_or("*"); - let p = control_path.as_deref().ok_or_else(|| { - anyhow!("--control/-c is required when using --target/-t") - })?; + let p = control_path + .as_deref() + .ok_or_else(|| anyhow!("--control/-c is required when using --target/-t"))?; cmd_evaluate_path(&mut out, format, &cli.db, t, p, &controls_dir) } else if let Some(ctrl) = control.as_deref() { - cmd_evaluate(&mut out, format, &cli.db, ctrl, cel.as_deref(), &controls_dir) + cmd_evaluate( + &mut out, + format, + &cli.db, + ctrl, + cel.as_deref(), + &controls_dir, + ) } else { Err(anyhow!( "Specify a control ID or use --target/-t and --control/-c" @@ -696,9 +714,7 @@ fn target_matches_module(target: &str, module_id: &str) -> bool { fn resolve_controls(controls_dir: &str, path: &str) -> Result> { let dir = std::path::Path::new(controls_dir); if !dir.exists() { - return Err(anyhow!( - "controls directory not found: '{controls_dir}'" - )); + return Err(anyhow!("controls directory not found: '{controls_dir}'")); } let mut all: Vec = Vec::new(); diff --git a/src/control/composite.rs b/src/control/composite.rs index 81ccf38..7785901 100644 --- a/src/control/composite.rs +++ b/src/control/composite.rs @@ -71,8 +71,10 @@ pub fn evaluate_composite_with_components( for component in components { let key = (component.evidence_class, component.activity_id); - let evidence_list: &[Evidence] = - evidence_by_class.get(&key).map(|v| v.as_slice()).unwrap_or(&[]); + let evidence_list: &[Evidence] = evidence_by_class + .get(&key) + .map(|v| v.as_slice()) + .unwrap_or(&[]); // Is this component effective? let component_effective = evidence_list @@ -160,8 +162,7 @@ fn evaluate_assertion( } } CrossCheckAssertion::SupersetOf => { - let missing: Vec<&String> = - referenced.iter().filter(|v| !local.contains(*v)).collect(); + let missing: Vec<&String> = referenced.iter().filter(|v| !local.contains(*v)).collect(); if missing.is_empty() { (true, format!("Local values cover all of '{uses}' export")) } else { @@ -177,7 +178,10 @@ fn evaluate_assertion( CrossCheckAssertion::ContainsAny => { let has_overlap = local.iter().any(|v| referenced.contains(v)); if has_overlap { - (true, format!("At least one local value found in '{uses}' export")) + ( + true, + format!("At least one local value found in '{uses}' export"), + ) } else { ( false, @@ -465,12 +469,8 @@ mod tests { #[test] fn cross_check_subset_of_fails_when_extra_ip() { // Firewall allows an IP not in WAF egress — cross-check fails. - let waf_ev = make_evidence_with_observables( - 3002, - 1, - true, - vec![obs("ip_range", "10.0.0.1")], - ); + let waf_ev = + make_evidence_with_observables(3002, 1, true, vec![obs("ip_range", "10.0.0.1")]); let fw_ev = make_evidence_with_observables( 3001, 1, @@ -519,12 +519,8 @@ mod tests { #[test] fn cross_check_nonempty_passes() { - let waf_ev = make_evidence_with_observables( - 3002, - 1, - true, - vec![obs("ip_range", "10.0.0.1")], - ); + let waf_ev = + make_evidence_with_observables(3002, 1, true, vec![obs("ip_range", "10.0.0.1")]); let mut map = HashMap::new(); map.insert((3002, Some(1)), vec![waf_ev]); @@ -605,14 +601,13 @@ mod tests { 3002, 1, true, - vec![obs("domain", "example.com"), obs("domain", "cdn.example.com")], - ); - let local_ev = make_evidence_with_observables( - 3001, - 1, - true, - vec![obs("domain", "cdn.example.com")], + vec![ + obs("domain", "example.com"), + obs("domain", "cdn.example.com"), + ], ); + let local_ev = + make_evidence_with_observables(3001, 1, true, vec![obs("domain", "cdn.example.com")]); let mut map = HashMap::new(); map.insert((3002, Some(1)), vec![export_ev]); map.insert((3001, Some(1)), vec![local_ev]); @@ -651,12 +646,8 @@ mod tests { #[test] fn cross_check_superset_of_passes() { - let export_ev = make_evidence_with_observables( - 3002, - 1, - true, - vec![obs("ip_range", "10.0.0.1")], - ); + let export_ev = + make_evidence_with_observables(3002, 1, true, vec![obs("ip_range", "10.0.0.1")]); let local_ev = make_evidence_with_observables( 3001, 1, diff --git a/src/control/mod.rs b/src/control/mod.rs index 4492a71..f93ca2c 100644 --- a/src/control/mod.rs +++ b/src/control/mod.rs @@ -4,7 +4,9 @@ pub mod definition; pub mod evaluator; pub mod framework; -pub use composite::{evaluate_composite, evaluate_composite_with_components, ComponentResult, CrossCheckResult}; +pub use composite::{ + evaluate_composite, evaluate_composite_with_components, ComponentResult, CrossCheckResult, +}; pub use definition::{ ComponentSpec, Control, ControlStatus, CrossCheck, CrossCheckAssertion, EvaluationLogic, ExportSpec, FrameworkMapping, ModuleRef, UptimeResult, diff --git a/src/dashboard/data.rs b/src/dashboard/data.rs index bf6c588..287f0c8 100644 --- a/src/dashboard/data.rs +++ b/src/dashboard/data.rs @@ -152,7 +152,11 @@ fn visit_yaml_files(dir: &std::path::Path, controls: &mut Vec) -> Resul continue; } visit_yaml_files(&path, controls)?; - } else if path.extension().map(|e| e == "yaml" || e == "yml").unwrap_or(false) { + } else if path + .extension() + .map(|e| e == "yaml" || e == "yml") + .unwrap_or(false) + { let content = std::fs::read_to_string(&path)?; match Control::load_yaml(&content) { Ok(control) => controls.push(control), @@ -214,10 +218,19 @@ mod tests { match controls { Ok(c) => { // We know there are at least 4 control files - assert!(c.len() >= 2, "expected at least 2 controls, got {}", c.len()); + assert!( + c.len() >= 2, + "expected at least 2 controls, got {}", + c.len() + ); // Should be sorted by ID for w in c.windows(2) { - assert!(w[0].id <= w[1].id, "controls not sorted: {} > {}", w[0].id, w[1].id); + assert!( + w[0].id <= w[1].id, + "controls not sorted: {} > {}", + w[0].id, + w[1].id + ); } } Err(_) => { diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs index 1763fa7..988e99c 100644 --- a/src/dashboard/mod.rs +++ b/src/dashboard/mod.rs @@ -230,10 +230,7 @@ mod tests { #[test] fn next_wraps_around() { let mut app = App::new(); - app.controls = vec![ - data::ControlRow::empty("a"), - data::ControlRow::empty("b"), - ]; + app.controls = vec![data::ControlRow::empty("a"), data::ControlRow::empty("b")]; app.next(); assert_eq!(app.selected, 1); app.next(); @@ -243,10 +240,7 @@ mod tests { #[test] fn previous_wraps_around() { let mut app = App::new(); - app.controls = vec![ - data::ControlRow::empty("a"), - data::ControlRow::empty("b"), - ]; + app.controls = vec![data::ControlRow::empty("a"), data::ControlRow::empty("b")]; app.previous(); assert_eq!(app.selected, 1); // wrapped from 0 } @@ -325,10 +319,7 @@ mod tests { #[test] fn handle_key_jk_navigate() { let mut app = App::new(); - app.controls = vec![ - data::ControlRow::empty("a"), - data::ControlRow::empty("b"), - ]; + app.controls = vec![data::ControlRow::empty("a"), data::ControlRow::empty("b")]; app.handle_key(key(KeyCode::Char('j'))); assert_eq!(app.selected, 1); app.handle_key(key(KeyCode::Char('k'))); diff --git a/src/dashboard/ui.rs b/src/dashboard/ui.rs index 84ac83d..40eafdc 100644 --- a/src/dashboard/ui.rs +++ b/src/dashboard/ui.rs @@ -21,7 +21,7 @@ fn render_main(frame: &mut Frame, app: &App) { .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // header - Constraint::Min(5), // table + Constraint::Min(5), // table Constraint::Length(3), // footer ]) .split(area); @@ -34,7 +34,11 @@ fn render_main(frame: &mut Frame, app: &App) { app.controls.len(), ); let header = Paragraph::new(header_text) - .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) .block(Block::default().borders(Borders::BOTTOM)); frame.render_widget(header, chunks[0]); @@ -111,8 +115,8 @@ fn render_detail(frame: &mut Frame, app: &App, idx: usize) { let row = match app.controls.get(idx) { Some(r) => r, None => { - let msg = Paragraph::new("No control data available.") - .style(Style::default().fg(Color::Red)); + let msg = + Paragraph::new("No control data available.").style(Style::default().fg(Color::Red)); frame.render_widget(msg, area); return; } @@ -123,7 +127,7 @@ fn render_detail(frame: &mut Frame, app: &App, idx: usize) { .constraints([ Constraint::Length(3), // header Constraint::Length(5), // status summary - Constraint::Min(8), // evidence + transcript + Constraint::Min(8), // evidence + transcript Constraint::Length(3), // footer ]) .split(area); @@ -131,7 +135,11 @@ fn render_detail(frame: &mut Frame, app: &App, idx: usize) { // Header let header_text = format!(" {} — {}", row.control.id, row.control.name); let header = Paragraph::new(header_text) - .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) .block(Block::default().borders(Borders::BOTTOM)); frame.render_widget(header, chunks[0]); @@ -152,7 +160,9 @@ fn render_detail(frame: &mut Frame, app: &App, idx: usize) { Span::raw(" Status: "), Span::styled( row.status_text().to_uppercase(), - Style::default().fg(status_color).add_modifier(Modifier::BOLD), + Style::default() + .fg(status_color) + .add_modifier(Modifier::BOLD), ), Span::raw(" Confidence: "), Span::raw(row.confidence_text()), @@ -167,12 +177,10 @@ fn render_detail(frame: &mut Frame, app: &App, idx: usize) { // Evidence timeline + transcript let mut lines: Vec = Vec::new(); - lines.push(Line::from( - Span::styled( - " Evidence Timeline:", - Style::default().add_modifier(Modifier::BOLD), - ), - )); + lines.push(Line::from(Span::styled( + " Evidence Timeline:", + Style::default().add_modifier(Modifier::BOLD), + ))); if row.evidence.is_empty() { lines.push(Line::from(" No evidence records found.")); @@ -200,19 +208,14 @@ fn render_detail(frame: &mut Frame, app: &App, idx: usize) { } // Test transcripts - let has_transcript = row - .evidence - .iter() - .any(|e| e.test_transcript.is_some()); + let has_transcript = row.evidence.iter().any(|e| e.test_transcript.is_some()); if has_transcript { lines.push(Line::from("")); - lines.push(Line::from( - Span::styled( - " Test Transcripts:", - Style::default().add_modifier(Modifier::BOLD), - ), - )); + lines.push(Line::from(Span::styled( + " Test Transcripts:", + Style::default().add_modifier(Modifier::BOLD), + ))); for ev in &row.evidence { if let Some(ref transcript) = ev.test_transcript { lines.push(Line::from(format!( @@ -245,10 +248,7 @@ fn render_detail(frame: &mut Frame, app: &App, idx: usize) { } // Apply scroll offset - let visible_lines: Vec = lines - .into_iter() - .skip(app.scroll_offset) - .collect(); + let visible_lines: Vec = lines.into_iter().skip(app.scroll_offset).collect(); let evidence_section = Paragraph::new(visible_lines) .block(Block::default().borders(Borders::TOP).title(" Evidence ")); diff --git a/src/lib.rs b/src/lib.rs index 2d08384..a684d62 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,7 +15,7 @@ pub mod secrets; pub mod storage; pub use evidence::{ConfidenceLevel, Evidence, StatusId}; -pub use module::{Observer, Module, Registry, Tester}; +pub use module::{Module, Observer, Registry, Tester}; #[cfg(test)] pub mod testutil; diff --git a/src/module/mod.rs b/src/module/mod.rs index 0e28b29..a4e709a 100644 --- a/src/module/mod.rs +++ b/src/module/mod.rs @@ -1,14 +1,14 @@ // Module system — pluggable observers and testers. -pub mod observer; pub mod executor; +pub mod observer; pub mod registry; pub mod safety; pub mod tester; pub mod validation; -pub use observer::Observer; pub use executor::{Executor, TestConfig}; +pub use observer::Observer; pub use registry::{ModuleInfo, Registry}; pub use safety::{ AuthorizationLevel, Authorizer, AutoAuthorizer, EnvironmentScope, SafetyClassification, diff --git a/src/module/registry.rs b/src/module/registry.rs index f5bcdd9..4675491 100644 --- a/src/module/registry.rs +++ b/src/module/registry.rs @@ -3,7 +3,7 @@ use std::sync::{Arc, RwLock}; use anyhow::{anyhow, Result}; -use super::{Observer, Module, Tester}; +use super::{Module, Observer, Tester}; /// Metadata about a registered module, suitable for CLI listings and API responses. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] diff --git a/src/modules/observers/azure.rs b/src/modules/observers/azure.rs new file mode 100644 index 0000000..de639d6 --- /dev/null +++ b/src/modules/observers/azure.rs @@ -0,0 +1,805 @@ +use std::collections::HashMap; + +use anyhow::{anyhow, Result}; +use chrono::Utc; +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::evidence::{ + ConfidenceLevel, Evidence, Finding, Metadata, ModuleInfo, Observable, SourceInfo, StatusId, +}; +use crate::module::{observer::Observer, CredentialReq, Module}; + +// ─── Microsoft Graph HTTP helpers ──────────────────────────────────────────── + +/// Performs an authenticated GET to the Microsoft Graph API. +/// `base_url` defaults to `https://graph.microsoft.com` but can be overridden +/// via `AZURE_BASE_URL` for testing with a mock server. +fn graph_get(token: &str, base_url: &str, path: &str) -> Result<(Value, u16)> { + let url = format!("{}{}", base_url.trim_end_matches('/'), path); + let resp = ureq::get(&url) + .set("Authorization", &format!("Bearer {}", token)) + .set("Accept", "application/json") + .call(); + + match resp { + Ok(r) => { + let status = r.status(); + let body: Value = r + .into_json() + .map_err(|e| anyhow!("parsing Graph JSON: {}", e))?; + Ok((body, status)) + } + Err(ureq::Error::Status(code, r)) => { + let body: Value = r + .into_json() + .unwrap_or_else(|_| json!({"error": {"code": "unknown", "message": "error"}})); + Ok((body, code)) + } + Err(e) => Err(anyhow!("Graph API request failed: {}", e)), + } +} + +/// Obtains an OAuth2 access token via client credentials flow. +/// `login_base` defaults to `https://login.microsoftonline.com` but can be +/// overridden via `AZURE_LOGIN_BASE` for testing. +fn get_access_token( + tenant_id: &str, + client_id: &str, + client_secret: &str, + login_base: &str, +) -> Result { + let url = format!( + "{}/{}/oauth2/v2.0/token", + login_base.trim_end_matches('/'), + tenant_id + ); + + let resp = ureq::post(&url) + .set("Content-Type", "application/x-www-form-urlencoded") + .send_string(&format!( + "grant_type=client_credentials&client_id={}&client_secret={}&scope=https%3A%2F%2Fgraph.microsoft.com%2F.default", + client_id, client_secret + )); + + match resp { + Ok(r) => { + let body: Value = r + .into_json() + .map_err(|e| anyhow!("parsing token JSON: {}", e))?; + body.get("access_token") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| anyhow!("no access_token in token response")) + } + Err(ureq::Error::Status(code, _)) => { + Err(anyhow!("token request failed with HTTP {}", code)) + } + Err(e) => Err(anyhow!("token request failed: {}", e)), + } +} + +/// Fetches all pages of a paginated Graph API collection. +fn graph_get_all_pages(token: &str, base_url: &str, path: &str) -> Result> { + let mut all_items: Vec = Vec::new(); + let (body, status) = graph_get(token, base_url, path)?; + + if status != 200 { + return Err(anyhow!( + "Graph API returned status {} querying {}", + status, + path + )); + } + + if let Some(items) = body.get("value").and_then(|v| v.as_array()) { + all_items.extend(items.iter().cloned()); + } + + // Follow @odata.nextLink for pagination. + let mut next_link = body + .get("@odata.nextLink") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + while let Some(link) = next_link.take() { + // nextLink is a full URL; strip base_url prefix to get the path. + let next_path = if link.starts_with(base_url.trim_end_matches('/')) { + link[base_url.trim_end_matches('/').len()..].to_string() + } else { + // For mock servers, the nextLink may be a full URL already. + link.clone() + }; + + let (page_body, page_status) = if next_path.starts_with("http") { + // Full URL — call directly. + let resp = ureq::get(&next_path) + .set("Authorization", &format!("Bearer {}", token)) + .set("Accept", "application/json") + .call(); + match resp { + Ok(r) => { + let s = r.status(); + let b: Value = r.into_json().map_err(|e| anyhow!("parsing JSON: {}", e))?; + (b, s) + } + Err(ureq::Error::Status(code, r)) => { + let b: Value = r.into_json().unwrap_or(json!({})); + (b, code) + } + Err(e) => return Err(anyhow!("pagination request failed: {}", e)), + } + } else { + graph_get(token, base_url, &next_path)? + }; + + if page_status != 200 { + break; + } + + if let Some(items) = page_body.get("value").and_then(|v| v.as_array()) { + all_items.extend(items.iter().cloned()); + } + + next_link = page_body + .get("@odata.nextLink") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + + Ok(all_items) +} + +// ─── ConditionalAccessObserver ─────────────────────────────────────────────── + +/// Queries Microsoft Graph API for Azure AD / Entra ID Conditional Access +/// policies and normalizes them into OCEAN evidence. Generates findings for +/// disabled policies or policies without MFA grant controls. +/// +/// Required config: `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`. +/// Optional: `AZURE_BASE_URL` (Graph API override), `AZURE_LOGIN_BASE` (login override). +pub struct ConditionalAccessObserver; + +impl Module for ConditionalAccessObserver { + fn id(&self) -> &str { + "azure.conditional_access" + } + fn name(&self) -> &str { + "Azure AD Conditional Access Observer" + } + fn version(&self) -> &str { + "0.1.0" + } + fn source_system(&self) -> &str { + "azure_ad" + } + fn evidence_types(&self) -> &[i32] { + &[1001] + } + + fn credential_requirements(&self) -> Vec { + vec![ + CredentialReq { + name: "AZURE_TENANT_ID".to_string(), + cred_type: "tenant_id".to_string(), + description: "Azure AD tenant ID".to_string(), + required: true, + }, + CredentialReq { + name: "AZURE_CLIENT_ID".to_string(), + cred_type: "client_id".to_string(), + description: "App registration client ID".to_string(), + required: true, + }, + CredentialReq { + name: "AZURE_CLIENT_SECRET".to_string(), + cred_type: "client_secret".to_string(), + description: "App registration client secret".to_string(), + required: true, + }, + ] + } +} + +impl Observer for ConditionalAccessObserver { + fn observe(&self, config: &HashMap) -> Result> { + let tenant_id = config + .get("AZURE_TENANT_ID") + .ok_or_else(|| anyhow!("AZURE_TENANT_ID is required"))?; + let client_id = config + .get("AZURE_CLIENT_ID") + .ok_or_else(|| anyhow!("AZURE_CLIENT_ID is required"))?; + let client_secret = config + .get("AZURE_CLIENT_SECRET") + .ok_or_else(|| anyhow!("AZURE_CLIENT_SECRET is required"))?; + + let graph_base = config + .get("AZURE_BASE_URL") + .cloned() + .unwrap_or_else(|| "https://graph.microsoft.com".to_string()); + let login_base = config + .get("AZURE_LOGIN_BASE") + .cloned() + .unwrap_or_else(|| "https://login.microsoftonline.com".to_string()); + + let now = Utc::now(); + let path = "/v1.0/identity/conditionalAccess/policies"; + let endpoint = format!("{}{}", graph_base.trim_end_matches('/'), path); + + // Obtain access token. + let token = get_access_token(tenant_id, client_id, client_secret, &login_base)?; + + // Fetch all CA policies (with pagination). + let policies = graph_get_all_pages(&token, &graph_base, path)?; + + let mut findings: Vec = Vec::new(); + let mut observables: Vec = Vec::new(); + let mut disabled_count = 0usize; + let mut policies_without_mfa = 0usize; + let mut mfa_policy_count = 0usize; + let mut device_compliance_count = 0usize; + let mut sign_in_risk_count = 0usize; + + for policy in &policies { + let display_name = policy + .get("displayName") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let state = policy + .get("state") + .and_then(|v| v.as_str()) + .unwrap_or("disabled"); + let policy_id = policy + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + + observables.push(Observable { + obs_type: "resource".to_string(), + value: format!("ca_policy:{}", policy_id), + name: String::new(), + }); + + if state != "enabled" { + disabled_count += 1; + findings.push(Finding { + title: "Disabled Conditional Access Policy".to_string(), + description: format!( + "CA policy {:?} is in state {:?} instead of enabled", + display_name, state + ), + severity_id: 3, + }); + continue; + } + + // Check grant controls for MFA requirement. + let grant_controls = policy.get("grantControls"); + let has_mfa = grant_controls + .and_then(|gc| gc.get("builtInControls")) + .and_then(|c| c.as_array()) + .map(|controls| controls.iter().any(|c| c.as_str() == Some("mfa"))) + .unwrap_or(false); + + // Check for device compliance. + let has_device_compliance = grant_controls + .and_then(|gc| gc.get("builtInControls")) + .and_then(|c| c.as_array()) + .map(|controls| { + controls + .iter() + .any(|c| c.as_str() == Some("compliantDevice")) + }) + .unwrap_or(false); + + // Check for sign-in risk conditions. + let has_sign_in_risk = policy + .get("conditions") + .and_then(|c| c.get("signInRiskLevels")) + .and_then(|r| r.as_array()) + .map(|levels| !levels.is_empty()) + .unwrap_or(false); + + if has_mfa { + mfa_policy_count += 1; + } + if has_device_compliance { + device_compliance_count += 1; + } + if has_sign_in_risk { + sign_in_risk_count += 1; + } + + if !has_mfa && !has_device_compliance { + policies_without_mfa += 1; + findings.push(Finding { + title: "No MFA or Device Compliance Required".to_string(), + description: format!( + "CA policy {:?} does not require MFA or device compliance in grant controls", + display_name + ), + severity_id: 2, + }); + } + } + + if findings.is_empty() { + findings.push(Finding { + title: "Conditional Access Policies Compliant".to_string(), + description: format!( + "All {} CA policies are enabled with MFA or device compliance controls", + policies.len() + ), + severity_id: 0, + }); + } + + let (status_id, status_text) = if disabled_count > 0 || policies_without_mfa > 0 { + ( + StatusId::Ineffective, + format!( + "{} disabled CA policies, {} without MFA/device compliance out of {} total", + disabled_count, + policies_without_mfa, + policies.len() + ), + ) + } else if policies.is_empty() { + ( + StatusId::Ineffective, + "No Conditional Access policies found".to_string(), + ) + } else { + ( + StatusId::Effective, + format!( + "All {} CA policies are enabled with appropriate grant controls", + policies.len() + ), + ) + }; + + let raw_data = json!({ + "total_policies": policies.len(), + "disabled_policies": disabled_count, + "policies_without_mfa": policies_without_mfa, + "mfa_policy_count": mfa_policy_count, + "device_compliance_count": device_compliance_count, + "sign_in_risk_count": sign_in_risk_count, + "policies": Value::Array(policies), + }); + + Ok(vec![Evidence { + id: Uuid::new_v4(), + control_id: "conditional_access.policy".to_string(), + class_uid: 1001, + category_uid: 1, + activity_id: 1, + time: now, + confidence_level: ConfidenceLevel::PassiveObservation, + metadata: Metadata { + module: ModuleInfo { + name: "azure.conditional_access".to_string(), + version: "0.1.0".to_string(), + module_type: "observer".to_string(), + }, + source: SourceInfo { + system: "azure_ad".to_string(), + api_version: "v1.0".to_string(), + endpoint, + }, + original_time: None, + processed_time: now, + safety_classification: None, + }, + observables, + status_id, + status: status_text, + raw_data, + findings, + test_transcript: None, + enrichments: vec![], + }]) + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// A tiny HTTP server that responds to multiple requests (token + API calls). + /// Returns responses in order from a provided list. + fn mock_server_multi(responses: Vec<(u16, String)>) -> String { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::{Arc, Mutex}; + use std::thread; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let responses = Arc::new(Mutex::new(responses.into_iter())); + + thread::spawn(move || { + for stream_result in listener.incoming() { + let Ok(mut stream) = stream_result else { + break; + }; + let mut buf = [0u8; 8192]; + let _ = stream.read(&mut buf); + + let (status, body) = { + let mut iter = responses.lock().unwrap(); + iter.next() + .unwrap_or((500, r#"{"error":"no more responses"}"#.to_string())) + }; + + let resp = format!( + "HTTP/1.1 {} OK\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{}", + status, + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.shutdown(std::net::Shutdown::Write); + let mut drain = [0u8; 256]; + while matches!(stream.read(&mut drain), Ok(n) if n > 0) {} + } + }); + + format!("http://127.0.0.1:{}", addr.port()) + } + + fn token_response() -> (u16, String) { + ( + 200, + r#"{"access_token":"mock_token","token_type":"Bearer","expires_in":3600}"#.to_string(), + ) + } + + fn base_config(base_url: &str) -> HashMap { + HashMap::from([ + ("AZURE_TENANT_ID".to_string(), "test-tenant".to_string()), + ("AZURE_CLIENT_ID".to_string(), "test-client".to_string()), + ("AZURE_CLIENT_SECRET".to_string(), "test-secret".to_string()), + ("AZURE_BASE_URL".to_string(), base_url.to_string()), + ("AZURE_LOGIN_BASE".to_string(), base_url.to_string()), + ]) + } + + const ENABLED_MFA_POLICY: &str = r#"{"value":[ + { + "id": "ca1", + "displayName": "Require MFA for all users", + "state": "enabled", + "conditions": { + "users": {"includeUsers": ["All"]}, + "signInRiskLevels": [] + }, + "grantControls": { + "operator": "OR", + "builtInControls": ["mfa"] + } + } + ]}"#; + + const DISABLED_POLICY: &str = r#"{"value":[ + { + "id": "ca2", + "displayName": "Old Policy", + "state": "disabled", + "conditions": {}, + "grantControls": { + "operator": "OR", + "builtInControls": ["mfa"] + } + } + ]}"#; + + const NO_MFA_POLICY: &str = r#"{"value":[ + { + "id": "ca3", + "displayName": "Block Legacy Auth", + "state": "enabled", + "conditions": {}, + "grantControls": { + "operator": "OR", + "builtInControls": ["block"] + } + } + ]}"#; + + const DEVICE_COMPLIANCE_POLICY: &str = r#"{"value":[ + { + "id": "ca4", + "displayName": "Require Compliant Device", + "state": "enabled", + "conditions": {}, + "grantControls": { + "operator": "OR", + "builtInControls": ["compliantDevice"] + } + } + ]}"#; + + const SIGN_IN_RISK_POLICY: &str = r#"{"value":[ + { + "id": "ca5", + "displayName": "Block High Risk Sign-ins", + "state": "enabled", + "conditions": { + "signInRiskLevels": ["high", "medium"] + }, + "grantControls": { + "operator": "OR", + "builtInControls": ["mfa"] + } + } + ]}"#; + + const EMPTY_POLICIES: &str = r#"{"value":[]}"#; + + // ── Metadata ───────────────────────────────────────────────────────────── + + #[test] + fn ca_observer_id() { + assert_eq!(ConditionalAccessObserver.id(), "azure.conditional_access"); + } + + #[test] + fn ca_observer_name() { + assert_eq!( + ConditionalAccessObserver.name(), + "Azure AD Conditional Access Observer" + ); + } + + #[test] + fn ca_observer_version() { + assert_eq!(ConditionalAccessObserver.version(), "0.1.0"); + } + + #[test] + fn ca_observer_source_system() { + assert_eq!(ConditionalAccessObserver.source_system(), "azure_ad"); + } + + #[test] + fn ca_observer_evidence_types() { + assert_eq!(ConditionalAccessObserver.evidence_types(), &[1001]); + } + + #[test] + fn ca_observer_credential_requirements() { + let reqs = ConditionalAccessObserver.credential_requirements(); + assert_eq!(reqs.len(), 3); + assert!(reqs + .iter() + .any(|r| r.name == "AZURE_TENANT_ID" && r.required)); + assert!(reqs + .iter() + .any(|r| r.name == "AZURE_CLIENT_ID" && r.required)); + assert!(reqs + .iter() + .any(|r| r.name == "AZURE_CLIENT_SECRET" && r.required)); + } + + // ── Config validation ──────────────────────────────────────────────────── + + #[test] + fn missing_tenant_id_errors() { + let config = HashMap::from([ + ("AZURE_CLIENT_ID".to_string(), "c".to_string()), + ("AZURE_CLIENT_SECRET".to_string(), "s".to_string()), + ]); + let err = ConditionalAccessObserver.observe(&config).unwrap_err(); + assert!(err.to_string().contains("AZURE_TENANT_ID")); + } + + #[test] + fn missing_client_id_errors() { + let config = HashMap::from([ + ("AZURE_TENANT_ID".to_string(), "t".to_string()), + ("AZURE_CLIENT_SECRET".to_string(), "s".to_string()), + ]); + let err = ConditionalAccessObserver.observe(&config).unwrap_err(); + assert!(err.to_string().contains("AZURE_CLIENT_ID")); + } + + #[test] + fn missing_client_secret_errors() { + let config = HashMap::from([ + ("AZURE_TENANT_ID".to_string(), "t".to_string()), + ("AZURE_CLIENT_ID".to_string(), "c".to_string()), + ]); + let err = ConditionalAccessObserver.observe(&config).unwrap_err(); + assert!(err.to_string().contains("AZURE_CLIENT_SECRET")); + } + + // ── HTTP integration ───────────────────────────────────────────────────── + + #[test] + fn enabled_mfa_policy_is_effective() { + let srv = mock_server_multi(vec![ + token_response(), + (200, ENABLED_MFA_POLICY.to_string()), + ]); + let ev = &ConditionalAccessObserver + .observe(&base_config(&srv)) + .unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Effective); + assert_eq!(ev.control_id, "conditional_access.policy"); + assert_eq!(ev.class_uid, 1001); + assert_eq!(ev.observables.len(), 1); + } + + #[test] + fn disabled_policy_is_ineffective() { + let srv = mock_server_multi(vec![token_response(), (200, DISABLED_POLICY.to_string())]); + let ev = &ConditionalAccessObserver + .observe(&base_config(&srv)) + .unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Ineffective); + assert!(ev + .findings + .iter() + .any(|f| f.title == "Disabled Conditional Access Policy")); + } + + #[test] + fn no_mfa_policy_is_ineffective() { + let srv = mock_server_multi(vec![token_response(), (200, NO_MFA_POLICY.to_string())]); + let ev = &ConditionalAccessObserver + .observe(&base_config(&srv)) + .unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Ineffective); + assert!(ev + .findings + .iter() + .any(|f| f.title == "No MFA or Device Compliance Required")); + } + + #[test] + fn device_compliance_policy_is_effective() { + let srv = mock_server_multi(vec![ + token_response(), + (200, DEVICE_COMPLIANCE_POLICY.to_string()), + ]); + let ev = &ConditionalAccessObserver + .observe(&base_config(&srv)) + .unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Effective); + assert_eq!(ev.raw_data["device_compliance_count"], 1); + } + + #[test] + fn sign_in_risk_policy_counted() { + let srv = mock_server_multi(vec![ + token_response(), + (200, SIGN_IN_RISK_POLICY.to_string()), + ]); + let ev = &ConditionalAccessObserver + .observe(&base_config(&srv)) + .unwrap()[0]; + assert_eq!(ev.raw_data["sign_in_risk_count"], 1); + } + + #[test] + fn empty_policies_is_ineffective() { + let srv = mock_server_multi(vec![token_response(), (200, EMPTY_POLICIES.to_string())]); + let ev = &ConditionalAccessObserver + .observe(&base_config(&srv)) + .unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Ineffective); + assert!(ev.status.contains("No Conditional Access policies")); + } + + #[test] + fn api_error_returns_err() { + let srv = mock_server_multi(vec![ + token_response(), + ( + 403, + r#"{"error":{"code":"Authorization_RequestDenied","message":"Forbidden"}}"# + .to_string(), + ), + ]); + let result = ConditionalAccessObserver.observe(&base_config(&srv)); + assert!(result.is_err()); + } + + #[test] + fn token_error_returns_err() { + let srv = mock_server_multi(vec![( + 401, + r#"{"error":"invalid_client","error_description":"bad secret"}"#.to_string(), + )]); + let result = ConditionalAccessObserver.observe(&base_config(&srv)); + assert!(result.is_err()); + } + + #[test] + fn raw_data_has_expected_keys() { + let srv = mock_server_multi(vec![ + token_response(), + (200, ENABLED_MFA_POLICY.to_string()), + ]); + let ev = &ConditionalAccessObserver + .observe(&base_config(&srv)) + .unwrap()[0]; + assert!(ev.raw_data.get("total_policies").is_some()); + assert!(ev.raw_data.get("disabled_policies").is_some()); + assert!(ev.raw_data.get("policies_without_mfa").is_some()); + assert!(ev.raw_data.get("mfa_policy_count").is_some()); + } + + #[test] + fn observer_does_not_set_test_transcript() { + let srv = mock_server_multi(vec![token_response(), (200, EMPTY_POLICIES.to_string())]); + let ev = &ConditionalAccessObserver + .observe(&base_config(&srv)) + .unwrap()[0]; + assert!(ev.test_transcript.is_none()); + } + + // ── Pagination ─────────────────────────────────────────────────────────── + + #[test] + fn handles_pagination() { + // We need the mock server URL in the nextLink, so build dynamically. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let base = format!("http://127.0.0.1:{}", addr.port()); + + let page1 = format!( + r#"{{"value":[{{"id":"ca1","displayName":"P1","state":"enabled","conditions":{{}},"grantControls":{{"operator":"OR","builtInControls":["mfa"]}}}}],"@odata.nextLink":"{}/v1.0/identity/conditionalAccess/policies?$skiptoken=page2"}}"#, + base + ); + let page2 = r#"{"value":[{"id":"ca2","displayName":"P2","state":"enabled","conditions":{},"grantControls":{"operator":"OR","builtInControls":["mfa"]}}]}"#; + + let responses = vec![token_response(), (200, page1), (200, page2.to_string())]; + + use std::io::{Read, Write}; + use std::sync::{Arc, Mutex}; + use std::thread; + + let responses = Arc::new(Mutex::new(responses.into_iter())); + + thread::spawn(move || { + for stream_result in listener.incoming() { + let Ok(mut stream) = stream_result else { + break; + }; + let mut buf = [0u8; 8192]; + let _ = stream.read(&mut buf); + + let (status, body) = { + let mut iter = responses.lock().unwrap(); + iter.next() + .unwrap_or((500, r#"{"error":"done"}"#.to_string())) + }; + + let resp = format!( + "HTTP/1.1 {} OK\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{}", + status, + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.shutdown(std::net::Shutdown::Write); + let mut drain = [0u8; 256]; + while matches!(stream.read(&mut drain), Ok(n) if n > 0) {} + } + }); + + let ev = &ConditionalAccessObserver + .observe(&base_config(&base)) + .unwrap()[0]; + assert_eq!(ev.raw_data["total_policies"], 2); + assert_eq!(ev.observables.len(), 2); + assert_eq!(ev.status_id, StatusId::Effective); + } +} diff --git a/src/modules/observers/mod.rs b/src/modules/observers/mod.rs index 923933b..993c5cb 100644 --- a/src/modules/observers/mod.rs +++ b/src/modules/observers/mod.rs @@ -1,4 +1,5 @@ pub mod aws; +pub mod azure; pub mod github; pub mod mock; pub mod okta; @@ -13,6 +14,7 @@ pub fn register_all(registry: &Registry) { registry.register_observer(Arc::new(mock::MockObserver)); registry.register_observer(Arc::new(mock::MockNetworkObserver)); registry.register_observer(Arc::new(aws::IamObserver)); + registry.register_observer(Arc::new(azure::ConditionalAccessObserver)); registry.register_observer(Arc::new(github::BranchProtectionObserver)); registry.register_observer(Arc::new(okta::MfaPolicyObserver)); registry.register_observer(Arc::new(okta_population::MfaEnrollmentPopulationObserver)); diff --git a/src/modules/observers/okta.rs b/src/modules/observers/okta.rs index 3a52251..b8ee558 100644 --- a/src/modules/observers/okta.rs +++ b/src/modules/observers/okta.rs @@ -201,12 +201,7 @@ impl Observer for MfaPolicyObserver { let first_active = policies .iter() - .find(|p| { - p.get("status") - .and_then(|v| v.as_str()) - .unwrap_or("") - == "ACTIVE" - }); + .find(|p| p.get("status").and_then(|v| v.as_str()).unwrap_or("") == "ACTIVE"); if let Some(active_policy) = first_active { first_active_policy_name = active_policy diff --git a/src/modules/observers/okta_population.rs b/src/modules/observers/okta_population.rs index 59c393e..662277a 100644 --- a/src/modules/observers/okta_population.rs +++ b/src/modules/observers/okta_population.rs @@ -140,10 +140,7 @@ fn classify_user_factors(factors: &[Value]) -> UserCompliance { .get("factorType") .and_then(|v| v.as_str()) .unwrap_or(""); - let status = factor - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let status = factor.get("status").and_then(|v| v.as_str()).unwrap_or(""); if status != "ACTIVE" { continue; @@ -203,10 +200,7 @@ impl Observer for MfaEnrollmentPopulationObserver { let (resp, status) = okta_get(token, &url)?; if status != 200 { - bail!( - "Okta API returned status {} querying users", - status - ); + bail!("Okta API returned status {} querying users", status); } let page_users = resp @@ -224,11 +218,7 @@ impl Observer for MfaEnrollmentPopulationObserver { if next.starts_with("http") { next_url = Some(next); } else { - next_url = Some(format!( - "{}{}", - base_url.trim_end_matches('/'), - next - )); + next_url = Some(format!("{}{}", base_url.trim_end_matches('/'), next)); } } } @@ -245,10 +235,7 @@ impl Observer for MfaEnrollmentPopulationObserver { let mut non_compliant_ids: Vec = Vec::new(); for user in &all_users { - let user_id = user - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let user_id = user.get("id").and_then(|v| v.as_str()).unwrap_or(""); if user_id.is_empty() { continue; @@ -295,17 +282,16 @@ impl Observer for MfaEnrollmentPopulationObserver { }; // Step 4: Build evidence - let (status_id, status_text) = - if coverage_pct >= 99.0 && partially_compliant_count == 0 { - ( - StatusId::Effective, - format!( - "{}/{} users have PR-only MFA ({:.1}% coverage)", - compliant_count, total_users, coverage_pct - ), - ) - } else { - ( + let (status_id, status_text) = if coverage_pct >= 99.0 && partially_compliant_count == 0 { + ( + StatusId::Effective, + format!( + "{}/{} users have PR-only MFA ({:.1}% coverage)", + compliant_count, total_users, coverage_pct + ), + ) + } else { + ( StatusId::Ineffective, format!( "{}/{} compliant, {} partially compliant, {} non-compliant ({:.1}% PR-only coverage)", @@ -313,7 +299,7 @@ impl Observer for MfaEnrollmentPopulationObserver { non_compliant_count, coverage_pct ), ) - }; + }; let findings = if status_id == StatusId::Ineffective { vec![Finding { @@ -502,9 +488,7 @@ mod tests { assert_eq!(observer.id(), "okta.mfa_enrollment_population"); assert_eq!(observer.evidence_types(), &[1001]); - let srv = multi_mock_server(vec![ - ("/api/v1/users", 200, EMPTY_USERS.to_string()), - ]); + let srv = multi_mock_server(vec![("/api/v1/users", 200, EMPTY_USERS.to_string())]); let ev = &observer.observe(&base_config(&srv)).unwrap()[0]; assert_eq!(ev.class_uid, 1001); assert_eq!(ev.activity_id, 7); @@ -569,9 +553,7 @@ mod tests { #[test] fn empty_user_list_returns_effective() { - let srv = multi_mock_server(vec![ - ("/api/v1/users", 200, EMPTY_USERS.to_string()), - ]); + let srv = multi_mock_server(vec![("/api/v1/users", 200, EMPTY_USERS.to_string())]); let ev = &MfaEnrollmentPopulationObserver .observe(&base_config(&srv)) .unwrap()[0]; @@ -590,9 +572,7 @@ mod tests { let ev = &MfaEnrollmentPopulationObserver .observe(&base_config(&srv)) .unwrap()[0]; - let non_compliant = ev.raw_data["iam_auth"]["non_compliant"] - .as_array() - .unwrap(); + let non_compliant = ev.raw_data["iam_auth"]["non_compliant"].as_array().unwrap(); let ids: Vec<&str> = non_compliant.iter().map(|v| v.as_str().unwrap()).collect(); assert!(ids.contains(&"u3"), "u3 should be non-compliant"); } diff --git a/src/modules/testers/azure.rs b/src/modules/testers/azure.rs new file mode 100644 index 0000000..810707c --- /dev/null +++ b/src/modules/testers/azure.rs @@ -0,0 +1,640 @@ +use std::collections::HashMap; + +use anyhow::{anyhow, Result}; +use chrono::Utc; +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::evidence::{ + ConfidenceLevel, Evidence, Finding, Metadata, ModuleInfo, Observable, SourceInfo, StatusId, + TranscriptRecorder, +}; +use crate::module::{ + tester::Tester, CredentialReq, EnvironmentScope, Module, SafetyClassification, +}; + +// ─── MfaBypassTester ────────────────────────────────────────────────────────── + +/// Attempts authentication via the OAuth2 Resource Owner Password Credentials +/// (ROPC) flow without satisfying MFA to verify that Conditional Access policies +/// block the attempt. This is a safe, read-only probe — it only observes whether +/// the authentication attempt is properly blocked by CA policies. +/// +/// Required config: `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, +/// `AZURE_TEST_USER`, `AZURE_TEST_PASSWORD`. +/// Optional: `AZURE_LOGIN_BASE` (login endpoint override for testing). +pub struct MfaBypassTester; + +impl Module for MfaBypassTester { + fn id(&self) -> &str { + "azure.mfa_bypass" + } + fn name(&self) -> &str { + "Azure AD MFA Bypass Tester" + } + fn version(&self) -> &str { + "0.1.0" + } + fn source_system(&self) -> &str { + "azure_ad" + } + fn evidence_types(&self) -> &[i32] { + &[1001] + } + + fn credential_requirements(&self) -> Vec { + vec![ + CredentialReq { + name: "AZURE_TENANT_ID".to_string(), + cred_type: "tenant_id".to_string(), + description: "Azure AD tenant ID".to_string(), + required: true, + }, + CredentialReq { + name: "AZURE_CLIENT_ID".to_string(), + cred_type: "client_id".to_string(), + description: "App registration client ID (must allow ROPC flow)".to_string(), + required: true, + }, + CredentialReq { + name: "AZURE_CLIENT_SECRET".to_string(), + cred_type: "client_secret".to_string(), + description: "App registration client secret".to_string(), + required: true, + }, + CredentialReq { + name: "AZURE_TEST_USER".to_string(), + cred_type: "username".to_string(), + description: "Test user UPN for MFA bypass attempt".to_string(), + required: true, + }, + CredentialReq { + name: "AZURE_TEST_PASSWORD".to_string(), + cred_type: "password".to_string(), + description: "Test user password for MFA bypass attempt".to_string(), + required: true, + }, + ] + } +} + +impl Tester for MfaBypassTester { + fn safety_class(&self) -> SafetyClassification { + SafetyClassification::Safe + } + fn environment_scope(&self) -> EnvironmentScope { + EnvironmentScope::Production + } + + fn pre_flight_checks(&self) -> Vec { + vec![ + "verify Azure AD login endpoint reachable".to_string(), + "verify test credentials configured".to_string(), + ] + } + + fn cleanup_procedures(&self) -> Vec { + vec![] // Safe read-only ROPC probe — no state changes, no cleanup needed. + } + + fn test(&self, config: &HashMap) -> Result> { + let tenant_id = config + .get("AZURE_TENANT_ID") + .ok_or_else(|| anyhow!("AZURE_TENANT_ID is required"))?; + let client_id = config + .get("AZURE_CLIENT_ID") + .ok_or_else(|| anyhow!("AZURE_CLIENT_ID is required"))?; + let client_secret = config + .get("AZURE_CLIENT_SECRET") + .ok_or_else(|| anyhow!("AZURE_CLIENT_SECRET is required"))?; + let test_user = config + .get("AZURE_TEST_USER") + .ok_or_else(|| anyhow!("AZURE_TEST_USER is required for MFA bypass testing"))?; + let test_password = config + .get("AZURE_TEST_PASSWORD") + .ok_or_else(|| anyhow!("AZURE_TEST_PASSWORD is required for MFA bypass testing"))?; + + let login_base = config + .get("AZURE_LOGIN_BASE") + .cloned() + .unwrap_or_else(|| "https://login.microsoftonline.com".to_string()); + + let now = Utc::now(); + let mut recorder = TranscriptRecorder::new(); + let safety_class = "safe".to_string(); + let endpoint = format!("/{}/oauth2/v2.0/token", tenant_id); + let url = format!("{}{}", login_base.trim_end_matches('/'), endpoint); + + recorder.record_action( + "initiate ROPC authentication without MFA interaction", + Some(json!({ + "target": tenant_id, + "method": "ropc_flow", + "user": test_user, + "endpoint": &endpoint, + })), + ); + + recorder.record_action( + "submit ROPC credentials (no MFA interaction possible)", + Some(json!({ + "credentials": "redacted", + "mfa_interaction": "none", + "grant_type": "password", + })), + ); + + // Attempt ROPC flow — if MFA is enforced via CA policies, this should fail + // with an "interaction_required" error (AADSTS50076). + let post_body = format!( + "grant_type=password&client_id={}&client_secret={}&username={}&password={}&scope=openid", + client_id, client_secret, test_user, test_password + ); + + let post_resp = ureq::post(&url) + .set("Content-Type", "application/x-www-form-urlencoded") + .set("Accept", "application/json") + .send_string(&post_body); + + let (http_status, resp_body): (u16, Value) = match post_resp { + Ok(r) => { + let code = r.status(); + let body: Value = r.into_json().unwrap_or(json!({})); + (code, body) + } + Err(ureq::Error::Status(code, r)) => { + let body: Value = r.into_json().unwrap_or(json!({})); + (code, body) + } + Err(e) => return Err(anyhow!("Azure AD ROPC request failed: {}", e)), + }; + + let error_code = resp_body + .get("error") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let error_description = resp_body + .get("error_description") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // Determine control effectiveness. + // "interaction_required" means CA policy demands MFA — bypass blocked. + // A 200 with access_token means ROPC succeeded without MFA — bypass worked. + let (status_id, status_text, bypass_blocked) = if error_code == "interaction_required" { + recorder.record_observation( + format!( + "ROPC rejected with interaction_required (HTTP {})", + http_status + ), + true, + ); + recorder.record_observation("Conditional Access policy requires MFA interaction", true); + ( + StatusId::Effective, + "MFA bypass attempt was correctly blocked by Conditional Access".to_string(), + true, + ) + } else if error_code == "invalid_grant" && error_description.contains("AADSTS50076") { + // AADSTS50076 specifically means MFA is required. + recorder.record_observation("ROPC rejected with AADSTS50076 (MFA required)", true); + ( + StatusId::Effective, + "MFA bypass attempt was correctly blocked by Conditional Access".to_string(), + true, + ) + } else if http_status == 200 && resp_body.get("access_token").is_some() { + recorder.record_observation("ROPC succeeded — access token issued without MFA", false); + recorder.record_observation("session established without MFA verification", false); + ( + StatusId::Ineffective, + "MFA bypass succeeded — ROPC authentication completed without MFA".to_string(), + false, + ) + } else if http_status == 401 || http_status == 403 { + recorder.record_observation( + format!("authentication rejected with HTTP {}", http_status), + true, + ); + ( + StatusId::Effective, + "MFA bypass attempt was correctly blocked".to_string(), + true, + ) + } else { + // Other errors (bad credentials, locked account, etc.) — bypass not achieved. + recorder.record_observation( + format!( + "ROPC returned error {:?} (HTTP {})", + error_code, http_status + ), + true, + ); + recorder.record_observation("authentication did not succeed without MFA", true); + ( + StatusId::Effective, + "MFA bypass attempt was correctly blocked".to_string(), + true, + ) + }; + + let transcript = recorder.finalize(); + + let findings = if bypass_blocked { + vec![Finding { + title: "MFA Bypass Blocked".to_string(), + description: format!( + "ROPC authentication without MFA was blocked (HTTP {}, error: {:?})", + http_status, error_code + ), + severity_id: 0, + }] + } else { + vec![Finding { + title: "MFA Bypass Succeeded".to_string(), + description: "ROPC authentication completed without MFA — Conditional Access MFA enforcement is not working".to_string(), + severity_id: 3, + }] + }; + + let raw_data = json!({ + "test_scenario": "azure_mfa_bypass_attempt", + "target_system": tenant_id, + "test_result": if bypass_blocked { "blocked" } else { "bypassed" }, + "http_status": http_status, + "error_code": error_code, + "bypass_blocked": bypass_blocked, + }); + + Ok(vec![Evidence { + id: Uuid::new_v4(), + control_id: "conditional_access.mfa_enforcement".to_string(), + class_uid: 1001, + category_uid: 1, + activity_id: 2, + time: now, + confidence_level: ConfidenceLevel::ActiveVerification, + metadata: Metadata { + module: ModuleInfo { + name: "azure.mfa_bypass".to_string(), + version: "0.1.0".to_string(), + module_type: "tester".to_string(), + }, + source: SourceInfo { + system: "azure_ad".to_string(), + api_version: "v2.0".to_string(), + endpoint, + }, + original_time: None, + processed_time: now, + safety_classification: Some(safety_class), + }, + observables: vec![ + Observable { + obs_type: "resource".to_string(), + value: "conditional_access_policy".to_string(), + name: String::new(), + }, + Observable { + obs_type: "user".to_string(), + value: test_user.clone(), + name: String::new(), + }, + ], + status_id, + status: status_text, + raw_data, + findings, + test_transcript: Some(transcript), + enrichments: vec![], + }]) + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn mock_server(status: u16, body: &str) -> String { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::thread; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = body.to_string(); + + thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 8192]; + let _ = stream.read(&mut buf); + let resp = format!( + "HTTP/1.1 {} OK\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{}", + status, + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.shutdown(std::net::Shutdown::Write); + let mut drain = [0u8; 256]; + while matches!(stream.read(&mut drain), Ok(n) if n > 0) {} + } + }); + + format!("http://127.0.0.1:{}", addr.port()) + } + + fn base_config(base_url: &str) -> HashMap { + HashMap::from([ + ("AZURE_TENANT_ID".to_string(), "test-tenant".to_string()), + ("AZURE_CLIENT_ID".to_string(), "test-client".to_string()), + ("AZURE_CLIENT_SECRET".to_string(), "test-secret".to_string()), + ( + "AZURE_TEST_USER".to_string(), + "test@example.com".to_string(), + ), + ( + "AZURE_TEST_PASSWORD".to_string(), + "TestPass123!".to_string(), + ), + ("AZURE_LOGIN_BASE".to_string(), base_url.to_string()), + ]) + } + + // ── Metadata ───────────────────────────────────────────────────────────── + + #[test] + fn mfa_bypass_id() { + assert_eq!(MfaBypassTester.id(), "azure.mfa_bypass"); + } + + #[test] + fn mfa_bypass_name() { + assert_eq!(MfaBypassTester.name(), "Azure AD MFA Bypass Tester"); + } + + #[test] + fn mfa_bypass_version() { + assert_eq!(MfaBypassTester.version(), "0.1.0"); + } + + #[test] + fn mfa_bypass_source_system() { + assert_eq!(MfaBypassTester.source_system(), "azure_ad"); + } + + #[test] + fn mfa_bypass_evidence_types() { + assert_eq!(MfaBypassTester.evidence_types(), &[1001]); + } + + #[test] + fn mfa_bypass_credential_requirements() { + let reqs = MfaBypassTester.credential_requirements(); + assert_eq!(reqs.len(), 5); + assert!(reqs + .iter() + .any(|r| r.name == "AZURE_TENANT_ID" && r.required)); + assert!(reqs + .iter() + .any(|r| r.name == "AZURE_CLIENT_ID" && r.required)); + assert!(reqs + .iter() + .any(|r| r.name == "AZURE_CLIENT_SECRET" && r.required)); + assert!(reqs + .iter() + .any(|r| r.name == "AZURE_TEST_USER" && r.required)); + assert!(reqs + .iter() + .any(|r| r.name == "AZURE_TEST_PASSWORD" && r.required)); + } + + #[test] + fn mfa_bypass_safety_class() { + assert_eq!(MfaBypassTester.safety_class(), SafetyClassification::Safe); + } + + #[test] + fn mfa_bypass_environment_scope() { + assert_eq!( + MfaBypassTester.environment_scope(), + EnvironmentScope::Production + ); + } + + #[test] + fn mfa_bypass_pre_flight_nonempty() { + assert!(!MfaBypassTester.pre_flight_checks().is_empty()); + } + + #[test] + fn mfa_bypass_cleanup_empty() { + assert!(MfaBypassTester.cleanup_procedures().is_empty()); + } + + // ── Config validation ──────────────────────────────────────────────────── + + #[test] + fn missing_tenant_id_errors() { + let config = HashMap::from([ + ("AZURE_CLIENT_ID".to_string(), "c".to_string()), + ("AZURE_CLIENT_SECRET".to_string(), "s".to_string()), + ("AZURE_TEST_USER".to_string(), "u".to_string()), + ("AZURE_TEST_PASSWORD".to_string(), "p".to_string()), + ]); + let err = MfaBypassTester.test(&config).unwrap_err(); + assert!(err.to_string().contains("AZURE_TENANT_ID")); + } + + #[test] + fn missing_client_id_errors() { + let config = HashMap::from([ + ("AZURE_TENANT_ID".to_string(), "t".to_string()), + ("AZURE_CLIENT_SECRET".to_string(), "s".to_string()), + ("AZURE_TEST_USER".to_string(), "u".to_string()), + ("AZURE_TEST_PASSWORD".to_string(), "p".to_string()), + ]); + let err = MfaBypassTester.test(&config).unwrap_err(); + assert!(err.to_string().contains("AZURE_CLIENT_ID")); + } + + #[test] + fn missing_client_secret_errors() { + let config = HashMap::from([ + ("AZURE_TENANT_ID".to_string(), "t".to_string()), + ("AZURE_CLIENT_ID".to_string(), "c".to_string()), + ("AZURE_TEST_USER".to_string(), "u".to_string()), + ("AZURE_TEST_PASSWORD".to_string(), "p".to_string()), + ]); + let err = MfaBypassTester.test(&config).unwrap_err(); + assert!(err.to_string().contains("AZURE_CLIENT_SECRET")); + } + + #[test] + fn missing_test_user_errors() { + let config = HashMap::from([ + ("AZURE_TENANT_ID".to_string(), "t".to_string()), + ("AZURE_CLIENT_ID".to_string(), "c".to_string()), + ("AZURE_CLIENT_SECRET".to_string(), "s".to_string()), + ("AZURE_TEST_PASSWORD".to_string(), "p".to_string()), + ]); + let err = MfaBypassTester.test(&config).unwrap_err(); + assert!(err.to_string().contains("AZURE_TEST_USER")); + } + + #[test] + fn missing_test_password_errors() { + let config = HashMap::from([ + ("AZURE_TENANT_ID".to_string(), "t".to_string()), + ("AZURE_CLIENT_ID".to_string(), "c".to_string()), + ("AZURE_CLIENT_SECRET".to_string(), "s".to_string()), + ("AZURE_TEST_USER".to_string(), "u".to_string()), + ]); + let err = MfaBypassTester.test(&config).unwrap_err(); + assert!(err.to_string().contains("AZURE_TEST_PASSWORD")); + } + + // ── HTTP integration ───────────────────────────────────────────────────── + + #[test] + fn interaction_required_is_effective() { + let srv = mock_server( + 400, + r#"{"error":"interaction_required","error_description":"AADSTS50076: MFA required"}"#, + ); + let ev = &MfaBypassTester.test(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Effective); + assert!(ev.findings.iter().any(|f| f.title == "MFA Bypass Blocked")); + assert_eq!(ev.class_uid, 1001); + assert_eq!(ev.control_id, "conditional_access.mfa_enforcement"); + } + + #[test] + fn invalid_grant_aadsts50076_is_effective() { + let srv = mock_server( + 400, + r#"{"error":"invalid_grant","error_description":"AADSTS50076: Due to a configuration change"}"#, + ); + let ev = &MfaBypassTester.test(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Effective); + assert!(ev.findings.iter().any(|f| f.title == "MFA Bypass Blocked")); + } + + #[test] + fn success_response_is_ineffective() { + let srv = mock_server( + 200, + r#"{"access_token":"eyJ...","token_type":"Bearer","expires_in":3600}"#, + ); + let ev = &MfaBypassTester.test(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Ineffective); + assert!(ev + .findings + .iter() + .any(|f| f.title == "MFA Bypass Succeeded")); + assert_eq!(ev.findings[0].severity_id, 3); + } + + #[test] + fn http_401_is_effective() { + let srv = mock_server( + 401, + r#"{"error":"invalid_client","error_description":"bad credentials"}"#, + ); + let ev = &MfaBypassTester.test(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Effective); + } + + #[test] + fn http_403_is_effective() { + let srv = mock_server(403, r#"{"error":"access_denied"}"#); + let ev = &MfaBypassTester.test(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Effective); + } + + #[test] + fn other_error_is_effective() { + let srv = mock_server( + 400, + r#"{"error":"invalid_grant","error_description":"AADSTS50126: Invalid username or password"}"#, + ); + let ev = &MfaBypassTester.test(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Effective); + } + + #[test] + fn mfa_bypass_has_transcript() { + let srv = mock_server( + 400, + r#"{"error":"interaction_required","error_description":"MFA required"}"#, + ); + let ev = &MfaBypassTester.test(&base_config(&srv)).unwrap()[0]; + let t = ev.test_transcript.as_ref().unwrap(); + assert!(!t.actions_attempted.is_empty()); + assert!(!t.observations.is_empty()); + } + + #[test] + fn mfa_bypass_raw_data_keys() { + let srv = mock_server( + 400, + r#"{"error":"interaction_required","error_description":"MFA"}"#, + ); + let ev = &MfaBypassTester.test(&base_config(&srv)).unwrap()[0]; + assert!(ev.raw_data.get("test_scenario").is_some()); + assert!(ev.raw_data.get("bypass_blocked").is_some()); + assert!(ev.raw_data.get("error_code").is_some()); + assert_eq!(ev.raw_data["bypass_blocked"].as_bool(), Some(true)); + } + + #[test] + fn mfa_bypass_success_raw_data_bypassed() { + let srv = mock_server( + 200, + r#"{"access_token":"tok","token_type":"Bearer","expires_in":3600}"#, + ); + let ev = &MfaBypassTester.test(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.raw_data["bypass_blocked"].as_bool(), Some(false)); + assert_eq!(ev.raw_data["test_result"].as_str(), Some("bypassed")); + } + + #[test] + fn mfa_bypass_has_two_observables() { + let srv = mock_server( + 400, + r#"{"error":"interaction_required","error_description":"MFA"}"#, + ); + let ev = &MfaBypassTester.test(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.observables.len(), 2); + assert!(ev.observables.iter().any(|o| o.obs_type == "resource")); + assert!(ev.observables.iter().any(|o| o.obs_type == "user")); + } + + #[test] + fn mfa_bypass_safety_classification_in_metadata() { + let srv = mock_server( + 400, + r#"{"error":"interaction_required","error_description":"MFA"}"#, + ); + let ev = &MfaBypassTester.test(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.metadata.safety_classification.as_deref(), Some("safe")); + } + + #[test] + fn mfa_bypass_unique_ids() { + let srv1 = mock_server( + 400, + r#"{"error":"interaction_required","error_description":"MFA"}"#, + ); + let srv2 = mock_server( + 400, + r#"{"error":"interaction_required","error_description":"MFA"}"#, + ); + let id1 = MfaBypassTester.test(&base_config(&srv1)).unwrap()[0].id; + let id2 = MfaBypassTester.test(&base_config(&srv2)).unwrap()[0].id; + assert_ne!(id1, id2); + } +} diff --git a/src/modules/testers/mod.rs b/src/modules/testers/mod.rs index 349edc8..f3802b5 100644 --- a/src/modules/testers/mod.rs +++ b/src/modules/testers/mod.rs @@ -1,4 +1,5 @@ pub mod aws; +pub mod azure; pub mod github; pub mod mock; pub mod okta; @@ -12,6 +13,7 @@ use crate::module::registry::Registry; pub fn register_all(registry: &Registry) { registry.register_tester(Arc::new(mock::MockTester)); registry.register_tester(Arc::new(aws::S3PublicAccessTester)); + registry.register_tester(Arc::new(azure::MfaBypassTester)); registry.register_tester(Arc::new(github::SecretPushTester)); registry.register_tester(Arc::new(okta::MfaBypassTester)); registry.register_tester(Arc::new(okta_pr_mfa_downgrade::PrMfaDowngradeTester)); diff --git a/src/modules/testers/okta_pr_mfa_downgrade.rs b/src/modules/testers/okta_pr_mfa_downgrade.rs index c5483b3..8499b1f 100644 --- a/src/modules/testers/okta_pr_mfa_downgrade.rs +++ b/src/modules/testers/okta_pr_mfa_downgrade.rs @@ -218,62 +218,60 @@ impl Tester for PrMfaDowngradeTester { let mut downgrade_possible = !phishable_offered.is_empty(); // ── Determine outcome ─────────────────────────────────────────────── - let (status_id, status_text, findings) = - if http_status == 401 || http_status == 403 { - downgrade_possible = false; + let (status_id, status_text, findings) = if http_status == 401 || http_status == 403 { + downgrade_possible = false; + ( + StatusId::Effective, + "Primary authentication rejected — no factor challenge needed".to_string(), + vec![Finding { + title: "Authentication Rejected Before Factor Challenge".to_string(), + description: format!( + "Authentication rejected with HTTP {} — no MFA factor challenge reached", + http_status + ), + severity_id: 0, + }], + ) + } else if authn_status == "SUCCESS" { + downgrade_possible = true; + ( + StatusId::Ineffective, + "MFA not required at sign-in".to_string(), + vec![Finding { + title: "MFA Not Required at Sign-In".to_string(), + description: "Authentication succeeded without any MFA challenge".to_string(), + severity_id: 4, + }], + ) + } else if authn_status == "MFA_ENROLL" { + ( + StatusId::Ineffective, + "MFA enrollment incomplete for test user".to_string(), + vec![Finding { + title: "MFA Enrollment Incomplete".to_string(), + description: "Test user not enrolled in any MFA factor".to_string(), + severity_id: 2, + }], + ) + } else if authn_status == "MFA_REQUIRED" + || authn_status == "MFA_CHALLENGE" + || http_status == 200 + { + if downgrade_possible { ( - StatusId::Effective, - "Primary authentication rejected — no factor challenge needed".to_string(), + StatusId::Ineffective, + format!("Phishable MFA factors offered: {:?}", phishable_offered), vec![Finding { - title: "Authentication Rejected Before Factor Challenge".to_string(), + title: "Phishable MFA Factors Available at Sign-In".to_string(), description: format!( - "Authentication rejected with HTTP {} — no MFA factor challenge reached", - http_status + "Phishable factors offered: {:?}. Downgrade attack possible.", + phishable_offered ), - severity_id: 0, - }], - ) - } else if authn_status == "SUCCESS" { - downgrade_possible = true; - ( - StatusId::Ineffective, - "MFA not required at sign-in".to_string(), - vec![Finding { - title: "MFA Not Required at Sign-In".to_string(), - description: "Authentication succeeded without any MFA challenge" - .to_string(), - severity_id: 4, + severity_id: 3, }], ) - } else if authn_status == "MFA_ENROLL" { + } else { ( - StatusId::Ineffective, - "MFA enrollment incomplete for test user".to_string(), - vec![Finding { - title: "MFA Enrollment Incomplete".to_string(), - description: "Test user not enrolled in any MFA factor".to_string(), - severity_id: 2, - }], - ) - } else if authn_status == "MFA_REQUIRED" - || authn_status == "MFA_CHALLENGE" - || http_status == 200 - { - if downgrade_possible { - ( - StatusId::Ineffective, - format!("Phishable MFA factors offered: {:?}", phishable_offered), - vec![Finding { - title: "Phishable MFA Factors Available at Sign-In".to_string(), - description: format!( - "Phishable factors offered: {:?}. Downgrade attack possible.", - phishable_offered - ), - severity_id: 3, - }], - ) - } else { - ( StatusId::Effective, "Only phishing-resistant factors offered in MFA challenge".to_string(), vec![Finding { @@ -284,34 +282,31 @@ impl Tester for PrMfaDowngradeTester { severity_id: 0, }], ) - } - } else { - ( - StatusId::Effective, - format!( - "Auth did not succeed: HTTP {} {:?}", + } + } else { + ( + StatusId::Effective, + format!( + "Auth did not succeed: HTTP {} {:?}", + http_status, authn_status + ), + vec![Finding { + title: "No Downgrade Path Detected".to_string(), + description: format!( + "Authentication did not succeed (HTTP {}, status: {:?})", http_status, authn_status ), - vec![Finding { - title: "No Downgrade Path Detected".to_string(), - description: format!( - "Authentication did not succeed (HTTP {}, status: {:?})", - http_status, authn_status - ), - severity_id: 0, - }], - ) - }; + severity_id: 0, + }], + ) + }; // ── Observations ──────────────────────────────────────────────────── recorder.record_observation( format!("HTTP {} authn_status: {:?}", http_status, authn_status), !downgrade_possible, ); - recorder.record_observation( - format!("offered factors: {:?}", offered_factors), - true, - ); + recorder.record_observation(format!("offered factors: {:?}", offered_factors), true); recorder.record_observation( format!("phishable factors offered: {:?}", phishable_offered), phishable_offered.is_empty(), @@ -540,7 +535,10 @@ mod tests { #[test] fn http_403_is_effective() { - let srv = mock_server(403, r#"{"errorCode":"E0000006","errorSummary":"Forbidden"}"#); + let srv = mock_server( + 403, + r#"{"errorCode":"E0000006","errorSummary":"Forbidden"}"#, + ); let ev = &PrMfaDowngradeTester.test(&base_config(&srv)).unwrap()[0]; assert_eq!(ev.status_id, StatusId::Effective); } @@ -599,10 +597,7 @@ mod tests { fn offered_factors_populated() { let srv = mock_server(200, MFA_REQUIRED_WITH_PHISHABLE); let ev = &PrMfaDowngradeTester.test(&base_config(&srv)).unwrap()[0]; - assert_eq!( - ev.raw_data["offered_factors"].as_array().unwrap().len(), - 2 - ); + assert_eq!(ev.raw_data["offered_factors"].as_array().unwrap().len(), 2); } #[test] diff --git a/src/testutil.rs b/src/testutil.rs index cab8e99..a5b3fd4 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -14,7 +14,7 @@ use crate::evidence::{ SourceInfo, StatusId, }; use crate::module::{ - AuthorizationLevel, Authorizer, Observer, CredentialReq, EnvironmentScope, Module, + AuthorizationLevel, Authorizer, CredentialReq, EnvironmentScope, Module, Observer, SafetyClassification, Tester, };