Compare commits

...
Author SHA1 Message Date
Ian 01ab5b0995 Change npm's lockfile registry URLs back to https:// after a wrapped run 2026-10-23 11:58:39 -04:00
ian-at-strix 97a5a148e0 Warn when npm or uv leaves http:// registry URLs in its lockfile (#83) 2026-10-23 11:49:52 -04:00
ian-at-strixandIan Graham Martinez 877807740b Allow a held-back package once from the Windows tray (#64)
Co-authored-by: Ian Graham Martinez <[email protected]>
2026-10-23 11:25:50 -04:00
ian-at-strix cd9b0614bc Link OSV advisories in block reasons (#77) 2026-10-23 11:11:14 -04:00
ian-at-strix 6cad09d32d Make the links in the block popups clickable (#74) 2026-10-23 10:58:18 -04:00
ian-at-strix 23db767cc1 Allow a held-back package once from the app, releasing the waiting download (#37)
* Hold a held-back download open so allowing it lets it through

* Allow a held-back package once from the app, releasing the waiting download
2026-10-23 10:55:03 -04:00
ian-at-strix 37a56fb7d2 Show the Windows service's blocks from the tray (#63)
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:52:09 -04:00
ian-at-strix 6c69036db0 Screen mirrors listed in the config file, and reject unusable config values (#73)
* Screen mirrors listed in the config file

* Reject config values the firewall cannot use
2026-10-22 18:26:59 -04:00
ian-at-strix 4fd3021760 Create the Windows data folder with its ACL instead of setting it after (#55) 2026-10-22 18:25:28 -04:00
ian-at-strix d01ac3634b Let an allow entry lift only the age check, never the malware checks (#69) 2026-10-22 18:11:30 -04:00
33 changed files with 781 additions and 134 deletions

No files matched your search

+5 -1
View File
@@ -90,6 +90,10 @@ path = "tests/tpm.rs"
name = "managers" name = "managers"
path = "tests/managers.rs" path = "tests/managers.rs"
[[test]]
name = "blocks"
path = "tests/blocks.rs"
[features] [features]
# Builds the machine-wide binaries: the Windows service and tray, and the macOS uninstaller. # Builds the machine-wide binaries: the Windows service and tray, and the macOS uninstaller.
machine-wide = [] machine-wide = []
@@ -125,7 +129,7 @@ schannel = "0.1.29"
tokio = { version = "1.53.1", features = ["net"] } tokio = { version = "1.53.1", features = ["net"] }
tracing-appender = "0.2.5" tracing-appender = "0.2.5"
tray-icon = { version = "0.26.1", default-features = false } tray-icon = { version = "0.26.1", default-features = false }
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_System_Com", "Win32_System_Console", "Win32_System_Environment", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_Environment", "Win32_System_JobObjects", "Win32_System_Pipes", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
windows-registry = "0.6.1" windows-registry = "0.6.1"
windows-service = "0.8.0" windows-service = "0.8.0"
+1
View File
@@ -11,4 +11,5 @@ pub static BUN: Manager = Manager {
("npm_config_registry", "http://registry.npmjs.org/"), ("npm_config_registry", "http://registry.npmjs.org/"),
], ],
args: &[], args: &[],
lockfile: None,
}; };
+1
View File
@@ -17,4 +17,5 @@ pub static CARGO: Manager = Manager {
"source.firewall.registry=\"sparse+http://index.crates.io/\"", "source.firewall.registry=\"sparse+http://index.crates.io/\"",
], ],
}], }],
lockfile: None,
}; };
+1
View File
@@ -8,4 +8,5 @@ pub static GEM: Manager = Manager {
when: &["install", "update", "fetch"], when: &["install", "update", "fetch"],
flags: &["--clear-sources", "--source", "http://rubygems.org/"], flags: &["--clear-sources", "--source", "http://rubygems.org/"],
}], }],
lockfile: None,
}; };
+1
View File
@@ -11,4 +11,5 @@ pub static GO: Manager = Manager {
("GOFLAGS", "-mod=mod"), ("GOFLAGS", "-mod=mod"),
], ],
args: &[], args: &[],
lockfile: None,
}; };
+8
View File
@@ -36,6 +36,14 @@ pub struct Manager {
pub command: &'static str, pub command: &'static str,
pub env: &'static [(&'static str, &'static str)], pub env: &'static [(&'static str, &'static str)],
pub args: &'static [Args], pub args: &'static [Args],
/// Where the manager records registry URLs, which then carry our http:// override.
pub lockfile: Option<Lockfile>,
}
/// What to do after a run about http:// registry URLs left in the lockfile at this path.
pub enum Lockfile {
Restore(&'static str),
Warn(&'static str),
} }
pub static MANAGERS: &[&Manager] = pub static MANAGERS: &[&Manager] =
+2 -1
View File
@@ -1,4 +1,4 @@
use super::{Manager, PROXY}; use super::{Lockfile, Manager, PROXY};
pub static NPM: Manager = Manager { pub static NPM: Manager = Manager {
command: "npm", command: "npm",
@@ -8,4 +8,5 @@ pub static NPM: Manager = Manager {
("npm_config_registry", "http://registry.npmjs.org/"), ("npm_config_registry", "http://registry.npmjs.org/"),
], ],
args: &[], args: &[],
lockfile: Some(Lockfile::Restore("package-lock.json")),
}; };
+1
View File
@@ -13,4 +13,5 @@ pub static PDM: Manager = Manager {
("PDM_PYPI_VERIFY_SSL", "false"), ("PDM_PYPI_VERIFY_SSL", "false"),
], ],
args: &[], args: &[],
lockfile: None,
}; };
+1 -1
View File
@@ -12,5 +12,5 @@ pub static PIP: Manager = recipe("pip");
pub static PIP3: Manager = recipe("pip3"); pub static PIP3: Manager = recipe("pip3");
const fn recipe(name: &'static str) -> Manager { const fn recipe(name: &'static str) -> Manager {
Manager { command: name, env: PIP_ENV, args: &[] } Manager { command: name, env: PIP_ENV, args: &[], lockfile: None }
} }
+1
View File
@@ -13,4 +13,5 @@ pub static PIPENV: Manager = Manager {
("PIP_TRUSTED_HOST", "pypi.org files.pythonhosted.org"), ("PIP_TRUSTED_HOST", "pypi.org files.pythonhosted.org"),
], ],
args: &[], args: &[],
lockfile: None,
}; };
+1
View File
@@ -9,4 +9,5 @@ pub static PNPM: Manager = Manager {
("pnpm_config_registry", "http://registry.npmjs.org/"), ("pnpm_config_registry", "http://registry.npmjs.org/"),
], ],
args: &[], args: &[],
lockfile: None,
}; };
+2 -1
View File
@@ -1,7 +1,8 @@
use super::{Manager, PROXY, PYPI_SIMPLE}; use super::{Lockfile, Manager, PROXY, PYPI_SIMPLE};
pub static UV: Manager = Manager { pub static UV: Manager = Manager {
command: "uv", command: "uv",
env: &[("HTTP_PROXY", PROXY), ("http_proxy", PROXY), ("UV_DEFAULT_INDEX", PYPI_SIMPLE)], env: &[("HTTP_PROXY", PROXY), ("http_proxy", PROXY), ("UV_DEFAULT_INDEX", PYPI_SIMPLE)],
args: &[], args: &[],
lockfile: Some(Lockfile::Warn("uv.lock")),
}; };
+35 -1
View File
@@ -6,9 +6,11 @@ mod windows;
pub use forward::spawn; pub use forward::spawn;
pub use managers::*; pub use managers::*;
use crate::policies::registries::BUILT_IN;
use std::io::{Error, ErrorKind}; use std::io::{Error, ErrorKind};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use tracing::{info, warn};
pub struct Invocation { pub struct Invocation {
pub program: String, pub program: String,
@@ -50,10 +52,42 @@ pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::R
let status = command.status(); let status = command.status();
#[cfg(windows)] #[cfg(windows)]
let status = status.map_err(|error| windows::explain(error, &program)); let status = status.map_err(|error| windows::explain(error, &program));
Ok(status?.code().unwrap_or(1)) let code = status?.code().unwrap_or(1);
let manager = MANAGERS.iter().find(|manager| manager.command == invocation.program);
match manager.and_then(|manager| manager.lockfile.as_ref()) {
Some(Lockfile::Restore(lockfile)) => restore_https(lockfile, dir),
Some(Lockfile::Warn(lockfile)) => warn_if_plaintext(lockfile, dir),
None => {}
}
Ok(code)
} }
// Command::new only finds .exe on Windows; npm, pnpm and gem are .cmd shims found via PATHEXT. // Command::new only finds .exe on Windows; npm, pnpm and gem are .cmd shims found via PATHEXT.
fn resolve(program: &str) -> std::io::Result<PathBuf> { fn resolve(program: &str) -> std::io::Result<PathBuf> {
which::which(program).map_err(|e| Error::new(ErrorKind::NotFound, e)) which::which(program).map_err(|e| Error::new(ErrorKind::NotFound, e))
} }
fn warn_if_plaintext(lockfile: &str, dir: Option<&Path>) {
let path = dir.unwrap_or(Path::new(".")).join(lockfile);
let Ok(text) = std::fs::read_to_string(path) else { return };
if BUILT_IN.iter().any(|registry| text.contains(&format!("http://{}/", registry.host()))) {
warn!("{lockfile} has http:// registry URLs; change them to https:// before committing");
}
}
fn restore_https(lockfile: &str, dir: Option<&Path>) {
let path = dir.unwrap_or(Path::new(".")).join(lockfile);
let Ok(text) = std::fs::read_to_string(&path) else { return };
let mut fixed = text.clone();
for registry in BUILT_IN {
let host = registry.host();
fixed = fixed.replace(&format!("http://{host}/"), &format!("https://{host}/"));
}
if fixed == text {
return;
}
match std::fs::write(&path, fixed) {
Ok(()) => info!("changed {lockfile}'s registry URLs back to https://"),
Err(e) => warn!("could not change {lockfile}'s registry URLs back to https://: {e}"),
}
}
+128 -13
View File
@@ -1,14 +1,25 @@
use crate::config::{self, AllowedPackage};
use crate::policies::Package; use crate::policies::Package;
use crate::Firewall;
use rama::error::BoxError;
use serde::Serialize; use serde::Serialize;
use std::path::Path;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::{Duration, Instant};
use tokio::sync::watch;
/// Bounded, so blocks pile up harmlessly while nothing collects them. /// So blocks can't pile up while no app collects them.
const KEPT: usize = 50; 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);
static RECENT: Mutex<Vec<BlockedPackage>> = Mutex::new(Vec::new()); #[derive(Clone, Serialize)]
#[derive(Serialize)]
pub struct BlockedPackage { pub struct BlockedPackage {
pub id: u64,
pub eco: &'static str, pub eco: &'static str,
pub name: String, pub name: String,
pub version: Option<String>, pub version: Option<String>,
@@ -16,21 +27,125 @@ pub struct BlockedPackage {
pub reason: String, pub reason: String,
} }
pub fn record(eco: &'static str, pkg: &Package, filter: &'static str, reason: &str) { struct Blocks {
let mut recent = RECENT.lock().unwrap_or_else(|e| e.into_inner()); last_id: u64,
if recent.len() == KEPT { unsent: Vec<BlockedPackage>,
recent.remove(0); /// 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>,
} }
recent.push(BlockedPackage {
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");
}
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,
eco, eco,
name: pkg.name.clone(), name: pkg.name.clone(),
version: pkg.version.clone(), version: pkg.version.clone(),
filter, filter,
reason: reason.to_owned(), 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)
} }
/// Hands each block over once. /// Also saved to the config, so it survives a restart.
pub fn take() -> Vec<BlockedPackage> { fn trust(firewall: &Firewall, config: &Path, id: u64) -> Result<bool, BoxError> {
std::mem::take(&mut *RECENT.lock().unwrap_or_else(|e| e.into_inner())) 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())
} }
+55 -25
View File
@@ -1,9 +1,11 @@
use crate::policies::registries::{Registry, BUILT_IN}; use crate::policies::registries::{host_of, BUILT_IN};
use directories::ProjectDirs; use directories::ProjectDirs;
use rama::net::uri::Uri;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
use std::collections::HashMap; use std::collections::HashMap;
use std::io::{Error, ErrorKind}; use std::io::{Error, ErrorKind};
use std::num::{NonZeroU16, NonZeroU64};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
pub const DEFAULT_PORT: u16 = 8080; pub const DEFAULT_PORT: u16 = 8080;
@@ -11,18 +13,17 @@ pub const DEFAULT_PORT: u16 = 8080;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct FirewallConfig { pub struct FirewallConfig {
pub port: u16, pub port: NonZeroU16,
/// Allow a package when a filter could not check it. /// Allow a package when a filter could not check it.
pub fail_open: bool, pub fail_open: bool,
/// 0 turns the age check off. /// 0 turns the age check off.
pub min_age_hours: u64, pub min_age_hours: u64,
pub denylist: DenylistSource, pub denylist: DenylistSource,
/// Packages never screened, by ecosystem: `{"npm": [{"name": "left-pad"}]}`. /// Packages exempt from the age check, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
pub allow: HashMap<String, Vec<AllowedPackage>>, pub allow: HashMap<String, Vec<AllowedPackage>>,
pub telemetry: bool, pub telemetry: bool,
/// Not describable in the file yet, so this is always the built-in table. /// Mirrors laid out like a built-in registry, by its name: `{"npm": ["https://npm.example.com/"]}`.
#[serde(skip)] pub registries: HashMap<String, Vec<String>>,
pub registries: Vec<&'static Registry>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -40,19 +41,19 @@ pub struct DenylistSource {
pub base: String, pub base: String,
/// Polled for a new commit, which re-fetches every list in use. /// Polled for a new commit, which re-fetches every list in use.
pub revision: String, pub revision: String,
pub poll_secs: u64, pub poll_secs: NonZeroU64,
} }
impl Default for FirewallConfig { impl Default for FirewallConfig {
fn default() -> Self { fn default() -> Self {
FirewallConfig { FirewallConfig {
port: DEFAULT_PORT, port: NonZeroU16::new(DEFAULT_PORT).unwrap(),
fail_open: true, fail_open: true,
min_age_hours: 24, min_age_hours: 24,
denylist: DenylistSource::default(), denylist: DenylistSource::default(),
allow: HashMap::new(), allow: HashMap::new(),
telemetry: true, telemetry: true,
registries: BUILT_IN.to_vec(), registries: HashMap::new(),
} }
} }
} }
@@ -63,7 +64,7 @@ impl Default for DenylistSource {
base: "https://raw.githubusercontent.com/usestrix/firewall-blocklist/main".into(), base: "https://raw.githubusercontent.com/usestrix/firewall-blocklist/main".into(),
revision: "https://api.github.com/repos/usestrix/firewall-blocklist/commits/main" revision: "https://api.github.com/repos/usestrix/firewall-blocklist/commits/main"
.into(), .into(),
poll_secs: 10, poll_secs: NonZeroU64::new(10).unwrap(),
} }
} }
} }
@@ -87,7 +88,8 @@ impl FirewallConfig {
}; };
let invalid = |e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display())); let invalid = |e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display()));
let layer: Value = serde_json::from_str(&text).map_err(invalid)?; let layer: Value = serde_json::from_str(&text).map_err(invalid)?;
FirewallConfig::deserialize(&layer).map_err(invalid)?; let config = FirewallConfig::deserialize(&layer).map_err(invalid)?;
check(&config).map_err(|e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display())))?;
let Value::Object(layer) = layer else { let Value::Object(layer) = layer else {
unreachable!("a config parses only from an object"); unreachable!("a config parses only from an object");
}; };
@@ -97,19 +99,7 @@ impl FirewallConfig {
} }
pub fn local_url(&self, path: &str) -> String { pub fn local_url(&self, path: &str) -> String {
crate::local_url(self.port, path) crate::local_url(self.port.get(), path)
}
/// Without building a `Firewall`, which needs a runtime.
pub fn hosts(&self) -> Vec<&'static str> {
let mut hosts: Vec<&'static str> = Vec::new();
for registry in &self.registries {
let host = registry.host();
if !hosts.contains(&host) {
hosts.push(host);
}
}
hosts
} }
} }
@@ -131,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. /// 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<()> { pub fn allow(path: &Path, eco: &str, name: &str, version: &str) -> std::io::Result<()> {
let invalid = |e: String| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display())); let invalid = |e: String| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display()));
let mut config = match std::fs::read_to_string(path) { let mut config = match std::fs::read_to_string(path) {
Ok(text) => serde_json::from_str(&text).map_err(|e| invalid(e.to_string()))?, Ok(text) => serde_json::from_str(&text).map_err(|e| invalid(e.to_string()))?,
@@ -157,3 +147,43 @@ pub fn allow(path: &Path, eco: &str, name: &str, version: Option<&str>) -> std::
} }
std::fs::write(path, serde_json::to_string_pretty(&config)?) std::fs::write(path, serde_json::to_string_pretty(&config)?)
} }
/// What serde's types cannot say.
fn check(config: &FirewallConfig) -> Result<(), String> {
// `Duration::hours` panics past this.
if config.min_age_hours > time::Duration::MAX.whole_hours() as u64 {
return Err(format!("{} hours is too long", config.min_age_hours));
}
for eco in config.allow.keys() {
if !BUILT_IN.iter().any(|r| r.eco == eco) {
return Err(format!("unknown ecosystem {eco:?} in allow"));
}
}
for url in [&config.denylist.base, &config.denylist.revision] {
if parse_url(url, &["http", "https"]).is_none() {
return Err(format!("{url:?} is not an http(s) URL"));
}
}
for (name, mirrors) in &config.registries {
if !BUILT_IN.iter().any(|r| r.name() == name) {
return Err(format!("no built-in registry is named {name}"));
}
if let Some(url) = mirrors.iter().find(|url| !is_mirror(url)) {
return Err(format!("{url}: a mirror is an https:// URL ending in /"));
}
}
Ok(())
}
/// No port or userinfo, as `host_of` is what gets intercepted.
fn is_mirror(url: &str) -> bool {
let Some(uri) = parse_url(url, &["https"]) else { return false };
let bare_host = uri.host_str().is_some_and(|host| host == host_of(url));
bare_host && uri.query().is_none() && uri.fragment().is_none() && url.ends_with('/')
}
fn parse_url(url: &str, schemes: &[&str]) -> Option<Uri> {
let uri = Uri::parse(url).ok()?;
let has_host = uri.host_str().is_some_and(|host| !host.is_empty());
(has_host && schemes.contains(&uri.scheme_str()?)).then_some(uri)
}
+46 -8
View File
@@ -1,4 +1,4 @@
use crate::config::FirewallConfig; use crate::config::{AllowedPackage, FirewallConfig};
use crate::policies::filters::age::Age; use crate::policies::filters::age::Age;
use crate::policies::filters::denylist::Denylist; use crate::policies::filters::denylist::Denylist;
use crate::policies::filters::osv::Osv; use crate::policies::filters::osv::Osv;
@@ -6,11 +6,16 @@ use crate::policies::filters::Filter;
use crate::policies::registries::*; use crate::policies::registries::*;
use regex::Regex; use regex::Regex;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Instant;
pub struct Firewall { pub struct Firewall {
pub config: FirewallConfig, pub config: FirewallConfig,
registries: Vec<&'static Registry>,
patterns: HashMap<&'static str, Vec<Regex>>, patterns: HashMap<&'static str, Vec<Regex>>,
filters: Vec<Box<dyn Filter>>, 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 { impl Default for Firewall {
@@ -21,18 +26,19 @@ impl Default for Firewall {
impl Firewall { impl Firewall {
pub fn new(config: FirewallConfig) -> Self { pub fn new(config: FirewallConfig) -> Self {
let registries = with_mirrors(&config.registries);
let mut patterns = HashMap::new(); let mut patterns = HashMap::new();
for registry in &config.registries { for registry in &registries {
let compiled = registry.patterns.iter().map(|p| Regex::new(p.re).expect(p.re)); let compiled = registry.patterns.iter().map(|p| Regex::new(p.re).expect(p.re));
patterns.insert(registry.prefix, compiled.collect()); patterns.insert(registry.prefix, compiled.collect());
} }
let ecosystems = config.registries.iter().map(|r| r.osv_name); let ecosystems = registries.iter().map(|r| r.osv_name);
let denylist = Denylist::new(config.denylist.clone(), ecosystems); let denylist = Denylist::new(config.denylist.clone(), ecosystems);
let mut filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)]; let mut filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)];
if config.min_age_hours > 0 { if config.min_age_hours > 0 {
filters.push(Box::new(Age::new(config.min_age_hours))); filters.push(Box::new(Age::new(config.min_age_hours)));
} }
Firewall { config, patterns, filters } Firewall { config, registries, patterns, filters, age_lifted: Mutex::default() }
} }
pub fn match_on<'a>( pub fn match_on<'a>(
@@ -40,20 +46,27 @@ impl Firewall {
url: &'a str, url: &'a str,
key: fn(&Registry) -> &'static str, key: fn(&Registry) -> &'static str,
) -> Option<(&'static Registry, &'a str)> { ) -> Option<(&'static Registry, &'a str)> {
self.config.registries.iter().find_map(|r| url.strip_prefix(key(r)).map(|rest| (*r, rest))) self.registries.iter().find_map(|r| url.strip_prefix(key(r)).map(|rest| (*r, rest)))
} }
pub fn hosts(&self) -> Vec<&'static str> { pub fn hosts(&self) -> Vec<&'static str> {
self.config.hosts() let mut hosts: Vec<&'static str> = Vec::new();
for registry in &self.registries {
let host = registry.host();
if !hosts.contains(&host) {
hosts.push(host);
}
}
hosts
} }
pub fn is_https_url(&self, url: &str) -> bool { pub fn is_https_url(&self, url: &str) -> bool {
let host = host_of(url); let host = host_of(url);
self.config.registries.iter().any(|r| r.upstream.starts_with("https://") && r.host() == host) self.registries.iter().any(|r| r.upstream.starts_with("https://") && r.host() == host)
} }
pub fn is_index_url(&self, url: &str) -> bool { pub fn is_index_url(&self, url: &str) -> bool {
self.config.registries.iter().any(|r| r.index && r.host() == host_of(url)) self.registries.iter().any(|r| r.index && r.host() == host_of(url))
} }
pub fn resolve(&self, registry: &'static Registry, rest: &str) -> Resolved { pub fn resolve(&self, registry: &'static Registry, rest: &str) -> Resolved {
@@ -63,4 +76,29 @@ impl Firewall {
pub fn filters(&self) -> &[Box<dyn Filter>] { pub fn filters(&self) -> &[Box<dyn Filter>] {
&self.filters &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.
fn with_mirrors(mirrors: &HashMap<String, Vec<String>>) -> Vec<&'static Registry> {
let mut registries = BUILT_IN.to_vec();
for built_in in BUILT_IN {
for upstream in mirrors.get(built_in.name()).into_iter().flatten() {
let upstream = upstream.clone().leak();
registries.push(Box::leak(Box::new(Registry { upstream, ..(*built_in).clone() })));
}
}
registries
} }
+5 -24
View File
@@ -4,7 +4,7 @@ mod uninstall;
pub use uninstall::uninstall; pub use uninstall::uninstall;
use crate::config::{self, FirewallConfig}; use crate::config::FirewallConfig;
use crate::{blocks, mitm, telemetry}; use crate::{blocks, mitm, telemetry};
use crate::Firewall; use crate::Firewall;
use rama::bytes::Bytes; use rama::bytes::Bytes;
@@ -22,7 +22,6 @@ use rama::net::apple::networkextension::{transparent_proxy_ffi, NwTcpStream, Tcp
use rama::rt::Executor; use rama::rt::Executor;
use rama::telemetry::tracing::apple::oslog::{OsLogLayer, Privacy}; use rama::telemetry::tracing::apple::oslog::{OsLogLayer, Privacy};
use rama::{Layer, Service}; use rama::{Layer, Service};
use serde::Deserialize;
use std::convert::Infallible; use std::convert::Infallible;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
@@ -40,6 +39,7 @@ const CONFIG_FILE: &str = "/Library/Application Support/Firewall/config.json";
struct Handler { struct Handler {
ca: Arc<mitm::CertificateAuthority>, ca: Arc<mitm::CertificateAuthority>,
hosts: Vec<&'static str>, hosts: Vec<&'static str>,
firewall: Arc<Firewall>,
service: mitm::MitmService, service: mitm::MitmService,
} }
@@ -59,12 +59,7 @@ impl TransparentProxyHandler for Handler {
let message = String::from_utf8_lossy(&message).into_owned(); let message = String::from_utf8_lossy(&message).into_owned();
Some(Bytes::from(match message.as_str() { Some(Bytes::from(match message.as_str() {
"ca" => self.ca.pem.clone(), "ca" => self.ca.pem.clone(),
"blocks" => serde_json::to_string(&blocks::take()).expect("blocks serialize"), other => blocks::answer(&self.firewall, CONFIG_FILE.as_ref(), 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}"),
},
})) }))
} }
@@ -119,22 +114,8 @@ fn handler(exec: Executor) -> Result<Handler, BoxError> {
let signer: mitm::CaSigner = Arc::new(key); let signer: mitm::CaSigner = Arc::new(key);
let ca = install::certificate_authority(&hosts, &*signer); let ca = install::certificate_authority(&hosts, &*signer);
let ca = Arc::new(ca.inspect_err(|_| telemetry::error("ca"))?); let ca = Arc::new(ca.inspect_err(|_| telemetry::error("ca"))?);
let service = mitm::mitm_service(firewall, &ca, signer, None, exec); let service = mitm::mitm_service(firewall.clone(), &ca, signer, None, exec);
Ok(Handler { ca, hosts, service }) Ok(Handler { ca, hosts, firewall, 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: /// stderr is invisible in a system extension, so events go to the unified log:
+22 -7
View File
@@ -11,6 +11,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use tracing::warn; use tracing::warn;
use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT}; use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
use windows::Win32::Security::*; use windows::Win32::Security::*;
use windows::Win32::Storage::FileSystem::CreateDirectoryW;
use windows_registry::Type; use windows_registry::Type;
/// SYSTEM and admins write, users only read, so no one else can plant a CA for node. /// 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) 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. /// 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> { pub(super) fn secure_data_dir() -> io::Result<PathBuf> {
let dir = data_dir(); let dir = data_dir();
match std::fs::symlink_metadata(&dir) { let exists = match std::fs::symlink_metadata(&dir) {
Ok(meta) if meta.file_type().is_symlink() => std::fs::remove_dir(&dir)?, Ok(meta) if meta.file_type().is_symlink() => {
std::fs::remove_dir(&dir)?;
false
}
Ok(_) if !owned_by_admins(&dir)? => { Ok(_) if !owned_by_admins(&dir)? => {
warn!(dir = %dir.display(), "replacing a data folder not created by an admin"); 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
} }
_ => {} Ok(_) => true,
} Err(_) => false,
std::fs::create_dir_all(&dir)?; };
let info = OWNER_SECURITY_INFORMATION let info = OWNER_SECURITY_INFORMATION
| DACL_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION
| PROTECTED_DACL_SECURITY_INFORMATION; | PROTECTED_DACL_SECURITY_INFORMATION;
let path = HSTRING::from(dir.as_path()); let path = HSTRING::from(dir.as_path());
let acl = security_descriptor(DATA_DIR_ACL)?; let acl = security_descriptor(DATA_DIR_ACL)?;
if exists {
unsafe { SetFileSecurityW(&path, info, PSECURITY_DESCRIPTOR(acl.0)).ok()? }; 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) Ok(dir)
} }
+1
View File
@@ -2,6 +2,7 @@
//! LocalSystem service screens it as the macOS extension does. //! LocalSystem service screens it as the macOS extension does.
mod ca; mod ca;
mod pipe;
mod service; mod service;
mod tpm; mod tpm;
mod tray; mod tray;
+86
View File
@@ -0,0 +1,86 @@
//! How the tray asks the service what it blocked, and allows a new package once, as the macOS
//! app messages its extension.
use super::{config_file, security_descriptor};
use crate::{blocks, Firewall};
use rama::error::BoxError;
use std::os::windows::io::AsRawHandle;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions};
use windows::core::{Owned, PWSTR};
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Security::SECURITY_ATTRIBUTES;
use windows::Win32::System::Pipes::GetNamedPipeClientProcessId;
use windows::Win32::System::Threading::*;
pub(super) const PIPE: &str = r"\\.\pipe\StrixFirewall";
/// Signed-in users can connect, as the tray runs as them.
const PIPE_ACL: &str = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)";
pub(super) async fn serve(firewall: Arc<Firewall>) -> Result<(), BoxError> {
let mut server = create(true)?;
loop {
server.connect().await?;
let client = std::mem::replace(&mut server, create(false)?);
let firewall = Arc::clone(&firewall);
tokio::spawn(async move {
if let Err(error) = answer(client, &firewall).await {
tracing::debug!(%error, "could not answer the tray");
}
});
}
}
fn create(first: bool) -> Result<NamedPipeServer, BoxError> {
let acl = security_descriptor(PIPE_ACL)?;
let mut attributes = SECURITY_ATTRIBUTES {
nLength: size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: acl.0,
bInheritHandle: false.into(),
};
let attributes = (&raw mut attributes).cast();
let mut options = ServerOptions::new();
// Fails if anyone else made the pipe first.
options.first_pipe_instance(first);
Ok(unsafe { options.create_with_security_attributes_raw(PIPE, attributes)? })
}
/// A line each way.
async fn answer(client: NamedPipeServer, firewall: &Firewall) -> Result<(), BoxError> {
let mut client = BufReader::new(client);
let mut request = String::new();
(&mut client)
.take(64 * 1024)
.read_line(&mut request)
.await?;
let request = request.trim_end();
// Only the app can message the macOS extension; here any program could connect.
let reply = if !client_is_tray(client.get_ref())? {
"error: only the tray can ask".to_owned()
} else if request.starts_with("allow ") {
// Trusting writes the config for every user, so it is left to an administrator.
"error: trusting a package takes an administrator".to_owned()
} else {
blocks::answer(firewall, &config_file(), request)
};
client.write_all(format!("{reply}\n").as_bytes()).await?;
// Closing before the tray hangs up could discard the reply.
let _ = tokio::time::timeout(Duration::from_secs(5), client.read(&mut [0])).await;
Ok(())
}
fn client_is_tray(client: &NamedPipeServer) -> Result<bool, BoxError> {
let mut id = 0;
unsafe { GetNamedPipeClientProcessId(HANDLE(client.as_raw_handle()), &mut id)? };
let process = unsafe { Owned::new(OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, id)?) };
let mut path = [0u16; 1024];
let mut len = path.len() as u32;
let flags = PROCESS_NAME_WIN32;
unsafe { QueryFullProcessImageNameW(*process, flags, PWSTR(path.as_mut_ptr()), &mut len)? };
let path = String::from_utf16(&path[..len as usize])?;
let tray = std::env::current_exe()?.with_file_name("firewall-tray.exe");
// Windows paths ignore case.
Ok(path.to_lowercase() == tray.to_string_lossy().to_lowercase())
}
+10 -1
View File
@@ -1,6 +1,8 @@
use super::ca::{load_or_create_ca, secure_data_dir}; use super::ca::{load_or_create_ca, secure_data_dir};
use super::tpm::CaKey; use super::tpm::CaKey;
use super::{config_file, loopback_addresses, write_hosts_entries, CRL_ADDRESS, SERVICE_NAME}; use super::{
config_file, loopback_addresses, pipe, write_hosts_entries, CRL_ADDRESS, SERVICE_NAME,
};
use crate::config::FirewallConfig; use crate::config::FirewallConfig;
use crate::http::{reply, text}; use crate::http::{reply, text};
use crate::{mitm, telemetry, Firewall}; use crate::{mitm, telemetry, Firewall};
@@ -153,6 +155,13 @@ async fn start_screening(status: &ServiceStatusHandle) -> Result<Vec<&'static st
let crl = HttpServer::auto(exec.clone()).service(crl); let crl = HttpServer::auto(exec.clone()).service(crl);
tokio::spawn(listen_on(CRL_ADDRESS, 80, &exec)?.serve(crl)); tokio::spawn(listen_on(CRL_ADDRESS, 80, &exec)?.serve(crl));
let tray_firewall = Arc::clone(&firewall);
tokio::spawn(async move {
if let Err(error) = pipe::serve(tray_firewall).await {
tracing::error!(%error, "the tray cannot reach the service");
}
});
let service = mitm::mitm_service(firewall, &ca, signer, Some(crl_url), exec.clone()); let service = mitm::mitm_service(firewall, &ca, signer, Some(crl_url), exec.clone());
for (address, host) in loopback_addresses(&hosts) { for (address, host) in loopback_addresses(&hosts) {
for port in [80, 443] { for port in [80, 443] {
+199 -9
View File
@@ -1,24 +1,34 @@
//! The notification-area icon, started at sign-in as the menu bar app is on macOS. //! The notification-area icon, started at sign-in as the menu bar app is on macOS.
use super::pipe::PIPE;
use super::*; use super::*;
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}; use muda::{IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use std::io::Cursor; use serde::Deserialize;
use std::io::{BufRead, BufReader, Cursor, Write};
use std::os::windows::io::AsRawHandle;
use std::sync::mpsc::{channel, Receiver, Sender};
use std::time::Duration; use std::time::Duration;
use tray_icon::{Icon, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}; use tray_icon::{Icon, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
use windows::core::{w, PCWSTR}; use windows::core::{w, PCWSTR};
use windows::Win32::Foundation::*; use windows::Win32::Foundation::*;
use windows::Win32::System::Pipes::GetNamedPipeServerProcessId;
use windows::Win32::System::Threading::CreateMutexW; use windows::Win32::System::Threading::CreateMutexW;
use windows::Win32::UI::Shell::*; use windows::Win32::UI::Shell::*;
use windows::Win32::UI::WindowsAndMessaging::*; use windows::Win32::UI::WindowsAndMessaging::*;
use windows_registry::CURRENT_USER; use windows_registry::CURRENT_USER;
use windows_service::service::{Service, ServiceAccess, ServiceState}; use windows_service::service::{Service, ServiceAccess, ServiceState, ServiceStatus};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
const MENU_PAUSE: &str = "pause"; const MENU_PAUSE: &str = "pause";
const MENU_RESTART: &str = "restart"; const MENU_RESTART: &str = "restart";
const MENU_LOG: &str = "log"; const MENU_LOG: &str = "log";
const MENU_QUIT: &str = "quit"; const MENU_QUIT: &str = "quit";
/// Also the service's request.
const ALLOW_ONCE: &str = "allow-once";
const LOGO: &[u8] = include_bytes!("tray.png"); const LOGO: &[u8] = include_bytes!("tray.png");
/// Where blocked packages are listed, below Open logs.
const BLOCKED_AT: usize = 5;
const BLOCKED_KEPT: usize = 10;
/// Runs until Quit, once per session. /// Runs until Quit, once per session.
pub fn run() -> Result<(), BoxError> { pub fn run() -> Result<(), BoxError> {
@@ -29,6 +39,7 @@ pub fn run() -> Result<(), BoxError> {
} }
let status = MenuItem::new("", false, None); let status = MenuItem::new("", false, None);
let pause = MenuItem::with_id(MENU_PAUSE, "", false, None); let pause = MenuItem::with_id(MENU_PAUSE, "", false, None);
let blocked_separator = PredefinedMenuItem::separator();
let menu = Menu::with_items(&[ let menu = Menu::with_items(&[
&status, &status,
&PredefinedMenuItem::separator(), &PredefinedMenuItem::separator(),
@@ -39,7 +50,9 @@ pub fn run() -> Result<(), BoxError> {
&MenuItem::with_id(MENU_QUIT, "Quit", true, None), &MenuItem::with_id(MENU_QUIT, "Quit", true, None),
])?; ])?;
let (logo, logo_size) = logo_mask()?; let (logo, logo_size) = logo_mask()?;
let icon = TrayIconBuilder::new().with_menu(Box::new(menu)).build()?; let icon = TrayIconBuilder::new()
.with_menu(Box::new(menu.clone()))
.build()?;
let window = HWND(icon.window_handle()); let window = HWND(icon.window_handle());
let mut tray = Tray { let mut tray = Tray {
icon, icon,
@@ -49,9 +62,14 @@ pub fn run() -> Result<(), BoxError> {
status, status,
pause, pause,
state: None, state: None,
menu,
blocked_separator,
blocked: Vec::new(),
allowed: channel(),
}; };
tray.update(); tray.update();
unsafe { SetTimer(None, 0, 2000, None) }; // The service holds a new package's download only while it is asked this often.
unsafe { SetTimer(None, 0, 500, None) };
let mut message = MSG::default(); let mut message = MSG::default();
while unsafe { GetMessageW(&mut message, None, 0, 0) }.0 > 0 { while unsafe { GetMessageW(&mut message, None, 0, 0) }.0 > 0 {
unsafe { DispatchMessageW(&message) }; unsafe { DispatchMessageW(&message) };
@@ -62,6 +80,7 @@ pub fn run() -> Result<(), BoxError> {
// Ours, not the icon window's own hover timer. // Ours, not the icon window's own hover timer.
if message.message == WM_TIMER && message.hwnd.is_invalid() { if message.message == WM_TIMER && message.hwnd.is_invalid() {
tray.update(); tray.update();
tray.collect_blocks();
} }
while let Ok(event) = TrayIconEvent::receiver().try_recv() { while let Ok(event) = TrayIconEvent::receiver().try_recv() {
// The menu opens on release, so this keeps it current. // The menu opens on release, so this keeps it current.
@@ -94,11 +113,47 @@ struct Tray {
pause: MenuItem, pause: MenuItem,
/// As the menu last showed it. /// As the menu last showed it.
state: Option<ServiceState>, state: Option<ServiceState>,
menu: Menu,
blocked_separator: PredefinedMenuItem,
/// The most recent, as the menu lists them.
blocked: Vec<BlockedPackage>,
/// Allowed from another thread, so no longer listed.
allowed: (Sender<BlockedPackage>, Receiver<BlockedPackage>),
}
#[derive(Clone, PartialEq, Deserialize)]
struct BlockedPackage {
id: u64,
eco: String,
name: String,
version: Option<String>,
filter: String,
reason: String,
}
impl BlockedPackage {
fn label(&self) -> String {
match &self.version {
Some(version) => format!("{} {version} ({})", self.name, self.eco),
None => format!("{} ({})", self.name, self.eco),
}
}
/// The denylist and OSV only flag packages known to be malicious; the age check only holds
/// back new ones.
fn malicious(&self) -> bool {
self.filter != "age"
}
/// Malware is never allowed, and a new package only by version.
fn trustable(&self) -> bool {
!self.malicious() && self.version.is_some()
}
} }
impl Tray { impl Tray {
fn update(&mut self) { fn update(&mut self) {
self.state = service_state(); self.state = service_status().map(|status| status.current_state);
let screening = self.state == Some(ServiceState::Running); let screening = self.state == Some(ServiceState::Running);
let light = light_taskbar(); let light = light_taskbar();
if self.drawn != Some((light, screening)) { if self.drawn != Some((light, screening)) {
@@ -117,7 +172,85 @@ impl Tray {
self.pause.set_enabled(self.state.is_some()); self.pause.set_enabled(self.state.is_some());
} }
/// The service hands each block over once, so whichever tray asks first shows it.
fn collect_blocks(&mut self) {
let allowed: Vec<_> = self.allowed.1.try_iter().collect();
if !allowed.is_empty() {
self.blocked.retain(|blocked| !allowed.contains(blocked));
self.list_blocked();
}
if self.state != Some(ServiceState::Running) {
return;
}
let Ok(reply) = ask_service("blocks") else {
return;
};
let Ok(packages) = serde_json::from_str::<Vec<BlockedPackage>>(&reply) else {
return;
};
let mut new = Vec::new();
for package in packages {
if !self.blocked.contains(&package) {
self.blocked.push(package.clone());
new.push(package);
}
}
if new.is_empty() {
return;
}
let excess = self.blocked.len().saturating_sub(BLOCKED_KEPT);
self.blocked.drain(..excess);
self.list_blocked();
// Off the UI thread, so the menu keeps working while they are open.
let allowed = self.allowed.0.clone();
std::thread::spawn(move || {
for package in new {
if alert(&package) {
allow_once(package, &allowed);
}
}
});
}
fn list_blocked(&self) {
while self.menu.items().len() > BLOCKED_AT + 2 {
self.menu.remove_at(BLOCKED_AT);
}
let _ = self.menu.insert(&self.blocked_separator, BLOCKED_AT);
for (i, package) in self.blocked.iter().enumerate() {
let verb = if package.malicious() {
"Blocked"
} else {
"Held back"
};
let reason = if package.malicious() {
format!("Malicious: {}", package.reason)
} else {
package.reason.clone()
};
let reason = MenuItem::new(reason, false, None);
let once = MenuItem::with_id(format!("{ALLOW_ONCE}:{i}"), "Allow Once", true, None);
let items: &[&dyn IsMenuItem] = if package.trustable() {
&[&reason, &once]
} else {
&[&reason]
};
let label = format!("{verb} {}", package.label());
if let Ok(item) = Submenu::with_items(label, true, items) {
let _ = self.menu.insert(&item, BLOCKED_AT + 1 + i);
}
}
}
fn on_menu(&self, id: &str) { fn on_menu(&self, id: &str) {
if let Some(i) = id.strip_prefix(ALLOW_ONCE).and_then(|i| i.strip_prefix(':')) {
let package = i.parse().ok().and_then(|i: usize| self.blocked.get(i));
if let Some(package) = package.cloned() {
let allowed = self.allowed.0.clone();
std::thread::spawn(move || allow_once(package, &allowed));
}
return;
}
let state = self.state; let state = self.state;
let screening = state == Some(ServiceState::Running); let screening = state == Some(ServiceState::Running);
let action: fn() -> Result<(), BoxError> = match id { let action: fn() -> Result<(), BoxError> = match id {
@@ -143,6 +276,62 @@ impl Tray {
} }
} }
/// Whether the user chose to allow it once.
fn alert(package: &BlockedPackage) -> bool {
let text = format!("{} {}.", package.label(), package.reason);
let (title, text, style) = if package.malicious() {
let title = format!("Blocked malware: {}", package.label());
let text = text
+ "\n\nIt was not installed. Don't install it from another source. If you didn't expect this "
+ "package, a dependency may be compromised; tell your security team.";
(title, text, MB_ICONERROR)
} else if !package.trustable() {
("Held back a new package".to_owned(), text, MB_ICONINFORMATION)
} else {
let text = text + "\n\nAllow it once?";
("Held back a new package".to_owned(), text, MB_YESNO | MB_ICONINFORMATION)
};
let (title, text) = (HSTRING::from(title), HSTRING::from(text));
unsafe { MessageBoxW(None, &text, &title, style | MB_SETFOREGROUND) == IDYES }
}
/// Tells the user when the install had already stopped waiting.
fn allow_once(package: BlockedPackage, allowed: &Sender<BlockedPackage>) {
let reply = match ask_service(&format!("{ALLOW_ONCE} {}", package.id)) {
Ok(reply) => reply,
Err(error) => return show_error(error),
};
match reply.as_str() {
"ok" => {}
"late" => {
let title = format!("Allowed {} for the next minute", package.label());
let text = "The install had already stopped waiting for it. Run it again to finish.";
let (title, text) = (HSTRING::from(title), HSTRING::from(text));
unsafe { MessageBoxW(None, &text, &title, MB_ICONINFORMATION | MB_SETFOREGROUND) };
}
other => return show_error(other.into()),
}
let _ = allowed.send(package);
}
/// One request per connection, as the service answers.
fn ask_service(request: &str) -> Result<String, BoxError> {
let mut pipe = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(PIPE)?;
// Any program can create the pipe if it gets there before the service.
let mut server = 0;
unsafe { GetNamedPipeServerProcessId(HANDLE(pipe.as_raw_handle()), &mut server)? };
if service_status().and_then(|status| status.process_id) != Some(server) {
return Err("Another program is posing as the firewall service.".into());
}
pipe.write_all(format!("{request}\n").as_bytes())?;
let mut reply = String::new();
BufReader::new(pipe).read_line(&mut reply)?;
Ok(reply.trim_end().to_owned())
}
fn show_error(error: BoxError) { fn show_error(error: BoxError) {
let text = match error.source() { let text = match error.source() {
Some(source) => format!("{error}: {source}"), Some(source) => format!("{error}: {source}"),
@@ -153,13 +342,13 @@ fn show_error(error: BoxError) {
} }
/// `None` when the service is not installed. /// `None` when the service is not installed.
fn service_state() -> Option<ServiceState> { fn service_status() -> Option<ServiceStatus> {
let manager = let manager =
ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT).ok()?; ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT).ok()?;
let service = manager let service = manager
.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS) .open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS)
.ok()?; .ok()?;
Some(service.query_status().ok()?.current_state) service.query_status().ok()
} }
fn light_taskbar() -> bool { fn light_taskbar() -> bool {
@@ -167,7 +356,8 @@ fn light_taskbar() -> bool {
let Ok(key) = CURRENT_USER.open(personalize) else { let Ok(key) = CURRENT_USER.open(personalize) else {
return false; return false;
}; };
key.get_u32("SystemUsesLightTheme").is_ok_and(|light| light == 1) key.get_u32("SystemUsesLightTheme")
.is_ok_and(|light| light == 1)
} }
fn status_text(state: Option<ServiceState>) -> &'static str { fn status_text(state: Option<ServiceState>) -> &'static str {
+1 -1
View File
@@ -143,7 +143,7 @@ pub fn describe(advisories: &[&Advisory]) -> String {
/// A failed fetch leaves the last good list in place; a list that never loaded is retried every tick. /// A failed fetch leaves the last good list in place; a list that never loaded is retried every tick.
async fn watch(source: DenylistSource, lists: Arc<Lists>) { async fn watch(source: DenylistSource, lists: Arc<Lists>) {
let period = Duration::from_secs(source.poll_secs); let period = Duration::from_secs(source.poll_secs.get());
let mut seen = revision(&source, None).await; let mut seen = revision(&source, None).await;
loop { loop {
tokio::time::sleep(period).await; tokio::time::sleep(period).await;
+11 -5
View File
@@ -3,6 +3,7 @@ pub mod denylist;
pub mod osv; pub mod osv;
use super::registries::{Package, Registry}; use super::registries::{Package, Registry};
use crate::config::AllowedPackage;
use crate::Firewall; use crate::Firewall;
use std::future::Future; use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
@@ -25,16 +26,19 @@ pub trait Filter: Send + Sync {
} }
/// Asks every filter. A `Deny` wins; failing that, the first filter that could not tell. /// 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( pub async fn consult(
firewall: &Firewall, firewall: &Firewall,
registry: &'static Registry, registry: &'static Registry,
pkg: &Package, pkg: &Package,
) -> Option<(&'static str, Verdict)> { ) -> Option<(&'static str, Verdict)> {
if allowed(firewall, registry, pkg) { let lifted = allowed(firewall, registry, pkg)
return None; || firewall.age_check_lifted(registry.eco, |entry| matches(registry, pkg, entry));
}
let mut unsure = None; let mut unsure = None;
for filter in firewall.filters() { for filter in firewall.filters() {
if lifted && filter.name() == "age" {
continue;
}
match filter.check(registry, pkg).await { match filter.check(registry, pkg).await {
Verdict::Allow => continue, Verdict::Allow => continue,
Verdict::Deny(reason) => return Some((filter.name(), Verdict::Deny(reason))), Verdict::Deny(reason) => return Some((filter.name(), Verdict::Deny(reason))),
@@ -49,7 +53,10 @@ fn allowed(firewall: &Firewall, registry: &Registry, pkg: &Package) -> bool {
let Some(entries) = firewall.config.allow.get(registry.eco) else { let Some(entries) = firewall.config.allow.get(registry.eco) else {
return false; return false;
}; };
entries.iter().any(|entry| { entries.iter().any(|entry| matches(registry, pkg, entry))
}
fn matches(registry: &Registry, pkg: &Package, entry: &AllowedPackage) -> bool {
let name_matches = match registry.normalize { let name_matches = match registry.normalize {
Some(normalize) => normalize(&entry.name) == pkg.name, Some(normalize) => normalize(&entry.name) == pkg.name,
None => entry.name == pkg.name, None => entry.name == pkg.name,
@@ -57,5 +64,4 @@ fn allowed(firewall: &Firewall, registry: &Registry, pkg: &Package) -> bool {
// Go writes a leading v that the parsed version drops, and PEP 440 ignores one. // 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)); 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()) name_matches && (version.is_none() || version == pkg.version.as_deref())
})
} }
+3 -1
View File
@@ -41,7 +41,9 @@ impl Filter for Osv {
if hits.is_empty() { if hits.is_empty() {
Verdict::Allow Verdict::Allow
} else { } else {
Verdict::Deny(format!("is flagged malicious by OSV ({})", hits.join(", "))) let links: Vec<String> =
hits.iter().map(|id| format!("{id} https://osv.dev/vulnerability/{id}")).collect();
Verdict::Deny(format!("is flagged malicious by OSV: {}", links.join(", ")))
} }
}) })
} }
+4 -1
View File
@@ -70,7 +70,10 @@ pub async fn screen(url: &str, firewall: &Firewall) -> Option<String> {
info!(eco = registry.eco, pkg = %pkg.label(), %url, "screening"); info!(eco = registry.eco, pkg = %pkg.label(), %url, "screening");
match consult(firewall, registry, &pkg).await { match consult(firewall, registry, &pkg).await {
Some((filter, Verdict::Deny(reason))) => { Some((filter, Verdict::Deny(reason))) => {
crate::blocks::record(registry.eco, &pkg, filter, &reason); if crate::blocks::block(registry.eco, &pkg, filter, &reason).await {
info!(eco = registry.eco, pkg = %pkg.label(), "allowed while held");
return None;
}
Some(format!("{} {reason}", pkg.label())) Some(format!("{} {reason}", pkg.label()))
} }
Some((filter, Verdict::Unknown(e))) if !firewall.config.fail_open => { Some((filter, Verdict::Unknown(e))) if !firewall.config.fail_open => {
+7 -2
View File
@@ -36,7 +36,7 @@ pub struct Pattern {
} }
/// Requests to the local server match on `prefix`; intercepted URLs match on `upstream`. /// Requests to the local server match on `prefix`; intercepted URLs match on `upstream`.
#[derive(Debug)] #[derive(Debug, Clone)]
pub struct Registry { pub struct Registry {
pub prefix: &'static str, pub prefix: &'static str,
pub eco: &'static str, pub eco: &'static str,
@@ -52,7 +52,7 @@ pub struct Registry {
} }
/// A URL to fetch and a JSON pointer to the timestamp in its reply. /// A URL to fetch and a JSON pointer to the timestamp in its reply.
#[derive(Debug)] #[derive(Debug, Clone)]
pub struct Published { pub struct Published {
pub url: &'static str, pub url: &'static str,
pub pointer: &'static str, pub pointer: &'static str,
@@ -63,6 +63,11 @@ impl Registry {
pub fn host(&self) -> &'static str { pub fn host(&self) -> &'static str {
host_of(self.upstream) host_of(self.upstream)
} }
/// What the config file calls it.
pub fn name(&self) -> &'static str {
self.prefix.trim_matches('/')
}
} }
/// The host of an https:// URL, with the path dropped. /// The host of an https:// URL, with the path dropped.
+1 -1
View File
@@ -15,7 +15,7 @@ use std::sync::Arc;
use tracing::{info, info_span, warn, Instrument}; use tracing::{info, info_span, warn, Instrument};
pub fn serve(config: FirewallConfig) -> std::io::Result<()> { pub fn serve(config: FirewallConfig) -> std::io::Result<()> {
let bound = std::net::TcpListener::bind(("127.0.0.1", config.port))?; let bound = std::net::TcpListener::bind(("127.0.0.1", config.port.get()))?;
bound.set_nonblocking(true)?; bound.set_nonblocking(true)?;
crate::runtime()?.block_on(async move { crate::runtime()?.block_on(async move {
let firewall = Arc::new(Firewall::new(config)); let firewall = Arc::new(Firewall::new(config));
+27
View File
@@ -0,0 +1,27 @@
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);
}
+56 -8
View File
@@ -1,4 +1,4 @@
use firewall::config::path; use firewall::config::{path, AllowedPackage};
use firewall::policies::filters::consult; use firewall::policies::filters::consult;
use firewall::policies::{screen, Package}; use firewall::policies::{screen, Package};
use firewall::{Firewall, FirewallConfig}; use firewall::{Firewall, FirewallConfig};
@@ -7,7 +7,7 @@ use firewall::{Firewall, FirewallConfig};
fn a_file_only_has_to_set_what_it_changes() { fn a_file_only_has_to_set_what_it_changes() {
let config: FirewallConfig = serde_json::from_str(r#"{"fail_open": false}"#).expect("parses"); let config: FirewallConfig = serde_json::from_str(r#"{"fail_open": false}"#).expect("parses");
assert!(!config.fail_open); assert!(!config.fail_open);
assert_eq!(config.port, firewall::DEFAULT_PORT); assert_eq!(config.port.get(), firewall::DEFAULT_PORT);
} }
#[test] #[test]
@@ -15,6 +15,28 @@ fn a_misspelled_setting_is_an_error() {
assert!(serde_json::from_str::<FirewallConfig>(r#"{"fail-open": false}"#).is_err()); assert!(serde_json::from_str::<FirewallConfig>(r#"{"fail-open": false}"#).is_err());
} }
#[test]
fn nonsense_values_are_errors() {
let file = std::env::temp_dir().join(format!("firewall-nonsense-{}.json", std::process::id()));
for bad in [
r#"{"allow": {"nmp": [{"name": "left-pad"}]}}"#,
r#"{"denylist": {"poll_secs": 0}}"#,
r#"{"denylist": {"base": "raw.githubusercontent.com/usestrix"}}"#,
r#"{"denylist": {"revision": "https://"}}"#,
r#"{"port": 0}"#,
r#"{"min_age_hours": 18446744073709551615}"#,
r#"{"registries": {"npm": ["http://npm.example.com/"]}}"#,
r#"{"registries": {"npm": ["https://npm.example.com"]}}"#,
r#"{"registries": {"npm": ["https://npm.example.com:8443/"]}}"#,
r#"{"registries": {"npm": ["https://[email protected]/"]}}"#,
r#"{"registries": {"npm": ["https://npm.example.com/?a=/"]}}"#,
] {
std::fs::write(&file, bad).expect("write");
assert!(FirewallConfig::load_from(std::slice::from_ref(&file)).is_err(), "{bad}");
}
std::fs::remove_file(&file).ok();
}
#[test] #[test]
fn the_config_lives_under_the_config_directory() { fn the_config_lives_under_the_config_directory() {
let path = path().expect("a home directory"); let path = path().expect("a home directory");
@@ -39,14 +61,29 @@ fn a_later_file_wins_and_allow_lists_add_up() {
} }
#[tokio::test] #[tokio::test]
async fn an_allowed_version_skips_the_filters() { async fn a_mirror_is_screened_like_the_registry_it_copies() {
// An unreachable denylist would otherwise leave the package unchecked. let config = r#"{"registries": {"npm": ["https://npm.example.com/"]}}"#;
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
assert!(firewall.hosts().contains(&"npm.example.com"));
let url = "https://npm.example.com/left-pad/-/left-pad-1.3.0.tgz";
let (registry, rest) = firewall.match_on(url, |r| r.upstream).expect("the mirror");
assert_eq!(firewall.resolve(registry, rest).pkg.map(|p| p.label()), Some("[email protected]".into()));
let unknown = std::env::temp_dir().join(format!("firewall-mirror-{}.json", std::process::id()));
std::fs::write(&unknown, r#"{"registries": {"nmp": ["https://npm.example.com/"]}}"#).expect("write");
let loaded = FirewallConfig::load_from(std::slice::from_ref(&unknown));
std::fs::remove_file(&unknown).ok();
assert!(loaded.is_err());
}
#[tokio::test]
async fn an_allowed_version_is_still_checked_for_malware() {
// The unreachable denylist shows that it still runs.
let config = r#"{"allow": {"go": [{"name": "golang.org/x/net", "version": "v0.20.0"}]}, "denylist": {"base": "http://127.0.0.1:1"}}"#; let 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 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 (registry, _) = firewall.match_on("/go/golang.org/x/net/@v/list", |r| r.prefix).expect("go");
let pkg = |version: &str| Package { name: "golang.org/x/net".into(), version: Some(version.into()) }; let pkg = Package { name: "golang.org/x/net".into(), version: Some("0.20.0".into()) };
assert!(consult(&firewall, registry, &pkg("0.20.0")).await.is_none()); let verdict = consult(&firewall, registry, &pkg).await;
assert!(consult(&firewall, registry, &pkg("0.21.0")).await.is_some()); assert_eq!(verdict.map(|(filter, _)| filter), Some("denylist"));
} }
#[tokio::test] #[tokio::test]
@@ -58,12 +95,23 @@ async fn failing_closed_blocks_what_could_not_be_checked() {
assert!(blocked.contains("could not be checked"), "{blocked:?}"); 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] #[test]
fn allowing_keeps_the_rest_of_the_file() { fn allowing_keeps_the_rest_of_the_file() {
let path = std::env::temp_dir().join(format!("firewall-allow-{}.json", std::process::id())); let path = std::env::temp_dir().join(format!("firewall-allow-{}.json", std::process::id()));
std::fs::write(&path, r#"{"fail_open": false}"#).expect("write"); std::fs::write(&path, r#"{"fail_open": false}"#).expect("write");
for _ in 0..2 { for _ in 0..2 {
firewall::config::allow(&path, "npm", "left-pad", Some("1.3.0")).expect("allow"); firewall::config::allow(&path, "npm", "left-pad", "1.3.0").expect("allow");
} }
let text = std::fs::read_to_string(&path).expect("read"); let text = std::fs::read_to_string(&path).expect("read");
std::fs::remove_file(&path).ok(); std::fs::remove_file(&path).ok();
+44 -8
View File
@@ -2,13 +2,13 @@ import AppKit
import Security import Security
struct BlockedPackage: Codable, Hashable, Identifiable { struct BlockedPackage: Codable, Hashable, Identifiable {
let id: UInt64
let eco: String let eco: String
let name: String let name: String
let version: String? let version: String?
let filter: String let filter: String
let reason: String let reason: String
var id: String { "\(eco)/\(name)@\(version ?? "")" }
var label: String { version.map { "\(name) \($0)" } ?? name } 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. /// The denylist and OSV only flag packages known to be malicious; the age check only holds back new ones.
var malicious: Bool { filter != "age" } var malicious: Bool { filter != "age" }
@@ -16,25 +16,61 @@ struct BlockedPackage: Codable, Hashable, Identifiable {
var trustable: Bool { !malicious && version != nil } 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 @MainActor
enum BlockAlert { enum BlockAlert {
/// Returns whether the user chose to trust the package. static func show(_ package: BlockedPackage) -> Allowance? {
static func show(_ package: BlockedPackage) -> Bool {
let alert = NSAlert() let alert = NSAlert()
alert.addButton(withTitle: "OK") alert.addButton(withTitle: "OK")
if package.malicious { if package.malicious {
alert.alertStyle = .critical alert.alertStyle = .critical
alert.messageText = "Blocked malware: \(package.label) (\(package.eco))" alert.messageText = "Blocked malware: \(package.label) (\(package.eco))"
alert.informativeText = "\(package.label) \(package.reason).\n\n" setText(alert, "\(package.label) \(package.reason).\n\n"
+ "It was not installed. Don't install it from another source. " + "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." + "If you didn't expect this package, a dependency may be compromised; tell your security team.")
} else { } else {
alert.messageText = "Held back a new package" alert.messageText = "Held back a new package"
alert.informativeText = "\(package.label) (\(package.eco)) \(package.reason)." setText(alert, "\(package.label) (\(package.eco)) \(package.reason).")
if package.trustable { alert.addButton(withTitle: "Trust this version") } if package.trustable {
alert.addButton(withTitle: "Allow Once")
alert.addButton(withTitle: "Trust this version")
}
} }
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
return alert.runModal() == .alertSecondButtonReturn 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()
}
/// An alert's own text is plain, so this text goes in a selectable field, where its links open on click.
private static func setText(_ alert: NSAlert, _ text: String) {
let linked = NSMutableAttributedString(string: text, attributes: [.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)])
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
for match in detector.matches(in: text, range: NSRange(text.startIndex..., in: text)) {
if let url = match.url { linked.addAttribute(.link, value: url, range: match.range) }
}
let field = NSTextField(wrappingLabelWithString: "")
field.allowsEditingTextAttributes = true
field.isSelectable = true
field.attributedStringValue = linked
field.preferredMaxLayoutWidth = 300
field.frame.size = field.fittingSize
alert.accessoryView = field
} }
} }
+10 -14
View File
@@ -203,26 +203,22 @@ final class FirewallModel: NSObject, ObservableObject {
if let url = URL(string: pane) { NSWorkspace.shared.open(url) } 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. /// Trusting changes the config for every user, so it takes an admin.
func trust(_ package: BlockedPackage) async { func allow(_ package: BlockedPackage, _ allowance: Allowance) async {
guard package.trustable else { return } guard package.trustable else { return }
guard AdminAuthorization.confirm() else { if allowance == .always, !AdminAuthorization.confirm() {
log("Trust cancelled for \(package.label)") log("Trust cancelled for \(package.label)")
return return
} }
do { do {
var request = ["eco": package.eco, "name": package.name] let reply = try await providerMessage("\(allowance == .once ? "allow-once" : "allow") \(package.id)")
request["version"] = package.version guard reply == "ok" || reply == "late" else { throw FirewallError.extensionRefused(reply) }
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 } blocked.removeAll { $0 == package }
log("Trusted \(package.label) (\(package.eco))") log("Allowed \(package.label) (\(package.eco)) \(allowance)")
if reply == "late" { BlockAlert.late(package, allowance) }
} catch { } catch {
log("Could not trust \(package.label): \(error.localizedDescription)") log("Could not allow \(package.label): \(error.localizedDescription)")
return
} }
await restart()
} }
/// The extension cannot message the app, so the app asks it for blocks. /// The extension cannot message the app, so the app asks it for blocks.
@@ -232,11 +228,11 @@ final class FirewallModel: NSObject, ObservableObject {
let packages = try? JSONDecoder().decode([BlockedPackage].self, from: Data(reply.utf8)) { let packages = try? JSONDecoder().decode([BlockedPackage].self, from: Data(reply.utf8)) {
for package in packages where !blocked.contains(package) { for package in packages where !blocked.contains(package) {
blocked.append(package) blocked.append(package)
if BlockAlert.show(package) { await trust(package) } if let allowance = BlockAlert.show(package) { await allow(package, allowance) }
} }
blocked = Array(blocked.suffix(10)) blocked = Array(blocked.suffix(10))
} }
try? await Task.sleep(nanoseconds: 2_000_000_000) try? await Task.sleep(nanoseconds: 500_000_000)
} }
} }
+5 -1
View File
@@ -29,8 +29,12 @@ struct MenuBarView: View {
if !package.malicious { if !package.malicious {
Menu("Held back \(package.label) (\(package.eco))") { Menu("Held back \(package.label) (\(package.eco))") {
Text(package.reason) Text(package.reason)
Button("Allow Once") {
Task { await model.allow(package, .once) }
}
.disabled(model.busy || !package.trustable)
Button("Trust this version") { Button("Trust this version") {
Task { await model.trust(package) } Task { await model.allow(package, .always) }
} }
.disabled(model.busy || !package.trustable) .disabled(model.busy || !package.trustable)
} }