Skip to main content

jsdet_core/
observation.rs

1/// A single observable action performed by JavaScript during execution.
2///
3/// Observations are the OUTPUT of detonation. They describe what the code DID,
4/// not what it IS. Every observation includes enough context to reconstruct
5/// the action without access to the original script.
6///
7/// Consumers (Sear, Soleno) receive a `Vec<Observation>` in execution order.
8/// The observation stream is the single source of truth for behavioral analysis.
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub enum Observation {
11    /// A bridged API function was called.
12    ApiCall {
13        api: String,
14        args: Vec<Value>,
15        result: Value,
16    },
17    /// A bridged object property was read.
18    PropertyRead {
19        object: String,
20        property: String,
21        value: Value,
22    },
23    /// A bridged object property was written.
24    PropertyWrite {
25        object: String,
26        property: String,
27        value: Value,
28    },
29    /// DOM was mutated (element created, attribute set, innerHTML written, etc.).
30    DomMutation {
31        kind: DomMutationKind,
32        target: String,
33        detail: String,
34    },
35    /// An outbound network request was attempted.
36    NetworkRequest {
37        url: String,
38        method: String,
39        headers: Vec<(String, String)>,
40        body: Option<String>,
41    },
42    /// A timer was registered.
43    TimerSet {
44        id: u32,
45        delay_ms: u32,
46        is_interval: bool,
47        callback_preview: String,
48    },
49    /// Dynamic code execution: `eval()`, `Function()`, `setTimeout(string)`, etc.
50    DynamicCodeExec {
51        source: DynamicCodeSource,
52        code_preview: String,
53    },
54    /// Cookie was read or written.
55    CookieAccess {
56        operation: CookieOp,
57        name: String,
58        value: Option<String>,
59    },
60    /// A CSS rule matched that would trigger an external URL load.
61    CssExfiltration {
62        selector: String,
63        url: String,
64        trigger: String,
65    },
66    /// JavaScript attempted to instantiate a WebAssembly module.
67    WasmInstantiation {
68        module_size: usize,
69        import_names: Vec<String>,
70        export_names: Vec<String>,
71    },
72    /// A fingerprinting API was accessed.
73    FingerprintAccess { api: String, detail: String },
74    /// Message sent between execution contexts.
75    ContextMessage {
76        from_context: String,
77        to_context: String,
78        payload: Value,
79    },
80    /// Script execution produced an error.
81    Error {
82        message: String,
83        script_index: Option<usize>,
84    },
85    /// Execution hit a resource limit.
86    ResourceLimit {
87        kind: ResourceLimitKind,
88        detail: String,
89    },
90}
91
92/// Machine-consumable runtime evidence derived from one observation.
93///
94/// This is intentionally narrower than [`Observation`]. It captures the stable
95/// security-relevant fact consumers can fuse into their own risk models.
96#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
97#[serde(tag = "kind", rename_all = "snake_case")]
98#[non_exhaustive]
99pub enum RuntimeEvidence {
100    Redirect {
101        target: String,
102    },
103    PopupOpen {
104        target: String,
105    },
106    WebSocket {
107        url: String,
108        operation: String,
109        payload_preview: String,
110    },
111    WebRtc {
112        operation: String,
113        target: String,
114        payload_preview: String,
115    },
116    WorkerSpawn {
117        worker_type: String,
118        script_url: String,
119    },
120    NetworkRequest {
121        url: String,
122        method: String,
123    },
124    DomMutation {
125        target: String,
126        operation: String,
127        value: String,
128    },
129    CookieAccess {
130        operation: String,
131        name: String,
132    },
133    DynamicCodeExec {
134        source: String,
135        code_preview: String,
136    },
137    CssExfiltration {
138        selector: String,
139        url: String,
140        trigger: String,
141    },
142    FingerprintAccess {
143        api: String,
144        detail: String,
145    },
146    WasmInstantiation {
147        module_size: usize,
148    },
149    ContextMessage {
150        from_context: String,
151        to_context: String,
152        payload_preview: String,
153    },
154    CredentialForm {
155        action: String,
156        password_field: bool,
157    },
158    ServiceWorkerRegister {
159        script_url: String,
160    },
161    NotificationPermissionRequest,
162    NotificationCreate {
163        title: String,
164    },
165    PaymentRequest {
166        stage: String,
167    },
168    ClipboardAccess {
169        operation: String,
170        content_preview: String,
171    },
172    GeolocationAccess {
173        operation: String,
174    },
175    CryptoOperation {
176        operation: String,
177        detail: String,
178    },
179    IndexedDbOpen {
180        name: String,
181    },
182    TrustedTypesPolicy {
183        name: String,
184    },
185    StorageWrite {
186        area: String,
187        key: String,
188    },
189    TimerScheduled {
190        delay_ms: u32,
191        is_interval: bool,
192    },
193    SchedulerTask {
194        operation: String,
195        priority: String,
196    },
197    InputMonitor {
198        event_type: String,
199    },
200    Dialog {
201        dialog_type: String,
202        message: String,
203    },
204    NavigationTrap {
205        trap_type: String,
206        message: String,
207    },
208    ResourceLimit {
209        limit_kind: String,
210        detail: String,
211    },
212    Error {
213        message: String,
214    },
215}
216
217#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)]
218#[serde(rename_all = "snake_case")]
219pub enum EvidenceDerivation {
220    #[default]
221    Observed,
222    Derived,
223}
224
225#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)]
226#[serde(rename_all = "snake_case")]
227pub enum EvidenceFreshness {
228    Fresh,
229    Recent,
230    #[default]
231    Unknown,
232    Stale,
233}
234
235#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)]
236#[serde(rename_all = "snake_case")]
237pub enum Exploitability {
238    #[default]
239    None,
240    Low,
241    Medium,
242    High,
243    Certain,
244}
245
246#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)]
247#[serde(rename_all = "snake_case")]
248pub enum Maliciousness {
249    #[default]
250    None,
251    Contextual,
252    Suspicious,
253    High,
254    Certain,
255}
256
257#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)]
258#[serde(default)]
259pub struct EvidenceProvenance {
260    pub engine: String,
261    pub layer: String,
262    pub artifact_id: Option<String>,
263    pub refs: Vec<String>,
264    pub context: String,
265    pub observed_from: String,
266}
267
268#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Default)]
269#[serde(default)]
270pub struct RuntimeEvidenceItem {
271    pub evidence_id: String,
272    pub family: String,
273    pub summary: String,
274    pub detail: String,
275    pub severity: crate::analysis::Severity,
276    pub derivation: EvidenceDerivation,
277    pub freshness: EvidenceFreshness,
278    pub confidence: f64,
279    pub exploitability: Exploitability,
280    pub maliciousness: Maliciousness,
281    pub provenance: EvidenceProvenance,
282    pub contradicts: Vec<String>,
283}
284
285impl Observation {
286    /// Convert one observation into a stable runtime-evidence item.
287    #[must_use]
288    pub fn to_runtime_evidence(&self) -> RuntimeEvidence {
289        match self {
290            Self::ApiCall { api, args, .. } => api_call_runtime_evidence(api, args),
291            Self::PropertyRead {
292                object,
293                property,
294                value,
295            } => RuntimeEvidence::DomMutation {
296                target: object.clone(),
297                operation: format!("property_read:{property}"),
298                value: value.as_str().unwrap_or("").to_string(),
299            },
300            Self::PropertyWrite {
301                object,
302                property,
303                value,
304            } => RuntimeEvidence::DomMutation {
305                target: object.clone(),
306                operation: format!("property_write:{property}"),
307                value: value.as_str().unwrap_or("").to_string(),
308            },
309            Self::DomMutation {
310                kind,
311                target,
312                detail,
313            } => RuntimeEvidence::DomMutation {
314                target: target.clone(),
315                operation: kind.as_str().into(),
316                value: detail.clone(),
317            },
318            Self::NetworkRequest { url, method, .. } => RuntimeEvidence::NetworkRequest {
319                url: url.clone(),
320                method: method.clone(),
321            },
322            Self::TimerSet {
323                delay_ms,
324                is_interval,
325                callback_preview,
326                ..
327            } => RuntimeEvidence::DomMutation {
328                target: "timer".into(),
329                operation: if *is_interval {
330                    "set_interval"
331                } else {
332                    "set_timeout"
333                }
334                .into(),
335                value: format!("{delay_ms}ms:{callback_preview}"),
336            },
337            Self::DynamicCodeExec {
338                source,
339                code_preview,
340            } => RuntimeEvidence::DynamicCodeExec {
341                source: source.as_str().into(),
342                code_preview: code_preview.clone(),
343            },
344            Self::CookieAccess {
345                operation, name, ..
346            } => RuntimeEvidence::CookieAccess {
347                operation: operation.as_str().into(),
348                name: name.clone(),
349            },
350            Self::CssExfiltration {
351                selector,
352                url,
353                trigger,
354            } => RuntimeEvidence::CssExfiltration {
355                selector: selector.clone(),
356                url: url.clone(),
357                trigger: trigger.clone(),
358            },
359            Self::WasmInstantiation { module_size, .. } => RuntimeEvidence::WasmInstantiation {
360                module_size: *module_size,
361            },
362            Self::FingerprintAccess { api, detail } => RuntimeEvidence::FingerprintAccess {
363                api: api.clone(),
364                detail: detail.clone(),
365            },
366            Self::ContextMessage {
367                from_context,
368                to_context,
369                payload,
370            } => RuntimeEvidence::ContextMessage {
371                from_context: from_context.clone(),
372                to_context: to_context.clone(),
373                payload_preview: payload
374                    .as_str()
375                    .map(str::to_string)
376                    .unwrap_or_else(|| payload.to_string()),
377            },
378            Self::Error { message, .. } => RuntimeEvidence::Error {
379                message: message.clone(),
380            },
381            Self::ResourceLimit { kind, detail } => RuntimeEvidence::ResourceLimit {
382                limit_kind: kind.as_str().into(),
383                detail: detail.clone(),
384            },
385        }
386    }
387}
388
389/// Convert a stream of observations into stable runtime evidence.
390#[must_use]
391pub fn observations_to_runtime_evidence(observations: &[Observation]) -> Vec<RuntimeEvidence> {
392    observations
393        .iter()
394        .map(Observation::to_runtime_evidence)
395        .collect()
396}
397
398#[must_use]
399pub fn observations_to_contract_evidence(observations: &[Observation]) -> Vec<RuntimeEvidenceItem> {
400    observations
401        .iter()
402        .enumerate()
403        .map(|(idx, observation)| {
404            let runtime_evidence = observation.to_runtime_evidence();
405            let (family, summary, detail, exploitability, maliciousness) =
406                runtime_evidence_contract(&runtime_evidence);
407            RuntimeEvidenceItem {
408                evidence_id: format!("runtime-{idx}"),
409                family,
410                summary,
411                detail,
412                severity: runtime_evidence_severity(&runtime_evidence),
413                derivation: EvidenceDerivation::Observed,
414                freshness: EvidenceFreshness::Fresh,
415                confidence: 1.0,
416                exploitability,
417                maliciousness,
418                provenance: EvidenceProvenance {
419                    engine: "jsdet".into(),
420                    layer: "sandbox".into(),
421                    artifact_id: None,
422                    refs: vec![],
423                    context: "runtime".into(),
424                    observed_from: format!("{runtime_evidence:?}"),
425                },
426                contradicts: vec![],
427            }
428        })
429        .collect()
430}
431
432fn runtime_evidence_contract(
433    evidence: &RuntimeEvidence,
434) -> (String, String, String, Exploitability, Maliciousness) {
435    match evidence {
436        RuntimeEvidence::CredentialForm { action, .. } => (
437            "credential_collection".into(),
438            "credential_form".into(),
439            action.clone(),
440            Exploitability::High,
441            Maliciousness::High,
442        ),
443        RuntimeEvidence::DynamicCodeExec {
444            source,
445            code_preview,
446        } => (
447            "runtime_behavior".into(),
448            source.clone(),
449            code_preview.clone(),
450            Exploitability::High,
451            Maliciousness::Suspicious,
452        ),
453        RuntimeEvidence::ContextMessage {
454            to_context,
455            payload_preview,
456            ..
457        } => (
458            "context_relay".into(),
459            to_context.clone(),
460            payload_preview.clone(),
461            Exploitability::Medium,
462            Maliciousness::Suspicious,
463        ),
464        RuntimeEvidence::PaymentRequest { stage } => (
465            "payment_flow".into(),
466            stage.clone(),
467            stage.clone(),
468            Exploitability::High,
469            Maliciousness::High,
470        ),
471        RuntimeEvidence::NetworkRequest { url, method } => (
472            "network_behavior".into(),
473            method.clone(),
474            url.clone(),
475            Exploitability::Medium,
476            Maliciousness::Contextual,
477        ),
478        other => (
479            "runtime_behavior".into(),
480            format!("{other:?}"),
481            format!("{other:?}"),
482            Exploitability::Low,
483            Maliciousness::Contextual,
484        ),
485    }
486}
487
488fn runtime_evidence_severity(evidence: &RuntimeEvidence) -> crate::analysis::Severity {
489    use crate::analysis::Severity;
490
491    match evidence {
492        RuntimeEvidence::CredentialForm { .. }
493        | RuntimeEvidence::PaymentRequest { .. }
494        | RuntimeEvidence::NotificationPermissionRequest
495        | RuntimeEvidence::TrustedTypesPolicy { .. } => Severity::High,
496        RuntimeEvidence::DynamicCodeExec { .. }
497        | RuntimeEvidence::ContextMessage { .. }
498        | RuntimeEvidence::ServiceWorkerRegister { .. }
499        | RuntimeEvidence::ClipboardAccess { .. }
500        | RuntimeEvidence::GeolocationAccess { .. }
501        | RuntimeEvidence::CryptoOperation { .. }
502        | RuntimeEvidence::WebSocket { .. }
503        | RuntimeEvidence::WebRtc { .. } => Severity::Medium,
504        RuntimeEvidence::NetworkRequest { .. }
505        | RuntimeEvidence::DomMutation { .. }
506        | RuntimeEvidence::CookieAccess { .. }
507        | RuntimeEvidence::CssExfiltration { .. }
508        | RuntimeEvidence::FingerprintAccess { .. }
509        | RuntimeEvidence::WasmInstantiation { .. }
510        | RuntimeEvidence::WorkerSpawn { .. }
511        | RuntimeEvidence::NotificationCreate { .. }
512        | RuntimeEvidence::IndexedDbOpen { .. }
513        | RuntimeEvidence::StorageWrite { .. }
514        | RuntimeEvidence::TimerScheduled { .. }
515        | RuntimeEvidence::SchedulerTask { .. }
516        | RuntimeEvidence::InputMonitor { .. }
517        | RuntimeEvidence::Dialog { .. }
518        | RuntimeEvidence::NavigationTrap { .. } => Severity::Low,
519        RuntimeEvidence::PopupOpen { .. }
520        | RuntimeEvidence::Redirect { .. }
521        | RuntimeEvidence::ResourceLimit { .. }
522        | RuntimeEvidence::Error { .. } => Severity::Info,
523    }
524}
525
526fn api_call_runtime_evidence(api: &str, args: &[Value]) -> RuntimeEvidence {
527    if api == "window.open" {
528        return RuntimeEvidence::PopupOpen {
529            target: first_stringish_arg(args).unwrap_or_default(),
530        };
531    }
532    if api.contains("websocket.connect") || api.contains("websocket.send") {
533        return RuntimeEvidence::WebSocket {
534            url: extract_array_item(args, 0).unwrap_or_default(),
535            operation: if api.contains("connect") {
536                "connect".into()
537            } else {
538                "send".into()
539            },
540            payload_preview: extract_array_item(args, 1).unwrap_or_default(),
541        };
542    }
543    if api.starts_with("webrtc.") {
544        return RuntimeEvidence::WebRtc {
545            operation: api.trim_start_matches("webrtc.").to_string(),
546            target: extract_array_item(args, 0).unwrap_or_default(),
547            payload_preview: extract_array_item(args, 1).unwrap_or_default(),
548        };
549    }
550    if api.starts_with("wasm.") {
551        return RuntimeEvidence::WasmInstantiation {
552            module_size: extract_array_item(args, 0)
553                .and_then(|value| value.parse::<usize>().ok())
554                .unwrap_or_default(),
555        };
556    }
557    if api.contains("worker.create") || api.contains("sharedworker.create") {
558        return RuntimeEvidence::WorkerSpawn {
559            worker_type: if api.contains("sharedworker") {
560                "shared_worker".into()
561            } else {
562                "worker".into()
563            },
564            script_url: first_stringish_arg(args).unwrap_or_default(),
565        };
566    }
567    if api == "credential_form_detected" {
568        let action = extract_array_item(args, 0).unwrap_or_default();
569        let password_field = extract_array_item(args, 2)
570            .map(|v| v.eq_ignore_ascii_case("true"))
571            .unwrap_or(false);
572        return RuntimeEvidence::CredentialForm {
573            action,
574            password_field,
575        };
576    }
577    if api.contains("serviceworker.register") {
578        return RuntimeEvidence::ServiceWorkerRegister {
579            script_url: first_stringish_arg(args).unwrap_or_default(),
580        };
581    }
582    if api == "notification.requestPermission" {
583        return RuntimeEvidence::NotificationPermissionRequest;
584    }
585    if api == "notification.create" {
586        return RuntimeEvidence::NotificationCreate {
587            title: first_stringish_arg(args).unwrap_or_default(),
588        };
589    }
590    if api == "payment.request" || api == "payment.show" {
591        return RuntimeEvidence::PaymentRequest {
592            stage: if api == "payment.request" {
593                "request".into()
594            } else {
595                "show".into()
596            },
597        };
598    }
599    if api == "clipboard.writeText"
600        || api == "clipboard.write"
601        || api == "clipboard.readText"
602        || api == "clipboard.read"
603    {
604        return RuntimeEvidence::ClipboardAccess {
605            operation: api
606                .split('.')
607                .next_back()
608                .unwrap_or("clipboard")
609                .to_string(),
610            content_preview: first_stringish_arg(args).unwrap_or_default(),
611        };
612    }
613    if api.starts_with("geolocation.") {
614        return RuntimeEvidence::GeolocationAccess {
615            operation: api.split('.').nth(1).unwrap_or("unknown").to_string(),
616        };
617    }
618    if let Some(operation) = api.strip_prefix("crypto.subtle.") {
619        return RuntimeEvidence::CryptoOperation {
620            operation: operation.replace("Key", "_key").to_lowercase(),
621            detail: extract_array_item(args, 0).unwrap_or_default(),
622        };
623    }
624    if api == "crypto.getRandomValues" {
625        return RuntimeEvidence::CryptoOperation {
626            operation: "get_random_values".into(),
627            detail: extract_array_item(args, 0).unwrap_or_default(),
628        };
629    }
630    if api == "indexedDB.open" {
631        return RuntimeEvidence::IndexedDbOpen {
632            name: extract_array_item(args, 0).unwrap_or_default(),
633        };
634    }
635    if api == "trustedTypes.createPolicy" {
636        return RuntimeEvidence::TrustedTypesPolicy {
637            name: extract_array_item(args, 0).unwrap_or_default(),
638        };
639    }
640    if api.contains("Storage.setItem") || api.contains("storage.set") {
641        return RuntimeEvidence::StorageWrite {
642            area: if api.contains("localStorage") {
643                "local_storage".into()
644            } else if api.contains("sessionStorage") {
645                "session_storage".into()
646            } else {
647                "storage".into()
648            },
649            key: extract_array_item(args, 0).unwrap_or_default(),
650        };
651    }
652    if api == "storageEvent.register" {
653        return RuntimeEvidence::ContextMessage {
654            from_context: "current".into(),
655            to_context: "storage_event".into(),
656            payload_preview: "register".into(),
657        };
658    }
659    if api == "storageEvent.fire" {
660        let area = extract_array_item(args, 0).unwrap_or_else(|| "storage".into());
661        let key = extract_array_item(args, 1).unwrap_or_default();
662        let value = extract_array_item(args, 2).unwrap_or_default();
663        return RuntimeEvidence::ContextMessage {
664            from_context: area,
665            to_context: "storage_event".into(),
666            payload_preview: if value.is_empty() {
667                key
668            } else {
669                format!("{key}:{value}")
670            },
671        };
672    }
673    if api == "fingerprint.read" {
674        return RuntimeEvidence::FingerprintAccess {
675            api: extract_array_item(args, 0).unwrap_or_else(|| "fingerprint".into()),
676            detail: extract_array_item(args, 1).unwrap_or_default(),
677        };
678    }
679    if api == "timer.setTimeout" || api == "timer.setInterval" {
680        let delay_ms = extract_array_item(args, 1)
681            .and_then(|v| v.parse::<u32>().ok())
682            .unwrap_or_default();
683        return RuntimeEvidence::TimerScheduled {
684            delay_ms,
685            is_interval: api == "timer.setInterval",
686        };
687    }
688    if api == "scheduler.postTask" || api == "scheduler.yield" {
689        return RuntimeEvidence::SchedulerTask {
690            operation: if api == "scheduler.postTask" {
691                "post_task".into()
692            } else {
693                "yield".into()
694            },
695            priority: extract_array_item(args, 0).unwrap_or_else(|| "user-visible".into()),
696        };
697    }
698    if api == "requestIdleCallback" {
699        return RuntimeEvidence::SchedulerTask {
700            operation: "idle_callback".into(),
701            priority: extract_array_item(args, 0).unwrap_or_else(|| "0".into()),
702        };
703    }
704    if api == "requestAnimationFrame" {
705        return RuntimeEvidence::SchedulerTask {
706            operation: "animation_frame".into(),
707            priority: "16".into(),
708        };
709    }
710    if api == "queueMicrotask" {
711        return RuntimeEvidence::SchedulerTask {
712            operation: "microtask".into(),
713            priority: "immediate".into(),
714        };
715    }
716    if api == "mutationObserver.observe" {
717        return RuntimeEvidence::FingerprintAccess {
718            api: "MutationObserver.observe".into(),
719            detail: extract_array_item(args, 1).unwrap_or_default(),
720        };
721    }
722    if api == "intersectionObserver.observe" {
723        return RuntimeEvidence::FingerprintAccess {
724            api: "IntersectionObserver.observe".into(),
725            detail: extract_array_item(args, 1).unwrap_or_default(),
726        };
727    }
728    if api == "resizeObserver.observe" {
729        return RuntimeEvidence::FingerprintAccess {
730            api: "ResizeObserver.observe".into(),
731            detail: extract_array_item(args, 1).unwrap_or_default(),
732        };
733    }
734    if api == "url.createObjectURL" {
735        return RuntimeEvidence::FingerprintAccess {
736            api: "URL.createObjectURL".into(),
737            detail: extract_array_item(args, 1).unwrap_or_default(),
738        };
739    }
740    if api == "element.addEventListener" {
741        return RuntimeEvidence::InputMonitor {
742            event_type: extract_array_item(args, 1).unwrap_or_default(),
743        };
744    }
745    if api == "window.alert" || api == "window.confirm" || api == "window.prompt" {
746        return RuntimeEvidence::Dialog {
747            dialog_type: api.split('.').next_back().unwrap_or("dialog").to_string(),
748            message: extract_array_item(args, 0).unwrap_or_default(),
749        };
750    }
751    if api == "window.beforeunload.register" || api == "window.beforeunload.trigger" {
752        return RuntimeEvidence::NavigationTrap {
753            trap_type: if api.ends_with(".register") {
754                "beforeunload_register".into()
755            } else {
756                "beforeunload_trigger".into()
757            },
758            message: extract_array_item(args, 0).unwrap_or_default(),
759        };
760    }
761    if api == "element.requestFullscreen" || api == "element.requestPointerLock" {
762        return RuntimeEvidence::NavigationTrap {
763            trap_type: if api.ends_with("requestFullscreen") {
764                "fullscreen_request".into()
765            } else {
766                "pointer_lock_request".into()
767            },
768            message: String::new(),
769        };
770    }
771    if api == "history.pushState" || api == "history.replaceState" {
772        return RuntimeEvidence::NavigationTrap {
773            trap_type: if api.ends_with("pushState") {
774                "history_push_state".into()
775            } else {
776                "history_replace_state".into()
777            },
778            message: extract_array_item(args, 0).unwrap_or_default(),
779        };
780    }
781    if api == "navigator.vibrate" || api == "speechSynthesis.speak" {
782        return RuntimeEvidence::FingerprintAccess {
783            api: api.to_string(),
784            detail: extract_array_item(args, 0).unwrap_or_default(),
785        };
786    }
787    if api == "broadcastChannel.create" {
788        let name = extract_array_item(args, 0).unwrap_or_else(|| "unnamed".into());
789        return RuntimeEvidence::ContextMessage {
790            from_context: "current".into(),
791            to_context: format!("broadcast:{name}"),
792            payload_preview: "create".into(),
793        };
794    }
795    if api.contains("postMessage")
796        && api != "messagePort.postMessage"
797        && api != "sharedworker.port.postMessage"
798        && api != "broadcastChannel.postMessage"
799    {
800        return RuntimeEvidence::ContextMessage {
801            from_context: "current".into(),
802            to_context: extract_array_item(args, 1).unwrap_or_else(|| "*".into()),
803            payload_preview: extract_array_item(args, 0).unwrap_or_default(),
804        };
805    }
806    if api == "broadcastChannel.postMessage" {
807        let name = extract_array_item(args, 0).unwrap_or_else(|| "unnamed".into());
808        return RuntimeEvidence::ContextMessage {
809            from_context: "current".into(),
810            to_context: format!("broadcast:{name}"),
811            payload_preview: extract_array_item(args, 1).unwrap_or_default(),
812        };
813    }
814    if api == "window.name.set" {
815        return RuntimeEvidence::ContextMessage {
816            from_context: "current".into(),
817            to_context: "window_name".into(),
818            payload_preview: extract_array_item(args, 0).unwrap_or_default(),
819        };
820    }
821    if api == "messageChannel.create" {
822        return RuntimeEvidence::ContextMessage {
823            from_context: "current".into(),
824            to_context: "message_channel".into(),
825            payload_preview: "create".into(),
826        };
827    }
828    if api == "messagePort.postMessage" {
829        return RuntimeEvidence::ContextMessage {
830            from_context: "current".into(),
831            to_context: extract_array_item(args, 0).unwrap_or_else(|| "port".into()),
832            payload_preview: extract_array_item(args, 1).unwrap_or_default(),
833        };
834    }
835    if api == "sharedworker.port.postMessage" {
836        let worker_url = extract_array_item(args, 0).unwrap_or_else(|| "shared_worker".into());
837        return RuntimeEvidence::ContextMessage {
838            from_context: "current".into(),
839            to_context: format!("shared_worker:{worker_url}"),
840            payload_preview: extract_array_item(args, 1).unwrap_or_default(),
841        };
842    }
843    RuntimeEvidence::DomMutation {
844        target: api.to_string(),
845        operation: "api_call".into(),
846        value: args
847            .iter()
848            .filter_map(Value::as_str)
849            .collect::<Vec<_>>()
850            .join(" | "),
851    }
852}
853
854fn first_stringish_arg(args: &[Value]) -> Option<String> {
855    extract_array_item(args, 0).or_else(|| args.first().and_then(Value::as_str).map(str::to_string))
856}
857
858fn extract_array_item(args: &[Value], index: usize) -> Option<String> {
859    if let Some(raw) = args.first().and_then(Value::as_str)
860        && raw.starts_with('[')
861        && let Ok(parsed) = serde_json::from_str::<Vec<serde_json::Value>>(raw)
862        && let Some(value) = parsed.get(index)
863    {
864        if let Some(s) = value.as_str() {
865            return Some(s.to_string());
866        } else if value.is_null() {
867            return None;
868        } else {
869            return Some(value.to_string());
870        }
871    }
872
873    args.get(index)
874        .and_then(Value::as_str)
875        .map(|s| s.to_string())
876}
877
878/// What kind of DOM mutation occurred.
879#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
880pub enum DomMutationKind {
881    ElementCreated,
882    ChildAppended,
883    ChildRemoved,
884    AttributeSet,
885    AttributeRemoved,
886    StyleMutation,
887    ClassMutation,
888    TextMutation,
889    InnerHtmlSet,
890    DocumentWrite,
891}
892
893#[cfg(test)]
894mod tests {
895    use super::{
896        DynamicCodeSource, Observation, RuntimeEvidence, Value, observations_to_contract_evidence,
897    };
898
899    #[test]
900    fn websocket_api_call_maps_to_runtime_evidence() {
901        let obs = Observation::ApiCall {
902            api: "websocket.send".into(),
903            args: vec![Value::string("[\"wss://evil.com/socket\",\"hunter2\"]")],
904            result: Value::Undefined,
905        };
906        assert!(matches!(
907            obs.to_runtime_evidence(),
908            RuntimeEvidence::WebSocket {
909                url,
910                operation,
911                payload_preview
912            } if url == "wss://evil.com/socket" && operation == "send" && payload_preview == "hunter2"
913        ));
914    }
915
916    #[test]
917    fn worker_api_call_maps_to_runtime_evidence() {
918        let obs = Observation::ApiCall {
919            api: "worker.create".into(),
920            args: vec![Value::string("[\"https://evil.com/bg.js\"]")],
921            result: Value::Undefined,
922        };
923        assert!(matches!(
924            obs.to_runtime_evidence(),
925            RuntimeEvidence::WorkerSpawn {
926                worker_type,
927                script_url
928            } if worker_type == "worker" && script_url == "https://evil.com/bg.js"
929        ));
930    }
931
932    #[test]
933    fn webrtc_api_call_maps_to_runtime_evidence() {
934        let obs = Observation::ApiCall {
935            api: "webrtc.data_channel_send".into(),
936            args: vec![Value::string("[\"telemetry\",\"candidate=192.168.1.12\"]")],
937            result: Value::Undefined,
938        };
939        assert!(matches!(
940            obs.to_runtime_evidence(),
941            RuntimeEvidence::WebRtc {
942                operation,
943                target,
944                payload_preview
945            } if operation == "data_channel_send"
946                && target == "telemetry"
947                && payload_preview == "candidate=192.168.1.12"
948        ));
949    }
950
951    #[test]
952    fn contract_evidence_includes_required_fields() {
953        let observations = vec![
954            Observation::DynamicCodeExec {
955                source: DynamicCodeSource::Eval,
956                code_preview: "alert(document.cookie)".into(),
957            },
958            Observation::NetworkRequest {
959                url: "https://evil.example/exfil".into(),
960                method: "POST".into(),
961                headers: vec![],
962                body: Some("c=session".into()),
963            },
964        ];
965
966        let items = observations_to_contract_evidence(&observations);
967        assert_eq!(items.len(), 2);
968        assert_eq!(items[0].evidence_id, "runtime-0");
969        assert_eq!(items[0].severity, crate::analysis::Severity::Medium);
970        assert_eq!(items[0].provenance.engine, "jsdet");
971        assert_eq!(items[0].provenance.layer, "sandbox");
972        assert!(items[0].contradicts.is_empty());
973        assert_eq!(items[1].family, "network_behavior");
974        assert_eq!(items[1].severity, crate::analysis::Severity::Low);
975    }
976
977    #[test]
978    fn payment_and_notification_map_to_runtime_evidence() {
979        let payment = Observation::ApiCall {
980            api: "payment.show".into(),
981            args: vec![Value::string("[]")],
982            result: Value::Undefined,
983        };
984        let notification = Observation::ApiCall {
985            api: "notification.requestPermission".into(),
986            args: vec![Value::string("[]")],
987            result: Value::Undefined,
988        };
989        assert!(matches!(
990            payment.to_runtime_evidence(),
991            RuntimeEvidence::PaymentRequest { stage } if stage == "show"
992        ));
993        assert!(matches!(
994            notification.to_runtime_evidence(),
995            RuntimeEvidence::NotificationPermissionRequest
996        ));
997    }
998
999    #[test]
1000    fn notification_create_maps_to_runtime_evidence() {
1001        let notification = Observation::ApiCall {
1002            api: "notification.create".into(),
1003            args: vec![Value::string("[\"Verify now\"]")],
1004            result: Value::Undefined,
1005        };
1006        assert!(matches!(
1007            notification.to_runtime_evidence(),
1008            RuntimeEvidence::NotificationCreate { title } if title == "Verify now"
1009        ));
1010    }
1011
1012    #[test]
1013    fn fingerprint_read_and_timer_api_calls_map_to_runtime_evidence() {
1014        let fingerprint = Observation::ApiCall {
1015            api: "fingerprint.read".into(),
1016            args: vec![Value::string("[\"navigator.userAgent\",\"Mozilla/5.0\"]")],
1017            result: Value::Undefined,
1018        };
1019        let timer = Observation::ApiCall {
1020            api: "timer.setTimeout".into(),
1021            args: vec![Value::string("[1,5000]")],
1022            result: Value::Undefined,
1023        };
1024        assert!(matches!(
1025            fingerprint.to_runtime_evidence(),
1026            RuntimeEvidence::FingerprintAccess { api, detail }
1027                if api == "navigator.userAgent" && detail == "Mozilla/5.0"
1028        ));
1029        assert!(matches!(
1030            timer.to_runtime_evidence(),
1031            RuntimeEvidence::TimerScheduled {
1032                delay_ms,
1033                is_interval
1034            } if delay_ms == 5000 && !is_interval
1035        ));
1036    }
1037
1038    #[test]
1039    fn input_monitor_api_call_maps_to_runtime_evidence() {
1040        let obs = Observation::ApiCall {
1041            api: "element.addEventListener".into(),
1042            args: vec![Value::string("[12,\"keydown\"]")],
1043            result: Value::Undefined,
1044        };
1045        assert!(matches!(
1046            obs.to_runtime_evidence(),
1047            RuntimeEvidence::InputMonitor { event_type } if event_type == "keydown"
1048        ));
1049    }
1050
1051    #[test]
1052    fn indexeddb_trusted_types_and_scheduler_map_to_runtime_evidence() {
1053        let indexeddb = Observation::ApiCall {
1054            api: "indexedDB.open".into(),
1055            args: vec![Value::string("[\"vault\",1]")],
1056            result: Value::Undefined,
1057        };
1058        let trusted_types = Observation::ApiCall {
1059            api: "trustedTypes.createPolicy".into(),
1060            args: vec![Value::string("[\"stage-loader\"]")],
1061            result: Value::Undefined,
1062        };
1063        let scheduler = Observation::ApiCall {
1064            api: "scheduler.postTask".into(),
1065            args: vec![Value::string("[\"background\"]")],
1066            result: Value::Undefined,
1067        };
1068        assert!(matches!(
1069            indexeddb.to_runtime_evidence(),
1070            RuntimeEvidence::IndexedDbOpen { name } if name == "vault"
1071        ));
1072        assert!(matches!(
1073            trusted_types.to_runtime_evidence(),
1074            RuntimeEvidence::TrustedTypesPolicy { name } if name == "stage-loader"
1075        ));
1076        assert!(matches!(
1077            scheduler.to_runtime_evidence(),
1078            RuntimeEvidence::SchedulerTask { operation, priority }
1079                if operation == "post_task" && priority == "background"
1080        ));
1081    }
1082
1083    #[test]
1084    fn crypto_subtle_ops_map_to_runtime_evidence() {
1085        let decrypt = Observation::ApiCall {
1086            api: "crypto.subtle.decrypt".into(),
1087            args: vec![Value::string("[\"AES-GCM\"]")],
1088            result: Value::Undefined,
1089        };
1090        let import_key = Observation::ApiCall {
1091            api: "crypto.subtle.importKey".into(),
1092            args: vec![Value::string("[\"raw\"]")],
1093            result: Value::Undefined,
1094        };
1095        assert!(matches!(
1096            decrypt.to_runtime_evidence(),
1097            RuntimeEvidence::CryptoOperation { operation, detail }
1098                if operation == "decrypt" && detail == "AES-GCM"
1099        ));
1100        assert!(matches!(
1101            import_key.to_runtime_evidence(),
1102            RuntimeEvidence::CryptoOperation { operation, detail }
1103                if operation == "import_key" && detail == "raw"
1104        ));
1105    }
1106
1107    #[test]
1108    fn broadcast_channel_api_call_maps_to_runtime_evidence() {
1109        let obs = Observation::ApiCall {
1110            api: "broadcastChannel.postMessage".into(),
1111            args: vec![Value::string("[\"session\",\"otp=123456\"]")],
1112            result: Value::Undefined,
1113        };
1114        assert!(matches!(
1115            obs.to_runtime_evidence(),
1116            RuntimeEvidence::ContextMessage { to_context, payload_preview, .. }
1117                if to_context == "broadcast:session" && payload_preview == "otp=123456"
1118        ));
1119    }
1120
1121    #[test]
1122    fn message_port_api_call_maps_to_runtime_evidence() {
1123        let obs = Observation::ApiCall {
1124            api: "messagePort.postMessage".into(),
1125            args: vec![Value::string("[\"port1\",\"token=hunter2\"]")],
1126            result: Value::Undefined,
1127        };
1128        assert!(matches!(
1129            obs.to_runtime_evidence(),
1130            RuntimeEvidence::ContextMessage { to_context, payload_preview, .. }
1131                if to_context == "port1" && payload_preview == "token=hunter2"
1132        ));
1133    }
1134
1135    #[test]
1136    fn window_name_set_maps_to_runtime_evidence() {
1137        let obs = Observation::ApiCall {
1138            api: "window.name.set".into(),
1139            args: vec![Value::string("[\"https://evil.com/steal\"]")],
1140            result: Value::Undefined,
1141        };
1142        assert!(matches!(
1143            obs.to_runtime_evidence(),
1144            RuntimeEvidence::ContextMessage { to_context, payload_preview, .. }
1145                if to_context == "window_name"
1146                    && payload_preview == "https://evil.com/steal"
1147        ));
1148    }
1149
1150    #[test]
1151    fn shared_worker_port_postmessage_maps_to_runtime_evidence() {
1152        let obs = Observation::ApiCall {
1153            api: "sharedworker.port.postMessage".into(),
1154            args: vec![Value::string("[\"https://evil.com/sw.js\",\"otp=123456\"]")],
1155            result: Value::Undefined,
1156        };
1157        assert!(matches!(
1158            obs.to_runtime_evidence(),
1159            RuntimeEvidence::ContextMessage { to_context, payload_preview, .. }
1160                if to_context == "shared_worker:https://evil.com/sw.js"
1161                    && payload_preview == "otp=123456"
1162        ));
1163    }
1164
1165    #[test]
1166    fn dialog_and_beforeunload_api_calls_map_to_runtime_evidence() {
1167        let dialog = Observation::ApiCall {
1168            api: "window.alert".into(),
1169            args: vec![Value::string("[\"Windows Defender Warning\"]")],
1170            result: Value::Undefined,
1171        };
1172        let trap = Observation::ApiCall {
1173            api: "window.beforeunload.register".into(),
1174            args: vec![Value::string("[\"Your computer is infected\"]")],
1175            result: Value::Undefined,
1176        };
1177        assert!(matches!(
1178            dialog.to_runtime_evidence(),
1179            RuntimeEvidence::Dialog {
1180                dialog_type,
1181                message
1182            } if dialog_type == "alert" && message == "Windows Defender Warning"
1183        ));
1184        assert!(matches!(
1185            trap.to_runtime_evidence(),
1186            RuntimeEvidence::NavigationTrap { trap_type, message }
1187                if trap_type == "beforeunload_register" && message == "Your computer is infected"
1188        ));
1189    }
1190
1191    #[test]
1192    fn fullscreen_pointerlock_and_alarm_api_calls_map_to_runtime_evidence() {
1193        let fullscreen = Observation::ApiCall {
1194            api: "element.requestFullscreen".into(),
1195            args: vec![Value::string("[1]")],
1196            result: Value::Undefined,
1197        };
1198        let pointer_lock = Observation::ApiCall {
1199            api: "element.requestPointerLock".into(),
1200            args: vec![Value::string("[1]")],
1201            result: Value::Undefined,
1202        };
1203        let vibrate = Observation::ApiCall {
1204            api: "navigator.vibrate".into(),
1205            args: vec![Value::string("[[200,100,200]]")],
1206            result: Value::Undefined,
1207        };
1208        let speech = Observation::ApiCall {
1209            api: "speechSynthesis.speak".into(),
1210            args: vec![Value::string("[\"Your device is infected\"]")],
1211            result: Value::Undefined,
1212        };
1213        assert!(matches!(
1214            fullscreen.to_runtime_evidence(),
1215            RuntimeEvidence::NavigationTrap { trap_type, message }
1216                if trap_type == "fullscreen_request" && message.is_empty()
1217        ));
1218        assert!(matches!(
1219            pointer_lock.to_runtime_evidence(),
1220            RuntimeEvidence::NavigationTrap { trap_type, message }
1221                if trap_type == "pointer_lock_request" && message.is_empty()
1222        ));
1223        assert!(matches!(
1224            vibrate.to_runtime_evidence(),
1225            RuntimeEvidence::FingerprintAccess { api, detail }
1226                if api == "navigator.vibrate" && detail == "[200,100,200]"
1227        ));
1228        assert!(matches!(
1229            speech.to_runtime_evidence(),
1230            RuntimeEvidence::FingerprintAccess { api, detail }
1231                if api == "speechSynthesis.speak" && detail == "Your device is infected"
1232        ));
1233    }
1234
1235    #[test]
1236    fn animation_frame_and_microtask_api_calls_map_to_runtime_evidence() {
1237        let animation_frame = Observation::ApiCall {
1238            api: "requestAnimationFrame".into(),
1239            args: vec![Value::string("[]")],
1240            result: Value::Undefined,
1241        };
1242        let microtask = Observation::ApiCall {
1243            api: "queueMicrotask".into(),
1244            args: vec![Value::string("[]")],
1245            result: Value::Undefined,
1246        };
1247        assert!(matches!(
1248            animation_frame.to_runtime_evidence(),
1249            RuntimeEvidence::SchedulerTask { operation, priority }
1250                if operation == "animation_frame" && priority == "16"
1251        ));
1252        assert!(matches!(
1253            microtask.to_runtime_evidence(),
1254            RuntimeEvidence::SchedulerTask { operation, priority }
1255                if operation == "microtask" && priority == "immediate"
1256        ));
1257    }
1258
1259    #[test]
1260    fn context_message_preserves_payload_preview() {
1261        let message = Observation::ContextMessage {
1262            from_context: "popup".into(),
1263            to_context: "opener".into(),
1264            payload: Value::string("{\"email\":\"victim@example.com\"}"),
1265        };
1266        assert!(matches!(
1267            message.to_runtime_evidence(),
1268            RuntimeEvidence::ContextMessage {
1269                from_context,
1270                to_context,
1271                payload_preview
1272            } if from_context == "popup"
1273                && to_context == "opener"
1274                && payload_preview == "{\"email\":\"victim@example.com\"}"
1275        ));
1276    }
1277}
1278
1279impl DomMutationKind {
1280    #[must_use]
1281    pub fn as_str(&self) -> &'static str {
1282        match self {
1283            Self::ElementCreated => "element_created",
1284            Self::ChildAppended => "child_appended",
1285            Self::ChildRemoved => "child_removed",
1286            Self::AttributeSet => "attribute_set",
1287            Self::AttributeRemoved => "attribute_removed",
1288            Self::StyleMutation => "style_mutation",
1289            Self::ClassMutation => "class_mutation",
1290            Self::TextMutation => "text_mutation",
1291            Self::InnerHtmlSet => "inner_html_set",
1292            Self::DocumentWrite => "document_write",
1293        }
1294    }
1295}
1296
1297/// How dynamic code was invoked.
1298#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1299pub enum DynamicCodeSource {
1300    Eval,
1301    Function,
1302    SetTimeoutString,
1303    SetIntervalString,
1304    ImportScripts,
1305}
1306
1307impl DynamicCodeSource {
1308    #[must_use]
1309    pub fn as_str(&self) -> &'static str {
1310        match self {
1311            Self::Eval => "eval",
1312            Self::Function => "function",
1313            Self::SetTimeoutString => "set_timeout_string",
1314            Self::SetIntervalString => "set_interval_string",
1315            Self::ImportScripts => "import_scripts",
1316        }
1317    }
1318}
1319
1320/// Cookie read or write.
1321#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1322pub enum CookieOp {
1323    Read,
1324    Write,
1325    Delete,
1326}
1327
1328impl CookieOp {
1329    #[must_use]
1330    pub fn as_str(&self) -> &'static str {
1331        match self {
1332            Self::Read => "read",
1333            Self::Write => "write",
1334            Self::Delete => "delete",
1335        }
1336    }
1337}
1338
1339/// Which resource limit was hit.
1340#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1341pub enum ResourceLimitKind {
1342    Fuel,
1343    Memory,
1344    Timeout,
1345    ObservationCount,
1346    ScriptCount,
1347    StackDepth,
1348}
1349
1350impl ResourceLimitKind {
1351    #[must_use]
1352    pub fn as_str(&self) -> &'static str {
1353        match self {
1354            Self::Fuel => "fuel",
1355            Self::Memory => "memory",
1356            Self::Timeout => "timeout",
1357            Self::ObservationCount => "observation_count",
1358            Self::ScriptCount => "script_count",
1359            Self::StackDepth => "stack_depth",
1360        }
1361    }
1362}
1363
1364/// A taint label attached to string-like values crossing the Rust bridge.
1365#[derive(
1366    Debug, Clone, Copy, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
1367)]
1368pub struct TaintLabel(pub u32);
1369
1370impl TaintLabel {
1371    pub const CLEAN: Self = Self(0);
1372
1373    #[must_use]
1374    pub fn new(id: u32) -> Self {
1375        Self(id)
1376    }
1377
1378    #[must_use]
1379    pub fn is_clean(self) -> bool {
1380        self == Self::CLEAN
1381    }
1382
1383    #[must_use]
1384    pub fn is_tainted(self) -> bool {
1385        !self.is_clean()
1386    }
1387
1388    #[must_use]
1389    pub fn combine(self, other: Self) -> Self {
1390        if self.is_tainted() { self } else { other }
1391    }
1392}
1393
1394/// A confirmed taint flow from a source-labeled value into a sink.
1395#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1396pub struct TaintFlow {
1397    pub sink: String,
1398    pub label: TaintLabel,
1399    pub tainted_args: Vec<usize>,
1400}
1401
1402/// Backwards-compatible alias for older APIs.
1403pub type TaintedValue = Value;
1404
1405/// A loosely-typed value passed through the bridge.
1406#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1407pub enum Value {
1408    Undefined,
1409    Null,
1410    Bool(bool),
1411    Int(i64),
1412    Float(f64),
1413    String(String, TaintLabel),
1414    /// JSON-encoded complex value (objects, arrays).
1415    Json(String, TaintLabel),
1416    /// Raw bytes (`ArrayBuffer`, `Uint8Array`).
1417    Bytes(Vec<u8>),
1418}
1419
1420impl PartialEq for Value {
1421    fn eq(&self, other: &Self) -> bool {
1422        match (self, other) {
1423            (Self::Undefined, Self::Undefined) | (Self::Null, Self::Null) => true,
1424            (Self::Bool(a), Self::Bool(b)) => a == b,
1425            (Self::Int(a), Self::Int(b)) => a == b,
1426            (Self::Float(a), Self::Float(b)) => a.to_bits() == b.to_bits(),
1427            (Self::String(a, _), Self::String(b, _)) | (Self::Json(a, _), Self::Json(b, _)) => {
1428                a == b
1429            }
1430            (Self::Bytes(a), Self::Bytes(b)) => a == b,
1431            _ => false,
1432        }
1433    }
1434}
1435
1436impl Value {
1437    #[must_use]
1438    pub fn string(value: impl Into<String>) -> Self {
1439        Self::String(value.into(), TaintLabel::CLEAN)
1440    }
1441
1442    #[must_use]
1443    pub fn tainted_string(value: impl Into<String>, label: TaintLabel) -> Self {
1444        Self::String(value.into(), label)
1445    }
1446
1447    #[must_use]
1448    pub fn json(value: impl Into<String>) -> Self {
1449        Self::Json(value.into(), TaintLabel::CLEAN)
1450    }
1451
1452    #[must_use]
1453    pub fn tainted_json(value: impl Into<String>, label: TaintLabel) -> Self {
1454        Self::Json(value.into(), label)
1455    }
1456
1457    #[must_use]
1458    pub fn is_nullish(&self) -> bool {
1459        matches!(self, Self::Undefined | Self::Null)
1460    }
1461
1462    #[must_use]
1463    pub fn as_str(&self) -> Option<&str> {
1464        match self {
1465            Self::String(s, _) | Self::Json(s, _) => Some(s),
1466            _ => None,
1467        }
1468    }
1469
1470    #[must_use]
1471    pub fn as_bool(&self) -> Option<bool> {
1472        match self {
1473            Self::Bool(b) => Some(*b),
1474            _ => None,
1475        }
1476    }
1477
1478    #[must_use]
1479    pub fn taint_label(&self) -> TaintLabel {
1480        match self {
1481            Self::String(_, label) | Self::Json(_, label) => *label,
1482            _ => TaintLabel::CLEAN,
1483        }
1484    }
1485
1486    #[must_use]
1487    pub fn is_tainted(&self) -> bool {
1488        self.taint_label().is_tainted()
1489    }
1490
1491    #[must_use]
1492    pub fn with_taint(self, label: TaintLabel) -> Self {
1493        match self {
1494            Self::String(s, _) => Self::String(s, label),
1495            Self::Json(s, _) => Self::Json(s, label),
1496            other => other,
1497        }
1498    }
1499
1500    pub fn concat(&self, other: &Self) -> Option<Self> {
1501        match (self, other) {
1502            (Self::String(a, left), Self::String(b, right)) => {
1503                Some(Self::String(format!("{a}{b}"), left.combine(*right)))
1504            }
1505            _ => None,
1506        }
1507    }
1508
1509    pub fn slice(&self, start: usize, end: usize) -> Option<Self> {
1510        match self {
1511            Self::String(s, label) => {
1512                let chars: Vec<char> = s.chars().collect();
1513                let start = start.min(chars.len());
1514                let end = end.min(chars.len()).max(start);
1515                Some(Self::String(chars[start..end].iter().collect(), *label))
1516            }
1517            _ => None,
1518        }
1519    }
1520
1521    pub fn replace(&self, from: &str, to: &str) -> Option<Self> {
1522        match self {
1523            Self::String(s, label) => Some(Self::String(s.replace(from, to), *label)),
1524            _ => None,
1525        }
1526    }
1527
1528    #[must_use]
1529    pub fn check_taint_at_sink(sink: &str, args: &[Self]) -> Option<TaintFlow> {
1530        let tainted_args: Vec<usize> = args
1531            .iter()
1532            .enumerate()
1533            .filter_map(|(idx, value)| value.is_tainted().then_some(idx))
1534            .collect();
1535        let first = tainted_args.first().copied()?;
1536        Some(TaintFlow {
1537            sink: sink.to_string(),
1538            label: args[first].taint_label(),
1539            tainted_args,
1540        })
1541    }
1542}
1543
1544impl std::fmt::Display for Value {
1545    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1546        match self {
1547            Self::Undefined => write!(f, "undefined"),
1548            Self::Null => write!(f, "null"),
1549            Self::Bool(b) => write!(f, "{b}"),
1550            Self::Int(n) => write!(f, "{n}"),
1551            Self::Float(n) => write!(f, "{n}"),
1552            Self::String(s, _) => write!(f, "{s:?}"),
1553            Self::Json(j, _) => write!(f, "{j}"),
1554            Self::Bytes(b) => write!(f, "<{} bytes>", b.len()),
1555        }
1556    }
1557}