Compare commits

...
28 changed files with 437 additions and 114 deletions

No files matched your search

+73 -13
View File
@@ -935,7 +935,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
"wasi 0.11.1+wasi-snapshot-preview1",
]
[[package]]
@@ -1331,9 +1331,9 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.105"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"futures-util",
@@ -1500,7 +1500,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.61.2",
]
@@ -1630,6 +1630,24 @@ dependencies = [
"autocfg",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "objc2-system-configuration"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
@@ -3111,6 +3129,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"which",
"whoami",
]
[[package]]
@@ -3588,6 +3607,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasi"
version = "0.14.7+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
dependencies = [
"wasip2",
]
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
@@ -3598,10 +3626,19 @@ dependencies = [
]
[[package]]
name = "wasm-bindgen"
version = "0.2.128"
name = "wasite"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
dependencies = [
"wasi 0.14.7+wasi-0.2.4",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
@@ -3612,9 +3649,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.128"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -3622,9 +3659,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.128"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -3635,13 +3672,23 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.128"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
@@ -3669,6 +3716,19 @@ dependencies = [
"libc",
]
[[package]]
name = "whoami"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
dependencies = [
"libc",
"libredox",
"objc2-system-configuration",
"wasite",
"web-sys",
]
[[package]]
name = "wildcard"
version = "0.3.0"
+1
View File
@@ -72,6 +72,7 @@ tokio = { version = "1.53.1", features = ["io-util", "rt", "sync", "time"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
which = "8"
whoami = "2.1.3"
[target.'cfg(target_vendor = "apple")'.dependencies]
core-foundation = "0.10.1"
+1 -1
View File
@@ -82,7 +82,7 @@ async fn handle(req: Request, firewall: &Firewall) -> Result<Response, Infallibl
};
let reason = policies::screen(&url, firewall).await;
telemetry::screened(firewall, &url, reason.is_some());
telemetry::screened(firewall, &url, reason.as_deref());
if let Some(reason) = reason {
warn!(method = %req.method(), %url, %reason, "blocked");
return Ok(blocked(&reason));
+58 -13
View File
@@ -1,6 +1,7 @@
use crate::policies::registries::{Registry, BUILT_IN};
use directories::ProjectDirs;
use serde::Deserialize;
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::io::{Error, ErrorKind};
use std::path::PathBuf;
@@ -17,21 +18,35 @@ pub struct FirewallConfig {
pub min_age_hours: u64,
pub denylist: DenylistSource,
/// Packages never screened, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
pub allow: HashMap<String, Vec<AllowedPackage>>,
pub allow: PackageList,
pub telemetry: bool,
/// Reports installs to, and takes blocks from, a Strix workspace.
pub platform: Option<PlatformConfig>,
/// Not describable in the file yet, so this is always the built-in table.
#[serde(skip)]
pub registries: Vec<&'static Registry>,
}
/// Packages by ecosystem, as the allow list and organization blocks name them.
pub type PackageList = HashMap<String, Vec<ListedPackage>>;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AllowedPackage {
pub struct ListedPackage {
pub name: String,
/// Every version when absent.
pub version: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlatformConfig {
pub api_key: String,
/// Where a self-hosted Strix lives.
#[serde(default = "hosted")]
pub url: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct DenylistSource {
@@ -51,6 +66,7 @@ impl Default for FirewallConfig {
denylist: DenylistSource::default(),
allow: HashMap::new(),
telemetry: true,
platform: None,
registries: BUILT_IN.to_vec(),
}
}
@@ -68,18 +84,31 @@ impl Default for DenylistSource {
}
impl FirewallConfig {
/// A missing file means defaults; a bad one is an error, so the policy is never quietly relaxed.
pub fn load() -> std::io::Result<Self> {
let Some(path) = path() else {
return Ok(Self::default());
};
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Self::default()),
Err(e) => return Err(Error::new(e.kind(), format!("{}: {e}", path.display()))),
};
serde_json::from_str(&text)
.map_err(|e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display())))
let mut paths: Vec<PathBuf> = path().into_iter().collect();
paths.extend(crate::os::system_config());
Self::load_from(&paths)
}
/// Later files win key by key, but allow lists add up.
/// A bad file is an error, so the policy is never quietly relaxed.
pub fn load_from(paths: &[PathBuf]) -> std::io::Result<Self> {
let mut merged = Map::new();
for path in paths {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == ErrorKind::NotFound => continue,
Err(e) => return Err(Error::new(e.kind(), 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)?;
FirewallConfig::deserialize(&layer).map_err(invalid)?;
let Value::Object(layer) = layer else {
unreachable!("a config parses only from an object");
};
merge(&mut merged, layer);
}
Ok(serde_json::from_value(Value::Object(merged)).expect("each layer parsed on its own"))
}
pub fn local_url(&self, path: &str) -> String {
@@ -99,7 +128,23 @@ impl FirewallConfig {
}
}
fn hosted() -> String {
"https://app.usestrix.com".into()
}
pub fn path() -> Option<PathBuf> {
let dirs = ProjectDirs::from("com", "usestrix", "firewall")?;
Some(dirs.config_dir().join("config.json"))
}
fn merge(base: &mut Map<String, Value>, top: Map<String, Value>) {
for (key, value) in top {
match (base.get_mut(&key), value) {
(Some(Value::Object(base)), Value::Object(top)) => merge(base, top),
(Some(Value::Array(base)), Value::Array(top)) => base.extend(top),
(_, value) => {
base.insert(key, value);
}
}
}
}
+7 -1
View File
@@ -1,6 +1,7 @@
use crate::config::FirewallConfig;
use crate::policies::filters::age::Age;
use crate::policies::filters::denylist::Denylist;
use crate::policies::filters::organization::OrganizationBlocks;
use crate::policies::filters::osv::Osv;
use crate::policies::filters::Filter;
use crate::policies::registries::*;
@@ -28,7 +29,12 @@ impl Firewall {
}
let ecosystems = config.registries.iter().map(|r| r.osv_name);
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::new();
if let Some(platform) = &config.platform {
filters.push(Box::new(OrganizationBlocks::new(platform.clone())));
}
filters.push(Box::new(denylist));
filters.push(Box::new(Osv));
if config.min_age_hours > 0 {
filters.push(Box::new(Age::new(config.min_age_hours)));
}
+1
View File
@@ -4,6 +4,7 @@ pub mod firewall;
pub mod http;
pub mod mitm;
pub mod os;
mod platform;
pub mod policies;
pub mod proxy;
pub mod telemetry;
+2
View File
@@ -56,6 +56,7 @@ fn options() -> Command {
}
fn serve() -> ! {
telemetry::set_mode("proxy");
let config = load();
let port = config.port;
print_setup(&config);
@@ -83,6 +84,7 @@ fn uninstall() -> ! {
}
fn run(invocation: Invocation) -> ! {
telemetry::set_mode("wrapper");
let command = &invocation.program;
let port = match aliasing::spawn(0, load()) {
Ok(port) => port,
+1 -1
View File
@@ -37,7 +37,7 @@ where
async fn serve(&self, req: Request) -> Result<Response, Self::Error> {
if let Some(url) = https_url(&req) {
let reason = policies::screen(&url, &self.firewall).await;
telemetry::screened(&self.firewall, &url, reason.is_some());
telemetry::screened(&self.firewall, &url, reason.as_deref());
if let Some(reason) = reason {
warn!(%url, "blocked by firewall: {reason}");
return Ok(blocked(&reason));
+8 -1
View File
@@ -23,6 +23,7 @@ use rama::rt::Executor;
use rama::telemetry::tracing::apple::oslog::{OsLogLayer, Privacy};
use rama::{Layer, Service};
use std::convert::Infallible;
use std::path::PathBuf;
use std::sync::Arc;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
@@ -32,6 +33,7 @@ const KEYCHAIN_SERVICE: &str = BUNDLE_ID;
const KEYCHAIN_ACCOUNT: &str = "ca";
/// Must match `macos/LaunchAgents/com.usestrix.firewall.env.plist`, which points npm at it.
const CA_FILE: &str = "/Library/Application Support/Firewall/ca.pem";
const CONFIG_FILE: &str = "/Library/Application Support/Firewall/config.json";
#[derive(Clone)]
struct Handler {
@@ -97,9 +99,14 @@ impl TransparentProxyHandlerFactory for Factory {
}
}
pub fn system_config() -> Option<PathBuf> {
Some(CONFIG_FILE.into())
}
fn handler(exec: Executor) -> Result<Handler, BoxError> {
telemetry::set_mode("extension");
telemetry::heartbeat();
let config = FirewallConfig::load().inspect_err(|_| telemetry::error("config"))?;
let config = FirewallConfig::load_from(&[CONFIG_FILE.into()]).inspect_err(|_| telemetry::error("config"))?;
let firewall = Arc::new(Firewall::new(config));
let hosts = firewall.hosts();
let key = enclave::EnclaveKey::load_or_create().inspect_err(|_| telemetry::error("enclave"))?;
+6 -1
View File
@@ -2,10 +2,15 @@
pub mod macos;
#[cfg(target_os = "macos")]
pub use macos::uninstall;
pub use macos::{system_config, uninstall};
/// Elsewhere nothing is installed system-wide.
#[cfg(not(target_os = "macos"))]
pub fn uninstall() -> Result<(), rama::error::BoxError> {
Ok(())
}
#[cfg(not(target_os = "macos"))]
pub fn system_config() -> Option<std::path::PathBuf> {
None
}
+73
View File
@@ -0,0 +1,73 @@
use crate::config::PlatformConfig;
use crate::policies::Package;
use crate::FirewallConfig;
use rama::http::client::EasyHttpWebClient;
use rama::http::header::{AUTHORIZATION, CONTENT_TYPE};
use rama::http::service::client::HttpClientExt;
use serde_json::{json, Value};
use std::sync::{LazyLock, Mutex};
use std::time::Duration;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use tracing::{debug, warn};
const TIMEOUT: Duration = Duration::from_secs(10);
/// The most events the platform takes in one request.
const BATCH: usize = 500;
/// On when a readable config names a Strix API key.
static PLATFORM: LazyLock<Option<Platform>> = LazyLock::new(|| {
let config = FirewallConfig::load().ok()?.platform?;
Some(Platform { config, events: Mutex::default() })
});
struct Platform {
config: PlatformConfig,
events: Mutex<Vec<Value>>,
}
pub fn on() -> bool {
PLATFORM.is_some()
}
pub fn record(eco: &str, pkg: &Package, reason: Option<&str>) {
let Some(platform) = &*PLATFORM else { return };
let event = json!({
"ecosystem": eco,
"name": pkg.name,
"version": pkg.version,
"blocked": reason.is_some(),
"reason": reason,
"occurred_at": OffsetDateTime::now_utc().format(&Rfc3339).ok(),
});
platform.events.lock().expect("platform lock").push(event);
}
/// Events in a batch the platform did not take are dropped, not retried.
pub async fn send() {
let Some(platform) = &*PLATFORM else { return };
let events = std::mem::take(&mut *platform.events.lock().expect("platform lock"));
let url = format!("{}/api/v1/firewall/events", platform.config.url);
let client = EasyHttpWebClient::default();
for batch in events.chunks(BATCH) {
let body = json!({
"device": whoami::hostname().ok(),
"user": whoami::username().ok(),
"os": std::env::consts::OS,
"firewall_version": env!("CARGO_PKG_VERSION"),
"events": batch,
});
let request = client
.post(&url)
.header(CONTENT_TYPE, "application/json")
.header(AUTHORIZATION, format!("Bearer {}", platform.config.api_key));
match tokio::time::timeout(TIMEOUT, request.body(body.to_string()).send()).await {
Ok(Ok(response)) if response.status().is_success() => {
debug!(events = batch.len(), "installs reported")
}
Ok(Ok(response)) => warn!(status = %response.status(), %url, "platform refused installs"),
Ok(Err(error)) => warn!(%error, %url, "could not report installs"),
Err(_) => warn!(%url, "reporting installs timed out"),
}
}
}
+6 -3
View File
@@ -1,8 +1,10 @@
pub mod age;
pub mod denylist;
pub mod organization;
pub mod osv;
use super::registries::{Package, Registry};
use crate::config::PackageList;
use crate::Firewall;
use std::future::Future;
use std::pin::Pin;
@@ -30,7 +32,7 @@ pub async fn consult(
registry: &'static Registry,
pkg: &Package,
) -> Option<(&'static str, Verdict)> {
if allowed(firewall, registry, pkg) {
if listed(&firewall.config.allow, registry, pkg) {
return None;
}
let mut unsure = None;
@@ -45,8 +47,9 @@ pub async fn consult(
unsure
}
fn allowed(firewall: &Firewall, registry: &Registry, pkg: &Package) -> bool {
let Some(entries) = firewall.config.allow.get(registry.eco) else {
/// Whether `list` names `pkg`.
fn listed(list: &PackageList, registry: &Registry, pkg: &Package) -> bool {
let Some(entries) = list.get(registry.eco) else {
return false;
};
entries.iter().any(|entry| {
+94
View File
@@ -0,0 +1,94 @@
use super::{listed, Filter, Verdict, VerdictFuture, AGENT};
use crate::config::{PackageList, PlatformConfig};
use crate::policies::registries::Registry;
use crate::policies::Package;
use rama::http::client::EasyHttpWebClient;
use rama::http::header::{AUTHORIZATION, USER_AGENT};
use rama::http::service::client::HttpClientExt;
use rama::http::{BodyExtractExt, StatusCode};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use tokio::sync::OnceCell;
use tokio::task::JoinHandle;
use tracing::{info, warn};
const POLL: Duration = Duration::from_secs(60);
type Blocks = Arc<OnceCell<RwLock<Arc<Result<PackageList, String>>>>>;
/// Packages a Strix admin blocked, downloaded when the first package is screened.
pub struct OrganizationBlocks {
platform: PlatformConfig,
blocks: Blocks,
watch: JoinHandle<()>,
}
impl OrganizationBlocks {
pub fn new(platform: PlatformConfig) -> Self {
let blocks = Arc::new(OnceCell::new());
let watch = tokio::spawn(watch(platform.clone(), Arc::clone(&blocks)));
OrganizationBlocks { platform, blocks, watch }
}
}
impl Drop for OrganizationBlocks {
fn drop(&mut self) {
self.watch.abort();
}
}
impl Filter for OrganizationBlocks {
fn name(&self) -> &'static str {
"organization"
}
fn check<'a>(&'a self, registry: &'static Registry, pkg: &'a Package) -> VerdictFuture<'a> {
Box::pin(async move {
let load = || async { RwLock::new(Arc::new(load(&self.platform).await)) };
let blocks = self.blocks.get_or_init(load).await;
let blocks = Arc::clone(&blocks.read().expect("blocks lock"));
match &*blocks {
Err(e) => Verdict::Unknown(e.clone()),
Ok(blocks) if listed(blocks, registry, pkg) => {
Verdict::Deny("is blocked by your organization".into())
}
Ok(_) => Verdict::Allow,
}
})
}
}
/// A failed fetch leaves the last good list in place.
async fn watch(platform: PlatformConfig, blocks: Blocks) {
loop {
tokio::time::sleep(POLL).await;
let Some(current) = blocks.get() else { continue };
if let Ok(fresh) = load(&platform).await {
*current.write().expect("blocks lock") = Arc::new(Ok(fresh));
}
}
}
async fn load(platform: &PlatformConfig) -> Result<PackageList, String> {
let blocks = fetch(platform).await;
match &blocks {
Ok(_) => info!("organization blocks loaded"),
Err(error) => warn!(%error, "organization blocks unavailable"),
}
blocks
}
async fn fetch(platform: &PlatformConfig) -> Result<PackageList, String> {
let url = format!("{}/api/v1/firewall/blocklist", platform.url);
let client = EasyHttpWebClient::default();
let request = client
.get(&url)
.header(USER_AGENT, AGENT)
.header(AUTHORIZATION, format!("Bearer {}", platform.api_key));
let response = request.send().await.map_err(|e| e.to_string())?;
if response.status() != StatusCode::OK {
return Err(format!("{url} replied {}", response.status()));
}
let text = response.try_into_string().await.map_err(|e| e.to_string())?;
serde_json::from_str(&text).map_err(|e| e.to_string())
}
+3
View File
@@ -72,6 +72,9 @@ pub async fn screen(url: &str, firewall: &Firewall) -> Option<String> {
Some((_, Verdict::Deny(reason))) => {
Some(format!("{} {reason}", pkg.label()))
}
Some((filter, Verdict::Unknown(e))) if !firewall.config.fail_open => {
Some(format!("{} could not be checked by {filter} ({e})", pkg.label()))
}
_ => None,
}
}
+5 -1
View File
@@ -1,6 +1,6 @@
use crate::http::{blocked, reply, text};
use crate::policies::{self, Check, Decision};
use crate::{Firewall, FirewallConfig};
use crate::{telemetry, Firewall, FirewallConfig};
use rama::http::body::util::BodyExt;
use rama::http::client::EasyHttpWebClient;
use rama::http::header::CONTENT_TYPE;
@@ -19,6 +19,7 @@ pub fn serve(config: FirewallConfig) -> std::io::Result<()> {
bound.set_nonblocking(true)?;
crate::runtime()?.block_on(async move {
let firewall = Arc::new(Firewall::new(config));
telemetry::heartbeat();
let exec = Executor::default();
let listener = TcpListener::try_from_std_tcp_listener(bound, exec.clone())?;
let server = HttpServer::auto(exec).service(service_fn(move |req: Request| {
@@ -44,6 +45,7 @@ async fn screen(target: String, firewall: &Firewall) -> Response {
}
Decision::Block { eco, pkg, filter, reason } => {
let reason = format!("{} {reason}", pkg.label());
telemetry::screened_package(eco, &pkg, Some(&reason));
warn!(%eco, %filter, "blocked by firewall: {reason}");
blocked(&reason)
}
@@ -51,9 +53,11 @@ async fn screen(target: String, firewall: &Firewall) -> Response {
match &check {
Check::NoPackage => {}
Check::Allowed(pkg) => {
telemetry::screened_package(target.eco, pkg, None);
info!(eco = %target.eco, pkg = %pkg.label(), "cleared by every filter")
}
Check::Unverified { pkg, filter, error } => {
telemetry::screened_package(target.eco, pkg, None);
info!(pkg = %pkg.label(), %filter, %error, "filter could not check, allowing")
}
}
+31 -10
View File
@@ -1,10 +1,12 @@
use crate::platform;
use crate::policies::Package;
use crate::{Firewall, FirewallConfig};
use rama::http::client::EasyHttpWebClient;
use rama::http::header::CONTENT_TYPE;
use rama::http::service::client::HttpClientExt;
use rama::rt::blocking::Runtime;
use serde_json::{json, Value};
use std::sync::{LazyLock, Mutex};
use std::sync::{LazyLock, Mutex, OnceLock};
use std::time::Duration;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
@@ -29,35 +31,47 @@ static TELEMETRY: LazyLock<Option<Telemetry>> = LazyLock::new(|| {
})
});
/// How the firewall is running: "extension", "wrapper" or "proxy".
static MODE: OnceLock<&'static str> = OnceLock::new();
struct Telemetry {
key: &'static str,
session: String,
events: Mutex<Vec<Value>>,
}
pub fn screened(firewall: &Firewall, url: &str, blocked: bool) {
if TELEMETRY.is_none() {
pub fn screened(firewall: &Firewall, url: &str, reason: Option<&str>) {
if !on() {
return;
}
let Some((registry, rest)) = firewall.match_on(url, |r| r.upstream) else {
return;
};
let Some(pkg) = firewall.resolve(registry, rest).pkg else {
return;
};
let mut properties = json!({ "eco": registry.eco, "blocked": blocked });
if blocked {
if let Some(pkg) = firewall.resolve(registry, rest).pkg {
screened_package(registry.eco, &pkg, reason);
}
}
pub fn screened_package(eco: &str, pkg: &Package, reason: Option<&str>) {
platform::record(eco, pkg, reason);
let mut properties = json!({ "eco": eco, "blocked": reason.is_some() });
if let Some(reason) = reason {
properties["package"] = pkg.label().into();
properties["reason"] = reason.into();
}
record("package_screened", properties);
}
pub fn set_mode(mode: &'static str) {
MODE.set(mode).ok();
}
pub fn error(kind: &'static str) {
record("error", json!({ "kind": kind }));
}
pub fn heartbeat() {
if TELEMETRY.is_none() {
if !on() {
return;
}
tokio::spawn(async {
@@ -69,7 +83,7 @@ pub fn heartbeat() {
}
pub fn flush() {
if TELEMETRY.is_none() {
if !on() {
return;
}
match Runtime::try_new() {
@@ -78,9 +92,15 @@ pub fn flush() {
}
}
/// PostHog, or the Strix platform when the config names one.
fn on() -> bool {
TELEMETRY.is_some() || platform::on()
}
fn record(event: &str, mut properties: Value) {
let Some(telemetry) = &*TELEMETRY else { return };
properties["distinct_id"] = telemetry.session.clone().into();
properties["mode"] = MODE.get().copied().into();
properties["os"] = std::env::consts::OS.into();
properties["arch"] = std::env::consts::ARCH.into();
properties["$process_person_profile"] = false.into();
@@ -90,6 +110,7 @@ fn record(event: &str, mut properties: Value) {
}
pub async fn send() {
platform::send().await;
let Some(telemetry) = &*TELEMETRY else { return };
let batch = std::mem::take(&mut *telemetry.events.lock().expect("telemetry lock"));
if batch.is_empty() {
+27 -1
View File
@@ -1,6 +1,6 @@
use firewall::config::path;
use firewall::policies::filters::consult;
use firewall::policies::Package;
use firewall::policies::{screen, Package};
use firewall::{Firewall, FirewallConfig};
#[test]
@@ -21,6 +21,23 @@ fn the_config_lives_under_the_config_directory() {
assert!(path.ends_with("config.json"), "{}", path.display());
}
#[test]
fn a_later_file_wins_and_allow_lists_add_up() {
let dir = std::env::temp_dir().join(format!("firewall-layers-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let user = dir.join("user.json");
let system = dir.join("system.json");
std::fs::write(&user, r#"{"min_age_hours": 1, "fail_open": false, "allow": {"npm": [{"name": "a"}]}}"#).expect("write");
std::fs::write(&system, r#"{"min_age_hours": 24, "allow": {"npm": [{"name": "b"}]}}"#).expect("write");
let missing = dir.join("missing.json");
let config = FirewallConfig::load_from(&[user, system, missing]).expect("loads");
std::fs::remove_dir_all(&dir).ok();
assert_eq!(config.min_age_hours, 24);
assert!(!config.fail_open);
let names: Vec<&str> = config.allow["npm"].iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["a", "b"]);
}
#[tokio::test]
async fn an_allowed_version_skips_the_filters() {
// An unreachable denylist would otherwise leave the package unchecked.
@@ -31,3 +48,12 @@ async fn an_allowed_version_skips_the_filters() {
assert!(consult(&firewall, registry, &pkg("0.20.0")).await.is_none());
assert!(consult(&firewall, registry, &pkg("0.21.0")).await.is_some());
}
#[tokio::test]
async fn failing_closed_blocks_what_could_not_be_checked() {
let config = r#"{"fail_open": false, "denylist": {"base": "http://127.0.0.1:1"}}"#;
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
let url = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz";
let blocked = screen(url, &firewall).await.unwrap_or_default();
assert!(blocked.contains("could not be checked"), "{blocked:?}");
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+24
View File
@@ -0,0 +1,24 @@
{
"fill" : {
"solid" : "srgb:0.07000,0.07000,0.07000,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "logo.png",
"name" : "logo",
"position" : {
"scale" : 1.8,
"translation-in-points" : [0, 0]
}
}
]
}
],
"supported-platforms" : {
"squares" : [
"macOS"
]
}
}
@@ -1,68 +0,0 @@
{
"images": [
{
"idiom": "mac",
"size": "16x16",
"scale": "1x",
"filename": "icon_16.png"
},
{
"idiom": "mac",
"size": "16x16",
"scale": "2x",
"filename": "icon_32.png"
},
{
"idiom": "mac",
"size": "32x32",
"scale": "1x",
"filename": "icon_32.png"
},
{
"idiom": "mac",
"size": "32x32",
"scale": "2x",
"filename": "icon_64.png"
},
{
"idiom": "mac",
"size": "128x128",
"scale": "1x",
"filename": "icon_128.png"
},
{
"idiom": "mac",
"size": "128x128",
"scale": "2x",
"filename": "icon_256.png"
},
{
"idiom": "mac",
"size": "256x256",
"scale": "1x",
"filename": "icon_256.png"
},
{
"idiom": "mac",
"size": "256x256",
"scale": "2x",
"filename": "icon_512.png"
},
{
"idiom": "mac",
"size": "512x512",
"scale": "1x",
"filename": "icon_512.png"
},
{
"idiom": "mac",
"size": "512x512",
"scale": "2x",
"filename": "icon_1024.png"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

+16
View File
@@ -114,6 +114,22 @@ final class FirewallModel: NSObject, ObservableObject {
completed = true
log("Setup complete")
offerRestart()
}
/// Apps opened before setup, terminals included, only get the environment the env agent sets once reopened.
private func offerRestart() {
let alert = NSAlert()
alert.messageText = "Restart to finish setting up"
alert.informativeText = "Apps that were already open, such as terminals, can't install packages until they're reopened. Restarting reopens them all."
alert.addButton(withTitle: "Restart")
alert.addButton(withTitle: "Later")
NSApp.activate(ignoringOtherApps: true)
guard alert.runModal() == .alertFirstButtonReturn else { return }
let osascript = Process()
osascript.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
osascript.arguments = ["-e", "tell application \"loginwindow\" to «event aevtrest»"]
try? osascript.run()
}
/// After the admin gate, every stage runs even if an earlier one fails, so a partial uninstall never strands the rest.