1use jsdet_core::Observation;
12use jsdet_core::observation::CookieOp;
13
14#[derive(Default)]
17struct AnalysisState {
18 findings: Vec<BehavioralFinding>,
19 timeline: Vec<TimelineEntry>,
20 has_cookie_read: bool,
22 has_external_fetch: bool,
23 has_storage_write: bool,
24 external_urls: Vec<String>,
25 cookie_read_idx: Option<usize>,
26 fetch_indices: Vec<usize>,
27 has_wallet_connect: bool,
29 has_sign_transaction: bool,
30 has_sign_message: bool,
31 has_chain_switch: bool,
32 wallet_connect_idx: Option<usize>,
33 sign_indices: Vec<usize>,
34 has_clipboard_write: bool,
36 has_clipboard_read: bool,
37 clipboard_write_content: Option<String>,
38}
39
40#[derive(Debug, Clone)]
42#[allow(dead_code)]
43pub struct BehavioralFinding {
44 pub severity: Severity,
45 pub category: Category,
46 pub title: String,
47 pub detail: String,
48 pub evidence: Vec<usize>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
53#[allow(dead_code)]
54pub enum Severity {
55 Info,
56 Low,
57 Medium,
58 High,
59 Critical,
60}
61
62impl Severity {
63 pub fn icon(self) -> &'static str {
64 match self {
65 Self::Critical => "!!!",
66 Self::High => "!!",
67 Self::Medium => "!",
68 Self::Low => "~",
69 Self::Info => "i",
70 }
71 }
72
73 pub fn color_code(self) -> &'static str {
74 match self {
75 Self::Critical => "\x1b[91m", Self::High => "\x1b[31m", Self::Medium => "\x1b[33m", Self::Low => "\x1b[36m", Self::Info => "\x1b[90m", }
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85#[allow(dead_code)]
86pub enum Category {
87 CredentialTheft,
88 DataExfiltration,
89 CodeInjection,
90 Persistence,
91 Fingerprinting,
92 Evasion,
93 NetworkActivity,
94 PermissionAbuse,
95 CookieAccess,
96 DomManipulation,
97 CryptoTheft,
98 ClipboardHijack,
99 PrivacyViolation,
100}
101
102impl Category {
103 pub fn label(self) -> &'static str {
104 match self {
105 Self::CredentialTheft => "CREDENTIAL THEFT",
106 Self::DataExfiltration => "DATA EXFILTRATION",
107 Self::CodeInjection => "CODE INJECTION",
108 Self::Persistence => "PERSISTENCE",
109 Self::Fingerprinting => "FINGERPRINTING",
110 Self::Evasion => "EVASION",
111 Self::NetworkActivity => "NETWORK",
112 Self::PermissionAbuse => "PERMISSION ABUSE",
113 Self::CookieAccess => "COOKIE ACCESS",
114 Self::DomManipulation => "DOM MUTATION",
115 Self::CryptoTheft => "CRYPTO THEFT",
116 Self::ClipboardHijack => "CLIPBOARD HIJACK",
117 Self::PrivacyViolation => "PRIVACY VIOLATION",
118 }
119 }
120}
121
122pub fn analyze(observations: &[Observation]) -> AnalysisReport {
124 let mut s = AnalysisState::default();
125
126 for (i, obs) in observations.iter().enumerate() {
127 match obs {
128 Observation::ApiCall { api, args, .. } => {
129 let args_str = args
130 .iter()
131 .map(|a| a.to_string())
132 .collect::<Vec<_>>()
133 .join(", ");
134 s.timeline.push(TimelineEntry {
135 index: i,
136 action: format!("{api}({args_str})"),
137 category: categorize_api(api),
138 });
139
140 if api.contains("cookie") && api.contains("get") {
141 s.has_cookie_read = true;
142 s.cookie_read_idx = Some(i);
143 }
144
145 if is_child_process_require(api, args) {
146 s.findings.push(BehavioralFinding {
147 severity: Severity::High,
148 category: Category::CodeInjection,
149 title: "Node child_process module loaded".into(),
150 detail:
151 "Script imports child_process, enabling command execution primitives"
152 .into(),
153 evidence: vec![i],
154 });
155 }
156
157 if is_command_execution_api(api) {
158 let command = extract_first_string_arg(args)
159 .unwrap_or_else(|| args_str.chars().take(200).collect::<String>());
160 let title = if api == "child_process.spawn" {
161 "Process spawning via child_process.spawn"
162 } else {
163 "Command execution via child_process"
164 };
165 let detail = if command.is_empty() {
166 format!("Script invokes {api}, a direct host command execution sink")
167 } else {
168 format!(
169 "Script invokes {api} with command payload: {}",
170 command.chars().take(200).collect::<String>()
171 )
172 };
173 s.findings.push(BehavioralFinding {
174 severity: Severity::Critical,
175 category: Category::CodeInjection,
176 title: title.into(),
177 detail,
178 evidence: vec![i],
179 });
180 }
181
182 if api.contains("location.href.set")
184 || api.contains("location.assign")
185 || api.contains("location.replace")
186 {
187 if let Some(url) = extract_first_string_arg(args) {
188 if url.starts_with("javascript:") || url.starts_with("data:") {
190 s.findings.push(BehavioralFinding {
191 severity: Severity::Critical,
192 category: Category::CodeInjection,
193 title: format!(
194 "Dangerous protocol redirect: {}...",
195 url.chars().take(40).collect::<String>()
196 ),
197 detail: format!(
198 "Script redirects via {}",
199 if url.starts_with("javascript:") {
200 "javascript: URI — code execution"
201 } else {
202 "data: URI — embedded payload"
203 }
204 ),
205 evidence: vec![i],
206 });
207 }
208 }
209 if let Some(url) = extract_url_from_args(args)
210 && is_external_url(&url)
211 {
212 s.has_external_fetch = true;
213 s.external_urls.push(url.clone());
214 s.fetch_indices.push(i);
215 s.findings.push(BehavioralFinding {
216 severity: Severity::High,
217 category: Category::NetworkActivity,
218 title: format!(
219 "Redirect to external URL: {}",
220 &url.chars().take(60).collect::<String>()
221 ),
222 detail: format!("Script redirects the page to {url}"),
223 evidence: vec![i],
224 });
225 }
226 }
227
228 if api == "navigator.sendBeacon"
230 && let Some(url) = extract_url_from_args(args)
231 && is_external_url(&url)
232 {
233 s.has_external_fetch = true;
234 s.external_urls.push(url.clone());
235 s.fetch_indices.push(i);
236 s.findings.push(BehavioralFinding {
237 severity: Severity::High,
238 category: Category::DataExfiltration,
239 title: format!("sendBeacon exfiltration to: {}", &url.chars().take(60).collect::<String>()),
240 detail: "navigator.sendBeacon fires even during page unload — stealthy data exfiltration".into(),
241 evidence: vec![i],
242 });
243 }
244
245 if (api == "fetch" || api.contains("xhr"))
246 && let Some(url) = extract_url_from_args(args)
247 && is_external_url(&url)
248 {
249 s.has_external_fetch = true;
250 s.external_urls.push(url);
251 s.fetch_indices.push(i);
252 }
253
254 if api.contains("setInnerHTML") || api.contains("document.write") {
257 let content = args_str.to_ascii_lowercase();
258 if content.contains("password") || content.contains("type=\"password") {
259 s.findings.push(BehavioralFinding {
260 severity: Severity::Critical,
261 category: Category::CredentialTheft,
262 title: "Credential form injected via innerHTML/document.write".into(),
263 detail: "Script injects HTML containing a password field".into(),
264 evidence: vec![i],
265 });
266 }
267 if content.contains("<form") && content.contains("action=") {
268 if let Some(start) = content.find("action=") {
270 let rest = &content[start + 7..];
271 let rest = rest.trim_start_matches('"').trim_start_matches('\'');
272 let end = rest.find(['"', '\'', ' ', '>']).unwrap_or(rest.len());
273 let action_url = &rest[..end];
274 if action_url.starts_with("http") {
275 s.has_external_fetch = true;
276 s.external_urls.push(action_url.to_string());
277 s.fetch_indices.push(i);
278 }
279 }
280 }
281 }
282
283 if api.contains("setAttribute") {
285 let content = args_str.to_ascii_lowercase();
286
287 if content.contains("action")
289 && let Some(url) = extract_url_from_args(args)
290 && is_external_url(&url)
291 {
292 s.findings.push(BehavioralFinding {
293 severity: Severity::Critical,
294 category: Category::CredentialTheft,
295 title: format!(
296 "Form submits to external URL: {}",
297 &url.chars().take(60).collect::<String>()
298 ),
299 detail: format!("Credential form sends data to {url}"),
300 evidence: vec![i],
301 });
302 s.has_external_fetch = true;
303 s.external_urls.push(url);
304 s.fetch_indices.push(i);
305 }
306 }
307
308 if api == "taint_sink_reached" {
315 let sink_name = extract_url_from_args(args).unwrap_or_else(|| "unknown".into());
316 s.findings.push(BehavioralFinding {
317 severity: Severity::Critical,
318 category: Category::CodeInjection,
319 title: format!("CONFIRMED: Tainted data reached {sink_name}"),
320 detail: format!("User-controlled input flowed through string operations to {sink_name}() — this is a confirmed code injection vulnerability, not a heuristic guess."),
321 evidence: vec![i],
322 });
323 }
324
325 if api == "credential_form_detected" {
326 let action_url = extract_url_from_args(args).unwrap_or_default();
327 let has_password = args_str.contains("true");
328 let is_external_action = action_url.starts_with("http://")
329 || action_url.starts_with("https://")
330 || action_url.starts_with("//");
331 if has_password && is_external_action {
332 s.findings.push(BehavioralFinding {
333 severity: Severity::Critical,
334 category: Category::CredentialTheft,
335 title: format!(
336 "Credential form submits to: {}",
337 &action_url.chars().take(60).collect::<String>()
338 ),
339 detail: format!(
340 "Form with password field submits to external URL: {action_url}"
341 ),
342 evidence: vec![i],
343 });
344 s.has_external_fetch = true;
345 s.external_urls.push(action_url);
346 s.fetch_indices.push(i);
347 }
348 }
349
350 if api.contains("Storage.setItem") || api.contains("storage.set") {
351 s.has_storage_write = true;
352 }
353
354 if api.contains("executeScript") {
355 s.findings.push(BehavioralFinding {
356 severity: Severity::Critical,
357 category: Category::CodeInjection,
358 title: "Remote code execution via executeScript".into(),
359 detail: format!("Extension injects code into browser tabs via {api}"),
360 evidence: vec![i],
361 });
362 }
363
364 if (api.contains("websocket.connect") || api.contains("websocket.send"))
366 && let Some(url) = extract_url_from_args(args).or_else(|| {
367 args.first().and_then(|a| a.as_str()).and_then(|s| {
369 if s.starts_with('[') {
370 serde_json::from_str::<Vec<serde_json::Value>>(s)
371 .ok()
372 .and_then(|arr| {
373 arr.first().and_then(|v| v.as_str().map(|s| s.to_string()))
374 })
375 } else if s.starts_with("ws://") || s.starts_with("wss://") {
376 Some(s.to_string())
377 } else {
378 None
379 }
380 })
381 })
382 {
383 s.findings.push(BehavioralFinding {
384 severity: Severity::High,
385 category: Category::NetworkActivity,
386 title: format!(
387 "WebSocket connection to: {}",
388 &url.chars().take(60).collect::<String>()
389 ),
390 detail: format!("Persistent C2 channel via WebSocket to {url}"),
391 evidence: vec![i],
392 });
393 s.has_external_fetch = true;
394 s.external_urls.push(url);
395 s.fetch_indices.push(i);
396 }
397
398 if api.contains("worker.create") || api.contains("sharedworker.create") {
400 s.findings.push(BehavioralFinding {
401 severity: Severity::Medium,
402 category: Category::Evasion,
403 title: "Web Worker created — possible payload hiding".into(),
404 detail: "Script creates a Worker thread for background execution".into(),
405 evidence: vec![i],
406 });
407 }
408
409 if api.contains("wasm.instantiate") || api.contains("wasm.compile") {
411 s.findings.push(BehavioralFinding {
412 severity: Severity::High,
413 category: Category::Evasion,
414 title: "WebAssembly module loaded — possible crypto miner".into(),
415 detail: "Script loads a WASM module, commonly used for hash computation in crypto miners".into(),
416 evidence: vec![i],
417 });
418 }
419
420 if api.contains("serviceworker.register") {
422 s.findings.push(BehavioralFinding {
423 severity: Severity::High,
424 category: Category::Persistence,
425 title: "ServiceWorker registration — persistent background execution".into(),
426 detail: "Script registers a ServiceWorker for persistent access even after tab closes".into(),
427 evidence: vec![i],
428 });
429 }
430
431 if api == "ethereum.request" || api == "ethereum.on" {
433 let method = extract_first_string_arg(args).unwrap_or_default();
434 if method == "eth_requestAccounts" || method == "eth_accounts" {
435 s.has_wallet_connect = true;
436 s.wallet_connect_idx = Some(i);
437 s.findings.push(BehavioralFinding {
438 severity: Severity::High,
439 category: Category::CryptoTheft,
440 title: "Wallet connection requested".into(),
441 detail: format!(
442 "Script requests access to user's Ethereum wallet via {method}"
443 ),
444 evidence: vec![i],
445 });
446 }
447 }
448 if api == "crypto.sign_transaction" || api == "crypto.send_transaction" {
449 s.has_sign_transaction = true;
450 s.sign_indices.push(i);
451 let is_send = api == "crypto.send_transaction";
452 s.findings.push(BehavioralFinding {
453 severity: Severity::Critical,
454 category: Category::CryptoTheft,
455 title: if is_send {
456 "DIRECT ETH TRANSFER requested".into()
457 } else {
458 "Transaction signing requested".into()
459 },
460 detail: format!(
461 "Script requests user {} a transaction: {}",
462 if is_send { "send" } else { "sign" },
463 args_str.chars().take(200).collect::<String>()
464 ),
465 evidence: vec![i],
466 });
467 }
468 if api == "crypto.sign_message" {
469 s.has_sign_message = true;
470 s.sign_indices.push(i);
471 let method = extract_first_string_arg(args).unwrap_or_default();
472 let severity = if method.contains("signTypedData") {
473 Severity::Critical } else {
475 Severity::High
476 };
477 s.findings.push(BehavioralFinding {
478 severity,
479 category: Category::CryptoTheft,
480 title: format!("Message signing via {method}"),
481 detail: "Script requests cryptographic signature — EIP-712 permit signatures can authorize token transfers".into(),
482 evidence: vec![i],
483 });
484 }
485 if api == "crypto.chain_switch" {
486 s.has_chain_switch = true;
487 s.findings.push(BehavioralFinding {
488 severity: Severity::High,
489 category: Category::CryptoTheft,
490 title: "Chain switch requested".into(),
491 detail: "Script attempts to switch wallet to a different blockchain network — common drainer tactic to move to attacker-controlled chain".into(),
492 evidence: vec![i],
493 });
494 }
495 if api.starts_with("crypto.solana_sign") {
496 s.has_sign_transaction = true;
497 s.sign_indices.push(i);
498 s.findings.push(BehavioralFinding {
499 severity: Severity::Critical,
500 category: Category::CryptoTheft,
501 title: format!("Solana transaction signing: {api}"),
502 detail: "Script requests Solana wallet to sign a transaction".into(),
503 evidence: vec![i],
504 });
505 }
506 if api == "solana.connect" {
507 s.has_wallet_connect = true;
508 s.wallet_connect_idx = Some(i);
509 s.findings.push(BehavioralFinding {
510 severity: Severity::High,
511 category: Category::CryptoTheft,
512 title: "Solana wallet connection requested".into(),
513 detail: "Script connects to user's Solana wallet".into(),
514 evidence: vec![i],
515 });
516 }
517
518 if api == "clipboard.writeText" || api == "clipboard.write" {
520 s.has_clipboard_write = true;
521 s.clipboard_write_content = extract_url_from_args(args)
522 .or_else(|| args.first().and_then(|a| a.as_str()).map(|s| s.to_string()));
523 let content = s.clipboard_write_content.as_deref().unwrap_or("");
525 let is_crypto_addr =
526 (content.starts_with("0x") && content.len() >= 42
528 && content[2..42].chars().all(|c| c.is_ascii_hexdigit()))
529 || (content.starts_with("bc1") && content.len() >= 42 && content.len() <= 62)
531 || (content.starts_with('1') && content.len() >= 26 && content.len() <= 35
533 && content.chars().all(|c| c.is_ascii_alphanumeric()))
534 || (content.starts_with('T') && content.len() == 34
536 && content.chars().all(|c| c.is_ascii_alphanumeric()));
537 let severity = if is_crypto_addr {
538 Severity::Critical
539 } else {
540 Severity::High
541 };
542 s.findings.push(BehavioralFinding {
543 severity,
544 category: Category::ClipboardHijack,
545 title: if is_crypto_addr {
546 "Clipboard hijacked with crypto address".into()
547 } else {
548 "Clipboard content overwritten".into()
549 },
550 detail: format!(
551 "Script writes to clipboard: {}...",
552 &content[..content.len().min(80)]
553 ),
554 evidence: vec![i],
555 });
556 }
557 if api == "clipboard.readText" || api == "clipboard.read" {
558 s.has_clipboard_read = true;
559 s.findings.push(BehavioralFinding {
560 severity: Severity::Medium,
561 category: Category::ClipboardHijack,
562 title: "Clipboard content read".into(),
563 detail: "Script reads clipboard — may be harvesting copied passwords or crypto addresses".into(),
564 evidence: vec![i],
565 });
566 }
567
568 if api == "notification.requestPermission" {
570 s.findings.push(BehavioralFinding {
571 severity: Severity::Medium,
572 category: Category::PrivacyViolation,
573 title: "Push notification permission requested".into(),
574 detail: "Script requests notification permissions — used for persistent social engineering".into(),
575 evidence: vec![i],
576 });
577 }
578
579 if api == "payment.request" || api == "payment.show" {
581 s.findings.push(BehavioralFinding {
582 severity: Severity::Critical,
583 category: Category::CryptoTheft,
584 title: "Payment Request API invoked".into(),
585 detail: "Script triggers browser payment flow — may be attempting unauthorized payment".into(),
586 evidence: vec![i],
587 });
588 }
589
590 if (api == "history.pushState" || api == "history.replaceState")
592 && let Some(url) = extract_url_from_args(args)
593 {
594 s.findings.push(BehavioralFinding {
595 severity: Severity::Medium,
596 category: Category::Evasion,
597 title: format!("URL bar manipulation via {api}"),
598 detail: format!(
599 "Script changes visible URL to {url} — may hide phishing URL"
600 ),
601 evidence: vec![i],
602 });
603 }
604
605 if api.starts_with("geolocation.") {
607 s.findings.push(BehavioralFinding {
608 severity: Severity::Medium,
609 category: Category::PrivacyViolation,
610 title: format!("Geolocation access: {api}"),
611 detail: "Script accesses user's physical location".into(),
612 evidence: vec![i],
613 });
614 }
615
616 if api.contains("webRequest") && api.contains("addListener") {
617 s.findings.push(BehavioralFinding {
618 severity: Severity::High,
619 category: Category::NetworkActivity,
620 title: "Web request interception".into(),
621 detail: "Extension intercepts and can modify all web requests".into(),
622 evidence: vec![i],
623 });
624 }
625 }
626
627 Observation::CookieAccess {
628 operation, name, ..
629 } => {
630 s.timeline.push(TimelineEntry {
631 index: i,
632 action: format!("cookie.{operation:?}({name})"),
633 category: Category::CookieAccess,
634 });
635 if matches!(operation, CookieOp::Read) {
636 s.has_cookie_read = true;
637 s.cookie_read_idx = Some(i);
638 }
639 }
640
641 Observation::NetworkRequest { url, method, .. } => {
642 s.timeline.push(TimelineEntry {
643 index: i,
644 action: format!("{method} {url}"),
645 category: Category::NetworkActivity,
646 });
647 if is_external_url(url) {
648 s.has_external_fetch = true;
649 s.external_urls.push(url.clone());
650 s.fetch_indices.push(i);
651 }
652 }
653
654 Observation::DynamicCodeExec {
655 source,
656 code_preview,
657 } => {
658 let preview = if code_preview.len() > 60 {
660 format!("{}...", &code_preview[..60])
661 } else {
662 code_preview.clone()
663 };
664 s.timeline.push(TimelineEntry {
665 index: i,
666 action: format!("{source:?}: {preview}"),
667 category: Category::CodeInjection,
668 });
669 s.findings.push(BehavioralFinding {
670 severity: Severity::High,
671 category: Category::CodeInjection,
672 title: format!("Dynamic code execution via {source:?}"),
673 detail: format!("Code: {preview}"),
674 evidence: vec![i],
675 });
676 }
677
678 Observation::CssExfiltration { selector, url, .. } => {
680 s.findings.push(BehavioralFinding {
681 severity: Severity::Critical,
682 category: Category::DataExfiltration,
683 title: format!("CSS keylogger: {selector} → {url}"),
684 detail: format!(
685 "CSS rule exfiltrates input data via background-image URL: {url}"
686 ),
687 evidence: vec![i],
688 });
689 s.has_external_fetch = true;
690 s.external_urls.push(url.clone());
691 s.fetch_indices.push(i);
692 }
693
694 Observation::FingerprintAccess { api, .. } => {
695 s.timeline.push(TimelineEntry {
696 index: i,
697 action: format!("fingerprint: {api}"),
698 category: Category::Fingerprinting,
699 });
700 s.findings.push(BehavioralFinding {
701 severity: Severity::Medium,
702 category: Category::Fingerprinting,
703 title: format!("Browser fingerprinting via {api}"),
704 detail: "Script probes browser identity APIs".into(),
705 evidence: vec![i],
706 });
707 }
708
709 Observation::WasmInstantiation { module_size, .. } => {
710 s.findings.push(BehavioralFinding {
711 severity: Severity::High,
712 category: Category::Evasion,
713 title: "WebAssembly module instantiation".into(),
714 detail: format!("Script loads a {module_size}-byte WASM module — may contain obfuscated logic"),
715 evidence: vec![i],
716 });
717 }
718
719 Observation::DomMutation {
720 kind,
721 target,
722 detail,
723 } => {
724 s.timeline.push(TimelineEntry {
725 index: i,
726 action: format!("DOM {kind:?} on <{target}>: {detail}"),
727 category: Category::DomManipulation,
728 });
729 }
730
731 _ => {}
732 }
733 }
734
735 if s.has_cookie_read && s.has_external_fetch {
737 let mut evidence = Vec::new();
738 if let Some(idx) = s.cookie_read_idx {
739 evidence.push(idx);
740 }
741 evidence.extend(&s.fetch_indices);
742 s.findings.push(BehavioralFinding {
743 severity: Severity::Critical,
744 category: Category::DataExfiltration,
745 title: "Cookie exfiltration to external server".into(),
746 detail: format!(
747 "Script reads cookies and sends data to: {}",
748 s.external_urls.join(", ")
749 ),
750 evidence,
751 });
752 } else if s.has_cookie_read {
753 s.findings.push(BehavioralFinding {
754 severity: Severity::Medium,
755 category: Category::CookieAccess,
756 title: "Cookie access detected".into(),
757 detail: "Script reads browser cookies".into(),
758 evidence: s.cookie_read_idx.into_iter().collect(),
759 });
760 }
761
762 if s.has_external_fetch && !s.has_cookie_read {
763 s.findings.push(BehavioralFinding {
764 severity: Severity::Medium,
765 category: Category::NetworkActivity,
766 title: "External network requests".into(),
767 detail: format!("Requests to: {}", s.external_urls.join(", ")),
768 evidence: s.fetch_indices.clone(),
769 });
770 }
771
772 if s.has_storage_write {
773 s.findings.push(BehavioralFinding {
774 severity: Severity::Low,
775 category: Category::Persistence,
776 title: "Local storage persistence".into(),
777 detail: "Script stores data in localStorage/sessionStorage".into(),
778 evidence: Vec::new(),
779 });
780 }
781
782 if s.has_wallet_connect && (s.has_sign_transaction || s.has_sign_message) {
784 let mut evidence = Vec::new();
785 if let Some(idx) = s.wallet_connect_idx {
786 evidence.push(idx);
787 }
788 evidence.extend(&s.sign_indices);
789 s.findings.push(BehavioralFinding {
790 severity: Severity::Critical,
791 category: Category::CryptoTheft,
792 title: "CONFIRMED: Crypto wallet drainer".into(),
793 detail: "Script connects to wallet AND requests transaction/message signing — this is a wallet drainer attack".into(),
794 evidence,
795 });
796 }
797
798 if s.has_wallet_connect && s.has_chain_switch {
800 s.findings.push(BehavioralFinding {
801 severity: Severity::Critical,
802 category: Category::CryptoTheft,
803 title: "Wallet drainer with chain manipulation".into(),
804 detail: "Script connects wallet and switches chain — drainers move victims to attacker-controlled networks".into(),
805 evidence: s.wallet_connect_idx.into_iter().collect(),
806 });
807 }
808
809 if s.has_wallet_connect && s.has_external_fetch {
811 let mut evidence = Vec::new();
812 if let Some(idx) = s.wallet_connect_idx {
813 evidence.push(idx);
814 }
815 evidence.extend(&s.fetch_indices);
816 s.findings.push(BehavioralFinding {
817 severity: Severity::High,
818 category: Category::CryptoTheft,
819 title: "Wallet drainer with C2 communication".into(),
820 detail: format!(
821 "Script connects wallet and communicates with external server: {}",
822 s.external_urls.join(", ")
823 ),
824 evidence,
825 });
826 }
827
828 if s.has_clipboard_read && s.has_clipboard_write {
830 s.findings.push(BehavioralFinding {
831 severity: Severity::Critical,
832 category: Category::ClipboardHijack,
833 title: "CONFIRMED: Clipboard swap attack".into(),
834 detail: "Script reads clipboard content and writes replacement — classic crypto address swap attack".into(),
835 evidence: Vec::new(),
836 });
837 }
838
839 if s.has_clipboard_write && s.has_wallet_connect {
841 s.findings.push(BehavioralFinding {
842 severity: Severity::Critical,
843 category: Category::CryptoTheft,
844 title: "Clipboard + wallet combined attack".into(),
845 detail:
846 "Script manipulates clipboard AND connects to wallet — multi-vector crypto theft"
847 .into(),
848 evidence: Vec::new(),
849 });
850 }
851
852 s.findings.sort_by(|a, b| b.severity.cmp(&a.severity));
854
855 let risk_score = compute_risk_score(&s.findings);
857
858 AnalysisReport {
859 findings: s.findings,
860 timeline: s.timeline,
861 risk_score,
862 observation_count: observations.len(),
863 }
864}
865
866pub struct AnalysisReport {
868 pub findings: Vec<BehavioralFinding>,
869 pub timeline: Vec<TimelineEntry>,
870 pub risk_score: u8, pub observation_count: usize,
872}
873
874impl AnalysisReport {
875 pub fn format_text(&self, target: &str, duration_us: u64) -> String {
877 let reset = "\x1b[0m";
878 let bold = "\x1b[1m";
879 let dim = "\x1b[2m";
880
881 let mut out = String::new();
882
883 out.push_str(&format!(
884 "\n{bold}jsdet v0.1.0 — JavaScript Detonation Report{reset}\n"
885 ));
886 out.push_str(&"━".repeat(55));
887 out.push('\n');
888 out.push_str(&format!("Target: {target}\n"));
889 out.push_str(&format!(
890 "Duration: {:.1}ms | Observations: {}\n",
891 duration_us as f64 / 1000.0,
892 self.observation_count
893 ));
894
895 let risk_color = if self.risk_score >= 80 {
896 "\x1b[91m"
897 } else if self.risk_score >= 50 {
898 "\x1b[33m"
899 } else if self.risk_score >= 20 {
900 "\x1b[36m"
901 } else {
902 "\x1b[32m"
903 };
904 out.push_str(&format!(
905 "Risk: {risk_color}{bold}{}/100{reset}\n",
906 self.risk_score
907 ));
908 out.push('\n');
909
910 if self.findings.is_empty() {
911 out.push_str(&format!("{dim}No behavioral findings.{reset}\n"));
912 } else {
913 out.push_str(&format!("{bold}Behavioral Summary:{reset}\n"));
914 for finding in &self.findings {
915 let color = finding.severity.color_code();
916 let icon = finding.severity.icon();
917 out.push_str(&format!(
918 " {color}[{icon}] {}{reset} — {}\n",
919 finding.category.label(),
920 finding.title,
921 ));
922 out.push_str(&format!(" {dim}{}{reset}\n", finding.detail));
923 }
924 out.push('\n');
925 }
926
927 if !self.timeline.is_empty() {
928 out.push_str(&format!("{bold}Timeline:{reset}\n"));
929 for (i, entry) in self.timeline.iter().take(20).enumerate() {
930 let color = entry.category.color_code();
931 out.push_str(&format!(
932 " {dim}{:>3}.{reset} {color}{}{reset}\n",
933 i + 1,
934 entry.action
935 ));
936 }
937 if self.timeline.len() > 20 {
938 out.push_str(&format!(
939 " {dim}... and {} more{reset}\n",
940 self.timeline.len() - 20
941 ));
942 }
943 }
944
945 out
946 }
947}
948
949impl Category {
950 fn color_code(self) -> &'static str {
951 match self {
952 Self::CredentialTheft
953 | Self::DataExfiltration
954 | Self::CodeInjection
955 | Self::CryptoTheft => "\x1b[31m",
956 Self::PermissionAbuse | Self::NetworkActivity | Self::ClipboardHijack => "\x1b[33m",
957 Self::Persistence
958 | Self::CookieAccess
959 | Self::Evasion
960 | Self::Fingerprinting
961 | Self::PrivacyViolation => "\x1b[36m",
962 Self::DomManipulation => "\x1b[90m",
963 }
964 }
965}
966
967#[allow(dead_code)]
969pub struct TimelineEntry {
970 pub index: usize,
971 pub action: String,
972 pub category: Category,
973}
974
975fn categorize_api(api: &str) -> Category {
976 if api.contains("ethereum")
977 || api.contains("solana")
978 || api.starts_with("crypto.sign")
979 || api.starts_with("crypto.solana")
980 || api.starts_with("crypto.chain")
981 {
982 Category::CryptoTheft
983 } else if api.contains("clipboard") {
984 Category::ClipboardHijack
985 } else if api.contains("geolocation") || api.contains("notification") || api.contains("payment")
986 {
987 Category::PrivacyViolation
988 } else if is_command_execution_api(api) {
989 Category::CodeInjection
990 } else if api.contains("cookie") {
991 Category::CookieAccess
992 } else if api.contains("fetch") || api.contains("xhr") || api.contains("Request") {
993 Category::NetworkActivity
994 } else if api.contains("eval") || api.contains("Function") || api.contains("executeScript") {
995 Category::CodeInjection
996 } else if api.contains("Storage") || api.contains("storage") {
997 Category::Persistence
998 } else if api.contains("fingerprint") || api.contains("canvas") || api.contains("webgl") {
999 Category::Fingerprinting
1000 } else if api.contains("webRequest") {
1001 Category::NetworkActivity
1002 } else {
1003 Category::DomManipulation
1004 }
1005}
1006
1007fn extract_url_from_args(args: &[jsdet_core::observation::Value]) -> Option<String> {
1008 for arg in args {
1009 if let jsdet_core::observation::Value::String(s, _) = arg {
1010 if s.starts_with("http://") || s.starts_with("https://") {
1012 return Some(s.clone());
1013 }
1014 if s.starts_with('[')
1017 && let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(s)
1018 {
1019 for item in arr {
1020 if let Some(str_val) = item.as_str()
1021 && (str_val.starts_with("http://") || str_val.starts_with("https://"))
1022 {
1023 return Some(str_val.to_string());
1024 }
1025 }
1026 }
1027 if let Some(pos) = s.find("https://").or_else(|| s.find("http://")) {
1029 let rest = &s[pos..];
1030 let end = rest.find(['"', '\'', ' ', '>', ']']).unwrap_or(rest.len());
1031 let url = &rest[..end];
1032 if url.len() > 10 {
1033 return Some(url.to_string());
1034 }
1035 }
1036 }
1037 }
1038 None
1039}
1040
1041fn is_external_url(url: &str) -> bool {
1042 url.starts_with("http://") || url.starts_with("https://")
1043}
1044
1045fn extract_first_string_arg(args: &[jsdet_core::observation::Value]) -> Option<String> {
1048 for arg in args {
1049 if let jsdet_core::observation::Value::String(s, _) = arg {
1050 if s.starts_with('[') {
1051 if let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(s)
1052 && let Some(first) = arr.first()
1053 && let Some(s) = first.as_str()
1054 {
1055 return Some(s.to_string());
1056 }
1057 } else {
1058 return Some(s.clone());
1059 }
1060 }
1061 }
1062 None
1063}
1064
1065fn is_child_process_require(api: &str, args: &[jsdet_core::observation::Value]) -> bool {
1066 matches!(api, "require" | "node:require")
1067 && extract_first_string_arg(args)
1068 .is_some_and(|module| matches!(module.as_str(), "child_process" | "node:child_process"))
1069}
1070
1071fn is_command_execution_api(api: &str) -> bool {
1072 matches!(
1073 api,
1074 "child_process.exec" | "child_process.execSync" | "child_process.spawn"
1075 )
1076}
1077
1078fn compute_risk_score(findings: &[BehavioralFinding]) -> u8 {
1079 let mut score: u32 = 0;
1080 for finding in findings {
1081 let base: u32 = match finding.severity {
1082 Severity::Critical => 30,
1083 Severity::High => 20,
1084 Severity::Medium => 10,
1085 Severity::Low => 5,
1086 Severity::Info => 1,
1087 };
1088 let multiplier: u32 = if finding.title.starts_with("CONFIRMED:")
1093 || finding.title.starts_with("DIRECT ETH TRANSFER")
1094 {
1095 2
1096 } else {
1097 1
1098 };
1099 score = score.saturating_add(base.saturating_mul(multiplier));
1101 }
1102 score.min(100) as u8
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107 use super::*;
1108 use jsdet_core::observation::{DynamicCodeSource, Value};
1109
1110 #[test]
1111 fn cookie_plus_fetch_is_exfiltration() {
1112 let observations = vec![
1113 Observation::ApiCall {
1114 api: "document.cookie.get".into(),
1115 args: vec![],
1116 result: Value::string("session=abc"),
1117 },
1118 Observation::ApiCall {
1119 api: "fetch".into(),
1120 args: vec![Value::string("[\"https://evil.com/exfil\",\"POST\"]")],
1121 result: Value::Undefined,
1122 },
1123 ];
1124 let report = analyze(&observations);
1125 assert!(
1126 report
1127 .findings
1128 .iter()
1129 .any(|f| f.category == Category::DataExfiltration),
1130 "should detect data exfiltration"
1131 );
1132 assert!(report.risk_score >= 30);
1133 }
1134
1135 #[test]
1136 fn eval_is_code_injection() {
1137 let observations = vec![Observation::DynamicCodeExec {
1138 source: DynamicCodeSource::Eval,
1139 code_preview: "alert(1)".into(),
1140 }];
1141 let report = analyze(&observations);
1142 assert!(
1143 report
1144 .findings
1145 .iter()
1146 .any(|f| f.category == Category::CodeInjection)
1147 );
1148 }
1149
1150 #[test]
1151 fn empty_observations_zero_risk() {
1152 let report = analyze(&[]);
1153 assert_eq!(report.risk_score, 0);
1154 assert!(report.findings.is_empty());
1155 }
1156
1157 #[test]
1158 fn crypto_wallet_drainer_scores_critical() {
1159 let observations = vec![
1161 Observation::ApiCall {
1162 api: "ethereum.request".into(),
1163 args: vec![Value::string("[\"eth_requestAccounts\",\"[]\"]")],
1164 result: Value::Undefined,
1165 },
1166 Observation::ApiCall {
1167 api: "crypto.sign_message".into(),
1168 args: vec![Value::string(
1169 "[\"eth_signTypedData_v4\",\"[\\\"0xVICTIM\\\",\\\"{}\\\"]\"]",
1170 )],
1171 result: Value::Undefined,
1172 },
1173 Observation::ApiCall {
1174 api: "fetch".into(),
1175 args: vec![Value::string(
1176 "[\"https://drainer-c2.evil/api/drain\",\"POST\"]",
1177 )],
1178 result: Value::Undefined,
1179 },
1180 ];
1181 let report = analyze(&observations);
1182
1183 assert!(
1185 report
1186 .findings
1187 .iter()
1188 .any(|f| f.category == Category::CryptoTheft && f.title.contains("CONFIRMED")),
1189 "must detect confirmed crypto drainer, findings: {:?}",
1190 report.findings.iter().map(|f| &f.title).collect::<Vec<_>>()
1191 );
1192
1193 assert!(
1195 report.risk_score >= 90,
1196 "crypto wallet drainer must score >= 90, got {}",
1197 report.risk_score
1198 );
1199 }
1200
1201 #[test]
1202 fn clipboard_swap_detected() {
1203 let observations = vec![
1204 Observation::ApiCall {
1205 api: "clipboard.readText".into(),
1206 args: vec![],
1207 result: Value::Undefined,
1208 },
1209 Observation::ApiCall {
1210 api: "clipboard.writeText".into(),
1211 args: vec![Value::string("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD38")],
1212 result: Value::Undefined,
1213 },
1214 ];
1215 let report = analyze(&observations);
1216
1217 assert!(
1219 report
1220 .findings
1221 .iter()
1222 .any(|f| f.category == Category::ClipboardHijack && f.title.contains("CONFIRMED")),
1223 "must detect confirmed clipboard swap"
1224 );
1225
1226 assert!(
1228 report
1229 .findings
1230 .iter()
1231 .any(|f| f.title.contains("crypto address")),
1232 "must detect crypto address in clipboard write"
1233 );
1234 }
1235
1236 #[test]
1237 fn multi_vector_attack_maxes_risk() {
1238 let observations = vec![
1240 Observation::ApiCall {
1241 api: "document.cookie.get".into(),
1242 args: vec![],
1243 result: Value::string("session=abc"),
1244 },
1245 Observation::ApiCall {
1246 api: "ethereum.request".into(),
1247 args: vec![Value::string("[\"eth_requestAccounts\",\"[]\"]")],
1248 result: Value::Undefined,
1249 },
1250 Observation::ApiCall {
1251 api: "clipboard.writeText".into(),
1252 args: vec![Value::string("0xATTACKER")],
1253 result: Value::Undefined,
1254 },
1255 Observation::ApiCall {
1256 api: "crypto.sign_transaction".into(),
1257 args: vec![Value::string("[{\"to\":\"0xATTACKER\"}]")],
1258 result: Value::Undefined,
1259 },
1260 Observation::ApiCall {
1261 api: "fetch".into(),
1262 args: vec![Value::string("[\"https://mega-c2.evil/harvest\",\"POST\"]")],
1263 result: Value::Undefined,
1264 },
1265 ];
1266 let report = analyze(&observations);
1267 assert_eq!(
1268 report.risk_score, 100,
1269 "multi-vector attack must score 100, got {}",
1270 report.risk_score
1271 );
1272 }
1273
1274 #[test]
1275 fn child_process_exec_api_call_is_critical() {
1276 let observations = vec![
1277 Observation::ApiCall {
1278 api: "require".into(),
1279 args: vec![Value::string("child_process")],
1280 result: Value::Undefined,
1281 },
1282 Observation::ApiCall {
1283 api: "child_process.exec".into(),
1284 args: vec![Value::string("cat /etc/passwd")],
1285 result: Value::Undefined,
1286 },
1287 ];
1288
1289 let report = analyze(&observations);
1290
1291 assert!(
1292 report.findings.iter().any(|f| {
1293 f.severity == Severity::Critical
1294 && f.category == Category::CodeInjection
1295 && f.title.contains("Command execution")
1296 }),
1297 "expected critical command execution finding, got {:?}",
1298 report.findings.iter().map(|f| &f.title).collect::<Vec<_>>()
1299 );
1300 assert!(
1301 report.risk_score >= 50,
1302 "expected elevated risk, got {}",
1303 report.risk_score
1304 );
1305 }
1306
1307 #[test]
1308 fn child_process_spawn_api_call_is_critical() {
1309 let observations = vec![Observation::ApiCall {
1310 api: "child_process.spawn".into(),
1311 args: vec![Value::string("[\"/bin/sh\",\"-c\",\"id\"]")],
1312 result: Value::Undefined,
1313 }];
1314
1315 let report = analyze(&observations);
1316
1317 assert!(report.findings.iter().any(|f| {
1318 f.severity == Severity::Critical
1319 && f.category == Category::CodeInjection
1320 && f.title.contains("spawn")
1321 }));
1322 }
1323}