Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,15 @@ Four things to know before touching them:
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
- **"Disable" does not mean the same thing on every backend.** Windows sets
a flag the rule survives; firewalld removes a service/port from a zone and
can add it back; **ufw and nftables have no off switch at all, so disabling
deletes the rule.** `linux::apply::Reversibility` carries that to the
confirm dialog — a dialog saying "disable" over a deletion is how someone
loses a rule they meant to keep. Apply always writes a full config backup
first and re-reads afterwards to confirm the rule actually went.
- **Raw nftables is the only backend that edits the user's rules** for
*collection*. 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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "firebreak"
version = "0.7.0"
version = "0.7.12"
edition = "2021"
description = "On-demand Windows Firewall rule-usage auditor: correlates WFP audit events (5156/5157) with firewall rules to find unused and over-broad rules"
license-file = "LICENSE"
Expand Down
4 changes: 3 additions & 1 deletion assets/collect.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ $ports = @{}; Get-NetFirewallPortFilter -All | ForEach-Object {
}
$svcs = @{}; Get-NetFirewallServiceFilter -All | ForEach-Object { $svcs[$_.InstanceID] = [string]$_.Service }
$addrs = @{}; Get-NetFirewallAddressFilter -All | ForEach-Object { $addrs[$_.InstanceID] = (@($_.RemoteAddress) -join ',') }
$rules = Get-NetFirewallRule | ForEach-Object {
$rules = Get-NetFirewallRule -PolicyStore ActiveStore -TracePolicyStore | ForEach-Object {
$p = $ports[$_.InstanceID]
[pscustomobject]@{
Name = $_.Name
Expand All @@ -52,6 +52,8 @@ $rules = Get-NetFirewallRule | ForEach-Object {
RemotePort = $p.RemotePort
Service = $svcs[$_.InstanceID]
RemoteAddress = $addrs[$_.InstanceID]
PolicyStoreSource = [string]$_.PolicyStoreSource
PolicyStoreSourceType = [string]$_.PolicyStoreSourceType
}
}
ConvertTo-Json -InputObject @($rules) -Compress -Depth 3 | Set-Content -Encoding UTF8 (Join-Path $work "rules.json")
Expand Down
12 changes: 0 additions & 12 deletions build.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,4 @@
fn main() {
// build number = git commit count (monotonic, unique per commit).
let build = std::process::Command::new("git")
.args(["rev-list", "--count", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "0".to_string());
println!("cargo:rustc-env=FIREBREAK_BUILD={build}");
println!("cargo:rerun-if-changed=.git/HEAD");

if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let manifest = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
Expand Down
96 changes: 96 additions & 0 deletions src/baseline_checks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,19 @@ const CHECKS: &[Check] = &[

pub fn flags_for(rule: &RuleInfo) -> Vec<BaselineFlag> {
let mut out = Vec::new();

// A rule applied by Group Policy or another management system is not
// this machine's to change: switching it off here lasts until the next
// policy refresh puts it back. Saying so stops someone "fixing" the same
// rule every week and wondering why it returns.
if rule.is_managed() {
out.push(BaselineFlag {
title: "Managed centrally",
advice: "This rule comes from Group Policy or device management, not from this \
machine. Disabling it here is undone at the next policy refresh — change \
it where it is defined.",
});
}
let name = rule.display_name.to_lowercase();
let group = rule.group.as_deref().unwrap_or("").to_lowercase();
let inbound = rule.direction.eq_ignore_ascii_case("inbound");
Expand Down Expand Up @@ -133,14 +146,95 @@ pub fn flags_for(rule: &RuleInfo) -> Vec<BaselineFlag> {
title: "Broad inbound allow",
advice: "Inbound allow with no program and no port restriction — vet scope (RemoteAddress, profile) or tighten.",
});
} else if inbound && rule.is_enabled() && rule.action.eq_ignore_ascii_case("allow") {
// A rule *with* a port restriction can still be enormous. Fedora
// Workstation ships 1025-65535/tcp open by default: 64,511 ports,
// which the "no port restriction" test above sails straight past
// while being the broadest rule on the host.
if let Some(spec) = rule.local_port.as_deref() {
let span = port_span(spec);
if span > WIDE_PORT_SPAN {
out.push(BaselineFlag {
title: "Very wide port range",
advice: "This inbound allow covers thousands of ports, so anything that binds \
one of them is reachable — check the Listening column for what is \
actually behind it, and narrow the range if you can.",
});
}
}
}
out
}

/// More ports than any single service needs. Chosen to sit just above the
/// privileged range so "all high ports" trips it and a legitimate multi-port
/// service does not.
const WIDE_PORT_SPAN: u32 = 1024;

/// Total number of ports a rule's port spec admits.
fn port_span(spec: &str) -> u32 {
crate::listeners::parse_port_ranges(spec)
.iter()
.map(|(a, b)| b.saturating_sub(*a).saturating_add(1))
.sum()
}

#[cfg(test)]
mod tests {
use super::*;

fn wide_rule(port: &str) -> RuleInfo {
RuleInfo {
name: "r".into(),
display_name: "r".into(),
description: None,
enabled: "True".into(),
direction: "Inbound".into(),
action: "Allow".into(),
profile: "Any".into(),
group: None,
program: None,
protocol: Some("tcp".into()),
local_port: Some(port.into()),
remote_port: None,
service: None,
remote_address: None,
policy_source: None,
policy_source_type: None,
}
}

#[test]
fn a_huge_port_range_is_flagged_even_though_it_is_a_restriction() {
// Fedora Workstation's default. The "no port restriction" test does
// not fire here, so without this the broadest rule on the host is
// the one rule nothing flags.
let flags = flags_for(&wide_rule("1025-65535"));
assert!(
flags.iter().any(|f| f.title == "Very wide port range"),
"{flags:?}"
);
}

#[test]
fn an_ordinary_multi_port_service_is_not_flagged() {
for spec in ["80,443", "137,138,139", "8000-8080", "22"] {
let flags = flags_for(&wide_rule(spec));
assert!(
!flags.iter().any(|f| f.title == "Very wide port range"),
"{spec} should not be flagged: {flags:?}"
);
}
}

#[test]
fn port_spans_are_counted_across_ranges_and_lists() {
assert_eq!(port_span("22"), 1);
assert_eq!(port_span("80,443"), 2);
assert_eq!(port_span("1025-65535"), 64511);
assert_eq!(port_span("RPC"), 0);
}

fn rule(
display: &str,
dir: &str,
Expand All @@ -164,6 +258,8 @@ mod tests {
remote_port: None,
service: None,
remote_address: None,
policy_source: None,
policy_source_type: None,
}
}

Expand Down
Loading
Loading