Compare commits

..
Author SHA1 Message Date
Ian d2d17f9ee3 Screen downloads from a PyPI mirror listed only under pypi
A PyPI mirror serves its files from the index host, so the pypi registry
now names packages in /packages/ paths too, and checks their age.
2026-10-23 12:46:18 -04:00
ian-at-strix 21f3cfb147 Send command-prefix downloads to the configured mirrors (#78) 2026-10-23 12:28:49 -04:00
ian-at-strix a3f53315cf Add a rustfmt config matching the code's style, and format to it (#68) 2026-10-23 12:18:25 -04:00
ian-at-strix 677884aa36 Change npm's lockfile registry URLs back to https:// after a wrapped run (#84) 2026-10-23 12:13:49 -04:00
ian-at-strix e6691b252c Version 0.1.0-rc.1 to test the release pipeline (#88) 2026-10-23 12:01:36 -04:00
ian-at-strix a2f631866e Build, sign and notarize Firewall.pkg in the release workflow (#85)
* Build, sign and notarize Firewall.pkg in the release workflow

* Stop publishing a Homebrew formula (#87)
2026-10-23 11:53:53 -04:00
ian-at-strix b42185ee6f Give every build a new bundle version so macOS replaces the installed extension (#76) 2026-10-23 11:53:33 -04:00
30 changed files with 235 additions and 179 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 }}
+1 -1
View File
@@ -3370,7 +3370,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strix-firewall"
version = "0.1.0"
version = "0.1.0-rc.1"
dependencies = [
"clap",
"core-foundation 0.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "strix-firewall"
version = "0.1.0"
version = "0.1.0-rc.1"
edition = "2021"
license = "Apache-2.0"
authors = ["Strix"]
+1
View File
@@ -0,0 +1 @@
use_small_heuristics = "Max"
+4
View File
@@ -80,6 +80,7 @@ async fn handle(req: Request, firewall: &Firewall) -> Result<Response, Infallibl
warn!(method = %req.method(), uri = %req.uri(), "unroutable, expected an absolute URL");
return Ok(text(400, "firewall: expected an absolute URL\n"));
};
let url = firewall.mirrored(url);
let reason = policies::screen(&url, firewall).await;
telemetry::screened(firewall, &url, reason.as_deref());
@@ -129,6 +130,9 @@ pub fn downgrade(firewall: &Firewall, body: Vec<u8>) -> Vec<u8> {
Ok(text) => text,
Err(raw) => return raw.into_bytes(),
};
for (upstream, mirror) in firewall.mirrors() {
text = text.replace(mirror, upstream);
}
for host in firewall.hosts() {
text = text.replace(&format!("https://{host}"), &format!("http://{host}"));
}
+4 -1
View File
@@ -83,7 +83,10 @@ fn record(
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
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());
+7 -3
View File
@@ -86,10 +86,13 @@ impl FirewallConfig {
Err(e) if e.kind() == ErrorKind::NotFound => continue,
Err(e) => return Err(Error::new(e.kind(), format!("{}: {e}", path.display()))),
};
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 config = FirewallConfig::deserialize(&layer).map_err(invalid)?;
check(&config).map_err(|e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display())))?;
check(&config).map_err(|e| {
Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display()))
})?;
let Value::Object(layer) = layer else {
unreachable!("a config parses only from an object");
};
@@ -122,7 +125,8 @@ 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 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!({}),
+22
View File
@@ -69,6 +69,28 @@ impl Firewall {
self.registries.iter().any(|r| r.index && r.host() == host_of(url))
}
/// The URL at the first mirror of the built-in registry it is on, if one is configured.
pub fn mirrored(&self, url: String) -> String {
for (upstream, mirror) in self.mirrors() {
if let Some(rest) = url.strip_prefix(upstream) {
return format!("{mirror}{rest}");
}
}
url
}
/// Each built-in upstream with a mirror, paired with its first one.
pub fn mirrors(&self) -> Vec<(&'static str, &str)> {
let mut mirrors = Vec::new();
for registry in BUILT_IN {
let first = self.config.registries.get(registry.name()).and_then(|m| m.first());
if let Some(mirror) = first {
mirrors.push((registry.upstream, mirror.as_str()));
}
}
mirrors
}
pub fn resolve(&self, registry: &'static Registry, rest: &str) -> Resolved {
resolve(registry, rest, &self.patterns[registry.prefix])
}
+5 -1
View File
@@ -25,7 +25,11 @@ pub fn local_url(port: u16, path: &str) -> String {
}
pub fn init_logging(default_level: &str) {
fmt().with_env_filter(log_filter(default_level)).with_target(false).with_writer(std::io::stderr).init();
fmt()
.with_env_filter(log_filter(default_level))
.with_target(false)
.with_writer(std::io::stderr)
.init();
}
pub(crate) fn log_filter(default_level: &str) -> EnvFilter {
+1 -4
View File
@@ -58,10 +58,7 @@ fn serve() -> ! {
Ok(()) => exit(0),
Err(e) if e.kind() == ErrorKind::AddrInUse => {
let location = config::path().map_or("the config".into(), |p| p.display().to_string());
fail(
"port",
&format!("port {port} is in use, set another in {location}"),
)
fail("port", &format!("port {port} is in use, set another in {location}"))
}
Err(e) if e.kind() == ErrorKind::PermissionDenied => {
fail("port", &format!("not allowed to listen on port {port}"))
+1 -1
View File
@@ -1,6 +1,6 @@
use crate::http::{blocked, https_url};
use crate::{policies, telemetry};
use crate::Firewall;
use crate::{policies, telemetry};
use rama::error::BoxError;
use rama::http::{Request, Response};
use rama::{Layer, Service};
+6 -3
View File
@@ -75,8 +75,8 @@ fn create() -> Result<SecKey, BoxError> {
.set_key_type(KeyType::ec_sec_prime_random())
.set_token(Token::SecureEnclave)
.set_access_control(access);
let key = SecKey::new(&options)
.map_err(|e| format!("secure enclave key generation failed: {e}"))?;
let key =
SecKey::new(&options).map_err(|e| format!("secure enclave key generation failed: {e}"))?;
let blob = key
.attributes()
@@ -102,7 +102,10 @@ fn load(blob: &[u8]) -> Result<SecKey, BoxError> {
CFString::wrap_under_get_rule(kSecAttrKeyClass),
CFString::wrap_under_get_rule(kSecAttrKeyClassPrivate).into_CFType(),
),
(CFString::from_static_string(TOKEN_OBJECT_ID), CFData::from_buffer(blob).into_CFType()),
(
CFString::from_static_string(TOKEN_OBJECT_ID),
CFData::from_buffer(blob).into_CFType(),
),
])
};
let mut error = ptr::null_mut();
+5 -3
View File
@@ -5,8 +5,8 @@ mod uninstall;
pub use uninstall::uninstall;
use crate::config::FirewallConfig;
use crate::{blocks, mitm, telemetry};
use crate::Firewall;
use crate::{blocks, mitm, telemetry};
use rama::bytes::Bytes;
use rama::error::BoxError;
use rama::io::BridgeIo;
@@ -107,7 +107,8 @@ pub fn system_config() -> Option<PathBuf> {
fn handler(exec: Executor) -> Result<Handler, BoxError> {
telemetry::set_mode("extension");
telemetry::heartbeat();
let config = FirewallConfig::load_from(&[CONFIG_FILE.into()]).inspect_err(|_| telemetry::error("config"))?;
let config = FirewallConfig::load_from(&[CONFIG_FILE.into()])
.inspect_err(|_| telemetry::error("config"))?;
let firewall = Arc::new(Firewall::new(config));
let hosts = firewall.hosts();
let key = enclave::EnclaveKey::load_or_create().inspect_err(|_| telemetry::error("enclave"))?;
@@ -125,7 +126,8 @@ fn init(_config: Option<&TransparentProxyInitConfig>) -> bool {
std::panic::set_hook(Box::new(|panic| tracing::error!("{panic}")));
if let Ok(oslog) = OsLogLayer::new(BUNDLE_ID, "extension") {
let oslog = oslog.with_privacy(Privacy::Public);
let _ = tracing_subscriber::registry().with(crate::log_filter("info")).with(oslog).try_init();
let _ =
tracing_subscriber::registry().with(crate::log_filter("info")).with(oslog).try_init();
}
true
}
+2 -6
View File
@@ -37,9 +37,7 @@ pub fn load_or_create_ca(
None => {
warn!(?hosts, "minting a new CA");
let ca = CertificateAuthority::generate(hosts, signer)?;
LOCAL_MACHINE
.create(REGISTRY_KEY)?
.set_string("ca", ca.to_blob(hosts))?;
LOCAL_MACHINE.create(REGISTRY_KEY)?.set_string("ca", ca.to_blob(hosts))?;
// Windows discards CRLs it cached before now, in case an old CA's is cached at our URL.
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() / 100
+ FILETIME_UNIX_EPOCH;
@@ -119,9 +117,7 @@ fn owned_by_admins(path: &Path) -> windows::core::Result<bool> {
.ok()?
};
let admins = [WinBuiltinAdministratorsSid, WinLocalSystemSid];
let owned = admins
.into_iter()
.any(|sid| unsafe { IsWellKnownSid(owner, sid) }.as_bool());
let owned = admins.into_iter().any(|sid| unsafe { IsWellKnownSid(owner, sid) }.as_bool());
// `owner` points into the descriptor, so it is freed last.
unsafe { LocalFree(Some(HLOCAL(descriptor.0))) };
Ok(owned)
+5 -20
View File
@@ -48,9 +48,7 @@ pub fn system_config() -> Option<PathBuf> {
/// In the folder Windows reads it from, normally %SystemRoot%\System32\drivers\etc.
fn hosts_file() -> PathBuf {
let key = r"SYSTEM\CurrentControlSet\Services\Tcpip\Parameters";
let configured = LOCAL_MACHINE
.open(key)
.and_then(|key| key.get_hstring("DataBasePath"));
let configured = LOCAL_MACHINE.open(key).and_then(|key| key.get_hstring("DataBasePath"));
let dir = match configured {
Ok(dir) => expand_environment(&dir),
Err(_) => known_folder(&FOLDERID_System).join(r"drivers\etc"),
@@ -71,17 +69,12 @@ fn expand_environment(path: &HSTRING) -> PathBuf {
let mut expanded = vec![0; len as usize];
let len = unsafe { ExpandEnvironmentStringsW(path, Some(&mut expanded)) };
// Less the terminating null.
PathBuf::from(String::from_utf16_lossy(
&expanded[..(len as usize).saturating_sub(1)],
))
PathBuf::from(String::from_utf16_lossy(&expanded[..(len as usize).saturating_sub(1)]))
}
/// From 127.0.0.3, leaving 127.0.0.1 to whatever else serves locally.
fn loopback_addresses(hosts: &[&'static str]) -> Vec<(Ipv4Addr, &'static str)> {
(3..)
.zip(hosts)
.map(|(i, host)| (Ipv4Addr::new(127, 0, 0, i), *host))
.collect()
(3..).zip(hosts).map(|(i, host)| (Ipv4Addr::new(127, 0, 0, i), *host)).collect()
}
/// Points each host at its listener, replacing the lines we wrote before; with none, only removes
@@ -91,11 +84,7 @@ fn write_hosts_entries(hosts: &[&'static str]) -> std::io::Result<()> {
let old = std::fs::read(hosts_file())?;
let ours = |line: &&[u8]| line.trim_ascii_end().ends_with(TAG.as_bytes());
let lines = old.split_inclusive(|&byte| byte == b'\n');
let mut new: Vec<u8> = lines
.filter(|line| !ours(line))
.flatten()
.copied()
.collect();
let mut new: Vec<u8> = lines.filter(|line| !ours(line)).flatten().copied().collect();
if !new.is_empty() && !new.ends_with(b"\n") {
new.extend_from_slice(b"\r\n");
}
@@ -120,11 +109,7 @@ fn security_descriptor(sddl: &str) -> windows::core::Result<Owned<HLOCAL>> {
}
fn read_registry_string(key: &str, value: &str) -> Result<Option<String>, BoxError> {
none_if_missing(
LOCAL_MACHINE
.open(key)
.and_then(|key| key.get_string(value)),
)
none_if_missing(LOCAL_MACHINE.open(key).and_then(|key| key.get_string(value)))
}
/// `None` for a missing key or value.
+1 -4
View File
@@ -51,10 +51,7 @@ fn create(first: bool) -> Result<NamedPipeServer, 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?;
(&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())? {
+4 -12
View File
@@ -79,9 +79,7 @@ fn run_until_stopped() -> Result<(), BoxError> {
// Answered once handled, so a failed pause fails the caller's ControlService.
let (reply, handled) = tokio::sync::oneshot::channel();
let _ = control_sender.send((control, reply));
handled
.blocking_recv()
.unwrap_or(ServiceControlHandlerResult::NoError)
handled.blocking_recv().unwrap_or(ServiceControlHandlerResult::NoError)
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
@@ -109,9 +107,7 @@ fn run_until_stopped() -> Result<(), BoxError> {
}
Err(error) => {
tracing::error!(%error, "could not rewrite the hosts file");
let code = error
.raw_os_error()
.map_or(ERROR_GEN_FAILURE.0, |code| code as u32);
let code = error.raw_os_error().map_or(ERROR_GEN_FAILURE.0, |code| code as u32);
ServiceControlHandlerResult::Other(code)
}
};
@@ -165,12 +161,8 @@ async fn start_screening(status: &ServiceStatusHandle) -> Result<Vec<&'static st
let service = mitm::mitm_service(firewall, &ca, signer, Some(crl_url), exec.clone());
for (address, host) in loopback_addresses(&hosts) {
for port in [80, 443] {
let upstream = RedirectedHost {
host,
port,
resolver: resolver.clone(),
service: service.clone(),
};
let upstream =
RedirectedHost { host, port, resolver: resolver.clone(), service: service.clone() };
let upstream = ConsumeErrLayer::trace_as_warning().into_layer(upstream);
tokio::spawn(listen_on(address, port, &exec)?.serve(upstream));
}
+8 -27
View File
@@ -50,9 +50,7 @@ pub fn run() -> Result<(), BoxError> {
&MenuItem::with_id(MENU_QUIT, "Quit", true, None),
])?;
let (logo, logo_size) = logo_mask()?;
let icon = TrayIconBuilder::new()
.with_menu(Box::new(menu.clone()))
.build()?;
let icon = TrayIconBuilder::new().with_menu(Box::new(menu.clone())).build()?;
let window = HWND(icon.window_handle());
let mut tray = Tray {
icon,
@@ -84,11 +82,7 @@ pub fn run() -> Result<(), BoxError> {
}
while let Ok(event) = TrayIconEvent::receiver().try_recv() {
// The menu opens on release, so this keeps it current.
if let TrayIconEvent::Click {
button_state: MouseButtonState::Down,
..
} = event
{
if let TrayIconEvent::Click { button_state: MouseButtonState::Down, .. } = event {
tray.update();
}
}
@@ -218,11 +212,7 @@ impl Tray {
}
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 verb = if package.malicious() { "Blocked" } else { "Held back" };
let reason = if package.malicious() {
format!("Malicious: {}", package.reason)
} else {
@@ -230,11 +220,8 @@ impl Tray {
};
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 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);
@@ -316,10 +303,7 @@ fn allow_once(package: BlockedPackage, allowed: &Sender<BlockedPackage>) {
/// 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)?;
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)? };
@@ -345,9 +329,7 @@ fn show_error(error: BoxError) {
fn service_status() -> Option<ServiceStatus> {
let manager =
ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT).ok()?;
let service = manager
.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS)
.ok()?;
let service = manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS).ok()?;
service.query_status().ok()
}
@@ -356,8 +338,7 @@ fn light_taskbar() -> bool {
let Ok(key) = CURRENT_USER.open(personalize) else {
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 {
+1 -3
View File
@@ -63,9 +63,7 @@ impl Filter for Denylist {
fn check<'a>(&'a self, registry: &'static Registry, pkg: &'a Package) -> VerdictFuture<'a> {
Box::pin(async move {
let eco = registry.osv_name;
let load = || async {
RwLock::new(Arc::new(load(&self.source, eco).await))
};
let load = || async { RwLock::new(Arc::new(load(&self.source, eco).await)) };
let list = self.lists[eco].get_or_init(load).await;
let list = Arc::clone(&list.read().expect("denylist lock"));
match &*list {
+4 -2
View File
@@ -41,8 +41,10 @@ impl Filter for Osv {
if hits.is_empty() {
Verdict::Allow
} else {
let links: Vec<String> =
hits.iter().map(|id| format!("{id} https://osv.dev/vulnerability/{id}")).collect();
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(", ")))
}
})
+24 -18
View File
@@ -11,9 +11,12 @@ pub static PYPI: Registry = Registry {
normalize: Some(pep503),
patterns: &[
Pattern { re: r"^simple/(?<name>[^/]+)", rewrite: Some(FILES) },
// A mirror serves the files from its index host.
WHEEL,
SDIST,
Pattern { re: "", rewrite: Some(FILES) },
],
published: None,
published: PUBLISHED,
};
/// The directories are hash shards, but the filename names the distribution.
@@ -24,25 +27,28 @@ pub static PYPI_FILES: Registry = Registry {
upstream: "https://files.pythonhosted.org/",
index: false,
normalize: Some(pep503),
patterns: &[
// A wheel escapes - _ and . to _, so the first two fields are exactly name and version.
Pattern {
re: r"^packages/.+/(?<name>[^-/]+)-(?<version>[^-/]+)-[^/]+\.whl(?:\.metadata)?$",
rewrite: None,
},
// An sdist keeps the name's hyphens, so the version is the last field starting with a digit.
Pattern {
re: r"^packages/.+/(?<name>.+)-(?<version>\d[^-/]*)\.(?:tar\.gz|zip|tar\.bz2)$",
rewrite: None,
},
],
published: Some(Published {
url: "https://pypi.org/pypi/{name}/{version}/json",
pointer: "/urls/0/upload_time_iso_8601",
escape: None,
}),
patterns: &[WHEEL, SDIST],
published: PUBLISHED,
};
// A wheel escapes - _ and . to _, so the first two fields are exactly name and version.
const WHEEL: Pattern = Pattern {
re: r"^packages/.+/(?<name>[^-/]+)-(?<version>[^-/]+)-[^/]+\.whl(?:\.metadata)?$",
rewrite: None,
};
// An sdist keeps the name's hyphens, so the version is the last field starting with a digit.
const SDIST: Pattern = Pattern {
re: r"^packages/.+/(?<name>.+)-(?<version>\d[^-/]*)\.(?:tar\.gz|zip|tar\.bz2)$",
rewrite: None,
};
const PUBLISHED: Option<Published> = Some(Published {
url: "https://pypi.org/pypi/{name}/{version}/json",
pointer: "/urls/0/upload_time_iso_8601",
escape: None,
});
/// PEP 503: runs of - _ and . collapse to one -, lowercased, which also un-escapes a wheel.
fn pep503(name: &str) -> String {
let mut out = String::with_capacity(name.len());
+1 -5
View File
@@ -23,11 +23,7 @@ static TELEMETRY: LazyLock<Option<Telemetry>> = LazyLock::new(|| {
return None;
}
let session = format!("{:016x}", rand::random::<u64>());
Some(Telemetry {
key,
session,
events: Mutex::default(),
})
Some(Telemetry { key, session, events: Mutex::default() })
});
/// How the firewall is running: "extension", "service", "wrapper" or "proxy".
+10 -3
View File
@@ -14,7 +14,8 @@ async fn allowing_once_releases_the_held_download() {
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();
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;
}
@@ -22,6 +23,12 @@ async fn allowing_once_releases_the_held_download() {
};
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);
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
);
}
+26 -6
View File
@@ -49,8 +49,13 @@ fn a_later_file_wins_and_allow_lists_add_up() {
std::fs::create_dir_all(&dir).expect("temp dir");
let user = dir.join("user.json");
let system = dir.join("system.json");
std::fs::write(&user, r#"{"min_age_hours": 1, "fail_open": false, "allow": {"npm": [{"name": "a"}]}}"#).expect("write");
std::fs::write(&system, r#"{"min_age_hours": 24, "allow": {"npm": [{"name": "b"}]}}"#).expect("write");
std::fs::write(
&user,
r#"{"min_age_hours": 1, "fail_open": false, "allow": {"npm": [{"name": "a"}]}}"#,
)
.expect("write");
std::fs::write(&system, r#"{"min_age_hours": 24, "allow": {"npm": [{"name": "b"}]}}"#)
.expect("write");
let missing = dir.join("missing.json");
let config = FirewallConfig::load_from(&[user, system, missing]).expect("loads");
std::fs::remove_dir_all(&dir).ok();
@@ -67,20 +72,34 @@ async fn a_mirror_is_screened_like_the_registry_it_copies() {
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()));
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");
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 a_pypi_mirror_screens_the_files_it_serves() {
let config = r#"{"registries": {"pypi": ["https://art.example.com/pypi/"]}}"#;
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
let url = "https://art.example.com/pypi/packages/ab/cd/six-1.16.0-py2.py3-none-any.whl";
let (registry, rest) = firewall.match_on(url, |r| r.upstream).expect("the mirror");
assert_eq!(firewall.resolve(registry, rest).pkg.map(|p| p.label()), Some("[email protected]".into()));
}
#[tokio::test]
async fn an_allowed_version_is_still_checked_for_malware() {
// The unreachable denylist shows that it still runs.
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 (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 = Package { name: "golang.org/x/net".into(), version: Some("0.20.0".into()) };
let verdict = consult(&firewall, registry, &pkg).await;
assert_eq!(verdict.map(|(filter, _)| filter), Some("denylist"));
@@ -99,7 +118,8 @@ async fn failing_closed_blocks_what_could_not_be_checked() {
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 (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");
+15 -1
View File
@@ -49,7 +49,8 @@ fn a_plain_http_upstream_is_not_upgraded() {
let port = aliasing::spawn(0, FirewallConfig::default()).expect("spawn");
let mut proxy = TcpStream::connect(("127.0.0.1", port)).expect("connect");
let request = format!("GET http://{addr}/x HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
let request =
format!("GET http://{addr}/x HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
proxy.write_all(request.as_bytes()).expect("write");
let mut response = String::new();
proxy.read_to_string(&mut response).expect("read");
@@ -64,3 +65,16 @@ async fn downgrade_touches_known_hosts_in_text_only() {
let binary = vec![0x1f, 0x8b, 0xff, 0xfe];
assert_eq!(downgrade(&firewall, binary.clone()), binary);
}
#[tokio::test]
async fn a_mirror_stands_in_for_its_registry() {
let mut config = FirewallConfig::default();
config.registries.insert("pypi".into(), vec!["https://art.example.com/pypi/".into()]);
let firewall = Firewall::new(config);
let mirrored = firewall.mirrored("https://pypi.org/simple/six/".into());
assert_eq!(mirrored, "https://art.example.com/pypi/simple/six/");
let npm = "https://registry.npmjs.org/left-pad";
assert_eq!(firewall.mirrored(npm.into()), npm);
let links = b"https://art.example.com/pypi/packages/six.whl".to_vec();
assert_eq!(downgrade(&firewall, links), b"http://pypi.org/packages/six.whl".to_vec());
}
+2 -1
View File
@@ -36,7 +36,8 @@ pub fn all(port: u16) -> Vec<(&'static str, Outcome)> {
fn run(probe: &Probe, port: u16) -> Outcome {
let args = probe.args.iter().map(|arg| arg.to_string()).collect();
let dir = probe.setup.and_then(|prepare| prepare());
match aliasing::run(port, &Invocation::new(probe.manager.command.into(), args), dir.as_deref()) {
match aliasing::run(port, &Invocation::new(probe.manager.command.into(), args), dir.as_deref())
{
Ok(0) => Outcome::Ok,
Ok(_) => Outcome::Failed,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Outcome::Absent,
+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"]
+1 -1
View File
@@ -48,7 +48,7 @@ build() {
xcodegen generate >/dev/null
xcodebuild -project Firewall.xcodeproj -scheme StrixFirewall -configuration $CONFIGURATION \
-destination generic/platform=macOS \
-derivedDataPath build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
-derivedDataPath build CURRENT_PROJECT_VERSION="$(date +%s)" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" CODE_SIGN_ENTITLEMENTS="" build >/dev/null
}
+4
View File
@@ -19,6 +19,8 @@ settings:
ENABLE_DEBUG_DYLIB: "NO"
CODE_SIGN_STYLE: Automatic
CODE_SIGN_IDENTITY: Apple Development
# build.sh sets a fresh one per build; macOS only replaces an installed extension whose version differs.
CURRENT_PROJECT_VERSION: "1"
packages:
Rama:
@@ -42,6 +44,7 @@ targets:
properties:
CFBundlePackageType: SYSX
CFBundleShortVersionString: "0.1"
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install.
NetworkExtension:
NEMachServiceName: ${TEAM}.com.usestrix.firewall.extension
@@ -102,6 +105,7 @@ targets:
path: App/Info.plist
properties:
CFBundleShortVersionString: "0.1"
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install.
LSUIElement: true