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