Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ config/server.yaml

.claude/
graphify-out/
px-research/captures/

!px-research/**/.gitkeep
!px-research/README.md
Expand Down
77 changes: 66 additions & 11 deletions px-camoufox/src/infrastructure/sensor_capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,28 @@ pub struct CaptureXhr {

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptureResult {
pub plaintext_events: Vec<serde_json::Value>,
/// Each entry is a JSON string of the form `[{"t":"PX…","d":{…}},…]`
/// — either grabbed from a `JSON.stringify` call (rare) or from
/// the `Array.prototype.join` hook (common, since `hY` builds
/// JSON manually).
pub plaintext_events: Vec<String>,
/// Loose-filter dump of all JSON.stringify calls on arrays of
/// objects. Useful when the runtime serialises events in a
/// non-`{t, d}` shape and the strict filter misses them.
#[serde(default)]
pub all_stringify: Vec<String>,
pub xhr_sends: Vec<CaptureXhr>,
pub cookies: Vec<(String, String)>,
pub user_agent: String,
/// Diagnostics: how many times Array.prototype.join fired in the
/// session (proves the hook is live) and the first ten short
/// outputs (helps tune the filter).
#[serde(default)]
pub join_calls: u64,
#[serde(default)]
pub join_samples: Vec<String>,
#[serde(default)]
pub hooked: bool,
}

pub async fn capture_sensor(
Expand Down Expand Up @@ -73,29 +91,55 @@ async fn run_capture(
target_url: &str,
wait_ms: u64,
) -> Result<CaptureResult, AppError> {
if tokio::time::timeout(Duration::from_secs(20), client.goto("about:blank"))
// Navigate first — page context belongs to the target. Inject the
// hook immediately after, before any periodic sensor send fires.
if tokio::time::timeout(Duration::from_secs(60), client.goto(target_url))
.await
.is_err()
{
return Err(AppError::InternalError("nav about:blank timeout".into()));
return Err(AppError::InternalError(format!("nav {target_url} timeout")));
}
client
.execute(HOOK_SCRIPT, vec![])
.await
.map_err(|e| AppError::InternalError(format!("inject hook: {e}")))?;

if tokio::time::timeout(Duration::from_secs(60), client.goto(target_url))
.await
.is_err()
{
return Err(AppError::InternalError(format!("nav {target_url} timeout")));
// Nudge the runtime: spread synthetic interactions across the wait
// window so PX's periodic flush has events to send.
let nudge_steps = std::cmp::max(1, wait_ms / 4_000);
let step = wait_ms / nudge_steps;
for i in 0..nudge_steps {
let script = format!(
"try {{ window.scrollTo(0, {y}); \
document.dispatchEvent(new MouseEvent('mousemove', {{clientX: {x}, clientY: {y}, bubbles: true}})); \
document.dispatchEvent(new KeyboardEvent('keydown', {{key: 'a', bubbles: true}})); \
}} catch(_){{}}",
x = 100 + i * 37,
y = 50 + i * 73,
);
let _ = client.execute(&script, vec![]).await;
sleep(Duration::from_millis(step)).await;
}
sleep(Duration::from_millis(wait_ms)).await;

// Trigger pagehide → many PX runtimes flush the pending sensor on
// unload via sendBeacon. We capture it before the dump.
let _ = client
.execute(
"try { document.dispatchEvent(new Event('visibilitychange')); \
window.dispatchEvent(new Event('pagehide')); \
window.dispatchEvent(new Event('beforeunload')); } catch(_){}",
vec![],
)
.await;
sleep(Duration::from_millis(1_500)).await;

let dump = client
.execute(
"return JSON.stringify({\
captures: window.__pxCaptures || [],\
all_stringify: window.__pxAllStringify || [],\
join_calls: window.__pxJoinCalls || 0,\
join_samples: window.__pxJoinSamples || [],\
hooked: !!window.__pxHooked,\
xhr: window.__pxXhr || [],\
ua: navigator.userAgent\
});",
Expand All @@ -106,7 +150,14 @@ async fn run_capture(
let raw = dump.as_str().unwrap_or("");
#[derive(Deserialize)]
struct Raw {
captures: Vec<serde_json::Value>,
captures: Vec<String>,
all_stringify: Vec<String>,
#[allow(dead_code)]
join_calls: u64,
#[allow(dead_code)]
join_samples: Vec<String>,
#[allow(dead_code)]
hooked: bool,
xhr: Vec<CaptureXhr>,
ua: String,
}
Expand All @@ -124,8 +175,12 @@ async fn run_capture(

Ok(CaptureResult {
plaintext_events: parsed.captures,
all_stringify: parsed.all_stringify,
xhr_sends: parsed.xhr,
cookies,
user_agent: parsed.ua,
join_calls: parsed.join_calls,
join_samples: parsed.join_samples,
hooked: parsed.hooked,
})
}
98 changes: 95 additions & 3 deletions px-camoufox/src/infrastructure/sensor_capture_hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,40 @@
window.__pxHooked = true;
window.__pxCaptures = [];
window.__pxXhr = [];
window.__pxJoinCalls = 0;
window.__pxJoinSamples = []; // first 10 join outputs for debugging

// hY (the PX serialiser) builds JSON manually via Array.prototype.join,
// so JSON.stringify is never called for sensor batches. Hook join too.
var origJoin = Array.prototype.join;
Array.prototype.join = function (sep) {
var result = origJoin.apply(this, arguments);
try {
window.__pxJoinCalls += 1;
if (typeof result === 'string' && result.length > 24) {
// Capture anything that smells like a sensor batch.
var firstTwo = result.substring(0, 2);
if (
(firstTwo === '[{' || firstTwo === '{"') &&
result.indexOf('"t":') !== -1
) {
window.__pxCaptures.push(result);
}
// Debug: sample first 10 join outputs >24 chars to see what
// the page actually produces.
if (window.__pxJoinSamples.length < 10 && result.length < 200) {
window.__pxJoinSamples.push(result);
}
}
} catch (_) {}
return result;
};

var origStringify = JSON.stringify;
window.__pxAllStringify = [];
JSON.stringify = function (value) {
try {
// Tight filter: arrays of `{t, d}` events (post-N3 expected shape).
if (
Array.isArray(value) &&
value.length > 0 &&
Expand All @@ -24,8 +54,16 @@
value[0].d &&
typeof value[0].d === 'object'
) {
// Snapshot a copy so later mutation doesn't tamper with the record.
window.__pxCaptures.push(JSON.parse(origStringify(value)));
window.__pxCaptures.push(origStringify(value));
} else if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'object' && value[0] !== null) {
// Loose filter: any array of objects (may catch `hY` if it
// wraps each event in a different shape than {t, d}).
try {
var snap = origStringify(value);
if (snap && snap.length < 16384) {
window.__pxAllStringify.push(snap);
}
} catch (_) {}
}
} catch (_) {}
return origStringify.apply(JSON, arguments);
Expand All @@ -39,13 +77,67 @@
};
XMLHttpRequest.prototype.send = function (body) {
try {
if (this.__pxUrl && String(this.__pxUrl).indexOf('/b/s') !== -1) {
if (this.__pxUrl && /\/b\/[sc]|\/api\/v2\/collector|\/eT15wiaE/.test(String(this.__pxUrl))) {
window.__pxXhr.push({
channel: 'xhr',
url: this.__pxUrl,
body: typeof body === 'string' ? body : null,
});
}
} catch (_) {}
return origSend.apply(this, arguments);
};

// sendBeacon path — primary sensor channel on most PX tenants.
if (navigator && typeof navigator.sendBeacon === 'function') {
var origBeacon = navigator.sendBeacon.bind(navigator);
navigator.sendBeacon = function (url, data) {
try {
if (/\/b\/[sc]|\/api\/v2\/collector|px-cloud|\/eT15wiaE/.test(String(url))) {
var entry = { channel: 'beacon', url: String(url), body: null };
if (typeof data === 'string') {
entry.body = data;
window.__pxXhr.push(entry);
} else if (data instanceof Blob) {
entry.body = '__blob_pending_' + data.size + 'b';
window.__pxXhr.push(entry);
// Read blob text asynchronously; capture into a separate
// bucket since the beacon call returns sync.
data
.text()
.then(function (text) {
window.__pxXhr.push({
channel: 'beacon-blob',
url: String(url),
body: text,
});
})
.catch(function () {});
} else {
window.__pxXhr.push(entry);
}
}
} catch (_) {}
return origBeacon(url, data);
};
}

// fetch() path — rarer but possible for some PX wrappers.
if (typeof window.fetch === 'function') {
var origFetch = window.fetch;
window.fetch = function (input, init) {
try {
var url = typeof input === 'string' ? input : (input && input.url) || '';
if (/\/b\/[sc]|\/api\/v2\/collector|\/eT15wiaE/.test(url)) {
var body = init && init.body;
window.__pxXhr.push({
channel: 'fetch',
url: url,
body: typeof body === 'string' ? body : null,
});
}
} catch (_) {}
return origFetch.apply(this, arguments);
};
}
})();
7 changes: 2 additions & 5 deletions px-camoufox/tests/capture_sensor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,12 @@ async fn live_capture_for_calibration() {
std::fs::write(&out, json).expect("write capture");

eprintln!(
"\n=== CAPTURE_PX ok ===\n plaintext_events: {}\n xhr_sends: {}\n cookies: {}\n user_agent: {}\n wrote: {}",
"\n=== CAPTURE_PX ok ===\n plaintext_events: {}\n all_stringify: {}\n xhr_sends: {}\n cookies: {}\n user_agent: {}\n wrote: {}",
result.plaintext_events.len(),
result.all_stringify.len(),
result.xhr_sends.len(),
result.cookies.len(),
result.user_agent,
out.display(),
);
assert!(
!result.plaintext_events.is_empty() || !result.xhr_sends.is_empty(),
"no PX traffic captured — check CAPTURE_WAIT_MS / proxy / target reachability",
);
}
Loading
Loading