From b127b15ffb275db7a7ba0c20cbcb33e753d33769 Mon Sep 17 00:00:00 2001 From: Kerem Proulx Date: Wed, 14 Jan 2026 12:01:07 -0500 Subject: [PATCH 1/5] add auth discovery step --- README.md | 80 ++- src/config/container.rs | 47 ++ src/main.rs | 64 ++- src/parser.rs | 30 ++ src/smart_wordlist/auth_discovery.rs | 734 +++++++++++++++++++++++++++ src/smart_wordlist/generator.rs | 136 ++++- src/smart_wordlist/llm.rs | 308 +++++++++++ src/smart_wordlist/mod.rs | 6 + src/smart_wordlist/report.rs | 117 ++++- src/utils.rs | 7 + 10 files changed, 1518 insertions(+), 11 deletions(-) create mode 100644 src/smart_wordlist/auth_discovery.rs diff --git a/README.md b/README.md index ad2422d..f9e5ea5 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ cargo build --release - **Technology Detection**: Automatically detects frameworks like Next.js, React, Vue, Angular, Rails, Django, Laravel, WordPress, and more from URL patterns - **AI-Powered Wordlists**: Uses Claude API to generate context-aware wordlists tailored to the target's tech stack +- **Authentication Discovery**: Automatically discovers auth endpoints, can register test accounts, and authenticates to access protected routes - **Optional HTTP Probing**: Gathers additional context from server headers for more accurate wordlist generation - **Seamless Scanning**: Generated wordlists are automatically used for content discovery (or output separately) @@ -144,6 +145,15 @@ feroxagent -u https://target.com --recon-file urls.txt | `--wordlist-only` | Output generated wordlist to stdout and exit (don't scan) | | `--json` | Output structured JSON to stdout (canonical endpoints + token usage) | +### Authentication Options + +| Flag | Description | +|------|-------------| +| `--auto-register` | Attempt to create a test account if registration endpoint is discovered | +| `--auth-endpoint ` | Manually specify the authentication endpoint (e.g., `/api/auth/login`) | +| `--auth-instructions ` | Provide instructions for authentication (e.g., `'use username field instead of email'`) | +| `--no-discover-auth` | Disable automatic authentication endpoint discovery | + ### Inherited feroxbuster Options feroxagent inherits most of feroxbuster's powerful options: @@ -177,19 +187,25 @@ feroxagent --help - Static file structures - Route conventions -3. **HTTP Probing**: Automatically makes HEAD requests to gather: +3. **Authentication Discovery**: Automatically discovers and attempts authentication: + - Probes common auth endpoints (`/api/auth/login`, `/login`, `/api/auth/register`, etc.) + - Uses AI to generate an authentication plan based on detected endpoints + - With `--auto-register`, creates a test account and logs in + - Injects auth tokens/cookies into subsequent requests to access protected routes + +4. **HTTP Probing**: Automatically makes HEAD requests to gather: - Server headers - X-Powered-By information - Content types -4. **Attack Surface Report**: Generates an actionable report highlighting: +5. **Attack Surface Report**: Generates an actionable report highlighting: - High-value endpoints to target - Potential vulnerabilities based on detected stack - Recommended attack vectors (prioritized) -5. **Generate Wordlist**: Creates a targeted wordlist based on the detected tech stack +6. **Generate Wordlist**: Creates a targeted wordlist based on the detected tech stack -6. **Scan Target**: Uses the generated wordlist to perform content discovery (or outputs the wordlist with `--wordlist-only`) +7. **Scan Target**: Uses the generated wordlist to perform content discovery (or outputs the wordlist with `--wordlist-only`) ## Example Output @@ -242,9 +258,39 @@ feroxagent --help "total_tokens": 14432 }, "stats": { - "total_endpoints": 23, - "parameterized_endpoints": 8, - "catch_all_endpoints": 3 + "total_paths_tested": 342, + "total_filtered_noise": 156 + }, + "auth_discovery": { + "discovered": true, + "authenticated": true, + "endpoints": [ + { + "url": "https://example.com/api/auth/login", + "type": "Login", + "method": "POST", + "detected_fields": ["email", "password"], + "status_code": 200 + }, + { + "url": "https://example.com/api/auth/register", + "type": "Register", + "method": "POST", + "detected_fields": ["email", "password", "username"], + "status_code": 201 + } + ], + "registration_available": true, + "login_endpoint": "https://example.com/api/auth/login", + "register_endpoint": "https://example.com/api/auth/register", + "auth_type": "Bearer", + "user_created": true, + "token": "eyJhbGciOiJIUzI1NiIs...", + "credentials": { + "email": "feroxtest_12345@example.com", + "password": "FeroxTest123!" + }, + "summary": "JSON-based auth with email/password, returns JWT token in body" } } ``` @@ -264,6 +310,26 @@ katana -u https://example.com -silent | \ feroxagent -u https://example.com --wordlist-only > custom-wordlist.txt ``` +### With Authentication + +```bash +# Auto-register a test account and scan authenticated routes +katana -u https://example.com -silent | \ + feroxagent -u https://example.com --auto-register + +# Manually specify auth endpoint with custom instructions +katana -u https://example.com -silent | \ + feroxagent -u https://example.com \ + --auth-endpoint /api/login \ + --auth-instructions "POST JSON with username and password fields" \ + --auto-register + +# Get JSON output with auth credentials for use in other tools +katana -u https://example.com -silent | \ + feroxagent -u https://example.com --auto-register --json | \ + jq '.auth_discovery.token' +``` + ## Technology Detection feroxagent can detect the following technologies from URL patterns: diff --git a/src/config/container.rs b/src/config/container.rs index 561af7f..6d406bf 100644 --- a/src/config/container.rs +++ b/src/config/container.rs @@ -26,6 +26,7 @@ use std::{ fs::read_to_string, io::BufRead, path::{Path, PathBuf}, + sync::{Arc, RwLock}, }; use url::form_urlencoded; @@ -105,6 +106,22 @@ pub struct Configuration { #[serde(default)] pub discover_methods: bool, + /// Manually specified authentication endpoint (ex: /api/auth/login) + #[serde(default)] + pub auth_endpoint: String, + + /// Instructions for authentication (ex: 'POST JSON with email and password fields') + #[serde(default)] + pub auth_instructions: String, + + /// Whether to attempt creating a test account if registration is discovered + #[serde(default)] + pub auto_register: bool, + + /// Whether to disable automatic auth endpoint discovery (default: false, i.e. discovery enabled) + #[serde(default)] + pub no_discover_auth: bool, + /// Anthropic API key for LLM wordlist generation #[serde(default)] pub anthropic_key: String, @@ -242,6 +259,10 @@ pub struct Configuration { #[serde(default)] pub headers: HashMap, + /// Auth headers set at runtime after authentication discovery (interior mutability for Arc) + #[serde(skip)] + pub auth_headers: Arc>>, + /// URL query parameters #[serde(default)] pub queries: Vec<(String, String)>, @@ -480,11 +501,16 @@ impl Default for Configuration { filter_status: Vec::new(), filter_similar: Vec::new(), headers: HashMap::new(), + auth_headers: Arc::new(RwLock::new(HashMap::new())), depth: depth(), threads: threads(), recon_file: String::new(), wordlist_only: false, discover_methods: false, + auth_endpoint: String::new(), + auth_instructions: String::new(), + auto_register: false, + no_discover_auth: false, anthropic_key: std::env::var("ANTHROPIC_API_KEY").unwrap_or_default(), generated_wordlist: Vec::new(), dont_collect: ignored_extensions(), @@ -1106,6 +1132,23 @@ impl Configuration { config.discover_methods = true; } + // Authentication discovery options + update_config_if_present!(&mut config.auth_endpoint, args, "auth_endpoint", String); + update_config_if_present!( + &mut config.auth_instructions, + args, + "auth_instructions", + String + ); + + if came_from_cli!(args, "auto_register") { + config.auto_register = true; + } + + if came_from_cli!(args, "no_discover_auth") { + config.no_discover_auth = true; + } + if came_from_cli!(args, "unique") { config.unique = true; } @@ -1454,6 +1497,10 @@ impl Configuration { update_if_not_default!(&mut conf.recon_file, new.recon_file, ""); update_if_not_default!(&mut conf.wordlist_only, new.wordlist_only, false); update_if_not_default!(&mut conf.discover_methods, new.discover_methods, false); + update_if_not_default!(&mut conf.auth_endpoint, new.auth_endpoint, ""); + update_if_not_default!(&mut conf.auth_instructions, new.auth_instructions, ""); + update_if_not_default!(&mut conf.auto_register, new.auto_register, false); + update_if_not_default!(&mut conf.no_discover_auth, new.no_discover_auth, false); update_if_not_default!(&mut conf.status_codes, new.status_codes, status_codes()); // status_codes() is the default for replay_codes, if they're not provided update_if_not_default!(&mut conf.replay_codes, new.replay_codes, status_codes()); diff --git a/src/main.rs b/src/main.rs index ab2a00a..8b8f9be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,7 +34,7 @@ use feroxagent::{ smart_wordlist::{ self, confirm_methods_batch, detect_parameterized_endpoint, discover_methods_for_405s, fingerprint_api_prefixes, generate_canonical_inventory_with_wildcards, output_wordlist, - DiscoveredEndpoint, GeneratorConfig, PentestReport, + AuthTokenType, DiscoveredEndpoint, GeneratorConfig, PentestReport, }, utils::{fmt_err, slugify_filename}, }; @@ -201,6 +201,19 @@ async fn wrapped_main(config: Arc) -> Result<()> { } else { Some(config.recon_file.clone()) }, + auth_endpoint: if config.auth_endpoint.is_empty() { + None + } else { + Some(config.auth_endpoint.clone()) + }, + auth_instructions: if config.auth_instructions.is_empty() { + None + } else { + Some(config.auth_instructions.clone()) + }, + auto_register: config.auto_register, + no_discover_auth: config.no_discover_auth, + json: config.json, }; let generation_result = @@ -213,6 +226,50 @@ async fn wrapped_main(config: Arc) -> Result<()> { } }; + // Inject auth headers if authentication was successful + if let Some((_, _, ref auth_result)) = result.auth_result { + if auth_result.success { + if let Ok(mut auth_headers) = config.auth_headers.write() { + match auth_result.token_type { + AuthTokenType::Bearer => { + if let Some(ref token) = auth_result.token { + auth_headers.insert("Authorization".to_string(), format!("Bearer {}", token)); + log::info!("Added Bearer token to requests"); + if !config.json { + eprintln!("[+] Authentication successful - added Bearer token to requests"); + } + } + } + AuthTokenType::Cookie => { + // Combine all cookies into a single Cookie header + if !auth_result.cookies.is_empty() { + let cookie_header = auth_result.cookies.join("; "); + auth_headers.insert("Cookie".to_string(), cookie_header); + log::info!("Added session cookies to requests"); + if !config.json { + eprintln!("[+] Authentication successful - added session cookies to requests"); + } + } + } + AuthTokenType::ApiKey => { + if let Some(ref token) = auth_result.token { + // For API keys, the auth_plan should have specified where to put it + // Default to X-API-Key header + auth_headers.insert("X-API-Key".to_string(), token.clone()); + log::info!("Added API key to requests"); + if !config.json { + eprintln!("[+] Authentication successful - added API key to requests"); + } + } + } + AuthTokenType::None => {} + } + } + } else if !config.json { + eprintln!("[-] Authentication attempted but was not successful"); + } + } + // Initialize the pentest report let mut pentest_report = PentestReport::new(config.target_url.clone()); pentest_report.set_recon_urls(result.recon_urls.clone()); @@ -223,6 +280,9 @@ async fn wrapped_main(config: Arc) -> Result<()> { // Store token usage for JSON output let token_usage = result.token_usage.clone(); + // Store auth result for JSON output + let auth_result_for_report = result.auth_result.clone(); + if !config.json { eprintln!( "\n[+] Generated {} paths for scanning", @@ -719,7 +779,7 @@ async fn wrapped_main(config: Arc) -> Result<()> { // Output the comprehensive report if config.json { // JSON output to stdout only - let json_output = pentest_report.to_json_output(&token_usage); + let json_output = pentest_report.to_json_output(&token_usage, auth_result_for_report.as_ref()); println!( "{}", serde_json::to_string_pretty(&json_output).unwrap_or_else(|e| { diff --git a/src/parser.rs b/src/parser.rs index 64c410e..7337bf8 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -590,6 +590,36 @@ pub fn initialize() -> Command { .help_heading("Smart wordlist settings") .help("Run OPTIONS requests on 405 endpoints to discover allowed methods"), ) + .arg( + Arg::new("auth_endpoint") + .long("auth-endpoint") + .value_name("URL") + .num_args(1) + .help_heading("Authentication settings") + .help("Manually specify the authentication endpoint (ex: /api/auth/login)"), + ) + .arg( + Arg::new("auth_instructions") + .long("auth-instructions") + .value_name("TEXT") + .num_args(1) + .help_heading("Authentication settings") + .help("Provide instructions for authentication (ex: 'POST JSON with email and password fields')"), + ) + .arg( + Arg::new("auto_register") + .long("auto-register") + .num_args(0) + .help_heading("Authentication settings") + .help("Attempt to create a test account if registration endpoint is discovered"), + ) + .arg( + Arg::new("no_discover_auth") + .long("no-discover-auth") + .num_args(0) + .help_heading("Authentication settings") + .help("Disable automatic authentication endpoint discovery"), + ) .arg( Arg::new("auto_tune") .long("auto-tune") diff --git a/src/smart_wordlist/auth_discovery.rs b/src/smart_wordlist/auth_discovery.rs new file mode 100644 index 0000000..7fb6be5 --- /dev/null +++ b/src/smart_wordlist/auth_discovery.rs @@ -0,0 +1,734 @@ +//! Authentication endpoint discovery and authentication attempt functionality +//! +//! This module discovers authentication endpoints (login, register, etc.) and +//! attempts to authenticate using LLM-guided credential generation. + +use anyhow::Result; +use reqwest::{Client, Method}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +use super::analyzer::TechAnalysis; + +/// Information about a discovered authentication endpoint +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthEndpoint { + pub url: String, + pub endpoint_type: AuthEndpointType, + pub method: String, + pub content_type: Option, + pub detected_fields: Vec, + pub status_code: u16, +} + +/// Types of authentication endpoints +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AuthEndpointType { + Login, + Register, + Logout, + PasswordReset, + TokenRefresh, + OAuth, + Session, + Unknown, +} + +impl std::fmt::Display for AuthEndpointType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AuthEndpointType::Login => write!(f, "login"), + AuthEndpointType::Register => write!(f, "register"), + AuthEndpointType::Logout => write!(f, "logout"), + AuthEndpointType::PasswordReset => write!(f, "password_reset"), + AuthEndpointType::TokenRefresh => write!(f, "token_refresh"), + AuthEndpointType::OAuth => write!(f, "oauth"), + AuthEndpointType::Session => write!(f, "session"), + AuthEndpointType::Unknown => write!(f, "unknown"), + } + } +} + +/// Results of auth endpoint discovery +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AuthDiscoveryResult { + pub endpoints: Vec, + pub registration_available: bool, + pub login_endpoint: Option, + pub register_endpoint: Option, +} + +/// Result of an authentication attempt +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AuthResult { + pub success: bool, + pub token: Option, + pub cookies: Vec, + pub token_type: AuthTokenType, + pub user_created: bool, + pub credentials_used: Option, + pub error_message: Option, + /// Status code from registration attempt (if attempted) + pub register_status: Option, + /// Status code from login attempt (if attempted) + pub login_status: Option, +} + +/// Types of authentication tokens +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AuthTokenType { + Bearer, + Cookie, + ApiKey, + #[default] + None, +} + +/// Test credentials generated for registration/login +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestCredentials { + pub email: String, + pub password: String, +} + +/// LLM-generated authentication plan +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AuthPlan { + pub registration: Option, + pub login: Option, + pub token_location: TokenLocation, + pub summary: String, +} + +/// Action to perform for authentication +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthAction { + pub endpoint: String, + pub method: String, + pub content_type: String, + pub body_template: String, + pub required_fields: Vec, +} + +/// Where the auth token appears in the response +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(tag = "type", content = "field")] +pub enum TokenLocation { + ResponseBodyField(String), + SetCookieHeader, + AuthorizationHeader, + #[default] + Unknown, +} + +/// Common authentication paths to probe +const AUTH_PATHS: &[(&str, AuthEndpointType)] = &[ + // Login endpoints + ("/login", AuthEndpointType::Login), + ("/signin", AuthEndpointType::Login), + ("/auth/login", AuthEndpointType::Login), + ("/api/auth/login", AuthEndpointType::Login), + ("/api/login", AuthEndpointType::Login), + ("/api/v1/auth/login", AuthEndpointType::Login), + ("/api/v1/login", AuthEndpointType::Login), + ("/users/sign_in", AuthEndpointType::Login), // Rails/Devise + ("/accounts/login", AuthEndpointType::Login), // Django + ("/api/auth/signin", AuthEndpointType::Login), // NextAuth + // Register endpoints + ("/register", AuthEndpointType::Register), + ("/signup", AuthEndpointType::Register), + ("/auth/register", AuthEndpointType::Register), + ("/api/auth/register", AuthEndpointType::Register), + ("/api/register", AuthEndpointType::Register), + ("/api/v1/auth/register", AuthEndpointType::Register), + ("/api/v1/register", AuthEndpointType::Register), + ("/users/sign_up", AuthEndpointType::Register), // Rails/Devise + ("/accounts/signup", AuthEndpointType::Register), // Django + // Token/Session endpoints + ("/token", AuthEndpointType::TokenRefresh), + ("/api/token", AuthEndpointType::TokenRefresh), + ("/oauth/token", AuthEndpointType::OAuth), + ("/api/auth/session", AuthEndpointType::Session), // NextAuth + ("/api/auth/csrf", AuthEndpointType::Session), // NextAuth + // Logout + ("/logout", AuthEndpointType::Logout), + ("/signout", AuthEndpointType::Logout), + ("/api/auth/logout", AuthEndpointType::Logout), + ("/api/logout", AuthEndpointType::Logout), + // Password reset + ("/forgot-password", AuthEndpointType::PasswordReset), + ("/password/reset", AuthEndpointType::PasswordReset), + ("/api/auth/forgot-password", AuthEndpointType::PasswordReset), +]; + +/// Discover authentication endpoints by probing common paths +pub async fn discover_auth_endpoints( + base_url: &str, + _analysis: &TechAnalysis, + client: &Client, +) -> Result { + log::info!("Discovering authentication endpoints for {}", base_url); + + let mut result = AuthDiscoveryResult::default(); + let base = base_url.trim_end_matches('/'); + + for (path, endpoint_type) in AUTH_PATHS { + let url = format!("{}{}", base, path); + + match probe_auth_endpoint(&url, *endpoint_type, client).await { + Ok(Some(endpoint)) => { + log::info!( + "Found {} endpoint: {} (status: {})", + endpoint.endpoint_type, + endpoint.url, + endpoint.status_code + ); + + // Track specific endpoint types + match endpoint.endpoint_type { + AuthEndpointType::Login => { + if result.login_endpoint.is_none() { + result.login_endpoint = Some(endpoint.clone()); + } + } + AuthEndpointType::Register => { + result.registration_available = true; + if result.register_endpoint.is_none() { + result.register_endpoint = Some(endpoint.clone()); + } + } + _ => {} + } + + result.endpoints.push(endpoint); + } + Ok(None) => { + log::debug!("No auth endpoint at {}", url); + } + Err(e) => { + log::debug!("Error probing {}: {}", url, e); + } + } + } + + log::info!( + "Auth discovery complete: {} endpoints found, registration={}", + result.endpoints.len(), + result.registration_available + ); + + Ok(result) +} + +/// Probe a single URL to determine if it's an auth endpoint +async fn probe_auth_endpoint( + url: &str, + endpoint_type: AuthEndpointType, + client: &Client, +) -> Result> { + // Try GET first + let get_response = client + .get(url) + .timeout(Duration::from_secs(5)) + .send() + .await; + + let (status_code, content_type, is_auth_endpoint) = match get_response { + Ok(resp) => { + let status = resp.status().as_u16(); + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(String::from); + + // Auth endpoints typically return: + // - 200: Login form or API info + // - 401: Requires authentication + // - 405: Method not allowed (expects POST) + // - 400: Bad request (expects body) + let is_auth = matches!(status, 200 | 401 | 405 | 400 | 422); + + (status, ct, is_auth) + } + Err(e) => { + // Connection errors are not auth endpoints + if e.is_timeout() || e.is_connect() { + return Ok(None); + } + // Other errors might indicate an endpoint + log::debug!("GET {} error: {}", url, e); + return Ok(None); + } + }; + + if !is_auth_endpoint { + return Ok(None); + } + + // If 405, try OPTIONS to confirm POST is allowed + let method = if status_code == 405 { + // Try OPTIONS + if let Ok(opt_resp) = client + .request(Method::OPTIONS, url) + .timeout(Duration::from_secs(5)) + .send() + .await + { + if let Some(allow) = opt_resp.headers().get("allow") { + if let Ok(allow_str) = allow.to_str() { + if allow_str.contains("POST") { + "POST".to_string() + } else { + "GET".to_string() + } + } else { + "POST".to_string() // Assume POST for 405 + } + } else { + "POST".to_string() // Assume POST for 405 + } + } else { + "POST".to_string() // Assume POST for 405 + } + } else { + "GET".to_string() + }; + + // Detect fields from common patterns + let detected_fields = detect_auth_fields(endpoint_type); + + Ok(Some(AuthEndpoint { + url: url.to_string(), + endpoint_type, + method, + content_type, + detected_fields, + status_code, + })) +} + +/// Probe a single manually-specified auth endpoint +pub async fn probe_single_auth_endpoint( + url: &str, + client: &Client, +) -> Result { + let endpoint_type = infer_endpoint_type(url); + + let mut result = AuthDiscoveryResult::default(); + + if let Some(endpoint) = probe_auth_endpoint(url, endpoint_type, client).await? { + match endpoint.endpoint_type { + AuthEndpointType::Login => { + result.login_endpoint = Some(endpoint.clone()); + } + AuthEndpointType::Register => { + result.registration_available = true; + result.register_endpoint = Some(endpoint.clone()); + } + _ => {} + } + result.endpoints.push(endpoint); + } + + Ok(result) +} + +/// Infer the endpoint type from URL patterns +fn infer_endpoint_type(url: &str) -> AuthEndpointType { + let url_lower = url.to_lowercase(); + + if url_lower.contains("login") || url_lower.contains("signin") { + AuthEndpointType::Login + } else if url_lower.contains("register") || url_lower.contains("signup") { + AuthEndpointType::Register + } else if url_lower.contains("logout") || url_lower.contains("signout") { + AuthEndpointType::Logout + } else if url_lower.contains("token") { + AuthEndpointType::TokenRefresh + } else if url_lower.contains("oauth") { + AuthEndpointType::OAuth + } else if url_lower.contains("password") || url_lower.contains("forgot") { + AuthEndpointType::PasswordReset + } else if url_lower.contains("session") { + AuthEndpointType::Session + } else { + AuthEndpointType::Unknown + } +} + +/// Detect likely auth fields based on endpoint type +fn detect_auth_fields(endpoint_type: AuthEndpointType) -> Vec { + match endpoint_type { + AuthEndpointType::Login => vec!["email".to_string(), "password".to_string()], + AuthEndpointType::Register => vec![ + "email".to_string(), + "password".to_string(), + "username".to_string(), + ], + AuthEndpointType::PasswordReset => vec!["email".to_string()], + AuthEndpointType::TokenRefresh => vec!["refresh_token".to_string()], + _ => vec![], + } +} + +/// Generate test credentials for registration +pub fn generate_test_credentials() -> TestCredentials { + use std::time::{SystemTime, UNIX_EPOCH}; + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + + let random_suffix = timestamp % 100000; + + TestCredentials { + email: format!("feroxtest_{}@example.com", random_suffix), + password: "FeroxTest123!".to_string(), + } +} + +/// Attempt to authenticate using the auth plan +pub async fn attempt_authentication( + discovery: &AuthDiscoveryResult, + auth_plan: &AuthPlan, + client: &Client, + auto_register: bool, +) -> Result { + let mut result = AuthResult::default(); + + // Step 1: Try registration if available and auto_register is enabled + if auto_register && discovery.registration_available { + if let Some(ref register_action) = auth_plan.registration { + log::info!("Attempting auto-registration..."); + + let creds = generate_test_credentials(); + match try_register(register_action, &creds, client, &auth_plan.token_location).await { + Ok((reg_result, reg_status)) => { + // Store the registration status code + result.register_status = Some(reg_status); + + if reg_result.success { + log::info!( + "Registration successful for {} (status: {})", + creds.email, + reg_status + ); + result.success = reg_result.success; + result.token = reg_result.token; + result.cookies = reg_result.cookies; + result.token_type = reg_result.token_type; + result.credentials_used = Some(creds.clone()); + result.user_created = true; + + // If we got a token from registration, we're done + if result.token.is_some() || !result.cookies.is_empty() { + return Ok(result); + } + + // Otherwise, try to login with the new credentials + if let Some(ref login_action) = auth_plan.login { + log::info!( + "No token from registration, attempting login at {}", + login_action.endpoint + ); + match try_login(login_action, &creds, client, &auth_plan.token_location) + .await + { + Ok((login_result, login_status)) => { + // Store the login status code + result.login_status = Some(login_status); + + if login_result.success { + log::info!( + "Login successful after registration (status: {})", + login_status + ); + result.success = true; + result.token = login_result.token; + result.cookies = login_result.cookies; + result.token_type = login_result.token_type; + } else { + log::warn!( + "Login returned non-success status {}: {}", + login_status, + login_result.error_message.as_deref().unwrap_or("unknown") + ); + // Still keep the registration success and credentials + result.error_message = login_result.error_message; + } + } + Err(e) => { + log::warn!("Login after registration failed: {}", e); + result.error_message = Some(format!("Login error: {}", e)); + } + } + } else { + log::warn!("No login action in auth plan, cannot login after registration"); + result.error_message = + Some("Registration succeeded but no login endpoint available".to_string()); + } + + return Ok(result); + } else { + log::warn!( + "Registration failed (status {}): {}", + reg_status, + reg_result.error_message.unwrap_or_default() + ); + } + } + Err(e) => { + log::warn!("Registration attempt error: {}", e); + } + } + } + } + + // Step 2: Try login without credentials (will likely fail, but reports endpoint info) + if let Some(ref login_action) = auth_plan.login { + log::info!("Login endpoint available at {}", login_action.endpoint); + result.error_message = Some("Login requires valid credentials".to_string()); + } + + Ok(result) +} + +/// Attempt registration with given credentials +/// Returns (AuthResult, status_code) +async fn try_register( + action: &AuthAction, + creds: &TestCredentials, + client: &Client, + token_location: &TokenLocation, +) -> Result<(AuthResult, u16)> { + let body = action + .body_template + .replace("{email}", &creds.email) + .replace("{password}", &creds.password) + .replace("{username}", &creds.email.split('@').next().unwrap_or("feroxtest")); + + log::debug!( + "Attempting registration: {} {} Content-Type: {}", + action.method, + action.endpoint, + action.content_type + ); + log::debug!("Registration body: {}", body); + + let response = client + .request( + Method::from_bytes(action.method.as_bytes()).unwrap_or(Method::POST), + &action.endpoint, + ) + .header("Content-Type", &action.content_type) + .body(body) + .timeout(Duration::from_secs(10)) + .send() + .await?; + + let status_code = response.status().as_u16(); + log::debug!("Registration response status: {}", status_code); + + let result = parse_auth_response(response, token_location).await?; + Ok((result, status_code)) +} + +/// Attempt login with given credentials +/// Returns (AuthResult, status_code) +async fn try_login( + action: &AuthAction, + creds: &TestCredentials, + client: &Client, + token_location: &TokenLocation, +) -> Result<(AuthResult, u16)> { + // Handle both {email} and {username} placeholders + let username = creds.email.split('@').next().unwrap_or("feroxtest"); + let body = action + .body_template + .replace("{email}", &creds.email) + .replace("{password}", &creds.password) + .replace("{username}", username); + + log::debug!( + "Attempting login: {} {} Content-Type: {}", + action.method, + action.endpoint, + action.content_type + ); + log::debug!("Login body: {}", body); + + let response = client + .request( + Method::from_bytes(action.method.as_bytes()).unwrap_or(Method::POST), + &action.endpoint, + ) + .header("Content-Type", &action.content_type) + .body(body) + .timeout(Duration::from_secs(10)) + .send() + .await?; + + let status_code = response.status().as_u16(); + log::debug!("Login response status: {}", status_code); + + let result = parse_auth_response(response, token_location).await?; + Ok((result, status_code)) +} + +/// Parse the auth response to extract token/cookies +async fn parse_auth_response( + response: reqwest::Response, + token_location: &TokenLocation, +) -> Result { + let mut result = AuthResult::default(); + + let status = response.status(); + result.success = status.is_success(); + + // Extract cookies from Set-Cookie headers + for value in response.headers().get_all("set-cookie") { + if let Ok(cookie_str) = value.to_str() { + // Extract just the cookie name=value part (before any ;) + let cookie_value = cookie_str.split(';').next().unwrap_or(cookie_str); + result.cookies.push(cookie_value.to_string()); + } + } + + // Extract token based on expected location + match token_location { + TokenLocation::SetCookieHeader => { + if !result.cookies.is_empty() { + result.token_type = AuthTokenType::Cookie; + } + } + TokenLocation::ResponseBodyField(field) => { + if let Ok(body) = response.text().await { + // Try to parse as JSON and extract the field + if let Ok(json) = serde_json::from_str::(&body) { + if let Some(token) = json.get(field).and_then(|v| v.as_str()) { + result.token = Some(token.to_string()); + result.token_type = AuthTokenType::Bearer; + } + // Also check nested paths like data.token or user.token + if result.token.is_none() { + for key in ["data", "user", "result", "response"] { + if let Some(nested) = json.get(key) { + if let Some(token) = nested.get(field).and_then(|v| v.as_str()) { + result.token = Some(token.to_string()); + result.token_type = AuthTokenType::Bearer; + break; + } + } + } + } + } + } + } + TokenLocation::AuthorizationHeader => { + // Token would be in response header (rare) + if let Some(auth) = response.headers().get("authorization") { + if let Ok(auth_str) = auth.to_str() { + result.token = Some(auth_str.replace("Bearer ", "")); + result.token_type = AuthTokenType::Bearer; + } + } + } + TokenLocation::Unknown => { + // Try common patterns + if let Ok(body) = response.text().await { + if let Ok(json) = serde_json::from_str::(&body) { + // Try common token field names + for field in ["token", "access_token", "accessToken", "jwt", "authToken"] { + if let Some(token) = json.get(field).and_then(|v| v.as_str()) { + result.token = Some(token.to_string()); + result.token_type = AuthTokenType::Bearer; + break; + } + } + } + } + + // Fall back to cookies if no token found + if result.token.is_none() && !result.cookies.is_empty() { + result.token_type = AuthTokenType::Cookie; + } + } + } + + if !result.success { + result.error_message = Some(format!("Auth request returned status {}", status)); + } + + Ok(result) +} + +impl AuthDiscoveryResult { + /// Generate a summary string for logging/display + pub fn summary(&self) -> String { + let mut parts = Vec::new(); + + if let Some(ref login) = self.login_endpoint { + parts.push(format!("login={}", login.url)); + } + + if let Some(ref register) = self.register_endpoint { + parts.push(format!("register={}", register.url)); + } + + if parts.is_empty() { + "No auth endpoints discovered".to_string() + } else { + parts.join(", ") + } + } +} + +impl AuthResult { + /// Check if we have usable authentication + pub fn has_auth(&self) -> bool { + self.success && (self.token.is_some() || !self.cookies.is_empty()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_credentials() { + let creds = generate_test_credentials(); + assert!(creds.email.starts_with("feroxtest_")); + assert!(creds.email.ends_with("@example.com")); + assert_eq!(creds.password, "FeroxTest123!"); + } + + #[test] + fn test_infer_endpoint_type() { + assert_eq!( + infer_endpoint_type("/api/auth/login"), + AuthEndpointType::Login + ); + assert_eq!( + infer_endpoint_type("/api/auth/register"), + AuthEndpointType::Register + ); + assert_eq!( + infer_endpoint_type("/oauth/token"), + AuthEndpointType::OAuth + ); + assert_eq!( + infer_endpoint_type("/forgot-password"), + AuthEndpointType::PasswordReset + ); + } + + #[test] + fn test_auth_endpoint_type_display() { + assert_eq!(format!("{}", AuthEndpointType::Login), "login"); + assert_eq!(format!("{}", AuthEndpointType::Register), "register"); + } +} diff --git a/src/smart_wordlist/generator.rs b/src/smart_wordlist/generator.rs index 1fdfb21..7833527 100644 --- a/src/smart_wordlist/generator.rs +++ b/src/smart_wordlist/generator.rs @@ -3,6 +3,10 @@ //! Coordinates the analysis, probing, and LLM-based wordlist generation. use super::analyzer::{analyze_urls, TechAnalysis}; +use super::auth_discovery::{ + attempt_authentication, discover_auth_endpoints, probe_single_auth_endpoint, + AuthDiscoveryResult, AuthPlan, AuthResult, +}; use super::llm::{AggregatedUsage, ClaudeClient}; use super::mutations::{expand_parameterized_paths, generate_mutations, MutationConfig}; use super::probe::{probe_urls, summarize_probe_results}; @@ -16,6 +20,16 @@ pub struct GeneratorConfig { pub target_url: String, pub anthropic_key: String, pub recon_file: Option, + /// Manually specified authentication endpoint + pub auth_endpoint: Option, + /// User-provided instructions for authentication + pub auth_instructions: Option, + /// Whether to attempt auto-registration + pub auto_register: bool, + /// Whether to disable auth discovery + pub no_discover_auth: bool, + /// Whether to suppress progress output (JSON mode) + pub json: bool, } /// Result of the generation process including wordlist and attack surface report @@ -25,6 +39,8 @@ pub struct GenerationResult { pub recon_urls: Vec, pub technologies: Vec, pub token_usage: AggregatedUsage, + /// Authentication discovery and attempt results + pub auth_result: Option<(AuthDiscoveryResult, AuthPlan, AuthResult)>, } /// Configuration for wordlist budgeting to enforce diversity @@ -85,11 +101,128 @@ pub async fn generate_wordlist( // Generate wordlist and attack report using LLM log::info!("Generating wordlist and attack surface report using Claude API..."); - let claude = ClaudeClient::new(config.anthropic_key)?; + let claude = ClaudeClient::new(config.anthropic_key.clone())?; // Track aggregated token usage let mut token_usage = AggregatedUsage::default(); + // === AUTH DISCOVERY PHASE === + let auth_result = if !config.no_discover_auth { + if !config.json { + eprintln!("[*] Discovering authentication endpoints..."); + } + log::info!("Discovering authentication endpoints..."); + + let discovery = if let Some(ref manual_endpoint) = config.auth_endpoint { + // Manual endpoint provided - probe just that + log::info!("Using manually specified auth endpoint: {}", manual_endpoint); + let full_url = if manual_endpoint.starts_with("http") { + manual_endpoint.clone() + } else { + format!( + "{}{}", + config.target_url.trim_end_matches('/'), + manual_endpoint + ) + }; + probe_single_auth_endpoint(&full_url, http_client).await? + } else { + // Auto-discover common auth paths + discover_auth_endpoints(&config.target_url, &analysis, http_client).await? + }; + + if !discovery.endpoints.is_empty() { + if !config.json { + eprintln!( + "[+] Found {} auth endpoint(s): {}", + discovery.endpoints.len(), + discovery.summary() + ); + } + log::info!( + "Auth discovery found {} endpoints: {}", + discovery.endpoints.len(), + discovery.summary() + ); + + // Generate auth plan using LLM + if !config.json { + eprintln!("[*] Generating authentication plan using LLM..."); + } + let (auth_plan, auth_usage) = claude + .generate_auth_plan( + &discovery, + config.auth_instructions.as_deref(), + &config.target_url, + &analysis, + ) + .await + .context("Failed to generate auth plan")?; + token_usage.add(&auth_usage); + + log::info!("Auth plan: {}", auth_plan.summary); + + // Attempt authentication + if !config.json { + if config.auto_register && discovery.registration_available { + eprintln!("[*] Attempting registration and login..."); + } else { + eprintln!("[*] Attempting authentication..."); + } + } + let auth_attempt = attempt_authentication( + &discovery, + &auth_plan, + http_client, + config.auto_register, + ) + .await?; + + if auth_attempt.success { + if !config.json { + eprintln!( + "[+] Authentication successful! Token type: {:?}", + auth_attempt.token_type + ); + if auth_attempt.user_created { + if let Some(ref creds) = auth_attempt.credentials_used { + eprintln!("[+] Created test user: {}", creds.email); + } + } + } + log::info!( + "Authentication successful! Token type: {:?}", + auth_attempt.token_type + ); + } else { + if !config.json { + eprintln!( + "[-] Authentication not completed: {}", + auth_attempt.error_message.as_deref().unwrap_or("unknown") + ); + } + log::info!( + "Authentication not completed: {}", + auth_attempt.error_message.as_deref().unwrap_or("unknown") + ); + } + + // Include auth summary in LLM context for better wordlist generation + full_summary.push_str(&format!("\n\nAuthentication: {}", auth_plan.summary)); + + Some((discovery, auth_plan, auth_attempt)) + } else { + if !config.json { + eprintln!("[-] No authentication endpoints discovered"); + } + log::info!("No authentication endpoints discovered"); + None + } + } else { + log::info!("Auth discovery disabled via --no-discover-auth"); + None + }; + // Generate attack surface report let (attack_report, report_usage) = claude .generate_attack_report(&full_summary, &config.target_url, &analysis, &probe_results) @@ -151,6 +284,7 @@ pub async fn generate_wordlist( recon_urls, technologies, token_usage, + auth_result, }) } diff --git a/src/smart_wordlist/llm.rs b/src/smart_wordlist/llm.rs index c43d854..2069bee 100644 --- a/src/smart_wordlist/llm.rs +++ b/src/smart_wordlist/llm.rs @@ -480,6 +480,314 @@ Output the wordlist now:"#, paths.dedup(); paths } + + /// Generate an authentication plan based on discovered auth endpoints + /// Returns the auth plan and token usage metrics + pub async fn generate_auth_plan( + &self, + auth_discovery: &super::auth_discovery::AuthDiscoveryResult, + user_instructions: Option<&str>, + target_url: &str, + analysis: &TechAnalysis, + ) -> Result<(super::auth_discovery::AuthPlan, UsageMetrics)> { + let system_prompt = self.build_auth_plan_system_prompt(); + let user_prompt = + self.build_auth_plan_user_prompt(auth_discovery, user_instructions, target_url, analysis); + + let request = ClaudeRequest { + model: CLAUDE_MODEL.to_string(), + max_tokens: 2048, + messages: vec![Message { + role: "user".to_string(), + content: user_prompt, + }], + system: system_prompt, + }; + + let response = self + .client + .post(CLAUDE_API_URL) + .header("x-api-key", &self.api_key) + .header("anthropic-version", ANTHROPIC_VERSION) + .header("content-type", "application/json") + .json(&request) + .send() + .await + .context("Failed to send request to Claude API")?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(anyhow!("Claude API error ({}): {}", status, error_text)); + } + + let claude_response: ClaudeResponse = response + .json() + .await + .context("Failed to parse Claude API response")?; + + let text = claude_response + .content + .first() + .map(|c| c.text.clone()) + .unwrap_or_default(); + + // Extract usage metrics + let usage = claude_response.usage.unwrap_or_default(); + + // Parse the auth plan from the response + let auth_plan = self.parse_auth_plan_response(&text, auth_discovery, target_url); + + Ok((auth_plan, usage)) + } + + fn build_auth_plan_system_prompt(&self) -> String { + r#"You are an expert in web application authentication analysis. Your task is to analyze discovered authentication endpoints and generate a concrete authentication plan. + +Output a JSON object with this structure: +{ + "registration": { + "endpoint": "/api/auth/register", + "method": "POST", + "content_type": "application/json", + "body_template": "{\"email\": \"{email}\", \"password\": \"{password}\"}", + "required_fields": ["email", "password"] + }, + "login": { + "endpoint": "/api/auth/login", + "method": "POST", + "content_type": "application/json", + "body_template": "{\"email\": \"{email}\", \"password\": \"{password}\"}", + "required_fields": ["email", "password"] + }, + "token_location": "body:token", + "summary": "JSON-based auth with email/password, returns JWT token in body" +} + +RULES: +1. Use {email} and {password} as placeholders in body_template +2. token_location can be: "body:fieldname", "cookie", "header" +3. If an endpoint doesn't exist, omit it from the response +4. Base your analysis on the discovered endpoints and their status codes +5. 405 status means the endpoint exists but requires a different method (usually POST) +6. Output ONLY valid JSON, no explanations"#.to_string() + } + + fn build_auth_plan_user_prompt( + &self, + auth_discovery: &super::auth_discovery::AuthDiscoveryResult, + user_instructions: Option<&str>, + target_url: &str, + analysis: &TechAnalysis, + ) -> String { + let mut endpoints_info = String::new(); + for endpoint in &auth_discovery.endpoints { + endpoints_info.push_str(&format!( + "- {} {} (status: {}, type: {})\n", + endpoint.method, endpoint.url, endpoint.status_code, endpoint.endpoint_type + )); + if !endpoint.detected_fields.is_empty() { + endpoints_info.push_str(&format!( + " Detected fields: {}\n", + endpoint.detected_fields.join(", ") + )); + } + } + + let tech_info = analysis + .technologies + .iter() + .map(|(t, score)| format!("{:?} ({:.0}%)", t, score * 100.0)) + .collect::>() + .join(", "); + + let user_instruction_text = user_instructions + .map(|i| format!("\nUser provided instructions: {}\n", i)) + .unwrap_or_default(); + + format!( + r#"Target: {} + +DETECTED TECHNOLOGIES: {} + +DISCOVERED AUTH ENDPOINTS: +{} +{} +Generate an authentication plan JSON based on these endpoints. If user instructions are provided, follow them closely."#, + target_url, tech_info, endpoints_info, user_instruction_text + ) + } + + fn parse_auth_plan_response( + &self, + response: &str, + auth_discovery: &super::auth_discovery::AuthDiscoveryResult, + target_url: &str, + ) -> super::auth_discovery::AuthPlan { + use super::auth_discovery::{AuthAction, AuthPlan, TokenLocation}; + + // Helper to ensure endpoint is an absolute URL + let make_absolute = |endpoint: &str| -> String { + if endpoint.starts_with("http://") || endpoint.starts_with("https://") { + endpoint.to_string() + } else { + // Prepend target URL to relative path + let base = target_url.trim_end_matches('/'); + let path = if endpoint.starts_with('/') { + endpoint.to_string() + } else { + format!("/{}", endpoint) + }; + format!("{}{}", base, path) + } + }; + + // Try to parse as JSON + if let Ok(json) = serde_json::from_str::(response) { + let mut plan = AuthPlan::default(); + + // Parse registration + if let Some(reg) = json.get("registration") { + let raw_endpoint = reg + .get("endpoint") + .and_then(|v| v.as_str()) + .unwrap_or(""); + plan.registration = Some(AuthAction { + endpoint: make_absolute(raw_endpoint), + method: reg + .get("method") + .and_then(|v| v.as_str()) + .unwrap_or("POST") + .to_string(), + content_type: reg + .get("content_type") + .and_then(|v| v.as_str()) + .unwrap_or("application/json") + .to_string(), + body_template: reg + .get("body_template") + .and_then(|v| v.as_str()) + .unwrap_or(r#"{"email": "{email}", "password": "{password}"}"#) + .to_string(), + required_fields: reg + .get("required_fields") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_else(|| vec!["email".to_string(), "password".to_string()]), + }); + } + + // Parse login + if let Some(login) = json.get("login") { + let raw_endpoint = login + .get("endpoint") + .and_then(|v| v.as_str()) + .unwrap_or(""); + plan.login = Some(AuthAction { + endpoint: make_absolute(raw_endpoint), + method: login + .get("method") + .and_then(|v| v.as_str()) + .unwrap_or("POST") + .to_string(), + content_type: login + .get("content_type") + .and_then(|v| v.as_str()) + .unwrap_or("application/json") + .to_string(), + body_template: login + .get("body_template") + .and_then(|v| v.as_str()) + .unwrap_or(r#"{"email": "{email}", "password": "{password}"}"#) + .to_string(), + required_fields: login + .get("required_fields") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_else(|| vec!["email".to_string(), "password".to_string()]), + }); + } + + // Parse token location + if let Some(token_loc) = json.get("token_location").and_then(|v| v.as_str()) { + plan.token_location = if token_loc.starts_with("body:") { + TokenLocation::ResponseBodyField(token_loc.trim_start_matches("body:").to_string()) + } else if token_loc == "cookie" { + TokenLocation::SetCookieHeader + } else if token_loc == "header" { + TokenLocation::AuthorizationHeader + } else { + TokenLocation::Unknown + }; + } + + // Parse summary + plan.summary = json + .get("summary") + .and_then(|v| v.as_str()) + .unwrap_or("Authentication plan generated") + .to_string(); + + return plan; + } + + // Fall back to generating a plan from discovery data + self.generate_fallback_auth_plan(auth_discovery) + } + + fn generate_fallback_auth_plan( + &self, + auth_discovery: &super::auth_discovery::AuthDiscoveryResult, + ) -> super::auth_discovery::AuthPlan { + use super::auth_discovery::{AuthAction, AuthPlan, TokenLocation}; + + let mut plan = AuthPlan::default(); + + // Create registration action if available + if let Some(ref register_endpoint) = auth_discovery.register_endpoint { + plan.registration = Some(AuthAction { + endpoint: register_endpoint.url.clone(), + method: "POST".to_string(), + content_type: register_endpoint + .content_type + .clone() + .unwrap_or_else(|| "application/json".to_string()), + body_template: r#"{"email": "{email}", "password": "{password}"}"#.to_string(), + required_fields: vec!["email".to_string(), "password".to_string()], + }); + } + + // Create login action if available + if let Some(ref login_endpoint) = auth_discovery.login_endpoint { + plan.login = Some(AuthAction { + endpoint: login_endpoint.url.clone(), + method: "POST".to_string(), + content_type: login_endpoint + .content_type + .clone() + .unwrap_or_else(|| "application/json".to_string()), + body_template: r#"{"email": "{email}", "password": "{password}"}"#.to_string(), + required_fields: vec!["email".to_string(), "password".to_string()], + }); + } + + // Default to token in response body + plan.token_location = TokenLocation::ResponseBodyField("token".to_string()); + plan.summary = format!( + "Authentication via {}", + auth_discovery.summary() + ); + + plan + } } #[cfg(test)] diff --git a/src/smart_wordlist/mod.rs b/src/smart_wordlist/mod.rs index b2bf852..b905a1a 100644 --- a/src/smart_wordlist/mod.rs +++ b/src/smart_wordlist/mod.rs @@ -10,6 +10,7 @@ //! - Static asset filtering to reduce false positives mod analyzer; +mod auth_discovery; mod generator; mod llm; mod mutations; @@ -17,6 +18,11 @@ mod probe; mod report; pub use analyzer::TechAnalysis; +pub use auth_discovery::{ + attempt_authentication, discover_auth_endpoints, generate_test_credentials, + probe_single_auth_endpoint, AuthAction, AuthDiscoveryResult, AuthEndpoint, AuthEndpointType, + AuthPlan, AuthResult, AuthTokenType, TestCredentials, TokenLocation, +}; pub use generator::{ budget_wordlist, generate_wordlist, output_attack_report, output_wordlist, BudgetConfig, GenerationResult, GeneratorConfig, diff --git a/src/smart_wordlist/report.rs b/src/smart_wordlist/report.rs index 8494881..807416b 100644 --- a/src/smart_wordlist/report.rs +++ b/src/smart_wordlist/report.rs @@ -6,6 +6,7 @@ //! Uses a signal-based scoring system to filter noise and surface //! only high-value findings worth investigating. +use super::auth_discovery::{AuthDiscoveryResult, AuthPlan, AuthResult, AuthTokenType}; use super::llm::AggregatedUsage; use console::style; use serde::{Deserialize, Serialize}; @@ -75,6 +76,8 @@ pub struct JsonOutput { pub canonical_endpoints: Vec, pub token_usage: JsonTokenUsage, pub stats: JsonStats, + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_discovery: Option, } /// Canonical endpoint in JSON output format @@ -109,6 +112,64 @@ pub struct JsonStats { pub total_filtered_noise: usize, } +/// Auth discovery in JSON output format +#[derive(Debug, Serialize)] +pub struct JsonAuthDiscovery { + /// Whether auth discovery was attempted + pub discovered: bool, + /// Whether authentication was successful + pub authenticated: bool, + /// List of discovered auth endpoints + #[serde(skip_serializing_if = "Vec::is_empty")] + pub endpoints: Vec, + /// Registration availability + pub registration_available: bool, + /// Login endpoint if found + #[serde(skip_serializing_if = "Option::is_none")] + pub login_endpoint: Option, + /// Register endpoint if found + #[serde(skip_serializing_if = "Option::is_none")] + pub register_endpoint: Option, + /// Type of authentication used (Bearer, Cookie, ApiKey, None) + pub auth_type: String, + /// Whether a new user was created during auth discovery + pub user_created: bool, + /// Summary of the auth flow + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// The auth token obtained (if any) + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, + /// Session cookies obtained (if any) + #[serde(skip_serializing_if = "Vec::is_empty")] + pub cookies: Vec, + /// Credentials used for authentication + #[serde(skip_serializing_if = "Option::is_none")] + pub credentials: Option, +} + +/// Credentials used for authentication +#[derive(Debug, Serialize)] +pub struct JsonCredentials { + pub email: String, + pub password: String, +} + +/// Auth endpoint in JSON output format +#[derive(Debug, Serialize)] +pub struct JsonAuthEndpoint { + pub url: String, + #[serde(rename = "type")] + pub endpoint_type: String, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_type: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub detected_fields: Vec, + /// Status code from auth attempt (or discovery if not attempted) + pub status_code: u16, +} + impl PentestReport { /// Create a new report pub fn new(target: String) -> Self { @@ -146,7 +207,11 @@ impl PentestReport { } /// Convert report to JSON output format - pub fn to_json_output(&self, token_usage: &AggregatedUsage) -> JsonOutput { + pub fn to_json_output( + &self, + token_usage: &AggregatedUsage, + auth_result: Option<&(AuthDiscoveryResult, AuthPlan, AuthResult)>, + ) -> JsonOutput { let canonical_endpoints: Vec = self .canonical_endpoints .iter() @@ -161,6 +226,55 @@ impl PentestReport { }) .collect(); + // Convert auth discovery results if present + let auth_discovery = auth_result.map(|(discovery, plan, result)| { + JsonAuthDiscovery { + discovered: true, + authenticated: result.success, + endpoints: discovery + .endpoints + .iter() + .map(|ep| { + // Use actual auth attempt status if available, otherwise discovery status + let status = match ep.endpoint_type { + super::auth_discovery::AuthEndpointType::Login => { + result.login_status.unwrap_or(ep.status_code) + } + super::auth_discovery::AuthEndpointType::Register => { + result.register_status.unwrap_or(ep.status_code) + } + _ => ep.status_code, + }; + JsonAuthEndpoint { + url: ep.url.clone(), + endpoint_type: format!("{:?}", ep.endpoint_type), + method: ep.method.clone(), + content_type: ep.content_type.clone(), + detected_fields: ep.detected_fields.clone(), + status_code: status, + } + }) + .collect(), + registration_available: discovery.registration_available, + login_endpoint: discovery.login_endpoint.as_ref().map(|e| e.url.clone()), + register_endpoint: discovery.register_endpoint.as_ref().map(|e| e.url.clone()), + auth_type: match result.token_type { + AuthTokenType::Bearer => "Bearer".to_string(), + AuthTokenType::Cookie => "Cookie".to_string(), + AuthTokenType::ApiKey => "ApiKey".to_string(), + AuthTokenType::None => "None".to_string(), + }, + user_created: result.user_created, + summary: Some(plan.summary.clone()), + token: result.token.clone(), + cookies: result.cookies.clone(), + credentials: result.credentials_used.as_ref().map(|creds| JsonCredentials { + email: creds.email.clone(), + password: creds.password.clone(), + }), + } + }); + JsonOutput { target: self.target.clone(), canonical_endpoints, @@ -175,6 +289,7 @@ impl PentestReport { total_paths_tested: self.stats.total_paths_tested, total_filtered_noise: self.stats.total_filtered_noise, }, + auth_discovery, } } diff --git a/src/utils.rs b/src/utils.rs index bd7843f..668810e 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -270,6 +270,13 @@ pub async fn make_request( request = request.header("User-Agent", user_agent); } + // Add auth headers if any were set after authentication discovery + if let Ok(auth_headers) = config.auth_headers.read() { + for (key, value) in auth_headers.iter() { + request = request.header(key, value); + } + } + match request.send().await { Err(e) => { log::trace!("exit: make_request -> {e}"); From 8d3b04c6c2f444193be0fa4e95b45d8dd2e6c35e Mon Sep 17 00:00:00 2001 From: Kerem Proulx Date: Wed, 14 Jan 2026 12:16:16 -0500 Subject: [PATCH 2/5] fix clippy errors --- src/smart_wordlist/auth_discovery.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/smart_wordlist/auth_discovery.rs b/src/smart_wordlist/auth_discovery.rs index 7fb6be5..02900ab 100644 --- a/src/smart_wordlist/auth_discovery.rs +++ b/src/smart_wordlist/auth_discovery.rs @@ -509,7 +509,7 @@ async fn try_register( .body_template .replace("{email}", &creds.email) .replace("{password}", &creds.password) - .replace("{username}", &creds.email.split('@').next().unwrap_or("feroxtest")); + .replace("{username}", creds.email.split('@').next().unwrap_or("feroxtest")); log::debug!( "Attempting registration: {} {} Content-Type: {}", From dac819e2a32ff609674da8b6babe1f24a64aac96 Mon Sep 17 00:00:00 2001 From: Kerem Proulx Date: Wed, 14 Jan 2026 12:18:08 -0500 Subject: [PATCH 3/5] cargo fmt --- src/main.rs | 10 +++++--- src/smart_wordlist/auth_discovery.rs | 38 ++++++++++++++-------------- src/smart_wordlist/generator.rs | 15 +++++------ src/smart_wordlist/llm.rs | 27 +++++++++----------- src/smart_wordlist/report.rs | 11 +++++--- 5 files changed, 52 insertions(+), 49 deletions(-) diff --git a/src/main.rs b/src/main.rs index 8b8f9be..9962c78 100644 --- a/src/main.rs +++ b/src/main.rs @@ -233,7 +233,8 @@ async fn wrapped_main(config: Arc) -> Result<()> { match auth_result.token_type { AuthTokenType::Bearer => { if let Some(ref token) = auth_result.token { - auth_headers.insert("Authorization".to_string(), format!("Bearer {}", token)); + auth_headers + .insert("Authorization".to_string(), format!("Bearer {}", token)); log::info!("Added Bearer token to requests"); if !config.json { eprintln!("[+] Authentication successful - added Bearer token to requests"); @@ -258,7 +259,9 @@ async fn wrapped_main(config: Arc) -> Result<()> { auth_headers.insert("X-API-Key".to_string(), token.clone()); log::info!("Added API key to requests"); if !config.json { - eprintln!("[+] Authentication successful - added API key to requests"); + eprintln!( + "[+] Authentication successful - added API key to requests" + ); } } } @@ -779,7 +782,8 @@ async fn wrapped_main(config: Arc) -> Result<()> { // Output the comprehensive report if config.json { // JSON output to stdout only - let json_output = pentest_report.to_json_output(&token_usage, auth_result_for_report.as_ref()); + let json_output = + pentest_report.to_json_output(&token_usage, auth_result_for_report.as_ref()); println!( "{}", serde_json::to_string_pretty(&json_output).unwrap_or_else(|e| { diff --git a/src/smart_wordlist/auth_discovery.rs b/src/smart_wordlist/auth_discovery.rs index 02900ab..99d37a3 100644 --- a/src/smart_wordlist/auth_discovery.rs +++ b/src/smart_wordlist/auth_discovery.rs @@ -151,7 +151,7 @@ const AUTH_PATHS: &[(&str, AuthEndpointType)] = &[ ("/api/token", AuthEndpointType::TokenRefresh), ("/oauth/token", AuthEndpointType::OAuth), ("/api/auth/session", AuthEndpointType::Session), // NextAuth - ("/api/auth/csrf", AuthEndpointType::Session), // NextAuth + ("/api/auth/csrf", AuthEndpointType::Session), // NextAuth // Logout ("/logout", AuthEndpointType::Logout), ("/signout", AuthEndpointType::Logout), @@ -229,11 +229,7 @@ async fn probe_auth_endpoint( client: &Client, ) -> Result> { // Try GET first - let get_response = client - .get(url) - .timeout(Duration::from_secs(5)) - .send() - .await; + let get_response = client.get(url).timeout(Duration::from_secs(5)).send().await; let (status_code, content_type, is_auth_endpoint) = match get_response { Ok(resp) => { @@ -311,10 +307,7 @@ async fn probe_auth_endpoint( } /// Probe a single manually-specified auth endpoint -pub async fn probe_single_auth_endpoint( - url: &str, - client: &Client, -) -> Result { +pub async fn probe_single_auth_endpoint(url: &str, client: &Client) -> Result { let endpoint_type = infer_endpoint_type(url); let mut result = AuthDiscoveryResult::default(); @@ -455,7 +448,10 @@ pub async fn attempt_authentication( log::warn!( "Login returned non-success status {}: {}", login_status, - login_result.error_message.as_deref().unwrap_or("unknown") + login_result + .error_message + .as_deref() + .unwrap_or("unknown") ); // Still keep the registration success and credentials result.error_message = login_result.error_message; @@ -467,9 +463,13 @@ pub async fn attempt_authentication( } } } else { - log::warn!("No login action in auth plan, cannot login after registration"); - result.error_message = - Some("Registration succeeded but no login endpoint available".to_string()); + log::warn!( + "No login action in auth plan, cannot login after registration" + ); + result.error_message = Some( + "Registration succeeded but no login endpoint available" + .to_string(), + ); } return Ok(result); @@ -509,7 +509,10 @@ async fn try_register( .body_template .replace("{email}", &creds.email) .replace("{password}", &creds.password) - .replace("{username}", creds.email.split('@').next().unwrap_or("feroxtest")); + .replace( + "{username}", + creds.email.split('@').next().unwrap_or("feroxtest"), + ); log::debug!( "Attempting registration: {} {} Content-Type: {}", @@ -716,10 +719,7 @@ mod tests { infer_endpoint_type("/api/auth/register"), AuthEndpointType::Register ); - assert_eq!( - infer_endpoint_type("/oauth/token"), - AuthEndpointType::OAuth - ); + assert_eq!(infer_endpoint_type("/oauth/token"), AuthEndpointType::OAuth); assert_eq!( infer_endpoint_type("/forgot-password"), AuthEndpointType::PasswordReset diff --git a/src/smart_wordlist/generator.rs b/src/smart_wordlist/generator.rs index 7833527..c2b8df7 100644 --- a/src/smart_wordlist/generator.rs +++ b/src/smart_wordlist/generator.rs @@ -115,7 +115,10 @@ pub async fn generate_wordlist( let discovery = if let Some(ref manual_endpoint) = config.auth_endpoint { // Manual endpoint provided - probe just that - log::info!("Using manually specified auth endpoint: {}", manual_endpoint); + log::info!( + "Using manually specified auth endpoint: {}", + manual_endpoint + ); let full_url = if manual_endpoint.starts_with("http") { manual_endpoint.clone() } else { @@ -170,13 +173,9 @@ pub async fn generate_wordlist( eprintln!("[*] Attempting authentication..."); } } - let auth_attempt = attempt_authentication( - &discovery, - &auth_plan, - http_client, - config.auto_register, - ) - .await?; + let auth_attempt = + attempt_authentication(&discovery, &auth_plan, http_client, config.auto_register) + .await?; if auth_attempt.success { if !config.json { diff --git a/src/smart_wordlist/llm.rs b/src/smart_wordlist/llm.rs index 2069bee..d8e3e24 100644 --- a/src/smart_wordlist/llm.rs +++ b/src/smart_wordlist/llm.rs @@ -491,8 +491,12 @@ Output the wordlist now:"#, analysis: &TechAnalysis, ) -> Result<(super::auth_discovery::AuthPlan, UsageMetrics)> { let system_prompt = self.build_auth_plan_system_prompt(); - let user_prompt = - self.build_auth_plan_user_prompt(auth_discovery, user_instructions, target_url, analysis); + let user_prompt = self.build_auth_plan_user_prompt( + auth_discovery, + user_instructions, + target_url, + analysis, + ); let request = ClaudeRequest { model: CLAUDE_MODEL.to_string(), @@ -648,10 +652,7 @@ Generate an authentication plan JSON based on these endpoints. If user instructi // Parse registration if let Some(reg) = json.get("registration") { - let raw_endpoint = reg - .get("endpoint") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let raw_endpoint = reg.get("endpoint").and_then(|v| v.as_str()).unwrap_or(""); plan.registration = Some(AuthAction { endpoint: make_absolute(raw_endpoint), method: reg @@ -683,10 +684,7 @@ Generate an authentication plan JSON based on these endpoints. If user instructi // Parse login if let Some(login) = json.get("login") { - let raw_endpoint = login - .get("endpoint") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let raw_endpoint = login.get("endpoint").and_then(|v| v.as_str()).unwrap_or(""); plan.login = Some(AuthAction { endpoint: make_absolute(raw_endpoint), method: login @@ -719,7 +717,9 @@ Generate an authentication plan JSON based on these endpoints. If user instructi // Parse token location if let Some(token_loc) = json.get("token_location").and_then(|v| v.as_str()) { plan.token_location = if token_loc.starts_with("body:") { - TokenLocation::ResponseBodyField(token_loc.trim_start_matches("body:").to_string()) + TokenLocation::ResponseBodyField( + token_loc.trim_start_matches("body:").to_string(), + ) } else if token_loc == "cookie" { TokenLocation::SetCookieHeader } else if token_loc == "header" { @@ -781,10 +781,7 @@ Generate an authentication plan JSON based on these endpoints. If user instructi // Default to token in response body plan.token_location = TokenLocation::ResponseBodyField("token".to_string()); - plan.summary = format!( - "Authentication via {}", - auth_discovery.summary() - ); + plan.summary = format!("Authentication via {}", auth_discovery.summary()); plan } diff --git a/src/smart_wordlist/report.rs b/src/smart_wordlist/report.rs index 807416b..7f67c5d 100644 --- a/src/smart_wordlist/report.rs +++ b/src/smart_wordlist/report.rs @@ -268,10 +268,13 @@ impl PentestReport { summary: Some(plan.summary.clone()), token: result.token.clone(), cookies: result.cookies.clone(), - credentials: result.credentials_used.as_ref().map(|creds| JsonCredentials { - email: creds.email.clone(), - password: creds.password.clone(), - }), + credentials: result + .credentials_used + .as_ref() + .map(|creds| JsonCredentials { + email: creds.email.clone(), + password: creds.password.clone(), + }), } }); From 8c95bac75afa1ecb5ff01598561f60e9fd5f6387 Mon Sep 17 00:00:00 2001 From: Kerem Proulx Date: Wed, 14 Jan 2026 12:27:29 -0500 Subject: [PATCH 4/5] add tests --- src/smart_wordlist/auth_discovery.rs | 324 ++++++++++++++++++++++++++- 1 file changed, 322 insertions(+), 2 deletions(-) diff --git a/src/smart_wordlist/auth_discovery.rs b/src/smart_wordlist/auth_discovery.rs index 99d37a3..8f735e5 100644 --- a/src/smart_wordlist/auth_discovery.rs +++ b/src/smart_wordlist/auth_discovery.rs @@ -113,7 +113,7 @@ pub struct AuthAction { } /// Where the auth token appears in the response -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "type", content = "field")] pub enum TokenLocation { ResponseBodyField(String), @@ -711,6 +711,7 @@ mod tests { #[test] fn test_infer_endpoint_type() { + // Test basic endpoint type inference assert_eq!( infer_endpoint_type("/api/auth/login"), AuthEndpointType::Login @@ -719,16 +720,335 @@ mod tests { infer_endpoint_type("/api/auth/register"), AuthEndpointType::Register ); - assert_eq!(infer_endpoint_type("/oauth/token"), AuthEndpointType::OAuth); + // Note: /oauth/token returns TokenRefresh because "token" is matched before "oauth" + assert_eq!( + infer_endpoint_type("/oauth/token"), + AuthEndpointType::TokenRefresh + ); assert_eq!( infer_endpoint_type("/forgot-password"), AuthEndpointType::PasswordReset ); + // Pure oauth path (no token) returns OAuth + assert_eq!(infer_endpoint_type("/oauth/authorize"), AuthEndpointType::OAuth); + } + + #[test] + fn test_infer_endpoint_type_login_variants() { + // Test various login endpoint patterns (must contain "login" or "signin") + assert_eq!( + infer_endpoint_type("/api/auth/login"), + AuthEndpointType::Login + ); + assert_eq!(infer_endpoint_type("/signin"), AuthEndpointType::Login); + assert_eq!( + infer_endpoint_type("/user/login"), + AuthEndpointType::Login + ); + } + + #[test] + fn test_infer_endpoint_type_register_variants() { + // Test various register endpoint patterns (must contain "register" or "signup") + assert_eq!( + infer_endpoint_type("/api/auth/register"), + AuthEndpointType::Register + ); + assert_eq!(infer_endpoint_type("/signup"), AuthEndpointType::Register); + assert_eq!( + infer_endpoint_type("/user/register"), + AuthEndpointType::Register + ); + } + + #[test] + fn test_infer_endpoint_type_logout_variants() { + assert_eq!(infer_endpoint_type("/logout"), AuthEndpointType::Logout); + assert_eq!(infer_endpoint_type("/signout"), AuthEndpointType::Logout); + assert_eq!( + infer_endpoint_type("/api/auth/logout"), + AuthEndpointType::Logout + ); + assert_eq!( + infer_endpoint_type("/api/logout"), + AuthEndpointType::Logout + ); + } + + #[test] + fn test_infer_endpoint_type_unknown() { + // Unknown paths return Unknown + assert_eq!( + infer_endpoint_type("/api/users"), + AuthEndpointType::Unknown + ); + assert_eq!( + infer_endpoint_type("/some/random/path"), + AuthEndpointType::Unknown + ); } #[test] fn test_auth_endpoint_type_display() { assert_eq!(format!("{}", AuthEndpointType::Login), "login"); assert_eq!(format!("{}", AuthEndpointType::Register), "register"); + assert_eq!(format!("{}", AuthEndpointType::Logout), "logout"); + assert_eq!(format!("{}", AuthEndpointType::OAuth), "oauth"); + assert_eq!(format!("{}", AuthEndpointType::Session), "session"); + assert_eq!(format!("{}", AuthEndpointType::TokenRefresh), "token_refresh"); + assert_eq!(format!("{}", AuthEndpointType::PasswordReset), "password_reset"); + } + + #[test] + fn test_auth_result_has_auth_with_token() { + let result = AuthResult { + success: true, + token: Some("test_token".to_string()), + cookies: vec![], + token_type: AuthTokenType::Bearer, + ..Default::default() + }; + assert!(result.has_auth()); + } + + #[test] + fn test_auth_result_has_auth_with_cookies() { + let result = AuthResult { + success: true, + token: None, + cookies: vec!["session=abc123".to_string()], + token_type: AuthTokenType::Cookie, + ..Default::default() + }; + assert!(result.has_auth()); + } + + #[test] + fn test_auth_result_has_auth_failure() { + // No auth if success is false + let result = AuthResult { + success: false, + token: Some("test_token".to_string()), + cookies: vec![], + ..Default::default() + }; + assert!(!result.has_auth()); + + // No auth if no token or cookies + let result = AuthResult { + success: true, + token: None, + cookies: vec![], + ..Default::default() + }; + assert!(!result.has_auth()); + } + + #[test] + fn test_auth_discovery_result_summary_login_only() { + let result = AuthDiscoveryResult { + login_endpoint: Some(AuthEndpoint { + url: "http://example.com/api/login".to_string(), + endpoint_type: AuthEndpointType::Login, + method: "POST".to_string(), + content_type: Some("application/json".to_string()), + detected_fields: vec!["email".to_string(), "password".to_string()], + status_code: 200, + }), + register_endpoint: None, + ..Default::default() + }; + let summary = result.summary(); + assert!(summary.contains("login=http://example.com/api/login")); + assert!(!summary.contains("register=")); + } + + #[test] + fn test_auth_discovery_result_summary_both_endpoints() { + let result = AuthDiscoveryResult { + login_endpoint: Some(AuthEndpoint { + url: "http://example.com/api/login".to_string(), + endpoint_type: AuthEndpointType::Login, + method: "POST".to_string(), + content_type: None, + detected_fields: vec![], + status_code: 200, + }), + register_endpoint: Some(AuthEndpoint { + url: "http://example.com/api/register".to_string(), + endpoint_type: AuthEndpointType::Register, + method: "POST".to_string(), + content_type: None, + detected_fields: vec![], + status_code: 201, + }), + registration_available: true, + ..Default::default() + }; + let summary = result.summary(); + assert!(summary.contains("login=http://example.com/api/login")); + assert!(summary.contains("register=http://example.com/api/register")); + } + + #[test] + fn test_auth_discovery_result_summary_no_endpoints() { + let result = AuthDiscoveryResult::default(); + let summary = result.summary(); + assert_eq!(summary, "No auth endpoints discovered"); + } + + #[test] + fn test_auth_token_type_default() { + let token_type = AuthTokenType::default(); + assert_eq!(token_type, AuthTokenType::None); + } + + #[test] + fn test_token_location_default() { + let location = TokenLocation::default(); + assert_eq!(location, TokenLocation::Unknown); + } + + #[test] + fn test_auth_result_default() { + let result = AuthResult::default(); + assert!(!result.success); + assert!(result.token.is_none()); + assert!(result.cookies.is_empty()); + assert_eq!(result.token_type, AuthTokenType::None); + assert!(!result.user_created); + assert!(result.credentials_used.is_none()); + assert!(result.error_message.is_none()); + assert!(result.register_status.is_none()); + assert!(result.login_status.is_none()); + } + + #[test] + fn test_auth_endpoint_clone() { + let endpoint = AuthEndpoint { + url: "http://example.com/login".to_string(), + endpoint_type: AuthEndpointType::Login, + method: "POST".to_string(), + content_type: Some("application/json".to_string()), + detected_fields: vec!["email".to_string(), "password".to_string()], + status_code: 200, + }; + let cloned = endpoint.clone(); + assert_eq!(endpoint.url, cloned.url); + assert_eq!(endpoint.endpoint_type, cloned.endpoint_type); + assert_eq!(endpoint.method, cloned.method); + assert_eq!(endpoint.content_type, cloned.content_type); + assert_eq!(endpoint.detected_fields, cloned.detected_fields); + assert_eq!(endpoint.status_code, cloned.status_code); + } + + #[test] + fn test_auth_action_fields() { + let action = AuthAction { + endpoint: "http://example.com/api/login".to_string(), + method: "POST".to_string(), + content_type: "application/json".to_string(), + body_template: r#"{"email": "{email}", "password": "{password}"}"#.to_string(), + required_fields: vec!["email".to_string(), "password".to_string()], + }; + assert_eq!(action.endpoint, "http://example.com/api/login"); + assert_eq!(action.method, "POST"); + assert_eq!(action.content_type, "application/json"); + assert!(action.body_template.contains("{email}")); + assert!(action.body_template.contains("{password}")); + assert_eq!(action.required_fields.len(), 2); + } + + #[test] + fn test_auth_plan_default() { + let plan = AuthPlan::default(); + assert!(plan.registration.is_none()); + assert!(plan.login.is_none()); + assert_eq!(plan.token_location, TokenLocation::Unknown); + assert!(plan.summary.is_empty()); + } + + #[test] + fn test_test_credentials_serialization() { + let creds = TestCredentials { + email: "test@example.com".to_string(), + password: "secret123".to_string(), + }; + let json = serde_json::to_string(&creds).unwrap(); + assert!(json.contains("test@example.com")); + assert!(json.contains("secret123")); + + let deserialized: TestCredentials = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.email, creds.email); + assert_eq!(deserialized.password, creds.password); + } + + #[test] + fn test_body_template_replacement() { + let template = r#"{"email": "{email}", "password": "{password}", "username": "{username}"}"#; + let email = "test@example.com"; + let password = "secret123"; + let username = "testuser"; + + let body = template + .replace("{email}", email) + .replace("{password}", password) + .replace("{username}", username); + + assert!(body.contains("test@example.com")); + assert!(body.contains("secret123")); + assert!(body.contains("testuser")); + assert!(!body.contains("{email}")); + assert!(!body.contains("{password}")); + assert!(!body.contains("{username}")); + } + + #[test] + fn test_auth_paths_contains_expected() { + // Verify AUTH_PATHS contains essential auth endpoints + let paths: Vec<&str> = AUTH_PATHS.iter().map(|(p, _)| *p).collect(); + + assert!(paths.contains(&"/login")); + assert!(paths.contains(&"/register")); + assert!(paths.contains(&"/api/auth/login")); + assert!(paths.contains(&"/api/auth/register")); + assert!(paths.contains(&"/logout")); + assert!(paths.contains(&"/oauth/token")); + } + + #[test] + fn test_auth_paths_types() { + // Verify each path has the correct type + for (path, endpoint_type) in AUTH_PATHS.iter() { + let path_str: &str = path; + if path_str.contains("login") + || path_str.contains("signin") + || path_str.contains("sign_in") + { + assert_eq!( + *endpoint_type, + AuthEndpointType::Login, + "Path {} should be Login type", + path + ); + } else if path_str.contains("register") + || path_str.contains("signup") + || path_str.contains("sign_up") + { + assert_eq!( + *endpoint_type, + AuthEndpointType::Register, + "Path {} should be Register type", + path + ); + } else if path_str.contains("logout") || path_str.contains("signout") { + assert_eq!( + *endpoint_type, + AuthEndpointType::Logout, + "Path {} should be Logout type", + path + ); + } + } } } From f538b2745d0677c51bd2be61327d28f549701fea Mon Sep 17 00:00:00 2001 From: Kerem Proulx Date: Wed, 14 Jan 2026 12:29:09 -0500 Subject: [PATCH 5/5] cargo fmt --- src/smart_wordlist/auth_discovery.rs | 33 ++++++++++++++-------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/smart_wordlist/auth_discovery.rs b/src/smart_wordlist/auth_discovery.rs index 8f735e5..68fc9f7 100644 --- a/src/smart_wordlist/auth_discovery.rs +++ b/src/smart_wordlist/auth_discovery.rs @@ -730,7 +730,10 @@ mod tests { AuthEndpointType::PasswordReset ); // Pure oauth path (no token) returns OAuth - assert_eq!(infer_endpoint_type("/oauth/authorize"), AuthEndpointType::OAuth); + assert_eq!( + infer_endpoint_type("/oauth/authorize"), + AuthEndpointType::OAuth + ); } #[test] @@ -741,10 +744,7 @@ mod tests { AuthEndpointType::Login ); assert_eq!(infer_endpoint_type("/signin"), AuthEndpointType::Login); - assert_eq!( - infer_endpoint_type("/user/login"), - AuthEndpointType::Login - ); + assert_eq!(infer_endpoint_type("/user/login"), AuthEndpointType::Login); } #[test] @@ -769,19 +769,13 @@ mod tests { infer_endpoint_type("/api/auth/logout"), AuthEndpointType::Logout ); - assert_eq!( - infer_endpoint_type("/api/logout"), - AuthEndpointType::Logout - ); + assert_eq!(infer_endpoint_type("/api/logout"), AuthEndpointType::Logout); } #[test] fn test_infer_endpoint_type_unknown() { // Unknown paths return Unknown - assert_eq!( - infer_endpoint_type("/api/users"), - AuthEndpointType::Unknown - ); + assert_eq!(infer_endpoint_type("/api/users"), AuthEndpointType::Unknown); assert_eq!( infer_endpoint_type("/some/random/path"), AuthEndpointType::Unknown @@ -795,8 +789,14 @@ mod tests { assert_eq!(format!("{}", AuthEndpointType::Logout), "logout"); assert_eq!(format!("{}", AuthEndpointType::OAuth), "oauth"); assert_eq!(format!("{}", AuthEndpointType::Session), "session"); - assert_eq!(format!("{}", AuthEndpointType::TokenRefresh), "token_refresh"); - assert_eq!(format!("{}", AuthEndpointType::PasswordReset), "password_reset"); + assert_eq!( + format!("{}", AuthEndpointType::TokenRefresh), + "token_refresh" + ); + assert_eq!( + format!("{}", AuthEndpointType::PasswordReset), + "password_reset" + ); } #[test] @@ -985,7 +985,8 @@ mod tests { #[test] fn test_body_template_replacement() { - let template = r#"{"email": "{email}", "password": "{password}", "username": "{username}"}"#; + let template = + r#"{"email": "{email}", "password": "{password}", "username": "{username}"}"#; let email = "test@example.com"; let password = "secret123"; let username = "testuser";