mirror of
https://github.com/usestrix/firewall.git
synced 2026-11-09 05:19:35 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01ab5b0995 | ||
|
|
97a5a148e0 | ||
|
|
877807740b | ||
|
|
cd9b0614bc | ||
|
|
6cad09d32d | ||
|
|
23db767cc1 | ||
|
|
37a56fb7d2 |
No files matched your search
+1
-1
@@ -129,7 +129,7 @@ schannel = "0.1.29"
|
||||
tokio = { version = "1.53.1", features = ["net"] }
|
||||
tracing-appender = "0.2.5"
|
||||
tray-icon = { version = "0.26.1", default-features = false }
|
||||
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_Environment", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
|
||||
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_Environment", "Win32_System_JobObjects", "Win32_System_Pipes", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
|
||||
windows-registry = "0.6.1"
|
||||
windows-service = "0.8.0"
|
||||
|
||||
|
||||
@@ -11,4 +11,5 @@ pub static BUN: Manager = Manager {
|
||||
("npm_config_registry", "http://registry.npmjs.org/"),
|
||||
],
|
||||
args: &[],
|
||||
lockfile: None,
|
||||
};
|
||||
@@ -17,4 +17,5 @@ pub static CARGO: Manager = Manager {
|
||||
"source.firewall.registry=\"sparse+http://index.crates.io/\"",
|
||||
],
|
||||
}],
|
||||
lockfile: None,
|
||||
};
|
||||
@@ -8,4 +8,5 @@ pub static GEM: Manager = Manager {
|
||||
when: &["install", "update", "fetch"],
|
||||
flags: &["--clear-sources", "--source", "http://rubygems.org/"],
|
||||
}],
|
||||
lockfile: None,
|
||||
};
|
||||
@@ -11,4 +11,5 @@ pub static GO: Manager = Manager {
|
||||
("GOFLAGS", "-mod=mod"),
|
||||
],
|
||||
args: &[],
|
||||
lockfile: None,
|
||||
};
|
||||
@@ -36,6 +36,14 @@ pub struct Manager {
|
||||
pub command: &'static str,
|
||||
pub env: &'static [(&'static str, &'static str)],
|
||||
pub args: &'static [Args],
|
||||
/// Where the manager records registry URLs, which then carry our http:// override.
|
||||
pub lockfile: Option<Lockfile>,
|
||||
}
|
||||
|
||||
/// What to do after a run about http:// registry URLs left in the lockfile at this path.
|
||||
pub enum Lockfile {
|
||||
Restore(&'static str),
|
||||
Warn(&'static str),
|
||||
}
|
||||
|
||||
pub static MANAGERS: &[&Manager] =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Manager, PROXY};
|
||||
use super::{Lockfile, Manager, PROXY};
|
||||
|
||||
pub static NPM: Manager = Manager {
|
||||
command: "npm",
|
||||
@@ -8,4 +8,5 @@ pub static NPM: Manager = Manager {
|
||||
("npm_config_registry", "http://registry.npmjs.org/"),
|
||||
],
|
||||
args: &[],
|
||||
lockfile: Some(Lockfile::Restore("package-lock.json")),
|
||||
};
|
||||
@@ -13,4 +13,5 @@ pub static PDM: Manager = Manager {
|
||||
("PDM_PYPI_VERIFY_SSL", "false"),
|
||||
],
|
||||
args: &[],
|
||||
lockfile: None,
|
||||
};
|
||||
@@ -12,5 +12,5 @@ pub static PIP: Manager = recipe("pip");
|
||||
pub static PIP3: Manager = recipe("pip3");
|
||||
|
||||
const fn recipe(name: &'static str) -> Manager {
|
||||
Manager { command: name, env: PIP_ENV, args: &[] }
|
||||
Manager { command: name, env: PIP_ENV, args: &[], lockfile: None }
|
||||
}
|
||||
@@ -13,4 +13,5 @@ pub static PIPENV: Manager = Manager {
|
||||
("PIP_TRUSTED_HOST", "pypi.org files.pythonhosted.org"),
|
||||
],
|
||||
args: &[],
|
||||
lockfile: None,
|
||||
};
|
||||
@@ -9,4 +9,5 @@ pub static PNPM: Manager = Manager {
|
||||
("pnpm_config_registry", "http://registry.npmjs.org/"),
|
||||
],
|
||||
args: &[],
|
||||
lockfile: None,
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
use super::{Manager, PROXY, PYPI_SIMPLE};
|
||||
use super::{Lockfile, Manager, PROXY, PYPI_SIMPLE};
|
||||
|
||||
pub static UV: Manager = Manager {
|
||||
command: "uv",
|
||||
env: &[("HTTP_PROXY", PROXY), ("http_proxy", PROXY), ("UV_DEFAULT_INDEX", PYPI_SIMPLE)],
|
||||
args: &[],
|
||||
lockfile: Some(Lockfile::Warn("uv.lock")),
|
||||
};
|
||||
@@ -6,9 +6,11 @@ mod windows;
|
||||
pub use forward::spawn;
|
||||
pub use managers::*;
|
||||
|
||||
use crate::policies::registries::BUILT_IN;
|
||||
use std::io::{Error, ErrorKind};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub struct Invocation {
|
||||
pub program: String,
|
||||
@@ -50,10 +52,42 @@ pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::R
|
||||
let status = command.status();
|
||||
#[cfg(windows)]
|
||||
let status = status.map_err(|error| windows::explain(error, &program));
|
||||
Ok(status?.code().unwrap_or(1))
|
||||
let code = status?.code().unwrap_or(1);
|
||||
let manager = MANAGERS.iter().find(|manager| manager.command == invocation.program);
|
||||
match manager.and_then(|manager| manager.lockfile.as_ref()) {
|
||||
Some(Lockfile::Restore(lockfile)) => restore_https(lockfile, dir),
|
||||
Some(Lockfile::Warn(lockfile)) => warn_if_plaintext(lockfile, dir),
|
||||
None => {}
|
||||
}
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
// Command::new only finds .exe on Windows; npm, pnpm and gem are .cmd shims found via PATHEXT.
|
||||
fn resolve(program: &str) -> std::io::Result<PathBuf> {
|
||||
which::which(program).map_err(|e| Error::new(ErrorKind::NotFound, e))
|
||||
}
|
||||
|
||||
fn warn_if_plaintext(lockfile: &str, dir: Option<&Path>) {
|
||||
let path = dir.unwrap_or(Path::new(".")).join(lockfile);
|
||||
let Ok(text) = std::fs::read_to_string(path) else { return };
|
||||
if BUILT_IN.iter().any(|registry| text.contains(&format!("http://{}/", registry.host()))) {
|
||||
warn!("{lockfile} has http:// registry URLs; change them to https:// before committing");
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_https(lockfile: &str, dir: Option<&Path>) {
|
||||
let path = dir.unwrap_or(Path::new(".")).join(lockfile);
|
||||
let Ok(text) = std::fs::read_to_string(&path) else { return };
|
||||
let mut fixed = text.clone();
|
||||
for registry in BUILT_IN {
|
||||
let host = registry.host();
|
||||
fixed = fixed.replace(&format!("http://{host}/"), &format!("https://{host}/"));
|
||||
}
|
||||
if fixed == text {
|
||||
return;
|
||||
}
|
||||
match std::fs::write(&path, fixed) {
|
||||
Ok(()) => info!("changed {lockfile}'s registry URLs back to https://"),
|
||||
Err(e) => warn!("could not change {lockfile}'s registry URLs back to https://: {e}"),
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! LocalSystem service screens it as the macOS extension does.
|
||||
|
||||
mod ca;
|
||||
mod pipe;
|
||||
mod service;
|
||||
mod tpm;
|
||||
mod tray;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! How the tray asks the service what it blocked, and allows a new package once, as the macOS
|
||||
//! app messages its extension.
|
||||
|
||||
use super::{config_file, security_descriptor};
|
||||
use crate::{blocks, Firewall};
|
||||
use rama::error::BoxError;
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions};
|
||||
use windows::core::{Owned, PWSTR};
|
||||
use windows::Win32::Foundation::HANDLE;
|
||||
use windows::Win32::Security::SECURITY_ATTRIBUTES;
|
||||
use windows::Win32::System::Pipes::GetNamedPipeClientProcessId;
|
||||
use windows::Win32::System::Threading::*;
|
||||
|
||||
pub(super) const PIPE: &str = r"\\.\pipe\StrixFirewall";
|
||||
/// Signed-in users can connect, as the tray runs as them.
|
||||
const PIPE_ACL: &str = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)";
|
||||
|
||||
pub(super) async fn serve(firewall: Arc<Firewall>) -> Result<(), BoxError> {
|
||||
let mut server = create(true)?;
|
||||
loop {
|
||||
server.connect().await?;
|
||||
let client = std::mem::replace(&mut server, create(false)?);
|
||||
let firewall = Arc::clone(&firewall);
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = answer(client, &firewall).await {
|
||||
tracing::debug!(%error, "could not answer the tray");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn create(first: bool) -> Result<NamedPipeServer, BoxError> {
|
||||
let acl = security_descriptor(PIPE_ACL)?;
|
||||
let mut attributes = SECURITY_ATTRIBUTES {
|
||||
nLength: size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: acl.0,
|
||||
bInheritHandle: false.into(),
|
||||
};
|
||||
let attributes = (&raw mut attributes).cast();
|
||||
let mut options = ServerOptions::new();
|
||||
// Fails if anyone else made the pipe first.
|
||||
options.first_pipe_instance(first);
|
||||
Ok(unsafe { options.create_with_security_attributes_raw(PIPE, attributes)? })
|
||||
}
|
||||
|
||||
/// A line each way.
|
||||
async fn answer(client: NamedPipeServer, firewall: &Firewall) -> Result<(), BoxError> {
|
||||
let mut client = BufReader::new(client);
|
||||
let mut request = String::new();
|
||||
(&mut client)
|
||||
.take(64 * 1024)
|
||||
.read_line(&mut request)
|
||||
.await?;
|
||||
let request = request.trim_end();
|
||||
// Only the app can message the macOS extension; here any program could connect.
|
||||
let reply = if !client_is_tray(client.get_ref())? {
|
||||
"error: only the tray can ask".to_owned()
|
||||
} else if request.starts_with("allow ") {
|
||||
// Trusting writes the config for every user, so it is left to an administrator.
|
||||
"error: trusting a package takes an administrator".to_owned()
|
||||
} else {
|
||||
blocks::answer(firewall, &config_file(), request)
|
||||
};
|
||||
client.write_all(format!("{reply}\n").as_bytes()).await?;
|
||||
// Closing before the tray hangs up could discard the reply.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), client.read(&mut [0])).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn client_is_tray(client: &NamedPipeServer) -> Result<bool, BoxError> {
|
||||
let mut id = 0;
|
||||
unsafe { GetNamedPipeClientProcessId(HANDLE(client.as_raw_handle()), &mut id)? };
|
||||
let process = unsafe { Owned::new(OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, id)?) };
|
||||
let mut path = [0u16; 1024];
|
||||
let mut len = path.len() as u32;
|
||||
let flags = PROCESS_NAME_WIN32;
|
||||
unsafe { QueryFullProcessImageNameW(*process, flags, PWSTR(path.as_mut_ptr()), &mut len)? };
|
||||
let path = String::from_utf16(&path[..len as usize])?;
|
||||
let tray = std::env::current_exe()?.with_file_name("firewall-tray.exe");
|
||||
// Windows paths ignore case.
|
||||
Ok(path.to_lowercase() == tray.to_string_lossy().to_lowercase())
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::ca::{load_or_create_ca, secure_data_dir};
|
||||
use super::tpm::CaKey;
|
||||
use super::{config_file, loopback_addresses, write_hosts_entries, CRL_ADDRESS, SERVICE_NAME};
|
||||
use super::{
|
||||
config_file, loopback_addresses, pipe, write_hosts_entries, CRL_ADDRESS, SERVICE_NAME,
|
||||
};
|
||||
use crate::config::FirewallConfig;
|
||||
use crate::http::{reply, text};
|
||||
use crate::{mitm, telemetry, Firewall};
|
||||
@@ -153,6 +155,13 @@ async fn start_screening(status: &ServiceStatusHandle) -> Result<Vec<&'static st
|
||||
let crl = HttpServer::auto(exec.clone()).service(crl);
|
||||
tokio::spawn(listen_on(CRL_ADDRESS, 80, &exec)?.serve(crl));
|
||||
|
||||
let tray_firewall = Arc::clone(&firewall);
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = pipe::serve(tray_firewall).await {
|
||||
tracing::error!(%error, "the tray cannot reach the service");
|
||||
}
|
||||
});
|
||||
|
||||
let service = mitm::mitm_service(firewall, &ca, signer, Some(crl_url), exec.clone());
|
||||
for (address, host) in loopback_addresses(&hosts) {
|
||||
for port in [80, 443] {
|
||||
|
||||
+199
-9
@@ -1,24 +1,34 @@
|
||||
//! The notification-area icon, started at sign-in as the menu bar app is on macOS.
|
||||
|
||||
use super::pipe::PIPE;
|
||||
use super::*;
|
||||
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
|
||||
use std::io::Cursor;
|
||||
use muda::{IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use serde::Deserialize;
|
||||
use std::io::{BufRead, BufReader, Cursor, Write};
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use std::sync::mpsc::{channel, Receiver, Sender};
|
||||
use std::time::Duration;
|
||||
use tray_icon::{Icon, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
||||
use windows::core::{w, PCWSTR};
|
||||
use windows::Win32::Foundation::*;
|
||||
use windows::Win32::System::Pipes::GetNamedPipeServerProcessId;
|
||||
use windows::Win32::System::Threading::CreateMutexW;
|
||||
use windows::Win32::UI::Shell::*;
|
||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||
use windows_registry::CURRENT_USER;
|
||||
use windows_service::service::{Service, ServiceAccess, ServiceState};
|
||||
use windows_service::service::{Service, ServiceAccess, ServiceState, ServiceStatus};
|
||||
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
|
||||
|
||||
const MENU_PAUSE: &str = "pause";
|
||||
const MENU_RESTART: &str = "restart";
|
||||
const MENU_LOG: &str = "log";
|
||||
const MENU_QUIT: &str = "quit";
|
||||
/// Also the service's request.
|
||||
const ALLOW_ONCE: &str = "allow-once";
|
||||
const LOGO: &[u8] = include_bytes!("tray.png");
|
||||
/// Where blocked packages are listed, below Open logs.
|
||||
const BLOCKED_AT: usize = 5;
|
||||
const BLOCKED_KEPT: usize = 10;
|
||||
|
||||
/// Runs until Quit, once per session.
|
||||
pub fn run() -> Result<(), BoxError> {
|
||||
@@ -29,6 +39,7 @@ pub fn run() -> Result<(), BoxError> {
|
||||
}
|
||||
let status = MenuItem::new("", false, None);
|
||||
let pause = MenuItem::with_id(MENU_PAUSE, "", false, None);
|
||||
let blocked_separator = PredefinedMenuItem::separator();
|
||||
let menu = Menu::with_items(&[
|
||||
&status,
|
||||
&PredefinedMenuItem::separator(),
|
||||
@@ -39,7 +50,9 @@ pub fn run() -> Result<(), BoxError> {
|
||||
&MenuItem::with_id(MENU_QUIT, "Quit", true, None),
|
||||
])?;
|
||||
let (logo, logo_size) = logo_mask()?;
|
||||
let icon = TrayIconBuilder::new().with_menu(Box::new(menu)).build()?;
|
||||
let icon = TrayIconBuilder::new()
|
||||
.with_menu(Box::new(menu.clone()))
|
||||
.build()?;
|
||||
let window = HWND(icon.window_handle());
|
||||
let mut tray = Tray {
|
||||
icon,
|
||||
@@ -49,9 +62,14 @@ pub fn run() -> Result<(), BoxError> {
|
||||
status,
|
||||
pause,
|
||||
state: None,
|
||||
menu,
|
||||
blocked_separator,
|
||||
blocked: Vec::new(),
|
||||
allowed: channel(),
|
||||
};
|
||||
tray.update();
|
||||
unsafe { SetTimer(None, 0, 2000, None) };
|
||||
// The service holds a new package's download only while it is asked this often.
|
||||
unsafe { SetTimer(None, 0, 500, None) };
|
||||
let mut message = MSG::default();
|
||||
while unsafe { GetMessageW(&mut message, None, 0, 0) }.0 > 0 {
|
||||
unsafe { DispatchMessageW(&message) };
|
||||
@@ -62,6 +80,7 @@ pub fn run() -> Result<(), BoxError> {
|
||||
// Ours, not the icon window's own hover timer.
|
||||
if message.message == WM_TIMER && message.hwnd.is_invalid() {
|
||||
tray.update();
|
||||
tray.collect_blocks();
|
||||
}
|
||||
while let Ok(event) = TrayIconEvent::receiver().try_recv() {
|
||||
// The menu opens on release, so this keeps it current.
|
||||
@@ -94,11 +113,47 @@ struct Tray {
|
||||
pause: MenuItem,
|
||||
/// As the menu last showed it.
|
||||
state: Option<ServiceState>,
|
||||
menu: Menu,
|
||||
blocked_separator: PredefinedMenuItem,
|
||||
/// The most recent, as the menu lists them.
|
||||
blocked: Vec<BlockedPackage>,
|
||||
/// Allowed from another thread, so no longer listed.
|
||||
allowed: (Sender<BlockedPackage>, Receiver<BlockedPackage>),
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Deserialize)]
|
||||
struct BlockedPackage {
|
||||
id: u64,
|
||||
eco: String,
|
||||
name: String,
|
||||
version: Option<String>,
|
||||
filter: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
impl BlockedPackage {
|
||||
fn label(&self) -> String {
|
||||
match &self.version {
|
||||
Some(version) => format!("{} {version} ({})", self.name, self.eco),
|
||||
None => format!("{} ({})", self.name, self.eco),
|
||||
}
|
||||
}
|
||||
|
||||
/// The denylist and OSV only flag packages known to be malicious; the age check only holds
|
||||
/// back new ones.
|
||||
fn malicious(&self) -> bool {
|
||||
self.filter != "age"
|
||||
}
|
||||
|
||||
/// Malware is never allowed, and a new package only by version.
|
||||
fn trustable(&self) -> bool {
|
||||
!self.malicious() && self.version.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Tray {
|
||||
fn update(&mut self) {
|
||||
self.state = service_state();
|
||||
self.state = service_status().map(|status| status.current_state);
|
||||
let screening = self.state == Some(ServiceState::Running);
|
||||
let light = light_taskbar();
|
||||
if self.drawn != Some((light, screening)) {
|
||||
@@ -117,7 +172,85 @@ impl Tray {
|
||||
self.pause.set_enabled(self.state.is_some());
|
||||
}
|
||||
|
||||
/// The service hands each block over once, so whichever tray asks first shows it.
|
||||
fn collect_blocks(&mut self) {
|
||||
let allowed: Vec<_> = self.allowed.1.try_iter().collect();
|
||||
if !allowed.is_empty() {
|
||||
self.blocked.retain(|blocked| !allowed.contains(blocked));
|
||||
self.list_blocked();
|
||||
}
|
||||
if self.state != Some(ServiceState::Running) {
|
||||
return;
|
||||
}
|
||||
let Ok(reply) = ask_service("blocks") else {
|
||||
return;
|
||||
};
|
||||
let Ok(packages) = serde_json::from_str::<Vec<BlockedPackage>>(&reply) else {
|
||||
return;
|
||||
};
|
||||
let mut new = Vec::new();
|
||||
for package in packages {
|
||||
if !self.blocked.contains(&package) {
|
||||
self.blocked.push(package.clone());
|
||||
new.push(package);
|
||||
}
|
||||
}
|
||||
if new.is_empty() {
|
||||
return;
|
||||
}
|
||||
let excess = self.blocked.len().saturating_sub(BLOCKED_KEPT);
|
||||
self.blocked.drain(..excess);
|
||||
self.list_blocked();
|
||||
// Off the UI thread, so the menu keeps working while they are open.
|
||||
let allowed = self.allowed.0.clone();
|
||||
std::thread::spawn(move || {
|
||||
for package in new {
|
||||
if alert(&package) {
|
||||
allow_once(package, &allowed);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn list_blocked(&self) {
|
||||
while self.menu.items().len() > BLOCKED_AT + 2 {
|
||||
self.menu.remove_at(BLOCKED_AT);
|
||||
}
|
||||
let _ = self.menu.insert(&self.blocked_separator, BLOCKED_AT);
|
||||
for (i, package) in self.blocked.iter().enumerate() {
|
||||
let verb = if package.malicious() {
|
||||
"Blocked"
|
||||
} else {
|
||||
"Held back"
|
||||
};
|
||||
let reason = if package.malicious() {
|
||||
format!("Malicious: {}", package.reason)
|
||||
} else {
|
||||
package.reason.clone()
|
||||
};
|
||||
let reason = MenuItem::new(reason, false, None);
|
||||
let once = MenuItem::with_id(format!("{ALLOW_ONCE}:{i}"), "Allow Once", true, None);
|
||||
let items: &[&dyn IsMenuItem] = if package.trustable() {
|
||||
&[&reason, &once]
|
||||
} else {
|
||||
&[&reason]
|
||||
};
|
||||
let label = format!("{verb} {}", package.label());
|
||||
if let Ok(item) = Submenu::with_items(label, true, items) {
|
||||
let _ = self.menu.insert(&item, BLOCKED_AT + 1 + i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_menu(&self, id: &str) {
|
||||
if let Some(i) = id.strip_prefix(ALLOW_ONCE).and_then(|i| i.strip_prefix(':')) {
|
||||
let package = i.parse().ok().and_then(|i: usize| self.blocked.get(i));
|
||||
if let Some(package) = package.cloned() {
|
||||
let allowed = self.allowed.0.clone();
|
||||
std::thread::spawn(move || allow_once(package, &allowed));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let state = self.state;
|
||||
let screening = state == Some(ServiceState::Running);
|
||||
let action: fn() -> Result<(), BoxError> = match id {
|
||||
@@ -143,6 +276,62 @@ impl Tray {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the user chose to allow it once.
|
||||
fn alert(package: &BlockedPackage) -> bool {
|
||||
let text = format!("{} {}.", package.label(), package.reason);
|
||||
let (title, text, style) = if package.malicious() {
|
||||
let title = format!("Blocked malware: {}", package.label());
|
||||
let text = text
|
||||
+ "\n\nIt was not installed. Don't install it from another source. If you didn't expect this "
|
||||
+ "package, a dependency may be compromised; tell your security team.";
|
||||
(title, text, MB_ICONERROR)
|
||||
} else if !package.trustable() {
|
||||
("Held back a new package".to_owned(), text, MB_ICONINFORMATION)
|
||||
} else {
|
||||
let text = text + "\n\nAllow it once?";
|
||||
("Held back a new package".to_owned(), text, MB_YESNO | MB_ICONINFORMATION)
|
||||
};
|
||||
let (title, text) = (HSTRING::from(title), HSTRING::from(text));
|
||||
unsafe { MessageBoxW(None, &text, &title, style | MB_SETFOREGROUND) == IDYES }
|
||||
}
|
||||
|
||||
/// Tells the user when the install had already stopped waiting.
|
||||
fn allow_once(package: BlockedPackage, allowed: &Sender<BlockedPackage>) {
|
||||
let reply = match ask_service(&format!("{ALLOW_ONCE} {}", package.id)) {
|
||||
Ok(reply) => reply,
|
||||
Err(error) => return show_error(error),
|
||||
};
|
||||
match reply.as_str() {
|
||||
"ok" => {}
|
||||
"late" => {
|
||||
let title = format!("Allowed {} for the next minute", package.label());
|
||||
let text = "The install had already stopped waiting for it. Run it again to finish.";
|
||||
let (title, text) = (HSTRING::from(title), HSTRING::from(text));
|
||||
unsafe { MessageBoxW(None, &text, &title, MB_ICONINFORMATION | MB_SETFOREGROUND) };
|
||||
}
|
||||
other => return show_error(other.into()),
|
||||
}
|
||||
let _ = allowed.send(package);
|
||||
}
|
||||
|
||||
/// One request per connection, as the service answers.
|
||||
fn ask_service(request: &str) -> Result<String, BoxError> {
|
||||
let mut pipe = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(PIPE)?;
|
||||
// Any program can create the pipe if it gets there before the service.
|
||||
let mut server = 0;
|
||||
unsafe { GetNamedPipeServerProcessId(HANDLE(pipe.as_raw_handle()), &mut server)? };
|
||||
if service_status().and_then(|status| status.process_id) != Some(server) {
|
||||
return Err("Another program is posing as the firewall service.".into());
|
||||
}
|
||||
pipe.write_all(format!("{request}\n").as_bytes())?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new(pipe).read_line(&mut reply)?;
|
||||
Ok(reply.trim_end().to_owned())
|
||||
}
|
||||
|
||||
fn show_error(error: BoxError) {
|
||||
let text = match error.source() {
|
||||
Some(source) => format!("{error}: {source}"),
|
||||
@@ -153,13 +342,13 @@ fn show_error(error: BoxError) {
|
||||
}
|
||||
|
||||
/// `None` when the service is not installed.
|
||||
fn service_state() -> Option<ServiceState> {
|
||||
fn service_status() -> Option<ServiceStatus> {
|
||||
let manager =
|
||||
ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT).ok()?;
|
||||
let service = manager
|
||||
.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS)
|
||||
.ok()?;
|
||||
Some(service.query_status().ok()?.current_state)
|
||||
service.query_status().ok()
|
||||
}
|
||||
|
||||
fn light_taskbar() -> bool {
|
||||
@@ -167,7 +356,8 @@ fn light_taskbar() -> bool {
|
||||
let Ok(key) = CURRENT_USER.open(personalize) else {
|
||||
return false;
|
||||
};
|
||||
key.get_u32("SystemUsesLightTheme").is_ok_and(|light| light == 1)
|
||||
key.get_u32("SystemUsesLightTheme")
|
||||
.is_ok_and(|light| light == 1)
|
||||
}
|
||||
|
||||
fn status_text(state: Option<ServiceState>) -> &'static str {
|
||||
|
||||
@@ -41,7 +41,9 @@ impl Filter for Osv {
|
||||
if hits.is_empty() {
|
||||
Verdict::Allow
|
||||
} else {
|
||||
Verdict::Deny(format!("is flagged malicious by OSV ({})", hits.join(", ")))
|
||||
let links: Vec<String> =
|
||||
hits.iter().map(|id| format!("{id} https://osv.dev/vulnerability/{id}")).collect();
|
||||
Verdict::Deny(format!("is flagged malicious by OSV: {}", links.join(", ")))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,12 +30,12 @@ enum BlockAlert {
|
||||
if package.malicious {
|
||||
alert.alertStyle = .critical
|
||||
alert.messageText = "Blocked malware: \(package.label) (\(package.eco))"
|
||||
alert.informativeText = "\(package.label) \(package.reason).\n\n"
|
||||
setText(alert, "\(package.label) \(package.reason).\n\n"
|
||||
+ "It was not installed. Don't install it from another source. "
|
||||
+ "If you didn't expect this package, a dependency may be compromised; tell your security team."
|
||||
+ "If you didn't expect this package, a dependency may be compromised; tell your security team.")
|
||||
} else {
|
||||
alert.messageText = "Held back a new package"
|
||||
alert.informativeText = "\(package.label) (\(package.eco)) \(package.reason)."
|
||||
setText(alert, "\(package.label) (\(package.eco)) \(package.reason).")
|
||||
if package.trustable {
|
||||
alert.addButton(withTitle: "Allow Once")
|
||||
alert.addButton(withTitle: "Trust this version")
|
||||
@@ -56,6 +56,22 @@ enum BlockAlert {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
/// An alert's own text is plain, so this text goes in a selectable field, where its links open on click.
|
||||
private static func setText(_ alert: NSAlert, _ text: String) {
|
||||
let linked = NSMutableAttributedString(string: text, attributes: [.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)])
|
||||
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
for match in detector.matches(in: text, range: NSRange(text.startIndex..., in: text)) {
|
||||
if let url = match.url { linked.addAttribute(.link, value: url, range: match.range) }
|
||||
}
|
||||
let field = NSTextField(wrappingLabelWithString: "")
|
||||
field.allowsEditingTextAttributes = true
|
||||
field.isSelectable = true
|
||||
field.attributedStringValue = linked
|
||||
field.preferredMaxLayoutWidth = 300
|
||||
field.frame.size = field.fittingSize
|
||||
alert.accessoryView = field
|
||||
}
|
||||
}
|
||||
|
||||
enum AdminAuthorization {
|
||||
|
||||
Reference in new issue
Block a user