#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum Observation {
ApiCall {
api: String,
args: Vec<Value>,
result: Value,
},
PropertyRead {
object: String,
property: String,
value: Value,
},
PropertyWrite {
object: String,
property: String,
value: Value,
},
DomMutation {
kind: DomMutationKind,
target: String,
detail: String,
},
NetworkRequest {
url: String,
method: String,
headers: Vec<(String, String)>,
body: Option<String>,
},
TimerSet {
id: u32,
delay_ms: u32,
is_interval: bool,
callback_preview: String,
},
DynamicCodeExec {
source: DynamicCodeSource,
code_preview: String,
},
CookieAccess {
operation: CookieOp,
name: String,
value: Option<String>,
},
CssExfiltration {
selector: String,
url: String,
trigger: String,
},
WasmInstantiation {
module_size: usize,
import_names: Vec<String>,
export_names: Vec<String>,
},
FingerprintAccess { api: String, detail: String },
ContextMessage {
from_context: String,
to_context: String,
payload: Value,
},
Error {
message: String,
script_index: Option<usize>,
},
ResourceLimit {
kind: ResourceLimitKind,
detail: String,
},
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum RuntimeEvidence {
Redirect {
target: String,
},
PopupOpen {
target: String,
},
WebSocket {
url: String,
operation: String,
payload_preview: String,
},
WebRtc {
operation: String,
target: String,
payload_preview: String,
},
WorkerSpawn {
worker_type: String,
script_url: String,
},
NetworkRequest {
url: String,
method: String,
},
DomMutation {
target: String,
operation: String,
value: String,
},
CookieAccess {
operation: String,
name: String,
},
DynamicCodeExec {
source: String,
code_preview: String,
},
CssExfiltration {
selector: String,
url: String,
trigger: String,
},
FingerprintAccess {
api: String,
detail: String,
},
WasmInstantiation {
module_size: usize,
},
ContextMessage {
from_context: String,
to_context: String,
payload_preview: String,
},
CredentialForm {
action: String,
password_field: bool,
},
ServiceWorkerRegister {
script_url: String,
},
NotificationPermissionRequest,
NotificationCreate {
title: String,
},
PaymentRequest {
stage: String,
},
ClipboardAccess {
operation: String,
content_preview: String,
},
GeolocationAccess {
operation: String,
},
CryptoOperation {
operation: String,
detail: String,
},
IndexedDbOpen {
name: String,
},
TrustedTypesPolicy {
name: String,
},
StorageWrite {
area: String,
key: String,
},
TimerScheduled {
delay_ms: u32,
is_interval: bool,
},
SchedulerTask {
operation: String,
priority: String,
},
InputMonitor {
event_type: String,
},
Dialog {
dialog_type: String,
message: String,
},
NavigationTrap {
trap_type: String,
message: String,
},
ResourceLimit {
limit_kind: String,
detail: String,
},
Error {
message: String,
},
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceDerivation {
#[default]
Observed,
Derived,
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceFreshness {
Fresh,
Recent,
#[default]
Unknown,
Stale,
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum Exploitability {
#[default]
None,
Low,
Medium,
High,
Certain,
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum Maliciousness {
#[default]
None,
Contextual,
Suspicious,
High,
Certain,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct EvidenceProvenance {
pub engine: String,
pub layer: String,
pub artifact_id: Option<String>,
pub refs: Vec<String>,
pub context: String,
pub observed_from: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct RuntimeEvidenceItem {
pub evidence_id: String,
pub family: String,
pub summary: String,
pub detail: String,
pub severity: crate::analysis::Severity,
pub derivation: EvidenceDerivation,
pub freshness: EvidenceFreshness,
pub confidence: f64,
pub exploitability: Exploitability,
pub maliciousness: Maliciousness,
pub provenance: EvidenceProvenance,
pub contradicts: Vec<String>,
}
impl Observation {
#[must_use]
pub fn to_runtime_evidence(&self) -> RuntimeEvidence {
match self {
Self::ApiCall { api, args, .. } => api_call_runtime_evidence(api, args),
Self::PropertyRead {
object,
property,
value,
} => RuntimeEvidence::DomMutation {
target: object.clone(),
operation: format!("property_read:{property}"),
value: value.as_str().unwrap_or("").to_string(),
},
Self::PropertyWrite {
object,
property,
value,
} => RuntimeEvidence::DomMutation {
target: object.clone(),
operation: format!("property_write:{property}"),
value: value.as_str().unwrap_or("").to_string(),
},
Self::DomMutation {
kind,
target,
detail,
} => RuntimeEvidence::DomMutation {
target: target.clone(),
operation: kind.as_str().into(),
value: detail.clone(),
},
Self::NetworkRequest { url, method, .. } => RuntimeEvidence::NetworkRequest {
url: url.clone(),
method: method.clone(),
},
Self::TimerSet {
delay_ms,
is_interval,
callback_preview,
..
} => RuntimeEvidence::DomMutation {
target: "timer".into(),
operation: if *is_interval {
"set_interval"
} else {
"set_timeout"
}
.into(),
value: format!("{delay_ms}ms:{callback_preview}"),
},
Self::DynamicCodeExec {
source,
code_preview,
} => RuntimeEvidence::DynamicCodeExec {
source: source.as_str().into(),
code_preview: code_preview.clone(),
},
Self::CookieAccess {
operation, name, ..
} => RuntimeEvidence::CookieAccess {
operation: operation.as_str().into(),
name: name.clone(),
},
Self::CssExfiltration {
selector,
url,
trigger,
} => RuntimeEvidence::CssExfiltration {
selector: selector.clone(),
url: url.clone(),
trigger: trigger.clone(),
},
Self::WasmInstantiation { module_size, .. } => RuntimeEvidence::WasmInstantiation {
module_size: *module_size,
},
Self::FingerprintAccess { api, detail } => RuntimeEvidence::FingerprintAccess {
api: api.clone(),
detail: detail.clone(),
},
Self::ContextMessage {
from_context,
to_context,
payload,
} => RuntimeEvidence::ContextMessage {
from_context: from_context.clone(),
to_context: to_context.clone(),
payload_preview: payload
.as_str()
.map(str::to_string)
.unwrap_or_else(|| payload.to_string()),
},
Self::Error { message, .. } => RuntimeEvidence::Error {
message: message.clone(),
},
Self::ResourceLimit { kind, detail } => RuntimeEvidence::ResourceLimit {
limit_kind: kind.as_str().into(),
detail: detail.clone(),
},
}
}
}
#[must_use]
pub fn observations_to_runtime_evidence(observations: &[Observation]) -> Vec<RuntimeEvidence> {
observations
.iter()
.map(Observation::to_runtime_evidence)
.collect()
}
#[must_use]
pub fn observations_to_contract_evidence(observations: &[Observation]) -> Vec<RuntimeEvidenceItem> {
observations
.iter()
.enumerate()
.map(|(idx, observation)| {
let runtime_evidence = observation.to_runtime_evidence();
let (family, summary, detail, exploitability, maliciousness) =
runtime_evidence_contract(&runtime_evidence);
RuntimeEvidenceItem {
evidence_id: format!("runtime-{idx}"),
family,
summary,
detail,
severity: runtime_evidence_severity(&runtime_evidence),
derivation: EvidenceDerivation::Observed,
freshness: EvidenceFreshness::Fresh,
confidence: 1.0,
exploitability,
maliciousness,
provenance: EvidenceProvenance {
engine: "jsdet".into(),
layer: "sandbox".into(),
artifact_id: None,
refs: vec![],
context: "runtime".into(),
observed_from: format!("{runtime_evidence:?}"),
},
contradicts: vec![],
}
})
.collect()
}
fn runtime_evidence_contract(
evidence: &RuntimeEvidence,
) -> (String, String, String, Exploitability, Maliciousness) {
match evidence {
RuntimeEvidence::CredentialForm { action, .. } => (
"credential_collection".into(),
"credential_form".into(),
action.clone(),
Exploitability::High,
Maliciousness::High,
),
RuntimeEvidence::DynamicCodeExec {
source,
code_preview,
} => (
"runtime_behavior".into(),
source.clone(),
code_preview.clone(),
Exploitability::High,
Maliciousness::Suspicious,
),
RuntimeEvidence::ContextMessage {
to_context,
payload_preview,
..
} => (
"context_relay".into(),
to_context.clone(),
payload_preview.clone(),
Exploitability::Medium,
Maliciousness::Suspicious,
),
RuntimeEvidence::PaymentRequest { stage } => (
"payment_flow".into(),
stage.clone(),
stage.clone(),
Exploitability::High,
Maliciousness::High,
),
RuntimeEvidence::NetworkRequest { url, method } => (
"network_behavior".into(),
method.clone(),
url.clone(),
Exploitability::Medium,
Maliciousness::Contextual,
),
other => (
"runtime_behavior".into(),
format!("{other:?}"),
format!("{other:?}"),
Exploitability::Low,
Maliciousness::Contextual,
),
}
}
fn runtime_evidence_severity(evidence: &RuntimeEvidence) -> crate::analysis::Severity {
use crate::analysis::Severity;
match evidence {
RuntimeEvidence::CredentialForm { .. }
| RuntimeEvidence::PaymentRequest { .. }
| RuntimeEvidence::NotificationPermissionRequest
| RuntimeEvidence::TrustedTypesPolicy { .. } => Severity::High,
RuntimeEvidence::DynamicCodeExec { .. }
| RuntimeEvidence::ContextMessage { .. }
| RuntimeEvidence::ServiceWorkerRegister { .. }
| RuntimeEvidence::ClipboardAccess { .. }
| RuntimeEvidence::GeolocationAccess { .. }
| RuntimeEvidence::CryptoOperation { .. }
| RuntimeEvidence::WebSocket { .. }
| RuntimeEvidence::WebRtc { .. } => Severity::Medium,
RuntimeEvidence::NetworkRequest { .. }
| RuntimeEvidence::DomMutation { .. }
| RuntimeEvidence::CookieAccess { .. }
| RuntimeEvidence::CssExfiltration { .. }
| RuntimeEvidence::FingerprintAccess { .. }
| RuntimeEvidence::WasmInstantiation { .. }
| RuntimeEvidence::WorkerSpawn { .. }
| RuntimeEvidence::NotificationCreate { .. }
| RuntimeEvidence::IndexedDbOpen { .. }
| RuntimeEvidence::StorageWrite { .. }
| RuntimeEvidence::TimerScheduled { .. }
| RuntimeEvidence::SchedulerTask { .. }
| RuntimeEvidence::InputMonitor { .. }
| RuntimeEvidence::Dialog { .. }
| RuntimeEvidence::NavigationTrap { .. } => Severity::Low,
RuntimeEvidence::PopupOpen { .. }
| RuntimeEvidence::Redirect { .. }
| RuntimeEvidence::ResourceLimit { .. }
| RuntimeEvidence::Error { .. } => Severity::Info,
}
}
fn api_call_runtime_evidence(api: &str, args: &[Value]) -> RuntimeEvidence {
if api == "window.open" {
return RuntimeEvidence::PopupOpen {
target: first_stringish_arg(args).unwrap_or_default(),
};
}
if api.contains("websocket.connect") || api.contains("websocket.send") {
return RuntimeEvidence::WebSocket {
url: extract_array_item(args, 0).unwrap_or_default(),
operation: if api.contains("connect") {
"connect".into()
} else {
"send".into()
},
payload_preview: extract_array_item(args, 1).unwrap_or_default(),
};
}
if api.starts_with("webrtc.") {
return RuntimeEvidence::WebRtc {
operation: api.trim_start_matches("webrtc.").to_string(),
target: extract_array_item(args, 0).unwrap_or_default(),
payload_preview: extract_array_item(args, 1).unwrap_or_default(),
};
}
if api.starts_with("wasm.") {
return RuntimeEvidence::WasmInstantiation {
module_size: extract_array_item(args, 0)
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or_default(),
};
}
if api.contains("worker.create") || api.contains("sharedworker.create") {
return RuntimeEvidence::WorkerSpawn {
worker_type: if api.contains("sharedworker") {
"shared_worker".into()
} else {
"worker".into()
},
script_url: first_stringish_arg(args).unwrap_or_default(),
};
}
if api == "credential_form_detected" {
let action = extract_array_item(args, 0).unwrap_or_default();
let password_field = extract_array_item(args, 2)
.map(|v| v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
return RuntimeEvidence::CredentialForm {
action,
password_field,
};
}
if api.contains("serviceworker.register") {
return RuntimeEvidence::ServiceWorkerRegister {
script_url: first_stringish_arg(args).unwrap_or_default(),
};
}
if api == "notification.requestPermission" {
return RuntimeEvidence::NotificationPermissionRequest;
}
if api == "notification.create" {
return RuntimeEvidence::NotificationCreate {
title: first_stringish_arg(args).unwrap_or_default(),
};
}
if api == "payment.request" || api == "payment.show" {
return RuntimeEvidence::PaymentRequest {
stage: if api == "payment.request" {
"request".into()
} else {
"show".into()
},
};
}
if api == "clipboard.writeText"
|| api == "clipboard.write"
|| api == "clipboard.readText"
|| api == "clipboard.read"
{
return RuntimeEvidence::ClipboardAccess {
operation: api
.split('.')
.next_back()
.unwrap_or("clipboard")
.to_string(),
content_preview: first_stringish_arg(args).unwrap_or_default(),
};
}
if api.starts_with("geolocation.") {
return RuntimeEvidence::GeolocationAccess {
operation: api.split('.').nth(1).unwrap_or("unknown").to_string(),
};
}
if let Some(operation) = api.strip_prefix("crypto.subtle.") {
return RuntimeEvidence::CryptoOperation {
operation: operation.replace("Key", "_key").to_lowercase(),
detail: extract_array_item(args, 0).unwrap_or_default(),
};
}
if api == "crypto.getRandomValues" {
return RuntimeEvidence::CryptoOperation {
operation: "get_random_values".into(),
detail: extract_array_item(args, 0).unwrap_or_default(),
};
}
if api == "indexedDB.open" {
return RuntimeEvidence::IndexedDbOpen {
name: extract_array_item(args, 0).unwrap_or_default(),
};
}
if api == "trustedTypes.createPolicy" {
return RuntimeEvidence::TrustedTypesPolicy {
name: extract_array_item(args, 0).unwrap_or_default(),
};
}
if api.contains("Storage.setItem") || api.contains("storage.set") {
return RuntimeEvidence::StorageWrite {
area: if api.contains("localStorage") {
"local_storage".into()
} else if api.contains("sessionStorage") {
"session_storage".into()
} else {
"storage".into()
},
key: extract_array_item(args, 0).unwrap_or_default(),
};
}
if api == "storageEvent.register" {
return RuntimeEvidence::ContextMessage {
from_context: "current".into(),
to_context: "storage_event".into(),
payload_preview: "register".into(),
};
}
if api == "storageEvent.fire" {
let area = extract_array_item(args, 0).unwrap_or_else(|| "storage".into());
let key = extract_array_item(args, 1).unwrap_or_default();
let value = extract_array_item(args, 2).unwrap_or_default();
return RuntimeEvidence::ContextMessage {
from_context: area,
to_context: "storage_event".into(),
payload_preview: if value.is_empty() {
key
} else {
format!("{key}:{value}")
},
};
}
if api == "fingerprint.read" {
return RuntimeEvidence::FingerprintAccess {
api: extract_array_item(args, 0).unwrap_or_else(|| "fingerprint".into()),
detail: extract_array_item(args, 1).unwrap_or_default(),
};
}
if api == "timer.setTimeout" || api == "timer.setInterval" {
let delay_ms = extract_array_item(args, 1)
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or_default();
return RuntimeEvidence::TimerScheduled {
delay_ms,
is_interval: api == "timer.setInterval",
};
}
if api == "scheduler.postTask" || api == "scheduler.yield" {
return RuntimeEvidence::SchedulerTask {
operation: if api == "scheduler.postTask" {
"post_task".into()
} else {
"yield".into()
},
priority: extract_array_item(args, 0).unwrap_or_else(|| "user-visible".into()),
};
}
if api == "requestIdleCallback" {
return RuntimeEvidence::SchedulerTask {
operation: "idle_callback".into(),
priority: extract_array_item(args, 0).unwrap_or_else(|| "0".into()),
};
}
if api == "requestAnimationFrame" {
return RuntimeEvidence::SchedulerTask {
operation: "animation_frame".into(),
priority: "16".into(),
};
}
if api == "queueMicrotask" {
return RuntimeEvidence::SchedulerTask {
operation: "microtask".into(),
priority: "immediate".into(),
};
}
if api == "mutationObserver.observe" {
return RuntimeEvidence::FingerprintAccess {
api: "MutationObserver.observe".into(),
detail: extract_array_item(args, 1).unwrap_or_default(),
};
}
if api == "intersectionObserver.observe" {
return RuntimeEvidence::FingerprintAccess {
api: "IntersectionObserver.observe".into(),
detail: extract_array_item(args, 1).unwrap_or_default(),
};
}
if api == "resizeObserver.observe" {
return RuntimeEvidence::FingerprintAccess {
api: "ResizeObserver.observe".into(),
detail: extract_array_item(args, 1).unwrap_or_default(),
};
}
if api == "url.createObjectURL" {
return RuntimeEvidence::FingerprintAccess {
api: "URL.createObjectURL".into(),
detail: extract_array_item(args, 1).unwrap_or_default(),
};
}
if api == "element.addEventListener" {
return RuntimeEvidence::InputMonitor {
event_type: extract_array_item(args, 1).unwrap_or_default(),
};
}
if api == "window.alert" || api == "window.confirm" || api == "window.prompt" {
return RuntimeEvidence::Dialog {
dialog_type: api.split('.').next_back().unwrap_or("dialog").to_string(),
message: extract_array_item(args, 0).unwrap_or_default(),
};
}
if api == "window.beforeunload.register" || api == "window.beforeunload.trigger" {
return RuntimeEvidence::NavigationTrap {
trap_type: if api.ends_with(".register") {
"beforeunload_register".into()
} else {
"beforeunload_trigger".into()
},
message: extract_array_item(args, 0).unwrap_or_default(),
};
}
if api == "element.requestFullscreen" || api == "element.requestPointerLock" {
return RuntimeEvidence::NavigationTrap {
trap_type: if api.ends_with("requestFullscreen") {
"fullscreen_request".into()
} else {
"pointer_lock_request".into()
},
message: String::new(),
};
}
if api == "history.pushState" || api == "history.replaceState" {
return RuntimeEvidence::NavigationTrap {
trap_type: if api.ends_with("pushState") {
"history_push_state".into()
} else {
"history_replace_state".into()
},
message: extract_array_item(args, 0).unwrap_or_default(),
};
}
if api == "navigator.vibrate" || api == "speechSynthesis.speak" {
return RuntimeEvidence::FingerprintAccess {
api: api.to_string(),
detail: extract_array_item(args, 0).unwrap_or_default(),
};
}
if api == "broadcastChannel.create" {
let name = extract_array_item(args, 0).unwrap_or_else(|| "unnamed".into());
return RuntimeEvidence::ContextMessage {
from_context: "current".into(),
to_context: format!("broadcast:{name}"),
payload_preview: "create".into(),
};
}
if api.contains("postMessage")
&& api != "messagePort.postMessage"
&& api != "sharedworker.port.postMessage"
&& api != "broadcastChannel.postMessage"
{
return RuntimeEvidence::ContextMessage {
from_context: "current".into(),
to_context: extract_array_item(args, 1).unwrap_or_else(|| "*".into()),
payload_preview: extract_array_item(args, 0).unwrap_or_default(),
};
}
if api == "broadcastChannel.postMessage" {
let name = extract_array_item(args, 0).unwrap_or_else(|| "unnamed".into());
return RuntimeEvidence::ContextMessage {
from_context: "current".into(),
to_context: format!("broadcast:{name}"),
payload_preview: extract_array_item(args, 1).unwrap_or_default(),
};
}
if api == "window.name.set" {
return RuntimeEvidence::ContextMessage {
from_context: "current".into(),
to_context: "window_name".into(),
payload_preview: extract_array_item(args, 0).unwrap_or_default(),
};
}
if api == "messageChannel.create" {
return RuntimeEvidence::ContextMessage {
from_context: "current".into(),
to_context: "message_channel".into(),
payload_preview: "create".into(),
};
}
if api == "messagePort.postMessage" {
return RuntimeEvidence::ContextMessage {
from_context: "current".into(),
to_context: extract_array_item(args, 0).unwrap_or_else(|| "port".into()),
payload_preview: extract_array_item(args, 1).unwrap_or_default(),
};
}
if api == "sharedworker.port.postMessage" {
let worker_url = extract_array_item(args, 0).unwrap_or_else(|| "shared_worker".into());
return RuntimeEvidence::ContextMessage {
from_context: "current".into(),
to_context: format!("shared_worker:{worker_url}"),
payload_preview: extract_array_item(args, 1).unwrap_or_default(),
};
}
RuntimeEvidence::DomMutation {
target: api.to_string(),
operation: "api_call".into(),
value: args
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(" | "),
}
}
fn first_stringish_arg(args: &[Value]) -> Option<String> {
extract_array_item(args, 0).or_else(|| args.first().and_then(Value::as_str).map(str::to_string))
}
fn extract_array_item(args: &[Value], index: usize) -> Option<String> {
if let Some(raw) = args.first().and_then(Value::as_str)
&& raw.starts_with('[')
&& let Ok(parsed) = serde_json::from_str::<Vec<serde_json::Value>>(raw)
&& let Some(value) = parsed.get(index)
{
if let Some(s) = value.as_str() {
return Some(s.to_string());
} else if value.is_null() {
return None;
} else {
return Some(value.to_string());
}
}
args.get(index)
.and_then(Value::as_str)
.map(|s| s.to_string())
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum DomMutationKind {
ElementCreated,
ChildAppended,
ChildRemoved,
AttributeSet,
AttributeRemoved,
StyleMutation,
ClassMutation,
TextMutation,
InnerHtmlSet,
DocumentWrite,
}
#[cfg(test)]
mod tests {
use super::{
DynamicCodeSource, Observation, RuntimeEvidence, Value, observations_to_contract_evidence,
};
#[test]
fn websocket_api_call_maps_to_runtime_evidence() {
let obs = Observation::ApiCall {
api: "websocket.send".into(),
args: vec![Value::string("[\"wss://evil.com/socket\",\"hunter2\"]")],
result: Value::Undefined,
};
assert!(matches!(
obs.to_runtime_evidence(),
RuntimeEvidence::WebSocket {
url,
operation,
payload_preview
} if url == "wss://evil.com/socket" && operation == "send" && payload_preview == "hunter2"
));
}
#[test]
fn worker_api_call_maps_to_runtime_evidence() {
let obs = Observation::ApiCall {
api: "worker.create".into(),
args: vec![Value::string("[\"https://evil.com/bg.js\"]")],
result: Value::Undefined,
};
assert!(matches!(
obs.to_runtime_evidence(),
RuntimeEvidence::WorkerSpawn {
worker_type,
script_url
} if worker_type == "worker" && script_url == "https://evil.com/bg.js"
));
}
#[test]
fn webrtc_api_call_maps_to_runtime_evidence() {
let obs = Observation::ApiCall {
api: "webrtc.data_channel_send".into(),
args: vec![Value::string("[\"telemetry\",\"candidate=192.168.1.12\"]")],
result: Value::Undefined,
};
assert!(matches!(
obs.to_runtime_evidence(),
RuntimeEvidence::WebRtc {
operation,
target,
payload_preview
} if operation == "data_channel_send"
&& target == "telemetry"
&& payload_preview == "candidate=192.168.1.12"
));
}
#[test]
fn contract_evidence_includes_required_fields() {
let observations = vec![
Observation::DynamicCodeExec {
source: DynamicCodeSource::Eval,
code_preview: "alert(document.cookie)".into(),
},
Observation::NetworkRequest {
url: "https://evil.example/exfil".into(),
method: "POST".into(),
headers: vec![],
body: Some("c=session".into()),
},
];
let items = observations_to_contract_evidence(&observations);
assert_eq!(items.len(), 2);
assert_eq!(items[0].evidence_id, "runtime-0");
assert_eq!(items[0].severity, crate::analysis::Severity::Medium);
assert_eq!(items[0].provenance.engine, "jsdet");
assert_eq!(items[0].provenance.layer, "sandbox");
assert!(items[0].contradicts.is_empty());
assert_eq!(items[1].family, "network_behavior");
assert_eq!(items[1].severity, crate::analysis::Severity::Low);
}
#[test]
fn payment_and_notification_map_to_runtime_evidence() {
let payment = Observation::ApiCall {
api: "payment.show".into(),
args: vec![Value::string("[]")],
result: Value::Undefined,
};
let notification = Observation::ApiCall {
api: "notification.requestPermission".into(),
args: vec![Value::string("[]")],
result: Value::Undefined,
};
assert!(matches!(
payment.to_runtime_evidence(),
RuntimeEvidence::PaymentRequest { stage } if stage == "show"
));
assert!(matches!(
notification.to_runtime_evidence(),
RuntimeEvidence::NotificationPermissionRequest
));
}
#[test]
fn notification_create_maps_to_runtime_evidence() {
let notification = Observation::ApiCall {
api: "notification.create".into(),
args: vec![Value::string("[\"Verify now\"]")],
result: Value::Undefined,
};
assert!(matches!(
notification.to_runtime_evidence(),
RuntimeEvidence::NotificationCreate { title } if title == "Verify now"
));
}
#[test]
fn fingerprint_read_and_timer_api_calls_map_to_runtime_evidence() {
let fingerprint = Observation::ApiCall {
api: "fingerprint.read".into(),
args: vec![Value::string("[\"navigator.userAgent\",\"Mozilla/5.0\"]")],
result: Value::Undefined,
};
let timer = Observation::ApiCall {
api: "timer.setTimeout".into(),
args: vec![Value::string("[1,5000]")],
result: Value::Undefined,
};
assert!(matches!(
fingerprint.to_runtime_evidence(),
RuntimeEvidence::FingerprintAccess { api, detail }
if api == "navigator.userAgent" && detail == "Mozilla/5.0"
));
assert!(matches!(
timer.to_runtime_evidence(),
RuntimeEvidence::TimerScheduled {
delay_ms,
is_interval
} if delay_ms == 5000 && !is_interval
));
}
#[test]
fn input_monitor_api_call_maps_to_runtime_evidence() {
let obs = Observation::ApiCall {
api: "element.addEventListener".into(),
args: vec![Value::string("[12,\"keydown\"]")],
result: Value::Undefined,
};
assert!(matches!(
obs.to_runtime_evidence(),
RuntimeEvidence::InputMonitor { event_type } if event_type == "keydown"
));
}
#[test]
fn indexeddb_trusted_types_and_scheduler_map_to_runtime_evidence() {
let indexeddb = Observation::ApiCall {
api: "indexedDB.open".into(),
args: vec![Value::string("[\"vault\",1]")],
result: Value::Undefined,
};
let trusted_types = Observation::ApiCall {
api: "trustedTypes.createPolicy".into(),
args: vec![Value::string("[\"stage-loader\"]")],
result: Value::Undefined,
};
let scheduler = Observation::ApiCall {
api: "scheduler.postTask".into(),
args: vec![Value::string("[\"background\"]")],
result: Value::Undefined,
};
assert!(matches!(
indexeddb.to_runtime_evidence(),
RuntimeEvidence::IndexedDbOpen { name } if name == "vault"
));
assert!(matches!(
trusted_types.to_runtime_evidence(),
RuntimeEvidence::TrustedTypesPolicy { name } if name == "stage-loader"
));
assert!(matches!(
scheduler.to_runtime_evidence(),
RuntimeEvidence::SchedulerTask { operation, priority }
if operation == "post_task" && priority == "background"
));
}
#[test]
fn crypto_subtle_ops_map_to_runtime_evidence() {
let decrypt = Observation::ApiCall {
api: "crypto.subtle.decrypt".into(),
args: vec![Value::string("[\"AES-GCM\"]")],
result: Value::Undefined,
};
let import_key = Observation::ApiCall {
api: "crypto.subtle.importKey".into(),
args: vec![Value::string("[\"raw\"]")],
result: Value::Undefined,
};
assert!(matches!(
decrypt.to_runtime_evidence(),
RuntimeEvidence::CryptoOperation { operation, detail }
if operation == "decrypt" && detail == "AES-GCM"
));
assert!(matches!(
import_key.to_runtime_evidence(),
RuntimeEvidence::CryptoOperation { operation, detail }
if operation == "import_key" && detail == "raw"
));
}
#[test]
fn broadcast_channel_api_call_maps_to_runtime_evidence() {
let obs = Observation::ApiCall {
api: "broadcastChannel.postMessage".into(),
args: vec![Value::string("[\"session\",\"otp=123456\"]")],
result: Value::Undefined,
};
assert!(matches!(
obs.to_runtime_evidence(),
RuntimeEvidence::ContextMessage { to_context, payload_preview, .. }
if to_context == "broadcast:session" && payload_preview == "otp=123456"
));
}
#[test]
fn message_port_api_call_maps_to_runtime_evidence() {
let obs = Observation::ApiCall {
api: "messagePort.postMessage".into(),
args: vec![Value::string("[\"port1\",\"token=hunter2\"]")],
result: Value::Undefined,
};
assert!(matches!(
obs.to_runtime_evidence(),
RuntimeEvidence::ContextMessage { to_context, payload_preview, .. }
if to_context == "port1" && payload_preview == "token=hunter2"
));
}
#[test]
fn window_name_set_maps_to_runtime_evidence() {
let obs = Observation::ApiCall {
api: "window.name.set".into(),
args: vec![Value::string("[\"https://evil.com/steal\"]")],
result: Value::Undefined,
};
assert!(matches!(
obs.to_runtime_evidence(),
RuntimeEvidence::ContextMessage { to_context, payload_preview, .. }
if to_context == "window_name"
&& payload_preview == "https://evil.com/steal"
));
}
#[test]
fn shared_worker_port_postmessage_maps_to_runtime_evidence() {
let obs = Observation::ApiCall {
api: "sharedworker.port.postMessage".into(),
args: vec![Value::string("[\"https://evil.com/sw.js\",\"otp=123456\"]")],
result: Value::Undefined,
};
assert!(matches!(
obs.to_runtime_evidence(),
RuntimeEvidence::ContextMessage { to_context, payload_preview, .. }
if to_context == "shared_worker:https://evil.com/sw.js"
&& payload_preview == "otp=123456"
));
}
#[test]
fn dialog_and_beforeunload_api_calls_map_to_runtime_evidence() {
let dialog = Observation::ApiCall {
api: "window.alert".into(),
args: vec![Value::string("[\"Windows Defender Warning\"]")],
result: Value::Undefined,
};
let trap = Observation::ApiCall {
api: "window.beforeunload.register".into(),
args: vec![Value::string("[\"Your computer is infected\"]")],
result: Value::Undefined,
};
assert!(matches!(
dialog.to_runtime_evidence(),
RuntimeEvidence::Dialog {
dialog_type,
message
} if dialog_type == "alert" && message == "Windows Defender Warning"
));
assert!(matches!(
trap.to_runtime_evidence(),
RuntimeEvidence::NavigationTrap { trap_type, message }
if trap_type == "beforeunload_register" && message == "Your computer is infected"
));
}
#[test]
fn fullscreen_pointerlock_and_alarm_api_calls_map_to_runtime_evidence() {
let fullscreen = Observation::ApiCall {
api: "element.requestFullscreen".into(),
args: vec![Value::string("[1]")],
result: Value::Undefined,
};
let pointer_lock = Observation::ApiCall {
api: "element.requestPointerLock".into(),
args: vec![Value::string("[1]")],
result: Value::Undefined,
};
let vibrate = Observation::ApiCall {
api: "navigator.vibrate".into(),
args: vec![Value::string("[[200,100,200]]")],
result: Value::Undefined,
};
let speech = Observation::ApiCall {
api: "speechSynthesis.speak".into(),
args: vec![Value::string("[\"Your device is infected\"]")],
result: Value::Undefined,
};
assert!(matches!(
fullscreen.to_runtime_evidence(),
RuntimeEvidence::NavigationTrap { trap_type, message }
if trap_type == "fullscreen_request" && message.is_empty()
));
assert!(matches!(
pointer_lock.to_runtime_evidence(),
RuntimeEvidence::NavigationTrap { trap_type, message }
if trap_type == "pointer_lock_request" && message.is_empty()
));
assert!(matches!(
vibrate.to_runtime_evidence(),
RuntimeEvidence::FingerprintAccess { api, detail }
if api == "navigator.vibrate" && detail == "[200,100,200]"
));
assert!(matches!(
speech.to_runtime_evidence(),
RuntimeEvidence::FingerprintAccess { api, detail }
if api == "speechSynthesis.speak" && detail == "Your device is infected"
));
}
#[test]
fn animation_frame_and_microtask_api_calls_map_to_runtime_evidence() {
let animation_frame = Observation::ApiCall {
api: "requestAnimationFrame".into(),
args: vec![Value::string("[]")],
result: Value::Undefined,
};
let microtask = Observation::ApiCall {
api: "queueMicrotask".into(),
args: vec![Value::string("[]")],
result: Value::Undefined,
};
assert!(matches!(
animation_frame.to_runtime_evidence(),
RuntimeEvidence::SchedulerTask { operation, priority }
if operation == "animation_frame" && priority == "16"
));
assert!(matches!(
microtask.to_runtime_evidence(),
RuntimeEvidence::SchedulerTask { operation, priority }
if operation == "microtask" && priority == "immediate"
));
}
#[test]
fn context_message_preserves_payload_preview() {
let message = Observation::ContextMessage {
from_context: "popup".into(),
to_context: "opener".into(),
payload: Value::string("{\"email\":\"victim@example.com\"}"),
};
assert!(matches!(
message.to_runtime_evidence(),
RuntimeEvidence::ContextMessage {
from_context,
to_context,
payload_preview
} if from_context == "popup"
&& to_context == "opener"
&& payload_preview == "{\"email\":\"victim@example.com\"}"
));
}
}
impl DomMutationKind {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::ElementCreated => "element_created",
Self::ChildAppended => "child_appended",
Self::ChildRemoved => "child_removed",
Self::AttributeSet => "attribute_set",
Self::AttributeRemoved => "attribute_removed",
Self::StyleMutation => "style_mutation",
Self::ClassMutation => "class_mutation",
Self::TextMutation => "text_mutation",
Self::InnerHtmlSet => "inner_html_set",
Self::DocumentWrite => "document_write",
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum DynamicCodeSource {
Eval,
Function,
SetTimeoutString,
SetIntervalString,
ImportScripts,
}
impl DynamicCodeSource {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Eval => "eval",
Self::Function => "function",
Self::SetTimeoutString => "set_timeout_string",
Self::SetIntervalString => "set_interval_string",
Self::ImportScripts => "import_scripts",
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum CookieOp {
Read,
Write,
Delete,
}
impl CookieOp {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Read => "read",
Self::Write => "write",
Self::Delete => "delete",
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum ResourceLimitKind {
Fuel,
Memory,
Timeout,
ObservationCount,
ScriptCount,
StackDepth,
}
impl ResourceLimitKind {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Fuel => "fuel",
Self::Memory => "memory",
Self::Timeout => "timeout",
Self::ObservationCount => "observation_count",
Self::ScriptCount => "script_count",
Self::StackDepth => "stack_depth",
}
}
}
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
)]
pub struct TaintLabel(pub u32);
impl TaintLabel {
pub const CLEAN: Self = Self(0);
#[must_use]
pub fn new(id: u32) -> Self {
Self(id)
}
#[must_use]
pub fn is_clean(self) -> bool {
self == Self::CLEAN
}
#[must_use]
pub fn is_tainted(self) -> bool {
!self.is_clean()
}
#[must_use]
pub fn combine(self, other: Self) -> Self {
if self.is_tainted() { self } else { other }
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TaintFlow {
pub sink: String,
pub label: TaintLabel,
pub tainted_args: Vec<usize>,
}
pub type TaintedValue = Value;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum Value {
Undefined,
Null,
Bool(bool),
Int(i64),
Float(f64),
String(String, TaintLabel),
Json(String, TaintLabel),
Bytes(Vec<u8>),
}
impl PartialEq for Value {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Undefined, Self::Undefined) | (Self::Null, Self::Null) => true,
(Self::Bool(a), Self::Bool(b)) => a == b,
(Self::Int(a), Self::Int(b)) => a == b,
(Self::Float(a), Self::Float(b)) => a.to_bits() == b.to_bits(),
(Self::String(a, _), Self::String(b, _)) | (Self::Json(a, _), Self::Json(b, _)) => {
a == b
}
(Self::Bytes(a), Self::Bytes(b)) => a == b,
_ => false,
}
}
}
impl Value {
#[must_use]
pub fn string(value: impl Into<String>) -> Self {
Self::String(value.into(), TaintLabel::CLEAN)
}
#[must_use]
pub fn tainted_string(value: impl Into<String>, label: TaintLabel) -> Self {
Self::String(value.into(), label)
}
#[must_use]
pub fn json(value: impl Into<String>) -> Self {
Self::Json(value.into(), TaintLabel::CLEAN)
}
#[must_use]
pub fn tainted_json(value: impl Into<String>, label: TaintLabel) -> Self {
Self::Json(value.into(), label)
}
#[must_use]
pub fn is_nullish(&self) -> bool {
matches!(self, Self::Undefined | Self::Null)
}
#[must_use]
pub fn as_str(&self) -> Option<&str> {
match self {
Self::String(s, _) | Self::Json(s, _) => Some(s),
_ => None,
}
}
#[must_use]
pub fn as_bool(&self) -> Option<bool> {
match self {
Self::Bool(b) => Some(*b),
_ => None,
}
}
#[must_use]
pub fn taint_label(&self) -> TaintLabel {
match self {
Self::String(_, label) | Self::Json(_, label) => *label,
_ => TaintLabel::CLEAN,
}
}
#[must_use]
pub fn is_tainted(&self) -> bool {
self.taint_label().is_tainted()
}
#[must_use]
pub fn with_taint(self, label: TaintLabel) -> Self {
match self {
Self::String(s, _) => Self::String(s, label),
Self::Json(s, _) => Self::Json(s, label),
other => other,
}
}
pub fn concat(&self, other: &Self) -> Option<Self> {
match (self, other) {
(Self::String(a, left), Self::String(b, right)) => {
Some(Self::String(format!("{a}{b}"), left.combine(*right)))
}
_ => None,
}
}
pub fn slice(&self, start: usize, end: usize) -> Option<Self> {
match self {
Self::String(s, label) => {
let chars: Vec<char> = s.chars().collect();
let start = start.min(chars.len());
let end = end.min(chars.len()).max(start);
Some(Self::String(chars[start..end].iter().collect(), *label))
}
_ => None,
}
}
pub fn replace(&self, from: &str, to: &str) -> Option<Self> {
match self {
Self::String(s, label) => Some(Self::String(s.replace(from, to), *label)),
_ => None,
}
}
#[must_use]
pub fn check_taint_at_sink(sink: &str, args: &[Self]) -> Option<TaintFlow> {
let tainted_args: Vec<usize> = args
.iter()
.enumerate()
.filter_map(|(idx, value)| value.is_tainted().then_some(idx))
.collect();
let first = tainted_args.first().copied()?;
Some(TaintFlow {
sink: sink.to_string(),
label: args[first].taint_label(),
tainted_args,
})
}
}
impl std::fmt::Display for Value {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Undefined => write!(f, "undefined"),
Self::Null => write!(f, "null"),
Self::Bool(b) => write!(f, "{b}"),
Self::Int(n) => write!(f, "{n}"),
Self::Float(n) => write!(f, "{n}"),
Self::String(s, _) => write!(f, "{s:?}"),
Self::Json(j, _) => write!(f, "{j}"),
Self::Bytes(b) => write!(f, "<{} bytes>", b.len()),
}
}
}