mirror of
https://github.com/usestrix/firewall.git
synced 2026-11-09 05:19:35 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac071c7cab | ||
|
|
6c69036db0 | ||
|
|
4fd3021760 | ||
|
|
d01ac3634b |
No files matched your search
+1
-1
@@ -125,7 +125,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_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"
|
||||
|
||||
|
||||
+54
-24
@@ -1,9 +1,11 @@
|
||||
use crate::policies::registries::{Registry, BUILT_IN};
|
||||
use crate::policies::registries::{host_of, BUILT_IN};
|
||||
use directories::ProjectDirs;
|
||||
use rama::net::uri::Uri;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Error, ErrorKind};
|
||||
use std::num::{NonZeroU16, NonZeroU64};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const DEFAULT_PORT: u16 = 8080;
|
||||
@@ -11,18 +13,17 @@ pub const DEFAULT_PORT: u16 = 8080;
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct FirewallConfig {
|
||||
pub port: u16,
|
||||
pub port: NonZeroU16,
|
||||
/// Allow a package when a filter could not check it.
|
||||
pub fail_open: bool,
|
||||
/// 0 turns the age check off.
|
||||
pub min_age_hours: u64,
|
||||
pub denylist: DenylistSource,
|
||||
/// Packages never screened, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
|
||||
/// Packages exempt from the age check, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
|
||||
pub allow: HashMap<String, Vec<AllowedPackage>>,
|
||||
pub telemetry: bool,
|
||||
/// Not describable in the file yet, so this is always the built-in table.
|
||||
#[serde(skip)]
|
||||
pub registries: Vec<&'static Registry>,
|
||||
/// Mirrors laid out like a built-in registry, by its name: `{"npm": ["https://npm.example.com/"]}`.
|
||||
pub registries: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -40,19 +41,19 @@ pub struct DenylistSource {
|
||||
pub base: String,
|
||||
/// Polled for a new commit, which re-fetches every list in use.
|
||||
pub revision: String,
|
||||
pub poll_secs: u64,
|
||||
pub poll_secs: NonZeroU64,
|
||||
}
|
||||
|
||||
impl Default for FirewallConfig {
|
||||
fn default() -> Self {
|
||||
FirewallConfig {
|
||||
port: DEFAULT_PORT,
|
||||
port: NonZeroU16::new(DEFAULT_PORT).unwrap(),
|
||||
fail_open: true,
|
||||
min_age_hours: 24,
|
||||
denylist: DenylistSource::default(),
|
||||
allow: HashMap::new(),
|
||||
telemetry: true,
|
||||
registries: BUILT_IN.to_vec(),
|
||||
registries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +64,7 @@ impl Default for DenylistSource {
|
||||
base: "https://raw.githubusercontent.com/usestrix/firewall-blocklist/main".into(),
|
||||
revision: "https://api.github.com/repos/usestrix/firewall-blocklist/commits/main"
|
||||
.into(),
|
||||
poll_secs: 10,
|
||||
poll_secs: NonZeroU64::new(10).unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +88,8 @@ impl FirewallConfig {
|
||||
};
|
||||
let invalid = |e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display()));
|
||||
let layer: Value = serde_json::from_str(&text).map_err(invalid)?;
|
||||
FirewallConfig::deserialize(&layer).map_err(invalid)?;
|
||||
let config = FirewallConfig::deserialize(&layer).map_err(invalid)?;
|
||||
check(&config).map_err(|e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display())))?;
|
||||
let Value::Object(layer) = layer else {
|
||||
unreachable!("a config parses only from an object");
|
||||
};
|
||||
@@ -97,19 +99,7 @@ impl FirewallConfig {
|
||||
}
|
||||
|
||||
pub fn local_url(&self, path: &str) -> String {
|
||||
crate::local_url(self.port, path)
|
||||
}
|
||||
|
||||
/// Without building a `Firewall`, which needs a runtime.
|
||||
pub fn hosts(&self) -> Vec<&'static str> {
|
||||
let mut hosts: Vec<&'static str> = Vec::new();
|
||||
for registry in &self.registries {
|
||||
let host = registry.host();
|
||||
if !hosts.contains(&host) {
|
||||
hosts.push(host);
|
||||
}
|
||||
}
|
||||
hosts
|
||||
crate::local_url(self.port.get(), path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,3 +147,43 @@ pub fn allow(path: &Path, eco: &str, name: &str, version: Option<&str>) -> std::
|
||||
}
|
||||
std::fs::write(path, serde_json::to_string_pretty(&config)?)
|
||||
}
|
||||
|
||||
/// What serde's types cannot say.
|
||||
fn check(config: &FirewallConfig) -> Result<(), String> {
|
||||
// `Duration::hours` panics past this.
|
||||
if config.min_age_hours > time::Duration::MAX.whole_hours() as u64 {
|
||||
return Err(format!("{} hours is too long", config.min_age_hours));
|
||||
}
|
||||
for eco in config.allow.keys() {
|
||||
if !BUILT_IN.iter().any(|r| r.eco == eco) {
|
||||
return Err(format!("unknown ecosystem {eco:?} in allow"));
|
||||
}
|
||||
}
|
||||
for url in [&config.denylist.base, &config.denylist.revision] {
|
||||
if parse_url(url, &["http", "https"]).is_none() {
|
||||
return Err(format!("{url:?} is not an http(s) URL"));
|
||||
}
|
||||
}
|
||||
for (name, mirrors) in &config.registries {
|
||||
if !BUILT_IN.iter().any(|r| r.name() == name) {
|
||||
return Err(format!("no built-in registry is named {name}"));
|
||||
}
|
||||
if let Some(url) = mirrors.iter().find(|url| !is_mirror(url)) {
|
||||
return Err(format!("{url}: a mirror is an https:// URL ending in /"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// No port or userinfo, as `host_of` is what gets intercepted.
|
||||
fn is_mirror(url: &str) -> bool {
|
||||
let Some(uri) = parse_url(url, &["https"]) else { return false };
|
||||
let bare_host = uri.host_str().is_some_and(|host| host == host_of(url));
|
||||
bare_host && uri.query().is_none() && uri.fragment().is_none() && url.ends_with('/')
|
||||
}
|
||||
|
||||
fn parse_url(url: &str, schemes: &[&str]) -> Option<Uri> {
|
||||
let uri = Uri::parse(url).ok()?;
|
||||
let has_host = uri.host_str().is_some_and(|host| !host.is_empty());
|
||||
(has_host && schemes.contains(&uri.scheme_str()?)).then_some(uri)
|
||||
}
|
||||
+28
-7
@@ -9,6 +9,7 @@ use std::collections::HashMap;
|
||||
|
||||
pub struct Firewall {
|
||||
pub config: FirewallConfig,
|
||||
registries: Vec<&'static Registry>,
|
||||
patterns: HashMap<&'static str, Vec<Regex>>,
|
||||
filters: Vec<Box<dyn Filter>>,
|
||||
}
|
||||
@@ -21,18 +22,19 @@ impl Default for Firewall {
|
||||
|
||||
impl Firewall {
|
||||
pub fn new(config: FirewallConfig) -> Self {
|
||||
let registries = with_mirrors(&config.registries);
|
||||
let mut patterns = HashMap::new();
|
||||
for registry in &config.registries {
|
||||
for registry in ®istries {
|
||||
let compiled = registry.patterns.iter().map(|p| Regex::new(p.re).expect(p.re));
|
||||
patterns.insert(registry.prefix, compiled.collect());
|
||||
}
|
||||
let ecosystems = config.registries.iter().map(|r| r.osv_name);
|
||||
let ecosystems = registries.iter().map(|r| r.osv_name);
|
||||
let denylist = Denylist::new(config.denylist.clone(), ecosystems);
|
||||
let mut filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)];
|
||||
if config.min_age_hours > 0 {
|
||||
filters.push(Box::new(Age::new(config.min_age_hours)));
|
||||
}
|
||||
Firewall { config, patterns, filters }
|
||||
Firewall { config, registries, patterns, filters }
|
||||
}
|
||||
|
||||
pub fn match_on<'a>(
|
||||
@@ -40,20 +42,27 @@ impl Firewall {
|
||||
url: &'a str,
|
||||
key: fn(&Registry) -> &'static str,
|
||||
) -> Option<(&'static Registry, &'a str)> {
|
||||
self.config.registries.iter().find_map(|r| url.strip_prefix(key(r)).map(|rest| (*r, rest)))
|
||||
self.registries.iter().find_map(|r| url.strip_prefix(key(r)).map(|rest| (*r, rest)))
|
||||
}
|
||||
|
||||
pub fn hosts(&self) -> Vec<&'static str> {
|
||||
self.config.hosts()
|
||||
let mut hosts: Vec<&'static str> = Vec::new();
|
||||
for registry in &self.registries {
|
||||
let host = registry.host();
|
||||
if !hosts.contains(&host) {
|
||||
hosts.push(host);
|
||||
}
|
||||
}
|
||||
hosts
|
||||
}
|
||||
|
||||
pub fn is_https_url(&self, url: &str) -> bool {
|
||||
let host = host_of(url);
|
||||
self.config.registries.iter().any(|r| r.upstream.starts_with("https://") && r.host() == host)
|
||||
self.registries.iter().any(|r| r.upstream.starts_with("https://") && r.host() == host)
|
||||
}
|
||||
|
||||
pub fn is_index_url(&self, url: &str) -> bool {
|
||||
self.config.registries.iter().any(|r| r.index && r.host() == host_of(url))
|
||||
self.registries.iter().any(|r| r.index && r.host() == host_of(url))
|
||||
}
|
||||
|
||||
pub fn resolve(&self, registry: &'static Registry, rest: &str) -> Resolved {
|
||||
@@ -64,3 +73,15 @@ impl Firewall {
|
||||
&self.filters
|
||||
}
|
||||
}
|
||||
|
||||
/// The built-ins, then a copy of one per mirror. Leaked, as a `Registry` is `'static` everywhere.
|
||||
fn with_mirrors(mirrors: &HashMap<String, Vec<String>>) -> Vec<&'static Registry> {
|
||||
let mut registries = BUILT_IN.to_vec();
|
||||
for built_in in BUILT_IN {
|
||||
for upstream in mirrors.get(built_in.name()).into_iter().flatten() {
|
||||
let upstream = upstream.clone().leak();
|
||||
registries.push(Box::leak(Box::new(Registry { upstream, ..(*built_in).clone() })));
|
||||
}
|
||||
}
|
||||
registries
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tracing::warn;
|
||||
use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
|
||||
use windows::Win32::Security::*;
|
||||
use windows::Win32::Storage::FileSystem::CreateDirectoryW;
|
||||
use windows_registry::Type;
|
||||
|
||||
/// SYSTEM and admins write, users only read, so no one else can plant a CA for node.
|
||||
@@ -65,25 +66,39 @@ pub fn load_or_create_ca(
|
||||
Ok(ca)
|
||||
}
|
||||
|
||||
/// Created on first use, its owner and ACL reset every time. One a user made first, or a link,
|
||||
/// Created on first use with its ACL, which is reset every time. One a user made first, or a link,
|
||||
/// is replaced: its owner could grant itself access again, and a link would redirect our writes.
|
||||
pub(super) fn secure_data_dir() -> io::Result<PathBuf> {
|
||||
let dir = data_dir();
|
||||
match std::fs::symlink_metadata(&dir) {
|
||||
Ok(meta) if meta.file_type().is_symlink() => std::fs::remove_dir(&dir)?,
|
||||
let exists = match std::fs::symlink_metadata(&dir) {
|
||||
Ok(meta) if meta.file_type().is_symlink() => {
|
||||
std::fs::remove_dir(&dir)?;
|
||||
false
|
||||
}
|
||||
Ok(_) if !owned_by_admins(&dir)? => {
|
||||
warn!(dir = %dir.display(), "replacing a data folder not created by an admin");
|
||||
std::fs::remove_dir_all(&dir)?
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
false
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
Ok(_) => true,
|
||||
Err(_) => false,
|
||||
};
|
||||
let info = OWNER_SECURITY_INFORMATION
|
||||
| DACL_SECURITY_INFORMATION
|
||||
| PROTECTED_DACL_SECURITY_INFORMATION;
|
||||
let path = HSTRING::from(dir.as_path());
|
||||
let acl = security_descriptor(DATA_DIR_ACL)?;
|
||||
unsafe { SetFileSecurityW(&path, info, PSECURITY_DESCRIPTOR(acl.0)).ok()? };
|
||||
if exists {
|
||||
unsafe { SetFileSecurityW(&path, info, PSECURITY_DESCRIPTOR(acl.0)).ok()? };
|
||||
} else {
|
||||
let attributes = SECURITY_ATTRIBUTES {
|
||||
nLength: size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: acl.0,
|
||||
bInheritHandle: false.into(),
|
||||
};
|
||||
// Never inherits ProgramData's ACL, which lets users add files; fails if it was recreated.
|
||||
unsafe { CreateDirectoryW(&path, Some(&attributes))? };
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,58 @@
|
||||
//! How the tray asks the service what it blocked, as the macOS app messages its extension.
|
||||
|
||||
use super::security_descriptor;
|
||||
use crate::blocks;
|
||||
use rama::error::BoxError;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions};
|
||||
use windows::Win32::Security::SECURITY_ATTRIBUTES;
|
||||
|
||||
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() -> Result<(), BoxError> {
|
||||
let mut server = create(true)?;
|
||||
loop {
|
||||
server.connect().await?;
|
||||
let client = std::mem::replace(&mut server, create(false)?);
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = answer(client).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) -> Result<(), BoxError> {
|
||||
let mut client = BufReader::new(client);
|
||||
let mut request = String::new();
|
||||
(&mut client)
|
||||
.take(64 * 1024)
|
||||
.read_line(&mut request)
|
||||
.await?;
|
||||
let reply = match request.trim_end() {
|
||||
"blocks" => serde_json::to_string(&blocks::take())?,
|
||||
other => format!("error: unknown request {other}"),
|
||||
};
|
||||
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(())
|
||||
}
|
||||
@@ -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,12 @@ 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));
|
||||
|
||||
tokio::spawn(async {
|
||||
if let Err(error) = pipe::serve().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] {
|
||||
|
||||
+136
-8
@@ -1,17 +1,21 @@
|
||||
//! 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::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use serde::Deserialize;
|
||||
use std::io::{BufRead, BufReader, Cursor, Write};
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
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";
|
||||
@@ -19,6 +23,9 @@ const MENU_RESTART: &str = "restart";
|
||||
const MENU_LOG: &str = "log";
|
||||
const MENU_QUIT: &str = "quit";
|
||||
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 +36,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 +47,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,6 +59,9 @@ pub fn run() -> Result<(), BoxError> {
|
||||
status,
|
||||
pause,
|
||||
state: None,
|
||||
menu,
|
||||
blocked_separator,
|
||||
blocked: Vec::new(),
|
||||
};
|
||||
tray.update();
|
||||
unsafe { SetTimer(None, 0, 2000, None) };
|
||||
@@ -62,6 +75,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 +108,39 @@ 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>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Deserialize)]
|
||||
struct BlockedPackage {
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
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,6 +159,58 @@ 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) {
|
||||
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.
|
||||
std::thread::spawn(move || new.iter().for_each(alert));
|
||||
}
|
||||
|
||||
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 label = format!("{verb} {}", package.label());
|
||||
if let Ok(item) = Submenu::with_items(label, true, &[&reason]) {
|
||||
let _ = self.menu.insert(&item, BLOCKED_AT + 1 + i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_menu(&self, id: &str) {
|
||||
let state = self.state;
|
||||
let screening = state == Some(ServiceState::Running);
|
||||
@@ -143,6 +237,39 @@ impl Tray {
|
||||
}
|
||||
}
|
||||
|
||||
fn alert(package: &BlockedPackage) {
|
||||
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 {
|
||||
("Held back a new package".to_owned(), text, MB_ICONINFORMATION)
|
||||
};
|
||||
let (title, text) = (HSTRING::from(title), HSTRING::from(text));
|
||||
unsafe { MessageBoxW(None, &text, &title, style | MB_SETFOREGROUND) };
|
||||
}
|
||||
|
||||
/// 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 +280,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 +294,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 {
|
||||
|
||||
@@ -143,7 +143,7 @@ pub fn describe(advisories: &[&Advisory]) -> String {
|
||||
|
||||
/// A failed fetch leaves the last good list in place; a list that never loaded is retried every tick.
|
||||
async fn watch(source: DenylistSource, lists: Arc<Lists>) {
|
||||
let period = Duration::from_secs(source.poll_secs);
|
||||
let period = Duration::from_secs(source.poll_secs.get());
|
||||
let mut seen = revision(&source, None).await;
|
||||
loop {
|
||||
tokio::time::sleep(period).await;
|
||||
|
||||
@@ -25,16 +25,18 @@ pub trait Filter: Send + Sync {
|
||||
}
|
||||
|
||||
/// Asks every filter. A `Deny` wins; failing that, the first filter that could not tell.
|
||||
/// An allowed package skips only the age check, so malware is never let through.
|
||||
pub async fn consult(
|
||||
firewall: &Firewall,
|
||||
registry: &'static Registry,
|
||||
pkg: &Package,
|
||||
) -> Option<(&'static str, Verdict)> {
|
||||
if allowed(firewall, registry, pkg) {
|
||||
return None;
|
||||
}
|
||||
let allowed = allowed(firewall, registry, pkg);
|
||||
let mut unsure = None;
|
||||
for filter in firewall.filters() {
|
||||
if allowed && filter.name() == "age" {
|
||||
continue;
|
||||
}
|
||||
match filter.check(registry, pkg).await {
|
||||
Verdict::Allow => continue,
|
||||
Verdict::Deny(reason) => return Some((filter.name(), Verdict::Deny(reason))),
|
||||
|
||||
@@ -36,7 +36,7 @@ pub struct Pattern {
|
||||
}
|
||||
|
||||
/// Requests to the local server match on `prefix`; intercepted URLs match on `upstream`.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Registry {
|
||||
pub prefix: &'static str,
|
||||
pub eco: &'static str,
|
||||
@@ -52,7 +52,7 @@ pub struct Registry {
|
||||
}
|
||||
|
||||
/// A URL to fetch and a JSON pointer to the timestamp in its reply.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Published {
|
||||
pub url: &'static str,
|
||||
pub pointer: &'static str,
|
||||
@@ -63,6 +63,11 @@ impl Registry {
|
||||
pub fn host(&self) -> &'static str {
|
||||
host_of(self.upstream)
|
||||
}
|
||||
|
||||
/// What the config file calls it.
|
||||
pub fn name(&self) -> &'static str {
|
||||
self.prefix.trim_matches('/')
|
||||
}
|
||||
}
|
||||
|
||||
/// The host of an https:// URL, with the path dropped.
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::sync::Arc;
|
||||
use tracing::{info, info_span, warn, Instrument};
|
||||
|
||||
pub fn serve(config: FirewallConfig) -> std::io::Result<()> {
|
||||
let bound = std::net::TcpListener::bind(("127.0.0.1", config.port))?;
|
||||
let bound = std::net::TcpListener::bind(("127.0.0.1", config.port.get()))?;
|
||||
bound.set_nonblocking(true)?;
|
||||
crate::runtime()?.block_on(async move {
|
||||
let firewall = Arc::new(Firewall::new(config));
|
||||
|
||||
+43
-6
@@ -7,7 +7,7 @@ use firewall::{Firewall, FirewallConfig};
|
||||
fn a_file_only_has_to_set_what_it_changes() {
|
||||
let config: FirewallConfig = serde_json::from_str(r#"{"fail_open": false}"#).expect("parses");
|
||||
assert!(!config.fail_open);
|
||||
assert_eq!(config.port, firewall::DEFAULT_PORT);
|
||||
assert_eq!(config.port.get(), firewall::DEFAULT_PORT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -15,6 +15,28 @@ fn a_misspelled_setting_is_an_error() {
|
||||
assert!(serde_json::from_str::<FirewallConfig>(r#"{"fail-open": false}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonsense_values_are_errors() {
|
||||
let file = std::env::temp_dir().join(format!("firewall-nonsense-{}.json", std::process::id()));
|
||||
for bad in [
|
||||
r#"{"allow": {"nmp": [{"name": "left-pad"}]}}"#,
|
||||
r#"{"denylist": {"poll_secs": 0}}"#,
|
||||
r#"{"denylist": {"base": "raw.githubusercontent.com/usestrix"}}"#,
|
||||
r#"{"denylist": {"revision": "https://"}}"#,
|
||||
r#"{"port": 0}"#,
|
||||
r#"{"min_age_hours": 18446744073709551615}"#,
|
||||
r#"{"registries": {"npm": ["http://npm.example.com/"]}}"#,
|
||||
r#"{"registries": {"npm": ["https://npm.example.com"]}}"#,
|
||||
r#"{"registries": {"npm": ["https://npm.example.com:8443/"]}}"#,
|
||||
r#"{"registries": {"npm": ["https://[email protected]/"]}}"#,
|
||||
r#"{"registries": {"npm": ["https://npm.example.com/?a=/"]}}"#,
|
||||
] {
|
||||
std::fs::write(&file, bad).expect("write");
|
||||
assert!(FirewallConfig::load_from(std::slice::from_ref(&file)).is_err(), "{bad}");
|
||||
}
|
||||
std::fs::remove_file(&file).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_config_lives_under_the_config_directory() {
|
||||
let path = path().expect("a home directory");
|
||||
@@ -39,14 +61,29 @@ fn a_later_file_wins_and_allow_lists_add_up() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_allowed_version_skips_the_filters() {
|
||||
// An unreachable denylist would otherwise leave the package unchecked.
|
||||
async fn a_mirror_is_screened_like_the_registry_it_copies() {
|
||||
let config = r#"{"registries": {"npm": ["https://npm.example.com/"]}}"#;
|
||||
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
|
||||
assert!(firewall.hosts().contains(&"npm.example.com"));
|
||||
let url = "https://npm.example.com/left-pad/-/left-pad-1.3.0.tgz";
|
||||
let (registry, rest) = firewall.match_on(url, |r| r.upstream).expect("the mirror");
|
||||
assert_eq!(firewall.resolve(registry, rest).pkg.map(|p| p.label()), Some("[email protected]".into()));
|
||||
let unknown = std::env::temp_dir().join(format!("firewall-mirror-{}.json", std::process::id()));
|
||||
std::fs::write(&unknown, r#"{"registries": {"nmp": ["https://npm.example.com/"]}}"#).expect("write");
|
||||
let loaded = FirewallConfig::load_from(std::slice::from_ref(&unknown));
|
||||
std::fs::remove_file(&unknown).ok();
|
||||
assert!(loaded.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_allowed_version_is_still_checked_for_malware() {
|
||||
// The unreachable denylist shows that it still runs.
|
||||
let config = r#"{"allow": {"go": [{"name": "golang.org/x/net", "version": "v0.20.0"}]}, "denylist": {"base": "http://127.0.0.1:1"}}"#;
|
||||
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
|
||||
let (registry, _) = firewall.match_on("/go/golang.org/x/net/@v/list", |r| r.prefix).expect("go");
|
||||
let pkg = |version: &str| Package { name: "golang.org/x/net".into(), version: Some(version.into()) };
|
||||
assert!(consult(&firewall, registry, &pkg("0.20.0")).await.is_none());
|
||||
assert!(consult(&firewall, registry, &pkg("0.21.0")).await.is_some());
|
||||
let pkg = Package { name: "golang.org/x/net".into(), version: Some("0.20.0".into()) };
|
||||
let verdict = consult(&firewall, registry, &pkg).await;
|
||||
assert_eq!(verdict.map(|(filter, _)| filter), Some("denylist"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in new issue
Block a user