Compare commits

...
8 changed files with 200 additions and 74 deletions

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
name: Build Firewall.pkg
on:
workflow_call:
inputs:
plan:
required: true
type: string
jobs:
pkg:
runs-on: macos-26
env:
TEAM: ${{ vars.APPLE_TEAM }}
NOTARY_PROFILE: firewall
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- run: brew install xcodegen
- name: Install signing identities, profiles and notary key
env:
CERTIFICATES: ${{ secrets.MACOS_CERTIFICATES }}
CERTIFICATES_PASSWORD: ${{ secrets.MACOS_CERTIFICATES_PASSWORD }}
APP_PROFILE: ${{ secrets.MACOS_APP_PROFILE }}
EXTENSION_PROFILE: ${{ secrets.MACOS_EXTENSION_PROFILE }}
NOTARY_KEY: ${{ secrets.NOTARY_KEY }}
NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }}
NOTARY_ISSUER: ${{ secrets.NOTARY_ISSUER }}
run: |
keychain="$RUNNER_TEMP/signing.keychain-db"
security create-keychain -p "" "$keychain"
security set-keychain-settings "$keychain"
security unlock-keychain -p "" "$keychain"
security default-keychain -s "$keychain"
security list-keychains -d user -s "$keychain" login.keychain
base64 --decode <<<"$CERTIFICATES" > "$RUNNER_TEMP/certificates.p12"
security import "$RUNNER_TEMP/certificates.p12" -k "$keychain" -P "$CERTIFICATES_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/pkgbuild
security set-key-partition-list -S apple-tool:,apple: -s -k "" "$keychain" >/dev/null
profiles="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
mkdir -p "$profiles"
base64 --decode <<<"$APP_PROFILE" > "$profiles/app.provisionprofile"
base64 --decode <<<"$EXTENSION_PROFILE" > "$profiles/extension.provisionprofile"
printf '%s' "$NOTARY_KEY" > "$RUNNER_TEMP/notary.p8"
xcrun notarytool store-credentials "$NOTARY_PROFILE" \
--key "$RUNNER_TEMP/notary.p8" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER"
- run: macos/build.sh
- uses: actions/upload-artifact@v7
with:
name: artifacts-macos-pkg
path: macos/build/Firewall.pkg
+12 -49
View File
@@ -211,14 +211,24 @@ jobs:
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
custom-build-macos-pkg:
needs:
- plan
- build-local-artifacts
uses: ./.github/workflows/build-macos-pkg.yml
with:
plan: ${{ needs.plan.outputs.val }}
secrets: inherit
# Determines if we should publish/announce
host:
needs:
- plan
- build-local-artifacts
- build-global-artifacts
- custom-build-macos-pkg
# Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine)
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.custom-build-macos-pkg.result == 'skipped' || needs.custom-build-macos-pkg.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
runs-on: "ubuntu-22.04"
@@ -278,52 +288,6 @@ jobs:
gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
publish-homebrew-formula:
needs:
- plan
- host
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PLAN: ${{ needs.plan.outputs.val }}
GITHUB_USER: "axo bot"
GITHUB_EMAIL: "[email protected]"
if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: true
repository: "usestrix/homebrew-tap"
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
# So we have access to the formula
- name: Fetch homebrew formulae
uses: actions/download-artifact@v8
with:
pattern: artifacts-*
path: Formula/
merge-multiple: true
# This is extra complex because you can make your Formula name not match your app name
# so we need to find releases with a *.rb file, and publish with that filename.
- name: Commit formula files
run: |
git config --global user.name "${GITHUB_USER}"
git config --global user.email "${GITHUB_EMAIL}"
for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do
filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output)
name=$(echo "$filename" | sed "s/\.rb$//")
version=$(echo "$release" | jq .app_version --raw-output)
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
brew update
# We avoid reformatting user-provided data such as the app description and homepage.
brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true
git add "Formula/${filename}"
git commit -m "${name} ${version}"
done
git push
custom-publish-crates:
needs:
- plan
@@ -342,12 +306,11 @@ jobs:
needs:
- plan
- host
- publish-homebrew-formula
- custom-publish-crates
# use "always() && ..." to allow us to wait for all publish jobs while
# still allowing individual publish jobs to skip themselves (for prereleases).
# "host" however must run to completion, no skipping allowed!
if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') && (needs.custom-publish-crates.result == 'skipped' || needs.custom-publish-crates.result == 'success') }}
if: ${{ always() && needs.host.result == 'success' && (needs.custom-publish-crates.result == 'skipped' || needs.custom-publish-crates.result == 'success') }}
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+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 crate::blocks;
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() -> Result<(), BoxError> {
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).await {
if let Err(error) = answer(client, &firewall).await {
tracing::debug!(%error, "could not answer the tray");
}
});
@@ -40,19 +48,39 @@ fn create(first: bool) -> Result<NamedPipeServer, BoxError> {
}
/// 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 request = String::new();
(&mut client)
.take(64 * 1024)
.read_line(&mut request)
.await?;
let reply = match request.trim_end() {
"blocks" => serde_json::to_string(&blocks::take())?,
other => format!("error: unknown request {other}"),
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())
}
+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);
tokio::spawn(listen_on(CRL_ADDRESS, 80, &exec)?.serve(crl));
tokio::spawn(async {
if let Err(error) = pipe::serve().await {
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");
}
});
+69 -7
View File
@@ -2,10 +2,11 @@
use super::pipe::PIPE;
use super::*;
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use muda::{IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
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 tray_icon::{Icon, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
use windows::core::{w, PCWSTR};
@@ -22,6 +23,8 @@ const MENU_PAUSE: &str = "pause";
const MENU_RESTART: &str = "restart";
const MENU_LOG: &str = "log";
const MENU_QUIT: &str = "quit";
/// Also the service's request.
const ALLOW_ONCE: &str = "allow-once";
const LOGO: &[u8] = include_bytes!("tray.png");
/// Where blocked packages are listed, below Open logs.
const BLOCKED_AT: usize = 5;
@@ -62,9 +65,11 @@ pub fn run() -> Result<(), BoxError> {
menu,
blocked_separator,
blocked: Vec::new(),
allowed: channel(),
};
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();
while unsafe { GetMessageW(&mut message, None, 0, 0) }.0 > 0 {
unsafe { DispatchMessageW(&message) };
@@ -112,10 +117,13 @@ struct Tray {
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>,
@@ -136,6 +144,11 @@ impl BlockedPackage {
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 {
@@ -161,6 +174,11 @@ impl Tray {
/// 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;
}
@@ -184,7 +202,14 @@ impl Tray {
self.blocked.drain(..excess);
self.list_blocked();
// 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) {
@@ -204,14 +229,28 @@ impl Tray {
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, &[&reason]) {
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) {
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 screening = state == Some(ServiceState::Running);
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 (title, text, style) = if package.malicious() {
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 "
+ "package, a dependency may be compromised; tell your security team.";
(title, text, MB_ICONERROR)
} else {
} 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) };
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.
+3 -1
View File
@@ -41,7 +41,9 @@ impl Filter for Osv {
if hits.is_empty() {
Verdict::Allow
} 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(", ")))
}
})
}
+3 -3
View File
@@ -4,10 +4,10 @@ members = ["cargo:core"]
[dist]
cargo-dist-version = "0.32.0"
ci = "github"
installers = ["shell", "powershell", "homebrew", "msi"]
installers = ["shell", "powershell", "msi"]
# core/wix/main.wxs is edited to install the service.
allow-dirty = ["msi"]
features = ["machine-wide"]
tap = "usestrix/homebrew-tap"
publish-jobs = ["homebrew", "./publish-crates"]
publish-jobs = ["./publish-crates"]
global-artifacts-jobs = ["./build-macos-pkg"]
targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
+19 -3
View File
@@ -30,12 +30,12 @@ enum BlockAlert {
if package.malicious {
alert.alertStyle = .critical
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. "
+ "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 {
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")
@@ -56,6 +56,22 @@ enum BlockAlert {
NSApp.activate(ignoringOtherApps: true)
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 {