1use super::categories::{OwaspCategory, Severity};
6use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8use std::path::PathBuf;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct OwaspApiConfig {
13 #[serde(default)]
15 pub categories: HashSet<OwaspCategory>,
16
17 #[serde(default = "default_auth_header")]
19 pub auth_header: String,
20
21 #[serde(default)]
23 pub admin_paths_file: Option<PathBuf>,
24
25 #[serde(default)]
27 pub admin_paths: Vec<String>,
28
29 #[serde(default = "default_id_fields")]
31 pub id_fields: Vec<String>,
32
33 #[serde(default)]
35 pub valid_auth_token: Option<String>,
36
37 #[serde(default)]
39 pub alt_auth_tokens: Vec<AuthToken>,
40
41 #[serde(default = "default_report_path")]
43 pub report_path: PathBuf,
44
45 #[serde(default)]
47 pub report_format: ReportFormat,
48
49 #[serde(default)]
51 pub min_severity: Severity,
52
53 #[serde(default)]
55 pub rate_limit_config: RateLimitConfig,
56
57 #[serde(default)]
59 pub ssrf_config: SsrfConfig,
60
61 #[serde(default)]
63 pub discovery_config: DiscoveryConfig,
64
65 #[serde(default)]
67 pub verbose: bool,
68
69 #[serde(default = "default_concurrency")]
71 pub concurrency: usize,
72
73 #[serde(default = "default_timeout")]
75 pub timeout_ms: u64,
76}
77
78fn default_auth_header() -> String {
79 "Authorization".to_string()
80}
81
82fn default_id_fields() -> Vec<String> {
83 vec![
84 "id".to_string(),
85 "uuid".to_string(),
86 "user_id".to_string(),
87 "userId".to_string(),
88 "account_id".to_string(),
89 "accountId".to_string(),
90 "resource_id".to_string(),
91 "resourceId".to_string(),
92 ]
93}
94
95fn default_report_path() -> PathBuf {
96 PathBuf::from("owasp-report.json")
97}
98
99fn default_concurrency() -> usize {
100 10
101}
102
103fn default_timeout() -> u64 {
104 30000
105}
106
107impl Default for OwaspApiConfig {
108 fn default() -> Self {
109 Self {
110 categories: HashSet::new(),
111 auth_header: default_auth_header(),
112 admin_paths_file: None,
113 admin_paths: Vec::new(),
114 id_fields: default_id_fields(),
115 valid_auth_token: None,
116 alt_auth_tokens: Vec::new(),
117 report_path: default_report_path(),
118 report_format: ReportFormat::default(),
119 min_severity: Severity::Low,
120 rate_limit_config: RateLimitConfig::default(),
121 ssrf_config: SsrfConfig::default(),
122 discovery_config: DiscoveryConfig::default(),
123 verbose: false,
124 concurrency: default_concurrency(),
125 timeout_ms: default_timeout(),
126 }
127 }
128}
129
130impl OwaspApiConfig {
131 pub fn new() -> Self {
133 Self::default()
134 }
135
136 pub fn categories_to_test(&self) -> Vec<OwaspCategory> {
138 if self.categories.is_empty() {
139 OwaspCategory::all()
140 } else {
141 self.categories.iter().copied().collect()
142 }
143 }
144
145 pub fn should_test_category(&self, category: OwaspCategory) -> bool {
147 self.categories.is_empty() || self.categories.contains(&category)
148 }
149
150 pub fn load_admin_paths(&mut self) -> Result<(), std::io::Error> {
152 if let Some(ref path) = self.admin_paths_file {
153 let content = std::fs::read_to_string(path)?;
154 for line in content.lines() {
155 let trimmed = line.trim();
156 if !trimmed.is_empty() && !trimmed.starts_with('#') {
157 self.admin_paths.push(trimmed.to_string());
158 }
159 }
160 }
161 Ok(())
162 }
163
164 pub fn all_admin_paths(&self) -> Vec<&str> {
166 let mut paths: Vec<&str> = self.admin_paths.iter().map(String::as_str).collect();
167
168 if paths.is_empty() {
170 paths.extend(DEFAULT_ADMIN_PATHS.iter().copied());
171 }
172
173 paths
174 }
175
176 pub fn with_categories(mut self, categories: impl IntoIterator<Item = OwaspCategory>) -> Self {
178 self.categories = categories.into_iter().collect();
179 self
180 }
181
182 pub fn with_auth_header(mut self, header: impl Into<String>) -> Self {
184 self.auth_header = header.into();
185 self
186 }
187
188 pub fn with_valid_auth_token(mut self, token: impl Into<String>) -> Self {
190 self.valid_auth_token = Some(token.into());
191 self
192 }
193
194 pub fn with_admin_paths(mut self, paths: impl IntoIterator<Item = String>) -> Self {
196 self.admin_paths.extend(paths);
197 self
198 }
199
200 pub fn with_id_fields(mut self, fields: impl IntoIterator<Item = String>) -> Self {
202 self.id_fields = fields.into_iter().collect();
203 self
204 }
205
206 pub fn with_report_path(mut self, path: impl Into<PathBuf>) -> Self {
208 self.report_path = path.into();
209 self
210 }
211
212 pub fn with_report_format(mut self, format: ReportFormat) -> Self {
214 self.report_format = format;
215 self
216 }
217
218 pub fn with_verbose(mut self, verbose: bool) -> Self {
220 self.verbose = verbose;
221 self
222 }
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct AuthToken {
228 pub value: String,
230 #[serde(default)]
232 pub role: Option<String>,
233 #[serde(default)]
235 pub user_id: Option<String>,
236}
237
238impl AuthToken {
239 pub fn new(value: impl Into<String>) -> Self {
241 Self {
242 value: value.into(),
243 role: None,
244 user_id: None,
245 }
246 }
247
248 pub fn with_role(mut self, role: impl Into<String>) -> Self {
250 self.role = Some(role.into());
251 self
252 }
253
254 pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
256 self.user_id = Some(user_id.into());
257 self
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
263#[serde(rename_all = "lowercase")]
264pub enum ReportFormat {
265 #[default]
267 Json,
268 Sarif,
270}
271
272impl std::str::FromStr for ReportFormat {
273 type Err = String;
274
275 fn from_str(s: &str) -> Result<Self, Self::Err> {
276 match s.to_lowercase().as_str() {
277 "json" => Ok(Self::Json),
278 "sarif" => Ok(Self::Sarif),
279 _ => Err(format!("Unknown report format: '{}'. Valid values: json, sarif", s)),
280 }
281 }
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct RateLimitConfig {
287 #[serde(default = "default_burst_size")]
289 pub burst_size: usize,
290 #[serde(default = "default_max_limit")]
292 pub max_limit: usize,
293 #[serde(default = "default_large_payload_size")]
295 pub large_payload_size: usize,
296 #[serde(default = "default_max_nesting")]
298 pub max_nesting_depth: usize,
299}
300
301fn default_burst_size() -> usize {
302 100
303}
304
305fn default_max_limit() -> usize {
306 100000
307}
308
309fn default_large_payload_size() -> usize {
310 10 * 1024 * 1024 }
312
313fn default_max_nesting() -> usize {
314 100
315}
316
317impl Default for RateLimitConfig {
318 fn default() -> Self {
319 Self {
320 burst_size: default_burst_size(),
321 max_limit: default_max_limit(),
322 large_payload_size: default_large_payload_size(),
323 max_nesting_depth: default_max_nesting(),
324 }
325 }
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct SsrfConfig {
331 #[serde(default = "default_internal_urls")]
333 pub internal_urls: Vec<String>,
334 #[serde(default = "default_metadata_urls")]
336 pub metadata_urls: Vec<String>,
337 #[serde(default)]
339 pub url_fields: Vec<String>,
340}
341
342fn default_internal_urls() -> Vec<String> {
343 vec![
344 "http://localhost/".to_string(),
345 "http://127.0.0.1/".to_string(),
346 "http://[::1]/".to_string(),
347 "http://0.0.0.0/".to_string(),
348 "http://localhost:8080/".to_string(),
349 "http://localhost:3000/".to_string(),
350 "http://localhost:9000/".to_string(),
351 "http://internal/".to_string(),
352 "http://backend/".to_string(),
353 ]
354}
355
356fn default_metadata_urls() -> Vec<String> {
357 vec![
358 "http://169.254.169.254/latest/meta-data/".to_string(),
360 "http://169.254.169.254/latest/user-data/".to_string(),
361 "http://metadata.google.internal/computeMetadata/v1/".to_string(),
363 "http://169.254.169.254/metadata/instance".to_string(),
365 "http://169.254.169.254/metadata/v1/".to_string(),
367 "http://100.100.100.200/latest/meta-data/".to_string(),
369 ]
370}
371
372impl Default for SsrfConfig {
373 fn default() -> Self {
374 Self {
375 internal_urls: default_internal_urls(),
376 metadata_urls: default_metadata_urls(),
377 url_fields: vec![
378 "url".to_string(),
379 "uri".to_string(),
380 "link".to_string(),
381 "href".to_string(),
382 "callback".to_string(),
383 "redirect".to_string(),
384 "return_url".to_string(),
385 "webhook".to_string(),
386 "image_url".to_string(),
387 "fetch_url".to_string(),
388 ],
389 }
390 }
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct DiscoveryConfig {
396 #[serde(default = "default_api_versions")]
398 pub api_versions: Vec<String>,
399 #[serde(default = "default_discovery_paths")]
401 pub discovery_paths: Vec<String>,
402 #[serde(default = "default_true")]
404 pub check_deprecated: bool,
405}
406
407fn default_api_versions() -> Vec<String> {
408 vec![
409 "v1".to_string(),
410 "v2".to_string(),
411 "v3".to_string(),
412 "v4".to_string(),
413 "api/v1".to_string(),
414 "api/v2".to_string(),
415 "api/v3".to_string(),
416 ]
417}
418
419fn default_discovery_paths() -> Vec<String> {
420 vec![
421 "/swagger".to_string(),
422 "/swagger-ui".to_string(),
423 "/swagger.json".to_string(),
424 "/swagger.yaml".to_string(),
425 "/api-docs".to_string(),
426 "/openapi".to_string(),
427 "/openapi.json".to_string(),
428 "/openapi.yaml".to_string(),
429 "/graphql".to_string(),
430 "/graphiql".to_string(),
431 "/playground".to_string(),
432 "/debug".to_string(),
433 "/debug/".to_string(),
434 "/actuator".to_string(),
435 "/actuator/health".to_string(),
436 "/actuator/info".to_string(),
437 "/actuator/env".to_string(),
438 "/metrics".to_string(),
439 "/health".to_string(),
440 "/healthz".to_string(),
441 "/status".to_string(),
442 "/info".to_string(),
443 "/.env".to_string(),
444 "/config".to_string(),
445 "/admin".to_string(),
446 "/internal".to_string(),
447 "/test".to_string(),
448 "/dev".to_string(),
449 ]
450}
451
452fn default_true() -> bool {
453 true
454}
455
456impl Default for DiscoveryConfig {
457 fn default() -> Self {
458 Self {
459 api_versions: default_api_versions(),
460 discovery_paths: default_discovery_paths(),
461 check_deprecated: default_true(),
462 }
463 }
464}
465
466pub const DEFAULT_ADMIN_PATHS: &[&str] = &[
468 "/admin",
469 "/admin/",
470 "/admin/users",
471 "/admin/settings",
472 "/admin/config",
473 "/api/admin",
474 "/api/admin/",
475 "/api/admin/users",
476 "/api/v1/admin",
477 "/api/v2/admin",
478 "/management",
479 "/manage",
480 "/internal",
481 "/internal/",
482 "/system",
483 "/system/config",
484 "/settings",
485 "/config",
486 "/users/admin",
487];
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492
493 #[test]
494 fn test_default_config() {
495 let config = OwaspApiConfig::default();
496 assert!(config.categories.is_empty());
497 assert_eq!(config.auth_header, "Authorization");
498 assert!(!config.id_fields.is_empty());
499 }
500
501 #[test]
502 fn test_categories_to_test() {
503 let config = OwaspApiConfig::default();
504 assert_eq!(config.categories_to_test().len(), 10);
505
506 let config = OwaspApiConfig::default()
507 .with_categories([OwaspCategory::Api1Bola, OwaspCategory::Api7Ssrf]);
508 assert_eq!(config.categories_to_test().len(), 2);
509 }
510
511 #[test]
512 fn test_should_test_category() {
513 let config = OwaspApiConfig::default();
514 assert!(config.should_test_category(OwaspCategory::Api1Bola));
515
516 let config = OwaspApiConfig::default().with_categories([OwaspCategory::Api1Bola]);
517 assert!(config.should_test_category(OwaspCategory::Api1Bola));
518 assert!(!config.should_test_category(OwaspCategory::Api2BrokenAuth));
519 }
520
521 #[test]
522 fn test_builder_pattern() {
523 let config = OwaspApiConfig::new()
524 .with_auth_header("X-Auth-Token")
525 .with_valid_auth_token("secret123")
526 .with_verbose(true);
527
528 assert_eq!(config.auth_header, "X-Auth-Token");
529 assert_eq!(config.valid_auth_token, Some("secret123".to_string()));
530 assert!(config.verbose);
531 }
532
533 #[test]
534 fn test_report_format_from_str() {
535 assert_eq!("json".parse::<ReportFormat>().unwrap(), ReportFormat::Json);
536 assert_eq!("sarif".parse::<ReportFormat>().unwrap(), ReportFormat::Sarif);
537 assert!("invalid".parse::<ReportFormat>().is_err());
538 }
539}