diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70cf73c6..23dce4d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,9 @@ jobs: with: python-version: "3.11" + - name: Firewall backend reporter fixtures + run: python tests/test_firewall_state.py + # Pinned, because this job holds the workflow token: an unpinned global # install lets a compromised release of any of these three run arbitrary # code on the trusted side of the build. Bump deliberately. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e6fc2b4..c2428831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ Releases before `0.2.5` predate the public launch; their notes live in the ## [Unreleased] +### Added + +- Add read-only nftables ruleset and firewall-backend observations. General + firewall queries preserve failed/unknown probes and do not equate inactive + ufw with an unfiltered host. Safety notes precede bounded diagnostic excerpts + so large rulesets retain valid JSON and the interpretation caveat within the + planner output cap; mutating nftables actions remain out of scope (#239). + ### Changed - Remove whole-binary shell and runuser sudo grants. Firewall, group, Snap, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f36c0e9c..8f27e2a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ table below. | **Ubuntu LTS support** | The current suite is 83 Ubuntu stories. Committed live-VM evidence covers 79 of those Ubuntu stories on each LTS release, with a committed replay twin that reproduces each run: 22.04, 24.04 and 26.04 all at 79/79. The four additional stories are not yet included in a committed live-VM run. `ubuntu-vm.sh` accepts `UBUNTU_RELEASE=jammy\|noble\|resolute`. Remaining: story coverage for the cross-family actions, and one Debian-only action still has no story: `GrubSetKargs`. | medium | | **Distro detection coverage** | Robust `/etc/os-release` parsing for every release we claim to support. Pure-function tests against real fixture files, no integration mocks. The existing fixtures at the bottom of `crates/sysknife-core/src/distro.rs` show the shape. | easy | | **Action catalogue gaps** | Add a typed action (for example `EnableFirewallZone`). Small and isolated, and every PR carries the policy entry, the risk level and the tests. | easy | -| **E2E story coverage** | Real prompts, real LLM, real daemon. The suite is 137 stories: 54 atomic + 83 Ubuntu. What is left is the cross-family middle: of the action names available on both families, 59 are still untouched by any story, plus 10 Fedora-only and 1 Ubuntu-only ones. See #233 for the clustered map. | medium | +| **E2E story coverage** | Real prompts, real LLM, real daemon. The suite is 137 stories: 54 atomic + 83 Ubuntu. What is left is the cross-family middle: of the action names available on both families, 61 are still untouched by any story, plus 10 Fedora-only and 1 Ubuntu-only ones. See #233 for the clustered map. | medium | | **Fedora Atomic validation** | The action families exist and `DistroId::is_supported()` returns true for Atomic 41 and up. Nobody has run `tests/e2e/atomic-vm.sh` against a current release. Needs Fedora Atomic hardware or a VM host. | tedious | | **Demo recording on real hardware** | Replace the bundled demo GIF with a 30-second recording on real Ubuntu 26.04 with rollback visible. | easy | diff --git a/Makefile b/Makefile index a1870ecd..3e0fe96c 100644 --- a/Makefile +++ b/Makefile @@ -119,6 +119,7 @@ daemon-install: daemon-install-preflight build # from the daemon source and fails if one is missing. install -Dm 755 packaging/sysknife-apt-pin-edit $(HELPERS)/apt-pin-edit install -Dm 755 packaging/sysknife-action-steps $(HELPERS)/action-steps + install -Dm 755 packaging/sysknife-firewall-state $(HELPERS)/firewall-state install -Dm 755 packaging/sysknife-audit-edit $(HELPERS)/audit-edit install -Dm 755 packaging/sysknife-fail2ban-jail-edit $(HELPERS)/fail2ban-jail-edit install -Dm 755 packaging/sysknife-grub-kargs-edit $(HELPERS)/grub-kargs-edit @@ -150,6 +151,7 @@ daemon-uninstall: rm -f $(TMPFILES)/sysknife.conf rm -f $(HELPERS)/apt-pin-edit rm -f $(HELPERS)/action-steps + rm -f $(HELPERS)/firewall-state rm -f $(HELPERS)/audit-edit rm -f $(HELPERS)/fail2ban-jail-edit rm -f $(HELPERS)/grub-kargs-edit diff --git a/README.md b/README.md index 5355c6dd..c442fcbf 100644 --- a/README.md +++ b/README.md @@ -303,7 +303,7 @@ milestone. | Component | State | |---|---| | `sysknife-brain` — LLM planner, tool loop, safety fence | ✅ | -| `sysknife-daemon` — 190 typed actions, auth, preview, transactions | ✅ | +| `sysknife-daemon` — 192 typed actions, auth, preview, transactions | ✅ | | Live IPC + streaming + atomic-host rollback (rpm-ostree) | ✅ | | Terminal approval gate — one-time, TTL-bounded receipts | ✅ | | MCP server (Claude Code / Cursor / any MCP client) | ✅ | @@ -314,7 +314,7 @@ milestone. | **Every Ubuntu LTS validated** — 22.04, 24.04 and 26.04 all at 79/79, each with a replay twin that reproduces it | ✅ | | Telegram approval interface | 📋 roadmap | -**1,860 Rust tests and 72 frontend tests** form the current deterministic +**1,861 Rust tests and 72 frontend tests** form the current deterministic release baseline. ## Configure your LLM diff --git a/apps/sysknife-cli/src/mcp_server.rs b/apps/sysknife-cli/src/mcp_server.rs index c139939e..4bed33d2 100644 --- a/apps/sysknife-cli/src/mcp_server.rs +++ b/apps/sysknife-cli/src/mcp_server.rs @@ -444,6 +444,8 @@ const MCP_READ_ONLY_ACTIONS: &[&str] = &[ "GetFirewallState", "GetNetworkStatus", "GetListeningPorts", + "GetNftablesRuleset", + "GetFirewallBackendState", "ResolvectlStatus", "GetDateTime", "ListUsers", @@ -1825,8 +1827,8 @@ mod tests { classified, observer_actions, "every Observer-callable action must be explicitly classified as read-only or mutating" ); - assert_eq!(observer_actions.len(), 63); - assert_eq!(read_only.len(), 62); + assert_eq!(observer_actions.len(), 65); + assert_eq!(read_only.len(), 64); assert_eq!(mutating, BTreeSet::from(["AptUpdate"])); } diff --git a/crates/sysknife-brain/src/planning_tools/propose_plan.rs b/crates/sysknife-brain/src/planning_tools/propose_plan.rs index d51521e9..3bdc9deb 100644 --- a/crates/sysknife-brain/src/planning_tools/propose_plan.rs +++ b/crates/sysknife-brain/src/planning_tools/propose_plan.rs @@ -145,6 +145,10 @@ pub const KNOWN_ACTIONS: &[(&str, &str)] = &[ this is runtime status, NOT the saved configuration; on Ubuntu the saved config is NetplanGetConfig"), ("GetListeningPorts", "show listening TCP/UDP sockets and the process bound to each (ss -tulpn) — no params; read-only; use for \"what is listening on port X?\""), + ("GetNftablesRuleset", + "read the current nftables ruleset — no params; read-only; rules do not by themselves prove that traffic is blocked"), + ("GetFirewallBackendState", + "inspect nftables, ufw and firewalld observations — no params; read-only; use for general firewall status; unavailable or inactive frontends do not prove the host is unfiltered"), ("ConfigureWifi", "connect to a Wi-Fi network — params: ssid*, password (optional for open networks)"), ("SetDnsServers", diff --git a/crates/sysknife-brain/src/planning_tools/query_tools.rs b/crates/sysknife-brain/src/planning_tools/query_tools.rs index 141d7bf2..3adc4834 100644 --- a/crates/sysknife-brain/src/planning_tools/query_tools.rs +++ b/crates/sysknife-brain/src/planning_tools/query_tools.rs @@ -21,7 +21,7 @@ pub fn query_tools() -> Vec { }, ToolDefinition { name: "query_firewall".into(), - description: "Show current firewall rules and allowed services.".into(), + description: "Inspect nftables, ufw and firewalld observations; preserve unknown status when probes fail. An inactive frontend does not prove the host is unfiltered.".into(), input_schema: empty_schema.clone(), }, ToolDefinition { @@ -232,7 +232,7 @@ pub fn query_tool_to_action( match tool_name { "query_ufw_rules" => Ok(Some(("UfwStatus", serde_json::json!({"numbered": true})))), "query_services" => Ok(Some(("ListServices", serde_json::json!({})))), - "query_firewall" => Ok(Some(("GetFirewallState", serde_json::json!({})))), + "query_firewall" => Ok(Some(("GetFirewallBackendState", serde_json::json!({})))), "query_deployments" => Ok(Some(("ListDeployments", serde_json::json!({})))), "query_packages" => Ok(Some(("GetLayeredPackages", serde_json::json!({})))), "query_containers" => Ok(Some(("ListContainers", serde_json::json!({})))), @@ -314,7 +314,7 @@ mod tests { ); assert_eq!( query_tool_to_action("query_firewall", &empty), - Ok(Some(("GetFirewallState", serde_json::json!({})))) + Ok(Some(("GetFirewallBackendState", serde_json::json!({})))) ); assert_eq!( query_tool_to_action("query_deployments", &empty), diff --git a/crates/sysknife-daemon/src/actions/network.rs b/crates/sysknife-daemon/src/actions/network.rs index ae36b169..89142d4f 100644 --- a/crates/sysknife-daemon/src/actions/network.rs +++ b/crates/sysknife-daemon/src/actions/network.rs @@ -9,6 +9,8 @@ pub fn specs() -> Vec { get_firewall_state(), get_network_status(), get_listening_ports(), + get_nftables_ruleset(), + get_firewall_backend_state(), ] } @@ -105,6 +107,28 @@ pub fn get_network_status() -> ActionSpec { } } +/// Inspect nftables without modifying rules or accepting caller-controlled argv. +pub fn get_nftables_ruleset() -> ActionSpec { + ActionSpec { + action_name: "GetNftablesRuleset", + mechanism: command_mechanism("sudo", ["nft", "list", "ruleset"]), + risk_level: RiskLevel::Low, + reboot_required: false, + rollback_available: false, + } +} + +/// Report nftables and frontend observations, preserving unknown probe results. +pub fn get_firewall_backend_state() -> ActionSpec { + ActionSpec { + action_name: "GetFirewallBackendState", + mechanism: command_mechanism("/usr/lib/sysknife/firewall-state", [] as [&str; 0]), + risk_level: RiskLevel::Low, + reboot_required: false, + rollback_available: false, + } +} + /// List listening TCP/UDP sockets and, where the daemon has permission, the /// owning process (`ss -tulpnH`). Read-only; answers "what is listening on port /// X?". Run without sudo (like `GetNetworkStatus`'s `ip`); the socket/port list @@ -120,3 +144,35 @@ pub fn get_listening_ports() -> ActionSpec { rollback_available: false, } } + +#[cfg(test)] +mod firewall_tests { + use super::*; + use crate::actions::ActionMechanism; + + #[test] + fn firewall_queries_have_fixed_read_only_mechanisms() { + for (spec, program, args) in [ + ( + get_nftables_ruleset(), + "sudo", + vec!["nft", "list", "ruleset"], + ), + ( + get_firewall_backend_state(), + "/usr/lib/sysknife/firewall-state", + vec![], + ), + ] { + assert_eq!(spec.risk_level, RiskLevel::Low); + assert!(!spec.reboot_required && !spec.rollback_available); + assert_eq!( + spec.mechanism, + ActionMechanism::Command { + program, + args: args.into_iter().map(String::from).collect(), + } + ); + } + } +} diff --git a/crates/sysknife-daemon/src/executor.rs b/crates/sysknife-daemon/src/executor.rs index 18b585b6..a8bcb160 100644 --- a/crates/sysknife-daemon/src/executor.rs +++ b/crates/sysknife-daemon/src/executor.rs @@ -1315,6 +1315,8 @@ pub fn build_action_spec(action_name: &str, params: &Value) -> Result Ok(network::get_firewall_state()), "GetNetworkStatus" => Ok(network::get_network_status()), "GetListeningPorts" => Ok(network::get_listening_ports()), + "GetNftablesRuleset" => Ok(network::get_nftables_ruleset()), + "GetFirewallBackendState" => Ok(network::get_firewall_backend_state()), "ConfigureWifi" => { let ssid = validated_safe_arg(require_str(params, "ssid")?, "ssid")?; // password is optional — open networks connect without one. diff --git a/crates/sysknife-daemon/src/preview.rs b/crates/sysknife-daemon/src/preview.rs index 6d1d7e85..e80770da 100644 --- a/crates/sysknife-daemon/src/preview.rs +++ b/crates/sysknife-daemon/src/preview.rs @@ -143,6 +143,8 @@ fn preview_profile(action_name: &str) -> PreviewProfile { | "GetMemoryInfo" | "GetNetworkStatus" | "GetListeningPorts" + | "GetNftablesRuleset" + | "GetFirewallBackendState" | "GetJournalLog" | "GetLvmReport" | "GetSysctl" diff --git a/crates/sysknife-daemon/tests/actions_batch2.rs b/crates/sysknife-daemon/tests/actions_batch2.rs index eabda4c1..a1c53b44 100644 --- a/crates/sysknife-daemon/tests/actions_batch2.rs +++ b/crates/sysknife-daemon/tests/actions_batch2.rs @@ -244,6 +244,8 @@ fn network_family_covers_wifi_dns_and_firewall() { "GetFirewallState", "GetNetworkStatus", "GetListeningPorts", + "GetNftablesRuleset", + "GetFirewallBackendState", ] ); } diff --git a/crates/sysknife-daemon/tests/helper_install_coverage.rs b/crates/sysknife-daemon/tests/helper_install_coverage.rs index e8af31e2..cf40d971 100644 --- a/crates/sysknife-daemon/tests/helper_install_coverage.rs +++ b/crates/sysknife-daemon/tests/helper_install_coverage.rs @@ -202,6 +202,27 @@ fn every_referenced_helper_has_a_sudoers_grant() { .expect("read sudoers"); for helper in referenced_helpers() { let expected = format!("/usr/lib/sysknife/{helper}"); + if helper == "firewall-state" { + assert!( + !sudoers + .lines() + .filter(|line| !line.trim_start().starts_with('#')) + .any(|line| line.contains(&expected)), + "{expected} must have NO sudoers grant: the reporter must not be root-callable" + ); + // This reporter runs as the daemon user; only its fixed probe + // commands have sudo grants. Do not grant the whole helper root. + use sysknife_daemon::actions::{all_specs, ActionMechanism}; + let specs = all_specs(); + let reporter = specs + .iter() + .find(|s| s.action_name == "GetFirewallBackendState") + .expect("backend reporter is catalogued"); + assert!(matches!(&reporter.mechanism, + ActionMechanism::Command { program, args } + if *program == expected && args.is_empty())); + continue; + } assert!( sudoers.contains(&expected), "sudoers must grant {expected}, otherwise the action prompts for a password \ diff --git a/crates/sysknife-types/src/lib.rs b/crates/sysknife-types/src/lib.rs index 5d43f2ff..8f2f7606 100644 --- a/crates/sysknife-types/src/lib.rs +++ b/crates/sysknife-types/src/lib.rs @@ -138,6 +138,8 @@ pub const KNOWN_ACTION_NAMES: &[&str] = &[ "GetFirewallState", "GetNetworkStatus", "GetListeningPorts", + "GetNftablesRuleset", + "GetFirewallBackendState", "ConfigureWifi", "SetDnsServers", "ConfigureFirewall", diff --git a/docs/action-reference.md b/docs/action-reference.md index 0fff6628..aa5a1a4a 100644 --- a/docs/action-reference.md +++ b/docs/action-reference.md @@ -182,6 +182,8 @@ Every row is derived from the live code: the command from each action's `ActionS | `GetFirewallState` | `firewall-cmd --list-all` | Low | All | – | – | show current firewalld zones, open services, and port rules — no params | | `GetNetworkStatus` | `ip -brief addr` | Low | All | – | – | show LIVE network state: interfaces, IP addresses, and connection state — no params; this is runtime status, NOT the saved configuration; on Ubuntu the saved config is NetplanGetConfig | | `GetListeningPorts` | `ss -tulpnH` | Low | All | – | – | show listening TCP/UDP sockets and the process bound to each (ss -tulpn) — no params; read-only; use for "what is listening on port X?" | +| `GetNftablesRuleset` | `sudo nft list ruleset` | Low | All | – | – | read the current nftables ruleset — no params; read-only; rules do not by themselves prove that traffic is blocked | +| `GetFirewallBackendState` | `/usr/lib/sysknife/firewall-state` | Low | All | – | – | inspect nftables, ufw and firewalld observations — no params; read-only; use for general firewall status; unavailable or inactive frontends do not prove the host is unfiltered | ## resolvectl @@ -396,4 +398,4 @@ Every row is derived from the live code: the command from each action's `ActionS --- -_189 actions have an `ActionSpec` and are tabled above. The full catalogue (`KNOWN_ACTION_NAMES`) also includes `ListJobHistory`, which the dispatcher handles before the executor, for **190** total._ +_191 actions have an `ActionSpec` and are tabled above. The full catalogue (`KNOWN_ACTION_NAMES`) also includes `ListJobHistory`, which the dispatcher handles before the executor, for **192** total._ diff --git a/docs/architecture.md b/docs/architecture.md index ddc266af..b50a9aa1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,7 +71,7 @@ component uses the same resolution order. Privileged. The only component that touches the system. Provides: -- 190 typed actions (rpm-ostree, systemd, firewall, users, containers, +- 192 typed actions (rpm-ostree, systemd, firewall, users, containers, flatpak, toolbox, SSH, kernel args, …) - Role-based authorization (`Observer` → `Dev` → `Admin`, plus `Boot`) - Policy enforcement: stale-approval detection, request hash validation diff --git a/docs/distro-support.md b/docs/distro-support.md index 7e04c3ad..4d61f5e4 100644 --- a/docs/distro-support.md +++ b/docs/distro-support.md @@ -91,7 +91,7 @@ family and the atomic story family are implemented and covered by the workspace suite. What is missing is a way to put the helpers somewhere the daemon's own grants already point. -The deterministic workspace baseline is 1,860 Rust tests plus 72 frontend +The deterministic workspace baseline is 1,861 Rust tests plus 72 frontend tests. Those tests verify action construction, policy, approval, storage, and UI behavior, but they do not replace a real distribution VM run. diff --git a/docs/firewall-observations.md b/docs/firewall-observations.md new file mode 100644 index 00000000..b085bc07 --- /dev/null +++ b/docs/firewall-observations.md @@ -0,0 +1,37 @@ +# Firewall observations + +Use `GetFirewallBackendState` for a general firewall-state question. It probes +nftables JSON, `ufw status verbose`, and `firewall-cmd --list-all`. It reports +observed frontends and hooked nftables rules, preserving the individual probe +output excerpts and failure status. `query_firewall` uses this action during planning. + +The helper computes summaries from complete probe output before bounding the +diagnostics. State, backend observations, nftables counts and the safety note +precede `probes`. Each stdout excerpt is limited to 1,024 JSON-encoded bytes, +each stderr excerpt to 512, including escaping and the explicit +`[truncated by firewall-state]` marker. This leaves the complete JSON response +below the planner's 8 KiB cap, including for non-ASCII or escape-heavy output. +Small outputs remain unchanged. Excerpts can still contain firewall topology +and are sent to the configured model; run the read-only commands locally for +complete output rather than relying on these diagnostic excerpts. + +`GetNftablesRuleset` runs the fixed read-only `sudo nft list ruleset` command. +The sudoers grants allow only that command and its JSON form; neither grant +allows changing the ruleset. The reporter helper itself runs without sudo. + +An inactive ufw frontend does not imply the machine has no firewall. Likewise, +an empty nftables ruleset or a failed probe is not proof that traffic is +unfiltered: legacy iptables, other namespaces and other mechanisms can exist. +The reporter returns `unknown` when it cannot identify an observed backend. +Even observed rules do not establish whether particular traffic is blocked. + +Frontends may coexist or use nftables underneath, so the output is a list of +observations rather than a mutually exclusive backend guess. Existing +`GetFirewallState` remains the firewalld-specific zone query, and `UfwStatus` +remains ufw-specific. Neither is a general host-firewall verdict. + +The first change for [#239](https://github.com/lacs-project/sysknife/issues/239) +does not add rule mutation or automatically refuse installed ufw tooling based +on a probe result. Mutating parity needs a separate design for tables, chains, +handles and rollback. Live Debian validation remains separate from fixture +tests and from distro eligibility. diff --git a/docs/introduction.md b/docs/introduction.md index 52de45e4..647b5fa1 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -144,7 +144,7 @@ flow. ## Status -190 typed actions · 1,860 Rust tests + 72 frontend tests · MIT +192 typed actions · 1,861 Rust tests + 72 frontend tests · MIT SysKnife is the reference implementation of the [LACS specification](https://github.com/lacs-project/specification) — a diff --git a/docs/typed-actions.md b/docs/typed-actions.md index d71ab95b..44084018 100644 --- a/docs/typed-actions.md +++ b/docs/typed-actions.md @@ -62,7 +62,7 @@ codebase. On the daemon side (`sysknife-daemon`), each action is backed by an | `reboot_required` | Whether the daemon should warn the caller before proceeding | | `rollback_available` | Whether a failure triggers automatic rollback | -As of this writing the catalogue defines **190 actions** across families such +As of this writing the catalogue defines **192 actions** across families such as Deployment, Services, Package Layering, Flatpak, Containers, Toolbox, Network, Identity, SSH Keys, Package Repositories, apt/snap/ufw/netplan/grub (Debian-family), and rpm-ostree/AppArmor/cloud-init/Pro (Fedora-family). Each diff --git a/packaging/sysknife-firewall-state b/packaging/sysknife-firewall-state new file mode 100755 index 00000000..9c245fef --- /dev/null +++ b/packaging/sysknife-firewall-state @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Read firewall observations without treating an inactive frontend as no firewall. + +Installed root-owned at /usr/lib/sysknife/firewall-state, executed unprivileged. +Only the fixed nft/ufw read commands use the daemon account's exact sudo grants. +""" +import json +import os +import subprocess +import sys + + +TRUNCATION_MARKER = "\n[truncated by firewall-state]" +# Budgets include JSON quotes/escaping, not just source characters. All six +# streams together leave room for the summary below the brain's 8 KiB cap. +STDOUT_JSON_BYTES = 1024 +STDERR_JSON_BYTES = 512 + + +def bounded_text(text, budget): + if len(json.dumps(text)) <= budget: + return text + remaining = budget - len(json.dumps(TRUNCATION_MARKER)) + prefix = [] + for char in text: + width = len(json.dumps(char)) - 2 + if width > remaining: + break + prefix.append(char) + remaining -= width + return "".join(prefix) + TRUNCATION_MARKER + + +def bounded_probe(observation): + return {**observation, + "stdout": bounded_text(observation["stdout"], STDOUT_JSON_BYTES), + "stderr": bounded_text(observation["stderr"], STDERR_JSON_BYTES)} + + +def probe(argv): + try: + result = subprocess.run(argv, capture_output=True, text=True, timeout=5, + env={**os.environ, "LC_ALL": "C"}) + return {"status": "ok" if result.returncode == 0 else "failed", + "stdout": result.stdout, "stderr": result.stderr, + "returncode": result.returncode} + except FileNotFoundError: + return {"status": "unavailable", "stdout": "", "stderr": "command unavailable"} + except subprocess.TimeoutExpired: + return {"status": "timeout", "stdout": "", "stderr": "probe exceeded 5 seconds"} + except OSError as error: + return {"status": "failed", "stdout": "", "stderr": str(error)} + + +def summarize(nft, ufw, firewalld): + backends = [] + if ufw["status"] == "ok" and "Status: active" in ufw["stdout"].splitlines(): + backends.append("ufw") + if firewalld["status"] == "ok" and firewalld["stdout"].strip(): + backends.append("firewalld") + nft_state = {"status": "unknown"} + if nft["status"] == "ok": + try: + entries = json.loads(nft["stdout"])["nftables"] + if not isinstance(entries, list) or not all(isinstance(e, dict) for e in entries): + raise ValueError("nftables must be a list of objects") + chains = [e["chain"] for e in entries if isinstance(e.get("chain"), dict)] + hooks = [c for c in chains if c.get("hook")] + rules = sum(isinstance(e.get("rule"), dict) for e in entries) + nft_state = {"status": "rules_present" if rules or hooks else "no_rules_observed", + "rule_count": rules, "base_chain_count": len(hooks)} + if hooks and (rules or any(c.get("policy") == "drop" for c in hooks)): + backends.append("nftables") + except (ValueError, KeyError, TypeError): + nft_state = {"status": "unknown", "reason": "invalid nft JSON output"} + return {"state": "observations_available" if backends else "unknown", + "backends_observed": backends, "nftables": nft_state, + "note": "Frontends can share nftables. Rules or an active frontend do not prove " + "that particular traffic is blocked. Empty/failed probes do not prove " + "the host is unfiltered; legacy iptables and other mechanisms may exist.", + # Parse complete evidence above; only diagnostic excerpts are capped. + # Keep interpretation before diagnostics even if another layer caps it. + "probes": {"nftables": bounded_probe(nft), "ufw": bounded_probe(ufw), + "firewalld": bounded_probe(firewalld)}} + + +def main(): + if len(sys.argv) != 1: + print("firewall-state accepts no arguments", file=sys.stderr) + return 2 + # Resolve nft through sudo's secure_path. If /usr/sbin is unavailable there, + # the failed probe deliberately produces unknown, never an unfiltered claim. + result = summarize(probe(["sudo", "-n", "nft", "-j", "list", "ruleset"]), + probe(["sudo", "-n", "ufw", "status", "verbose"]), + probe(["firewall-cmd", "--list-all"])) + print(json.dumps(result)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packaging/sysknife-sudoers b/packaging/sysknife-sudoers index dc1f27a7..0a521d86 100644 --- a/packaging/sysknife-sudoers +++ b/packaging/sysknife-sudoers @@ -68,6 +68,10 @@ sysknife ALL=(root) NOPASSWD: /usr/bin/localectl # writes to /etc/firewalld/ which requires root. sysknife ALL=(root) NOPASSWD: /usr/bin/resolvectl +# Fixed read-only nftables probes; the reporter itself has NO sudo grant. +sysknife ALL=(root) NOPASSWD: /usr/sbin/nft list ruleset +sysknife ALL=(root) NOPASSWD: /usr/sbin/nft -j list ruleset + # nmcli — ConfigureWifi (`nmcli device wifi connect [password ]`) on # Ubuntu Desktop, where NetworkManager owns the interfaces rather than netplan. # Narrowed to the wifi-connect subcommand: a bare nmcli grant would also permit diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index 01db6320..0ec09919 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -286,6 +286,7 @@ run_shell_tests() { run_hygiene_group() { printf '\n### hygiene\n' + run_step 'hygiene: firewall backend reporter fixtures' python3 "$repo_root/tests/test_firewall_state.py" run_step 'hygiene: check_repo_completeness.sh' bash "$repo_root/scripts/check_repo_completeness.sh" run_step 'hygiene: check_release_versions.sh' bash "$repo_root/scripts/check_release_versions.sh" run_step 'hygiene: npm test --prefix packages/setup' npm test --prefix "$repo_root/packages/setup" diff --git a/tests/evidence/workspace-tests.json b/tests/evidence/workspace-tests.json index 9dfbac5c..e5015611 100644 --- a/tests/evidence/workspace-tests.json +++ b/tests/evidence/workspace-tests.json @@ -4,6 +4,6 @@ "tests": "cargo nextest run --workspace --locked" }, "frontend_tests": 72, - "tests": 1860, + "tests": 1861, "version": 2 } diff --git a/tests/test_firewall_state.py b/tests/test_firewall_state.py new file mode 100644 index 00000000..272acdf6 --- /dev/null +++ b/tests/test_firewall_state.py @@ -0,0 +1,103 @@ +"""Fixture tests for the read-only firewall reporter; no host probes run.""" +import importlib.machinery +import importlib.util +import json +import re +from pathlib import Path +import unittest + +path = Path(__file__).resolve().parents[1] / "packaging/sysknife-firewall-state" +loader = importlib.machinery.SourceFileLoader("firewall_state", str(path)) +spec = importlib.util.spec_from_loader(loader.name, loader) +module = importlib.util.module_from_spec(spec) +loader.exec_module(module) + + +def probe(stdout="", status="ok"): + return {"status": status, "stdout": stdout, "stderr": ""} + + +def nft(entries): + return probe(json.dumps({"nftables": entries})) + + +class FirewallStateTests(unittest.TestCase): + def test_large_ruleset_keeps_summary_and_caveat_before_bounded_probes(self): + entries = [{"chain": {"hook": "forward", "policy": "drop"}}] + entries += [{"rule": {"comment": "forward rule " + "x" * 200}} for _ in range(120)] + original = nft(entries) + result = module.summarize(original, probe("Status: inactive"), probe(status="failed")) + payload = json.dumps(result) + self.assertLess(payload.index('"note"'), payload.index('"probes"')) + self.assertEqual(result["nftables"]["rule_count"], 120) + self.assertIn("nftables", result["backends_observed"]) + self.assertIn("[truncated by firewall-state]", result["probes"]["nftables"]["stdout"]) + self.assertEqual(json.loads(original["stdout"])["nftables"], entries) + self.assert_payload_survives_brain_cap(payload) + + def assert_payload_survives_brain_cap(self, payload): + # Read the shipped cap so a future reduction cannot silently invalidate + # the helper's wire budget. The entire JSON must fit, not just its note. + source = (path.parents[1] / "crates/sysknife-brain/src/sanitize.rs").read_text(encoding="utf-8") + match = re.search(r"pub const MAX_OUTPUT_BYTES: usize = (\d+) \* (\d+);", source) + self.assertIsNotNone(match) + cap = int(match[1]) * int(match[2]) + encoded = payload.encode("utf-8") + self.assertLessEqual(len(encoded), cap) + survived = json.loads(encoded[:cap]) + self.assertIn("Empty/failed probes do not prove", survived["note"]) + + def test_all_probe_streams_are_bounded_after_json_escaping(self): + for text in ['"\\\n\t' * 5000, "防火墙😀" * 5000]: + with self.subTest(text=text[:8]): + p = {"status": "failed", "stdout": text, "stderr": text, "returncode": 1} + result = module.summarize(p, p, p) + self.assertEqual(result["state"], "unknown") + for observation in result["probes"].values(): + self.assertEqual(observation["returncode"], 1) + for field in ("stdout", "stderr"): + self.assertIn("[truncated by firewall-state]", observation[field]) + self.assert_payload_survives_brain_cap(json.dumps(result)) + + def test_small_probe_streams_are_preserved_without_a_marker(self): + p = {"status": "failed", "stdout": "a\\b\n中文", "stderr": "permission denied", "returncode": 1} + result = module.summarize(p, p, p) + self.assertEqual(result["probes"], {"nftables": p, "ufw": p, "firewalld": p}) + + def test_nft_rules_are_visible_when_ufw_is_inactive(self): + result = module.summarize(nft([ + {"chain": {"hook": "input", "policy": "drop"}}, + {"rule": {"expr": [{"accept": None}]}} + ]), probe("Status: inactive"), probe(status="failed")) + self.assertEqual(result["nftables"]["status"], "rules_present") + self.assertIn("nftables", result["backends_observed"]) + self.assertNotIn("ufw", result["backends_observed"]) + + def test_active_ufw_and_nft_are_not_exclusive(self): + result = module.summarize(nft([{"chain": {"hook": "input", "policy": "drop"}}]), probe("Status: active\nTo Action From"), probe(status="failed")) + self.assertEqual(result["backends_observed"], ["ufw", "nftables"]) + + def test_neither_never_claims_the_host_is_unfiltered(self): + result = module.summarize(nft([]), probe("Status: inactive"), probe(status="failed")) + self.assertEqual(result["state"], "unknown") + self.assertEqual(result["backends_observed"], []) + + def test_unavailable_permission_denied_and_malformed_are_unknown(self): + for p in [probe(status="unavailable"), probe(status="failed"), probe(status="timeout"), probe("not json"), probe('{}'), probe('{"nftables":{}}')]: + result = module.summarize(p, probe("Status: inactive"), probe(status="failed")) + self.assertEqual(result["state"], "unknown") + self.assertNotEqual(result["nftables"]["status"], "no_rules_observed") + + def test_unhooked_rules_and_empty_tables_do_not_prove_filtering(self): + for entries in [[{"table": {"name": "filter"}}], [{"chain": {"name": "unused"}}, {"rule": {"expr": [{"drop": None}]}}]]: + result = module.summarize(nft(entries), probe(status="unavailable"), probe(status="unavailable")) + self.assertEqual(result["state"], "unknown") + + def test_firewalld_output_is_preserved(self): + result = module.summarize(nft([]), probe("Status: inactive"), probe("public (active)\n services: ssh")) + self.assertIn("firewalld", result["backends_observed"]) + self.assertIn("services: ssh", result["probes"]["firewalld"]["stdout"]) + + +if __name__ == "__main__": + unittest.main()