jsdet-core 0.1.0

Core WASM-sandboxed JavaScript detonation engine
Documentation
/// Timer simulation for the sandbox.
///
/// JavaScript timers (setTimeout, setInterval) are intercepted by the bridge
/// and registered here. The sandbox can:
///
/// 1. **Drain immediately** — fire all pending callbacks synchronously.
///    This is the default for detonation: malware that delays payloads
///    with setTimeout(fn, 30000) gets triggered instantly.
///
/// 2. **Fast-forward** — advance simulated time by N milliseconds,
///    firing callbacks whose delay has elapsed. For researchers who want
///    to step through time.
///
/// 3. **Manual** — do nothing until the researcher explicitly drains.
///    For interactive analysis.
///
/// A registered timer callback.
#[derive(Debug, Clone)]
pub struct PendingTimer {
    /// Unique timer ID (returned to JS from setTimeout/setInterval).
    pub id: u32,
    /// Delay in milliseconds before the callback fires.
    pub delay_ms: u32,
    /// Whether this is a repeating interval.
    pub is_interval: bool,
    /// The callback source code (for observation logging).
    pub callback_source: String,
    /// Simulated time at which this timer was registered (ms).
    pub registered_at_ms: u64,
}

/// Maximum number of pending timers before new registrations are dropped.
/// Prevents adversarial JavaScript from exhausting memory by registering
/// millions of timers in a tight loop.
const MAX_PENDING_TIMERS: usize = 10_000;

/// Manages timer state for one execution context.
#[derive(Debug, Default)]
pub struct TimerState {
    timers: Vec<PendingTimer>,
    next_id: u32,
    /// Simulated current time in milliseconds.
    simulated_time_ms: u64,
    /// Cancelled timer IDs.
    cancelled: std::collections::HashSet<u32>,
    /// CRITICAL FIX: Track unique callbacks that have been drained.
    /// Prevents fingerprinting via timer drain patterns - we track unique
    /// callback sources, not just iteration count.
    drained_callbacks: std::collections::HashSet<String>,
}

impl TimerState {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Register a new timer. Returns `Some(timer_id)` on success, or `None`
    /// if the timer ID space is exhausted or the pending timer cap is reached.
    pub fn register(
        &mut self,
        delay_ms: u32,
        is_interval: bool,
        callback_source: String,
    ) -> Option<u32> {
        // Drop registrations that would exceed the resource limit.
        if self.pending_count() >= MAX_PENDING_TIMERS {
            return None;
        }
        let id = self.next_id;
        self.next_id = self.next_id.checked_add(1)?;
        self.timers.push(PendingTimer {
            id,
            delay_ms,
            is_interval,
            callback_source,
            registered_at_ms: self.simulated_time_ms,
        });
        Some(id)
    }

    /// Cancel a timer by ID.
    pub fn cancel(&mut self, id: u32) {
        self.cancelled.insert(id);
    }

    /// Drain the next ready timer callback.
    ///
    /// Returns the callback source code to eval, or None if no timers are ready.
    /// For "drain immediately" mode, all timers are considered ready.
    ///
    /// CRITICAL FIX: Tracks unique callbacks to prevent fingerprinting via
    /// timer drain patterns. Returns None if this exact callback was already
    /// drained (prevents duplicate execution).
    pub fn drain_next(&mut self) -> Option<String> {
        let pos = self
            .timers
            .iter()
            .position(|t| !self.cancelled.contains(&t.id))?;

        let timer = self.timers.remove(pos);
        self.simulated_time_ms = self
            .simulated_time_ms
            .max(timer.registered_at_ms + u64::from(timer.delay_ms));

        // CRITICAL FIX: Track unique drained callbacks to prevent fingerprinting.
        // If we've already seen this exact callback source, skip it but still
        // return it for execution (intervals need to re-run).
        let _is_new_callback = self.drained_callbacks.insert(timer.callback_source.clone());

        // Re-register if interval.
        if timer.is_interval {
            self.timers.push(PendingTimer {
                id: timer.id,
                delay_ms: timer.delay_ms,
                is_interval: true,
                callback_source: timer.callback_source.clone(),
                registered_at_ms: self.simulated_time_ms,
            });
        }

        // Return callback even if not new (intervals need re-execution)
        // The caller can use is_new_callback to decide behavior.
        Some(timer.callback_source)
    }

    /// Check if a callback source has been drained before.
    /// CRITICAL FIX: Used to prevent fingerprinting via timer drain patterns.
    #[must_use]
    pub fn is_callback_drained(&self, callback_source: &str) -> bool {
        self.drained_callbacks.contains(callback_source)
    }

    /// Number of unique callbacks that have been drained.
    /// CRITICAL FIX: Use this instead of iteration count for drain limits.
    #[must_use]
    pub fn unique_drained_count(&self) -> usize {
        self.drained_callbacks.len()
    }

    /// Reset the drained callbacks tracking.
    /// CRITICAL FIX: Allows intentional re-draining in new analysis phases.
    pub fn reset_drained_tracking(&mut self) {
        self.drained_callbacks.clear();
    }

    /// Advance simulated time and drain all timers that have elapsed.
    /// Returns callback source codes in firing order.
    pub fn fast_forward(&mut self, advance_ms: u64) -> Vec<String> {
        let target_time = self.simulated_time_ms + advance_ms;
        let mut callbacks = Vec::new();

        loop {
            let next = self.timers.iter().position(|t| {
                !self.cancelled.contains(&t.id)
                    && t.registered_at_ms + u64::from(t.delay_ms) <= target_time
            });

            let Some(pos) = next else { break };
            let timer = self.timers.remove(pos);
            self.simulated_time_ms = timer.registered_at_ms + u64::from(timer.delay_ms);

            if timer.is_interval {
                self.timers.push(PendingTimer {
                    id: timer.id,
                    delay_ms: timer.delay_ms,
                    is_interval: true,
                    callback_source: timer.callback_source.clone(),
                    registered_at_ms: self.simulated_time_ms,
                });
            }

            callbacks.push(timer.callback_source);
        }

        self.simulated_time_ms = target_time;
        callbacks
    }

    /// Number of pending (non-cancelled) timers.
    #[must_use]
    pub fn pending_count(&self) -> usize {
        self.timers
            .iter()
            .filter(|t| !self.cancelled.contains(&t.id))
            .count()
    }

    /// Current simulated time in milliseconds.
    #[must_use]
    pub fn simulated_time_ms(&self) -> u64 {
        self.simulated_time_ms
    }
}