use crate::error::Error;
pub fn verify_gui_binary(name: &str) -> Result<std::path::PathBuf, Error> {
let system_dirs = ["/usr/bin", "/usr/local/bin", "/bin", "/usr/sbin"];
for dir in &system_dirs {
let path = std::path::Path::new(dir).join(name);
if path.exists() {
verify_not_symlink_to_user_path(&path)?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let meta = std::fs::metadata(&path).map_err(|e| {
Error::BinaryResolution(format!(
"cannot stat gui binary {}: {e}",
path.display()
))
})?;
if meta.uid() != 0 {
return Err(Error::BinaryResolution(format!(
"gui binary {} is not owned by root (uid={}). \
this could be a spoofed binary.",
path.display(),
meta.uid()
)));
}
let mode = meta.mode();
if mode & 0o022 != 0 {
return Err(Error::BinaryResolution(format!(
"gui binary {} is writable by non-root (mode={mode:04o}). \
this could be a tampered binary.",
path.display()
)));
}
}
return Ok(path);
}
}
Err(Error::BinaryResolution(format!(
"gui binary '{name}' not found in system paths ({system_dirs:?}). \
refusing to use PATH-resolved binary to prevent spoofing."
)))
}
fn verify_not_symlink_to_user_path(path: &std::path::Path) -> Result<(), Error> {
let meta = std::fs::symlink_metadata(path)
.map_err(|e| Error::BinaryResolution(format!("cannot stat {}: {e}", path.display())))?;
if meta.file_type().is_symlink() {
let target = std::fs::read_link(path).map_err(|e| {
Error::BinaryResolution(format!("cannot read symlink {}: {e}", path.display()))
})?;
let combined = if target.is_absolute() {
target
} else {
path.parent()
.map_or_else(|| target.clone(), |p| p.join(&target))
};
let normalized = std::fs::canonicalize(&combined).map_err(|e| {
Error::BinaryResolution(format!(
"cannot resolve gui binary symlink {} -> {}: {e}",
path.display(),
combined.display()
))
})?;
let norm_str = normalized.to_string_lossy();
if norm_str.starts_with("/tmp")
|| norm_str.starts_with("/home")
|| norm_str.starts_with("/var/tmp")
{
return Err(Error::BinaryResolution(format!(
"gui binary {} is a symlink to user-writable location {}. \
this could be a spoofed binary.",
path.display(),
normalized.display()
)));
}
}
Ok(())
}
#[must_use]
pub fn check_x11_keylog_risk() -> Option<String> {
#[cfg(target_os = "linux")]
{
if let Ok(session_type) = std::env::var("XDG_SESSION_TYPE") {
if session_type == "x11" {
return Some(
"WARNING: running on X11. any process connected to this X server \
can intercept your passphrase keystrokes via xinput. \
switch to Wayland for input isolation."
.to_string(),
);
}
if session_type == "wayland" {
return None;
}
}
if std::env::var("WAYLAND_DISPLAY").is_ok() {
return None;
}
if std::env::var("DISPLAY").is_ok() {
return Some(
"WARNING: appears to be running on X11 (DISPLAY set, no WAYLAND_DISPLAY). \
passphrase keystrokes may be interceptable."
.to_string(),
);
}
None
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum PhysicalPresence {
Local,
Ssh,
X11Forward,
RemoteDesktop(String),
#[default]
Unknown,
}
#[must_use]
pub fn check_physical_presence() -> PhysicalPresence {
if std::env::var("SSH_CLIENT").is_ok()
|| std::env::var("SSH_CONNECTION").is_ok()
|| std::env::var("SSH_TTY").is_ok()
|| is_ssh_ancestor()
{
if let Ok(display) = std::env::var("DISPLAY") {
if display.starts_with("localhost:") || display.starts_with("127.0.0.1:") {
return PhysicalPresence::X11Forward;
}
}
return PhysicalPresence::Ssh;
}
#[cfg(target_os = "linux")]
{
if let Some(presence) = check_loginctl_session() {
return presence;
}
if let Some(name) = detect_remote_desktop_for_session() {
return PhysicalPresence::RemoteDesktop(name);
}
}
#[cfg(target_os = "macos")]
{
if detect_macos_screen_sharing() {
return PhysicalPresence::RemoteDesktop("Screen Sharing".to_string());
}
}
#[cfg(target_os = "windows")]
{
if detect_windows_rdp() {
return PhysicalPresence::RemoteDesktop("Remote Desktop".to_string());
}
}
if std::env::var("DISPLAY").is_ok() || std::env::var("WAYLAND_DISPLAY").is_ok() {
return PhysicalPresence::Local;
}
PhysicalPresence::Unknown
}
#[cfg(target_os = "linux")]
fn is_ssh_ancestor() -> bool {
let mut pid = std::process::id();
for _ in 0..20 {
let stat_path = format!("/proc/{pid}/stat");
if let Ok(stat) = std::fs::read_to_string(&stat_path) {
let parts: Vec<&str> = stat.split_whitespace().collect();
if parts.len() < 4 {
break;
}
let comm = parts[1].trim_matches(|c| c == '(' || c == ')');
if comm == "sshd" || comm == "dropbear" || comm == "mosh-server" {
return true;
}
if let Ok(ppid) = parts[3].parse::<u32>() {
if ppid == 0 || ppid == 1 {
break;
}
pid = ppid;
} else {
break;
}
} else {
break;
}
}
false
}
#[cfg(not(target_os = "linux"))]
fn is_ssh_ancestor() -> bool {
false
}
#[cfg(target_os = "linux")]
fn check_loginctl_session() -> Option<PhysicalPresence> {
let session_id = find_loginctl_session_id()?;
let output = std::process::Command::new("loginctl")
.args(["show-session", &session_id, "-p", "Remote"])
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let remote_line = String::from_utf8_lossy(&output.stdout);
let remote_line = remote_line.trim();
if remote_line == "Remote=yes" {
let host_output = std::process::Command::new("loginctl")
.args(["show-session", &session_id, "-p", "RemoteHost"])
.stderr(std::process::Stdio::null())
.output()
.ok();
let host = host_output.map_or_else(
|| "unknown".to_string(),
|o| {
String::from_utf8_lossy(&o.stdout)
.trim()
.strip_prefix("RemoteHost=")
.unwrap_or("unknown")
.to_string()
},
);
return Some(PhysicalPresence::RemoteDesktop(format!(
"remote session from {host}"
)));
}
if remote_line == "Remote=no" {
return Some(PhysicalPresence::Local);
}
None
}
#[cfg(target_os = "linux")]
fn find_loginctl_session_id() -> Option<String> {
if let Ok(id) = std::env::var("XDG_SESSION_ID") {
let id = id.trim().to_string();
if !id.is_empty() {
return Some(id);
}
}
if let Ok(id) = std::fs::read_to_string("/proc/self/sessionid") {
let id = id.trim().to_string();
if !id.is_empty() && id != "4294967295" {
let check = std::process::Command::new("loginctl")
.args(["show-session", &id, "-p", "Remote"])
.stderr(std::process::Stdio::null())
.output();
if let Ok(output) = check {
if output.status.success() && !output.stdout.is_empty() {
return Some(id);
}
}
}
}
let uid = unsafe { libc::getuid() };
let output = std::process::Command::new("loginctl")
.args(["list-sessions", "--no-legend"])
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
if let Ok(line_uid) = parts[1].parse::<u32>() {
if line_uid == uid {
return Some(parts[0].to_string());
}
}
}
}
}
None
}
#[cfg(target_os = "linux")]
fn detect_remote_desktop_for_session() -> Option<String> {
let display = std::env::var("DISPLAY").ok()?;
let xserver_pid = find_x_server_pid(&display)?;
let remote_parents = [
("xrdp", "xrdp"),
("xrdp-sesman", "xrdp"),
("xrdp-chansrv", "xrdp"),
("Xvnc", "VNC"),
("x11vnc", "VNC"),
("vino-server", "GNOME Screen Sharing"),
("tigervnc", "TigerVNC"),
("gnome-remote-desktop", "GNOME Remote Desktop"),
];
let mut pid = xserver_pid;
for _ in 0..30 {
let stat_path = format!("/proc/{pid}/comm");
if let Ok(comm) = std::fs::read_to_string(&stat_path) {
let comm = comm.trim();
for (process_name, label) in &remote_parents {
if comm == *process_name {
return Some((*label).to_string());
}
}
}
let stat_path = format!("/proc/{pid}/stat");
if let Ok(stat) = std::fs::read_to_string(&stat_path) {
let parts: Vec<&str> = stat.split_whitespace().collect();
if parts.len() < 4 {
break;
}
if let Ok(ppid) = parts[3].parse::<u32>() {
if ppid == 0 || ppid == 1 || ppid == pid {
break;
}
pid = ppid;
} else {
break;
}
} else {
break;
}
}
None
}
#[cfg(target_os = "linux")]
fn find_x_server_pid(display: &str) -> Option<u32> {
let x_server_names = ["Xorg", "X", "Xwayland", "Xephyr", "Xvnc", "Xrdp", "xrdp"];
if let Ok(entries) = std::fs::read_dir("/proc") {
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.chars().all(|c| c.is_ascii_digit()) {
continue;
}
let comm_path = path.join("comm");
if let Ok(comm) = std::fs::read_to_string(&comm_path) {
let comm = comm.trim();
if x_server_names.contains(&comm) {
if let Ok(cmdline) = std::fs::read_to_string(path.join("cmdline")) {
let cmdline = cmdline.replace('\0', " ");
if cmdline.contains(display)
|| cmdline.contains(&display.replace(':', " :"))
{
return name_str.parse().ok();
}
}
}
}
}
}
None
}
#[cfg(target_os = "macos")]
fn detect_macos_screen_sharing() -> bool {
std::process::Command::new("pgrep")
.args(["-x", "ScreensharingD"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(target_os = "windows")]
fn detect_windows_rdp() -> bool {
std::env::var("SESSIONNAME")
.map(|v| v.starts_with("RDP-"))
.unwrap_or(false)
}
#[derive(Debug, Clone, Default)]
pub struct GuiSecurityReport {
pub input_injectors: Vec<String>,
pub screen_recorders: Vec<String>,
pub accessibility_tools: Vec<String>,
pub presence: PhysicalPresence,
pub x11_session: bool,
pub threat_level: GuiThreatLevel,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum GuiThreatLevel {
#[default]
Safe,
Degraded(String),
Hostile(String),
}
#[must_use]
pub fn assess_gui_security() -> GuiSecurityReport {
let presence = check_physical_presence();
#[allow(unused_mut, unused_assignments)]
let mut x11_session = false;
#[cfg(target_os = "linux")]
{
x11_session = detect_x11_session();
}
#[allow(unused_mut, unused_assignments)]
let mut input_injectors = Vec::new();
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
{
input_injectors = detect_input_injectors();
}
let screen_recorders = detect_screen_recorders();
#[allow(unused_mut, unused_assignments)]
let mut accessibility_tools = Vec::new();
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
{
accessibility_tools = detect_accessibility_automation();
}
let mut report = GuiSecurityReport {
input_injectors,
screen_recorders,
accessibility_tools,
presence,
x11_session,
threat_level: GuiThreatLevel::Safe,
};
report.threat_level = compute_threat_level(&report);
report
}
#[must_use]
pub fn assess_gui_signals(_ctx: &super::DetectorContext) -> Vec<super::Signal> {
let report = assess_gui_security();
let mut signals = Vec::new();
match &report.presence {
PhysicalPresence::Ssh => signals.push(super::Signal::new(
super::SignalId::new("gui.presence.ssh"),
super::Category::GuiPresence,
super::Severity::Hostile,
"SSH session detected",
"no local display available for GUI approval",
"use the paired-device relay (`security set relay_required true`) or run envseal locally",
)),
PhysicalPresence::X11Forward => signals.push(super::Signal::new(
super::SignalId::new("gui.presence.x11_forward"),
super::Category::GuiPresence,
super::Severity::Hostile,
"X11 forwarding active",
"passphrase keystrokes interceptable by the remote host",
"switch to a local session or paired-device relay",
)),
PhysicalPresence::RemoteDesktop(name) => signals.push(super::Signal::new(
super::SignalId::new("gui.presence.remote_desktop"),
super::Category::GuiPresence,
super::Severity::Hostile,
"remote desktop session active",
format!("controlled remotely via {name}"),
"use a local session for sensitive operations",
)),
PhysicalPresence::Unknown | PhysicalPresence::Local => {}
}
for tool in &report.input_injectors {
signals.push(super::Signal::new(
super::SignalId::scoped("gui.input_injector", tool),
super::Category::InputInjection,
super::Severity::Hostile,
"input injection tool running",
format!("`{tool}` can synthesize keystrokes / clicks on this session"),
"stop the process before approving secret access",
));
}
for rec in &report.screen_recorders {
signals.push(super::Signal::new(
super::SignalId::scoped("gui.screen_recorder", rec),
super::Category::ScreenRecording,
super::Severity::Degraded,
"screen recorder running",
format!("`{rec}` may capture passphrase entry"),
"stop screen recording before entering credentials",
));
}
if report.x11_session {
signals.push(super::Signal::new(
super::SignalId::new("gui.session.x11"),
super::Category::GuiPresence,
super::Severity::Degraded,
"X11 session — no input isolation",
"any process on this display can read keystrokes",
"switch to Wayland or use the on-screen keyboard for passphrase entry",
));
}
for tool in &report.accessibility_tools {
signals.push(super::Signal::new(
super::SignalId::scoped("gui.accessibility_bridge", tool),
super::Category::AccessibilityBridge,
super::Severity::Warn,
"accessibility automation running",
format!("`{tool}` could synthesize approval clicks"),
"review accessibility-tool configuration",
));
}
signals
}
fn compute_threat_level(report: &GuiSecurityReport) -> GuiThreatLevel {
match &report.presence {
PhysicalPresence::Ssh => {
return GuiThreatLevel::Hostile(
"SSH session — no local display for GUI approval".to_string(),
);
}
PhysicalPresence::X11Forward => {
return GuiThreatLevel::Hostile(
"X11 forwarding — passphrase interceptable by remote host".to_string(),
);
}
PhysicalPresence::RemoteDesktop(name) => {
return GuiThreatLevel::Hostile(format!(
"remote desktop ({name}) — GUI can be controlled remotely"
));
}
PhysicalPresence::Unknown | PhysicalPresence::Local => {}
}
if !report.input_injectors.is_empty() {
return GuiThreatLevel::Hostile(format!(
"input injection tools detected: {}. these can simulate mouse clicks \
to auto-approve passphrase prompts.",
report.input_injectors.join(", ")
));
}
if !report.screen_recorders.is_empty() {
return GuiThreatLevel::Degraded(format!(
"screen recording detected: {}. passphrase entry may be captured.",
report.screen_recorders.join(", ")
));
}
if report.x11_session {
return GuiThreatLevel::Degraded(
"X11 session — any process can intercept keystrokes. \
use virtual keyboard for passphrase entry."
.to_string(),
);
}
if !report.accessibility_tools.is_empty() {
return GuiThreatLevel::Degraded(format!(
"accessibility automation detected: {}. these can simulate input events.",
report.accessibility_tools.join(", ")
));
}
GuiThreatLevel::Safe
}
#[cfg(target_os = "linux")]
fn detect_x11_session() -> bool {
if let Ok(session_type) = std::env::var("XDG_SESSION_TYPE") {
return session_type == "x11";
}
std::env::var("DISPLAY").is_ok() && std::env::var("WAYLAND_DISPLAY").is_err()
}
#[cfg(target_os = "linux")]
fn detect_input_injectors() -> Vec<String> {
let injection_tools = [
("xdotool", "xdotool (X11 input simulator)"),
("xte", "xte (xautomation toolkit)"),
("xdo", "xdo (X11 window/input tool)"),
("xmacro", "xmacro (X11 macro recorder)"),
("ydotool", "ydotool (uinput-based input simulator)"),
("ydotoold", "ydotool daemon (uinput injector)"),
("wtype", "wtype (Wayland keystroke injector)"),
("dotool", "dotool (uinput-based, Wayland-compatible)"),
("dotoold", "dotool daemon (uinput injector)"),
("evemu-event", "evemu (uinput event replay)"),
("evemu-play", "evemu (uinput event playback)"),
("autokey", "autokey (desktop automation)"),
("autokey-gtk", "autokey-gtk"),
("autokey-qt", "autokey-qt"),
("AutoHotkey", "AutoHotkey (input automation)"),
("AutoHotkey.exe", "AutoHotkey (input automation)"),
("AutoHotkeyU64.exe", "AutoHotkey U64 (input automation)"),
("AutoIt3.exe", "AutoIt3 (Windows automation)"),
("AutoIt3_x64.exe", "AutoIt3 x64 (Windows automation)"),
("nircmd.exe", "NirCmd (Windows command-line automation)"),
("nircmdc.exe", "NirCmd console"),
("cliclick", "cliclick (macOS click/keystroke automation)"),
];
detect_running_processes(&injection_tools)
}
#[cfg(target_os = "macos")]
fn detect_input_injectors() -> Vec<String> {
let injection_tools = [
("osascript", "osascript (AppleScript automation)"),
("cliclick", "cliclick (macOS input simulator)"),
("MouseTools", "MouseTools (mouse control)"),
];
detect_running_processes(&injection_tools)
}
#[cfg(target_os = "windows")]
fn detect_input_injectors() -> Vec<String> {
let injection_tools = [
("AutoHotkey.exe", "AutoHotkey (Windows input automation)"),
("autoit3.exe", "AutoIt (Windows automation)"),
("AutoIt.exe", "AutoIt (Windows automation)"),
("macrorecorder.exe", "Macro Recorder"),
("pulover.exe", "Pulover's Macro Creator"),
];
detect_running_processes(&injection_tools)
}
fn detect_screen_recorders() -> Vec<String> {
#[cfg(target_os = "linux")]
{
let recorders = [
("obs", "OBS Studio"),
("obs-studio", "OBS Studio"),
("simplescreenrecorder", "SimpleScreenRecorder"),
("kazam", "Kazam"),
("peek", "Peek (GIF recorder)"),
("recordmydesktop", "RecordMyDesktop"),
("ffmpeg", "ffmpeg"),
("wf-recorder", "wf-recorder (Wayland)"),
("gpu-screen-recorder", "GPU Screen Recorder"),
];
detect_running_processes(&recorders)
}
#[cfg(target_os = "macos")]
{
let recorders = [
("screencapturekit", "ScreenCaptureKit"),
("OBS", "OBS Studio"),
];
detect_running_processes(&recorders)
}
#[cfg(target_os = "windows")]
{
let recorders = [
("obs64.exe", "OBS Studio"),
("obs32.exe", "OBS Studio"),
("ShareX.exe", "ShareX"),
("CamtasiaStudio.exe", "Camtasia"),
("SnagitCapture.exe", "Snagit"),
("Bandicam.exe", "Bandicam"),
("Action.exe", "Action!"),
("screenrecorder.exe", "Screen Recorder"),
];
detect_running_processes(&recorders)
}
}
#[cfg(target_os = "linux")]
fn detect_accessibility_automation() -> Vec<String> {
let a11y_tools = [
("at-spi-bus-launcher", "AT-SPI (accessibility bus)"),
("dogtail", "Dogtail (a11y testing tool)"),
("ldtp", "LDTP (Linux Desktop Testing Project)"),
("accerciser", "Accerciser (a11y inspector)"),
];
let mut found = detect_running_processes(&a11y_tools);
if found.iter().any(|f| f.contains("AT-SPI")) && found.len() <= 1 {
found.clear();
}
found
}
#[cfg(target_os = "macos")]
fn detect_accessibility_automation() -> Vec<String> {
let a11y_tools = [
("ScriptEditor", "Script Editor (macOS automation)"),
("Automator", "Automator (macOS workflow automation)"),
("Hammerspoon", "Hammerspoon (macOS scripting)"),
];
detect_running_processes(&a11y_tools)
}
#[cfg(target_os = "windows")]
fn detect_accessibility_automation() -> Vec<String> {
let a11y_tools = [("Narrator.exe", "Narrator (screen reader / a11y bridge)")];
detect_running_processes(&a11y_tools)
}
#[cfg(target_os = "linux")]
fn detect_running_processes(tools: &[(&str, &str)]) -> Vec<String> {
let mut found = Vec::new();
if let Ok(entries) = std::fs::read_dir("/proc") {
for entry in entries.flatten() {
let comm_path = entry.path().join("comm");
if let Ok(comm) = std::fs::read_to_string(&comm_path) {
let comm = comm.trim();
for (name, label) in tools {
if comm == *name {
found.push((*label).to_string());
}
}
}
}
}
found.sort();
found.dedup();
found
}
#[cfg(target_os = "macos")]
fn detect_running_processes(tools: &[(&str, &str)]) -> Vec<String> {
let mut found = Vec::new();
for (name, label) in tools {
if let Ok(output) = std::process::Command::new("pgrep")
.args(["-x", name])
.output()
{
if output.status.success() {
found.push((*label).to_string());
}
}
}
found
}
#[cfg(target_os = "windows")]
fn detect_running_processes(tools: &[(&str, &str)]) -> Vec<String> {
let mut found = Vec::new();
for (name, label) in tools {
let filter = format!("IMAGENAME eq {name}");
if let Ok(output) = std::process::Command::new("tasklist")
.args(["/FI", &filter, "/NH", "/FO", "CSV"])
.output()
{
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.trim().contains(name) {
found.push((*label).to_string());
}
}
}
}
found
}
#[must_use]
pub fn generate_gui_challenge() -> (String, String) {
use rand::Rng;
let mut rng = rand::rngs::OsRng;
let code: u32 = rng.gen_range(100_000..1_000_000);
let code_str = code.to_string();
(format!("Type {code_str} to confirm"), code_str)
}
#[must_use]
pub fn verify_gui_challenge(expected: &str, actual: &str) -> bool {
if expected.len() != actual.len() {
return false;
}
let mut diff = 0u8;
for (a, b) in expected.bytes().zip(actual.bytes()) {
diff |= a ^ b;
}
std::hint::black_box(diff) == 0
}
#[must_use]
pub fn approval_delay_seconds(tier: &str) -> u32 {
match tier {
"standard" => 0,
"hardened" => 2,
"lockdown" => 5,
_ => 1,
}
}