mirror of
https://github.com/usestrix/firewall.git
synced 2026-11-09 05:19:35 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cbb31e15c | ||
|
|
91da9d68c9 |
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_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
|
||||
windows-registry = "0.6.1"
|
||||
windows-service = "0.8.0"
|
||||
|
||||
|
||||
+31
-3
@@ -1,10 +1,10 @@
|
||||
use crate::policies::registries::{Registry, BUILT_IN};
|
||||
use directories::ProjectDirs;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Error, ErrorKind};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const DEFAULT_PORT: u16 = 8080;
|
||||
|
||||
@@ -17,7 +17,7 @@ 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,
|
||||
/// Not describable in the file yet, so this is always the built-in table.
|
||||
@@ -129,3 +129,31 @@ 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)?)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -59,7 +60,11 @@ impl TransparentProxyHandler for Handler {
|
||||
Some(Bytes::from(match message.as_str() {
|
||||
"ca" => self.ca.pem.clone(),
|
||||
"blocks" => serde_json::to_string(&blocks::take()).expect("blocks serialize"),
|
||||
other => format!("error: unknown message {other}"),
|
||||
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}"),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -118,6 +123,20 @@ 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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))),
|
||||
|
||||
+20
-5
@@ -39,14 +39,14 @@ fn a_later_file_wins_and_allow_lists_add_up() {
|
||||
}
|
||||
|
||||
#[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]
|
||||
@@ -57,3 +57,18 @@ 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"));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import Security
|
||||
|
||||
struct BlockedPackage: Codable, Hashable, Identifiable {
|
||||
let eco: String
|
||||
@@ -11,22 +12,46 @@ struct BlockedPackage: Codable, Hashable, Identifiable {
|
||||
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 {
|
||||
static func show(_ package: BlockedPackage) {
|
||||
/// 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 a malicious package"
|
||||
alert.informativeText = "\(package.label) (\(package.eco)) \(package.reason). Do not install it."
|
||||
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)
|
||||
alert.runModal()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,11 +29,13 @@ 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)\"'"
|
||||
}
|
||||
@@ -201,6 +203,28 @@ 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 {
|
||||
@@ -208,7 +232,7 @@ 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)
|
||||
BlockAlert.show(package)
|
||||
if BlockAlert.show(package) { await trust(package) }
|
||||
}
|
||||
blocked = Array(blocked.suffix(10))
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ struct MenuBarView: View {
|
||||
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))") {
|
||||
|
||||
Reference in new issue
Block a user