1use regex::Regex;
2use reqwest::Client;
3use scraper::{Html, Selector};
4use serde::{Deserialize, Serialize};
5use std::collections::HashSet;
6use std::time::{Duration, Instant};
7
8use crate::payloads;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ApiEndpoint {
14 pub url: String,
15 pub status_code: u16,
16 pub api_type: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct VulnerabilityFinding {
21 pub vuln_type: String,
22 pub subtype: String,
23 pub endpoint: String,
24 pub parameter: String,
25 pub payload: String,
26 pub severity: String,
27 pub confidence: String,
28 pub evidence: String,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ApiScanResult {
33 pub domain: String,
34 pub endpoints_found: Vec<ApiEndpoint>,
35 pub vulnerabilities: Vec<VulnerabilityFinding>,
36 pub total_paths_probed: usize,
37 pub endpoints_tested: usize,
38}
39
40const HTML_KILLERS: &[&str] = &[
43 "<!doctype html",
44 "<html",
45 "<head>",
46 "<body>",
47 "<title>",
48 "<div",
49 "<form",
50 "<table",
51 "<script",
52 "not found</title>",
53 "404 not found",
54 "404 - not found",
55 "page not found",
56 "file not found",
57 "apache/2.",
58 "nginx/",
59 "microsoft-iis",
60 "server error",
61 "access denied",
62 "forbidden",
63 "directory listing",
64 "index of /",
65 "<h1>404</h1>",
66 "<h1>error</h1>",
67];
68
69const DOC_INDICATORS: &[&str] = &[
72 "\"openapi\":",
73 "\"swagger\":",
74 "\"info\":",
75 "\"paths\":",
76 "\"components\":",
77 "\"definitions\":",
78 "\"host\":",
79 "\"basepath\":",
80 "\"schemes\":",
81 "\"consumes\":",
82 "\"produces\":",
83];
84
85const DOC_URL_HINTS: &[&str] = &[
86 "openapi",
87 "swagger",
88 "docs",
89 "spec",
90 "schema",
91 "definition",
92 ".json",
93 ".yaml",
94 ".yml",
95];
96
97const API_HEADERS: &[&str] = &[
100 "x-api-version",
101 "x-api-key",
102 "x-rate-limit",
103 "x-ratelimit",
104 "x-request-id",
105 "x-correlation-id",
106 "x-trace-id",
107];
108
109const FRAMEWORK_SERVERS: &[&str] = &[
110 "express", "koa", "fastify", "spring", "django", "flask", "tornado", "rails", "sinatra",
111 "fastapi",
112];
113
114const AUTH_ERROR_PATTERNS: &[&str] = &[
117 r#""error"\s*:\s*"(unauthorized|forbidden|invalid.*token|missing.*auth)"#,
118 r#""message"\s*:\s*"(unauthorized|forbidden|authentication|authorization)"#,
119 r#""code"\s*:\s*"(401|403|auth_required|token_invalid)"#,
120 r#""status"\s*:\s*"(unauthorized|forbidden|error)","#,
121 r#""access_token""#,
122 r#""api_key""#,
123 r#""authentication.*required""#,
124 r#""invalid.*credentials""#,
125];
126
127const API_STRUCTURE_PATTERNS: &[&str] = &[
130 r#"^\s*\{\s*"data"\s*:\s*[\{\[]"#,
131 r#"^\s*\{\s*"result"\s*:\s*[\{\[]"#,
132 r#"^\s*\{\s*"results"\s*:\s*\["#,
133 r#"^\s*\{\s*"items"\s*:\s*\["#,
134 r#"^\s*\{\s*"records"\s*:\s*\["#,
135 r#"^\s*\{\s*"version"\s*:\s*"[^"]*""#,
136 r#"^\s*\{\s*"api_version"\s*:\s*"[^"]*""#,
137 r#"^\s*\{\s*"timestamp"\s*:\s*\d+"#,
138 r#"^\s*\{\s*"error"\s*:\s*\{\s*"code""#,
139 r#"^\s*\{\s*"error"\s*:\s*\{\s*"message""#,
140 r#"^\s*\{\s*"errors"\s*:\s*\[.*"message""#,
141 r#"^\s*\{\s*"success"\s*:\s*(true|false)"#,
142 r#"^\s*\{\s*"status"\s*:\s*"(up|down|ok|healthy|error|fail|success)""#,
143 r#"^\s*\{\s*"health"\s*:\s*"(up|down|ok)""#,
144];
145
146const SQL_ERROR_PATTERNS: &[&str] = &[
149 r"You have an error in your SQL syntax",
150 r"MySQL server version for the right syntax",
151 r"PostgreSQL.*ERROR.*syntax error",
152 r"ORA-[0-9]{5}.*invalid identifier",
153 r"SQLite error.*syntax error",
154 r"SQLException.*invalid column name",
155 r"mysql_fetch_array\(\).*expects parameter",
156 r"Warning.*mysql_.*\(\).*supplied argument",
157];
158
159const JS_API_PATTERNS: &[&str] = &[
162 r#"fetch\s*\(\s*['"`](/[^'"`\s]+)['"`]"#,
163 r#"axios\.[a-z]+\s*\(\s*['"`](/[^'"`\s]+)['"`]"#,
164 r#"\$\.ajax\([^)]*url\s*:\s*['"`](/[^'"`\s]+)['"`]"#,
165 r#"\$\.get\s*\(\s*['"`](/[^'"`\s]+)['"`]"#,
166 r#"\$\.post\s*\(\s*['"`](/[^'"`\s]+)['"`]"#,
167 r#"apiUrl\s*[:=]\s*['"`](/[^'"`\s]+)['"`]"#,
168 r#"API_URL\s*[:=]\s*['"`](/[^'"`\s]+)['"`]"#,
169 r#"baseURL\s*[:=]\s*['"`](/[^'"`\s]+)['"`]"#,
170 r#"endpoint\s*[:=]\s*['"`](/[^'"`\s]+)['"`]"#,
171];
172
173pub async fn scan_api_endpoints(
176 domain: &str,
177 progress_tx: Option<tokio::sync::mpsc::Sender<crate::ScanProgress>>,
178) -> Result<ApiScanResult, Box<dyn std::error::Error + Send + Sync>> {
179 let base_url = if domain.starts_with("http") {
180 domain.to_string()
181 } else {
182 format!("https://{}", domain)
183 };
184
185 let client = crate::http_client_builder()
186 .timeout(Duration::from_secs(15))
187 .danger_accept_invalid_certs(true)
188 .redirect(reqwest::redirect::Policy::limited(3))
189 .build()?;
190
191 let mut verified_endpoints: Vec<ApiEndpoint> = Vec::new();
193
194 if let Some(t) = &progress_tx {
195 let _ = t
196 .send(crate::ScanProgress {
197 module: "API Security".into(),
198 percentage: 5.0,
199 message: "Started API endpoint discovery...".into(),
200 status: "Info".into(),
201 })
202 .await;
203 }
204
205 let api_paths = payloads::lines(payloads::API_ENDPOINTS);
207 let total_paths_probed = api_paths.len();
208
209 for (i, path) in api_paths.iter().enumerate() {
210 if i % 10 == 0 {
211 if let Some(t) = &progress_tx {
212 let _ = t
213 .send(crate::ScanProgress {
214 module: "API Security".into(),
215 percentage: 5.0 + (15.0 * (i as f32 / total_paths_probed as f32)),
216 message: format!("Probing paths: {}", path),
217 status: "Info".into(),
218 })
219 .await;
220 }
221 }
222 let url = format!("{}{}", base_url.trim_end_matches('/'), path);
223 if let Some(endpoint) = verify_endpoint(&client, &url).await {
224 verified_endpoints.push(endpoint);
225 }
226 }
227
228 if let Some(t) = &progress_tx {
230 let _ = t
231 .send(crate::ScanProgress {
232 module: "API Security".into(),
233 percentage: 20.0,
234 message: "Extracting JavaScript endpoints...".into(),
235 status: "Info".into(),
236 })
237 .await;
238 }
239 let js_endpoints = extract_js_endpoints(&client, &base_url).await;
240 for url in &js_endpoints {
241 if !verified_endpoints.iter().any(|e| e.url == *url) {
242 if let Some(endpoint) = verify_endpoint(&client, url).await {
243 verified_endpoints.push(endpoint);
244 }
245 }
246 }
247
248 if let Some(t) = &progress_tx {
250 let _ = t
251 .send(crate::ScanProgress {
252 module: "API Security".into(),
253 percentage: 25.0,
254 message: "Checking robots.txt & sitemap.xml...".into(),
255 status: "Info".into(),
256 })
257 .await;
258 }
259 let robots_endpoints = extract_robots_sitemap_endpoints(&client, &base_url).await;
260 for url in &robots_endpoints {
261 if !verified_endpoints.iter().any(|e| e.url == *url) {
262 if let Some(endpoint) = verify_endpoint(&client, url).await {
263 verified_endpoints.push(endpoint);
264 }
265 }
266 }
267
268 if let Some(t) = &progress_tx {
270 let _ = t
271 .send(crate::ScanProgress {
272 module: "API Security".into(),
273 percentage: 30.0,
274 message: "Hunting for OpenAPI/Swagger docs...".into(),
275 status: "Info".into(),
276 })
277 .await;
278 }
279 let doc_endpoints = scrape_documentation_endpoints(&client, &base_url).await;
280 for url in &doc_endpoints {
281 if !verified_endpoints.iter().any(|e| e.url == *url) {
282 if let Some(endpoint) = verify_endpoint(&client, url).await {
283 verified_endpoints.push(endpoint);
284 }
285 }
286 }
287
288 if let Some(t) = &progress_tx {
290 let _ = t
291 .send(crate::ScanProgress {
292 module: "API Security".into(),
293 percentage: 35.0,
294 message: "Bruting common API subdomains...".into(),
295 status: "Info".into(),
296 })
297 .await;
298 }
299 let subdomain_endpoints = check_api_subdomains(&client, domain).await;
300 for url in &subdomain_endpoints {
301 if !verified_endpoints.iter().any(|e| e.url == *url) {
302 if let Some(endpoint) = verify_endpoint(&client, url).await {
303 verified_endpoints.push(endpoint);
304 }
305 }
306 }
307
308 let mut vulnerabilities: Vec<VulnerabilityFinding> = Vec::new();
310 let endpoints_tested = verified_endpoints.len();
311
312 if let Some(t) = &progress_tx {
313 let _ = t
314 .send(crate::ScanProgress {
315 module: "API Security".into(),
316 percentage: 40.0,
317 message: format!(
318 "Found {} endpoints, starting active fuzzing...",
319 endpoints_tested
320 ),
321 status: "Info".into(),
322 })
323 .await;
324 }
325
326 for (i, ep) in verified_endpoints.iter().enumerate() {
327 if let Some(t) = &progress_tx {
328 let _ = t
329 .send(crate::ScanProgress {
330 module: "API Security".into(),
331 percentage: 40.0 + (60.0 * (i as f32 / endpoints_tested.max(1) as f32)),
332 message: format!("Fuzzing endpoint: {}", ep.url),
333 status: "Info".into(),
334 })
335 .await;
336 }
337 let mut findings = test_endpoint(&client, &ep.url).await;
338 vulnerabilities.append(&mut findings);
339
340 let critical_count = vulnerabilities
342 .iter()
343 .filter(|v| v.severity == "CRITICAL")
344 .count();
345 if critical_count >= 10 {
346 break;
347 }
348 }
349
350 Ok(ApiScanResult {
351 domain: domain.to_string(),
352 endpoints_found: verified_endpoints,
353 vulnerabilities,
354 total_paths_probed,
355 endpoints_tested,
356 })
357}
358
359async fn verify_endpoint(client: &Client, url: &str) -> Option<ApiEndpoint> {
362 let methods = ["GET", "OPTIONS", "HEAD"];
364 let mut votes: Vec<(String, u16)> = Vec::new(); for method in &methods {
367 let req = match *method {
368 "GET" => client.get(url),
369 "OPTIONS" => client.request(reqwest::Method::OPTIONS, url),
370 "HEAD" => client.head(url),
371 _ => continue,
372 };
373
374 let resp = match req.send().await {
375 Ok(r) => r,
376 Err(_) => continue,
377 };
378
379 let status = resp.status().as_u16();
380
381 if matches!(status, 404 | 502 | 503 | 500) {
383 continue;
384 }
385
386 let headers: Vec<(String, String)> = resp
387 .headers()
388 .iter()
389 .map(|(k, v)| {
390 (
391 k.as_str().to_lowercase(),
392 v.to_str().unwrap_or("").to_lowercase(),
393 )
394 })
395 .collect();
396
397 let content_type = headers
398 .iter()
399 .find(|(k, _)| k == "content-type")
400 .map(|(_, v)| v.as_str())
401 .unwrap_or("");
402
403 if *method != "GET" {
405 if let Some(api_type) = detect_api_from_headers(content_type, &headers, status) {
406 votes.push((api_type, status));
407 }
408 continue;
409 }
410
411 let body = match resp.text().await {
413 Ok(t) => t,
414 Err(_) => continue,
415 };
416
417 if body.trim().len() < 5 {
418 continue;
419 }
420
421 let sample = if body.len() > 5000 {
422 &body[..5000]
423 } else {
424 &body
425 };
426 let sample_lower = sample.to_lowercase();
427
428 if HTML_KILLERS.iter().any(|k| sample_lower.contains(k)) {
430 continue;
431 }
432
433 let is_doc_url = DOC_URL_HINTS.iter().any(|h| url.to_lowercase().contains(h));
435 if is_doc_url {
436 let doc_score: usize = DOC_INDICATORS
437 .iter()
438 .filter(|d| sample_lower.contains(*d))
439 .count();
440 if doc_score >= 3 {
441 continue; }
443 }
444
445 let ct_api = if content_type.contains("application/json") {
447 if serde_json::from_str::<serde_json::Value>(sample).is_ok() {
449 Some("REST/JSON".to_string())
450 } else {
451 None
452 }
453 } else if content_type.contains("application/xml") || content_type.contains("text/xml") {
454 Some("REST/XML".to_string())
455 } else if content_type.contains("graphql") {
456 Some("GraphQL".to_string())
457 } else if content_type.contains("application/vnd.api+json") {
458 Some("JSON:API".to_string())
459 } else if content_type.contains("application/hal+json") {
460 Some("HAL+JSON".to_string())
461 } else if content_type.contains("application/problem+json") {
462 Some("Problem Details".to_string())
463 } else {
464 None
465 };
466
467 if let Some(api_type) = ct_api {
468 votes.push((api_type, status));
469 continue;
470 }
471
472 if matches!(status, 401 | 403) {
474 let auth_headers = [
475 "www-authenticate",
476 "x-api-key",
477 "x-auth-token",
478 "x-rate-limit",
479 ];
480 if auth_headers
481 .iter()
482 .any(|h| headers.iter().any(|(k, _)| k == h))
483 {
484 votes.push(("Protected API".to_string(), status));
485 continue;
486 }
487 let auth_regexes: Vec<Regex> = AUTH_ERROR_PATTERNS
489 .iter()
490 .filter_map(|p| Regex::new(p).ok())
491 .collect();
492 if auth_regexes.iter().any(|rx| rx.is_match(&sample_lower)) {
493 votes.push(("Protected API".to_string(), status));
494 continue;
495 }
496 }
497
498 let structure_regexes: Vec<Regex> = API_STRUCTURE_PATTERNS
500 .iter()
501 .filter_map(|p| Regex::new(p).ok())
502 .collect();
503 let structure_score: usize = structure_regexes
504 .iter()
505 .filter(|rx| rx.is_match(sample))
506 .count();
507
508 let api_header_score: usize = API_HEADERS
510 .iter()
511 .filter(|h| headers.iter().any(|(k, _)| k == **h))
512 .count();
513
514 let framework_score: usize = headers
516 .iter()
517 .filter(|(k, _)| k == "server")
518 .map(|(_, v)| FRAMEWORK_SERVERS.iter().filter(|f| v.contains(*f)).count() * 2)
519 .sum();
520
521 let total_score = structure_score + api_header_score + framework_score;
522
523 if total_score >= 4 || (total_score >= 2 && status == 200) {
524 votes.push(("REST API".to_string(), status));
525 }
526 }
527
528 if votes.is_empty() {
530 return None;
531 }
532
533 let best = votes
535 .iter()
536 .max_by_key(|(_, s)| {
537 if *s < 400 {
538 1000 - *s as i32
539 } else {
540 -((*s) as i32)
541 }
542 })
543 .unwrap();
544
545 Some(ApiEndpoint {
546 url: url.to_string(),
547 status_code: best.1,
548 api_type: best.0.clone(),
549 })
550}
551
552fn detect_api_from_headers(
553 content_type: &str,
554 headers: &[(String, String)],
555 status: u16,
556) -> Option<String> {
557 if content_type.contains("application/json") {
558 return Some("REST/JSON".to_string());
559 }
560 if content_type.contains("application/xml") || content_type.contains("text/xml") {
561 return Some("REST/XML".to_string());
562 }
563 if content_type.contains("graphql") {
564 return Some("GraphQL".to_string());
565 }
566 if matches!(status, 401 | 403) {
567 let auth_headers = ["www-authenticate", "x-api-key", "x-rate-limit"];
568 if auth_headers
569 .iter()
570 .any(|h| headers.iter().any(|(k, _)| k == h))
571 {
572 return Some("Protected API".to_string());
573 }
574 }
575 None
576}
577
578async fn extract_js_endpoints(client: &Client, base_url: &str) -> Vec<String> {
581 let mut endpoints = HashSet::new();
582 let resp = match client.get(base_url).send().await {
583 Ok(r) if r.status().is_success() => r,
584 _ => return Vec::new(),
585 };
586 let body = match resp.text().await {
587 Ok(t) => t,
588 Err(_) => return Vec::new(),
589 };
590
591 let mut all_js = String::new();
593 let mut external_urls = Vec::new();
594
595 {
596 let doc = Html::parse_document(&body);
597 let script_sel = Selector::parse("script").unwrap();
598 for el in doc.select(&script_sel) {
599 let inline = el.text().collect::<String>();
600 if inline.len() > 10 {
601 all_js.push('\n');
602 all_js.push_str(&inline);
603 }
604 if let Some(src) = el.value().attr("src") {
606 if external_urls.len() < 10 {
607 external_urls.push(src.to_string());
608 }
609 }
610 }
611 }
612
613 for src in external_urls {
614 if endpoints.len() > 10 {
615 break;
616 }
617 if let Some(js_url) = resolve_url(base_url, &src) {
618 if let Ok(resp) = client.get(&js_url).send().await {
619 if resp.status().is_success() {
620 if let Ok(js_body) = resp.text().await {
621 all_js.push('\n');
622 all_js.push_str(&js_body);
623 }
624 }
625 }
626 }
627 }
628
629 let regexes: Vec<Regex> = JS_API_PATTERNS
631 .iter()
632 .filter_map(|p| Regex::new(p).ok())
633 .collect();
634
635 for rx in ®exes {
636 for cap in rx.captures_iter(&all_js) {
637 if let Some(m) = cap.get(1) {
638 let path = m.as_str().trim();
639 if path.is_empty() {
640 continue;
641 }
642 if [".js", ".css", ".png", ".jpg", ".gif", ".ico", ".svg"]
644 .iter()
645 .any(|ext| path.to_lowercase().ends_with(ext))
646 {
647 continue;
648 }
649 let full = format!("{}{}", base_url.trim_end_matches('/'), path);
650 endpoints.insert(full);
651 }
652 }
653 }
654
655 endpoints.into_iter().collect()
656}
657
658async fn extract_robots_sitemap_endpoints(client: &Client, base_url: &str) -> Vec<String> {
659 let mut endpoints = HashSet::new();
660
661 let robots_url = format!("{}/robots.txt", base_url.trim_end_matches('/'));
663 if let Ok(resp) = client.get(&robots_url).send().await {
664 if resp.status().is_success() {
665 if let Ok(body) = resp.text().await {
666 for line in body.lines() {
667 let line = line.trim().to_lowercase();
668 if (line.starts_with("disallow:") || line.starts_with("allow:"))
669 && line.contains(':')
670 {
671 let path = line.split_once(':').map(|(_, v)| v.trim()).unwrap_or("");
672 if !path.is_empty()
673 && path != "/"
674 && ["api", "graphql", "rest"]
675 .iter()
676 .any(|kw| path.contains(kw))
677 {
678 endpoints.insert(format!("{}{}", base_url.trim_end_matches('/'), path));
679 }
680 }
681 }
682 }
683 }
684 }
685
686 let sitemap_url = format!("{}/sitemap.xml", base_url.trim_end_matches('/'));
688 if let Ok(resp) = client.get(&sitemap_url).send().await {
689 if resp.status().is_success() {
690 if let Ok(body) = resp.text().await {
691 if let Ok(rx) = Regex::new(r"<loc>([^<]+)</loc>") {
692 for cap in rx.captures_iter(&body) {
693 if let Some(m) = cap.get(1) {
694 let url = m.as_str();
695 if ["api", "graphql", "rest"]
696 .iter()
697 .any(|kw| url.to_lowercase().contains(kw))
698 {
699 endpoints.insert(url.to_string());
700 }
701 }
702 }
703 }
704 }
705 }
706 }
707
708 endpoints.into_iter().collect()
709}
710
711async fn scrape_documentation_endpoints(client: &Client, base_url: &str) -> Vec<String> {
712 let mut endpoints = HashSet::new();
713 let doc_paths = [
714 "/swagger.json",
715 "/openapi.json",
716 "/api-docs",
717 "/docs",
718 "/swagger",
719 "/api/swagger.json",
720 "/api/docs",
721 ];
722
723 for path in &doc_paths {
724 let url = format!("{}{}", base_url.trim_end_matches('/'), path);
725 let resp = match client.get(&url).send().await {
726 Ok(r) if r.status().is_success() => r,
727 _ => continue,
728 };
729 let body = match resp.text().await {
730 Ok(t) => t,
731 Err(_) => continue,
732 };
733
734 if let Ok(doc) = serde_json::from_str::<serde_json::Value>(&body) {
736 if let Some(paths) = doc.get("paths").and_then(|p| p.as_object()) {
737 for path_key in paths.keys() {
738 if path_key.starts_with('/') {
739 endpoints.insert(format!("{}{}", base_url.trim_end_matches('/'), path_key));
740 }
741 }
742 }
743 if let Some(base_path) = doc.get("basePath").and_then(|b| b.as_str()) {
744 if !base_path.is_empty() {
745 endpoints.insert(format!("{}{}", base_url.trim_end_matches('/'), base_path));
746 }
747 }
748 }
749 }
750
751 endpoints.into_iter().collect()
752}
753
754async fn check_api_subdomains(client: &Client, domain: &str) -> Vec<String> {
755 let mut endpoints = Vec::new();
756 let bare_domain = domain
757 .trim_start_matches("https://")
758 .trim_start_matches("http://")
759 .split('/')
760 .next()
761 .unwrap_or(domain);
762
763 let parts: Vec<&str> = bare_domain.split('.').collect();
764 if parts.len() < 2 {
765 return endpoints;
766 }
767
768 let base = format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]);
769
770 let prefixes = [
771 "api",
772 "rest",
773 "graphql",
774 "gateway",
775 "api-v1",
776 "api-v2",
777 "api-dev",
778 "dev-api",
779 "api-staging",
780 "staging-api",
781 "mobile-api",
782 "app-api",
783 "admin-api",
784 "auth-api",
785 ];
786
787 for prefix in &prefixes[..8] {
788 for proto in &["https", "http"] {
790 let url = format!("{}://{}.{}", proto, prefix, base);
791 if let Ok(resp) = client.get(&url).send().await {
792 if resp.status().is_success() || matches!(resp.status().as_u16(), 401 | 403) {
793 endpoints.push(url);
794 break; }
796 }
797 }
798 }
799
800 endpoints
801}
802
803async fn test_endpoint(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
806 let mut findings = Vec::new();
807
808 findings.append(&mut test_sql_injection(client, endpoint).await);
809 findings.append(&mut test_xss(client, endpoint).await);
810 findings.append(&mut test_ssti(client, endpoint).await);
811 findings.append(&mut test_ssrf(client, endpoint).await);
812 findings.append(&mut test_auth_bypass(client, endpoint).await);
813 findings.append(&mut test_command_injection(client, endpoint).await);
814 findings.append(&mut test_nosql_injection(client, endpoint).await);
815 findings.append(&mut test_xxe(client, endpoint).await);
816 findings.append(&mut test_lfi(client, endpoint).await);
817
818 findings
819}
820
821async fn test_sql_injection(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
824 let mut findings = Vec::new();
825 let sqli_payloads = payloads::lines(payloads::SQL_INJECTION);
826 let params = ["id", "user", "search", "q", "filter"];
827
828 let error_regexes: Vec<Regex> = SQL_ERROR_PATTERNS
829 .iter()
830 .filter_map(|p| Regex::new(p).ok())
831 .collect();
832
833 for param in ¶ms[..3] {
834 let baseline_url = format!("{}?{}=1", endpoint, param);
836 let baseline_body = match fetch_body(client, &baseline_url).await {
837 Some(b) => b,
838 None => continue,
839 };
840 if error_regexes.iter().any(|rx| rx.is_match(&baseline_body)) {
841 continue; }
843
844 for payload in sqli_payloads.iter().take(5) {
845 let encoded = urlencoding::encode(payload);
846 let test_url = format!("{}?{}={}", endpoint, param, encoded);
847
848 if payload.to_uppercase().contains("SLEEP")
850 || payload.to_uppercase().contains("WAITFOR")
851 {
852 let start = Instant::now();
853 if let Ok(resp) = client.get(&test_url).send().await {
854 let elapsed = start.elapsed().as_secs_f64();
855 let _ = resp.text().await;
856 if elapsed > 4.8 {
857 findings.push(VulnerabilityFinding {
858 vuln_type: "SQL_INJECTION".into(),
859 subtype: "Time-based Blind".into(),
860 endpoint: endpoint.into(),
861 parameter: param.to_string(),
862 payload: payload.to_string(),
863 severity: "CRITICAL".into(),
864 confidence: "MEDIUM".into(),
865 evidence: format!("Response delayed {:.1}s", elapsed),
866 });
867 return findings;
868 }
869 }
870 continue;
871 }
872
873 if let Some(body) = fetch_body(client, &test_url).await {
875 for rx in &error_regexes {
876 if let Some(m) = rx.find(&body) {
877 if !rx.is_match(&baseline_body) {
878 findings.push(VulnerabilityFinding {
879 vuln_type: "SQL_INJECTION".into(),
880 subtype: "Error-based".into(),
881 endpoint: endpoint.into(),
882 parameter: param.to_string(),
883 payload: payload.to_string(),
884 severity: "CRITICAL".into(),
885 confidence: "HIGH".into(),
886 evidence: format!("SQL error: {}", m.as_str()),
887 });
888 return findings;
889 }
890 }
891 }
892 }
893 }
894 }
895
896 findings
897}
898
899async fn test_xss(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
902 let mut findings = Vec::new();
903 let xss_payloads = payloads::lines(payloads::XSS);
904 let params = ["q", "search", "query", "keyword", "name"];
905
906 for payload in xss_payloads.iter().take(5) {
907 for param in ¶ms[..3] {
908 let encoded = urlencoding::encode(payload);
909 let test_url = format!("{}?{}={}", endpoint, param, encoded);
910
911 let resp = match client.get(&test_url).send().await {
912 Ok(r) => r,
913 Err(_) => continue,
914 };
915
916 if !resp.status().is_success() {
917 continue;
918 }
919
920 let ct = resp
921 .headers()
922 .get("content-type")
923 .and_then(|v| v.to_str().ok())
924 .unwrap_or("")
925 .to_lowercase();
926
927 if !ct.contains("text/html") {
928 continue;
929 }
930
931 let body = match resp.text().await {
932 Ok(t) => t,
933 Err(_) => continue,
934 };
935
936 if body.contains(payload) && !is_payload_safe_context(&body, payload) {
938 findings.push(VulnerabilityFinding {
939 vuln_type: "XSS".into(),
940 subtype: "Reflected".into(),
941 endpoint: endpoint.into(),
942 parameter: param.to_string(),
943 payload: payload.to_string(),
944 severity: "HIGH".into(),
945 confidence: "HIGH".into(),
946 evidence: "Payload reflected in HTML without encoding".into(),
947 });
948 return findings;
949 }
950 }
951 }
952 findings
953}
954
955fn is_payload_safe_context(content: &str, payload: &str) -> bool {
956 let pos = match content.find(payload) {
957 Some(p) => p,
958 None => return true,
959 };
960 let before = &content[..pos];
962 let after = &content[pos..];
963 if before.rfind("<!--").is_some() && after.contains("-->") {
964 let comment_start = before.rfind("<!--").unwrap();
965 if !before[comment_start..].contains("-->") {
966 return true;
967 }
968 }
969 let encoded = payload.replace('<', "<").replace('>', ">");
971 if content.contains(&encoded) {
972 return true;
973 }
974 false
975}
976
977async fn test_ssti(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
980 let mut findings = Vec::new();
981 let tests = [
982 ("{{7*7*7}}", "343"),
983 ("{{9*9*9}}", "729"),
984 ("${8*8*8}", "512"),
985 ("{{42*13}}", "546"),
986 ];
987 let params = ["template", "name", "msg", "content"];
988
989 for &(payload, expected) in &tests {
990 for param in ¶ms[..3] {
991 let baseline_url = format!("{}?{}=normaltext", endpoint, param);
993 let baseline = match fetch_body(client, &baseline_url).await {
994 Some(b) => b,
995 None => continue,
996 };
997
998 let encoded = urlencoding::encode(payload);
999 let test_url = format!("{}?{}={}", endpoint, param, encoded);
1000
1001 if let Some(body) = fetch_body(client, &test_url).await {
1002 if body.contains(expected)
1003 && !body.contains(payload)
1004 && !baseline.contains(expected)
1005 {
1006 findings.push(VulnerabilityFinding {
1007 vuln_type: "SSTI".into(),
1008 subtype: "Template Injection".into(),
1009 endpoint: endpoint.into(),
1010 parameter: param.to_string(),
1011 payload: payload.to_string(),
1012 severity: "CRITICAL".into(),
1013 confidence: "HIGH".into(),
1014 evidence: format!("Template executed: {} = {}", payload, expected),
1015 });
1016 return findings;
1017 }
1018 }
1019 }
1020 }
1021 findings
1022}
1023
1024async fn test_ssrf(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
1027 let mut findings = Vec::new();
1028 let ssrf_payloads = payloads::lines(payloads::SSRF);
1029 let params = ["url", "uri", "path", "dest", "redirect"];
1030 let indicators = [
1031 "root:",
1032 "daemon:",
1033 "localhost",
1034 "metadata",
1035 "ami-id",
1036 "instance-id",
1037 ];
1038
1039 for param in ¶ms[..3] {
1040 for payload in ssrf_payloads.iter().take(3) {
1041 let encoded = urlencoding::encode(payload);
1042 let test_url = format!("{}?{}={}", endpoint, param, encoded);
1043
1044 if let Some(body) = fetch_body(client, &test_url).await {
1045 for indicator in &indicators {
1046 if body.contains(indicator) {
1047 findings.push(VulnerabilityFinding {
1048 vuln_type: "SSRF".into(),
1049 subtype: "Server-Side Request Forgery".into(),
1050 endpoint: endpoint.into(),
1051 parameter: param.to_string(),
1052 payload: payload.to_string(),
1053 severity: "CRITICAL".into(),
1054 confidence: "HIGH".into(),
1055 evidence: format!("Internal data leaked: {}", indicator),
1056 });
1057 return findings;
1058 }
1059 }
1060 }
1061 }
1062 }
1063 findings
1064}
1065
1066async fn test_auth_bypass(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
1069 let mut findings = Vec::new();
1070
1071 let normal_status = match client.get(endpoint).send().await {
1073 Ok(r) => r.status().as_u16(),
1074 Err(_) => return findings,
1075 };
1076 if !matches!(normal_status, 401 | 403) {
1077 return findings; }
1079
1080 let bypass_headers = payloads::auth_headers(payloads::AUTH_BYPASS_HEADERS);
1081
1082 for (name, value) in bypass_headers.iter().take(10) {
1083 let resp = match client
1084 .get(endpoint)
1085 .header(name as &str, value as &str)
1086 .send()
1087 .await
1088 {
1089 Ok(r) => r,
1090 Err(_) => continue,
1091 };
1092
1093 if resp.status().as_u16() == 200 {
1094 findings.push(VulnerabilityFinding {
1095 vuln_type: "AUTH_BYPASS".into(),
1096 subtype: "Header-based".into(),
1097 endpoint: endpoint.into(),
1098 parameter: String::new(),
1099 payload: format!("{}: {}", name, value),
1100 severity: "CRITICAL".into(),
1101 confidence: "HIGH".into(),
1102 evidence: format!("Bypass with header {}: {}", name, value),
1103 });
1104 return findings;
1105 }
1106 }
1107 findings
1108}
1109
1110async fn test_command_injection(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
1113 let mut findings = Vec::new();
1114 let cmd_payloads = payloads::lines(payloads::COMMAND_INJECTION);
1115 let params = ["cmd", "exec", "command", "ping", "host"];
1116
1117 for param in ¶ms[..3] {
1118 for payload in cmd_payloads.iter().take(3) {
1119 if payload.to_lowercase().contains("sleep") {
1120 let encoded = urlencoding::encode(payload);
1121 let test_url = format!("{}?{}={}", endpoint, param, encoded);
1122 let start = Instant::now();
1123 if let Ok(resp) = client.get(&test_url).send().await {
1124 let elapsed = start.elapsed().as_secs_f64();
1125 let _ = resp.text().await;
1126 if elapsed > 4.5 {
1127 findings.push(VulnerabilityFinding {
1128 vuln_type: "COMMAND_INJECTION".into(),
1129 subtype: "Time-based".into(),
1130 endpoint: endpoint.into(),
1131 parameter: param.to_string(),
1132 payload: payload.to_string(),
1133 severity: "CRITICAL".into(),
1134 confidence: "HIGH".into(),
1135 evidence: format!("Command executed (delay: {:.1}s)", elapsed),
1136 });
1137 return findings;
1138 }
1139 }
1140 }
1141 }
1142 }
1143 findings
1144}
1145
1146async fn test_nosql_injection(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
1149 let mut findings = Vec::new();
1150 let nosql_payloads = payloads::lines(payloads::NOSQL_INJECTION);
1151
1152 for payload in nosql_payloads.iter().take(3) {
1153 let resp = match client
1154 .post(endpoint)
1155 .header("Content-Type", "application/json")
1156 .body(payload.to_string())
1157 .send()
1158 .await
1159 {
1160 Ok(r) => r,
1161 Err(_) => continue,
1162 };
1163
1164 if matches!(resp.status().as_u16(), 200 | 201) {
1165 let body = match resp.text().await {
1166 Ok(t) => t,
1167 Err(_) => continue,
1168 };
1169 if body.len() > 100 && !body.to_lowercase().contains("error") {
1170 findings.push(VulnerabilityFinding {
1171 vuln_type: "NOSQL_INJECTION".into(),
1172 subtype: "Operator Injection".into(),
1173 endpoint: endpoint.into(),
1174 parameter: String::new(),
1175 payload: payload.to_string(),
1176 severity: "HIGH".into(),
1177 confidence: "MEDIUM".into(),
1178 evidence: "NoSQL operator accepted, returned data".into(),
1179 });
1180 return findings;
1181 }
1182 }
1183 }
1184 findings
1185}
1186
1187async fn test_xxe(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
1190 let mut findings = Vec::new();
1191 let xxe_payloads = payloads::lines(payloads::XXE);
1192 let indicators = ["root:", "daemon:", "Windows", "[fonts]"];
1193
1194 for payload in xxe_payloads.iter().take(2) {
1195 let resp = match client
1196 .post(endpoint)
1197 .header("Content-Type", "application/xml")
1198 .body(payload.to_string())
1199 .send()
1200 .await
1201 {
1202 Ok(r) => r,
1203 Err(_) => continue,
1204 };
1205
1206 if resp.status().is_success() {
1207 let body = match resp.text().await {
1208 Ok(t) => t,
1209 Err(_) => continue,
1210 };
1211 for indicator in &indicators {
1212 if body.contains(indicator) {
1213 findings.push(VulnerabilityFinding {
1214 vuln_type: "XXE".into(),
1215 subtype: "XML External Entity".into(),
1216 endpoint: endpoint.into(),
1217 parameter: String::new(),
1218 payload: payload.to_string(),
1219 severity: "CRITICAL".into(),
1220 confidence: "HIGH".into(),
1221 evidence: "File contents disclosed via XXE".into(),
1222 });
1223 return findings;
1224 }
1225 }
1226 }
1227 }
1228 findings
1229}
1230
1231async fn test_lfi(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
1234 let mut findings = Vec::new();
1235 let lfi_payloads = payloads::lines(payloads::LFI);
1236 let params = ["file", "path", "page", "include", "template"];
1237 let indicators = ["root:x:", "daemon:", "[fonts]", "[extensions]"];
1238
1239 for param in ¶ms[..3] {
1240 for payload in lfi_payloads.iter().take(3) {
1241 let encoded = urlencoding::encode(payload);
1242 let test_url = format!("{}?{}={}", endpoint, param, encoded);
1243
1244 if let Some(body) = fetch_body(client, &test_url).await {
1245 for indicator in &indicators {
1246 if body.contains(indicator) {
1247 findings.push(VulnerabilityFinding {
1248 vuln_type: "LFI".into(),
1249 subtype: "Local File Inclusion".into(),
1250 endpoint: endpoint.into(),
1251 parameter: param.to_string(),
1252 payload: payload.to_string(),
1253 severity: "HIGH".into(),
1254 confidence: "HIGH".into(),
1255 evidence: "Local file contents exposed".into(),
1256 });
1257 return findings;
1258 }
1259 }
1260 }
1261 }
1262 }
1263 findings
1264}
1265
1266async fn fetch_body(client: &Client, url: &str) -> Option<String> {
1269 let resp = client.get(url).send().await.ok()?;
1270 if resp.status().as_u16() == 404 {
1271 return None;
1272 }
1273 resp.text().await.ok()
1274}
1275
1276fn resolve_url(base: &str, href: &str) -> Option<String> {
1277 if href.starts_with("javascript:") || href.starts_with('#') || href.starts_with("mailto:") {
1278 return None;
1279 }
1280 if href.starts_with("//") {
1281 return Some(format!("https:{}", href));
1282 }
1283 if href.starts_with("http://") || href.starts_with("https://") {
1284 return Some(href.to_string());
1285 }
1286 let base_trimmed = if let Some(idx) = base.rfind('/') {
1287 &base[..idx + 1]
1288 } else {
1289 base
1290 };
1291 Some(format!("{}{}", base_trimmed, href.trim_start_matches('/')))
1292}