jsdet-core 0.1.0

Core WASM-sandboxed JavaScript detonation engine
Documentation
/// A single observable action performed by JavaScript during execution.
///
/// Observations are the OUTPUT of detonation. They describe what the code DID,
/// not what it IS. Every observation includes enough context to reconstruct
/// the action without access to the original script.
///
/// Consumers (Sear, Soleno) receive a `Vec<Observation>` in execution order.
/// The observation stream is the single source of truth for behavioral analysis.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum Observation {
    /// A bridged API function was called.
    ApiCall {
        api: String,
        args: Vec<Value>,
        result: Value,
    },
    /// A bridged object property was read.
    PropertyRead {
        object: String,
        property: String,
        value: Value,
    },
    /// A bridged object property was written.
    PropertyWrite {
        object: String,
        property: String,
        value: Value,
    },
    /// DOM was mutated (element created, attribute set, innerHTML written, etc.).
    DomMutation {
        kind: DomMutationKind,
        target: String,
        detail: String,
    },
    /// An outbound network request was attempted.
    NetworkRequest {
        url: String,
        method: String,
        headers: Vec<(String, String)>,
        body: Option<String>,
    },
    /// A timer was registered.
    TimerSet {
        id: u32,
        delay_ms: u32,
        is_interval: bool,
        callback_preview: String,
    },
    /// Dynamic code execution: `eval()`, `Function()`, `setTimeout(string)`, etc.
    DynamicCodeExec {
        source: DynamicCodeSource,
        code_preview: String,
    },
    /// Cookie was read or written.
    CookieAccess {
        operation: CookieOp,
        name: String,
        value: Option<String>,
    },
    /// A CSS rule matched that would trigger an external URL load.
    CssExfiltration {
        selector: String,
        url: String,
        trigger: String,
    },
    /// JavaScript attempted to instantiate a WebAssembly module.
    WasmInstantiation {
        module_size: usize,
        import_names: Vec<String>,
        export_names: Vec<String>,
    },
    /// A fingerprinting API was accessed.
    FingerprintAccess { api: String, detail: String },
    /// Message sent between execution contexts.
    ContextMessage {
        from_context: String,
        to_context: String,
        payload: Value,
    },
    /// Script execution produced an error.
    Error {
        message: String,
        script_index: Option<usize>,
    },
    /// Execution hit a resource limit.
    ResourceLimit {
        kind: ResourceLimitKind,
        detail: String,
    },
}

/// What kind of DOM mutation occurred.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum DomMutationKind {
    ElementCreated,
    ChildAppended,
    ChildRemoved,
    AttributeSet,
    AttributeRemoved,
    StyleMutation,
    ClassMutation,
    TextMutation,
    InnerHtmlSet,
    DocumentWrite,
}

/// How dynamic code was invoked.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum DynamicCodeSource {
    Eval,
    Function,
    SetTimeoutString,
    SetIntervalString,
    ImportScripts,
}

/// Cookie read or write.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum CookieOp {
    Read,
    Write,
    Delete,
}

/// Which resource limit was hit.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum ResourceLimitKind {
    Fuel,
    Memory,
    Timeout,
    ObservationCount,
    ScriptCount,
    StackDepth,
}

/// A taint label attached to string-like values crossing the Rust bridge.
#[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 }
    }
}

/// A confirmed taint flow from a source-labeled value into a sink.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TaintFlow {
    pub sink: String,
    pub label: TaintLabel,
    pub tainted_args: Vec<usize>,
}

/// Backwards-compatible alias for older APIs.
pub type TaintedValue = Value;

/// A loosely-typed value passed through the bridge.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum Value {
    Undefined,
    Null,
    Bool(bool),
    Int(i64),
    Float(f64),
    String(String, TaintLabel),
    /// JSON-encoded complex value (objects, arrays).
    Json(String, TaintLabel),
    /// Raw bytes (`ArrayBuffer`, `Uint8Array`).
    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()),
        }
    }
}