Compare commits

..
14 changed files with 79 additions and 257 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 -128
View File
@@ -1,25 +1,14 @@
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>,
@@ -27,125 +16,21 @@ pub struct BlockedPackage {
pub reason: String,
}
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(),
};
push(&mut blocks.unsent, block.clone());
if filter != "age" {
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 => {
-27
View File
@@ -1,27 +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() {
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)
}
+1 -1
View File
@@ -48,7 +48,7 @@ build() {
xcodegen generate >/dev/null
xcodebuild -project Firewall.xcodeproj -scheme StrixFirewall -configuration $CONFIGURATION \
-destination generic/platform=macOS \
-derivedDataPath build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
-derivedDataPath build CURRENT_PROJECT_VERSION="$(date +%s)" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" CODE_SIGN_ENTITLEMENTS="" build >/dev/null
}
+4
View File
@@ -19,6 +19,8 @@ settings:
ENABLE_DEBUG_DYLIB: "NO"
CODE_SIGN_STYLE: Automatic
CODE_SIGN_IDENTITY: Apple Development
# build.sh sets a fresh one per build; macOS only replaces an installed extension whose version differs.
CURRENT_PROJECT_VERSION: "1"
packages:
Rama:
@@ -42,6 +44,7 @@ targets:
properties:
CFBundlePackageType: SYSX
CFBundleShortVersionString: "0.1"
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install.
NetworkExtension:
NEMachServiceName: ${TEAM}.com.usestrix.firewall.extension
@@ -102,6 +105,7 @@ targets:
path: App/Info.plist
properties:
CFBundleShortVersionString: "0.1"
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install.
LSUIElement: true