1#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub enum Observation {
11 ApiCall {
13 api: String,
14 args: Vec<Value>,
15 result: Value,
16 },
17 PropertyRead {
19 object: String,
20 property: String,
21 value: Value,
22 },
23 PropertyWrite {
25 object: String,
26 property: String,
27 value: Value,
28 },
29 DomMutation {
31 kind: DomMutationKind,
32 target: String,
33 detail: String,
34 },
35 NetworkRequest {
37 url: String,
38 method: String,
39 headers: Vec<(String, String)>,
40 body: Option<String>,
41 },
42 TimerSet {
44 id: u32,
45 delay_ms: u32,
46 is_interval: bool,
47 callback_preview: String,
48 },
49 DynamicCodeExec {
51 source: DynamicCodeSource,
52 code_preview: String,
53 },
54 CookieAccess {
56 operation: CookieOp,
57 name: String,
58 value: Option<String>,
59 },
60 CssExfiltration {
62 selector: String,
63 url: String,
64 trigger: String,
65 },
66 WasmInstantiation {
68 module_size: usize,
69 import_names: Vec<String>,
70 export_names: Vec<String>,
71 },
72 FingerprintAccess { api: String, detail: String },
74 ContextMessage {
76 from_context: String,
77 to_context: String,
78 payload: Value,
79 },
80 Error {
82 message: String,
83 script_index: Option<usize>,
84 },
85 ResourceLimit {
87 kind: ResourceLimitKind,
88 detail: String,
89 },
90}
91
92#[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 #[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#[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#[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#[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#[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#[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#[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#[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
1402pub type TaintedValue = Value;
1404
1405#[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(String, TaintLabel),
1416 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}