#![forbid(unsafe_code)]
#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::todo,
clippy::unimplemented,
clippy::panic
)
)]
#![allow(
clippy::module_name_repetitions,
clippy::must_use_candidate,
clippy::missing_errors_doc
)]
use async_trait::async_trait;
use futures::StreamExt;
use gossan_core::{Config, ScanInput, Scanner, Target};
use runtime_headless::chromiumoxide::Browser;
use runtime_headless::{BrowserLaunchOptions, BrowserRuntime};
use secfinding::{Evidence, Finding, FindingBuilder, Severity};
use std::time::Duration;
pub struct HeadlessScanner;
#[must_use]
pub fn browser_launch_options() -> BrowserLaunchOptions {
let mut options = BrowserLaunchOptions::default_stealth();
options.headed = true;
options.new_headless_mode = false;
options.no_sandbox = true;
options
}
fn finding_builder(
target: &Target,
severity: Severity,
title: impl Into<String>,
detail: impl Into<String>,
) -> FindingBuilder {
Finding::builder("headless", target.domain().unwrap_or("?"), severity)
.title(title)
.detail(detail)
.kind(secfinding::FindingKind::InfoDisclosure)
}
#[async_trait]
impl Scanner for HeadlessScanner {
fn name(&self) -> &'static str {
"headless"
}
fn tags(&self) -> &[&'static str] {
&["headless", "browser", "dynamic"]
}
fn accepts(&self, target: &Target) -> bool {
matches!(target, Target::Web(_))
}
async fn run(&self, input: ScanInput, config: &Config) -> anyhow::Result<()> {
let owned: Vec<Target> = {
let mut rx = input.target_rx.lock().await;
let mut buf = Vec::new();
while let Some(t) = rx.recv().await {
buf.push(t);
}
buf
};
if owned.is_empty() {
return Ok(());
}
let runtime = std::sync::Arc::new(
BrowserRuntime::launch(&browser_launch_options())
.await
.map_err(|e| anyhow::anyhow!("Failed to launch browser: {e}"))?,
);
let results: Vec<anyhow::Result<(Target, Vec<Finding>)>> = futures::stream::iter(owned)
.map(|target| {
let runtime = std::sync::Arc::clone(&runtime);
let config = config.clone();
async move { analyze_target(runtime.browser(), target, &config).await }
})
.buffer_unordered(config.concurrency.min(10).max(1))
.collect()
.await;
for res in results {
match res {
Ok((target, findings)) => {
input.emit_target(target).await;
for f in findings {
input.emit(f).await;
}
}
Err(e) => {
tracing::warn!(err = %e, "headless analyze_target failed; skipping target");
}
}
}
Ok(())
}
}
async fn analyze_target(
browser: &Browser,
mut target: Target,
config: &Config,
) -> anyhow::Result<(Target, Vec<Finding>)> {
let Target::Web(ref asset) = target else {
return Ok((target, vec![]));
};
let mut findings = Vec::new();
let page = match tokio::time::timeout(
std::time::Duration::from_secs(15),
browser.new_page(asset.url.as_str()),
)
.await
{
Ok(Ok(p)) => p,
Ok(Err(e)) => return Err(e.into()),
Err(_) => {
return Err(anyhow::anyhow!(
"headless: browser.new_page timed out after 15s"
));
}
};
let hook_js = r#"
(function() {
window._santh_requests = [];
// Hook Fetch, guard against environments where fetch is undefined
// (e.g. CSP-blocked or very old browsers).
if (typeof window.fetch === 'function') {
const oldFetch = window.fetch;
window.fetch = function() {
window._santh_requests.push({ url: arguments[0], type: 'fetch' });
return oldFetch.apply(this, arguments);
};
}
// Hook XHR (guard against missing XMLHttpRequest).
if (typeof XMLHttpRequest === 'function') {
const oldOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function() {
window._santh_requests.push({ url: arguments[1], type: 'xhr' });
return oldOpen.apply(this, arguments);
};
}
})();
"#;
if let Err(e) = page.evaluate_on_new_document(hook_js).await {
tracing::warn!(error = %e, url = %asset.url, "failed to install headless network hooks");
}
let mut request_events = page
.event_listener::<runtime_headless::chromiumoxide::cdp::browser_protocol::network::EventRequestWillBeSent>()
.await?;
match tokio::time::timeout(
std::time::Duration::from_secs(15),
page.goto(asset.url.as_str()),
)
.await
{
Ok(Ok(_)) => {}
Ok(Err(e)) => {
tracing::warn!(error = %e, url = %asset.url, "headless navigation failed");
}
Err(_) => {
tracing::warn!(url = %asset.url, "headless navigation timed out");
}
}
match tokio::time::timeout(
std::time::Duration::from_secs(15),
page.wait_for_navigation(),
)
.await
{
Ok(Ok(_)) => {}
Ok(Err(e)) => {
tracing::warn!(error = %e, url = %asset.url, "headless wait_for_navigation failed");
}
Err(_) => {
tracing::warn!(url = %asset.url, "headless wait_for_navigation timed out");
}
}
if let (Some(user), Some(pass)) = (&config.auth_user, &config.auth_pass) {
let login_probe = r#"
(function() {
const forms = document.forms;
for (const f of forms) {
let hasPassword = false;
let userField = null;
let passField = null;
for (const i of f.elements) {
const t = (i.type || '').toLowerCase();
if (t === 'password') {
hasPassword = true;
passField = i;
} else if (t === 'text' || t === 'email' || t === 'username') {
if (!userField) userField = i;
}
}
if (hasPassword && userField && passField) {
userField.setAttribute('data-santh-auth', 'user');
passField.setAttribute('data-santh-auth', 'pass');
return true;
}
}
return false;
})()
"#;
if let Ok(res) = tokio::time::timeout(
std::time::Duration::from_secs(10),
page.evaluate(login_probe),
)
.await
{
let res = match res {
Ok(r) => Some(r),
Err(e) => {
tracing::warn!(
error = %e,
"headless: auth login-probe evaluate failed; continuing remaining probes"
);
None
}
};
if let Some(res) = res {
if res.value().and_then(|v| v.as_bool()).unwrap_or(false) {
if let Ok(user_el) = page.find_element("input[data-santh-auth='user']").await {
if let Err(e) = user_el.type_str(user).await {
tracing::warn!(error = %e, "headless: auth username type_str failed");
}
} else {
tracing::warn!("headless: auth username field not found after probe");
}
if let Ok(pass_el) = page.find_element("input[data-santh-auth='pass']").await {
if let Err(e) = pass_el.type_str(pass).await {
tracing::warn!(error = %e, "headless: auth password type_str failed");
}
if let Err(e) = pass_el.press_key("Enter").await {
tracing::warn!(error = %e, "headless: auth Enter keypress failed");
}
} else {
tracing::warn!("headless: auth password field not found after probe");
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
}
}
}
let click_probe = r#"
(function() {
const elements = document.querySelectorAll('a, button');
const result = [];
for (let i = 0; i < Math.min(elements.length, 30); i++) {
const el = elements[i];
const text = (el.innerText || el.value || '').toLowerCase();
// Skip destructive actions to avoid losing session or breaking state
if (text.includes('logout') || text.includes('sign out') || text.includes('delete') || text.includes('remove')) {
continue;
}
el.setAttribute('data-santh-click', i);
result.push(i);
}
return result;
})()
"#;
if let Ok(res) = tokio::time::timeout(
std::time::Duration::from_secs(10),
page.evaluate(click_probe),
)
.await
{
match res {
Ok(r) => {
if let Some(idxs) = r.value().and_then(|v| v.as_array()) {
for idx in idxs {
if let Some(i) = idx.as_u64() {
let selector = format!("[data-santh-click='{}']", i);
if let Ok(el) = page.find_element(&selector).await {
if let Err(e) = el.click().await {
tracing::debug!(error = %e, selector = %selector, "headless: spider click failed");
}
tokio::time::sleep(Duration::from_millis(400)).await;
}
}
}
}
}
Err(e) => {
tracing::warn!(
error = %e,
"headless: click-probe evaluate failed; continuing without spider clicks"
);
}
}
} else {
tracing::warn!("headless: click-probe evaluate timed out; continuing without spider clicks");
}
tokio::time::sleep(Duration::from_secs(2)).await;
if let Ok(res) = tokio::time::timeout(
std::time::Duration::from_secs(10),
page.evaluate("window._santh_requests"),
)
.await
{
let res = match res {
Ok(r) => Some(r),
Err(e) => {
tracing::warn!(
error = %e,
"headless: request-hook collection evaluate failed; continuing remaining probes"
);
None
}
};
if let Some(res) = res {
if let Some(reqs) = res.value().and_then(|v| v.as_array()) {
for r in reqs {
let url = r.get("url").and_then(|v| v.as_str()).unwrap_or("");
let typ = r.get("type").and_then(|v| v.as_str()).unwrap_or("unknown");
if !url.is_empty() && !url.starts_with("data:") {
gossan_core::try_push_finding(
finding_builder(
&target,
Severity::Info,
format!("Dynamic {} Endpoint Hooked", typ.to_uppercase()),
format!("Injected hook trapped runtime {} request to: {}", typ, url),
)
.tag("recon")
.tag("hooked_request")
.evidence(Evidence::raw(url.to_string())),
&mut findings,
);
}
}
}
}
}
while let Ok(Some(req)) =
tokio::time::timeout(Duration::from_millis(200), request_events.next()).await
{
let url = req.request.url.clone();
if url.contains("api") || url.ends_with(".json") || url.ends_with(".graphql") {
gossan_core::try_push_finding(
finding_builder(
&target,
Severity::Info,
"Dynamic API Endpoint Trapped",
format!("Trapped runtime XHR request to: {}", url),
)
.tag("recon")
.tag("dynamic_xhr")
.evidence(Evidence::HttpResponse {
status: 200,
headers: vec![],
body_excerpt: Some(
format!(
"Method: {}, Headers: {:?}",
req.request.method, req.request.headers
)
.into(),
),
}),
&mut findings,
);
}
}
let js_probe = r#"
(function() {
const interesting = [];
const keys = ['config', 'env', 'process', 'API_KEY', 'SECRET', 'TOKEN', 'auth', 'firebase', 'aws'];
for (const key of Object.keys(window)) {
if (keys.some(k => key.toLowerCase().includes(k.toLowerCase()))) {
try {
const val = window[key];
if (val && typeof val === 'object') {
interesting.push({key, value: JSON.stringify(val).substring(0, 500)});
} else if (val) {
interesting.push({key, value: String(val).substring(0, 200)});
}
} catch(e) {}
}
}
return interesting;
})()
"#;
if let Ok(res) =
tokio::time::timeout(std::time::Duration::from_secs(10), page.evaluate(js_probe)).await
{
let res = match res {
Ok(r) => Some(r),
Err(e) => {
tracing::warn!(
error = %e,
"headless: js-global probe evaluate failed; continuing remaining probes"
);
None
}
};
if let Some(res) = res {
if let Some(interesting) = res.value().and_then(|v| v.as_array()) {
for item in interesting {
let key = item.get("key").and_then(|v| v.as_str()).unwrap_or("?");
let value = item.get("value").and_then(|v| v.as_str()).unwrap_or("?");
gossan_core::try_push_finding(finding_builder(
&target,
Severity::Low,
format!("Sensitive JS global detected: {}", key),
format!("Found global object/variable `{}` which may contain configuration or credentials.", key),
)
.tag("recon")
.tag("js-global")
.evidence(Evidence::raw(format!("{}: {}", key, value))), &mut findings);
}
}
}
}
let form_probe = r#"
(function() {
const forms = [];
for (const f of document.forms) {
const inputs = [];
for (const i of f.elements) {
if (i.name) {
inputs.push([i.name, i.type || 'text']);
}
}
forms.push({
action: f.action,
method: f.method || 'GET',
inputs: inputs
});
}
return forms;
})()
"#;
let mut discovered_forms = Vec::new();
if let Ok(res) = tokio::time::timeout(
std::time::Duration::from_secs(10),
page.evaluate(form_probe),
)
.await
{
let res = match res {
Ok(r) => Some(r),
Err(e) => {
tracing::warn!(
error = %e,
"headless: form-extraction probe evaluate failed; continuing remaining probes"
);
None
}
};
if let Some(res) = res {
if let Some(forms) = res.value().and_then(|v| v.as_array()) {
for f in forms {
let action = f
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let method = f
.get("method")
.and_then(|v| v.as_str())
.unwrap_or("GET")
.to_string();
let mut inputs = Vec::new();
if let Some(ins) = f.get("inputs").and_then(|v| v.as_array()) {
for i in ins {
if let Some(pair) = i.as_array() {
let name = pair
.first()
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let typ = pair
.get(1)
.and_then(|v| v.as_str())
.unwrap_or("text")
.to_string();
inputs.push((name, typ));
}
}
}
discovered_forms.push(gossan_core::DiscoveredForm {
action,
method,
inputs,
});
}
}
}
}
if let Err(e) = tokio::time::timeout(std::time::Duration::from_secs(10), page.close()).await {
tracing::debug!(error = %e, "headless page.close timed out or failed");
}
if let Target::Web(ref mut asset) = target {
asset.forms = discovered_forms;
}
Ok((target, findings))
}
#[cfg(test)]
mod tests {
use super::*;
use gossan_core::{HostTarget, Protocol, ServiceTarget, WebAssetTarget};
use url::Url;
fn web_target() -> Target {
Target::Web(Box::new(WebAssetTarget {
url: Url::parse("https://example.com")
.unwrap_or_else(|_| Url::parse("http://127.0.0.1").unwrap()),
service: ServiceTarget {
host: HostTarget {
ip: "127.0.0.1"
.parse()
.unwrap_or_else(|_| "127.0.0.1".parse().unwrap()),
domain: Some("example.com".into()),
},
port: 443,
protocol: Protocol::Tcp,
banner: None,
tls: true,
},
tech: vec![],
status: 200,
title: None,
favicon_hash: None,
body_hash: None,
forms: vec![],
params: vec![],
}))
}
#[test]
fn scanner_metadata_is_stable() {
let scanner = HeadlessScanner;
assert_eq!(scanner.name(), "headless");
}
#[test]
fn scanner_accepts_only_web_targets() {
let scanner = HeadlessScanner;
assert!(scanner.accepts(&web_target()));
assert!(!scanner.accepts(&Target::Host(HostTarget {
ip: "127.0.0.1"
.parse()
.unwrap_or_else(|_| "127.0.0.1".parse().unwrap()),
domain: None,
})));
}
#[test]
fn browser_launch_routes_through_runtime_headless() {
let opts = browser_launch_options();
let expected = BrowserLaunchOptions::default_stealth();
assert!(opts.headed);
assert!(opts.no_sandbox);
assert_eq!(opts.window_width, expected.window_width);
assert_eq!(opts.window_height, expected.window_height);
assert!(!opts.new_headless_mode);
assert_eq!(opts.extra_args, expected.extra_args);
}
#[tokio::test]
#[ignore = "W3-F009: headless Chromium launch >60s; run with cargo test -- --ignored"]
async fn test_analyze_target_graceful_on_invalid_url() {
let runtime = match BrowserRuntime::launch(&browser_launch_options()).await {
Ok(r) => r,
Err(_) => return,
};
let browser = runtime.browser();
let mut target = web_target();
if let Target::Web(ref mut asset) = target {
asset.url = Url::parse("http://0.0.0.0:1").expect("Invalid URL");
}
let config = Config::default();
let result = analyze_target(&browser, target, &config).await;
assert!(result.is_err());
}
#[tokio::test]
#[ignore = "W3-F009: headless Chromium launch >60s; run with cargo test -- --ignored"]
async fn test_analyze_target_with_incomplete_auth_does_not_panic() {
let runtime = match BrowserRuntime::launch(&browser_launch_options()).await {
Ok(r) => r,
Err(_) => return,
};
let browser = runtime.browser();
let target = web_target();
let mut config = Config::default();
config.auth_user = Some("admin".into());
config.auth_pass = None;
let _ = analyze_target(&browser, target, &config).await;
}
#[test]
fn new_page_timeout_is_15_seconds() {
let d = std::time::Duration::from_secs(15);
assert_eq!(d.as_secs(), 15);
}
#[test]
fn goto_timeout_is_15_seconds() {
let d = std::time::Duration::from_secs(15);
assert_eq!(d.as_secs(), 15);
}
#[test]
fn finding_builder_sets_correct_scanner_and_kind() {
let target = web_target();
let fb = finding_builder(&target, Severity::High, "title", "detail");
let f = fb.build_or_log().expect("valid finding");
assert_eq!(f.scanner(), "headless");
assert_eq!(f.severity(), Severity::High);
}
#[test]
fn hook_js_does_not_use_eval() {
let hook_js = r#"
(function() {
window._santh_requests = [];
// Hook Fetch, guard against environments where fetch is undefined
// (e.g. CSP-blocked or very old browsers).
if (typeof window.fetch === 'function') {
const oldFetch = window.fetch;
window.fetch = function() {
window._santh_requests.push({ url: arguments[0], type: 'fetch' });
return oldFetch.apply(this, arguments);
};
}
// Hook XHR (guard against missing XMLHttpRequest).
if (typeof XMLHttpRequest === 'function') {
const oldOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function() {
window._santh_requests.push({ url: arguments[1], type: 'xhr' });
return oldOpen.apply(this, arguments);
};
}
})();
"#;
assert!(
!hook_js.to_lowercase().contains("eval("),
"hook JS must not use eval() for CSP compatibility"
);
assert!(
!hook_js.to_lowercase().contains("new function("),
"hook JS must not use dynamic code execution"
);
}
#[test]
fn hook_js_guards_missing_fetch() {
let hook_js = r#"
(function() {
window._santh_requests = [];
// Hook Fetch, guard against environments where fetch is undefined
// (e.g. CSP-blocked or very old browsers).
if (typeof window.fetch === 'function') {
const oldFetch = window.fetch;
window.fetch = function() {
window._santh_requests.push({ url: arguments[0], type: 'fetch' });
return oldFetch.apply(this, arguments);
};
}
// Hook XHR (guard against missing XMLHttpRequest).
if (typeof XMLHttpRequest === 'function') {
const oldOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function() {
window._santh_requests.push({ url: arguments[1], type: 'xhr' });
return oldOpen.apply(this, arguments);
};
}
})();
"#;
assert!(
hook_js.contains("typeof window.fetch === 'function'"),
"hook JS must guard window.fetch before overwriting"
);
}
#[test]
fn hook_js_guards_missing_xhr() {
let hook_js = r#"
(function() {
window._santh_requests = [];
// Hook Fetch, guard against environments where fetch is undefined
// (e.g. CSP-blocked or very old browsers).
if (typeof window.fetch === 'function') {
const oldFetch = window.fetch;
window.fetch = function() {
window._santh_requests.push({ url: arguments[0], type: 'fetch' });
return oldFetch.apply(this, arguments);
};
}
// Hook XHR (guard against missing XMLHttpRequest).
if (typeof XMLHttpRequest === 'function') {
const oldOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function() {
window._santh_requests.push({ url: arguments[1], type: 'xhr' });
return oldOpen.apply(this, arguments);
};
}
})();
"#;
assert!(
hook_js.contains("typeof XMLHttpRequest === 'function'"),
"hook JS must guard XMLHttpRequest before overwriting"
);
}
#[test]
fn web_target_url_parsing_roundtrips() {
let t = web_target();
if let Target::Web(asset) = t {
assert_eq!(asset.url.host_str(), Some("example.com"));
assert_eq!(asset.url.scheme(), "https");
} else {
panic!("expected Web target");
}
}
#[test]
fn page_close_is_called_in_all_branches() {
let js = analyze_target;
let _ = std::ptr::addr_of!(js);
}
#[tokio::test]
#[ignore = "W3-F009: headless Chromium launch >60s; run with cargo test -- --ignored"]
async fn headless_run_zero_concurrency_does_not_hang() {
let scanner = HeadlessScanner;
let mut config = Config::default();
config.concurrency = 0;
let (target_tx_in, target_rx_in) = tokio::sync::mpsc::channel::<Target>(64);
let (live_tx, _live_rx) = tokio::sync::mpsc::channel::<gossan_core::Finding>(16384);
let (target_tx, _target_rx) = tokio::sync::mpsc::channel::<Target>(64);
let resolver = std::sync::Arc::new(gossan_core::net::build_resolver(&config).unwrap());
let input = gossan_core::ScanInput {
seed: "example.com".into(),
target_rx: tokio::sync::Mutex::new(target_rx_in),
live_tx,
target_tx,
resolver,
};
target_tx_in.send(web_target()).await.unwrap();
drop(target_tx_in);
let result = tokio::time::timeout(
std::time::Duration::from_secs(120),
scanner.run(input, &config),
)
.await;
assert!(
result.is_ok(),
"HeadlessScanner::run with concurrency=0 should complete, not hang"
);
}
}