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/// What kind of DOM mutation occurred.
93#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
94pub enum DomMutationKind {
95    ElementCreated,
96    ChildAppended,
97    ChildRemoved,
98    AttributeSet,
99    AttributeRemoved,
100    StyleMutation,
101    ClassMutation,
102    TextMutation,
103    InnerHtmlSet,
104    DocumentWrite,
105}
106
107/// How dynamic code was invoked.
108#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
109pub enum DynamicCodeSource {
110    Eval,
111    Function,
112    SetTimeoutString,
113    SetIntervalString,
114    ImportScripts,
115}
116
117/// Cookie read or write.
118#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
119pub enum CookieOp {
120    Read,
121    Write,
122    Delete,
123}
124
125/// Which resource limit was hit.
126#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
127pub enum ResourceLimitKind {
128    Fuel,
129    Memory,
130    Timeout,
131    ObservationCount,
132    ScriptCount,
133    StackDepth,
134}
135
136/// A taint label attached to string-like values crossing the Rust bridge.
137#[derive(
138    Debug, Clone, Copy, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
139)]
140pub struct TaintLabel(pub u32);
141
142impl TaintLabel {
143    pub const CLEAN: Self = Self(0);
144
145    #[must_use]
146    pub fn new(id: u32) -> Self {
147        Self(id)
148    }
149
150    #[must_use]
151    pub fn is_clean(self) -> bool {
152        self == Self::CLEAN
153    }
154
155    #[must_use]
156    pub fn is_tainted(self) -> bool {
157        !self.is_clean()
158    }
159
160    #[must_use]
161    pub fn combine(self, other: Self) -> Self {
162        if self.is_tainted() { self } else { other }
163    }
164}
165
166/// A confirmed taint flow from a source-labeled value into a sink.
167#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
168pub struct TaintFlow {
169    pub sink: String,
170    pub label: TaintLabel,
171    pub tainted_args: Vec<usize>,
172}
173
174/// Backwards-compatible alias for older APIs.
175pub type TaintedValue = Value;
176
177/// A loosely-typed value passed through the bridge.
178#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
179pub enum Value {
180    Undefined,
181    Null,
182    Bool(bool),
183    Int(i64),
184    Float(f64),
185    String(String, TaintLabel),
186    /// JSON-encoded complex value (objects, arrays).
187    Json(String, TaintLabel),
188    /// Raw bytes (`ArrayBuffer`, `Uint8Array`).
189    Bytes(Vec<u8>),
190}
191
192impl PartialEq for Value {
193    fn eq(&self, other: &Self) -> bool {
194        match (self, other) {
195            (Self::Undefined, Self::Undefined) | (Self::Null, Self::Null) => true,
196            (Self::Bool(a), Self::Bool(b)) => a == b,
197            (Self::Int(a), Self::Int(b)) => a == b,
198            (Self::Float(a), Self::Float(b)) => a.to_bits() == b.to_bits(),
199            (Self::String(a, _), Self::String(b, _)) | (Self::Json(a, _), Self::Json(b, _)) => {
200                a == b
201            }
202            (Self::Bytes(a), Self::Bytes(b)) => a == b,
203            _ => false,
204        }
205    }
206}
207
208impl Value {
209    #[must_use]
210    pub fn string(value: impl Into<String>) -> Self {
211        Self::String(value.into(), TaintLabel::CLEAN)
212    }
213
214    #[must_use]
215    pub fn tainted_string(value: impl Into<String>, label: TaintLabel) -> Self {
216        Self::String(value.into(), label)
217    }
218
219    #[must_use]
220    pub fn json(value: impl Into<String>) -> Self {
221        Self::Json(value.into(), TaintLabel::CLEAN)
222    }
223
224    #[must_use]
225    pub fn tainted_json(value: impl Into<String>, label: TaintLabel) -> Self {
226        Self::Json(value.into(), label)
227    }
228
229    #[must_use]
230    pub fn is_nullish(&self) -> bool {
231        matches!(self, Self::Undefined | Self::Null)
232    }
233
234    #[must_use]
235    pub fn as_str(&self) -> Option<&str> {
236        match self {
237            Self::String(s, _) | Self::Json(s, _) => Some(s),
238            _ => None,
239        }
240    }
241
242    #[must_use]
243    pub fn as_bool(&self) -> Option<bool> {
244        match self {
245            Self::Bool(b) => Some(*b),
246            _ => None,
247        }
248    }
249
250    #[must_use]
251    pub fn taint_label(&self) -> TaintLabel {
252        match self {
253            Self::String(_, label) | Self::Json(_, label) => *label,
254            _ => TaintLabel::CLEAN,
255        }
256    }
257
258    #[must_use]
259    pub fn is_tainted(&self) -> bool {
260        self.taint_label().is_tainted()
261    }
262
263    #[must_use]
264    pub fn with_taint(self, label: TaintLabel) -> Self {
265        match self {
266            Self::String(s, _) => Self::String(s, label),
267            Self::Json(s, _) => Self::Json(s, label),
268            other => other,
269        }
270    }
271
272    pub fn concat(&self, other: &Self) -> Option<Self> {
273        match (self, other) {
274            (Self::String(a, left), Self::String(b, right)) => {
275                Some(Self::String(format!("{a}{b}"), left.combine(*right)))
276            }
277            _ => None,
278        }
279    }
280
281    pub fn slice(&self, start: usize, end: usize) -> Option<Self> {
282        match self {
283            Self::String(s, label) => {
284                let chars: Vec<char> = s.chars().collect();
285                let start = start.min(chars.len());
286                let end = end.min(chars.len()).max(start);
287                Some(Self::String(chars[start..end].iter().collect(), *label))
288            }
289            _ => None,
290        }
291    }
292
293    pub fn replace(&self, from: &str, to: &str) -> Option<Self> {
294        match self {
295            Self::String(s, label) => Some(Self::String(s.replace(from, to), *label)),
296            _ => None,
297        }
298    }
299
300    #[must_use]
301    pub fn check_taint_at_sink(sink: &str, args: &[Self]) -> Option<TaintFlow> {
302        let tainted_args: Vec<usize> = args
303            .iter()
304            .enumerate()
305            .filter_map(|(idx, value)| value.is_tainted().then_some(idx))
306            .collect();
307        let first = tainted_args.first().copied()?;
308        Some(TaintFlow {
309            sink: sink.to_string(),
310            label: args[first].taint_label(),
311            tainted_args,
312        })
313    }
314}
315
316impl std::fmt::Display for Value {
317    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
318        match self {
319            Self::Undefined => write!(f, "undefined"),
320            Self::Null => write!(f, "null"),
321            Self::Bool(b) => write!(f, "{b}"),
322            Self::Int(n) => write!(f, "{n}"),
323            Self::Float(n) => write!(f, "{n}"),
324            Self::String(s, _) => write!(f, "{s:?}"),
325            Self::Json(j, _) => write!(f, "{j}"),
326            Self::Bytes(b) => write!(f, "<{} bytes>", b.len()),
327        }
328    }
329}