use super::categories::{OwaspCategory, Severity};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OwaspApiConfig {
#[serde(default)]
pub categories: HashSet<OwaspCategory>,
#[serde(default = "default_auth_header")]
pub auth_header: String,
#[serde(default)]
pub admin_paths_file: Option<PathBuf>,
#[serde(default)]
pub admin_paths: Vec<String>,
#[serde(default = "default_id_fields")]
pub id_fields: Vec<String>,
#[serde(default)]
pub valid_auth_token: Option<String>,
#[serde(default)]
pub alt_auth_tokens: Vec<AuthToken>,
#[serde(default = "default_report_path")]
pub report_path: PathBuf,
#[serde(default)]
pub report_format: ReportFormat,
#[serde(default)]
pub min_severity: Severity,
#[serde(default)]
pub rate_limit_config: RateLimitConfig,
#[serde(default)]
pub ssrf_config: SsrfConfig,
#[serde(default)]
pub discovery_config: DiscoveryConfig,
#[serde(default)]
pub verbose: bool,
#[serde(default = "default_concurrency")]
pub concurrency: usize,
#[serde(default = "default_timeout")]
pub timeout_ms: u64,
#[serde(default)]
pub insecure: bool,
#[serde(default = "default_iterations")]
pub iterations: usize,
#[serde(default)]
pub base_path: Option<String>,
#[serde(default)]
pub custom_headers: HashMap<String, String>,
}
fn default_auth_header() -> String {
"Authorization".to_string()
}
fn default_id_fields() -> Vec<String> {
vec![
"id".to_string(),
"uuid".to_string(),
"user_id".to_string(),
"userId".to_string(),
"account_id".to_string(),
"accountId".to_string(),
"resource_id".to_string(),
"resourceId".to_string(),
]
}
fn default_report_path() -> PathBuf {
PathBuf::from("owasp-report.json")
}
fn default_concurrency() -> usize {
10
}
fn default_iterations() -> usize {
1
}
fn default_timeout() -> u64 {
30000
}
impl Default for OwaspApiConfig {
fn default() -> Self {
Self {
categories: HashSet::new(),
auth_header: default_auth_header(),
admin_paths_file: None,
admin_paths: Vec::new(),
id_fields: default_id_fields(),
valid_auth_token: None,
alt_auth_tokens: Vec::new(),
report_path: default_report_path(),
report_format: ReportFormat::default(),
min_severity: Severity::Low,
rate_limit_config: RateLimitConfig::default(),
ssrf_config: SsrfConfig::default(),
discovery_config: DiscoveryConfig::default(),
verbose: false,
concurrency: default_concurrency(),
timeout_ms: default_timeout(),
insecure: false,
iterations: default_iterations(),
base_path: None,
custom_headers: HashMap::new(),
}
}
}
impl OwaspApiConfig {
pub fn new() -> Self {
Self::default()
}
pub fn categories_to_test(&self) -> Vec<OwaspCategory> {
if self.categories.is_empty() {
OwaspCategory::all()
} else {
self.categories.iter().copied().collect()
}
}
pub fn should_test_category(&self, category: OwaspCategory) -> bool {
self.categories.is_empty() || self.categories.contains(&category)
}
pub fn load_admin_paths(&mut self) -> Result<(), std::io::Error> {
if let Some(ref path) = self.admin_paths_file {
let content = std::fs::read_to_string(path)?;
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() && !trimmed.starts_with('#') {
self.admin_paths.push(trimmed.to_string());
}
}
}
Ok(())
}
pub fn all_admin_paths(&self) -> Vec<&str> {
let mut paths: Vec<&str> = self.admin_paths.iter().map(String::as_str).collect();
if paths.is_empty() {
paths.extend(DEFAULT_ADMIN_PATHS.iter().copied());
}
paths
}
pub fn with_categories(mut self, categories: impl IntoIterator<Item = OwaspCategory>) -> Self {
self.categories = categories.into_iter().collect();
self
}
pub fn with_auth_header(mut self, header: impl Into<String>) -> Self {
self.auth_header = header.into();
self
}
pub fn with_valid_auth_token(mut self, token: impl Into<String>) -> Self {
self.valid_auth_token = Some(token.into());
self
}
pub fn with_admin_paths(mut self, paths: impl IntoIterator<Item = String>) -> Self {
self.admin_paths.extend(paths);
self
}
pub fn with_id_fields(mut self, fields: impl IntoIterator<Item = String>) -> Self {
self.id_fields = fields.into_iter().collect();
self
}
pub fn with_report_path(mut self, path: impl Into<PathBuf>) -> Self {
self.report_path = path.into();
self
}
pub fn with_report_format(mut self, format: ReportFormat) -> Self {
self.report_format = format;
self
}
pub fn with_verbose(mut self, verbose: bool) -> Self {
self.verbose = verbose;
self
}
pub fn with_insecure(mut self, insecure: bool) -> Self {
self.insecure = insecure;
self
}
pub fn with_concurrency(mut self, concurrency: usize) -> Self {
self.concurrency = concurrency;
self
}
pub fn with_iterations(mut self, iterations: usize) -> Self {
self.iterations = iterations;
self
}
pub fn with_base_path(mut self, base_path: Option<String>) -> Self {
self.base_path = base_path;
self
}
pub fn with_custom_headers(mut self, headers: HashMap<String, String>) -> Self {
self.custom_headers = headers;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthToken {
pub value: String,
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub user_id: Option<String>,
}
impl AuthToken {
pub fn new(value: impl Into<String>) -> Self {
Self {
value: value.into(),
role: None,
user_id: None,
}
}
pub fn with_role(mut self, role: impl Into<String>) -> Self {
self.role = Some(role.into());
self
}
pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
self.user_id = Some(user_id.into());
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ReportFormat {
#[default]
Json,
Sarif,
}
impl std::str::FromStr for ReportFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"json" => Ok(Self::Json),
"sarif" => Ok(Self::Sarif),
_ => Err(format!("Unknown report format: '{}'. Valid values: json, sarif", s)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitConfig {
#[serde(default = "default_burst_size")]
pub burst_size: usize,
#[serde(default = "default_max_limit")]
pub max_limit: usize,
#[serde(default = "default_large_payload_size")]
pub large_payload_size: usize,
#[serde(default = "default_max_nesting")]
pub max_nesting_depth: usize,
}
fn default_burst_size() -> usize {
100
}
fn default_max_limit() -> usize {
100000
}
fn default_large_payload_size() -> usize {
10 * 1024 * 1024 }
fn default_max_nesting() -> usize {
100
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
burst_size: default_burst_size(),
max_limit: default_max_limit(),
large_payload_size: default_large_payload_size(),
max_nesting_depth: default_max_nesting(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SsrfConfig {
#[serde(default = "default_internal_urls")]
pub internal_urls: Vec<String>,
#[serde(default = "default_metadata_urls")]
pub metadata_urls: Vec<String>,
#[serde(default)]
pub url_fields: Vec<String>,
}
fn default_internal_urls() -> Vec<String> {
vec![
"http://localhost/".to_string(),
"http://127.0.0.1/".to_string(),
"http://[::1]/".to_string(),
"http://0.0.0.0/".to_string(),
"http://localhost:8080/".to_string(),
"http://localhost:3000/".to_string(),
"http://localhost:9000/".to_string(),
"http://internal/".to_string(),
"http://backend/".to_string(),
]
}
fn default_metadata_urls() -> Vec<String> {
vec![
"http://169.254.169.254/latest/meta-data/".to_string(),
"http://169.254.169.254/latest/user-data/".to_string(),
"http://metadata.google.internal/computeMetadata/v1/".to_string(),
"http://169.254.169.254/metadata/instance".to_string(),
"http://169.254.169.254/metadata/v1/".to_string(),
"http://100.100.100.200/latest/meta-data/".to_string(),
]
}
impl Default for SsrfConfig {
fn default() -> Self {
Self {
internal_urls: default_internal_urls(),
metadata_urls: default_metadata_urls(),
url_fields: vec![
"url".to_string(),
"uri".to_string(),
"link".to_string(),
"href".to_string(),
"callback".to_string(),
"redirect".to_string(),
"return_url".to_string(),
"webhook".to_string(),
"image_url".to_string(),
"fetch_url".to_string(),
],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveryConfig {
#[serde(default = "default_api_versions")]
pub api_versions: Vec<String>,
#[serde(default = "default_discovery_paths")]
pub discovery_paths: Vec<String>,
#[serde(default = "default_true")]
pub check_deprecated: bool,
}
fn default_api_versions() -> Vec<String> {
vec![
"v1".to_string(),
"v2".to_string(),
"v3".to_string(),
"v4".to_string(),
"api/v1".to_string(),
"api/v2".to_string(),
"api/v3".to_string(),
]
}
fn default_discovery_paths() -> Vec<String> {
vec![
"/swagger".to_string(),
"/swagger-ui".to_string(),
"/swagger.json".to_string(),
"/swagger.yaml".to_string(),
"/api-docs".to_string(),
"/openapi".to_string(),
"/openapi.json".to_string(),
"/openapi.yaml".to_string(),
"/graphql".to_string(),
"/graphiql".to_string(),
"/playground".to_string(),
"/debug".to_string(),
"/debug/".to_string(),
"/actuator".to_string(),
"/actuator/health".to_string(),
"/actuator/info".to_string(),
"/actuator/env".to_string(),
"/metrics".to_string(),
"/health".to_string(),
"/healthz".to_string(),
"/status".to_string(),
"/info".to_string(),
"/.env".to_string(),
"/config".to_string(),
"/admin".to_string(),
"/internal".to_string(),
"/test".to_string(),
"/dev".to_string(),
]
}
fn default_true() -> bool {
true
}
impl Default for DiscoveryConfig {
fn default() -> Self {
Self {
api_versions: default_api_versions(),
discovery_paths: default_discovery_paths(),
check_deprecated: default_true(),
}
}
}
pub const DEFAULT_ADMIN_PATHS: &[&str] = &[
"/admin",
"/admin/",
"/admin/users",
"/admin/settings",
"/admin/config",
"/api/admin",
"/api/admin/",
"/api/admin/users",
"/api/v1/admin",
"/api/v2/admin",
"/management",
"/manage",
"/internal",
"/internal/",
"/system",
"/system/config",
"/settings",
"/config",
"/users/admin",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = OwaspApiConfig::default();
assert!(config.categories.is_empty());
assert_eq!(config.auth_header, "Authorization");
assert!(!config.id_fields.is_empty());
}
#[test]
fn test_categories_to_test() {
let config = OwaspApiConfig::default();
assert_eq!(config.categories_to_test().len(), 10);
let config = OwaspApiConfig::default()
.with_categories([OwaspCategory::Api1Bola, OwaspCategory::Api7Ssrf]);
assert_eq!(config.categories_to_test().len(), 2);
}
#[test]
fn test_should_test_category() {
let config = OwaspApiConfig::default();
assert!(config.should_test_category(OwaspCategory::Api1Bola));
let config = OwaspApiConfig::default().with_categories([OwaspCategory::Api1Bola]);
assert!(config.should_test_category(OwaspCategory::Api1Bola));
assert!(!config.should_test_category(OwaspCategory::Api2BrokenAuth));
}
#[test]
fn test_builder_pattern() {
let config = OwaspApiConfig::new()
.with_auth_header("X-Auth-Token")
.with_valid_auth_token("secret123")
.with_verbose(true);
assert_eq!(config.auth_header, "X-Auth-Token");
assert_eq!(config.valid_auth_token, Some("secret123".to_string()));
assert!(config.verbose);
}
#[test]
fn test_report_format_from_str() {
assert_eq!("json".parse::<ReportFormat>().unwrap(), ReportFormat::Json);
assert_eq!("sarif".parse::<ReportFormat>().unwrap(), ReportFormat::Sarif);
assert!("invalid".parse::<ReportFormat>().is_err());
}
}