Skip to main content

web_analyzer/
api_security_scanner.rs

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// ── Result structs ──────────────────────────────────────────────────────────
11
12#[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
40// ── HTML killwords — definitive NOT-API indicators ──────────────────────────
41
42const 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
69// ── Swagger/OpenAPI documentation indicators ────────────────────────────────
70
71const 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
97// ── API-specific response headers ───────────────────────────────────────────
98
99const 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
114// ── Auth error patterns (regex) ─────────────────────────────────────────────
115
116const 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
127// ── RESTful API structure patterns ──────────────────────────────────────────
128
129const 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
146// ── SQL error patterns ──────────────────────────────────────────────────────
147
148const 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
159// ── JS API endpoint extraction patterns ─────────────────────────────────────
160
161const 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
173// ── Main scanner ────────────────────────────────────────────────────────────
174
175pub 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    // ── Phase 1: Endpoint Discovery ─────────────────────────────────────
192    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    // 1a. Probe paths from embedded api_endpoints.txt
206    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    // 1b. Extract endpoints from JavaScript on main page
229    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    // 1c. Extract API paths from robots.txt and sitemap.xml
249    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    // 1d. Scrape Swagger/OpenAPI documentation for real paths
269    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    // 1e. Check common API subdomains
289    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    // ── Phase 2: Vulnerability Testing ──────────────────────────────────
309    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        // Early exit on excessive criticals
341        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
359// ── Advanced API endpoint verification ──────────────────────────────────────
360
361async fn verify_endpoint(client: &Client, url: &str) -> Option<ApiEndpoint> {
362    // Try GET first, then OPTIONS, HEAD — majority voting
363    let methods = ["GET", "OPTIONS", "HEAD"];
364    let mut votes: Vec<(String, u16)> = Vec::new(); // (api_type, status)
365
366    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        // Immediate disqualifiers
382        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        // For HEAD/OPTIONS we can't read body, just check headers
404        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        // GET — full body analysis
412        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        // HTML killer filter
429        if HTML_KILLERS.iter().any(|k| sample_lower.contains(k)) {
430            continue;
431        }
432
433        // Documentation file detection
434        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; // Skip API documentation files
442            }
443        }
444
445        // Content-type based definitive detection
446        let ct_api = if content_type.contains("application/json") {
447            // Verify valid JSON
448            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        // Auth-protected endpoint detection (401/403)
473        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            // Check body for API-style auth errors
488            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        // API structure pattern scoring
499        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        // API header scoring
509        let api_header_score: usize = API_HEADERS
510            .iter()
511            .filter(|h| headers.iter().any(|(k, _)| k == **h))
512            .count();
513
514        // Framework detection via Server header
515        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    // Majority voting
529    if votes.is_empty() {
530        return None;
531    }
532
533    // Pick the best vote (prefer 2xx status)
534    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
578// ── Endpoint Discovery Helpers ──────────────────────────────────────────────
579
580async 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    // Collect inline JS
592    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            // Fetch up to 10 external JS files
605            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    // Extract API paths from JS content
630    let regexes: Vec<Regex> = JS_API_PATTERNS
631        .iter()
632        .filter_map(|p| Regex::new(p).ok())
633        .collect();
634
635    for rx in &regexes {
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                // Skip static assets
643                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    // robots.txt
662    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    // sitemap.xml
687    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        // Try to parse as JSON and extract "paths" key
735        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        // limit to avoid excessive requests
789        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; // Found, skip other protocol
795                }
796            }
797        }
798    }
799
800    endpoints
801}
802
803// ── Vulnerability Testing ───────────────────────────────────────────────────
804
805async 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
821// ── SQLi ────────────────────────────────────────────────────────────────────
822
823async 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 &params[..3] {
834        // Baseline
835        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; // Baseline already has SQL errors
842        }
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            // Time-based detection
849            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            // Error-based detection
874            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
899// ── XSS ─────────────────────────────────────────────────────────────────────
900
901async 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 &params[..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            // Payload reflected unencoded in HTML
937            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    // Inside HTML comment?
961    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    // Properly encoded?
970    let encoded = payload.replace('<', "&lt;").replace('>', "&gt;");
971    if content.contains(&encoded) {
972        return true;
973    }
974    false
975}
976
977// ── SSTI ────────────────────────────────────────────────────────────────────
978
979async 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 &params[..3] {
991            // Baseline
992            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
1024// ── SSRF ────────────────────────────────────────────────────────────────────
1025
1026async 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 &params[..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
1066// ── Auth Bypass ─────────────────────────────────────────────────────────────
1067
1068async fn test_auth_bypass(client: &Client, endpoint: &str) -> Vec<VulnerabilityFinding> {
1069    let mut findings = Vec::new();
1070
1071    // Check if endpoint is normally protected
1072    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; // Not protected, skip
1078    }
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
1110// ── Command Injection ───────────────────────────────────────────────────────
1111
1112async 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 &params[..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
1146// ── NoSQL Injection ─────────────────────────────────────────────────────────
1147
1148async 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
1187// ── XXE ─────────────────────────────────────────────────────────────────────
1188
1189async 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
1231// ── LFI ─────────────────────────────────────────────────────────────────────
1232
1233async 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 &params[..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
1266// ── Shared helpers ──────────────────────────────────────────────────────────
1267
1268async 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}