Compare commits

..
Author SHA1 Message Date
Ian c56a3799b6 Version 0.1.0 2026-10-26 09:55:03 -04:00
ian-at-strix 56d34dae15 Add a README (#65)
* Add a README

* Tweaks

* Tweaks

* Tweaks

* Install further up

* Blogpost Link

* Blogpost Link
2026-10-23 13:57:22 -04:00
ian-at-strix 2c5bb47251 Stop rebuilding the menu bar menu on every block poll (#93) 2026-10-23 13:35:26 -04:00
ian-at-strix 5036ba69a5 Version 0.1.0-rc.2 (#92) 2026-10-23 12:55:31 -04:00
ian-at-strix c16be8d492 Screen downloads from a PyPI mirror listed only under pypi (#91)
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:51:44 -04:00
ian-at-strixandIan Graham Martinez a162c5307b Re-land Windows Trust, logo and popup links (#90)
* Let an admin trust a held-back package from the Windows tray

* Offer Trust this version in the Windows block popup

* Show the Strix logo in the Windows binaries and block popup

* Make the links in the Windows block popups clickable

---------

Co-authored-by: Ian Graham Martinez <[email protected]>
2026-10-23 12:50:29 -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
ian-at-strix 97a5a148e0 Warn when npm or uv leaves http:// registry URLs in its lockfile (#83) 2026-10-23 11:49:52 -04:00
ian-at-strixandIan Graham Martinez 877807740b Allow a held-back package once from the Windows tray (#64)
Co-authored-by: Ian Graham Martinez <[email protected]>
2026-10-23 11:25:50 -04:00
ian-at-strix cd9b0614bc Link OSV advisories in block reasons (#77) 2026-10-23 11:11:14 -04:00
ian-at-strix 6cad09d32d Make the links in the block popups clickable (#74) 2026-10-23 10:58:18 -04:00
ian-at-strix 23db767cc1 Allow a held-back package once from the app, releasing the waiting download (#37)
* Hold a held-back download open so allowing it lets it through

* Allow a held-back package once from the app, releasing the waiting download
2026-10-23 10:55:03 -04:00
ian-at-strix 37a56fb7d2 Show the Windows service's blocks from the tray (#63)
The service answers the tray's requests on a named pipe, as the macOS extension answers the app's messages. The tray asks for blocks every two seconds, pops each new one up and lists the last ten in its menu.
2026-10-23 10:52:09 -04:00
50 changed files with 981 additions and 198 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 }}
+93
View File
@@ -0,0 +1,93 @@
# Strix's Dependency Firewall
Stops malicious packages before they install. Built by [Strix](https://strix.ai/blog/firewall-announcement).
Screening runs on your device: downloads go straight to the registry, never through a Strix server.
Each package's name and version are looked up [Strix's malicious-package blocklist](https://github.com/usestrix/firewall-blocklist), so installs are blocked system-wide *before* reaching your machine.
## Install
**Machine-wide**, from the [latest release](https://github.com/usestrix/firewall/releases/latest):
- macOS: open `Firewall.pkg`, then approve the extension when macOS asks.
- Windows: run the `.msi` as an admin.
**Prefix-mode**
cargo:
```sh
cargo install strix-firewall
```
curl:
```sh
curl -LsSf https://github.com/usestrix/firewall/releases/latest/download/strix-firewall-installer.sh | sh
```
Powershell:
```powershell
powershell -c "irm https://github.com/usestrix/firewall/releases/latest/download/strix-firewall-installer.ps1 | iex"
```
## Platforms
| | Machine-wide | Command prefix |
|---|---|---|
| macOS 13+ | ✅ Apple silicon or T2 chip | ✅ |
| Windows 10/11, x86-64 | ✅ with a TPM 2.0 | ✅ |
| Linux (glibc 2.35+), x86-64 or arm64 | 🚧 Coming Soon | ✅ |
## Supported registries
| Registry | Package managers |
|---|---|
| npm | npm, pnpm, bun |
| PyPI | pip, uv, pdm, pipenv |
| crates.io | cargo |
| Go module proxy | go |
| RubyGems | gem |
Mirrors of these, such as Artifactory or Nexus, can be added in via a config file.
## Features
- **Up-to-the-minute data:** Strix constantly monitors updates to top packages (covering 99% of installed packages) to scan for malware, and publishes the results. We also consult [OSSF's blocklist](https://github.com/ossf/malicious-packages) and [OSV](osv.dev), as well as other public reports. Updates take effect without any user action.
- **Holds back new versions.** New packages are blocked by default, since malware is usually detected within a day. (Although this can be disabled, configured, and bypassed).
- **Alerts you.** On macOS and Windows a popup names the blocked package and links its advisory.
- **Two modes:**
- **Machine-wide:** screens downloads from every app, across your entire machine.
- **Command prefix:** e.g., `firewall npm install left-pad`, or create an alias with `alias npm='firewall npm`.
## Configuration
Settings are in a `config.json`:
- Machine-wide: `/Library/Application Support/Firewall/` on macOS, `%ProgramData%\Strix Firewall\`
on Windows. Only an admin can edit it; restart from the menu bar or tray to apply.
- Command prefix: `~/.config/firewall/` on Linux, `~/Library/Application Support/com.usestrix.firewall/`
on macOS, `%APPDATA%\usestrix\firewall\config\` on Windows.
```json
{
"min_age_hours": 48,
"allow": { "npm": [{ "name": "left-pad", "version": "1.3.0" }] },
"registries": {
"npm": ["https://npm.example.com/"],
"pypi": ["https://pypi.example.com/"]
},
"telemetry": false
}
```
| Key | Default | |
|---|---|---|
| `min_age_hours` | `24` | How long new versions are held back. `0` turns this off. |
| `allow` | none | Packages exempt from the hold, by ecosystem (`npm`, `pypi`, `cargo`, `gem`, `go`). Omit `version` for every version. Known malware is still blocked. |
| `registries` | none | Mirrors, by the registry they copy (`npm`, `pypi`, `gem`, `go`, `cargo/index`). Both modes screen every mirror listed; command-prefix mode also downloads from the first one. |
| `fail_open` | `true` | Allow a package when it can't be checked. |
| `telemetry` | `true` | Log anonymized usage events to Strix. |
| `port` | `8080` | Local port for command-prefix mode. |
## License
Apache 2.0. See [LICENSE](LICENSE).
+80 -5
View File
@@ -711,6 +711,20 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "embed-resource"
version = "3.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f"
dependencies = [
"cc",
"memchr",
"rustc_version",
"toml",
"vswhom",
"winreg",
]
[[package]]
name = "encoding_rs"
version = "0.8.42"
@@ -3243,6 +3257,15 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_spanned"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
dependencies = [
"serde_core",
]
[[package]]
name = "sha1"
version = "0.11.0"
@@ -3375,6 +3398,7 @@ dependencies = [
"clap",
"core-foundation 0.10.1",
"directories",
"embed-resource",
"muda",
"percent-encoding",
"png",
@@ -3671,10 +3695,25 @@ dependencies = [
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
name = "toml"
version = "1.1.8+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
dependencies = [
"serde_core",
]
@@ -3693,13 +3732,19 @@ dependencies = [
[[package]]
name = "toml_parser"
version = "1.1.3+spec-1.1.0"
version = "1.1.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
[[package]]
name = "tower-layer"
version = "0.3.3"
@@ -3909,6 +3954,26 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vswhom"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
dependencies = [
"libc",
"vswhom-sys",
]
[[package]]
name = "vswhom-sys"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
dependencies = [
"cc",
"libc",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -4284,6 +4349,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.61.2",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
+5 -1
View File
@@ -129,10 +129,14 @@ schannel = "0.1.29"
tokio = { version = "1.53.1", features = ["net"] }
tracing-appender = "0.2.5"
tray-icon = { version = "0.26.1", default-features = false }
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_Environment", "Win32_System_JobObjects", "Win32_System_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_LibraryLoader", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_Controls", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
windows-registry = "0.6.1"
windows-service = "0.8.0"
# Only on Windows hosts, where the tray and service are built.
[target.'cfg(windows)'.build-dependencies]
embed-resource = "3.0.12"
[dev-dependencies]
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] }
+20
View File
@@ -0,0 +1,20 @@
//! The tray's task dialogs exist only in version 6 of the common controls, which takes a manifest.
//! Both machine-wide binaries also get the Strix icon.
fn main() {
println!("cargo:rerun-if-changed=src/bin/firewall-tray.manifest");
let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
if msvc && std::env::var_os("CARGO_FEATURE_MACHINE_WIDE").is_some() {
let manifest = concat!(env!("CARGO_MANIFEST_DIR"), "/src/bin/firewall-tray.manifest");
println!("cargo:rustc-link-arg-bin=firewall-tray=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg-bin=firewall-tray=/MANIFESTINPUT:{manifest}");
#[cfg(windows)]
embed_resource::compile_for(
"src/bin/firewall.rc",
["firewall-tray", "firewall-service"],
embed_resource::NONE,
)
// Fails the build, rather than ship without the icon.
.manifest_required()
.unwrap();
}
}
+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}"));
}
+1
View File
@@ -11,4 +11,5 @@ pub static BUN: Manager = Manager {
("npm_config_registry", "http://registry.npmjs.org/"),
],
args: &[],
lockfile: None,
};
+1
View File
@@ -17,4 +17,5 @@ pub static CARGO: Manager = Manager {
"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"],
flags: &["--clear-sources", "--source", "http://rubygems.org/"],
}],
lockfile: None,
};
+1
View File
@@ -11,4 +11,5 @@ pub static GO: Manager = Manager {
("GOFLAGS", "-mod=mod"),
],
args: &[],
lockfile: None,
};
+8
View File
@@ -36,6 +36,14 @@ pub struct Manager {
pub command: &'static str,
pub env: &'static [(&'static str, &'static str)],
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] =
+2 -1
View File
@@ -1,4 +1,4 @@
use super::{Manager, PROXY};
use super::{Lockfile, Manager, PROXY};
pub static NPM: Manager = Manager {
command: "npm",
@@ -8,4 +8,5 @@ pub static NPM: Manager = Manager {
("npm_config_registry", "http://registry.npmjs.org/"),
],
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"),
],
args: &[],
lockfile: None,
};
+1 -1
View File
@@ -12,5 +12,5 @@ pub static PIP: Manager = recipe("pip");
pub static PIP3: Manager = recipe("pip3");
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"),
],
args: &[],
lockfile: None,
};
+1
View File
@@ -9,4 +9,5 @@ pub static PNPM: Manager = Manager {
("pnpm_config_registry", "http://registry.npmjs.org/"),
],
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 {
command: "uv",
env: &[("HTTP_PROXY", PROXY), ("http_proxy", PROXY), ("UV_DEFAULT_INDEX", PYPI_SIMPLE)],
args: &[],
lockfile: Some(Lockfile::Warn("uv.lock")),
};
+35 -1
View File
@@ -6,9 +6,11 @@ mod windows;
pub use forward::spawn;
pub use managers::*;
use crate::policies::registries::BUILT_IN;
use std::io::{Error, ErrorKind};
use std::path::{Path, PathBuf};
use std::process::Command;
use tracing::{info, warn};
pub struct Invocation {
pub program: String,
@@ -50,10 +52,42 @@ pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::R
let status = command.status();
#[cfg(windows)]
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.
fn resolve(program: &str) -> std::io::Result<PathBuf> {
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}"),
}
}
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0"
processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
</assembly>
+2
View File
@@ -0,0 +1,2 @@
// The icon Explorer, UAC and the tray's popups show.
1 ICON "strix.ico"
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+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)
+6 -20
View File
@@ -2,6 +2,7 @@
//! LocalSystem service screens it as the macOS extension does.
mod ca;
mod pipe;
mod service;
mod tpm;
mod tray;
@@ -47,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"),
@@ -70,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
@@ -90,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");
}
@@ -119,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.
+99
View File
@@ -0,0 +1,99 @@
//! How the tray asks the service what it blocked, and allows a new package, 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::*;
use windows::Win32::System::Pipes::{GetNamedPipeClientProcessId, ImpersonateNamedPipeClient};
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 ") && !client_is_admin(client.get_ref())? {
"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())
}
/// Elevated, as only an elevated token has the Administrators group enabled.
fn client_is_admin(client: &NamedPipeServer) -> windows::core::Result<bool> {
let mut sid = [0u32; SECURITY_MAX_SID_SIZE as usize / 4];
let admins = PSID(sid.as_mut_ptr().cast());
let mut size = SECURITY_MAX_SID_SIZE;
unsafe { CreateWellKnownSid(WinBuiltinAdministratorsSid, None, Some(admins), &mut size)? };
unsafe { ImpersonateNamedPipeClient(HANDLE(client.as_raw_handle()))? };
let mut member = false.into();
let checked = unsafe { CheckTokenMembership(None, admins, &mut member) };
// Carrying on as the client would be worse than stopping.
if unsafe { RevertToSelf() }.is_err() {
std::process::abort();
}
checked?;
Ok(member.as_bool())
}
+14 -13
View File
@@ -1,6 +1,8 @@
use super::ca::{load_or_create_ca, secure_data_dir};
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::http::{reply, text};
use crate::{mitm, telemetry, Firewall};
@@ -77,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,
@@ -107,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)
}
};
@@ -153,15 +151,18 @@ 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));
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());
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));
}
+322 -18
View File
@@ -1,27 +1,53 @@
//! The notification-area icon, started at sign-in as the menu bar app is on macOS.
use super::pipe::PIPE;
use super::*;
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
use std::io::Cursor;
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};
use windows::core::{w, HRESULT, PCWSTR};
use windows::Win32::Foundation::*;
use windows::Win32::System::Threading::CreateMutexW;
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::System::Pipes::GetNamedPipeServerProcessId;
use windows::Win32::System::Threading::*;
use windows::Win32::UI::Controls::*;
use windows::Win32::UI::Shell::*;
use windows::Win32::UI::WindowsAndMessaging::*;
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};
const MENU_PAUSE: &str = "pause";
const MENU_RESTART: &str = "restart";
const MENU_LOG: &str = "log";
const MENU_QUIT: &str = "quit";
/// Also the service's requests, and `allow` the elevated `firewall-tray` command.
const ALLOW: &str = "allow";
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;
const BLOCKED_KEPT: usize = 10;
/// Runs until Quit, once per session.
pub fn run() -> Result<(), BoxError> {
let args: Vec<String> = std::env::args().skip(1).collect();
if let [verb, id, label @ ..] = &args[..] {
if verb == ALLOW {
// Run by `run_elevated`, so the service can tell an admin asked.
let trusted = ask_service(&format!("{ALLOW} {id}"))
.and_then(|reply| answered(&format!("Trusted {}", label.join(" ")), &reply));
if let Err(error) = trusted {
show_error(error);
std::process::exit(1);
}
return Ok(());
}
}
// Held until we exit.
let _mutex = unsafe { CreateMutexW(None, false, w!(r"Local\StrixFirewallTray"))? };
if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS {
@@ -29,6 +55,7 @@ pub fn run() -> Result<(), BoxError> {
}
let status = MenuItem::new("", false, None);
let pause = MenuItem::with_id(MENU_PAUSE, "", false, None);
let blocked_separator = PredefinedMenuItem::separator();
let menu = Menu::with_items(&[
&status,
&PredefinedMenuItem::separator(),
@@ -39,7 +66,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)).build()?;
let icon = TrayIconBuilder::new().with_menu(Box::new(menu.clone())).build()?;
let window = HWND(icon.window_handle());
let mut tray = Tray {
icon,
@@ -49,9 +76,14 @@ pub fn run() -> Result<(), BoxError> {
status,
pause,
state: None,
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) };
@@ -62,14 +94,11 @@ pub fn run() -> Result<(), BoxError> {
// Ours, not the icon window's own hover timer.
if message.message == WM_TIMER && message.hwnd.is_invalid() {
tray.update();
tray.collect_blocks();
}
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();
}
}
@@ -94,11 +123,47 @@ struct Tray {
pause: MenuItem,
/// As the menu last showed it.
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 {
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 light = light_taskbar();
if self.drawn != Some((light, screening)) {
@@ -117,7 +182,83 @@ impl Tray {
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 let Some(trust) = alert(&package) {
allow(package, trust, &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 trustable = package.trustable();
let once =
MenuItem::with_id(format!("{ALLOW_ONCE}:{i}"), "Allow Once", trustable, None);
let always =
MenuItem::with_id(format!("{ALLOW}:{i}"), "Trust this version", trustable, None);
let items: &[&dyn IsMenuItem] =
if package.malicious() { &[&reason] } else { &[&reason, &once, &always] };
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) {
if let Some((verb @ (ALLOW | ALLOW_ONCE), i)) = id.split_once(':') {
let trust = verb == ALLOW;
let package = i.parse().ok().and_then(|i: usize| self.blocked.get(i));
if let Some(package) = package.cloned() {
let allowed = self.allowed.0.clone();
std::thread::spawn(move || allow(package, trust, &allowed));
}
return;
}
let state = self.state;
let screening = state == Some(ServiceState::Running);
let action: fn() -> Result<(), BoxError> = match id {
@@ -143,6 +284,171 @@ impl Tray {
}
}
const ALLOW_ONCE_BUTTON: i32 = 100;
const TRUST_BUTTON: i32 = 101;
/// `firewall.rc`'s icon, embedded by `build.rs`.
const STRIX_ICON: PCWSTR = PCWSTR(1 as _);
/// `Some(true)` if the user chose to trust it, `Some(false)` to allow it once.
/// A task dialog, as a message box can't have both buttons.
fn alert(package: &BlockedPackage) -> Option<bool> {
let text = format!("{} {}.", package.label(), package.reason);
let (title, text, icon) = if package.malicious() {
let title = format!("Blocked malware: {}", package.label());
let text = text
+ "\n\nIt was not installed. Don't install it from another source. If you didn't expect this "
+ "package, a dependency may be compromised; tell your security team.";
(title, text, TD_ERROR_ICON)
} else {
("Held back a new package".to_owned(), text, STRIX_ICON)
};
let button = |id, text| TASKDIALOG_BUTTON { nButtonID: id, pszButtonText: text };
let allow = [
button(ALLOW_ONCE_BUTTON, w!("Allow Once")),
button(TRUST_BUTTON, w!("Trust this version")),
];
let buttons: &[_] = if package.trustable() { &allow } else { &[] };
let (title, text) = (HSTRING::from(title), HSTRING::from(linked(&text)));
let dialog = TASKDIALOGCONFIG {
cbSize: size_of::<TASKDIALOGCONFIG>() as u32,
dwFlags: TDF_ENABLE_HYPERLINKS,
// Where `STRIX_ICON` is loaded from; the `TD_` icons are Windows'.
hInstance: unsafe { GetModuleHandleW(None) }.ok()?.into(),
dwCommonButtons: TDCBF_CLOSE_BUTTON,
pszWindowTitle: w!("Strix Firewall"),
Anonymous1: TASKDIALOGCONFIG_0 { pszMainIcon: icon },
pszMainInstruction: PCWSTR(title.as_ptr()),
pszContent: PCWSTR(text.as_ptr()),
cButtons: buttons.len() as u32,
pButtons: buttons.as_ptr(),
pfCallback: Some(on_dialog),
..Default::default()
};
let mut chosen = 0;
unsafe { TaskDialogIndirect(&dialog, Some(&raw mut chosen), None, None) }.ok()?;
match chosen {
ALLOW_ONCE_BUTTON => Some(false),
TRUST_BUTTON => Some(true),
_ => None,
}
}
/// Asks for the foreground, as `MB_SETFOREGROUND` did; Windows may still leave it behind.
/// Opens the links `linked` made, in the browser.
unsafe extern "system" fn on_dialog(
window: HWND,
event: TASKDIALOG_NOTIFICATIONS,
_: WPARAM,
link: LPARAM,
_: isize,
) -> HRESULT {
if event == TDN_CREATED {
let _ = unsafe { SetForegroundWindow(window) };
} else if event == TDN_HYPERLINK_CLICKED {
let link = PCWSTR(link.0 as *const u16);
// Only web pages, whatever a block's reason contains.
let web = unsafe { link.to_string() }
.is_ok_and(|link| link.starts_with("https://") || link.starts_with("http://"));
if web {
let none = PCWSTR::null();
unsafe { ShellExecuteW(None, w!("open"), link, none, none, SW_SHOWNORMAL) };
}
}
S_OK
}
/// A task dialog's markup for the text, with each URL a link.
fn linked(text: &str) -> String {
let mut marked = String::new();
let mut rest = text;
while let Some(start) =
["https://", "http://"].iter().filter_map(|scheme| rest.find(scheme)).min()
{
let end = rest[start..].find(|c: char| c.is_whitespace() || c == ',' || c == ')');
let url = rest[start..end.map_or(rest.len(), |end| start + end)].trim_end_matches('.');
marked += &rest[..start];
marked += &format!("<a href=\"{url}\">{url}</a>");
rest = &rest[start + url.len()..];
}
marked + rest
}
/// Trusting writes the config for every user, so it goes through a UAC prompt.
/// The elevated process shows its own errors and late replies.
fn allow(package: BlockedPackage, trust: bool, allowed: &Sender<BlockedPackage>) {
let allowed_it = if trust {
run_elevated(&[ALLOW, &package.id.to_string(), &package.label()])
.map(|code| code == Some(0))
} else {
let title = format!("Allowed {} for the next minute", package.label());
ask_service(&format!("{ALLOW_ONCE} {}", package.id))
.and_then(|reply| answered(&title, &reply))
.map(|()| true)
};
match allowed_it {
Ok(true) => {
let _ = allowed.send(package);
}
Ok(false) => {}
Err(error) => show_error(error),
}
}
/// Tells the user, under `title`, when the install had already stopped waiting.
fn answered(title: &str, reply: &str) -> Result<(), BoxError> {
match reply {
"ok" => Ok(()),
"late" => {
let text = "The install had already stopped waiting for it. Run it again to finish.";
let (title, text) = (HSTRING::from(title), HSTRING::from(text));
unsafe { MessageBoxW(None, &text, &title, MB_ICONINFORMATION | MB_SETFOREGROUND) };
Ok(())
}
other => Err(other.into()),
}
}
/// This program as an admin, waiting for its exit code; `None` if the user said no.
fn run_elevated(args: &[&str]) -> Result<Option<u32>, BoxError> {
let exe = HSTRING::from(std::env::current_exe()?.as_path());
let parameters = HSTRING::from(args.join(" "));
let mut info = SHELLEXECUTEINFOW {
cbSize: size_of::<SHELLEXECUTEINFOW>() as u32,
fMask: SEE_MASK_NOCLOSEPROCESS,
lpVerb: w!("runas"),
lpFile: PCWSTR(exe.as_ptr()),
lpParameters: PCWSTR(parameters.as_ptr()),
nShow: SW_HIDE.0,
..Default::default()
};
if let Err(error) = unsafe { ShellExecuteExW(&mut info) } {
if error.code() == ERROR_CANCELLED.to_hresult() {
return Ok(None);
}
return Err(error.into());
}
let process = unsafe { Owned::new(info.hProcess) };
unsafe { WaitForSingleObject(*process, INFINITE) };
let mut code = 1;
unsafe { GetExitCodeProcess(*process, &mut code)? };
Ok(Some(code))
}
/// 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) {
let text = match error.source() {
Some(source) => format!("{error}: {source}"),
@@ -153,13 +459,11 @@ fn show_error(error: BoxError) {
}
/// `None` when the service is not installed.
fn service_state() -> Option<ServiceState> {
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()?;
Some(service.query_status().ok()?.current_state)
let service = manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS).ok()?;
service.query_status().ok()
}
fn light_taskbar() -> bool {
+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 {
+5 -1
View File
@@ -41,7 +41,11 @@ 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(", ")))
}
})
}
+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"]
+44 -8
View File
@@ -2,13 +2,13 @@ import AppKit
import Security
struct BlockedPackage: Codable, Hashable, Identifiable {
let id: UInt64
let eco: String
let name: String
let version: String?
let filter: String
let reason: String
var id: String { "\(eco)/\(name)@\(version ?? "")" }
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.
var malicious: Bool { filter != "age" }
@@ -16,25 +16,61 @@ struct BlockedPackage: Codable, Hashable, Identifiable {
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
enum BlockAlert {
/// Returns whether the user chose to trust the package.
static func show(_ package: BlockedPackage) -> Bool {
static func show(_ package: BlockedPackage) -> Allowance? {
let alert = NSAlert()
alert.addButton(withTitle: "OK")
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)."
if package.trustable { alert.addButton(withTitle: "Trust this version") }
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)
return alert.runModal() == .alertSecondButtonReturn
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()
}
/// 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
}
}
+11 -15
View File
@@ -203,26 +203,22 @@ final class FirewallModel: NSObject, ObservableObject {
if let url = URL(string: pane) { NSWorkspace.shared.open(url) }
}
/// Allowing a version writes it into the extension's config, which a restart then loads.
func trust(_ package: BlockedPackage) async {
/// Trusting changes the config for every user, so it takes an admin.
func allow(_ package: BlockedPackage, _ allowance: Allowance) async {
guard package.trustable else { return }
guard AdminAuthorization.confirm() else {
if allowance == .always, !AdminAuthorization.confirm() {
log("Trust cancelled for \(package.label)")
return
}
do {
var request = ["eco": package.eco, "name": package.name]
request["version"] = package.version
let json = String(decoding: try JSONSerialization.data(withJSONObject: request), as: UTF8.self)
let reply = try await providerMessage("allow \(json)")
guard reply == "ok" else { throw FirewallError.extensionRefused(reply) }
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("Trusted \(package.label) (\(package.eco))")
log("Allowed \(package.label) (\(package.eco)) \(allowance)")
if reply == "late" { BlockAlert.late(package, allowance) }
} catch {
log("Could not trust \(package.label): \(error.localizedDescription)")
return
log("Could not allow \(package.label): \(error.localizedDescription)")
}
await restart()
}
/// The extension cannot message the app, so the app asks it for blocks.
@@ -232,11 +228,11 @@ final class FirewallModel: NSObject, ObservableObject {
let packages = try? JSONDecoder().decode([BlockedPackage].self, from: Data(reply.utf8)) {
for package in packages where !blocked.contains(package) {
blocked.append(package)
if BlockAlert.show(package) { await trust(package) }
if let allowance = BlockAlert.show(package) { await allow(package, allowance) }
}
blocked = Array(blocked.suffix(10))
if blocked.count > 10 { blocked.removeFirst(blocked.count - 10) }
}
try? await Task.sleep(nanoseconds: 2_000_000_000)
try? await Task.sleep(nanoseconds: 500_000_000)
}
}
+5 -1
View File
@@ -29,8 +29,12 @@ struct MenuBarView: View {
if !package.malicious {
Menu("Held back \(package.label) (\(package.eco))") {
Text(package.reason)
Button("Allow Once") {
Task { await model.allow(package, .once) }
}
.disabled(model.busy || !package.trustable)
Button("Trust this version") {
Task { await model.trust(package) }
Task { await model.allow(package, .always) }
}
.disabled(model.busy || !package.trustable)
}
+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