KeiSeiKit-1.0/_primitives/_rust/firewall-diff/src/ufw.rs
Parfii-bot 521659bbfb feat(primitives): 2 Rust verification cubes
- ssh-check — parse sshd_config + drop-ins, merge last-wins, lint against
  hardened baseline (pw-auth=no, root=prohibit-password, maxauthtries≤3,
  AllowUsers whitelist, no CBC ciphers, ETM MACs, no ssh-rsa host key).
  4 modules: main (clap CLI) + parse + rules + check. Tests: 9 pass
  (hardened baseline, password-auth-yes-fails, cbc-cipher-fails,
  allow-users-not-in-whitelist-fails, missing-required-fails, etc.).

- firewall-diff — diff intent YAML against `ufw status numbered` output.
  Defensive-only (never runs ufw). Stdin or --status-file input. Parses
  (v6) families, normalises "Anywhere"→"any". Exit 2 on any missing/
  extra rule. 4 modules: main + intent + ufw + diff. Tests: 8 pass
  (load-minimal-intent, exact-match-clean, missing-rule-surfaced,
  extra-live-rule-surfaced, inactive-ufw-fails, integration).

Workspace: clap 4 + serde + serde_yaml + serde_json. release opt-level=z,
LTO, strip. Constructor Pattern: largest file check.rs 213 LOC (93 non-
test); every function under 30 LOC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:00:01 +08:00

173 lines
4.7 KiB
Rust

//! Parse `ufw status numbered` output.
//!
//! Typical shape (Ubuntu 22.04, ufw 0.36):
//!
//! Status: active
//!
//! To Action From
//! -- ------ ----
//! [ 1] 22/tcp LIMIT IN Anywhere
//! [ 2] 443/tcp ALLOW IN Anywhere
//! [ 3] 22/tcp (v6) LIMIT IN Anywhere (v6)
//!
//! We normalise "(v6)" to a separate family tag but key rules on port/proto
//! only (v6 and v4 rules with the same port/proto are treated as duplicates
//! of intent, which is usually the desired behaviour for parity checks).
use crate::intent::Action;
use serde::Serialize;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct LiveRule {
pub port: u16,
pub proto: String,
pub action: Action,
pub from: String,
pub family: Family,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub enum Family {
V4,
V6,
}
#[derive(Debug, Clone, Serialize)]
pub struct Live {
pub active: bool,
pub rules: Vec<LiveRule>,
}
pub fn parse(text: &str) -> Result<Live, String> {
let mut active = false;
let mut rules = Vec::new();
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() {
continue;
}
if let Some(rest) = line.strip_prefix("Status:") {
active = rest.trim().eq_ignore_ascii_case("active");
continue;
}
if line.starts_with("To") || line.starts_with("--") {
continue;
}
if let Some(r) = parse_rule(line) {
rules.push(r);
}
}
if text.trim().is_empty() {
return Err("could not detect an `ufw status` block (empty input)".into());
}
Ok(Live { active, rules })
}
/// Parse one numbered rule line. Returns None if the line is not a rule.
fn parse_rule(line: &str) -> Option<LiveRule> {
// Strip leading "[ N]" if present.
let body = if let Some(idx) = line.find(']') {
line[idx + 1..].trim()
} else {
line
};
// Columns: <to> <ACTION IN|OUT|FWD> <from>
// We split on 2+ whitespace runs which ufw pads with.
let parts: Vec<&str> = body.split(" ").filter(|s| !s.is_empty()).map(str::trim).collect();
if parts.len() < 3 {
return None;
}
let to = parts[0];
let action_raw = parts[1];
let from = parts[2];
let (to_clean, family) = if to.contains("(v6)") {
(to.replace("(v6)", "").trim().to_string(), Family::V6)
} else {
(to.to_string(), Family::V4)
};
let (port, proto) = split_port_proto(&to_clean)?;
let action = parse_action(action_raw)?;
Some(LiveRule {
port,
proto,
action,
from: from.replace("(v6)", "").trim().to_string(),
family,
})
}
fn split_port_proto(tok: &str) -> Option<(u16, String)> {
// "22/tcp" | "53" | "443/udp"
if let Some((port_s, proto_s)) = tok.split_once('/') {
Some((port_s.parse().ok()?, proto_s.to_ascii_lowercase()))
} else {
Some((tok.parse().ok()?, "tcp".into()))
}
}
fn parse_action(raw: &str) -> Option<Action> {
let up = raw.to_ascii_uppercase();
if up.starts_with("ALLOW") {
Some(Action::Allow)
} else if up.starts_with("DENY") {
Some(Action::Deny)
} else if up.starts_with("LIMIT") {
Some(Action::Limit)
} else if up.starts_with("REJECT") {
Some(Action::Reject)
} else {
None
}
}
impl LiveRule {
pub fn key(&self) -> String {
let from = if self.from.eq_ignore_ascii_case("Anywhere") {
"any"
} else {
&self.from
};
format!(
"{}/{}::{}::{}",
self.port,
self.proto,
from.to_ascii_lowercase(),
format!("{:?}", self.action).to_ascii_lowercase()
)
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"
Status: active
To Action From
-- ------ ----
[ 1] 22/tcp LIMIT IN Anywhere
[ 2] 443/tcp ALLOW IN Anywhere
[ 3] 22/tcp (v6) LIMIT IN Anywhere (v6)
"#;
#[test]
fn parses_active_and_rules() {
let l = parse(SAMPLE).unwrap();
assert!(l.active);
assert_eq!(l.rules.len(), 3);
assert_eq!(l.rules[0].port, 22);
assert_eq!(l.rules[0].proto, "tcp");
assert_eq!(l.rules[0].action, Action::Limit);
assert_eq!(l.rules[2].family, Family::V6);
}
#[test]
fn inactive_status_rejects_only_if_no_rules() {
let l = parse("Status: inactive\n").unwrap();
assert!(!l.active);
assert!(l.rules.is_empty());
}
}