Compare commits

..
Author SHA1 Message Date
Ian ac071c7cab Show the Windows service's blocks from the tray
The service answers the tray's requests on a named pipe, as the macOS extension answers the app's messages. The tray asks for blocks every two seconds, pops each new one up and lists the last ten in its menu.
2026-10-23 10:46:10 -04:00
12 changed files with 74 additions and 264 deletions

No files matched your search

-4
View File
@@ -90,10 +90,6 @@ path = "tests/tpm.rs"
name = "managers"
path = "tests/managers.rs"
[[test]]
name = "blocks"
path = "tests/blocks.rs"
[features]
# Builds the machine-wide binaries: the Windows service and tray, and the macOS uninstaller.
machine-wide = []
+13 -134
View File
@@ -1,157 +1,36 @@
use crate::config::{self, AllowedPackage};
use crate::policies::Package;
use crate::Firewall;
use rama::error::BoxError;
use serde::Serialize;
use std::path::Path;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tokio::sync::watch;
/// So blocks can't pile up while no app collects them.
/// Bounded, so blocks pile up harmlessly while nothing collects them.
const KEPT: usize = 50;
/// Under pip's, pipenv's and pdm's 15 s read timeout, the shortest of the supported managers.
const HOLD: Duration = Duration::from_secs(10);
/// Only hold while an app is asking for blocks, so someone can answer.
const WATCHED: Duration = Duration::from_secs(2);
/// For a minute, so the rest of that install can fetch it too.
const ONCE: Duration = Duration::from_secs(60);
#[derive(Clone, Serialize)]
static RECENT: Mutex<Vec<BlockedPackage>> = Mutex::new(Vec::new());
#[derive(Serialize)]
pub struct BlockedPackage {
pub id: u64,
pub eco: &'static str,
pub name: String,
pub version: Option<String>,
pub filter: &'static str,
pub reason: String,
/// Flagged as malware, not just held back for being new.
pub malicious: bool,
/// Malware is never allowed, and a new package only by version. Saves each app the rule.
pub trustable: bool,
}
struct Blocks {
last_id: u64,
unsent: Vec<BlockedPackage>,
/// Age holds, the only blocks that can be allowed, each with what wakes its waiting downloads.
held: Vec<(BlockedPackage, watch::Sender<bool>)>,
polled: Option<Instant>,
}
static BLOCKS: Mutex<Blocks> =
Mutex::new(Blocks { last_id: 0, unsent: Vec::new(), held: Vec::new(), polled: None });
/// Records a block and holds a new package's download for up to `HOLD`. Returns whether it was allowed.
pub async fn block(eco: &'static str, pkg: &Package, filter: &'static str, reason: &str) -> bool {
let Some(mut answer) = record(eco, pkg, filter, reason) else {
return false;
};
let allowed = tokio::time::timeout(HOLD, answer.wait_for(|allowed| *allowed)).await;
matches!(allowed, Ok(Ok(_)))
}
/// The app's requests: `blocks`, `allow <id>` or `allow-once <id>`.
/// An allow replies `late` if the download had stopped waiting.
pub fn answer(firewall: &Firewall, config: &Path, request: &str) -> String {
if request == "blocks" {
return serde_json::to_string(&take()).expect("blocks serialize");
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);
}
let result = match request.split_once(' ').map(|(verb, id)| (verb, id.parse())) {
Some(("allow", Ok(id))) => trust(firewall, config, id),
Some(("allow-once", Ok(id))) => allow_once(firewall, id),
_ => Err(format!("unknown message {request}").into()),
};
match result {
Ok(true) => "ok".to_owned(),
Ok(false) => "late".to_owned(),
Err(error) => format!("error: {error}"),
}
}
/// Hands each block over once.
fn take() -> Vec<BlockedPackage> {
let mut blocks = lock();
blocks.polled = Some(Instant::now());
std::mem::take(&mut blocks.unsent)
}
fn record(
eco: &'static str,
pkg: &Package,
filter: &'static str,
reason: &str,
) -> Option<watch::Receiver<bool>> {
let mut blocks = lock();
let watched = blocks.polled.is_some_and(|polled| polled.elapsed() < WATCHED);
let holds = filter == "age" && watched;
// A repeat fetch of a held package waits on the same answer.
let waiting = blocks.held.iter().find(|(held, wake)| {
wake.receiver_count() > 0 && held.eco == eco && held.name == pkg.name && held.version == pkg.version
});
if let (true, Some((_, wake))) = (holds, waiting) {
return Some(wake.subscribe());
}
blocks.last_id += 1;
let block = BlockedPackage {
id: blocks.last_id,
recent.push(BlockedPackage {
eco,
name: pkg.name.clone(),
version: pkg.version.clone(),
filter,
reason: reason.to_owned(),
malicious: filter != "age",
trustable: filter == "age" && pkg.version.is_some(),
};
push(&mut blocks.unsent, block.clone());
if block.malicious {
return None;
}
let (wake, allowed) = watch::channel(false);
push(&mut blocks.held, (block, wake));
holds.then_some(allowed)
});
}
/// Also saved to the config, so it survives a restart.
fn trust(firewall: &Firewall, config: &Path, id: u64) -> Result<bool, BoxError> {
let block = held(id)?;
let version = block.version.ok_or("only a version can be trusted")?;
config::allow(config, block.eco, &block.name, &version)?;
let package = AllowedPackage { name: block.name, version: Some(version) };
firewall.lift_age_check(block.eco, package, None);
Ok(release(id))
}
fn allow_once(firewall: &Firewall, id: u64) -> Result<bool, BoxError> {
let block = held(id)?;
let package = AllowedPackage { name: block.name, version: block.version };
firewall.lift_age_check(block.eco, package, Some(Instant::now() + ONCE));
Ok(release(id))
}
fn held(id: u64) -> Result<BlockedPackage, BoxError> {
let blocks = lock();
let held = blocks.held.iter().find(|(held, _)| held.id == id);
Ok(held.ok_or(format!("no new package was held back as {id}"))?.0.clone())
}
/// Returns whether a download was still waiting.
fn release(id: u64) -> bool {
let blocks = lock();
let Some((_, wake)) = blocks.held.iter().find(|(held, _)| held.id == id) else {
return false;
};
wake.send_replace(true);
wake.receiver_count() > 0
}
fn push<T>(list: &mut Vec<T>, item: T) {
if list.len() == KEPT {
list.remove(0);
}
list.push(item);
}
fn lock() -> std::sync::MutexGuard<'static, Blocks> {
BLOCKS.lock().unwrap_or_else(|e| e.into_inner())
/// Hands each block over once.
pub fn take() -> Vec<BlockedPackage> {
std::mem::take(&mut *RECENT.lock().unwrap_or_else(|e| e.into_inner()))
}
+1 -1
View File
@@ -121,7 +121,7 @@ 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: &str) -> std::io::Result<()> {
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()))?,
+2 -19
View File
@@ -1,4 +1,4 @@
use crate::config::{AllowedPackage, FirewallConfig};
use crate::config::FirewallConfig;
use crate::policies::filters::age::Age;
use crate::policies::filters::denylist::Denylist;
use crate::policies::filters::osv::Osv;
@@ -6,16 +6,12 @@ use crate::policies::filters::Filter;
use crate::policies::registries::*;
use regex::Regex;
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Instant;
pub struct Firewall {
pub config: FirewallConfig,
registries: Vec<&'static Registry>,
patterns: HashMap<&'static str, Vec<Regex>>,
filters: Vec<Box<dyn Filter>>,
/// Each lift expires at its time, or lasts until the firewall stops. Never saved.
age_lifted: Mutex<Vec<(String, AllowedPackage, Option<Instant>)>>,
}
impl Default for Firewall {
@@ -38,7 +34,7 @@ impl Firewall {
if config.min_age_hours > 0 {
filters.push(Box::new(Age::new(config.min_age_hours)));
}
Firewall { config, registries, patterns, filters, age_lifted: Mutex::default() }
Firewall { config, registries, patterns, filters }
}
pub fn match_on<'a>(
@@ -76,19 +72,6 @@ impl Firewall {
pub fn filters(&self) -> &[Box<dyn Filter>] {
&self.filters
}
pub fn lift_age_check(&self, eco: &str, package: AllowedPackage, until: Option<Instant>) {
let mut lifted = self.age_lifted.lock().unwrap_or_else(|e| e.into_inner());
lifted.retain(|(_, _, until)| until.is_none_or(|until| until > Instant::now()));
lifted.push((eco.to_owned(), package, until));
}
pub fn age_check_lifted(&self, eco: &str, matches: impl Fn(&AllowedPackage) -> bool) -> bool {
let lifted = self.age_lifted.lock().unwrap_or_else(|e| e.into_inner());
lifted.iter().any(|(e, package, until)| {
e == eco && until.is_none_or(|until| until > Instant::now()) && matches(package)
})
}
}
/// The built-ins, then a copy of one per mirror. Leaked, as a `Registry` is `'static` everywhere.
+24 -5
View File
@@ -4,7 +4,7 @@ mod uninstall;
pub use uninstall::uninstall;
use crate::config::FirewallConfig;
use crate::config::{self, FirewallConfig};
use crate::{blocks, mitm, telemetry};
use crate::Firewall;
use rama::bytes::Bytes;
@@ -22,6 +22,7 @@ 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;
@@ -39,7 +40,6 @@ const CONFIG_FILE: &str = "/Library/Application Support/Firewall/config.json";
struct Handler {
ca: Arc<mitm::CertificateAuthority>,
hosts: Vec<&'static str>,
firewall: Arc<Firewall>,
service: mitm::MitmService,
}
@@ -59,7 +59,12 @@ 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(),
other => blocks::answer(&self.firewall, CONFIG_FILE.as_ref(), other),
"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}"),
},
}))
}
@@ -114,8 +119,22 @@ fn handler(exec: Executor) -> Result<Handler, BoxError> {
let signer: mitm::CaSigner = Arc::new(key);
let ca = install::certificate_authority(&hosts, &*signer);
let ca = Arc::new(ca.inspect_err(|_| telemetry::error("ca"))?);
let service = mitm::mitm_service(firewall.clone(), &ca, signer, None, exec);
Ok(Handler { ca, hosts, firewall, service })
let service = mitm::mitm_service(firewall, &ca, signer, None, exec);
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:
+11 -15
View File
@@ -3,7 +3,6 @@ pub mod denylist;
pub mod osv;
use super::registries::{Package, Registry};
use crate::config::AllowedPackage;
use crate::Firewall;
use std::future::Future;
use std::pin::Pin;
@@ -32,11 +31,10 @@ pub async fn consult(
registry: &'static Registry,
pkg: &Package,
) -> Option<(&'static str, Verdict)> {
let lifted = allowed(firewall, registry, pkg)
|| firewall.age_check_lifted(registry.eco, |entry| matches(registry, pkg, entry));
let allowed = allowed(firewall, registry, pkg);
let mut unsure = None;
for filter in firewall.filters() {
if lifted && filter.name() == "age" {
if allowed && filter.name() == "age" {
continue;
}
match filter.check(registry, pkg).await {
@@ -53,15 +51,13 @@ fn allowed(firewall: &Firewall, registry: &Registry, pkg: &Package) -> bool {
let Some(entries) = firewall.config.allow.get(registry.eco) else {
return false;
};
entries.iter().any(|entry| matches(registry, pkg, entry))
}
fn matches(registry: &Registry, pkg: &Package, entry: &AllowedPackage) -> bool {
let name_matches = match registry.normalize {
Some(normalize) => normalize(&entry.name) == pkg.name,
None => entry.name == pkg.name,
};
// Go writes a leading v that the parsed version drops, and PEP 440 ignores one.
let version = entry.version.as_deref().map(|v| v.strip_prefix('v').unwrap_or(v));
name_matches && (version.is_none() || version == pkg.version.as_deref())
entries.iter().any(|entry| {
let name_matches = match registry.normalize {
Some(normalize) => normalize(&entry.name) == pkg.name,
None => entry.name == pkg.name,
};
// Go writes a leading v that the parsed version drops, and PEP 440 ignores one.
let version = entry.version.as_deref().map(|v| v.strip_prefix('v').unwrap_or(v));
name_matches && (version.is_none() || version == pkg.version.as_deref())
})
}
+1 -4
View File
@@ -70,10 +70,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))) => {
if crate::blocks::block(registry.eco, &pkg, filter, &reason).await {
info!(eco = registry.eco, pkg = %pkg.label(), "allowed while held");
return None;
}
crate::blocks::record(registry.eco, &pkg, filter, &reason);
Some(format!("{} {reason}", pkg.label()))
}
Some((filter, Verdict::Unknown(e))) if !firewall.config.fail_open => {
-29
View File
@@ -1,29 +0,0 @@
use firewall::blocks::{answer, block};
use firewall::policies::Package;
use firewall::Firewall;
use std::path::Path;
use std::time::Duration;
#[tokio::test]
async fn allowing_once_releases_the_held_download() {
let firewall = Firewall::default();
let config = Path::new("/nonexistent/config.json");
let pkg = Package { name: "left-pad".into(), version: Some("1.3.0".into()) };
assert!(!block("npm", &pkg, "age", "is new").await, "held while nothing was asking");
answer(&firewall, config, "blocks");
let held = tokio::spawn(async move { block("npm", &pkg, "age", "is new").await });
let id = loop {
let blocks: serde_json::Value = serde_json::from_str(&answer(&firewall, config, "blocks")).unwrap();
if let Some(id) = blocks[0]["id"].as_u64() {
assert_eq!(blocks[0]["malicious"], false);
assert_eq!(blocks[0]["trustable"], true);
break id;
}
tokio::time::sleep(Duration::from_millis(10)).await;
};
assert_eq!(answer(&firewall, config, &format!("allow-once {id}")), "ok");
assert!(held.await.unwrap(), "the waiting download goes through");
assert!(firewall.age_check_lifted("npm", |p| p.name == "left-pad"), "and the rest of the install");
assert!(!block("npm", &Package { name: "evil".into(), version: None }, "denylist", "is malware").await);
}
+2 -13
View File
@@ -1,4 +1,4 @@
use firewall::config::{path, AllowedPackage};
use firewall::config::path;
use firewall::policies::filters::consult;
use firewall::policies::{screen, Package};
use firewall::{Firewall, FirewallConfig};
@@ -95,23 +95,12 @@ async fn failing_closed_blocks_what_could_not_be_checked() {
assert!(blocked.contains("could not be checked"), "{blocked:?}");
}
#[tokio::test]
async fn lifting_the_age_check_still_checks_for_malware() {
let config = r#"{"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()) };
firewall.lift_age_check("go", AllowedPackage { name: pkg.name.clone(), version: None }, None);
let (filter, _) = consult(&firewall, registry, &pkg).await.expect("the denylist still runs");
assert_eq!(filter, "denylist");
}
#[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", "1.3.0").expect("allow");
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();
+5 -25
View File
@@ -2,13 +2,13 @@ import AppKit
import Security
struct BlockedPackage: Codable, Hashable, Identifiable {
let id: UInt64
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" }
@@ -16,15 +16,10 @@ struct BlockedPackage: Codable, Hashable, Identifiable {
var trustable: Bool { !malicious && version != nil }
}
enum Allowance {
/// For a minute, so the rest of that install can fetch it too.
case once
case always
}
@MainActor
enum BlockAlert {
static func show(_ package: BlockedPackage) -> Allowance? {
/// 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 {
@@ -36,25 +31,10 @@ enum BlockAlert {
} else {
alert.messageText = "Held back a new package"
alert.informativeText = "\(package.label) (\(package.eco)) \(package.reason)."
if package.trustable {
alert.addButton(withTitle: "Allow Once")
alert.addButton(withTitle: "Trust this version")
}
if package.trustable { alert.addButton(withTitle: "Trust this version") }
}
NSApp.activate(ignoringOtherApps: true)
switch alert.runModal() {
case .alertSecondButtonReturn: return .once
case .alertThirdButtonReturn: return .always
default: return nil
}
}
static func late(_ package: BlockedPackage, _ allowance: Allowance) {
let alert = NSAlert()
alert.messageText = allowance == .once ? "Allowed \(package.label) for the next minute" : "Trusted \(package.label)"
alert.informativeText = "The install had already stopped waiting for it. Run it again to finish."
NSApp.activate(ignoringOtherApps: true)
alert.runModal()
return alert.runModal() == .alertSecondButtonReturn
}
}
+14 -10
View File
@@ -203,22 +203,26 @@ final class FirewallModel: NSObject, ObservableObject {
if let url = URL(string: pane) { NSWorkspace.shared.open(url) }
}
/// Trusting changes the config for every user, so it takes an admin.
func allow(_ package: BlockedPackage, _ allowance: Allowance) async {
/// 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 }
if allowance == .always, !AdminAuthorization.confirm() {
guard AdminAuthorization.confirm() else {
log("Trust cancelled for \(package.label)")
return
}
do {
let reply = try await providerMessage("\(allowance == .once ? "allow-once" : "allow") \(package.id)")
guard reply == "ok" || reply == "late" else { throw FirewallError.extensionRefused(reply) }
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("Allowed \(package.label) (\(package.eco)) \(allowance)")
if reply == "late" { BlockAlert.late(package, allowance) }
log("Trusted \(package.label) (\(package.eco))")
} catch {
log("Could not allow \(package.label): \(error.localizedDescription)")
log("Could not trust \(package.label): \(error.localizedDescription)")
return
}
await restart()
}
/// The extension cannot message the app, so the app asks it for blocks.
@@ -228,11 +232,11 @@ final class FirewallModel: NSObject, ObservableObject {
let packages = try? JSONDecoder().decode([BlockedPackage].self, from: Data(reply.utf8)) {
for package in packages where !blocked.contains(package) {
blocked.append(package)
if let allowance = BlockAlert.show(package) { await allow(package, allowance) }
if BlockAlert.show(package) { await trust(package) }
}
blocked = Array(blocked.suffix(10))
}
try? await Task.sleep(nanoseconds: 500_000_000)
try? await Task.sleep(nanoseconds: 2_000_000_000)
}
}
+1 -5
View File
@@ -29,12 +29,8 @@ struct MenuBarView: View {
if !package.malicious {
Menu("Held back \(package.label) (\(package.eco))") {
Text(package.reason)
Button("Allow Once") {
Task { await model.allow(package, .once) }
}
.disabled(model.busy || !package.trustable)
Button("Trust this version") {
Task { await model.allow(package, .always) }
Task { await model.trust(package) }
}
.disabled(model.busy || !package.trustable)
}