diff --git a/neurosploit-rs/crates/harness/src/browser.rs b/neurosploit-rs/crates/harness/src/browser.rs new file mode 100644 index 0000000..7846176 --- /dev/null +++ b/neurosploit-rs/crates/harness/src/browser.rs @@ -0,0 +1,323 @@ +//! Browser validator — proving a payload *executed*, not that it appeared. +//! +//! A payload echoed into HTML is reflection. It becomes cross-site scripting +//! only when a browser parses that response and runs it, and the gap between +//! the two is where most XSS false positives live: the value lands inside an +//! attribute that is never evaluated, inside a `"), + format!("{{{{constructor.constructor(\"document.title='{m}'\")()}}}}"), + ] +} + +/// The driver script. Written to a temp file and run with `node`. +/// +/// It reports a single JSON line on stdout so the Rust side never has to parse +/// Playwright's own chatter, and it takes a screenshot only when it observed +/// something — an image of a page that did nothing is not evidence. +fn driver_js() -> &'static str { + r#" +const { chromium } = require('playwright'); +const [,, url, marker, shotPath, waitMs] = process.argv; +(async () => { + const out = { available: true, executed: false, marker_observed: false, channel: '', console: [], screenshot: '', notes: [] }; + let browser; + try { + browser = await chromium.launch({ args: ['--no-sandbox'] }); + const ctx = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await ctx.newPage(); + + // A dialog blocks the page until it is handled; handling it is also how we + // read its message. + page.on('dialog', async (d) => { + const msg = d.message() || ''; + if (msg.includes(marker)) { out.executed = true; out.marker_observed = true; out.channel = out.channel || 'dialog'; } + try { await d.dismiss(); } catch {} + }); + page.on('console', (m) => { + const t = m.text(); + if (out.console.length < 40) out.console.push(t); + if (t.includes(marker)) { out.executed = true; out.marker_observed = true; out.channel = out.channel || 'console'; } + }); + page.on('pageerror', (e) => { if (out.console.length < 40) out.console.push('pageerror: ' + e.message); }); + + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: Number(waitMs) }); + // Give deferred handlers (onerror, onload, timers) a chance to run. + await page.waitForTimeout(Math.min(2500, Number(waitMs) / 2)); + + const title = await page.title().catch(() => ''); + if (title.includes(marker)) { out.executed = true; out.marker_observed = true; out.channel = out.channel || 'title'; } + const global = await page.evaluate(() => window.__neurosploit || '').catch(() => ''); + if (String(global).includes(marker)) { out.executed = true; out.marker_observed = true; out.channel = out.channel || 'global'; } + + // Reflection without execution is worth recording: it tells the operator + // the input reaches the response and the context is what blocked it. + if (!out.executed) { + const html = await page.content().catch(() => ''); + if (html.includes(marker)) out.notes.push('marker is reflected in the DOM but did not execute — encoded, non-executing context, or blocked by CSP'); + } + if (out.marker_observed && shotPath) { + try { await page.screenshot({ path: shotPath, fullPage: false }); out.screenshot = shotPath; } catch {} + } + } catch (e) { + out.notes.push('browser error: ' + (e && e.message ? e.message : String(e))); + } finally { + try { if (browser) await browser.close(); } catch {} + process.stdout.write('NSJSON' + JSON.stringify(out) + 'NSEND'); + } +})(); +"# +} + +/// Pull the driver's result out of its stdout. +/// +/// Playwright and npx print their own noise, so the payload is delimited rather +/// than assumed to be the whole stream. +pub fn parse_result(stdout: &str) -> Option { + let start = stdout.find("NSJSON")? + "NSJSON".len(); + let end = stdout[start..].find("NSEND")? + start; + serde_json::from_str(&stdout[start..end]).ok() +} + +pub struct BrowserProbe { + policy: ScopePolicy, + timeout: Duration, +} + +impl BrowserProbe { + pub fn new(policy: ScopePolicy) -> Self { + BrowserProbe { policy, timeout: Duration::from_secs(30) } + } + + pub fn with_timeout(mut self, t: Duration) -> Self { + self.timeout = t; + self + } + + /// Is a browser usable on this machine? + pub fn available() -> bool { + which("node") && (playwright_installed() || which("npx")) + } + + /// Load `url` in a real browser and report whether `marker` executed. + /// + /// The scope guard runs first: driving a browser is still sending traffic, + /// and a validator that wanders off-target while confirming a finding would + /// undo the whole point of having a boundary. + pub async fn confirm_execution(&self, url: &str, marker: &str, shot: Option<&str>) -> BrowserResult { + let decision = self.policy.check(url, Action::Exploit); + if !decision.allowed() { + return BrowserResult::unavailable(&format!("scope guard refused the browser check: {}", decision.reason())); + } + if !Self::available() { + return BrowserResult::unavailable( + "no browser available — install node and `npx playwright install chromium`. Nothing is confirmed without one.", + ); + } + + let dir = std::env::temp_dir().join(format!("ns-browser-{}", std::process::id())); + if std::fs::create_dir_all(&dir).is_err() { + return BrowserResult::unavailable("could not create a temp dir for the browser driver"); + } + let script = dir.join("ns-xss-probe.js"); + if std::fs::write(&script, driver_js()).is_err() { + return BrowserResult::unavailable("could not write the browser driver"); + } + + let ms = self.timeout.as_millis().to_string(); + let mut cmd = tokio::process::Command::new("node"); + cmd.arg(&script).arg(url).arg(marker).arg(shot.unwrap_or("")).arg(&ms); + cmd.kill_on_drop(true); + cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()); + + let run = tokio::time::timeout(self.timeout + Duration::from_secs(10), async { + cmd.output().await + }) + .await; + + let output = match run { + Ok(Ok(o)) => o, + Ok(Err(e)) => return BrowserResult::unavailable(&format!("could not start node: {e}")), + Err(_) => return BrowserResult::unavailable("the browser check timed out"), + }; + let stdout = String::from_utf8_lossy(&output.stdout); + match parse_result(&stdout) { + Some(r) => r, + None => { + let err = String::from_utf8_lossy(&output.stderr); + let hint = if err.contains("Cannot find module 'playwright'") { + "playwright is not installed — run `npx playwright install chromium`" + } else { + "the browser driver produced no result" + }; + BrowserResult::unavailable(&format!("{hint}: {}", err.chars().take(200).collect::())) + } + } + } +} + +fn which(bin: &str) -> bool { + std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).any(|d| d.join(bin).is_file())) + .unwrap_or(false) +} + +fn playwright_installed() -> bool { + // A local install is what makes the driver's `require` work without a + // network round trip on every check. + ["node_modules/playwright", "node_modules/.bin/playwright"] + .iter() + .any(|p| std::path::Path::new(p).exists()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_payload_carries_the_marker_and_a_self_report() { + let m = crate::validation::canary("nsxss"); + for p in xss_payloads(&m) { + assert!(p.contains(&m), "payload must carry the marker: {p}"); + assert!( + p.contains("document.title") || p.contains("__neurosploit"), + "a payload that does not report itself proves nothing we can attribute: {p}" + ); + } + } + + #[test] + fn payloads_cover_the_contexts_a_value_lands_in() { + let p = xss_payloads("M"); + let all = p.join(" "); + assert!(all.contains("\">"), "raw-text element"); + assert!(all.contains("constructor.constructor"), "template expression"); + } + + #[test] + fn a_console_hit_alone_is_not_proof_of_execution() { + let r = BrowserResult { available: true, executed: true, marker_observed: true, channel: "console".into(), ..Default::default() }; + assert!(!r.proves_execution(), "a page can log the value it reflected without running it"); + let r = BrowserResult { channel: "dialog".into(), ..r }; + assert!(r.proves_execution()); + } + + #[test] + fn an_unavailable_browser_confirms_nothing() { + let r = BrowserResult::unavailable("node missing"); + assert!(!r.available && !r.proves_execution()); + // The distinction that matters: this is "we could not look", and it + // must never read as either a clean result or a finding. + assert!(!r.executed && !r.marker_observed); + } + + #[test] + fn the_driver_result_is_parsed_out_of_surrounding_noise() { + let out = "npm warn something\nDownloading Chromium...\nNSJSON{\"available\":true,\"executed\":true,\"marker_observed\":true,\"channel\":\"dialog\",\"console\":[],\"screenshot\":\"\",\"notes\":[]}NSEND\ntrailing chatter"; + let r = parse_result(out).expect("must find the delimited payload"); + assert!(r.proves_execution()); + assert_eq!(r.channel, "dialog"); + } + + #[test] + fn garbage_output_yields_no_result_rather_than_a_default_one() { + assert!(parse_result("playwright exploded").is_none()); + assert!(parse_result("NSJSON{not json}NSEND").is_none()); + } + + #[tokio::test] + async fn an_out_of_scope_url_is_refused_before_a_browser_starts() { + let mut policy = ScopePolicy::for_target("https://app.example.com/"); + policy.soft.max_requests_per_minute = 0; + let probe = BrowserProbe::new(policy); + let r = probe.confirm_execution("https://evil.test/x", "M", None).await; + assert!(!r.available); + assert!(r.notes[0].contains("scope guard refused"), "{:?}", r.notes); + } +} diff --git a/neurosploit-rs/crates/harness/src/lib.rs b/neurosploit-rs/crates/harness/src/lib.rs index 2bdd6a7..3782c58 100644 --- a/neurosploit-rs/crates/harness/src/lib.rs +++ b/neurosploit-rs/crates/harness/src/lib.rs @@ -10,6 +10,7 @@ pub mod agents; pub mod attack_graph; pub mod audit; pub mod belief; +pub mod browser; pub mod capability; pub mod claims; pub mod creds; @@ -43,6 +44,7 @@ pub use memory::{Memory, Query as MemoryQuery, Tier as MemoryTier}; pub use pool::{ModelPool, Task}; pub use audit::{AuditLog, AuditRecord, KillReason, KillSwitch}; pub use capability::{Capability, TokenError}; +pub use browser::{BrowserProbe, BrowserResult}; pub use claims::{Claim, ClaimSet, ClaimStatus, Decision, EvidenceLedger}; pub use policy::{Act, ActionKind, BlastRadius, EngagementPolicy, Environment, Protocol, Risk, RiskDecision, SafetyPolicy}; pub use replay::{ReplayEngine, ReqSpec}; diff --git a/neurosploit-rs/crates/harness/src/replay.rs b/neurosploit-rs/crates/harness/src/replay.rs index 17e98f6..704952f 100644 --- a/neurosploit-rs/crates/harness/src/replay.rs +++ b/neurosploit-rs/crates/harness/src/replay.rs @@ -25,6 +25,7 @@ //! the report; a 40MB response is not evidence, it is a liability. use crate::scope::ScopePolicy; +use serde::{Deserialize, Serialize}; use crate::validation::{Evidence, Exchange}; use std::collections::BTreeMap; use std::time::{Duration, Instant}; @@ -249,6 +250,197 @@ impl ReplayEngine { } } +// --------------------------------------------------------------------------- +// Effect layers +// +// The engagement that motivated this recorded 25 accepted POSTs and concluded +// "reset email flooding". Those are different facts at different layers, and +// the pipeline had no way to say so: a request being accepted is not a state +// change, and a state change is not a message leaving the building. +// +// request_effect the response: status, headers, latency, body delta +// application_effect something changed inside: a record, a token, a queued job +// external_effect something left: an email delivered, a webhook fired +// +// Each layer needs its own observation. The harness can measure the first +// directly, the second with a read-back, and the third only through a channel +// it controls (a mailbox it owns, an OOB callback). Anything it cannot observe +// is recorded as NOT OBSERVED rather than inferred — which is exactly the line +// the agent crossed. +// --------------------------------------------------------------------------- + +/// Which layer an observation belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum EffectLayer { + Request, + Application, + External, +} + +impl EffectLayer { + pub fn as_str(self) -> &'static str { + match self { + EffectLayer::Request => "request_effect", + EffectLayer::Application => "application_effect", + EffectLayer::External => "external_effect", + } + } +} + +/// What was seen at one layer — or that nothing was looked at. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EffectObservation { + pub layer: EffectLayer, + /// True only when the harness itself observed it. + pub observed: bool, + pub detail: String, + /// Evidence ledger ids, when the caller is building a claim set. + #[serde(default)] + pub evidence: Vec, +} + +impl EffectObservation { + pub fn seen(layer: EffectLayer, detail: impl Into) -> Self { + EffectObservation { layer, observed: true, detail: detail.into(), evidence: Vec::new() } + } + /// Not observed, with the reason. "We did not look" and "we looked and saw + /// nothing" are both recorded here, and neither is evidence of absence of + /// the effect — only of its demonstration. + pub fn not_seen(layer: EffectLayer, why: impl Into) -> Self { + EffectObservation { layer, observed: false, detail: why.into(), evidence: Vec::new() } + } +} + +/// Everything observed about one interaction, layer by layer. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct EffectReport { + #[serde(default)] + pub observations: Vec, +} + +impl EffectReport { + pub fn push(&mut self, o: EffectObservation) { + self.observations.push(o); + } + pub fn observed(&self, layer: EffectLayer) -> bool { + self.observations.iter().any(|o| o.layer == layer && o.observed) + } + /// The deepest layer actually demonstrated. This is what a severity or an + /// impact claim may be built on — and nothing deeper. + pub fn deepest_observed(&self) -> Option { + [EffectLayer::External, EffectLayer::Application, EffectLayer::Request] + .into_iter() + .find(|l| self.observed(*l)) + } + /// One line per layer, for the evidence section of a report. + pub fn summary(&self) -> String { + let mut out = String::new(); + for layer in [EffectLayer::Request, EffectLayer::Application, EffectLayer::External] { + let line = self + .observations + .iter() + .find(|o| o.layer == layer) + .map(|o| format!("{} {}", if o.observed { "observed:" } else { "NOT observed:" }, o.detail)) + .unwrap_or_else(|| "not examined".to_string()); + out.push_str(&format!(" {:<20} {line}\n", layer.as_str())); + } + out + } +} + +impl ReplayEngine { + /// Re-run an interaction and record what it did at every layer the harness + /// can reach. + /// + /// `verify` is a read-back request that reveals an application effect (the + /// record as it now stands, the account's state, the audit endpoint). + /// `marker` is a value expected to appear there if the write took. Without + /// a verification request the application layer is honestly reported as + /// unexamined — the alternative, inferring it from a 200, is the mistake + /// this whole layering exists to prevent. + pub async fn observe_effects( + &self, + action: &ReqSpec, + verify: Option<&ReqSpec>, + marker: Option<&str>, + ) -> (EffectReport, Option) { + let mut report = EffectReport::default(); + + let before = match verify { + Some(v) => self.send(v).await.ok(), + None => None, + }; + + let acted = match self.send(action).await { + Ok(x) => x, + Err(e) => { + report.push(EffectObservation::not_seen(EffectLayer::Request, format!("the request could not be sent: {e}"))); + report.push(EffectObservation::not_seen(EffectLayer::Application, "no request, so no application effect to look for")); + report.push(EffectObservation::not_seen(EffectLayer::External, "no request, so no external effect to look for")); + return (report, None); + } + }; + report.push(EffectObservation::seen( + EffectLayer::Request, + format!("{} {} → {} · {} bytes · {} ms", acted.method, acted.url, acted.status, acted.len(), acted.elapsed_ms), + )); + + match verify { + None => report.push(EffectObservation::not_seen( + EffectLayer::Application, + "no read-back was performed — a response status does not show whether state changed", + )), + Some(v) => match self.send(v).await { + Err(e) => report.push(EffectObservation::not_seen(EffectLayer::Application, format!("the read-back failed: {e}"))), + Ok(after) => { + let marker_hit = marker.map(|m| after.body.contains(m) && !before.as_ref().map(|b| b.body.contains(m)).unwrap_or(false)); + match marker_hit { + Some(true) => report.push(EffectObservation::seen( + EffectLayer::Application, + format!("the read-back now contains the injected value ({})", marker.unwrap_or("")), + )), + Some(false) => report.push(EffectObservation::not_seen( + EffectLayer::Application, + "the read-back does not contain the injected value — the request was accepted and the change was not persisted", + )), + None => { + // No marker to look for: fall back to whether the + // resource changed at all, which is weaker and says so. + let changed = before + .as_ref() + .map(|b| b.status != after.status || b.body != after.body) + .unwrap_or(false); + if changed { + report.push(EffectObservation::seen( + EffectLayer::Application, + format!("the resource differs after the request ({} → {}, {} → {} bytes)", before.as_ref().map(|b| b.status).unwrap_or(0), after.status, before.as_ref().map(|b| b.len()).unwrap_or(0), after.len()), + )); + } else { + report.push(EffectObservation::not_seen( + EffectLayer::Application, + "the resource is unchanged after the request", + )); + } + } + } + } + }, + } + + // The harness has no mailbox and no callback listener of its own yet, so + // it cannot see this layer. Saying that plainly is the point: an + // unobserved external effect must never be inferred from an accepted + // request. + report.push(EffectObservation::not_seen( + EffectLayer::External, + "no channel the harness controls (mailbox, OOB callback) was watching — delivery of mail, notifications or downstream jobs was not observed", + )); + + (report, Some(acted)) + } +} + /// Rebuild a request spec from a recorded exchange. pub fn spec_of(x: &Exchange) -> ReqSpec { ReqSpec { @@ -355,3 +547,56 @@ mod tests { } } } + +#[cfg(test)] +mod effect_tests { + use super::*; + + #[test] + fn an_accepted_request_is_not_an_application_effect() { + let mut r = EffectReport::default(); + r.push(EffectObservation::seen(EffectLayer::Request, "POST /reset → 302")); + r.push(EffectObservation::not_seen(EffectLayer::Application, "no read-back was performed")); + r.push(EffectObservation::not_seen(EffectLayer::External, "no mailbox watched")); + assert!(r.observed(EffectLayer::Request)); + assert!(!r.observed(EffectLayer::Application)); + // The deepest thing demonstrated is the request — an impact claim may + // be built on that and nothing further. + assert_eq!(r.deepest_observed(), Some(EffectLayer::Request)); + } + + #[test] + fn the_summary_says_not_observed_rather_than_staying_silent() { + let mut r = EffectReport::default(); + r.push(EffectObservation::seen(EffectLayer::Request, "25 requests accepted")); + r.push(EffectObservation::not_seen(EffectLayer::External, "no mailbox watched")); + let s = r.summary(); + assert!(s.contains("request_effect") && s.contains("observed:")); + assert!(s.contains("external_effect") && s.contains("NOT observed:")); + // A layer nobody looked at must not read as a clean result. + assert!(s.contains("application_effect") && s.contains("not examined")); + } + + #[test] + fn the_deepest_observed_layer_is_the_ceiling_for_a_claim() { + let mut r = EffectReport::default(); + r.push(EffectObservation::seen(EffectLayer::Request, "POST accepted")); + r.push(EffectObservation::seen(EffectLayer::Application, "read-back shows the new value")); + r.push(EffectObservation::not_seen(EffectLayer::External, "no delivery channel")); + assert_eq!(r.deepest_observed(), Some(EffectLayer::Application)); + let empty = EffectReport::default(); + assert_eq!(empty.deepest_observed(), None); + } + + #[tokio::test] + async fn a_refused_request_reports_every_layer_as_unobserved() { + let mut p = ScopePolicy::for_target("https://app.example.com/"); + p.soft.max_requests_per_minute = 0; + let engine = ReplayEngine::new(p); + let (report, exchange) = engine.observe_effects(&ReqSpec::get("https://other.test/x"), None, None).await; + assert!(exchange.is_none()); + for l in [EffectLayer::Request, EffectLayer::Application, EffectLayer::External] { + assert!(!report.observed(l), "{l:?} must not be reported as observed when nothing was sent"); + } + } +}