Skip to content
93 changes: 74 additions & 19 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,37 +1,83 @@
# Firebreak

On-demand Windows Firewall rule-usage auditor: correlates WFP audit events
(Security log 5156/5157) with the live WFP filter table and
`Get-NetFirewallRule` to find unused and over-broad rules. No service, no
driver — a native Rust GUI (`eframe`/`egui`) that runs, reports, and exits.
See `docs/ARCHITECTURE.md` for the design rationale (why the Security log,
not `pfirewall.log` or a packet-capture driver) and `docs/internals.md` for
the ingest pipeline.
On-demand firewall rule-usage auditor for Windows and Linux: finds unused
and over-broad rules. No service, no driver — it runs, reports, and exits.

- **Windows** correlates WFP audit events (Security log 5156/5157) with the
live WFP filter table and `Get-NetFirewallRule`. Native Rust GUI
(`eframe`/`egui`).
- **Linux** reads per-rule packet counters instead, because there is no
Linux event carrying rule identity and process identity together. See
`docs/spike-linux-port.md` for the full evidence survey behind that call.

See `docs/ARCHITECTURE.md` for the Windows design rationale (why the
Security log, not `pfirewall.log` or a packet-capture driver) and
`docs/internals.md` for the ingest pipeline.

## Stack

- Rust, edition 2021, GUI via `eframe`/`egui` 0.29.
- **Windows-only in practice.** Almost every real code path is
`#[cfg(windows)]` — WFP, the Security Event Log, audit policy, elevation.
The non-Windows fallback paths exist only so the pure logic (parsing,
scoping, aggregation) can be unit-tested from Linux.
- **Two real targets.** The Windows evidence layer (WFP, Security Event Log,
audit policy, PowerShell rule enumeration) is `#[cfg(windows)]`; the Linux
one lives under `src/linux/` and is `#[cfg(target_os = "linux")]`. Logic
that is portable but only *called* from one platform is
`#[cfg(any(windows, test))]` so it stays unit-tested from either host.
- Cross-compiled from Linux to `x86_64-pc-windows-gnu` (needs
`mingw-w64` + `rustup target add x86_64-pc-windows-gnu`); native build on
Windows works too. `cargo test` runs cross-platform — the tests exercise
the `#[cfg(not(windows))]`-safe logic, not the WinAPI calls themselves.
- `rusqlite` (bundled) for the local `%ProgramData%\firebreak\firebreak.db`.
Windows works too.
- `rusqlite` (bundled) for the local store —
`%ProgramData%\firebreak\firebreak.db` or `/var/lib/firebreak/firebreak.db`,
both created private and refused if another principal owns them
(`src/secure_dir.rs`).
- `minisign-verify` for self-update signature checking (fails closed —
see `signing/README.md` and `src/update.rs`).

## Linux backends (`src/linux/`)

| backend | rule identity | evidence | instrument? |
|---|---|---|---|
| ufw | `### tuple ###` in `user.rules` | iptables counters, always on | no |
| firewalld | zone + service/port | Firebreak's own shadow nft table | **yes** |
| nftables | family/table/chain + expression digest | the rule's *own* counter | partly |

Detection order is ufw → firewalld → raw nftables, and the order matters:
the first two *are* nftables underneath, so checking raw nftables first
would audit their generated rules instead of the vocabulary the user wrote.

Four things to know before touching them:

- **A kernel counter is a gauge, not an event stream.** It resets on reboot,
reload and `iptables -Z`. `linux/counters.rs` banks the old lifetime
instead of re-adding raw readings. Never add a raw counter to a total.
- **firewalld's nft table is `flags owner`** — the kernel refuses to let any
process add a counter to it, sudo included. Hence the shadow table, at
input priority 300 so a hit means "firewalld allowed this".
- **Unmeasurable is not unused.** Rich rules, ipsets, protocol-only entries,
unparseable tuples and counter-less nft rules are reported in their own
section. Folding them into the zero-hit list would invite deleting a
load-bearing rule.
- **Raw nftables is the only backend that edits the user's rules.** Adding a
counter takes the kernel's own JSON expression and inserts
`{"counter": null}` before the verdict — never re-derived from text — after
a full ruleset backup, and every touched rule is re-read and verified to be
its original self plus exactly one counter, or the whole thing is rolled
back. Keep all three of those. Identity is an expression digest, not the
handle, which is renumbered on every reload.

Rule scope is a backend-supplied vocabulary (`model::ScopeVocabulary`), not
Windows' Domain/Private/Public: firewalld zones are arbitrary and ufw has no
scopes at all.

## Gate

`./scripts/gate.sh` — fmt, clippy, test. Must be green before every commit.

**Clippy lints the `x86_64-pc-windows-gnu` target, not native Linux.**
Because of the `#[cfg(windows)]` gating above, linting the native target
reports huge swaths of real, used code as dead — it's noise, not signal.
The gate detects the host and lints natively only when actually run on
Windows.
**On a Linux host it lints both targets**: `x86_64-pc-windows-gnu` (the
Windows code, only checkable by cross-compiling) and native (the Linux
backends). The native lint used to be skipped because Windows-only code
compiled on Linux read as dead; that is now stated as `#[cfg(windows)]`
rather than suppressed, so the native lint is signal — and it is the only
thing that lints `src/linux/` at all. Don't drop it.

CI (`.github/workflows/ci.yml`) runs the same gate on push/PR to `main`,
installing `mingw-w64` first.
Expand Down Expand Up @@ -65,6 +111,15 @@ installing `mingw-w64` first.
every read in the evidence loop (Security log, audit policy, WFP filter
enum, the ACL-protected local DB) is admin-bound. Don't "fix" this
without reading that doc first; it's a considered verdict, not drift.
Linux needs root for the same reason: ufw's rule files, the iptables
counters and `/proc/<pid>/exe` for other users' processes. Without it
process attribution *silently shrinks* rather than failing, which is why
the Linux path refuses to run unprivileged instead of degrading.
- **On firewalld, collecting means writing to the kernel firewall.** The
Windows side never does this — it only reads. The shadow table is
therefore opt-in (`--enable-only`) and removable (`--restore-audit`), a
plain run never installs it, and the table carries no verdicts so it
cannot change any packet's fate. Keep all four of those properties.
- **Self-update is a supply-chain surface**: the in-app updater downloads
a release asset and its `.minisig` signature, verifying against a pinned
public key (`TRUSTED_PUBLIC_KEY` in `src/update.rs`) before installing.
Expand Down
17 changes: 12 additions & 5 deletions scripts/gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,19 @@ echo "== cargo clippy =="
if [[ "${OS:-}" == "Windows_NT" ]]; then
cargo clippy --all-targets -- -D warnings
else
# Firebreak is a Windows-only app (heavy #[cfg(windows)] use). Linting the
# native Linux target flags huge swaths of real code as dead, since none
# of it is reachable outside a Windows build. Lint the actual deployment
# target instead — requires the x86_64-pc-windows-gnu rustup target and
# a mingw-w64 gcc (`rustup target add x86_64-pc-windows-gnu`).
# Two real deployment targets, so lint both. The Windows target is the one
# the bulk of the code is written for and can only be checked by
# cross-compiling (needs the x86_64-pc-windows-gnu rustup target and a
# mingw-w64 gcc); the native target is the Linux build and its backends.
#
# Native linting used to be skipped because Windows-only code compiled on
# Linux read as dead. That is now expressed as #[cfg(windows)] instead, so
# the native lint is signal again — and it is the ONLY thing that lints the
# Linux backends at all. Do not drop it.
echo "-- windows target --"
cargo clippy --target x86_64-pc-windows-gnu -- -D warnings
echo "-- native (linux) target --"
cargo clippy --all-targets -- -D warnings
fi

echo "== cargo test =="
Expand Down
2 changes: 2 additions & 0 deletions src/audit_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ pub const FILTERING_PLATFORM_CONNECTION_GUID: &str = "{0CCE9226-69AE-11D9-BED3-5
#[cfg(windows)]
const SUBCATEGORY: GUID = GUID::from_u128(0x0CCE9226_69AE_11D9_BED3_505054503030);

#[cfg(windows)]
const POLICY_AUDIT_EVENT_SUCCESS: u32 = 0x1;
#[cfg(windows)]
const POLICY_AUDIT_EVENT_FAILURE: u32 = 0x2;
#[cfg(windows)]
const POLICY_AUDIT_EVENT_NONE: u32 = 0x4;
Expand Down
19 changes: 16 additions & 3 deletions src/collect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,24 @@
//! rules.json — Vec<RuleInfo>, exactly the shape enumerate_rules parses
//! events.evtx — Security log filtered to 5156/5157

use anyhow::{anyhow, bail, Context, Result};
#[cfg(any(windows, test))]
use anyhow::anyhow;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::io::{Read, Write};
#[cfg(any(windows, test))]
use std::io::Read;
use std::io::Write;
use std::path::{Path, PathBuf};

#[cfg(any(windows, test))]
use crate::model::RuleInfo;

pub const SCHEMA: u32 = 1;

/// The PowerShell fallback collector, kept embedded so the script a user
/// hands out always matches the parser in their build.
/// hands out always matches the parser in their build. Only the Windows UI
/// hands it out.
#[cfg(windows)]
pub const COLLECT_PS1: &str = include_str!("../assets/collect.ps1");

#[derive(Serialize, Deserialize)]
Expand All @@ -42,6 +49,10 @@ pub struct BundleContext {
pub iface_profiles: std::collections::HashMap<String, String>,
}

/// A bundle opened for review. Parsing one is portable and stays
/// unit-tested from any host; only *replaying* its events.evtx needs
/// EvtQuery, so nothing outside Windows calls this in a real run.
#[cfg(any(windows, test))]
pub struct Bundle {
pub manifest: Manifest,
pub rules: Vec<RuleInfo>,
Expand Down Expand Up @@ -130,6 +141,7 @@ pub fn collect(out_zip: &Path, progress: &dyn Fn(&str)) -> Result<()> {

/// Open a bundle: parse manifest/rules/context, extract events.evtx to a
/// temp file for the event API.
#[cfg(any(windows, test))]
pub fn read_bundle(zip_path: &Path) -> Result<Bundle> {
let file =
std::fs::File::open(zip_path).with_context(|| format!("opening {}", zip_path.display()))?;
Expand Down Expand Up @@ -173,6 +185,7 @@ pub fn read_bundle(zip_path: &Path) -> Result<Bundle> {
})
}

#[cfg(any(windows, test))]
fn read_entry(z: &mut zip::ZipArchive<std::fs::File>, name: &str) -> Result<String> {
let mut e = z
.by_name(name)
Expand Down
28 changes: 27 additions & 1 deletion src/elevation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,29 @@ pub fn is_elevated() -> bool {
}
}

#[cfg(not(windows))]
/// Linux: root, checked by effective UID. Every read in the evidence loop
/// needs it — ufw's rule files are root-only, iptables counters come from a
/// privileged netlink socket, and `/proc/<pid>/exe` only resolves for other
/// users' processes as root. Read from /proc rather than linking libc for
/// one call; the effective UID is the second field of the `Uid:` line.
#[cfg(target_os = "linux")]
pub fn is_elevated() -> bool {
effective_uid() == Some(0)
}

/// This process's effective UID. `None` if /proc is unreadable, which
/// callers must treat as "unknown", never as "root".
#[cfg(unix)]
pub fn effective_uid() -> Option<u32> {
std::fs::read_to_string("/proc/self/status")
.ok()?
.lines()
.find_map(|l| l.strip_prefix("Uid:"))
.and_then(|rest| rest.split_whitespace().nth(1))
.and_then(|euid| euid.parse().ok())
}

#[cfg(not(any(windows, target_os = "linux")))]
pub fn is_elevated() -> bool {
false
}
Expand Down Expand Up @@ -79,6 +101,10 @@ pub fn relaunch_elevated() -> bool {
}
}

/// No Linux equivalent of the UAC prompt: a GUI process cannot ask the
/// kernel for privilege mid-run, and re-execing under pkexec/sudo from
/// inside the app would be a worse trust story than telling the user to
/// start it as root.
#[cfg(not(windows))]
pub fn relaunch_elevated() -> bool {
false
Expand Down
12 changes: 4 additions & 8 deletions src/event_query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ use anyhow::Result;
use crate::model::EventRecord;

/// XPath filter for 5156/5157, resuming strictly after `since_record_id`.
/// Only the Windows binary queries the Security channel, but the cursor
/// logic is pure and stays unit-tested from any host.
#[cfg(any(windows, test))]
pub fn build_query(since_record_id: Option<u64>) -> String {
match since_record_id {
Some(id) => format!(
Expand Down Expand Up @@ -172,14 +175,6 @@ pub fn query_events_from_file(
drain_query(&result_set, on_event)
}

#[cfg(not(windows))]
pub fn query_events_from_file(
_path: &std::path::Path,
_on_event: impl FnMut(EventRecord),
) -> Result<SkippedCount> {
bail!("event log query is only available on Windows")
}

/// Pull every matched event from an open result set, delivering the parsed
/// ones to `on_event` and counting the ones that couldn't be parsed.
#[cfg(windows)]
Expand Down Expand Up @@ -304,6 +299,7 @@ fn decode_direction(raw: &str) -> String {
}

/// Extract just the EventRecordID from any rendered event XML.
#[cfg(any(windows, test))]
pub fn parse_record_id(xml: &str) -> Option<u64> {
let start = xml.find("<EventRecordID>")? + "<EventRecordID>".len();
let end = xml[start..].find("</EventRecordID>")? + start;
Expand Down
1 change: 1 addition & 0 deletions src/filter_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ pub fn enumerate_filters() -> Result<Vec<FilterInfo>> {

/// Decode a providerData blob as UTF-16LE text (lossy, control chars
/// stripped) plus a hex dump capped for storage.
#[cfg(windows)]
fn decode_provider_data(data: &[u8]) -> (String, String) {
let utf16: Vec<u16> = data
.chunks_exact(2)
Expand Down
Loading
Loading