Compare commits

..
Author SHA1 Message Date
Ian d2d9062ba3 Show a popup when pausing or resuming screening fails
The service now answers Pause and Continue only after rewriting the hosts
file, returning the write's error so the tray's ControlService call fails
and shows it. The tray's popup includes the error's source, which
windows-service's own message hides.
2026-10-22 16:54:42 -04:00
11 changed files with 30 additions and 207 deletions

No files matched your search

-93
View File
@@ -1,93 +0,0 @@
# Strix's Dependency Firewall
Stops malicious packages before they install. Built by [Strix](https://strix.ai/blog/firewall-announcement).
Screening runs on your device: downloads go straight to the registry, never through a Strix server.
Each package's name and version are looked up [Strix's malicious-package blocklist](https://github.com/usestrix/firewall-blocklist), so installs are blocked system-wide *before* reaching your machine.
## Install
**Machine-wide**, from the [latest release](https://github.com/usestrix/firewall/releases/latest):
- macOS: open `Firewall.pkg`, then approve the extension when macOS asks.
- Windows: run the `.msi` as an admin.
**Prefix-mode**
cargo:
```sh
cargo install strix-firewall
```
curl:
```sh
curl -LsSf https://github.com/usestrix/firewall/releases/latest/download/strix-firewall-installer.sh | sh
```
Powershell:
```powershell
powershell -c "irm https://github.com/usestrix/firewall/releases/latest/download/strix-firewall-installer.ps1 | iex"
```
## Platforms
| | Machine-wide | Command prefix |
|---|---|---|
| macOS 13+ | ✅ Apple silicon or T2 chip | ✅ |
| Windows 10/11, x86-64 | ✅ with a TPM 2.0 | ✅ |
| Linux (glibc 2.35+), x86-64 or arm64 | 🚧 Coming Soon | ✅ |
## Supported registries
| Registry | Package managers |
|---|---|
| npm | npm, pnpm, bun |
| PyPI | pip, uv, pdm, pipenv |
| crates.io | cargo |
| Go module proxy | go |
| RubyGems | gem |
Mirrors of these, such as Artifactory or Nexus, can be added in via a config file.
## Features
- **Up-to-the-minute data:** Strix constantly monitors updates to top packages (covering 99% of installed packages) to scan for malware, and publishes the results. We also consult [OSSF's blocklist](https://github.com/ossf/malicious-packages) and [OSV](osv.dev), as well as other public reports. Updates take effect without any user action.
- **Holds back new versions.** New packages are blocked by default, since malware is usually detected within a day. (Although this can be disabled, configured, and bypassed).
- **Alerts you.** On macOS and Windows a popup names the blocked package and links its advisory.
- **Two modes:**
- **Machine-wide:** screens downloads from every app, across your entire machine.
- **Command prefix:** e.g., `firewall npm install left-pad`, or create an alias with `alias npm='firewall npm`.
## Configuration
Settings are in a `config.json`:
- Machine-wide: `/Library/Application Support/Firewall/` on macOS, `%ProgramData%\Strix Firewall\`
on Windows. Only an admin can edit it; restart from the menu bar or tray to apply.
- Command prefix: `~/.config/firewall/` on Linux, `~/Library/Application Support/com.usestrix.firewall/`
on macOS, `%APPDATA%\usestrix\firewall\config\` on Windows.
```json
{
"min_age_hours": 48,
"allow": { "npm": [{ "name": "left-pad", "version": "1.3.0" }] },
"registries": {
"npm": ["https://npm.example.com/"],
"pypi": ["https://pypi.example.com/"]
},
"telemetry": false
}
```
| Key | Default | |
|---|---|---|
| `min_age_hours` | `24` | How long new versions are held back. `0` turns this off. |
| `allow` | none | Packages exempt from the hold, by ecosystem (`npm`, `pypi`, `cargo`, `gem`, `go`). Omit `version` for every version. Known malware is still blocked. |
| `registries` | none | Mirrors, by the registry they copy (`npm`, `pypi`, `gem`, `go`, `cargo/index`). Both modes screen every mirror listed; command-prefix mode also downloads from the first one. |
| `fail_open` | `true` | Allow a package when it can't be checked. |
| `telemetry` | `true` | Log anonymized usage events to Strix. |
| `port` | `8080` | Local port for command-prefix mode. |
## License
Apache 2.0. See [LICENSE](LICENSE).
-36
View File
@@ -1,36 +0,0 @@
use crate::policies::Package;
use serde::Serialize;
use std::sync::Mutex;
/// Bounded, so blocks pile up harmlessly while nothing collects them.
const KEPT: usize = 50;
static RECENT: Mutex<Vec<BlockedPackage>> = Mutex::new(Vec::new());
#[derive(Serialize)]
pub struct BlockedPackage {
pub eco: &'static str,
pub name: String,
pub version: Option<String>,
pub filter: &'static str,
pub reason: String,
}
pub fn record(eco: &'static str, pkg: &Package, filter: &'static str, reason: &str) {
let mut recent = RECENT.lock().unwrap_or_else(|e| e.into_inner());
if recent.len() == KEPT {
recent.remove(0);
}
recent.push(BlockedPackage {
eco,
name: pkg.name.clone(),
version: pkg.version.clone(),
filter,
reason: reason.to_owned(),
});
}
/// Hands each block over once.
pub fn take() -> Vec<BlockedPackage> {
std::mem::take(&mut *RECENT.lock().unwrap_or_else(|e| e.into_inner()))
}
+1 -1
View File
@@ -48,7 +48,7 @@ impl Default for FirewallConfig {
FirewallConfig {
port: DEFAULT_PORT,
fail_open: true,
min_age_hours: 24,
min_age_hours: 0,
denylist: DenylistSource::default(),
allow: HashMap::new(),
telemetry: true,
-1
View File
@@ -1,5 +1,4 @@
pub mod aliasing;
pub mod blocks;
pub mod config;
pub mod firewall;
pub mod http;
+1 -2
View File
@@ -5,7 +5,7 @@ mod uninstall;
pub use uninstall::uninstall;
use crate::config::FirewallConfig;
use crate::{blocks, mitm, telemetry};
use crate::{mitm, telemetry};
use crate::Firewall;
use rama::bytes::Bytes;
use rama::error::BoxError;
@@ -58,7 +58,6 @@ impl TransparentProxyHandler for Handler {
let message = String::from_utf8_lossy(&message).into_owned();
Some(Bytes::from(match message.as_str() {
"ca" => self.ca.pem.clone(),
"blocks" => serde_json::to_string(&blocks::take()).expect("blocks serialize"),
other => format!("error: unknown message {other}"),
}))
}
+22 -7
View File
@@ -26,6 +26,7 @@ use std::net::{IpAddr, Ipv4Addr};
use std::sync::Arc;
use std::time::Duration;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use windows::Win32::Foundation::ERROR_GEN_FAILURE;
use windows_service::service::*;
use windows_service::service_control_handler::{
self, ServiceControlHandlerResult, ServiceStatusHandle,
@@ -73,8 +74,12 @@ fn run_until_stopped() -> Result<(), BoxError> {
| ServiceControl::Shutdown
| ServiceControl::Pause
| ServiceControl::Continue => {
let _ = control_sender.send(control);
ServiceControlHandlerResult::NoError
// Answered once handled, so a failed pause fails the caller's ControlService.
let (reply, handled) = tokio::sync::oneshot::channel();
let _ = control_sender.send((control, reply));
handled
.blocking_recv()
.unwrap_or(ServiceControlHandlerResult::NoError)
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
@@ -89,16 +94,26 @@ fn run_until_stopped() -> Result<(), BoxError> {
}
};
// Only pausing removes our hosts entries; a stop or crash keeps them, failing closed.
while let Some(control) = control_receiver.recv().await {
while let Some((control, reply)) = control_receiver.recv().await {
let (hosts, state) = match control {
ServiceControl::Pause => (&[][..], ServiceState::Paused),
ServiceControl::Continue => (&hosts[..], ServiceState::Running),
_ => break,
};
match write_hosts_entries(hosts) {
Ok(()) => set_status(&status, state, false)?,
Err(error) => tracing::error!(%error, "could not rewrite the hosts file"),
}
let result = match write_hosts_entries(hosts) {
Ok(()) => {
set_status(&status, state, false)?;
ServiceControlHandlerResult::NoError
}
Err(error) => {
tracing::error!(%error, "could not rewrite the hosts file");
let code = error
.raw_os_error()
.map_or(ERROR_GEN_FAILURE.0, |code| code as u32);
ServiceControlHandlerResult::Other(code)
}
};
let _ = reply.send(result);
}
Ok::<_, BoxError>(())
});
+5 -1
View File
@@ -144,7 +144,11 @@ impl Tray {
}
fn show_error(error: BoxError) {
let text = HSTRING::from(error.to_string());
let text = match error.source() {
Some(source) => format!("{error}: {source}"),
None => error.to_string(),
};
let text = HSTRING::from(text);
unsafe { MessageBoxW(None, &text, w!("Strix Firewall"), MB_ICONERROR) };
}
+1 -2
View File
@@ -69,8 +69,7 @@ pub async fn screen(url: &str, firewall: &Firewall) -> Option<String> {
};
info!(eco = registry.eco, pkg = %pkg.label(), %url, "screening");
match consult(firewall, registry, &pkg).await {
Some((filter, Verdict::Deny(reason))) => {
crate::blocks::record(registry.eco, &pkg, filter, &reason);
Some((_, Verdict::Deny(reason))) => {
Some(format!("{} {reason}", pkg.label()))
}
Some((filter, Verdict::Unknown(e))) if !firewall.config.fail_open => {
-32
View File
@@ -1,32 +0,0 @@
import AppKit
struct BlockedPackage: Codable, Hashable, Identifiable {
let eco: String
let name: String
let version: String?
let filter: String
let reason: String
var id: String { "\(eco)/\(name)@\(version ?? "")" }
var label: String { version.map { "\(name) \($0)" } ?? name }
/// The denylist and OSV only flag packages known to be malicious; the age check only holds back new ones.
var malicious: Bool { filter != "age" }
}
@MainActor
enum BlockAlert {
static func show(_ package: BlockedPackage) {
let alert = NSAlert()
alert.addButton(withTitle: "OK")
if package.malicious {
alert.alertStyle = .critical
alert.messageText = "Blocked a malicious package"
alert.informativeText = "\(package.label) (\(package.eco)) \(package.reason). Do not install it."
} else {
alert.messageText = "Held back a new package"
alert.informativeText = "\(package.label) (\(package.eco)) \(package.reason)."
}
NSApp.activate(ignoringOtherApps: true)
alert.runModal()
}
}
-17
View File
@@ -51,7 +51,6 @@ final class FirewallModel: NSObject, ObservableObject {
@Published private(set) var completed = false
@Published private(set) var removed = false
@Published private(set) var screening = false
@Published private(set) var blocked: [BlockedPackage] = []
private let logger = Logger(subsystem: FirewallIDs.appBundleID, category: "installer")
private var pendingRequest: (request: OSSystemExtensionRequest, continuation: CheckedContinuation<Void, Error>)?
@@ -73,7 +72,6 @@ final class FirewallModel: NSObject, ObservableObject {
await refresh()
if CommandLine.arguments.contains("--install") { await install() }
}
Task { await watchBlocks() }
}
// MARK: - Flows
@@ -201,21 +199,6 @@ final class FirewallModel: NSObject, ObservableObject {
if let url = URL(string: pane) { NSWorkspace.shared.open(url) }
}
/// The extension cannot message the app, so the app asks it for blocks.
private func watchBlocks() async {
while true {
if screening, let reply = try? await providerMessage("blocks"),
let packages = try? JSONDecoder().decode([BlockedPackage].self, from: Data(reply.utf8)) {
for package in packages where !blocked.contains(package) {
blocked.append(package)
BlockAlert.show(package)
}
blocked = Array(blocked.suffix(10))
}
try? await Task.sleep(nanoseconds: 2_000_000_000)
}
}
private func stage(_ step: ReferenceWritableKeyPath<FirewallModel, StepStatus>,
running: String,
work: @MainActor () async throws -> String) async -> Bool {
-15
View File
@@ -22,20 +22,5 @@ struct MenuBarView: View {
Task { await model.restart() }
}
.disabled(model.busy)
if !model.blocked.isEmpty {
Divider()
ForEach(model.blocked) { package in
if !package.malicious {
Menu("Held back \(package.label) (\(package.eco))") {
Text(package.reason)
}
} else {
Menu("Blocked \(package.label) (\(package.eco))") {
Text("Malicious: \(package.reason)")
}
}
}
}
}
}