mirror of
https://github.com/usestrix/firewall.git
synced 2026-11-09 13:31:32 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01ab5b0995 | ||
|
|
97a5a148e0 | ||
|
|
877807740b | ||
|
|
cd9b0614bc | ||
|
|
6cad09d32d | ||
|
|
23db767cc1 | ||
|
|
37a56fb7d2 | ||
|
|
6c69036db0 | ||
|
|
4fd3021760 | ||
|
|
d01ac3634b | ||
|
|
91da9d68c9 | ||
|
|
e0b723a732 | ||
|
|
fc02226cd6 | ||
|
|
5068b3578d |
No files matched your search
+12
-1
@@ -38,14 +38,17 @@ path = "src/main.rs"
|
|||||||
[[bin]]
|
[[bin]]
|
||||||
name = "firewall-service"
|
name = "firewall-service"
|
||||||
path = "src/bin/firewall-service.rs"
|
path = "src/bin/firewall-service.rs"
|
||||||
|
required-features = ["machine-wide"]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "firewall-tray"
|
name = "firewall-tray"
|
||||||
path = "src/bin/firewall-tray.rs"
|
path = "src/bin/firewall-tray.rs"
|
||||||
|
required-features = ["machine-wide"]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "firewall-uninstall"
|
name = "firewall-uninstall"
|
||||||
path = "src/bin/firewall-uninstall.rs"
|
path = "src/bin/firewall-uninstall.rs"
|
||||||
|
required-features = ["machine-wide"]
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "proxy-test"
|
name = "proxy-test"
|
||||||
@@ -87,6 +90,14 @@ path = "tests/tpm.rs"
|
|||||||
name = "managers"
|
name = "managers"
|
||||||
path = "tests/managers.rs"
|
path = "tests/managers.rs"
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "blocks"
|
||||||
|
path = "tests/blocks.rs"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Builds the machine-wide binaries: the Windows service and tray, and the macOS uninstaller.
|
||||||
|
machine-wide = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = "4.6.6"
|
clap = "4.6.6"
|
||||||
directories = "6.0.0"
|
directories = "6.0.0"
|
||||||
@@ -118,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_Environment", "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"
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
@@ -11,4 +11,5 @@ pub static GO: Manager = Manager {
|
|||||||
("GOFLAGS", "-mod=mod"),
|
("GOFLAGS", "-mod=mod"),
|
||||||
],
|
],
|
||||||
args: &[],
|
args: &[],
|
||||||
|
lockfile: None,
|
||||||
};
|
};
|
||||||
@@ -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] =
|
||||||
|
|||||||
@@ -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")),
|
||||||
};
|
};
|
||||||
@@ -13,4 +13,5 @@ pub static PDM: Manager = Manager {
|
|||||||
("PDM_PYPI_VERIFY_SSL", "false"),
|
("PDM_PYPI_VERIFY_SSL", "false"),
|
||||||
],
|
],
|
||||||
args: &[],
|
args: &[],
|
||||||
|
lockfile: None,
|
||||||
};
|
};
|
||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
@@ -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")),
|
||||||
};
|
};
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
pub mod forward;
|
pub mod forward;
|
||||||
pub mod managers;
|
pub mod managers;
|
||||||
|
#[cfg(windows)]
|
||||||
|
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,
|
||||||
@@ -34,7 +38,8 @@ impl Invocation {
|
|||||||
|
|
||||||
pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::Result<i32> {
|
pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::Result<i32> {
|
||||||
let proxy = crate::local_url(port, "");
|
let proxy = crate::local_url(port, "");
|
||||||
let mut command = Command::new(resolve(&invocation.program)?);
|
let program = resolve(&invocation.program)?;
|
||||||
|
let mut command = Command::new(&program);
|
||||||
command.args(invocation.arguments(&proxy));
|
command.args(invocation.arguments(&proxy));
|
||||||
if let Some(dir) = dir {
|
if let Some(dir) = dir {
|
||||||
command.current_dir(dir);
|
command.current_dir(dir);
|
||||||
@@ -42,10 +47,47 @@ pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::R
|
|||||||
for (key, value) in managers::variables(&proxy) {
|
for (key, value) in managers::variables(&proxy) {
|
||||||
command.env(key, value);
|
command.env(key, value);
|
||||||
}
|
}
|
||||||
Ok(command.status()?.code().unwrap_or(1))
|
#[cfg(windows)]
|
||||||
|
windows::prepare();
|
||||||
|
let status = command.status();
|
||||||
|
#[cfg(windows)]
|
||||||
|
let status = status.map_err(|error| windows::explain(error, &program));
|
||||||
|
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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
use std::io::{Error, ErrorKind};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Once;
|
||||||
|
use tracing::debug;
|
||||||
|
use windows::core::{BOOL, PCWSTR};
|
||||||
|
use windows::Win32::System::Console::{SetConsoleCtrlHandler, CTRL_BREAK_EVENT, CTRL_C_EVENT};
|
||||||
|
use windows::Win32::System::JobObjects::{
|
||||||
|
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
|
||||||
|
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||||
|
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||||
|
};
|
||||||
|
use windows::Win32::System::Threading::GetCurrentProcess;
|
||||||
|
|
||||||
|
/// Called before spawning, so the manager behaves as it would run from the shell directly.
|
||||||
|
/// Once per process: a caller may run several managers.
|
||||||
|
pub fn prepare() {
|
||||||
|
static PREPARED: Once = Once::new();
|
||||||
|
PREPARED.call_once(|| {
|
||||||
|
// The console sends Ctrl+C to the manager too. Exiting first would hand the prompt back
|
||||||
|
// while a .cmd shim still asks "Terminate batch job (Y/N)?", so wait for its exit instead.
|
||||||
|
// A handler, unlike ignoring the signal outright, is not inherited by the manager.
|
||||||
|
let _ = unsafe { SetConsoleCtrlHandler(Some(ignore_interrupt), true) };
|
||||||
|
if let Err(error) = kill_children_on_exit() {
|
||||||
|
debug!(%error, "the manager may outlive the firewall");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "system" fn ignore_interrupt(event: u32) -> BOOL {
|
||||||
|
(event == CTRL_C_EVENT || event == CTRL_BREAK_EVENT).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Joins a job that kills what is left in it once the firewall exits, even if killed itself.
|
||||||
|
/// The manager inherits the job when spawned, as do its own children.
|
||||||
|
fn kill_children_on_exit() -> windows::core::Result<()> {
|
||||||
|
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
||||||
|
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||||
|
// Never closed: the handle closing as the firewall exits is what kills the rest.
|
||||||
|
let job = unsafe { CreateJobObjectW(None, PCWSTR::null())? };
|
||||||
|
unsafe {
|
||||||
|
SetInformationJobObject(
|
||||||
|
job,
|
||||||
|
JobObjectExtendedLimitInformation,
|
||||||
|
(&raw const limits).cast(),
|
||||||
|
size_of_val(&limits) as u32,
|
||||||
|
)?
|
||||||
|
};
|
||||||
|
unsafe { AssignProcessToJobObject(job, GetCurrentProcess()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rust refuses to pass a line break to a batch file, where it would cut the command short.
|
||||||
|
pub fn explain(error: Error, program: &Path) -> Error {
|
||||||
|
let batch = program
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat"));
|
||||||
|
if !batch || error.kind() != ErrorKind::InvalidInput {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
let name = program.file_name().unwrap_or(program.as_os_str()).to_string_lossy();
|
||||||
|
Error::new(
|
||||||
|
ErrorKind::InvalidInput,
|
||||||
|
format!("{name} is a batch file, which cannot be passed an argument with a line break"),
|
||||||
|
)
|
||||||
|
}
|
||||||
+128
-13
@@ -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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
recent.push(BlockedPackage {
|
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())
|
||||||
}
|
}
|
||||||
+84
-26
@@ -1,28 +1,29 @@
|
|||||||
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::{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::path::PathBuf;
|
use std::num::{NonZeroU16, NonZeroU64};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
pub const DEFAULT_PORT: u16 = 8080;
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,3 +119,71 @@ fn merge(base: &mut Map<String, Value>, top: Map<String, Value>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds an `allow` entry to the file, leaving the rest of it as written.
|
||||||
|
pub fn allow(path: &Path, eco: &str, name: &str, version: &str) -> std::io::Result<()> {
|
||||||
|
let invalid = |e: String| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display()));
|
||||||
|
let mut config = match std::fs::read_to_string(path) {
|
||||||
|
Ok(text) => serde_json::from_str(&text).map_err(|e| invalid(e.to_string()))?,
|
||||||
|
Err(e) if e.kind() == ErrorKind::NotFound => json!({}),
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
if !config.is_object() {
|
||||||
|
return Err(invalid("not an object".into()));
|
||||||
|
}
|
||||||
|
let entry = json!({ "name": name, "version": version });
|
||||||
|
let entries = &mut config["allow"][eco];
|
||||||
|
if entries.is_null() {
|
||||||
|
*entries = Value::Array(Vec::new());
|
||||||
|
}
|
||||||
|
let Some(entries) = entries.as_array_mut() else {
|
||||||
|
return Err(invalid(format!("allow.{eco} is not a list")));
|
||||||
|
};
|
||||||
|
if !entries.contains(&entry) {
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
std::fs::write(path, serde_json::to_string_pretty(&config)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
@@ -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 ®istries {
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -39,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,8 +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 => format!("error: unknown message {other}"),
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,8 +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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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:
|
||||||
|
|||||||
@@ -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)?;
|
||||||
unsafe { SetFileSecurityW(&path, info, PSECURITY_DESCRIPTOR(acl.0)).ok()? };
|
if exists {
|
||||||
|
unsafe { SetFileSecurityW(&path, info, PSECURITY_DESCRIPTOR(acl.0)).ok()? };
|
||||||
|
} else {
|
||||||
|
let attributes = SECURITY_ATTRIBUTES {
|
||||||
|
nLength: size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||||
|
lpSecurityDescriptor: acl.0,
|
||||||
|
bInheritHandle: false.into(),
|
||||||
|
};
|
||||||
|
// Never inherits ProgramData's ACL, which lets users add files; fails if it was recreated.
|
||||||
|
unsafe { CreateDirectoryW(&path, Some(&attributes))? };
|
||||||
|
}
|
||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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] {
|
||||||
@@ -162,7 +171,7 @@ async fn start_screening(status: &ServiceStatusHandle) -> Result<Vec<&'static st
|
|||||||
resolver: resolver.clone(),
|
resolver: resolver.clone(),
|
||||||
service: service.clone(),
|
service: service.clone(),
|
||||||
};
|
};
|
||||||
let upstream = ConsumeErrLayer::trace_as_debug().into_layer(upstream);
|
let upstream = ConsumeErrLayer::trace_as_warning().into_layer(upstream);
|
||||||
tokio::spawn(listen_on(address, port, &exec)?.serve(upstream));
|
tokio::spawn(listen_on(address, port, &exec)?.serve(upstream));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+199
-9
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,13 +53,15 @@ 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))
|
||||||
let name_matches = match registry.normalize {
|
}
|
||||||
Some(normalize) => normalize(&entry.name) == pkg.name,
|
|
||||||
None => entry.name == pkg.name,
|
fn matches(registry: &Registry, pkg: &Package, entry: &AllowedPackage) -> bool {
|
||||||
};
|
let name_matches = match registry.normalize {
|
||||||
// Go writes a leading v that the parsed version drops, and PEP 440 ignores one.
|
Some(normalize) => normalize(&entry.name) == pkg.name,
|
||||||
let version = entry.version.as_deref().map(|v| v.strip_prefix('v').unwrap_or(v));
|
None => entry.name == pkg.name,
|
||||||
name_matches && (version.is_none() || version == pkg.version.as_deref())
|
};
|
||||||
})
|
// 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));
|
||||||
|
name_matches && (version.is_none() || version == pkg.version.as_deref())
|
||||||
}
|
}
|
||||||
@@ -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(", ")))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+70
-7
@@ -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]
|
||||||
@@ -57,3 +94,29 @@ async fn failing_closed_blocks_what_could_not_be_checked() {
|
|||||||
let blocked = screen(url, &firewall).await.unwrap_or_default();
|
let blocked = screen(url, &firewall).await.unwrap_or_default();
|
||||||
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]
|
||||||
|
fn allowing_keeps_the_rest_of_the_file() {
|
||||||
|
let path = std::env::temp_dir().join(format!("firewall-allow-{}.json", std::process::id()));
|
||||||
|
std::fs::write(&path, r#"{"fail_open": false}"#).expect("write");
|
||||||
|
for _ in 0..2 {
|
||||||
|
firewall::config::allow(&path, "npm", "left-pad", "1.3.0").expect("allow");
|
||||||
|
}
|
||||||
|
let text = std::fs::read_to_string(&path).expect("read");
|
||||||
|
std::fs::remove_file(&path).ok();
|
||||||
|
let config: FirewallConfig = serde_json::from_str(&text).expect("parses");
|
||||||
|
assert!(!config.fail_open);
|
||||||
|
assert_eq!(config.allow["npm"].len(), 1);
|
||||||
|
assert_eq!(config.allow["npm"][0].version.as_deref(), Some("1.3.0"));
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ ci = "github"
|
|||||||
installers = ["shell", "powershell", "homebrew", "msi"]
|
installers = ["shell", "powershell", "homebrew", "msi"]
|
||||||
# core/wix/main.wxs is edited to install the service.
|
# core/wix/main.wxs is edited to install the service.
|
||||||
allow-dirty = ["msi"]
|
allow-dirty = ["msi"]
|
||||||
|
features = ["machine-wide"]
|
||||||
tap = "usestrix/homebrew-tap"
|
tap = "usestrix/homebrew-tap"
|
||||||
publish-jobs = ["homebrew", "./publish-crates"]
|
publish-jobs = ["homebrew", "./publish-crates"]
|
||||||
targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
|
targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
|
||||||
@@ -1,32 +1,93 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
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" }
|
||||||
|
/// Malware is never allowed, and a new package only by version.
|
||||||
|
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 {
|
||||||
static func show(_ package: BlockedPackage) {
|
static func show(_ package: BlockedPackage) -> Allowance? {
|
||||||
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 a malicious package"
|
alert.messageText = "Blocked malware: \(package.label) (\(package.eco))"
|
||||||
alert.informativeText = "\(package.label) (\(package.eco)) \(package.reason). Do not install it."
|
setText(alert, "\(package.label) \(package.reason).\n\n"
|
||||||
|
+ "It was not installed. Don't install it from another source. "
|
||||||
|
+ "If you didn't expect this package, a dependency may be compromised; tell your security team.")
|
||||||
} else {
|
} 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: "Allow Once")
|
||||||
|
alert.addButton(withTitle: "Trust this version")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
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()
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AdminAuthorization {
|
||||||
|
/// The allowlist applies to every user of the Mac, so changing it takes an admin, as `sudo` would.
|
||||||
|
static func confirm() -> Bool {
|
||||||
|
var authorization: AuthorizationRef?
|
||||||
|
guard AuthorizationCreate(nil, nil, [], &authorization) == errAuthorizationSuccess,
|
||||||
|
let authorization else { return false }
|
||||||
|
defer { AuthorizationFree(authorization, []) }
|
||||||
|
return "system.preferences".withCString { name in
|
||||||
|
var item = AuthorizationItem(name: name, valueLength: 0, value: nil, flags: 0)
|
||||||
|
return withUnsafeMutablePointer(to: &item) { item in
|
||||||
|
var rights = AuthorizationRights(count: 1, items: item)
|
||||||
|
let flags: AuthorizationFlags = [.interactionAllowed, .extendRights]
|
||||||
|
return AuthorizationCopyRights(authorization, &rights, nil, flags, nil) == errAuthorizationSuccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -29,11 +29,13 @@ enum FirewallError: Error, LocalizedError {
|
|||||||
case extensionUnavailable
|
case extensionUnavailable
|
||||||
case screeningDidNotStart(NEVPNStatus)
|
case screeningDidNotStart(NEVPNStatus)
|
||||||
case extensionStopped(String)
|
case extensionStopped(String)
|
||||||
|
case extensionRefused(String)
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .extensionUnavailable: return "The extension is not running"
|
case .extensionUnavailable: return "The extension is not running"
|
||||||
case let .screeningDidNotStart(status): return "Screening did not start (\(status.label))"
|
case let .screeningDidNotStart(status): return "Screening did not start (\(status.label))"
|
||||||
|
case let .extensionRefused(reply): return "The extension replied \(reply)"
|
||||||
case let .extensionStopped(reason):
|
case let .extensionStopped(reason):
|
||||||
return "The extension stopped: \(reason). Its log: log show --last 5m --predicate 'subsystem BEGINSWITH \"\(FirewallIDs.appBundleID)\"'"
|
return "The extension stopped: \(reason). Its log: log show --last 5m --predicate 'subsystem BEGINSWITH \"\(FirewallIDs.appBundleID)\"'"
|
||||||
}
|
}
|
||||||
@@ -201,6 +203,24 @@ 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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Trusting changes the config for every user, so it takes an admin.
|
||||||
|
func allow(_ package: BlockedPackage, _ allowance: Allowance) async {
|
||||||
|
guard package.trustable else { return }
|
||||||
|
if allowance == .always, !AdminAuthorization.confirm() {
|
||||||
|
log("Trust cancelled for \(package.label)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let reply = try await providerMessage("\(allowance == .once ? "allow-once" : "allow") \(package.id)")
|
||||||
|
guard reply == "ok" || reply == "late" else { throw FirewallError.extensionRefused(reply) }
|
||||||
|
blocked.removeAll { $0 == package }
|
||||||
|
log("Allowed \(package.label) (\(package.eco)) \(allowance)")
|
||||||
|
if reply == "late" { BlockAlert.late(package, allowance) }
|
||||||
|
} catch {
|
||||||
|
log("Could not allow \(package.label): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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.
|
||||||
private func watchBlocks() async {
|
private func watchBlocks() async {
|
||||||
while true {
|
while true {
|
||||||
@@ -208,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)
|
||||||
BlockAlert.show(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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ 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") {
|
||||||
|
Task { await model.allow(package, .always) }
|
||||||
|
}
|
||||||
|
.disabled(model.busy || !package.trustable)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Menu("Blocked \(package.label) (\(package.eco))") {
|
Menu("Blocked \(package.label) (\(package.eco))") {
|
||||||
|
|||||||
+1
-1
@@ -133,7 +133,7 @@ targets:
|
|||||||
arm64|arm64e) triple=aarch64-apple-darwin ;;
|
arm64|arm64e) triple=aarch64-apple-darwin ;;
|
||||||
x86_64) triple=x86_64-apple-darwin ;;
|
x86_64) triple=x86_64-apple-darwin ;;
|
||||||
esac
|
esac
|
||||||
cargo build --bin firewall-uninstall --target $triple $([ "$CONFIGURATION" = Debug ] || echo --release)
|
cargo build --bin firewall-uninstall --features machine-wide --target $triple $([ "$CONFIGURATION" = Debug ] || echo --release)
|
||||||
bins="$bins target/$triple/$CARGO_PROFILE/firewall-uninstall"
|
bins="$bins target/$triple/$CARGO_PROFILE/firewall-uninstall"
|
||||||
done
|
done
|
||||||
lipo -create $bins -output "$TARGET_BUILD_DIR/$EXECUTABLE_FOLDER_PATH/firewall-uninstall"
|
lipo -create $bins -output "$TARGET_BUILD_DIR/$EXECUTABLE_FOLDER_PATH/firewall-uninstall"
|
||||||
Reference in new issue
Block a user