Skip to main content

runtime_foxdriver/
sensors.rs

1//! The **Omniscient Page** — a passive, always-on instrumentation grid injected
2//! into every page's MAIN world before its scripts run.
3//!
4//! A human pentester *goes looking* for a DOM-XSS sink, a CSP gap, a postMessage
5//! handler. This module makes the page report all of them on its own: every
6//! write to a known DOM-XSS sink (with the actual value + a JS stack), every
7//! console line, every uncaught exception, every CSP violation, and every
8//! inbound `postMessage` is recorded into a bounded buffer the agent reads at
9//! will. Active hunting becomes passive telemetry — coverage no human can hold
10//! across every page, continuously.
11//!
12//! The script is injected two ways (see [`crate::Page::start_sensors`]): as a
13//! preload (so it runs before page scripts on every navigation) AND evaluated
14//! once on the current document (so a page already loaded at launch is covered).
15//! It is idempotent, defensive (every hook is wrapped in try/catch and calls the
16//! ORIGINAL implementation), and bounded (ring buffers capped), so it never
17//! breaks or hangs the page it observes.
18
19/// Per-category ring-buffer cap. A hostile page that spams `console.log` or
20/// fires sink writes in a loop cannot grow this without bound (Law 7).
21pub const SENSOR_BUFFER_CAP: usize = 300;
22
23/// Max captured length of any single value/code snippet.
24pub const SENSOR_SNIPPET_LEN: usize = 512;
25
26/// The sensor install script — an idempotent IIFE so the SAME source is valid
27/// both as a preload body and as a one-shot `evaluate` expression.
28///
29/// Records into a non-enumerable `window.__meridian_signals__` with slices:
30/// `sinks` (DOM-XSS), `console`, `errors` (uncaught + rejections), `csp`
31/// (violations), `postmessage` (inbound). Each entry carries a short value
32/// snippet and, where available, a JS stack so the agent can locate the source.
33pub const SENSOR_SCRIPT: &str = r#"(function () {
34  try {
35    if (window.__meridian_signals__ && window.__meridian_signals__.__installed) return;
36    var CAP = 300, SNIP = 512;
37    var P = Array.prototype.push, slice = Function.prototype.call.bind(Array.prototype.slice);
38    var S = { __installed: true, sinks: [], console: [], errors: [], csp: [], postmessage: [] };
39    try { Object.defineProperty(window, "__meridian_signals__", { value: S, writable: true, enumerable: false, configurable: true }); }
40    catch (e) { window.__meridian_signals__ = S; }
41    var now = function () { try { return Date.now(); } catch (e) { return 0; } };
42    var snip = function (v) {
43      try {
44        var s = typeof v === "string" ? v : (function () { try { return JSON.stringify(v); } catch (e) { return String(v); } })();
45        if (s == null) return "";
46        return s.length > SNIP ? s.slice(0, SNIP) + "…" : s;
47      } catch (e) { return ""; }
48    };
49    var stack = function () { try { return (new Error().stack || "").split("\n").slice(2, 8).join("\n"); } catch (e) { return ""; } };
50    var rec = function (bucket, entry) {
51      try { entry.ts = now(); P.call(bucket, entry); if (bucket.length > CAP) bucket.splice(0, bucket.length - CAP); } catch (e) {}
52    };
53
54    // ---- DOM-XSS sinks -----------------------------------------------------
55    var hookSetter = function (proto, prop, sink) {
56      try {
57        var d = Object.getOwnPropertyDescriptor(proto, prop);
58        if (!d || !d.set) return;
59        var orig = d.set;
60        Object.defineProperty(proto, prop, {
61          configurable: true, enumerable: d.enumerable, get: d.get,
62          set: function (val) { rec(S.sinks, { sink: sink, tag: (this && this.tagName) || "", value: snip(val), stack: stack() }); return orig.call(this, val); }
63        });
64      } catch (e) {}
65    };
66    hookSetter(Element.prototype, "innerHTML", "innerHTML");
67    hookSetter(Element.prototype, "outerHTML", "outerHTML");
68    try {
69      var iah = Element.prototype.insertAdjacentHTML;
70      Element.prototype.insertAdjacentHTML = function (pos, html) { rec(S.sinks, { sink: "insertAdjacentHTML", tag: (this && this.tagName) || "", value: snip(html), stack: stack() }); return iah.apply(this, arguments); };
71    } catch (e) {}
72    try {
73      var dw = document.write;
74      document.write = function () { rec(S.sinks, { sink: "document.write", value: snip(slice(arguments).join("")), stack: stack() }); return dw.apply(this, arguments); };
75    } catch (e) {}
76    try {
77      var ev = window.eval;
78      window.eval = function (code) { rec(S.sinks, { sink: "eval", value: snip(code), stack: stack() }); return ev.apply(this, arguments); };
79    } catch (e) {}
80    try {
81      var setAttr = Element.prototype.setAttribute;
82      Element.prototype.setAttribute = function (name, value) {
83        try { var n = ("" + name).toLowerCase(); if (n.indexOf("on") === 0 || ((n === "src" || n === "href") && /^\s*javascript:/i.test("" + value))) rec(S.sinks, { sink: "setAttribute:" + n, tag: (this && this.tagName) || "", value: snip(value), stack: stack() }); } catch (e) {}
84        return setAttr.apply(this, arguments);
85      };
86    } catch (e) {}
87    try {
88      var sd = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, "src");
89      if (sd && sd.set) { var so = sd.set; Object.defineProperty(HTMLScriptElement.prototype, "src", { configurable: true, get: sd.get, set: function (u) { rec(S.sinks, { sink: "script.src", value: snip(u), stack: stack() }); return so.call(this, u); } }); }
90    } catch (e) {}
91
92    // ---- console -----------------------------------------------------------
93    try {
94      ["log", "info", "warn", "error", "debug"].forEach(function (level) {
95        var orig = console[level];
96        if (typeof orig !== "function") return;
97        console[level] = function () { try { rec(S.console, { level: level, text: snip(slice(arguments).map(function (a) { return typeof a === "string" ? a : snip(a); }).join(" ")) }); } catch (e) {} return orig.apply(this, arguments); };
98      });
99    } catch (e) {}
100
101    // ---- uncaught errors + rejections -------------------------------------
102    try { window.addEventListener("error", function (e) { rec(S.errors, { kind: "error", message: snip(e && e.message), filename: (e && e.filename) || "", line: (e && e.lineno) || 0, col: (e && e.colno) || 0, stack: snip(e && e.error && e.error.stack) }); }, true); } catch (e) {}
103    try { window.addEventListener("unhandledrejection", function (e) { rec(S.errors, { kind: "unhandledrejection", message: snip(e && e.reason && (e.reason.message || e.reason)) }); }, true); } catch (e) {}
104
105    // ---- CSP violations ----------------------------------------------------
106    try { document.addEventListener("securitypolicyviolation", function (e) { rec(S.csp, { directive: (e && e.violatedDirective) || "", blocked: (e && e.blockedURI) || "", source: (e && e.sourceFile) || "", line: (e && e.lineNumber) || 0, sample: snip(e && e.sample) }); }, true); } catch (e) {}
107
108    // ---- inbound postMessage ----------------------------------------------
109    try { window.addEventListener("message", function (e) { rec(S.postmessage, { origin: (e && e.origin) || "", data: snip(e && e.data) }); }, true); } catch (e) {}
110  } catch (e) {}
111  return true;
112})()"#;
113
114/// Build the reader expression. When `clear` is true the buffer slices are
115/// emptied after the snapshot is taken (so the agent can read deltas).
116pub fn sensor_reader(clear: bool) -> String {
117    format!(
118        r#"(function () {{
119  var S = window.__meridian_signals__;
120  if (!S) return {{ installed: false, sinks: [], console: [], errors: [], csp: [], postmessage: [] }};
121  var snap = {{ installed: true,
122    sinks: S.sinks.slice(), console: S.console.slice(), errors: S.errors.slice(),
123    csp: S.csp.slice(), postmessage: S.postmessage.slice(),
124    counts: {{ sinks: S.sinks.length, console: S.console.length, errors: S.errors.length, csp: S.csp.length, postmessage: S.postmessage.length }} }};
125  if ({clear}) {{ S.sinks.length = 0; S.console.length = 0; S.errors.length = 0; S.csp.length = 0; S.postmessage.length = 0; }}
126  return snap;
127}})()"#
128    )
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    fn balanced(s: &str) -> bool {
136        let (mut paren, mut brace, mut bracket) = (0i32, 0i32, 0i32);
137        for c in s.chars() {
138            match c {
139                '(' => paren += 1,
140                ')' => paren -= 1,
141                '{' => brace += 1,
142                '}' => brace -= 1,
143                '[' => bracket += 1,
144                ']' => bracket -= 1,
145                _ => {}
146            }
147        }
148        paren == 0 && brace == 0 && bracket == 0
149    }
150
151    #[test]
152    fn sensor_script_is_idempotent_iife() {
153        assert!(SENSOR_SCRIPT.trim_start().starts_with("(function"));
154        assert!(SENSOR_SCRIPT.contains("__meridian_signals__"));
155        assert!(SENSOR_SCRIPT.contains("__installed"));
156        assert!(balanced(SENSOR_SCRIPT), "sensor script must be bracket-balanced");
157    }
158
159    #[test]
160    fn sensor_script_covers_every_sensor_category() {
161        // The categorical advantage is breadth — assert each sink/sensor is wired.
162        for needle in [
163            "innerHTML", "outerHTML", "insertAdjacentHTML", "document.write",
164            "window.eval", "setAttribute", "script.src",
165            "console[level]", "\"error\"", "unhandledrejection",
166            "securitypolicyviolation", "\"message\"",
167        ] {
168            assert!(SENSOR_SCRIPT.contains(needle), "sensor script missing hook: {needle}");
169        }
170    }
171
172    #[test]
173    fn sensor_reader_clear_flag_threads_through() {
174        assert!(sensor_reader(true).contains("if (true)"));
175        assert!(sensor_reader(false).contains("if (false)"));
176        assert!(balanced(&sensor_reader(true)));
177        assert!(sensor_reader(true).contains("counts"));
178    }
179
180    #[test]
181    fn caps_are_sane() {
182        assert!(SENSOR_BUFFER_CAP >= 100);
183        assert!(SENSOR_SNIPPET_LEN >= 128);
184    }
185}