mirror of
https://github.com/usestrix/firewall.git
synced 2026-11-09 05:19:35 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
582c260d44 |
No files matched your search
@@ -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).
|
||||
Generated
+5
-80
@@ -711,20 +711,6 @@ version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"memchr",
|
||||
"rustc_version",
|
||||
"toml",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.42"
|
||||
@@ -3257,15 +3243,6 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
@@ -3393,12 +3370,11 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strix-firewall"
|
||||
version = "0.1.0"
|
||||
version = "0.1.0-rc.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"core-foundation 0.10.1",
|
||||
"directories",
|
||||
"embed-resource",
|
||||
"muda",
|
||||
"percent-encoding",
|
||||
"png",
|
||||
@@ -3694,26 +3670,11 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.8+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -3732,19 +3693,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.5+spec-1.1.0"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
|
||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
@@ -3954,26 +3909,6 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vswhom"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"vswhom-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vswhom-sys"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -4349,16 +4284,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.56.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
|
||||
+2
-6
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "strix-firewall"
|
||||
version = "0.1.0"
|
||||
version = "0.1.0-rc.1"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
authors = ["Strix"]
|
||||
@@ -129,14 +129,10 @@ schannel = "0.1.29"
|
||||
tokio = { version = "1.53.1", features = ["net"] }
|
||||
tracing-appender = "0.2.5"
|
||||
tray-icon = { version = "0.26.1", default-features = false }
|
||||
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_Environment", "Win32_System_JobObjects", "Win32_System_LibraryLoader", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_Controls", "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-service = "0.8.0"
|
||||
|
||||
# Only on Windows hosts, where the tray and service are built.
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
embed-resource = "3.0.12"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
//! The tray's task dialogs exist only in version 6 of the common controls, which takes a manifest.
|
||||
//! Both machine-wide binaries also get the Strix icon.
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=src/bin/firewall-tray.manifest");
|
||||
let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
|
||||
if msvc && std::env::var_os("CARGO_FEATURE_MACHINE_WIDE").is_some() {
|
||||
let manifest = concat!(env!("CARGO_MANIFEST_DIR"), "/src/bin/firewall-tray.manifest");
|
||||
println!("cargo:rustc-link-arg-bin=firewall-tray=/MANIFEST:EMBED");
|
||||
println!("cargo:rustc-link-arg-bin=firewall-tray=/MANIFESTINPUT:{manifest}");
|
||||
#[cfg(windows)]
|
||||
embed_resource::compile_for(
|
||||
"src/bin/firewall.rc",
|
||||
["firewall-tray", "firewall-service"],
|
||||
embed_resource::NONE,
|
||||
)
|
||||
// Fails the build, rather than ship without the icon.
|
||||
.manifest_required()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,6 @@ async fn handle(req: Request, firewall: &Firewall) -> Result<Response, Infallibl
|
||||
warn!(method = %req.method(), uri = %req.uri(), "unroutable, expected an absolute URL");
|
||||
return Ok(text(400, "firewall: expected an absolute URL\n"));
|
||||
};
|
||||
let url = firewall.mirrored(url);
|
||||
|
||||
let reason = policies::screen(&url, firewall).await;
|
||||
telemetry::screened(firewall, &url, reason.as_deref());
|
||||
@@ -130,9 +129,6 @@ pub fn downgrade(firewall: &Firewall, body: Vec<u8>) -> Vec<u8> {
|
||||
Ok(text) => text,
|
||||
Err(raw) => return raw.into_bytes(),
|
||||
};
|
||||
for (upstream, mirror) in firewall.mirrors() {
|
||||
text = text.replace(mirror, upstream);
|
||||
}
|
||||
for host in firewall.hosts() {
|
||||
text = text.replace(&format!("https://{host}"), &format!("http://{host}"));
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0"
|
||||
processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
</assembly>
|
||||
@@ -1,2 +0,0 @@
|
||||
// The icon Explorer, UAC and the tray's popups show.
|
||||
1 ICON "strix.ico"
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -69,28 +69,6 @@ impl Firewall {
|
||||
self.registries.iter().any(|r| r.index && r.host() == host_of(url))
|
||||
}
|
||||
|
||||
/// The URL at the first mirror of the built-in registry it is on, if one is configured.
|
||||
pub fn mirrored(&self, url: String) -> String {
|
||||
for (upstream, mirror) in self.mirrors() {
|
||||
if let Some(rest) = url.strip_prefix(upstream) {
|
||||
return format!("{mirror}{rest}");
|
||||
}
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
/// Each built-in upstream with a mirror, paired with its first one.
|
||||
pub fn mirrors(&self) -> Vec<(&'static str, &str)> {
|
||||
let mut mirrors = Vec::new();
|
||||
for registry in BUILT_IN {
|
||||
let first = self.config.registries.get(registry.name()).and_then(|m| m.first());
|
||||
if let Some(mirror) = first {
|
||||
mirrors.push((registry.upstream, mirror.as_str()));
|
||||
}
|
||||
}
|
||||
mirrors
|
||||
}
|
||||
|
||||
pub fn resolve(&self, registry: &'static Registry, rest: &str) -> Resolved {
|
||||
resolve(registry, rest, &self.patterns[registry.prefix])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! How the tray asks the service what it blocked, and allows a new package, as the macOS app
|
||||
//! messages its extension.
|
||||
//! 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};
|
||||
@@ -11,8 +11,8 @@ 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::*;
|
||||
use windows::Win32::System::Pipes::{GetNamedPipeClientProcessId, ImpersonateNamedPipeClient};
|
||||
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";
|
||||
@@ -56,7 +56,8 @@ async fn answer(client: NamedPipeServer, firewall: &Firewall) -> Result<(), BoxE
|
||||
// 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 ") && !client_is_admin(client.get_ref())? {
|
||||
} 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)
|
||||
@@ -80,20 +81,3 @@ fn client_is_tray(client: &NamedPipeServer) -> Result<bool, BoxError> {
|
||||
// Windows paths ignore case.
|
||||
Ok(path.to_lowercase() == tray.to_string_lossy().to_lowercase())
|
||||
}
|
||||
|
||||
/// Elevated, as only an elevated token has the Administrators group enabled.
|
||||
fn client_is_admin(client: &NamedPipeServer) -> windows::core::Result<bool> {
|
||||
let mut sid = [0u32; SECURITY_MAX_SID_SIZE as usize / 4];
|
||||
let admins = PSID(sid.as_mut_ptr().cast());
|
||||
let mut size = SECURITY_MAX_SID_SIZE;
|
||||
unsafe { CreateWellKnownSid(WinBuiltinAdministratorsSid, None, Some(admins), &mut size)? };
|
||||
unsafe { ImpersonateNamedPipeClient(HANDLE(client.as_raw_handle()))? };
|
||||
let mut member = false.into();
|
||||
let checked = unsafe { CheckTokenMembership(None, admins, &mut member) };
|
||||
// Carrying on as the client would be worse than stopping.
|
||||
if unsafe { RevertToSelf() }.is_err() {
|
||||
std::process::abort();
|
||||
}
|
||||
checked?;
|
||||
Ok(member.as_bool())
|
||||
}
|
||||
+31
-164
@@ -9,12 +9,10 @@ use std::os::windows::io::AsRawHandle;
|
||||
use std::sync::mpsc::{channel, Receiver, Sender};
|
||||
use std::time::Duration;
|
||||
use tray_icon::{Icon, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
||||
use windows::core::{w, HRESULT, PCWSTR};
|
||||
use windows::core::{w, PCWSTR};
|
||||
use windows::Win32::Foundation::*;
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::System::Pipes::GetNamedPipeServerProcessId;
|
||||
use windows::Win32::System::Threading::*;
|
||||
use windows::Win32::UI::Controls::*;
|
||||
use windows::Win32::System::Threading::CreateMutexW;
|
||||
use windows::Win32::UI::Shell::*;
|
||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||
use windows_registry::CURRENT_USER;
|
||||
@@ -25,8 +23,7 @@ const MENU_PAUSE: &str = "pause";
|
||||
const MENU_RESTART: &str = "restart";
|
||||
const MENU_LOG: &str = "log";
|
||||
const MENU_QUIT: &str = "quit";
|
||||
/// Also the service's requests, and `allow` the elevated `firewall-tray` command.
|
||||
const ALLOW: &str = "allow";
|
||||
/// Also the service's request.
|
||||
const ALLOW_ONCE: &str = "allow-once";
|
||||
const LOGO: &[u8] = include_bytes!("tray.png");
|
||||
/// Where blocked packages are listed, below Open logs.
|
||||
@@ -35,19 +32,6 @@ const BLOCKED_KEPT: usize = 10;
|
||||
|
||||
/// Runs until Quit, once per session.
|
||||
pub fn run() -> Result<(), BoxError> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if let [verb, id, label @ ..] = &args[..] {
|
||||
if verb == ALLOW {
|
||||
// Run by `run_elevated`, so the service can tell an admin asked.
|
||||
let trusted = ask_service(&format!("{ALLOW} {id}"))
|
||||
.and_then(|reply| answered(&format!("Trusted {}", label.join(" ")), &reply));
|
||||
if let Err(error) = trusted {
|
||||
show_error(error);
|
||||
std::process::exit(1);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Held until we exit.
|
||||
let _mutex = unsafe { CreateMutexW(None, false, w!(r"Local\StrixFirewallTray"))? };
|
||||
if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS {
|
||||
@@ -215,8 +199,8 @@ impl Tray {
|
||||
let allowed = self.allowed.0.clone();
|
||||
std::thread::spawn(move || {
|
||||
for package in new {
|
||||
if let Some(trust) = alert(&package) {
|
||||
allow(package, trust, &allowed);
|
||||
if alert(&package) {
|
||||
allow_once(package, &allowed);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -235,13 +219,9 @@ impl Tray {
|
||||
package.reason.clone()
|
||||
};
|
||||
let reason = MenuItem::new(reason, false, None);
|
||||
let trustable = package.trustable();
|
||||
let once =
|
||||
MenuItem::with_id(format!("{ALLOW_ONCE}:{i}"), "Allow Once", trustable, None);
|
||||
let always =
|
||||
MenuItem::with_id(format!("{ALLOW}:{i}"), "Trust this version", trustable, None);
|
||||
let once = MenuItem::with_id(format!("{ALLOW_ONCE}:{i}"), "Allow Once", true, None);
|
||||
let items: &[&dyn IsMenuItem] =
|
||||
if package.malicious() { &[&reason] } else { &[&reason, &once, &always] };
|
||||
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);
|
||||
@@ -250,12 +230,11 @@ impl Tray {
|
||||
}
|
||||
|
||||
fn on_menu(&self, id: &str) {
|
||||
if let Some((verb @ (ALLOW | ALLOW_ONCE), i)) = id.split_once(':') {
|
||||
let trust = verb == ALLOW;
|
||||
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(package, trust, &allowed));
|
||||
std::thread::spawn(move || allow_once(package, &allowed));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -284,154 +263,42 @@ impl Tray {
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOW_ONCE_BUTTON: i32 = 100;
|
||||
const TRUST_BUTTON: i32 = 101;
|
||||
/// `firewall.rc`'s icon, embedded by `build.rs`.
|
||||
const STRIX_ICON: PCWSTR = PCWSTR(1 as _);
|
||||
|
||||
/// `Some(true)` if the user chose to trust it, `Some(false)` to allow it once.
|
||||
/// A task dialog, as a message box can't have both buttons.
|
||||
fn alert(package: &BlockedPackage) -> Option<bool> {
|
||||
/// Whether the user chose to allow it once.
|
||||
fn alert(package: &BlockedPackage) -> bool {
|
||||
let text = format!("{} {}.", package.label(), package.reason);
|
||||
let (title, text, icon) = if package.malicious() {
|
||||
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, TD_ERROR_ICON)
|
||||
(title, text, MB_ICONERROR)
|
||||
} else if !package.trustable() {
|
||||
("Held back a new package".to_owned(), text, MB_ICONINFORMATION)
|
||||
} else {
|
||||
("Held back a new package".to_owned(), text, STRIX_ICON)
|
||||
let text = text + "\n\nAllow it once?";
|
||||
("Held back a new package".to_owned(), text, MB_YESNO | MB_ICONINFORMATION)
|
||||
};
|
||||
let button = |id, text| TASKDIALOG_BUTTON { nButtonID: id, pszButtonText: text };
|
||||
let allow = [
|
||||
button(ALLOW_ONCE_BUTTON, w!("Allow Once")),
|
||||
button(TRUST_BUTTON, w!("Trust this version")),
|
||||
];
|
||||
let buttons: &[_] = if package.trustable() { &allow } else { &[] };
|
||||
let (title, text) = (HSTRING::from(title), HSTRING::from(linked(&text)));
|
||||
let dialog = TASKDIALOGCONFIG {
|
||||
cbSize: size_of::<TASKDIALOGCONFIG>() as u32,
|
||||
dwFlags: TDF_ENABLE_HYPERLINKS,
|
||||
// Where `STRIX_ICON` is loaded from; the `TD_` icons are Windows'.
|
||||
hInstance: unsafe { GetModuleHandleW(None) }.ok()?.into(),
|
||||
dwCommonButtons: TDCBF_CLOSE_BUTTON,
|
||||
pszWindowTitle: w!("Strix Firewall"),
|
||||
Anonymous1: TASKDIALOGCONFIG_0 { pszMainIcon: icon },
|
||||
pszMainInstruction: PCWSTR(title.as_ptr()),
|
||||
pszContent: PCWSTR(text.as_ptr()),
|
||||
cButtons: buttons.len() as u32,
|
||||
pButtons: buttons.as_ptr(),
|
||||
pfCallback: Some(on_dialog),
|
||||
..Default::default()
|
||||
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),
|
||||
};
|
||||
let mut chosen = 0;
|
||||
unsafe { TaskDialogIndirect(&dialog, Some(&raw mut chosen), None, None) }.ok()?;
|
||||
match chosen {
|
||||
ALLOW_ONCE_BUTTON => Some(false),
|
||||
TRUST_BUTTON => Some(true),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks for the foreground, as `MB_SETFOREGROUND` did; Windows may still leave it behind.
|
||||
/// Opens the links `linked` made, in the browser.
|
||||
unsafe extern "system" fn on_dialog(
|
||||
window: HWND,
|
||||
event: TASKDIALOG_NOTIFICATIONS,
|
||||
_: WPARAM,
|
||||
link: LPARAM,
|
||||
_: isize,
|
||||
) -> HRESULT {
|
||||
if event == TDN_CREATED {
|
||||
let _ = unsafe { SetForegroundWindow(window) };
|
||||
} else if event == TDN_HYPERLINK_CLICKED {
|
||||
let link = PCWSTR(link.0 as *const u16);
|
||||
// Only web pages, whatever a block's reason contains.
|
||||
let web = unsafe { link.to_string() }
|
||||
.is_ok_and(|link| link.starts_with("https://") || link.starts_with("http://"));
|
||||
if web {
|
||||
let none = PCWSTR::null();
|
||||
unsafe { ShellExecuteW(None, w!("open"), link, none, none, SW_SHOWNORMAL) };
|
||||
}
|
||||
}
|
||||
S_OK
|
||||
}
|
||||
|
||||
/// A task dialog's markup for the text, with each URL a link.
|
||||
fn linked(text: &str) -> String {
|
||||
let mut marked = String::new();
|
||||
let mut rest = text;
|
||||
while let Some(start) =
|
||||
["https://", "http://"].iter().filter_map(|scheme| rest.find(scheme)).min()
|
||||
{
|
||||
let end = rest[start..].find(|c: char| c.is_whitespace() || c == ',' || c == ')');
|
||||
let url = rest[start..end.map_or(rest.len(), |end| start + end)].trim_end_matches('.');
|
||||
marked += &rest[..start];
|
||||
marked += &format!("<a href=\"{url}\">{url}</a>");
|
||||
rest = &rest[start + url.len()..];
|
||||
}
|
||||
marked + rest
|
||||
}
|
||||
|
||||
/// Trusting writes the config for every user, so it goes through a UAC prompt.
|
||||
/// The elevated process shows its own errors and late replies.
|
||||
fn allow(package: BlockedPackage, trust: bool, allowed: &Sender<BlockedPackage>) {
|
||||
let allowed_it = if trust {
|
||||
run_elevated(&[ALLOW, &package.id.to_string(), &package.label()])
|
||||
.map(|code| code == Some(0))
|
||||
} else {
|
||||
let title = format!("Allowed {} for the next minute", package.label());
|
||||
ask_service(&format!("{ALLOW_ONCE} {}", package.id))
|
||||
.and_then(|reply| answered(&title, &reply))
|
||||
.map(|()| true)
|
||||
};
|
||||
match allowed_it {
|
||||
Ok(true) => {
|
||||
let _ = allowed.send(package);
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(error) => show_error(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells the user, under `title`, when the install had already stopped waiting.
|
||||
fn answered(title: &str, reply: &str) -> Result<(), BoxError> {
|
||||
match reply {
|
||||
"ok" => Ok(()),
|
||||
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) };
|
||||
Ok(())
|
||||
}
|
||||
other => Err(other.into()),
|
||||
other => return show_error(other.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// This program as an admin, waiting for its exit code; `None` if the user said no.
|
||||
fn run_elevated(args: &[&str]) -> Result<Option<u32>, BoxError> {
|
||||
let exe = HSTRING::from(std::env::current_exe()?.as_path());
|
||||
let parameters = HSTRING::from(args.join(" "));
|
||||
let mut info = SHELLEXECUTEINFOW {
|
||||
cbSize: size_of::<SHELLEXECUTEINFOW>() as u32,
|
||||
fMask: SEE_MASK_NOCLOSEPROCESS,
|
||||
lpVerb: w!("runas"),
|
||||
lpFile: PCWSTR(exe.as_ptr()),
|
||||
lpParameters: PCWSTR(parameters.as_ptr()),
|
||||
nShow: SW_HIDE.0,
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(error) = unsafe { ShellExecuteExW(&mut info) } {
|
||||
if error.code() == ERROR_CANCELLED.to_hresult() {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
let process = unsafe { Owned::new(info.hProcess) };
|
||||
unsafe { WaitForSingleObject(*process, INFINITE) };
|
||||
let mut code = 1;
|
||||
unsafe { GetExitCodeProcess(*process, &mut code)? };
|
||||
Ok(Some(code))
|
||||
let _ = allowed.send(package);
|
||||
}
|
||||
|
||||
/// One request per connection, as the service answers.
|
||||
|
||||
@@ -11,12 +11,9 @@ pub static PYPI: Registry = Registry {
|
||||
normalize: Some(pep503),
|
||||
patterns: &[
|
||||
Pattern { re: r"^simple/(?<name>[^/]+)", rewrite: Some(FILES) },
|
||||
// A mirror serves the files from its index host.
|
||||
WHEEL,
|
||||
SDIST,
|
||||
Pattern { re: "", rewrite: Some(FILES) },
|
||||
],
|
||||
published: PUBLISHED,
|
||||
published: None,
|
||||
};
|
||||
|
||||
/// The directories are hash shards, but the filename names the distribution.
|
||||
@@ -27,28 +24,25 @@ pub static PYPI_FILES: Registry = Registry {
|
||||
upstream: "https://files.pythonhosted.org/",
|
||||
index: false,
|
||||
normalize: Some(pep503),
|
||||
patterns: &[WHEEL, SDIST],
|
||||
published: PUBLISHED,
|
||||
patterns: &[
|
||||
// A wheel escapes - _ and . to _, so the first two fields are exactly name and version.
|
||||
Pattern {
|
||||
re: r"^packages/.+/(?<name>[^-/]+)-(?<version>[^-/]+)-[^/]+\.whl(?:\.metadata)?$",
|
||||
rewrite: None,
|
||||
},
|
||||
// An sdist keeps the name's hyphens, so the version is the last field starting with a digit.
|
||||
Pattern {
|
||||
re: r"^packages/.+/(?<name>.+)-(?<version>\d[^-/]*)\.(?:tar\.gz|zip|tar\.bz2)$",
|
||||
rewrite: None,
|
||||
},
|
||||
],
|
||||
published: Some(Published {
|
||||
url: "https://pypi.org/pypi/{name}/{version}/json",
|
||||
pointer: "/urls/0/upload_time_iso_8601",
|
||||
escape: None,
|
||||
}),
|
||||
};
|
||||
|
||||
// A wheel escapes - _ and . to _, so the first two fields are exactly name and version.
|
||||
const WHEEL: Pattern = Pattern {
|
||||
re: r"^packages/.+/(?<name>[^-/]+)-(?<version>[^-/]+)-[^/]+\.whl(?:\.metadata)?$",
|
||||
rewrite: None,
|
||||
};
|
||||
|
||||
// An sdist keeps the name's hyphens, so the version is the last field starting with a digit.
|
||||
const SDIST: Pattern = Pattern {
|
||||
re: r"^packages/.+/(?<name>.+)-(?<version>\d[^-/]*)\.(?:tar\.gz|zip|tar\.bz2)$",
|
||||
rewrite: None,
|
||||
};
|
||||
|
||||
const PUBLISHED: Option<Published> = Some(Published {
|
||||
url: "https://pypi.org/pypi/{name}/{version}/json",
|
||||
pointer: "/urls/0/upload_time_iso_8601",
|
||||
escape: None,
|
||||
});
|
||||
|
||||
/// PEP 503: runs of - _ and . collapse to one -, lowercased, which also un-escapes a wheel.
|
||||
fn pep503(name: &str) -> String {
|
||||
let mut out = String::with_capacity(name.len());
|
||||
|
||||
@@ -84,15 +84,6 @@ async fn a_mirror_is_screened_like_the_registry_it_copies() {
|
||||
assert!(loaded.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_pypi_mirror_screens_the_files_it_serves() {
|
||||
let config = r#"{"registries": {"pypi": ["https://art.example.com/pypi/"]}}"#;
|
||||
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
|
||||
let url = "https://art.example.com/pypi/packages/ab/cd/six-1.16.0-py2.py3-none-any.whl";
|
||||
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()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_allowed_version_is_still_checked_for_malware() {
|
||||
// The unreachable denylist shows that it still runs.
|
||||
|
||||
@@ -65,16 +65,3 @@ async fn downgrade_touches_known_hosts_in_text_only() {
|
||||
let binary = vec![0x1f, 0x8b, 0xff, 0xfe];
|
||||
assert_eq!(downgrade(&firewall, binary.clone()), binary);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_mirror_stands_in_for_its_registry() {
|
||||
let mut config = FirewallConfig::default();
|
||||
config.registries.insert("pypi".into(), vec!["https://art.example.com/pypi/".into()]);
|
||||
let firewall = Firewall::new(config);
|
||||
let mirrored = firewall.mirrored("https://pypi.org/simple/six/".into());
|
||||
assert_eq!(mirrored, "https://art.example.com/pypi/simple/six/");
|
||||
let npm = "https://registry.npmjs.org/left-pad";
|
||||
assert_eq!(firewall.mirrored(npm.into()), npm);
|
||||
let links = b"https://art.example.com/pypi/packages/six.whl".to_vec();
|
||||
assert_eq!(downgrade(&firewall, links), b"http://pypi.org/packages/six.whl".to_vec());
|
||||
}
|
||||
@@ -230,7 +230,7 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
blocked.append(package)
|
||||
if let allowance = BlockAlert.show(package) { await allow(package, allowance) }
|
||||
}
|
||||
if blocked.count > 10 { blocked.removeFirst(blocked.count - 10) }
|
||||
blocked = Array(blocked.suffix(10))
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user