Skip to main content

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