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