Compare commits

...
17 changed files with 186 additions and 26 deletions

No files matched your search

+1
View File
@@ -11,4 +11,5 @@ pub static BUN: Manager = Manager {
("npm_config_registry", "http://registry.npmjs.org/"), ("npm_config_registry", "http://registry.npmjs.org/"),
], ],
args: &[], args: &[],
lockfile: None,
}; };
+1
View File
@@ -17,4 +17,5 @@ pub static CARGO: Manager = Manager {
"source.firewall.registry=\"sparse+http://index.crates.io/\"", "source.firewall.registry=\"sparse+http://index.crates.io/\"",
], ],
}], }],
lockfile: None,
}; };
+1
View File
@@ -8,4 +8,5 @@ pub static GEM: Manager = Manager {
when: &["install", "update", "fetch"], when: &["install", "update", "fetch"],
flags: &["--clear-sources", "--source", "http://rubygems.org/"], flags: &["--clear-sources", "--source", "http://rubygems.org/"],
}], }],
lockfile: None,
}; };
+1
View File
@@ -11,4 +11,5 @@ pub static GO: Manager = Manager {
("GOFLAGS", "-mod=mod"), ("GOFLAGS", "-mod=mod"),
], ],
args: &[], args: &[],
lockfile: None,
}; };
+8
View File
@@ -36,6 +36,14 @@ pub struct Manager {
pub command: &'static str, pub command: &'static str,
pub env: &'static [(&'static str, &'static str)], pub env: &'static [(&'static str, &'static str)],
pub args: &'static [Args], pub args: &'static [Args],
/// Where the manager records registry URLs, which then carry our http:// override.
pub lockfile: Option<Lockfile>,
}
/// What to do after a run about http:// registry URLs left in the lockfile at this path.
pub enum Lockfile {
Restore(&'static str),
Warn(&'static str),
} }
pub static MANAGERS: &[&Manager] = pub static MANAGERS: &[&Manager] =
+2 -1
View File
@@ -1,4 +1,4 @@
use super::{Manager, PROXY}; use super::{Lockfile, Manager, PROXY};
pub static NPM: Manager = Manager { pub static NPM: Manager = Manager {
command: "npm", command: "npm",
@@ -8,4 +8,5 @@ pub static NPM: Manager = Manager {
("npm_config_registry", "http://registry.npmjs.org/"), ("npm_config_registry", "http://registry.npmjs.org/"),
], ],
args: &[], args: &[],
lockfile: Some(Lockfile::Restore("package-lock.json")),
}; };
+1
View File
@@ -13,4 +13,5 @@ pub static PDM: Manager = Manager {
("PDM_PYPI_VERIFY_SSL", "false"), ("PDM_PYPI_VERIFY_SSL", "false"),
], ],
args: &[], args: &[],
lockfile: None,
}; };
+1 -1
View File
@@ -12,5 +12,5 @@ pub static PIP: Manager = recipe("pip");
pub static PIP3: Manager = recipe("pip3"); pub static PIP3: Manager = recipe("pip3");
const fn recipe(name: &'static str) -> Manager { const fn recipe(name: &'static str) -> Manager {
Manager { command: name, env: PIP_ENV, args: &[] } Manager { command: name, env: PIP_ENV, args: &[], lockfile: None }
} }
+1
View File
@@ -13,4 +13,5 @@ pub static PIPENV: Manager = Manager {
("PIP_TRUSTED_HOST", "pypi.org files.pythonhosted.org"), ("PIP_TRUSTED_HOST", "pypi.org files.pythonhosted.org"),
], ],
args: &[], args: &[],
lockfile: None,
}; };
+1
View File
@@ -9,4 +9,5 @@ pub static PNPM: Manager = Manager {
("pnpm_config_registry", "http://registry.npmjs.org/"), ("pnpm_config_registry", "http://registry.npmjs.org/"),
], ],
args: &[], args: &[],
lockfile: None,
}; };
+2 -1
View File
@@ -1,7 +1,8 @@
use super::{Manager, PROXY, PYPI_SIMPLE}; use super::{Lockfile, Manager, PROXY, PYPI_SIMPLE};
pub static UV: Manager = Manager { pub static UV: Manager = Manager {
command: "uv", command: "uv",
env: &[("HTTP_PROXY", PROXY), ("http_proxy", PROXY), ("UV_DEFAULT_INDEX", PYPI_SIMPLE)], env: &[("HTTP_PROXY", PROXY), ("http_proxy", PROXY), ("UV_DEFAULT_INDEX", PYPI_SIMPLE)],
args: &[], args: &[],
lockfile: Some(Lockfile::Warn("uv.lock")),
}; };
+35 -1
View File
@@ -6,9 +6,11 @@ mod windows;
pub use forward::spawn; pub use forward::spawn;
pub use managers::*; pub use managers::*;
use crate::policies::registries::BUILT_IN;
use std::io::{Error, ErrorKind}; use std::io::{Error, ErrorKind};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use tracing::{info, warn};
pub struct Invocation { pub struct Invocation {
pub program: String, pub program: String,
@@ -50,10 +52,42 @@ pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::R
let status = command.status(); let status = command.status();
#[cfg(windows)] #[cfg(windows)]
let status = status.map_err(|error| windows::explain(error, &program)); let status = status.map_err(|error| windows::explain(error, &program));
Ok(status?.code().unwrap_or(1)) let code = status?.code().unwrap_or(1);
let manager = MANAGERS.iter().find(|manager| manager.command == invocation.program);
match manager.and_then(|manager| manager.lockfile.as_ref()) {
Some(Lockfile::Restore(lockfile)) => restore_https(lockfile, dir),
Some(Lockfile::Warn(lockfile)) => warn_if_plaintext(lockfile, dir),
None => {}
}
Ok(code)
} }
// Command::new only finds .exe on Windows; npm, pnpm and gem are .cmd shims found via PATHEXT. // Command::new only finds .exe on Windows; npm, pnpm and gem are .cmd shims found via PATHEXT.
fn resolve(program: &str) -> std::io::Result<PathBuf> { fn resolve(program: &str) -> std::io::Result<PathBuf> {
which::which(program).map_err(|e| Error::new(ErrorKind::NotFound, e)) which::which(program).map_err(|e| Error::new(ErrorKind::NotFound, e))
} }
fn warn_if_plaintext(lockfile: &str, dir: Option<&Path>) {
let path = dir.unwrap_or(Path::new(".")).join(lockfile);
let Ok(text) = std::fs::read_to_string(path) else { return };
if BUILT_IN.iter().any(|registry| text.contains(&format!("http://{}/", registry.host()))) {
warn!("{lockfile} has http:// registry URLs; change them to https:// before committing");
}
}
fn restore_https(lockfile: &str, dir: Option<&Path>) {
let path = dir.unwrap_or(Path::new(".")).join(lockfile);
let Ok(text) = std::fs::read_to_string(&path) else { return };
let mut fixed = text.clone();
for registry in BUILT_IN {
let host = registry.host();
fixed = fixed.replace(&format!("http://{host}/"), &format!("https://{host}/"));
}
if fixed == text {
return;
}
match std::fs::write(&path, fixed) {
Ok(()) => info!("changed {lockfile}'s registry URLs back to https://"),
Err(e) => warn!("could not change {lockfile}'s registry URLs back to https://: {e}"),
}
}
+37 -9
View File
@@ -1,24 +1,32 @@
//! How the tray asks the service what it blocked, as the macOS app messages its extension. //! How the tray asks the service what it blocked, and allows a new package once, as the macOS
//! app messages its extension.
use super::security_descriptor; use super::{config_file, security_descriptor};
use crate::blocks; use crate::{blocks, Firewall};
use rama::error::BoxError; use rama::error::BoxError;
use std::os::windows::io::AsRawHandle;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions}; 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::Security::SECURITY_ATTRIBUTES;
use windows::Win32::System::Pipes::GetNamedPipeClientProcessId;
use windows::Win32::System::Threading::*;
pub(super) const PIPE: &str = r"\\.\pipe\StrixFirewall"; pub(super) const PIPE: &str = r"\\.\pipe\StrixFirewall";
/// Signed-in users can connect, as the tray runs as them. /// 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)"; const PIPE_ACL: &str = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)";
pub(super) async fn serve() -> Result<(), BoxError> { pub(super) async fn serve(firewall: Arc<Firewall>) -> Result<(), BoxError> {
let mut server = create(true)?; let mut server = create(true)?;
loop { loop {
server.connect().await?; server.connect().await?;
let client = std::mem::replace(&mut server, create(false)?); let client = std::mem::replace(&mut server, create(false)?);
let firewall = Arc::clone(&firewall);
tokio::spawn(async move { tokio::spawn(async move {
if let Err(error) = answer(client).await { if let Err(error) = answer(client, &firewall).await {
tracing::debug!(%error, "could not answer the tray"); tracing::debug!(%error, "could not answer the tray");
} }
}); });
@@ -40,19 +48,39 @@ fn create(first: bool) -> Result<NamedPipeServer, BoxError> {
} }
/// A line each way. /// A line each way.
async fn answer(client: NamedPipeServer) -> Result<(), BoxError> { async fn answer(client: NamedPipeServer, firewall: &Firewall) -> Result<(), BoxError> {
let mut client = BufReader::new(client); let mut client = BufReader::new(client);
let mut request = String::new(); let mut request = String::new();
(&mut client) (&mut client)
.take(64 * 1024) .take(64 * 1024)
.read_line(&mut request) .read_line(&mut request)
.await?; .await?;
let reply = match request.trim_end() { let request = request.trim_end();
"blocks" => serde_json::to_string(&blocks::take())?, // Only the app can message the macOS extension; here any program could connect.
other => format!("error: unknown request {other}"), 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?; client.write_all(format!("{reply}\n").as_bytes()).await?;
// Closing before the tray hangs up could discard the reply. // Closing before the tray hangs up could discard the reply.
let _ = tokio::time::timeout(Duration::from_secs(5), client.read(&mut [0])).await; let _ = tokio::time::timeout(Duration::from_secs(5), client.read(&mut [0])).await;
Ok(()) 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())
}
+3 -2
View File
@@ -155,8 +155,9 @@ 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));
tokio::spawn(async { let tray_firewall = Arc::clone(&firewall);
if let Err(error) = pipe::serve().await { tokio::spawn(async move {
if let Err(error) = pipe::serve(tray_firewall).await {
tracing::error!(%error, "the tray cannot reach the service"); tracing::error!(%error, "the tray cannot reach the service");
} }
}); });
+69 -7
View File
@@ -2,10 +2,11 @@
use super::pipe::PIPE; use super::pipe::PIPE;
use super::*; use super::*;
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; use muda::{IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use serde::Deserialize; use serde::Deserialize;
use std::io::{BufRead, BufReader, Cursor, Write}; use std::io::{BufRead, BufReader, Cursor, Write};
use std::os::windows::io::AsRawHandle; 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};
@@ -22,6 +23,8 @@ 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. /// Where blocked packages are listed, below Open logs.
const BLOCKED_AT: usize = 5; const BLOCKED_AT: usize = 5;
@@ -62,9 +65,11 @@ pub fn run() -> Result<(), BoxError> {
menu, menu,
blocked_separator, blocked_separator,
blocked: Vec::new(), 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) };
@@ -112,10 +117,13 @@ struct Tray {
blocked_separator: PredefinedMenuItem, blocked_separator: PredefinedMenuItem,
/// The most recent, as the menu lists them. /// The most recent, as the menu lists them.
blocked: Vec<BlockedPackage>, blocked: Vec<BlockedPackage>,
/// Allowed from another thread, so no longer listed.
allowed: (Sender<BlockedPackage>, Receiver<BlockedPackage>),
} }
#[derive(Clone, PartialEq, Deserialize)] #[derive(Clone, PartialEq, Deserialize)]
struct BlockedPackage { struct BlockedPackage {
id: u64,
eco: String, eco: String,
name: String, name: String,
version: Option<String>, version: Option<String>,
@@ -136,6 +144,11 @@ impl BlockedPackage {
fn malicious(&self) -> bool { fn malicious(&self) -> bool {
self.filter != "age" 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 {
@@ -161,6 +174,11 @@ impl Tray {
/// The service hands each block over once, so whichever tray asks first shows it. /// The service hands each block over once, so whichever tray asks first shows it.
fn collect_blocks(&mut self) { 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) { if self.state != Some(ServiceState::Running) {
return; return;
} }
@@ -184,7 +202,14 @@ impl Tray {
self.blocked.drain(..excess); self.blocked.drain(..excess);
self.list_blocked(); self.list_blocked();
// Off the UI thread, so the menu keeps working while they are open. // Off the UI thread, so the menu keeps working while they are open.
std::thread::spawn(move || new.iter().for_each(alert)); 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) { fn list_blocked(&self) {
@@ -204,14 +229,28 @@ impl Tray {
package.reason.clone() package.reason.clone()
}; };
let reason = MenuItem::new(reason, false, None); 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()); let label = format!("{verb} {}", package.label());
if let Ok(item) = Submenu::with_items(label, true, &[&reason]) { if let Ok(item) = Submenu::with_items(label, true, items) {
let _ = self.menu.insert(&item, BLOCKED_AT + 1 + i); 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 {
@@ -237,7 +276,8 @@ impl Tray {
} }
} }
fn alert(package: &BlockedPackage) { /// Whether the user chose to allow it once.
fn alert(package: &BlockedPackage) -> bool {
let text = format!("{} {}.", package.label(), package.reason); let text = format!("{} {}.", package.label(), package.reason);
let (title, text, style) = if package.malicious() { let (title, text, style) = if package.malicious() {
let title = format!("Blocked malware: {}", package.label()); let title = format!("Blocked malware: {}", package.label());
@@ -245,11 +285,33 @@ fn alert(package: &BlockedPackage) {
+ "\n\nIt was not installed. Don't install it from another source. If you didn't expect this " + "\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."; + "package, a dependency may be compromised; tell your security team.";
(title, text, MB_ICONERROR) (title, text, MB_ICONERROR)
} else { } else if !package.trustable() {
("Held back a new package".to_owned(), text, MB_ICONINFORMATION) ("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)); let (title, text) = (HSTRING::from(title), HSTRING::from(text));
unsafe { MessageBoxW(None, &text, &title, style | MB_SETFOREGROUND) }; 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. /// One request per connection, as the service answers.
+3 -1
View File
@@ -41,7 +41,9 @@ impl Filter for Osv {
if hits.is_empty() { if hits.is_empty() {
Verdict::Allow Verdict::Allow
} else { } else {
Verdict::Deny(format!("is flagged malicious by OSV ({})", hits.join(", "))) let links: Vec<String> =
hits.iter().map(|id| format!("{id} https://osv.dev/vulnerability/{id}")).collect();
Verdict::Deny(format!("is flagged malicious by OSV: {}", links.join(", ")))
} }
}) })
} }
+19 -3
View File
@@ -30,12 +30,12 @@ enum BlockAlert {
if package.malicious { if package.malicious {
alert.alertStyle = .critical alert.alertStyle = .critical
alert.messageText = "Blocked malware: \(package.label) (\(package.eco))" alert.messageText = "Blocked malware: \(package.label) (\(package.eco))"
alert.informativeText = "\(package.label) \(package.reason).\n\n" setText(alert, "\(package.label) \(package.reason).\n\n"
+ "It was not installed. Don't install it from another source. " + "It was not installed. Don't install it from another source. "
+ "If you didn't expect this package, a dependency may be compromised; tell your security team." + "If you didn't expect this package, a dependency may be compromised; tell your security team.")
} else { } else {
alert.messageText = "Held back a new package" alert.messageText = "Held back a new package"
alert.informativeText = "\(package.label) (\(package.eco)) \(package.reason)." setText(alert, "\(package.label) (\(package.eco)) \(package.reason).")
if package.trustable { if package.trustable {
alert.addButton(withTitle: "Allow Once") alert.addButton(withTitle: "Allow Once")
alert.addButton(withTitle: "Trust this version") alert.addButton(withTitle: "Trust this version")
@@ -56,6 +56,22 @@ enum BlockAlert {
NSApp.activate(ignoringOtherApps: true) 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 { enum AdminAuthorization {