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) -> Result<ApiScanResult, Box<dyn std::error::Error + Send + Sync>> {
178 let base_url = if domain.starts_with("http") {
179 domain.to_string()
180 } else {
181 format!("https://{}", domain)
182 };
183
184 let client = Client::builder()
185 .timeout(Duration::from_secs(15))
186 .danger_accept_invalid_certs(true)
187 .redirect(reqwest::redirect::Policy::limited(3))
188 .build()?;
189
190 let mut verified_endpoints: Vec<ApiEndpoint> = Vec::new();
192
193 let api_paths = payloads::lines(payloads::API_ENDPOINTS);
195 let total_paths_probed = api_paths.len();
196
197 for path in &api_paths {
198 let url = format!("{}{}", base_url.trim_end_matches('/'), path);
199 if let Some(endpoint) = verify_endpoint(&client, &url).await {
200 verified_endpoints.push(endpoint);
201 }
202 }
203
204 let js_endpoints = extract_js_endpoints(&client, &base_url).await;
206 for url in &js_endpoints {
207 if !verified_endpoints.iter().any(|e| e.url == *url) {
208 if let Some(endpoint) = verify_endpoint(&client, url).await {
209 verified_endpoints.push(endpoint);
210 }
211 }
212 }
213
214 let robots_endpoints = extract_robots_sitemap_endpoints(&client, &base_url).await;
216 for url in &robots_endpoints {
217 if !verified_endpoints.iter().any(|e| e.url == *url) {
218 if let Some(endpoint) = verify_endpoint(&client, url).await {
219 verified_endpoints.push(endpoint);
220 }
221 }
222 }
223
224 let doc_endpoints = scrape_documentation_endpoints(&client, &base_url).await;
226 for url in &doc_endpoints {
227 if !verified_endpoints.iter().any(|e| e.url == *url) {
228 if let Some(endpoint) = verify_endpoint(&client, url).await {
229 verified_endpoints.push(endpoint);
230 }
231 }
232 }
233
234 let subdomain_endpoints = check_api_subdomains(&client, domain).await;
236 for url in &subdomain_endpoints {
237 if !verified_endpoints.iter().any(|e| e.url == *url) {
238 if let Some(endpoint) = verify_endpoint(&client, url).await {
239 verified_endpoints.push(endpoint);
240 }
241 }
242 }
243
244 let mut vulnerabilities: Vec<VulnerabilityFinding> = Vec::new();
246 let endpoints_tested = verified_endpoints.len();
247
248 for ep in &verified_endpoints {
249 let mut findings = test_endpoint(&client, &ep.url).await;
250 vulnerabilities.append(&mut findings);
251
252 let critical_count = vulnerabilities
254 .iter()
255 .filter(|v| v.severity == "CRITICAL")
256 .count();
257 if critical_count >= 10 {
258 break;
259 }
260 }
261
262 Ok(ApiScanResult {
263 domain: domain.to_string(),
264 endpoints_found: verified_endpoints,
265 vulnerabilities,
266 total_paths_probed,
267 endpoints_tested,
268 })
269}
270
271async fn verify_endpoint(client: &Client, url: &str) -> Option<ApiEndpoint> {
274 let methods = ["GET", "OPTIONS", "HEAD"];
276 let mut votes: Vec<(String, u16)> = Vec::new(); for method in &methods {
279 let req = match *method {
280 "GET" => client.get(url),
281 "OPTIONS" => client.request(reqwest::Method::OPTIONS, url),
282 "HEAD" => client.head(url),
283 _ => continue,
284 };
285
286 let resp = match req.send().await {
287 Ok(r) => r,
288 Err(_) => continue,
289 };
290
291 let status = resp.status().as_u16();
292
293 if matches!(status, 404 | 502 | 503 | 500) {
295 continue;
296 }
297
298 let headers: Vec<(String, String)> = resp
299 .headers()
300 .iter()
301 .map(|(k, v)| {
302 (
303 k.as_str().to_lowercase(),
304 v.to_str().unwrap_or("").to_lowercase(),
305 )
306 })
307 .collect();
308
309 let content_type = headers
310 .iter()
311 .find(|(k, _)| k == "content-type")
312 .map(|(_, v)| v.as_str())
313 .unwrap_or("");
314
315 if *method != "GET" {
317 if let Some(api_type) = detect_api_from_headers(content_type, &headers, status) {
318 votes.push((api_type, status));
319 }
320 continue;
321 }
322
323 let body = match resp.text().await {
325 Ok(t) => t,
326 Err(_) => continue,
327 };
328
329 if body.trim().len() < 5 {
330 continue;
331 }
332
333 let sample = if body.len() > 5000 {
334 &body[..5000]
335 } else {
336 &body
337 };
338 let sample_lower = sample.to_lowercase();
339
340 if HTML_KILLERS.iter().any(|k| sample_lower.contains(k)) {
342 continue;
343 }
344
345 let is_doc_url = DOC_URL_HINTS.iter().any(|h| url.to_lowercase().contains(h));
347 if is_doc_url {
348 let doc_score: usize = DOC_INDICATORS
349 .iter()
350 .filter(|d| sample_lower.contains(*d))
351 .count();
352 if doc_score >= 3 {
353 continue; }
355 }
356
357 let ct_api = if content_type.contains("application/json") {
359 if serde_json::from_str::<serde_json::Value>(sample).is_ok() {
361 Some("REST/JSON".to_string())
362 } else {
363 None
364 }
365 } else if content_type.contains("application/xml") || content_type.contains("text/xml") {
366 Some("REST/XML".to_string())
367 } else if content_type.contains("graphql") {
368 Some("GraphQL".to_string())
369 } else if content_type.contains("application/vnd.api+json") {
370 Some("JSON:API".to_string())
371 } else if content_type.contains("application/hal+json") {
372 Some("HAL+JSON".to_string())
373 } else if content_type.contains("application/problem+json") {
374 Some("Problem Details".to_string())
375 } else {
376 None
377 };
378
379 if let Some(api_type) = ct_api {
380 votes.push((api_type, status));
381 continue;
382 }
383
384 if matches!(status, 401 | 403) {
386 let auth_headers = [
387 "www-authenticate",
388 "x-api-key",
389 "x-auth-token",
390 "x-rate-limit",
391 ];
392 if auth_headers
393 .iter()
394 .any(|h| headers.iter().any(|(k, _)| k == h))
395 {
396 votes.push(("Protected API".to_string(), status));
397 continue;
398 }
399 let auth_regexes: Vec<Regex> = AUTH_ERROR_PATTERNS
401 .iter()
402 .filter_map(|p| Regex::new(p).ok())
403 .collect();
404 if auth_regexes.iter().any(|rx| rx.is_match(&sample_lower)) {
405 votes.push(("Protected API".to_string(), status));
406 continue;
407 }
408 }
409
410 let structure_regexes: Vec<Regex> = API_STRUCTURE_PATTERNS
412 .iter()
413 .filter_map(|p| Regex::new(p).ok())
414 .collect();
415 let structure_score: usize = structure_regexes
416 .iter()
417 .filter(|rx| rx.is_match(sample))
418 .count();
419
420 let api_header_score: usize = API_HEADERS
422 .iter()
423 .filter(|h| headers.iter().any(|(k, _)| k == **h))
424 .count();
425
426 let framework_score: usize = headers
428 .iter()
429 .filter(|(k, _)| k == "server")
430 .map(|(_, v)| FRAMEWORK_SERVERS.iter().filter(|f| v.contains(*f)).count() * 2)
431 .sum();
432
433 let total_score = structure_score + api_header_score + framework_score;
434
435 if total_score >= 4 || (total_score >= 2 && status == 200) {
436 votes.push(("REST API".to_string(), status));
437 }
438 }
439
440 if votes.is_empty() {
442 return None;
443 }
444
445 let best = votes
447 .iter()
448 .max_by_key(|(_, s)| {
449 if *s < 400 {
450 1000 - *s as i32
451 } else {
452 -((*s) as i32)
453 }
454 })
455 .unwrap();
456
457 Some(ApiEndpoint {
458 url: url.to_string(),
459 status_code: best.1,
460 api_type: best.0.clone(),
461 })
462}
463
464fn detect_api_from_headers(
465 content_type: &str,
466 headers: &[(String, String)],
467 status: u16,
468) -> Option<String> {
469 if content_type.contains("application/json") {
470 return Some("REST/JSON".to_string());
471 }
472 if content_type.contains("application/xml") || content_type.contains("text/xml") {
473 return Some("REST/XML".to_string());
474 }
475 if content_type.contains("graphql") {
476 return Some("GraphQL".to_string());
477 }
478 if matches!(status, 401 | 403) {
479 let auth_headers = ["www-authenticate", "x-api-key", "x-rate-limit"];
480 if auth_headers
481 .iter()
482 .any(|h| headers.iter().any(|(k, _)| k == h))
483 {
484 return Some("Protected API".to_string());
485 }
486 }
487 None
488}
489
490async fn extract_js_endpoints(client: &Client, base_url: &str) -> Vec<String> {
493 let mut endpoints = HashSet::new();
494 let resp = match client.get(base_url).send().await {
495 Ok(r) if r.status().is_success() => r,
496 _ => return Vec::new(),
497 };
498 let body = match resp.text().await {
499 Ok(t) => t,
500 Err(_) => return Vec::new(),
501 };
502
503 let mut all_js = String::new();
505 let mut external_urls = Vec::new();
506
507 {
508 let doc = Html::parse_document(&body);
509 let script_sel = Selector::parse("script").unwrap();
510 for el in doc.select(&script_sel) {
511 let inline = el.text().collect::<String>();
512 if inline.len() > 10 {
513 all_js.push('\n');
514 all_js.push_str(&inline);
515 }
516 if let Some(src) = el.value().attr("src") {
518 if external_urls.len() < 10 {
519 external_urls.push(src.to_string());
520 }
521 }
522 }
523 }
524
525 for src in external_urls {
526 if endpoints.len() > 10 {
527 break;
528 }
529 if let Some(js_url) = resolve_url(base_url, &src) {
530 if let Ok(resp) = client.get(&js_url).send().await {
531 if resp.status().is_success() {
532 if let Ok(js_body) = resp.text().await {
533 all_js.push('\n');
534 all_js.push_str(&js_body);
535 }
536 }
537 }
538 }
539 }
540
541 let regexes: Vec<Regex> = JS_API_PATTERNS
543 .iter()
544 .filter_map(|p| Regex::new(p).ok())
545 .collect();
546
547 for rx in ®exes {
548 for cap in rx.captures_iter(&all_js) {
549 if let Some(m) = cap.get(1) {
550 let path = m.as_str().trim();
551 if path.is_empty() {
552 continue;
553 }
554 if [".js", ".css", ".png", ".jpg", ".gif", ".ico", ".svg"]
556 .iter()
557 .any(|ext| path.to_lowercase().ends_with(ext))
558 {
559 continue;
560 }
561 let full = format!("{}{}", base_url.trim_end_matches('/'), path);
562 endpoints.insert(full);
563 }
564 }
565 }
566
567 endpoints.into_iter().collect()
568}
569
570async fn extract_robots_sitemap_endpoints(client: &Client, base_url: &str) -> Vec<String> {
571 let mut endpoints = HashSet::new();
572
573 let robots_url = format!("{}/robots.txt", base_url.trim_end_matches('/'));
575 if let Ok(resp) = client.get(&robots_url).send().await {
576 if resp.status().is_success() {
577 if let Ok(body) = resp.text().await {
578 for line in body.lines() {
579 let line = line.trim().to_lowercase();
580 if (line.starts_with("disallow:") || line.starts_with("allow:"))
581 && line.contains(':')
582 {
583 let path = line.split_once(':').map(|(_, v)| v.trim()).unwrap_or("");
584 if !path.is_empty()
585 && path != "/"
586 && ["api", "graphql", "rest"]
587 .iter()
588 .any(|kw| path.contains(kw))
589 {
590 endpoints.insert(format!("{}{}", base_url.trim_end_matches('/'), path));
591 }
592 }
593 }
594 }
595 }
596 }
597
598 let sitemap_url = format!("{}/sitemap.xml", base_url.trim_end_matches('/'));
600 if let Ok(resp) = client.get(&sitemap_url).send().await {
601 if resp.status().is_success() {
602 if let Ok(body) = resp.text().await {
603 if let Ok(rx) = Regex::new(r"<loc>([^<]+)</loc>") {
604 for cap in rx.captures_iter(&body) {
605 if let Some(m) = cap.get(1) {
606 let url = m.as_str();
607 if ["api", "graphql", "rest"]
608 .iter()
609 .any(|kw| url.to_lowercase().contains(kw))
610 {
611 endpoints.insert(url.to_string());
612 }
613 }
614 }
615 }
616 }
617 }
618 }
619
620 endpoints.into_iter().collect()
621}
622
623async fn scrape_documentation_endpoints(client: &Client, base_url: &str) -> Vec<String> {
624 let mut endpoints = HashSet::new();
625 let doc_paths = [
626 "/swagger.json",
627 "/openapi.json",
628 "/api-docs",
629 "/docs",
630 "/swagger",
631 "/api/swagger.json",
632 "/api/docs",
633 ];
634
635 for path in &doc_paths {
636 let url = format!("{}{}", base_url.trim_end_matches('/'), path);
637 let resp = match client.get(&url).send().await {
638 Ok(r) if r.status().is_success() => r,
639 _ => continue,
640 };
641 let body = match resp.text().await {
642 Ok(t) => t,
643 Err(_) => continue,
644 };
645
646 if let Ok(doc) = serde_json::from_str::<serde_json::Value>(&body) {
648 if let Some(paths) = doc.get("paths").and_then(|p| p.as_object()) {
649 for path_key in paths.keys() {
650 if path_key.starts_with('/') {
651 endpoints.insert(format!("{}{}", base_url.trim_end_matches('/'), path_key));
652 }
653 }
654 }
655 if let Some(base_path) = doc.get("basePath").and_then(|b| b.as_str()) {
656 if !base_path.is_empty() {
657 endpoints.insert(format!("{}{}", base_url.trim_end_matches('/'), base_path));
658 }
659 }
660 }
661 }
662
663 endpoints.into_iter().collect()
664}
665
666async fn check_api_subdomains(client: &Client, domain: &str) -> Vec<String> {
667 let mut endpoints = Vec::new();
668 let bare_domain = domain
669 .trim_start_matches("https://")
670 .trim_start_matches("http://")
671 .split('/')
672 .next()
673 .unwrap_or(domain);
674
675 let parts: Vec<&str> = bare_domain.split('.').collect();
676 if parts.len() < 2 {
677 return endpoints;
678 }
679
680 let base = format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]);
681
682 let prefixes = [
683 "api",
684 "rest",
685 "graphql",
686 "gateway",
687 "api-v1",
688 "api-v2",
689 "api-dev",
690 "dev-api",
691 "api-staging",
692 "staging-api",
693 "mobile-api",
694 "app-api",
695 "admin-api",
696 "auth-api",
697 ];
698
699 for prefix in &prefixes[..8] {
700 for proto in &["https", "http"] {
702 let url = format!("{}://{}.{}", proto, prefix, base);
703 if let Ok(resp) = client.get(&url).send().await {
704 if resp.status().is_success() || matches!(resp.status().as_u16(), 401 | 403) {
705 endpoints.push(url);
706 break; }
708 }
709 }
710 }
711
712 endpoints
713}
714
715async fn test_endpoint(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
718 let mut findings = Vec::new();
719
720 findings.append(&mut test_sql_injection(client, endpoint).await);
721 findings.append(&mut test_xss(client, endpoint).await);
722 findings.append(&mut test_ssti(client, endpoint).await);
723 findings.append(&mut test_ssrf(client, endpoint).await);
724 findings.append(&mut test_auth_bypass(client, endpoint).await);
725 findings.append(&mut test_command_injection(client, endpoint).await);
726 findings.append(&mut test_nosql_injection(client, endpoint).await);
727 findings.append(&mut test_xxe(client, endpoint).await);
728 findings.append(&mut test_lfi(client, endpoint).await);
729
730 findings
731}
732
733async fn test_sql_injection(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
736 let mut findings = Vec::new();
737 let sqli_payloads = payloads::lines(payloads::SQL_INJECTION);
738 let params = ["id", "user", "search", "q", "filter"];
739
740 let error_regexes: Vec<Regex> = SQL_ERROR_PATTERNS
741 .iter()
742 .filter_map(|p| Regex::new(p).ok())
743 .collect();
744
745 for param in ¶ms[..3] {
746 let baseline_url = format!("{}?{}=1", endpoint, param);
748 let baseline_body = match fetch_body(client, &baseline_url).await {
749 Some(b) => b,
750 None => continue,
751 };
752 if error_regexes.iter().any(|rx| rx.is_match(&baseline_body)) {
753 continue; }
755
756 for payload in sqli_payloads.iter().take(5) {
757 let encoded = urlencoding::encode(payload);
758 let test_url = format!("{}?{}={}", endpoint, param, encoded);
759
760 if payload.to_uppercase().contains("SLEEP")
762 || payload.to_uppercase().contains("WAITFOR")
763 {
764 let start = Instant::now();
765 if let Ok(resp) = client.get(&test_url).send().await {
766 let elapsed = start.elapsed().as_secs_f64();
767 let _ = resp.text().await;
768 if elapsed > 4.8 {
769 findings.push(VulnerabilityFinding {
770 vuln_type: "SQL_INJECTION".into(),
771 subtype: "Time-based Blind".into(),
772 endpoint: endpoint.into(),
773 parameter: param.to_string(),
774 payload: payload.to_string(),
775 severity: "CRITICAL".into(),
776 confidence: "MEDIUM".into(),
777 evidence: format!("Response delayed {:.1}s", elapsed),
778 });
779 return findings;
780 }
781 }
782 continue;
783 }
784
785 if let Some(body) = fetch_body(client, &test_url).await {
787 for rx in &error_regexes {
788 if let Some(m) = rx.find(&body) {
789 if !rx.is_match(&baseline_body) {
790 findings.push(VulnerabilityFinding {
791 vuln_type: "SQL_INJECTION".into(),
792 subtype: "Error-based".into(),
793 endpoint: endpoint.into(),
794 parameter: param.to_string(),
795 payload: payload.to_string(),
796 severity: "CRITICAL".into(),
797 confidence: "HIGH".into(),
798 evidence: format!("SQL error: {}", m.as_str()),
799 });
800 return findings;
801 }
802 }
803 }
804 }
805 }
806 }
807
808 findings
809}
810
811async fn test_xss(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
814 let mut findings = Vec::new();
815 let xss_payloads = payloads::lines(payloads::XSS);
816 let params = ["q", "search", "query", "keyword", "name"];
817
818 for payload in xss_payloads.iter().take(5) {
819 for param in ¶ms[..3] {
820 let encoded = urlencoding::encode(payload);
821 let test_url = format!("{}?{}={}", endpoint, param, encoded);
822
823 let resp = match client.get(&test_url).send().await {
824 Ok(r) => r,
825 Err(_) => continue,
826 };
827
828 if !resp.status().is_success() {
829 continue;
830 }
831
832 let ct = resp
833 .headers()
834 .get("content-type")
835 .and_then(|v| v.to_str().ok())
836 .unwrap_or("")
837 .to_lowercase();
838
839 if !ct.contains("text/html") {
840 continue;
841 }
842
843 let body = match resp.text().await {
844 Ok(t) => t,
845 Err(_) => continue,
846 };
847
848 if body.contains(payload) && !is_payload_safe_context(&body, payload) {
850 findings.push(VulnerabilityFinding {
851 vuln_type: "XSS".into(),
852 subtype: "Reflected".into(),
853 endpoint: endpoint.into(),
854 parameter: param.to_string(),
855 payload: payload.to_string(),
856 severity: "HIGH".into(),
857 confidence: "HIGH".into(),
858 evidence: "Payload reflected in HTML without encoding".into(),
859 });
860 return findings;
861 }
862 }
863 }
864 findings
865}
866
867fn is_payload_safe_context(content: &str, payload: &str) -> bool {
868 let pos = match content.find(payload) {
869 Some(p) => p,
870 None => return true,
871 };
872 let before = &content[..pos];
874 let after = &content[pos..];
875 if before.rfind("<!--").is_some() && after.contains("-->") {
876 let comment_start = before.rfind("<!--").unwrap();
877 if !before[comment_start..].contains("-->") {
878 return true;
879 }
880 }
881 let encoded = payload.replace('<', "<").replace('>', ">");
883 if content.contains(&encoded) {
884 return true;
885 }
886 false
887}
888
889async fn test_ssti(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
892 let mut findings = Vec::new();
893 let tests = [
894 ("{{7*7*7}}", "343"),
895 ("{{9*9*9}}", "729"),
896 ("${8*8*8}", "512"),
897 ("{{42*13}}", "546"),
898 ];
899 let params = ["template", "name", "msg", "content"];
900
901 for &(payload, expected) in &tests {
902 for param in ¶ms[..3] {
903 let baseline_url = format!("{}?{}=normaltext", endpoint, param);
905 let baseline = match fetch_body(client, &baseline_url).await {
906 Some(b) => b,
907 None => continue,
908 };
909
910 let encoded = urlencoding::encode(payload);
911 let test_url = format!("{}?{}={}", endpoint, param, encoded);
912
913 if let Some(body) = fetch_body(client, &test_url).await {
914 if body.contains(expected)
915 && !body.contains(payload)
916 && !baseline.contains(expected)
917 {
918 findings.push(VulnerabilityFinding {
919 vuln_type: "SSTI".into(),
920 subtype: "Template Injection".into(),
921 endpoint: endpoint.into(),
922 parameter: param.to_string(),
923 payload: payload.to_string(),
924 severity: "CRITICAL".into(),
925 confidence: "HIGH".into(),
926 evidence: format!("Template executed: {} = {}", payload, expected),
927 });
928 return findings;
929 }
930 }
931 }
932 }
933 findings
934}
935
936async fn test_ssrf(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
939 let mut findings = Vec::new();
940 let ssrf_payloads = payloads::lines(payloads::SSRF);
941 let params = ["url", "uri", "path", "dest", "redirect"];
942 let indicators = [
943 "root:",
944 "daemon:",
945 "localhost",
946 "metadata",
947 "ami-id",
948 "instance-id",
949 ];
950
951 for param in ¶ms[..3] {
952 for payload in ssrf_payloads.iter().take(3) {
953 let encoded = urlencoding::encode(payload);
954 let test_url = format!("{}?{}={}", endpoint, param, encoded);
955
956 if let Some(body) = fetch_body(client, &test_url).await {
957 for indicator in &indicators {
958 if body.contains(indicator) {
959 findings.push(VulnerabilityFinding {
960 vuln_type: "SSRF".into(),
961 subtype: "Server-Side Request Forgery".into(),
962 endpoint: endpoint.into(),
963 parameter: param.to_string(),
964 payload: payload.to_string(),
965 severity: "CRITICAL".into(),
966 confidence: "HIGH".into(),
967 evidence: format!("Internal data leaked: {}", indicator),
968 });
969 return findings;
970 }
971 }
972 }
973 }
974 }
975 findings
976}
977
978async fn test_auth_bypass(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
981 let mut findings = Vec::new();
982
983 let normal_status = match client.get(endpoint).send().await {
985 Ok(r) => r.status().as_u16(),
986 Err(_) => return findings,
987 };
988 if !matches!(normal_status, 401 | 403) {
989 return findings; }
991
992 let bypass_headers = payloads::auth_headers(payloads::AUTH_BYPASS_HEADERS);
993
994 for (name, value) in bypass_headers.iter().take(10) {
995 let resp = match client
996 .get(endpoint)
997 .header(name as &str, value as &str)
998 .send()
999 .await
1000 {
1001 Ok(r) => r,
1002 Err(_) => continue,
1003 };
1004
1005 if resp.status().as_u16() == 200 {
1006 findings.push(VulnerabilityFinding {
1007 vuln_type: "AUTH_BYPASS".into(),
1008 subtype: "Header-based".into(),
1009 endpoint: endpoint.into(),
1010 parameter: String::new(),
1011 payload: format!("{}: {}", name, value),
1012 severity: "CRITICAL".into(),
1013 confidence: "HIGH".into(),
1014 evidence: format!("Bypass with header {}: {}", name, value),
1015 });
1016 return findings;
1017 }
1018 }
1019 findings
1020}
1021
1022async fn test_command_injection(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
1025 let mut findings = Vec::new();
1026 let cmd_payloads = payloads::lines(payloads::COMMAND_INJECTION);
1027 let params = ["cmd", "exec", "command", "ping", "host"];
1028
1029 for param in ¶ms[..3] {
1030 for payload in cmd_payloads.iter().take(3) {
1031 if payload.to_lowercase().contains("sleep") {
1032 let encoded = urlencoding::encode(payload);
1033 let test_url = format!("{}?{}={}", endpoint, param, encoded);
1034 let start = Instant::now();
1035 if let Ok(resp) = client.get(&test_url).send().await {
1036 let elapsed = start.elapsed().as_secs_f64();
1037 let _ = resp.text().await;
1038 if elapsed > 4.5 {
1039 findings.push(VulnerabilityFinding {
1040 vuln_type: "COMMAND_INJECTION".into(),
1041 subtype: "Time-based".into(),
1042 endpoint: endpoint.into(),
1043 parameter: param.to_string(),
1044 payload: payload.to_string(),
1045 severity: "CRITICAL".into(),
1046 confidence: "HIGH".into(),
1047 evidence: format!("Command executed (delay: {:.1}s)", elapsed),
1048 });
1049 return findings;
1050 }
1051 }
1052 }
1053 }
1054 }
1055 findings
1056}
1057
1058async fn test_nosql_injection(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
1061 let mut findings = Vec::new();
1062 let nosql_payloads = payloads::lines(payloads::NOSQL_INJECTION);
1063
1064 for payload in nosql_payloads.iter().take(3) {
1065 let resp = match client
1066 .post(endpoint)
1067 .header("Content-Type", "application/json")
1068 .body(payload.to_string())
1069 .send()
1070 .await
1071 {
1072 Ok(r) => r,
1073 Err(_) => continue,
1074 };
1075
1076 if matches!(resp.status().as_u16(), 200 | 201) {
1077 let body = match resp.text().await {
1078 Ok(t) => t,
1079 Err(_) => continue,
1080 };
1081 if body.len() > 100 && !body.to_lowercase().contains("error") {
1082 findings.push(VulnerabilityFinding {
1083 vuln_type: "NOSQL_INJECTION".into(),
1084 subtype: "Operator Injection".into(),
1085 endpoint: endpoint.into(),
1086 parameter: String::new(),
1087 payload: payload.to_string(),
1088 severity: "HIGH".into(),
1089 confidence: "MEDIUM".into(),
1090 evidence: "NoSQL operator accepted, returned data".into(),
1091 });
1092 return findings;
1093 }
1094 }
1095 }
1096 findings
1097}
1098
1099async fn test_xxe(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
1102 let mut findings = Vec::new();
1103 let xxe_payloads = payloads::lines(payloads::XXE);
1104 let indicators = ["root:", "daemon:", "Windows", "[fonts]"];
1105
1106 for payload in xxe_payloads.iter().take(2) {
1107 let resp = match client
1108 .post(endpoint)
1109 .header("Content-Type", "application/xml")
1110 .body(payload.to_string())
1111 .send()
1112 .await
1113 {
1114 Ok(r) => r,
1115 Err(_) => continue,
1116 };
1117
1118 if resp.status().is_success() {
1119 let body = match resp.text().await {
1120 Ok(t) => t,
1121 Err(_) => continue,
1122 };
1123 for indicator in &indicators {
1124 if body.contains(indicator) {
1125 findings.push(VulnerabilityFinding {
1126 vuln_type: "XXE".into(),
1127 subtype: "XML External Entity".into(),
1128 endpoint: endpoint.into(),
1129 parameter: String::new(),
1130 payload: payload.to_string(),
1131 severity: "CRITICAL".into(),
1132 confidence: "HIGH".into(),
1133 evidence: "File contents disclosed via XXE".into(),
1134 });
1135 return findings;
1136 }
1137 }
1138 }
1139 }
1140 findings
1141}
1142
1143async fn test_lfi(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
1146 let mut findings = Vec::new();
1147 let lfi_payloads = payloads::lines(payloads::LFI);
1148 let params = ["file", "path", "page", "include", "template"];
1149 let indicators = ["root:x:", "daemon:", "[fonts]", "[extensions]"];
1150
1151 for param in ¶ms[..3] {
1152 for payload in lfi_payloads.iter().take(3) {
1153 let encoded = urlencoding::encode(payload);
1154 let test_url = format!("{}?{}={}", endpoint, param, encoded);
1155
1156 if let Some(body) = fetch_body(client, &test_url).await {
1157 for indicator in &indicators {
1158 if body.contains(indicator) {
1159 findings.push(VulnerabilityFinding {
1160 vuln_type: "LFI".into(),
1161 subtype: "Local File Inclusion".into(),
1162 endpoint: endpoint.into(),
1163 parameter: param.to_string(),
1164 payload: payload.to_string(),
1165 severity: "HIGH".into(),
1166 confidence: "HIGH".into(),
1167 evidence: "Local file contents exposed".into(),
1168 });
1169 return findings;
1170 }
1171 }
1172 }
1173 }
1174 }
1175 findings
1176}
1177
1178async fn fetch_body(client: &Client, url: &str) -> Option<String> {
1181 let resp = client.get(url).send().await.ok()?;
1182 if resp.status().as_u16() == 404 {
1183 return None;
1184 }
1185 resp.text().await.ok()
1186}
1187
1188fn resolve_url(base: &str, href: &str) -> Option<String> {
1189 if href.starts_with("javascript:") || href.starts_with('#') || href.starts_with("mailto:") {
1190 return None;
1191 }
1192 if href.starts_with("//") {
1193 return Some(format!("https:{}", href));
1194 }
1195 if href.starts_with("http://") || href.starts_with("https://") {
1196 return Some(href.to_string());
1197 }
1198 let base_trimmed = if let Some(idx) = base.rfind('/') {
1199 &base[..idx + 1]
1200 } else {
1201 base
1202 };
1203 Some(format!("{}{}", base_trimmed, href.trim_start_matches('/')))
1204}