Compare commits

..
Author SHA1 Message Date
ian-at-strix 55a41286e1 Reject config values the firewall cannot use (#44) 2026-10-22 18:08:34 -04:00
ian-at-strix ca7a899d02 Screen mirrors listed in the config file (#58) 2026-10-22 18:02:08 -04:00
Ian 0c1b39c351 Make the platforms doc easier to read 2026-10-22 17:39:21 -04:00
Ian 34be4bb352 Document Intel Macs and how to change machine-wide settings 2026-10-22 17:35:31 -04:00
Ian 5839e240ab Document that cargo install installs only the command 2026-10-22 17:35:31 -04:00
Ian b0580ca7ad Document the Windows tray and who can pause screening 2026-10-22 16:49:04 -04:00
Ian Graham Martinez d84c34706f Document the supported platforms
Which OS versions, architectures and package managers each mode supports,
and what machine-wide screening on Windows needs and does not cover.
2026-10-22 16:49:04 -04:00
31 changed files with 139 additions and 432 deletions

No files matched your search

+1 -8
View File
@@ -38,17 +38,14 @@ path = "src/main.rs"
[[bin]]
name = "firewall-service"
path = "src/bin/firewall-service.rs"
required-features = ["machine-wide"]
[[bin]]
name = "firewall-tray"
path = "src/bin/firewall-tray.rs"
required-features = ["machine-wide"]
[[bin]]
name = "firewall-uninstall"
path = "src/bin/firewall-uninstall.rs"
required-features = ["machine-wide"]
[[example]]
name = "proxy-test"
@@ -90,10 +87,6 @@ path = "tests/tpm.rs"
name = "managers"
path = "tests/managers.rs"
[features]
# Builds the machine-wide binaries: the Windows service and tray, and the macOS uninstaller.
machine-wide = []
[dependencies]
clap = "4.6.6"
directories = "6.0.0"
@@ -125,7 +118,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_System_Com", "Win32_System_Environment", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
windows-registry = "0.6.1"
windows-service = "0.8.0"
+1 -1
View File
@@ -8,7 +8,7 @@ pub static BUN: Manager = Manager {
("https_proxy", PROXY),
("HTTP_PROXY", PROXY),
("HTTPS_PROXY", PROXY),
("npm_config_registry", "http://{npm}/"),
("npm_config_registry", "http://registry.npmjs.org/"),
],
args: &[],
};
+1 -1
View File
@@ -14,7 +14,7 @@ pub static CARGO: Manager = Manager {
"--config",
"source.crates-io.replace-with=\"firewall\"",
"--config",
"source.firewall.registry=\"sparse+http://{cargo/index}/\"",
"source.firewall.registry=\"sparse+http://index.crates.io/\"",
],
}],
};
+1 -1
View File
@@ -6,6 +6,6 @@ pub static GEM: Manager = Manager {
// gem has no variable for the source list, and gem env and gem sources reject the flags.
args: &[Args {
when: &["install", "update", "fetch"],
flags: &["--clear-sources", "--source", "http://{gem}/"],
flags: &["--clear-sources", "--source", "http://rubygems.org/"],
}],
};
+1 -1
View File
@@ -6,7 +6,7 @@ pub static GO: Manager = Manager {
env: &[
("HTTP_PROXY", PROXY),
("http_proxy", PROXY),
("GOPROXY", "http://{go}"),
("GOPROXY", "http://proxy.golang.org"),
("GOSUMDB", "off"),
("GOFLAGS", "-mod=mod"),
],
+8 -26
View File
@@ -9,9 +9,6 @@ mod pipenv;
mod pnpm;
mod uv;
use crate::policies::registries::{host_of, BUILT_IN};
use crate::FirewallConfig;
pub use bun::BUN;
pub use cargo::CARGO;
pub use gem::GEM;
@@ -23,7 +20,7 @@ pub use pipenv::PIPENV;
pub use pnpm::PNPM;
pub use uv::UV;
pub const PYPI_SIMPLE: &str = "http://{pypi}/simple";
pub const PYPI_SIMPLE: &str = "http://pypi.org/simple";
pub const PROXY: &str = "{proxy}";
@@ -34,9 +31,7 @@ pub struct Args {
}
/// An https:// registry arrives as a CONNECT tunnel naming only the host, so each
/// manager also overrides its registry URL to http://. `{npm}` and the like name a
/// built-in registry, and expand to its first mirror, if any, without the scheme;
/// `{host:npm}` to just its host.
/// manager also overrides its registry URL to http://.
pub struct Manager {
pub command: &'static str,
pub env: &'static [(&'static str, &'static str)],
@@ -47,36 +42,23 @@ pub static MANAGERS: &[&Manager] =
&[&NPM, &PIP, &PIP3, &CARGO, &GO, &GEM, &UV, &PDM, &PIPENV, &PNPM, &BUN];
/// Every manager's variables, so whichever the command starts is pointed at the firewall.
pub fn variables(proxy: &str, config: &FirewallConfig) -> Vec<(String, String)> {
pub fn variables(proxy: &str) -> Vec<(String, String)> {
let env = MANAGERS.iter().flat_map(|manager| manager.env);
env.map(|(key, value)| (key.to_string(), expand(value, proxy, config))).collect()
env.map(|(key, value)| (key.to_string(), expand(value, proxy))).collect()
}
pub fn arguments(
proxy: &str,
config: &FirewallConfig,
manager: &Manager,
typed: &[String],
) -> Vec<String> {
pub fn arguments(proxy: &str, manager: &Manager, typed: &[String]) -> Vec<String> {
let mut out = Vec::new();
for args in manager.args {
let wanted =
args.when.is_empty() || typed.iter().any(|arg| args.when.contains(&arg.as_str()));
if wanted {
out.extend(args.flags.iter().map(|flag| expand(flag, proxy, config)));
out.extend(args.flags.iter().map(|flag| expand(flag, proxy)));
}
}
out
}
fn expand(value: &str, proxy: &str, config: &FirewallConfig) -> String {
let mut value = value.replace(PROXY, proxy);
for registry in BUILT_IN {
let mirror = config.registries.get(registry.name()).and_then(|mirrors| mirrors.first());
let upstream = mirror.map_or(registry.upstream, String::as_str);
let address = upstream.trim_start_matches("https://").trim_end_matches('/');
value = value.replace(&format!("{{{}}}", registry.name()), address);
value = value.replace(&format!("{{host:{}}}", registry.name()), host_of(upstream));
}
value
fn expand(value: &str, proxy: &str) -> String {
value.replace(PROXY, proxy)
}
+1 -1
View File
@@ -5,7 +5,7 @@ pub static NPM: Manager = Manager {
env: &[
("npm_config_proxy", PROXY),
("npm_config_https_proxy", PROXY),
("npm_config_registry", "http://{npm}/"),
("npm_config_registry", "http://registry.npmjs.org/"),
],
args: &[],
};
+1 -1
View File
@@ -4,7 +4,7 @@ use super::{Manager, PROXY, PYPI_SIMPLE};
const PIP_ENV: &[(&str, &str)] = &[
("PIP_PROXY", PROXY),
("PIP_INDEX_URL", PYPI_SIMPLE),
("PIP_TRUSTED_HOST", "{host:pypi} {host:pypi-files}"),
("PIP_TRUSTED_HOST", "pypi.org files.pythonhosted.org"),
("PIP_DISABLE_PIP_VERSION_CHECK", "1"),
];
+1 -1
View File
@@ -10,7 +10,7 @@ pub static PIPENV: Manager = Manager {
("https_proxy", PROXY),
("PIPENV_PYPI_MIRROR", PYPI_SIMPLE),
// pipenv installs through pip, which skips a plaintext index on a host it doesn't trust.
("PIP_TRUSTED_HOST", "{host:pypi} {host:pypi-files}"),
("PIP_TRUSTED_HOST", "pypi.org files.pythonhosted.org"),
],
args: &[],
};
+1 -1
View File
@@ -6,7 +6,7 @@ pub static PNPM: Manager = Manager {
env: &[
("pnpm_config_proxy", PROXY),
("pnpm_config_https_proxy", PROXY),
("pnpm_config_registry", "http://{npm}/"),
("pnpm_config_registry", "http://registry.npmjs.org/"),
],
args: &[],
};
+7 -21
View File
@@ -1,12 +1,9 @@
pub mod forward;
pub mod managers;
#[cfg(windows)]
mod windows;
pub use forward::spawn;
pub use managers::*;
use crate::FirewallConfig;
use std::io::{Error, ErrorKind};
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -22,12 +19,12 @@ impl Invocation {
}
/// What the program is run with: the typed arguments plus any its manager needs.
pub fn arguments(&self, proxy: &str, config: &FirewallConfig) -> Vec<String> {
pub fn arguments(&self, proxy: &str) -> Vec<String> {
let mut args = self.args.clone();
let Some(manager) = MANAGERS.iter().find(|manager| manager.command == self.program) else {
return args;
};
let ours = managers::arguments(proxy, config, manager, &args);
let ours = managers::arguments(proxy, manager, &args);
// Anything after `--` belongs to another program, so ours go before it.
let at = args.iter().position(|arg| arg == "--").unwrap_or(args.len());
args.splice(at..at, ours);
@@ -35,28 +32,17 @@ impl Invocation {
}
}
pub fn run(
port: u16,
config: &FirewallConfig,
invocation: &Invocation,
dir: Option<&Path>,
) -> std::io::Result<i32> {
pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::Result<i32> {
let proxy = crate::local_url(port, "");
let program = resolve(&invocation.program)?;
let mut command = Command::new(&program);
command.args(invocation.arguments(&proxy, config));
let mut command = Command::new(resolve(&invocation.program)?);
command.args(invocation.arguments(&proxy));
if let Some(dir) = dir {
command.current_dir(dir);
}
for (key, value) in managers::variables(&proxy, config) {
for (key, value) in managers::variables(&proxy) {
command.env(key, value);
}
#[cfg(windows)]
windows::prepare();
let status = command.status();
#[cfg(windows)]
let status = status.map_err(|error| windows::explain(error, &program));
Ok(status?.code().unwrap_or(1))
Ok(command.status()?.code().unwrap_or(1))
}
// Command::new only finds .exe on Windows; npm, pnpm and gem are .cmd shims found via PATHEXT.
-64
View File
@@ -1,64 +0,0 @@
use std::io::{Error, ErrorKind};
use std::path::Path;
use std::sync::Once;
use tracing::debug;
use windows::core::{BOOL, PCWSTR};
use windows::Win32::System::Console::{SetConsoleCtrlHandler, CTRL_BREAK_EVENT, CTRL_C_EVENT};
use windows::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
use windows::Win32::System::Threading::GetCurrentProcess;
/// Called before spawning, so the manager behaves as it would run from the shell directly.
/// Once per process: a caller may run several managers.
pub fn prepare() {
static PREPARED: Once = Once::new();
PREPARED.call_once(|| {
// The console sends Ctrl+C to the manager too. Exiting first would hand the prompt back
// while a .cmd shim still asks "Terminate batch job (Y/N)?", so wait for its exit instead.
// A handler, unlike ignoring the signal outright, is not inherited by the manager.
let _ = unsafe { SetConsoleCtrlHandler(Some(ignore_interrupt), true) };
if let Err(error) = kill_children_on_exit() {
debug!(%error, "the manager may outlive the firewall");
}
});
}
unsafe extern "system" fn ignore_interrupt(event: u32) -> BOOL {
(event == CTRL_C_EVENT || event == CTRL_BREAK_EVENT).into()
}
/// Joins a job that kills what is left in it once the firewall exits, even if killed itself.
/// The manager inherits the job when spawned, as do its own children.
fn kill_children_on_exit() -> windows::core::Result<()> {
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
// Never closed: the handle closing as the firewall exits is what kills the rest.
let job = unsafe { CreateJobObjectW(None, PCWSTR::null())? };
unsafe {
SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
(&raw const limits).cast(),
size_of_val(&limits) as u32,
)?
};
unsafe { AssignProcessToJobObject(job, GetCurrentProcess()) }
}
/// Rust refuses to pass a line break to a batch file, where it would cut the command short.
pub fn explain(error: Error, program: &Path) -> Error {
let batch = program
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat"));
if !batch || error.kind() != ErrorKind::InvalidInput {
return error;
}
let name = program.file_name().unwrap_or(program.as_os_str()).to_string_lossy();
Error::new(
ErrorKind::InvalidInput,
format!("{name} is a batch file, which cannot be passed an argument with a line break"),
)
}
-36
View File
@@ -1,36 +0,0 @@
use crate::policies::Package;
use serde::Serialize;
use std::sync::Mutex;
/// Bounded, so blocks pile up harmlessly while nothing collects them.
const KEPT: usize = 50;
static RECENT: Mutex<Vec<BlockedPackage>> = Mutex::new(Vec::new());
#[derive(Serialize)]
pub struct BlockedPackage {
pub eco: &'static str,
pub name: String,
pub version: Option<String>,
pub filter: &'static str,
pub reason: String,
}
pub fn record(eco: &'static str, pkg: &Package, filter: &'static str, reason: &str) {
let mut recent = RECENT.lock().unwrap_or_else(|e| e.into_inner());
if recent.len() == KEPT {
recent.remove(0);
}
recent.push(BlockedPackage {
eco,
name: pkg.name.clone(),
version: pkg.version.clone(),
filter,
reason: reason.to_owned(),
});
}
/// Hands each block over once.
pub fn take() -> Vec<BlockedPackage> {
std::mem::take(&mut *RECENT.lock().unwrap_or_else(|e| e.into_inner()))
}
+6 -34
View File
@@ -2,15 +2,15 @@ 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 serde_json::{Map, Value};
use std::collections::HashMap;
use std::io::{Error, ErrorKind};
use std::num::{NonZeroU16, NonZeroU64};
use std::path::{Path, PathBuf};
use std::path::PathBuf;
pub const DEFAULT_PORT: u16 = 8080;
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct FirewallConfig {
pub port: NonZeroU16,
@@ -19,14 +19,14 @@ pub struct FirewallConfig {
/// 0 turns the age check off.
pub min_age_hours: u64,
pub denylist: DenylistSource,
/// Packages exempt from the age check, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
/// Packages never screened, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
pub allow: HashMap<String, Vec<AllowedPackage>>,
pub telemetry: bool,
/// Mirrors laid out like a built-in registry, by its name: `{"npm": ["https://npm.example.com/"]}`.
pub registries: HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AllowedPackage {
pub name: String,
@@ -49,7 +49,7 @@ impl Default for FirewallConfig {
FirewallConfig {
port: NonZeroU16::new(DEFAULT_PORT).unwrap(),
fail_open: true,
min_age_hours: 24,
min_age_hours: 0,
denylist: DenylistSource::default(),
allow: HashMap::new(),
telemetry: true,
@@ -120,34 +120,6 @@ fn merge(base: &mut Map<String, Value>, top: Map<String, Value>) {
}
}
/// Adds an `allow` entry to the file, leaving the rest of it as written.
pub fn allow(path: &Path, eco: &str, name: &str, version: Option<&str>) -> std::io::Result<()> {
let invalid = |e: String| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display()));
let mut config = match std::fs::read_to_string(path) {
Ok(text) => serde_json::from_str(&text).map_err(|e| invalid(e.to_string()))?,
Err(e) if e.kind() == ErrorKind::NotFound => json!({}),
Err(e) => return Err(e),
};
if !config.is_object() {
return Err(invalid("not an object".into()));
}
let entry = json!({ "name": name, "version": version });
let entries = &mut config["allow"][eco];
if entries.is_null() {
*entries = Value::Array(Vec::new());
}
let Some(entries) = entries.as_array_mut() else {
return Err(invalid(format!("allow.{eco} is not a list")));
};
if !entries.contains(&entry) {
entries.push(entry);
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
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.
-1
View File
@@ -1,5 +1,4 @@
pub mod aliasing;
pub mod blocks;
pub mod config;
pub mod firewall;
pub mod http;
+2 -3
View File
@@ -73,14 +73,13 @@ fn serve() -> ! {
fn run(invocation: Invocation) -> ! {
telemetry::set_mode("wrapper");
let command = &invocation.program;
let config = load();
let port = match aliasing::spawn(0, config.clone()) {
let port = match aliasing::spawn(0, load()) {
Ok(port) => port,
Err(e) => fail("spawn", &format!("could not start the firewall: {e}")),
};
info!(url = %firewall::local_url(port, ""), "firewall listening");
let result = aliasing::run(port, &config, &invocation, None);
let result = aliasing::run(port, &invocation, None);
telemetry::flush();
match result {
Ok(code) => exit(code),
+3 -23
View File
@@ -4,8 +4,8 @@ mod uninstall;
pub use uninstall::uninstall;
use crate::config::{self, FirewallConfig};
use crate::{blocks, mitm, telemetry};
use crate::config::FirewallConfig;
use crate::{mitm, telemetry};
use crate::Firewall;
use rama::bytes::Bytes;
use rama::error::BoxError;
@@ -22,7 +22,6 @@ use rama::net::apple::networkextension::{transparent_proxy_ffi, NwTcpStream, Tcp
use rama::rt::Executor;
use rama::telemetry::tracing::apple::oslog::{OsLogLayer, Privacy};
use rama::{Layer, Service};
use serde::Deserialize;
use std::convert::Infallible;
use std::path::PathBuf;
use std::sync::Arc;
@@ -59,12 +58,7 @@ impl TransparentProxyHandler for Handler {
let message = String::from_utf8_lossy(&message).into_owned();
Some(Bytes::from(match message.as_str() {
"ca" => self.ca.pem.clone(),
"blocks" => serde_json::to_string(&blocks::take()).expect("blocks serialize"),
other => match other.strip_prefix("allow ").map(allow) {
Some(Ok(())) => "ok".to_owned(),
Some(Err(error)) => format!("error: {error}"),
None => format!("error: unknown message {other}"),
},
other => format!("error: unknown message {other}"),
}))
}
@@ -123,20 +117,6 @@ fn handler(exec: Executor) -> Result<Handler, BoxError> {
Ok(Handler { ca, hosts, service })
}
#[derive(Deserialize)]
struct AllowRequest {
eco: String,
name: String,
version: Option<String>,
}
/// Takes effect when the provider restarts, which rebuilds it from the config.
fn allow(request: &str) -> Result<(), BoxError> {
let request: AllowRequest = serde_json::from_str(request)?;
config::allow(CONFIG_FILE.as_ref(), &request.eco, &request.name, request.version.as_deref())?;
Ok(())
}
/// stderr is invisible in a system extension, so events go to the unified log:
/// `log stream --predicate 'subsystem == "com.usestrix.firewall"'`.
fn init(_config: Option<&TransparentProxyInitConfig>) -> bool {
+8 -23
View File
@@ -26,7 +26,6 @@ use std::net::{IpAddr, Ipv4Addr};
use std::sync::Arc;
use std::time::Duration;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use windows::Win32::Foundation::ERROR_GEN_FAILURE;
use windows_service::service::*;
use windows_service::service_control_handler::{
self, ServiceControlHandlerResult, ServiceStatusHandle,
@@ -74,12 +73,8 @@ fn run_until_stopped() -> Result<(), BoxError> {
| ServiceControl::Shutdown
| ServiceControl::Pause
| ServiceControl::Continue => {
// Answered once handled, so a failed pause fails the caller's ControlService.
let (reply, handled) = tokio::sync::oneshot::channel();
let _ = control_sender.send((control, reply));
handled
.blocking_recv()
.unwrap_or(ServiceControlHandlerResult::NoError)
let _ = control_sender.send(control);
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
@@ -94,26 +89,16 @@ fn run_until_stopped() -> Result<(), BoxError> {
}
};
// Only pausing removes our hosts entries; a stop or crash keeps them, failing closed.
while let Some((control, reply)) = control_receiver.recv().await {
while let Some(control) = control_receiver.recv().await {
let (hosts, state) = match control {
ServiceControl::Pause => (&[][..], ServiceState::Paused),
ServiceControl::Continue => (&hosts[..], ServiceState::Running),
_ => break,
};
let result = match write_hosts_entries(hosts) {
Ok(()) => {
set_status(&status, state, false)?;
ServiceControlHandlerResult::NoError
}
Err(error) => {
tracing::error!(%error, "could not rewrite the hosts file");
let code = error
.raw_os_error()
.map_or(ERROR_GEN_FAILURE.0, |code| code as u32);
ServiceControlHandlerResult::Other(code)
}
};
let _ = reply.send(result);
match write_hosts_entries(hosts) {
Ok(()) => set_status(&status, state, false)?,
Err(error) => tracing::error!(%error, "could not rewrite the hosts file"),
}
}
Ok::<_, BoxError>(())
});
@@ -162,7 +147,7 @@ async fn start_screening(status: &ServiceStatusHandle) -> Result<Vec<&'static st
resolver: resolver.clone(),
service: service.clone(),
};
let upstream = ConsumeErrLayer::trace_as_warning().into_layer(upstream);
let upstream = ConsumeErrLayer::trace_as_debug().into_layer(upstream);
tokio::spawn(listen_on(address, port, &exec)?.serve(upstream));
}
}
+1 -5
View File
@@ -144,11 +144,7 @@ impl Tray {
}
fn show_error(error: BoxError) {
let text = match error.source() {
Some(source) => format!("{error}: {source}"),
None => error.to_string(),
};
let text = HSTRING::from(text);
let text = HSTRING::from(error.to_string());
unsafe { MessageBoxW(None, &text, w!("Strix Firewall"), MB_ICONERROR) };
}
+3 -5
View File
@@ -25,18 +25,16 @@ 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)> {
let allowed = allowed(firewall, registry, pkg);
if allowed(firewall, registry, pkg) {
return None;
}
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))),
+1 -2
View File
@@ -69,8 +69,7 @@ pub async fn screen(url: &str, firewall: &Firewall) -> Option<String> {
};
info!(eco = registry.eco, pkg = %pkg.label(), %url, "screening");
match consult(firewall, registry, &pkg).await {
Some((filter, Verdict::Deny(reason))) => {
crate::blocks::record(registry.eco, &pkg, filter, &reason);
Some((_, Verdict::Deny(reason))) => {
Some(format!("{} {reason}", pkg.label()))
}
Some((filter, Verdict::Unknown(e))) if !firewall.config.fail_open => {
+5 -20
View File
@@ -76,14 +76,14 @@ async fn a_mirror_is_screened_like_the_registry_it_copies() {
}
#[tokio::test]
async fn an_allowed_version_is_still_checked_for_malware() {
// The unreachable denylist shows that it still runs.
async fn an_allowed_version_skips_the_filters() {
// An unreachable denylist would otherwise leave the package unchecked.
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 = 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"));
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());
}
#[tokio::test]
@@ -94,18 +94,3 @@ async fn failing_closed_blocks_what_could_not_be_checked() {
let blocked = screen(url, &firewall).await.unwrap_or_default();
assert!(blocked.contains("could not be checked"), "{blocked:?}");
}
#[test]
fn allowing_keeps_the_rest_of_the_file() {
let path = std::env::temp_dir().join(format!("firewall-allow-{}.json", std::process::id()));
std::fs::write(&path, r#"{"fail_open": false}"#).expect("write");
for _ in 0..2 {
firewall::config::allow(&path, "npm", "left-pad", Some("1.3.0")).expect("allow");
}
let text = std::fs::read_to_string(&path).expect("read");
std::fs::remove_file(&path).ok();
let config: FirewallConfig = serde_json::from_str(&text).expect("parses");
assert!(!config.fail_open);
assert_eq!(config.allow["npm"].len(), 1);
assert_eq!(config.allow["npm"][0].version.as_deref(), Some("1.3.0"));
}
+13 -26
View File
@@ -1,11 +1,11 @@
use firewall::aliasing::managers::*;
use firewall::aliasing::Invocation;
use firewall::{Firewall, FirewallConfig};
use firewall::Firewall;
#[test]
fn arguments_after_a_double_dash_reach_the_program() {
let typed = ["run", "--", "--release"].map(String::from).to_vec();
let args = Invocation::new("cargo".into(), typed).arguments("http://127.0.0.1:9000", &FirewallConfig::default());
let args = Invocation::new("cargo".into(), typed).arguments("http://127.0.0.1:9000");
assert_eq!(args.first().map(String::as_str), Some("run"));
assert_eq!(args[args.len() - 2..], ["--", "--release"]);
}
@@ -13,23 +13,12 @@ fn arguments_after_a_double_dash_reach_the_program() {
#[test]
fn recipes_expand_into_the_environment() {
let var = |name: &str, value: &str| (name.to_string(), value.to_string());
let config = FirewallConfig::default();
let npm = variables("http://127.0.0.1:9000", &config);
let npm = variables("http://127.0.0.1:9000");
assert!(npm.contains(&var("npm_config_proxy", "http://127.0.0.1:9000")));
assert!(npm.contains(&var("npm_config_registry", "http://registry.npmjs.org/")));
let gem = arguments("http://127.0.0.1:9000", &config, &GEM, &["install".to_string()]);
let gem = arguments("http://127.0.0.1:9000", &GEM, &["install".to_string()]);
assert_eq!(gem, ["--clear-sources", "--source", "http://rubygems.org/"]);
assert!(arguments("http://127.0.0.1:9000", &config, &GEM, &["sources".to_string()]).is_empty());
}
#[test]
fn a_mirror_replaces_the_registry_it_copies() {
let var = |name: &str, value: &str| (name.to_string(), value.to_string());
let config = r#"{"registries": {"npm": ["https://npm.example.com/"], "pypi": ["https://art.example.com/pypi/"]}}"#;
let env = variables("http://127.0.0.1:9000", &serde_json::from_str(config).expect("parses"));
assert!(env.contains(&var("npm_config_registry", "http://npm.example.com/")));
assert!(env.contains(&var("PIP_INDEX_URL", "http://art.example.com/pypi/simple")));
assert!(env.contains(&var("PIP_TRUSTED_HOST", "art.example.com files.pythonhosted.org")));
assert!(arguments("http://127.0.0.1:9000", &GEM, &["sources".to_string()]).is_empty());
}
#[test]
@@ -45,17 +34,15 @@ fn no_two_managers_set_a_variable_differently() {
#[tokio::test]
async fn every_overridden_registry_is_known() {
let config = FirewallConfig::default();
let hosts = Firewall::default().hosts();
let values = variables("", &config).into_iter().map(|(_, value)| value);
let flags = MANAGERS.iter().flat_map(|manager| {
let typed: Vec<String> = manager.args.iter().flat_map(|args| args.when).map(|arg| arg.to_string()).collect();
arguments("", &config, manager, &typed)
});
for value in values.chain(flags) {
for (_, after) in value.match_indices("http://").map(|(i, _)| value.split_at(i + 7)) {
let host = after.split(['/', '"']).next().unwrap_or(after);
assert!(hosts.contains(&host), "{value} points at {host}");
for manager in MANAGERS {
let values = manager.env.iter().map(|(_, value)| *value);
let flags = manager.args.iter().flat_map(|args| args.flags.iter().copied());
for value in values.chain(flags) {
for (_, after) in value.match_indices("http://").map(|(i, _)| value.split_at(i + 7)) {
let host = after.split(['/', '"']).next().unwrap_or(after);
assert!(hosts.contains(&host), "{} points at {host}", manager.command);
}
}
}
}
+2 -3
View File
@@ -6,11 +6,10 @@ use probe::Outcome;
fn main() -> std::io::Result<()> {
firewall::init_logging("info");
let config = FirewallConfig::load()?;
let port = aliasing::spawn(0, config.clone())?;
let port = aliasing::spawn(0, FirewallConfig::load()?)?;
println!("firewall proxy on {}\n", firewall::local_url(port, ""));
let results = probe::all(port, &config);
let results = probe::all(port);
println!();
let mut failed = 0;
for (name, outcome) in &results {
+4 -5
View File
@@ -1,5 +1,4 @@
use firewall::aliasing::{self, Invocation, Manager, CARGO, GEM, GO, NPM, PIP, PIP3, UV};
use firewall::FirewallConfig;
use std::path::PathBuf;
struct Probe {
@@ -30,14 +29,14 @@ static PROBES: &[Probe] = &[
},
];
pub fn all(port: u16, config: &FirewallConfig) -> Vec<(&'static str, Outcome)> {
PROBES.iter().map(|probe| (probe.manager.command, run(probe, port, config))).collect()
pub fn all(port: u16) -> Vec<(&'static str, Outcome)> {
PROBES.iter().map(|probe| (probe.manager.command, run(probe, port))).collect()
}
fn run(probe: &Probe, port: u16, config: &FirewallConfig) -> Outcome {
fn run(probe: &Probe, port: u16) -> Outcome {
let args = probe.args.iter().map(|arg| arg.to_string()).collect();
let dir = probe.setup.and_then(|prepare| prepare());
match aliasing::run(port, config, &Invocation::new(probe.manager.command.into(), args), dir.as_deref()) {
match aliasing::run(port, &Invocation::new(probe.manager.command.into(), args), dir.as_deref()) {
Ok(0) => Outcome::Ok,
Ok(_) => Outcome::Failed,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Outcome::Absent,
-1
View File
@@ -7,7 +7,6 @@ ci = "github"
installers = ["shell", "powershell", "homebrew", "msi"]
# core/wix/main.wxs is edited to install the service.
allow-dirty = ["msi"]
features = ["machine-wide"]
tap = "usestrix/homebrew-tap"
publish-jobs = ["homebrew", "./publish-crates"]
targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
+66
View File
@@ -0,0 +1,66 @@
# Platforms
## Requirements
| | Command prefix | Machine-wide |
|---|---|---|
| macOS | 13+, Apple silicon or Intel | Apple silicon, or an Intel Mac with a T2 chip |
| Windows | 10 or 11, x86-64 | A TPM 2.0, and an admin to install |
| Linux | glibc, x86-64 or arm64 | Not supported |
There are no musl, 32-bit or Windows on Arm builds.
`cargo install strix-firewall` installs only `firewall`. Add `--features machine-wide` for the
machine-wide binaries, which otherwise come with the macOS app and the Windows MSI.
## What is screened
npm, pnpm, bun, pip, pip3, pipenv, pdm, uv, cargo, go and gem, downloading from:
| Ecosystem | Hosts |
|---|---|
| npm | registry.npmjs.org |
| PyPI | pypi.org, files.pythonhosted.org |
| crates.io | index.crates.io, static.crates.io, crates.io |
| RubyGems | rubygems.org |
| Go | proxy.golang.org |
Machine-wide screening also covers mirrors listed in the config file, under the registry they
copy (`npm`, `pypi`, `pypi-files`, `cargo/index`, `cargo-dl`, `crates-api`, `gem` or `go`):
`"registries": {"npm": ["https://npm.example.com/"]}`. A mirror must lay out its paths like that
registry. `firewall <manager>` still sends managers to the public registries.
## How machine-wide screening works
Traffic to the hosts above is decrypted with a local CA that can only sign for those hosts. Its
private key never leaves the Secure Enclave (macOS) or the TPM (Windows).
- **macOS:** a system extension, which you approve in System Settings.
- **Windows:** a service, with a hosts-file entry pointing each registry host at it.
- Without a TPM, the service can't start, so the install fails.
- Any signed-in user can pause screening from the notification-area icon, until they resume
it or the service restarts.
- Clients that use an HTTP proxy or their own DNS skip the hosts file and aren't screened.
Nor is WSL.
- Each client has to trust the CA:
| Client | Trusts it through |
|---|---|
| npm, pnpm, bun | `NODE_EXTRA_CA_CERTS`, set to `%ProgramData%\Strix Firewall\ca.pem` |
| uv | `UV_SYSTEM_CERTS`, which uses the Windows certificate store |
| pip, cargo | The Windows certificate store |
| gem, go | Untested |
Tested on Windows 11 and Apple silicon only.
## Changing settings
Machine-wide screening reads an admin-only JSON file when it starts. After editing it, restart
screening. An invalid file stops screening from starting.
| OS | File | Restart from |
|---|---|---|
| macOS | `/Library/Application Support/Firewall/config.json` | The menu bar's Restart |
| Windows | `%ProgramData%\Strix Firewall\config.json` | The notification-area icon's Restart |
The `firewall` command reads `config.json` from the user's config folder instead.
-57
View File
@@ -1,57 +0,0 @@
import AppKit
import Security
struct BlockedPackage: Codable, Hashable, Identifiable {
let eco: String
let name: String
let version: String?
let filter: String
let reason: String
var id: String { "\(eco)/\(name)@\(version ?? "")" }
var label: String { version.map { "\(name) \($0)" } ?? name }
/// The denylist and OSV only flag packages known to be malicious; the age check only holds back new ones.
var malicious: Bool { filter != "age" }
/// Malware is never allowed, and a new package only by version.
var trustable: Bool { !malicious && version != nil }
}
@MainActor
enum BlockAlert {
/// Returns whether the user chose to trust the package.
static func show(_ package: BlockedPackage) -> Bool {
let alert = NSAlert()
alert.addButton(withTitle: "OK")
if package.malicious {
alert.alertStyle = .critical
alert.messageText = "Blocked malware: \(package.label) (\(package.eco))"
alert.informativeText = "\(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."
} else {
alert.messageText = "Held back a new package"
alert.informativeText = "\(package.label) (\(package.eco)) \(package.reason)."
if package.trustable { alert.addButton(withTitle: "Trust this version") }
}
NSApp.activate(ignoringOtherApps: true)
return alert.runModal() == .alertSecondButtonReturn
}
}
enum AdminAuthorization {
/// The allowlist applies to every user of the Mac, so changing it takes an admin, as `sudo` would.
static func confirm() -> Bool {
var authorization: AuthorizationRef?
guard AuthorizationCreate(nil, nil, [], &authorization) == errAuthorizationSuccess,
let authorization else { return false }
defer { AuthorizationFree(authorization, []) }
return "system.preferences".withCString { name in
var item = AuthorizationItem(name: name, valueLength: 0, value: nil, flags: 0)
return withUnsafeMutablePointer(to: &item) { item in
var rights = AuthorizationRights(count: 1, items: item)
let flags: AuthorizationFlags = [.interactionAllowed, .extendRights]
return AuthorizationCopyRights(authorization, &rights, nil, flags, nil) == errAuthorizationSuccess
}
}
}
}
-41
View File
@@ -29,13 +29,11 @@ enum FirewallError: Error, LocalizedError {
case extensionUnavailable
case screeningDidNotStart(NEVPNStatus)
case extensionStopped(String)
case extensionRefused(String)
var errorDescription: String? {
switch self {
case .extensionUnavailable: return "The extension is not running"
case let .screeningDidNotStart(status): return "Screening did not start (\(status.label))"
case let .extensionRefused(reply): return "The extension replied \(reply)"
case let .extensionStopped(reason):
return "The extension stopped: \(reason). Its log: log show --last 5m --predicate 'subsystem BEGINSWITH \"\(FirewallIDs.appBundleID)\"'"
}
@@ -53,7 +51,6 @@ final class FirewallModel: NSObject, ObservableObject {
@Published private(set) var completed = false
@Published private(set) var removed = false
@Published private(set) var screening = false
@Published private(set) var blocked: [BlockedPackage] = []
private let logger = Logger(subsystem: FirewallIDs.appBundleID, category: "installer")
private var pendingRequest: (request: OSSystemExtensionRequest, continuation: CheckedContinuation<Void, Error>)?
@@ -75,7 +72,6 @@ final class FirewallModel: NSObject, ObservableObject {
await refresh()
if CommandLine.arguments.contains("--install") { await install() }
}
Task { await watchBlocks() }
}
// MARK: - Flows
@@ -203,43 +199,6 @@ final class FirewallModel: NSObject, ObservableObject {
if let url = URL(string: pane) { NSWorkspace.shared.open(url) }
}
/// Allowing a version writes it into the extension's config, which a restart then loads.
func trust(_ package: BlockedPackage) async {
guard package.trustable else { return }
guard AdminAuthorization.confirm() else {
log("Trust cancelled for \(package.label)")
return
}
do {
var request = ["eco": package.eco, "name": package.name]
request["version"] = package.version
let json = String(decoding: try JSONSerialization.data(withJSONObject: request), as: UTF8.self)
let reply = try await providerMessage("allow \(json)")
guard reply == "ok" else { throw FirewallError.extensionRefused(reply) }
blocked.removeAll { $0 == package }
log("Trusted \(package.label) (\(package.eco))")
} catch {
log("Could not trust \(package.label): \(error.localizedDescription)")
return
}
await restart()
}
/// The extension cannot message the app, so the app asks it for blocks.
private func watchBlocks() async {
while true {
if screening, let reply = try? await providerMessage("blocks"),
let packages = try? JSONDecoder().decode([BlockedPackage].self, from: Data(reply.utf8)) {
for package in packages where !blocked.contains(package) {
blocked.append(package)
if BlockAlert.show(package) { await trust(package) }
}
blocked = Array(blocked.suffix(10))
}
try? await Task.sleep(nanoseconds: 2_000_000_000)
}
}
private func stage(_ step: ReferenceWritableKeyPath<FirewallModel, StepStatus>,
running: String,
work: @MainActor () async throws -> String) async -> Bool {
-19
View File
@@ -22,24 +22,5 @@ struct MenuBarView: View {
Task { await model.restart() }
}
.disabled(model.busy)
if !model.blocked.isEmpty {
Divider()
ForEach(model.blocked) { package in
if !package.malicious {
Menu("Held back \(package.label) (\(package.eco))") {
Text(package.reason)
Button("Trust this version") {
Task { await model.trust(package) }
}
.disabled(model.busy || !package.trustable)
}
} else {
Menu("Blocked \(package.label) (\(package.eco))") {
Text("Malicious: \(package.reason)")
}
}
}
}
}
}
+1 -1
View File
@@ -133,7 +133,7 @@ targets:
arm64|arm64e) triple=aarch64-apple-darwin ;;
x86_64) triple=x86_64-apple-darwin ;;
esac
cargo build --bin firewall-uninstall --features machine-wide --target $triple $([ "$CONFIGURATION" = Debug ] || echo --release)
cargo build --bin firewall-uninstall --target $triple $([ "$CONFIGURATION" = Debug ] || echo --release)
bins="$bins target/$triple/$CARGO_PROFILE/firewall-uninstall"
done
lipo -create $bins -output "$TARGET_BUILD_DIR/$EXECUTABLE_FOLDER_PATH/firewall-uninstall"