use std::fmt::{self, Write as _};
const FNV_OFFSET: u64 = 0xcbf29ce484222325;
const FNV_PRIME: u64 = 0x100000001b3;
const REVIEW_EXCEPTION_NOTE: &str =
"reviewed dry-run exception; verify owner, source scope, and expiry before live apply";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Backend {
Ufw,
Iptables,
Nftables,
}
impl Backend {
pub fn all() -> [Self; 3] {
[Self::Ufw, Self::Iptables, Self::Nftables]
}
pub fn next(self) -> Self {
match self {
Self::Ufw => Self::Iptables,
Self::Iptables => Self::Nftables,
Self::Nftables => Self::Ufw,
}
}
}
impl fmt::Display for Backend {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Ufw => f.write_str("ufw"),
Self::Iptables => f.write_str("iptables"),
Self::Nftables => f.write_str("nftables"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExecutionMode {
DryRun,
}
impl fmt::Display for ExecutionMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DryRun => f.write_str("dry-run only"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuleAction {
Allow,
Deny,
Reject,
}
impl RuleAction {
pub fn label(self) -> &'static str {
match self {
Self::Allow => "allow",
Self::Deny => "deny",
Self::Reject => "reject",
}
}
fn ufw_arg(self) -> &'static str {
self.label()
}
fn netfilter_target(self) -> &'static str {
match self {
Self::Allow => "ACCEPT",
Self::Deny => "DROP",
Self::Reject => "REJECT",
}
}
fn nft_verdict(self) -> &'static str {
match self {
Self::Allow => "accept",
Self::Deny => "drop",
Self::Reject => "reject",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
In,
Out,
}
impl Direction {
pub fn label(self) -> &'static str {
match self {
Self::In => "in",
Self::Out => "out",
}
}
fn iptables_chain(self) -> &'static str {
match self {
Self::In => "INPUT",
Self::Out => "OUTPUT",
}
}
fn nft_chain(self) -> &'static str {
match self {
Self::In => "input",
Self::Out => "output",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Protocol {
Any,
Tcp,
Udp,
}
impl Protocol {
pub fn label(self) -> &'static str {
match self {
Self::Any => "any",
Self::Tcp => "tcp",
Self::Udp => "udp",
}
}
}
impl fmt::Display for Protocol {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DefaultPolicy {
Allow,
Deny,
}
impl DefaultPolicy {
pub fn next(self) -> Self {
match self {
Self::Allow => Self::Deny,
Self::Deny => Self::Allow,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Allow => "allow",
Self::Deny => "deny",
}
}
fn iptables_target(self) -> &'static str {
match self {
Self::Allow => "ACCEPT",
Self::Deny => "DROP",
}
}
fn nft_policy(self) -> &'static str {
match self {
Self::Allow => "accept",
Self::Deny => "drop",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PortSpec {
Any,
Single(u16),
Range(u16, u16),
}
impl PortSpec {
pub fn ufw_value(self, protocol: Protocol) -> Option<String> {
let port = match self {
Self::Any => return None,
Self::Single(port) => port.to_string(),
Self::Range(start, end) => format!("{start}:{end}"),
};
match protocol {
Protocol::Any => Some(port),
Protocol::Tcp | Protocol::Udp => Some(format!("{port}/{protocol}")),
}
}
fn netfilter_value(self) -> Option<String> {
match self {
Self::Any => None,
Self::Single(port) => Some(port.to_string()),
Self::Range(start, end) => Some(format!("{start}:{end}")),
}
}
}
impl fmt::Display for PortSpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Any => f.write_str("any"),
Self::Single(port) => write!(f, "{port}"),
Self::Range(start, end) => write!(f, "{start}-{end}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FirewallRule {
pub id: u32,
pub enabled: bool,
pub action: RuleAction,
pub direction: Direction,
pub protocol: Protocol,
pub port: PortSpec,
pub note: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortCandidate {
pub name: &'static str,
pub group: &'static str,
pub protocol: Protocol,
pub port: PortSpec,
pub risk: &'static str,
pub aliases: &'static [&'static str],
pub detail: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortPriority {
pub index: usize,
pub name: &'static str,
pub group: &'static str,
pub protocol: Protocol,
pub port: PortSpec,
pub risk: &'static str,
pub status: CandidateStatus,
pub score: u16,
pub reason: String,
pub recommendation: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewException {
pub name: &'static str,
pub group: &'static str,
pub protocol: Protocol,
pub port: PortSpec,
pub risk: &'static str,
pub note: &'static str,
}
impl ReviewException {
fn from_candidate(candidate: &PortCandidate) -> Self {
Self {
name: candidate.name,
group: candidate.group,
protocol: candidate.protocol,
port: candidate.port,
risk: candidate.risk,
note: REVIEW_EXCEPTION_NOTE,
}
}
fn matches_candidate(&self, candidate: &PortCandidate) -> bool {
self.protocol == candidate.protocol && self.port == candidate.port
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FirewallProfile {
Workstation,
WebServer,
Developer,
Lockdown,
}
impl FirewallProfile {
pub fn all() -> [Self; 4] {
[
Self::Workstation,
Self::WebServer,
Self::Developer,
Self::Lockdown,
]
}
pub fn label(self) -> &'static str {
match self {
Self::Workstation => "workstation",
Self::WebServer => "web-server",
Self::Developer => "developer",
Self::Lockdown => "lockdown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CandidateStatus {
Blocked,
Allowed,
Open,
}
impl CandidateStatus {
pub fn label(self) -> &'static str {
match self {
Self::Blocked => "blocked",
Self::Allowed => "allowed",
Self::Open => "open",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CandidateFilter {
All,
Named,
AtRisk,
Open,
Blocked,
Allowed,
}
impl CandidateFilter {
pub fn next(self) -> Self {
match self {
Self::All => Self::Named,
Self::Named => Self::AtRisk,
Self::AtRisk => Self::Open,
Self::Open => Self::Blocked,
Self::Blocked => Self::Allowed,
Self::Allowed => Self::All,
}
}
pub fn label(self) -> &'static str {
match self {
Self::All => "all",
Self::Named => "named",
Self::AtRisk => "at-risk",
Self::Open => "open",
Self::Blocked => "blocked",
Self::Allowed => "allowed",
}
}
fn matches(self, candidate: &PortCandidate, status: CandidateStatus) -> bool {
match self {
Self::All => true,
Self::Named => {
candidate.aliases.iter().any(|alias| {
alias.contains("ssh")
|| alias.contains("nginx")
|| alias.contains("postgres")
|| alias.contains("docker")
}) || matches!(candidate.name, "SSH" | "HTTP" | "Postgres" | "Docker API")
}
Self::AtRisk => matches!(candidate.risk, "high" | "data" | "admin"),
Self::Open => status == CandidateStatus::Open,
Self::Blocked => status == CandidateStatus::Blocked,
Self::Allowed => status == CandidateStatus::Allowed,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlannedCommand {
pub backend: Backend,
pub program: &'static str,
pub args: Vec<String>,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendPlanSummary {
pub backend: Backend,
pub commands: usize,
pub first_command: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProfileImpactSummary {
pub profile: FirewallProfile,
pub staged_changes: usize,
pub resulting_rules: usize,
pub coverage_percent: u8,
pub at_risk_open: usize,
pub management_total: usize,
pub management_blocked: usize,
pub management_allowed: usize,
pub management_open: usize,
pub management_all_blocked: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfileRecommendation {
pub profile: FirewallProfile,
pub staged_changes: usize,
pub resulting_rules: usize,
pub coverage_percent: u8,
pub at_risk_open: usize,
pub management_blocked: usize,
pub management_allowed: usize,
pub management_open: usize,
pub management_all_blocked: bool,
pub score: i32,
pub reason: String,
pub warning: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfileChangePreview {
pub profile: FirewallProfile,
pub candidate_index: usize,
pub name: &'static str,
pub group: &'static str,
pub protocol: Protocol,
pub port: PortSpec,
pub risk: &'static str,
pub before: CandidateStatus,
pub after: CandidateStatus,
pub reason: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GroupCoverageSummary {
pub group: &'static str,
pub total: usize,
pub blocked: usize,
pub allowed: usize,
pub open: usize,
pub at_risk_open: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RiskCoverageSummary {
pub risk: &'static str,
pub total: usize,
pub blocked: usize,
pub allowed: usize,
pub open: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ManagementAccessSummary {
pub total: usize,
pub blocked: usize,
pub allowed: usize,
pub open: usize,
}
impl ManagementAccessSummary {
pub fn all_blocked(self) -> bool {
self.total > 0 && self.blocked == self.total
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FirewallStats {
pub total_rules: usize,
pub enabled_rules: usize,
pub allow_rules: usize,
pub deny_rules: usize,
pub reject_rules: usize,
pub inbound_rules: usize,
pub outbound_rules: usize,
pub known_ports: usize,
pub known_blocked: usize,
pub known_allowed: usize,
pub known_open: usize,
pub at_risk_open: usize,
pub at_risk_allowed: usize,
pub reviewed_exceptions: usize,
pub unreviewed_at_risk_allowed: usize,
pub high_risk_blocks: usize,
pub plan_commands: usize,
pub coverage_percent: u8,
pub risk_percent: u8,
}
impl FirewallStats {
pub fn readiness_grade(self) -> &'static str {
match self.risk_percent {
90..=100 => "A",
75..=89 => "B",
50..=74 => "C",
25..=49 => "D",
_ => "F",
}
}
pub fn readiness_label(self) -> &'static str {
match self.risk_percent {
90..=100 => "ready for review",
75..=89 => "nearly ready",
50..=74 => "needs hardening",
25..=49 => "high risk",
_ => "unsafe draft",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdvisorSeverity {
Critical,
Warning,
Info,
Good,
}
impl AdvisorSeverity {
pub fn label(self) -> &'static str {
match self {
Self::Critical => "critical",
Self::Warning => "warning",
Self::Info => "info",
Self::Good => "good",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdvisorFinding {
pub severity: AdvisorSeverity,
pub title: String,
pub detail: String,
pub recommendation: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdvisorNextAction {
pub severity: AdvisorSeverity,
pub title: String,
pub recommendation: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewOrderStep {
pub title: String,
pub detail: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewGateStatus {
Pass,
Warn,
Block,
}
impl ReviewGateStatus {
pub fn label(self) -> &'static str {
match self {
Self::Pass => "pass",
Self::Warn => "warn",
Self::Block => "block",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewGate {
pub status: ReviewGateStatus,
pub title: String,
pub detail: String,
pub action: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReviewGateSummary {
pub pass: usize,
pub warn: usize,
pub block: usize,
}
impl ReviewGateSummary {
fn from_gates(gates: &[ReviewGate]) -> Self {
let mut summary = Self {
pass: 0,
warn: 0,
block: 0,
};
for gate in gates {
match gate.status {
ReviewGateStatus::Pass => summary.pass += 1,
ReviewGateStatus::Warn => summary.warn += 1,
ReviewGateStatus::Block => summary.block += 1,
}
}
summary
}
}
impl AdvisorNextAction {
fn from_findings(findings: &[AdvisorFinding]) -> Self {
findings
.first()
.map(|finding| Self {
severity: finding.severity,
title: finding.title.clone(),
recommendation: finding.recommendation.clone(),
})
.unwrap_or_else(|| Self {
severity: AdvisorSeverity::Good,
title: "Dry-run plan has no advisor findings".into(),
recommendation:
"Review generated commands before any future live executor is added.".into(),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AdvisorSeveritySummary {
pub critical: usize,
pub warning: usize,
pub info: usize,
pub good: usize,
}
impl AdvisorSeveritySummary {
pub fn total(self) -> usize {
self.critical + self.warning + self.info + self.good
}
fn from_findings(findings: &[AdvisorFinding]) -> Self {
let mut summary = Self {
critical: 0,
warning: 0,
info: 0,
good: 0,
};
for finding in findings {
match finding.severity {
AdvisorSeverity::Critical => summary.critical += 1,
AdvisorSeverity::Warning => summary.warning += 1,
AdvisorSeverity::Info => summary.info += 1,
AdvisorSeverity::Good => summary.good += 1,
}
}
summary
}
}
impl PlannedCommand {
pub fn shell_line(&self) -> String {
let mut parts = Vec::with_capacity(self.args.len() + 1);
parts.push(self.program.to_string());
parts.extend(self.args.iter().map(|arg| shell_quote(arg)));
parts.join(" ")
}
}
#[derive(Debug, Clone)]
pub struct FirewallController {
mode: ExecutionMode,
backend: Backend,
default_incoming: DefaultPolicy,
default_outgoing: DefaultPolicy,
logging: bool,
rules: Vec<FirewallRule>,
candidates: Vec<PortCandidate>,
review_exceptions: Vec<ReviewException>,
next_id: u32,
audit_log: Vec<String>,
history: Vec<ControllerSnapshot>,
}
#[derive(Debug, Clone)]
struct ControllerSnapshot {
backend: Backend,
default_incoming: DefaultPolicy,
default_outgoing: DefaultPolicy,
logging: bool,
rules: Vec<FirewallRule>,
review_exceptions: Vec<ReviewException>,
next_id: u32,
}
impl FirewallController {
pub fn demo() -> Self {
let mut controller = Self {
mode: ExecutionMode::DryRun,
backend: Backend::Ufw,
default_incoming: DefaultPolicy::Deny,
default_outgoing: DefaultPolicy::Allow,
logging: true,
rules: Vec::new(),
candidates: default_candidates(),
review_exceptions: Vec::new(),
next_id: 1,
audit_log: vec![
"Dry-run controller started. No live firewall writes are available.".into(),
],
history: Vec::new(),
};
controller.stage_starter_rules();
controller
}
pub fn mode(&self) -> ExecutionMode {
self.mode
}
pub fn backend(&self) -> Backend {
self.backend
}
pub fn default_incoming(&self) -> DefaultPolicy {
self.default_incoming
}
pub fn default_outgoing(&self) -> DefaultPolicy {
self.default_outgoing
}
pub fn logging(&self) -> bool {
self.logging
}
pub fn rules(&self) -> &[FirewallRule] {
&self.rules
}
pub fn candidates(&self) -> &[PortCandidate] {
&self.candidates
}
pub fn review_exceptions(&self) -> &[ReviewException] {
&self.review_exceptions
}
pub fn candidate_status(&self, index: usize) -> Option<CandidateStatus> {
self.candidates
.get(index)
.map(|candidate| self.candidate_status_for(candidate))
}
pub fn candidate_has_review_exception(&self, index: usize) -> bool {
self.candidates
.get(index)
.is_some_and(|candidate| self.has_review_exception(candidate))
}
pub fn candidate_group(&self, index: usize) -> Option<&'static str> {
self.candidates.get(index).map(|candidate| candidate.group)
}
pub fn candidate_risk(&self, index: usize) -> Option<&'static str> {
self.candidates.get(index).map(|candidate| candidate.risk)
}
pub fn filtered_candidate_indexes(
&self,
filter: CandidateFilter,
group: Option<&str>,
risk: Option<&str>,
) -> Vec<usize> {
self.candidates
.iter()
.enumerate()
.filter(|(_, candidate)| group.map_or(true, |group| candidate.group == group))
.filter(|(_, candidate)| risk.map_or(true, |risk| candidate.risk == risk))
.filter_map(|(index, candidate)| {
let status = self.candidate_status_for(candidate);
filter.matches(candidate, status).then_some(index)
})
.collect()
}
pub fn candidate_groups(&self) -> Vec<&'static str> {
let mut groups = Vec::new();
for candidate in &self.candidates {
if !groups.contains(&candidate.group) {
groups.push(candidate.group);
}
}
groups
}
pub fn candidate_risks(&self) -> Vec<&'static str> {
let mut risks = Vec::new();
for candidate in &self.candidates {
if !risks.contains(&candidate.risk) {
risks.push(candidate.risk);
}
}
risks
}
pub fn group_coverage_summaries(&self) -> Vec<GroupCoverageSummary> {
self.candidate_groups()
.into_iter()
.map(|group| {
let mut summary = GroupCoverageSummary {
group,
total: 0,
blocked: 0,
allowed: 0,
open: 0,
at_risk_open: 0,
};
for candidate in self
.candidates
.iter()
.filter(|candidate| candidate.group == group)
{
summary.total += 1;
let status = self.candidate_status_for(candidate);
match status {
CandidateStatus::Blocked => summary.blocked += 1,
CandidateStatus::Allowed => summary.allowed += 1,
CandidateStatus::Open => {
summary.open += 1;
if matches!(candidate.risk, "high" | "data" | "admin") {
summary.at_risk_open += 1;
}
}
}
}
summary
})
.collect()
}
pub fn risk_coverage_summaries(&self) -> Vec<RiskCoverageSummary> {
self.candidate_risks()
.into_iter()
.map(|risk| {
let mut summary = RiskCoverageSummary {
risk,
total: 0,
blocked: 0,
allowed: 0,
open: 0,
};
for candidate in self
.candidates
.iter()
.filter(|candidate| candidate.risk == risk)
{
summary.total += 1;
match self.candidate_status_for(candidate) {
CandidateStatus::Blocked => summary.blocked += 1,
CandidateStatus::Allowed => summary.allowed += 1,
CandidateStatus::Open => summary.open += 1,
}
}
summary
})
.collect()
}
pub fn management_access_summary(&self) -> ManagementAccessSummary {
let mut summary = ManagementAccessSummary {
total: 0,
blocked: 0,
allowed: 0,
open: 0,
};
for candidate in self
.candidates
.iter()
.filter(|candidate| matches!(candidate.name, "SSH" | "RDP" | "VNC" | "Kubernetes"))
{
summary.total += 1;
match self.candidate_status_for(candidate) {
CandidateStatus::Blocked => summary.blocked += 1,
CandidateStatus::Allowed => summary.allowed += 1,
CandidateStatus::Open => summary.open += 1,
}
}
summary
}
pub fn last_message(&self) -> &str {
self.audit_log
.last()
.map(String::as_str)
.unwrap_or("No activity yet")
}
pub fn audit_log(&self) -> &[String] {
&self.audit_log
}
pub fn record_report_export(&mut self, path: &str) {
self.audit_log
.push(format!("Exported dry-run report to {path}."));
}
pub fn record_script_export(&mut self, path: &str) {
self.audit_log
.push(format!("Exported review-only command script to {path}."));
}
pub fn record_manifest_export(&mut self, path: &str) {
self.audit_log
.push(format!("Exported review-only JSON manifest to {path}."));
}
pub fn record_bundle_export(&mut self) {
self.audit_log
.push("Exported review-only report, script, and JSON manifest bundle.".into());
}
pub fn history_depth(&self) -> usize {
self.history.len()
}
pub fn stats(&self) -> FirewallStats {
let total_rules = self.rules.len();
let enabled_rules = self.rules.iter().filter(|rule| rule.enabled).count();
let allow_rules = self
.rules
.iter()
.filter(|rule| rule.action == RuleAction::Allow)
.count();
let deny_rules = self
.rules
.iter()
.filter(|rule| rule.action == RuleAction::Deny)
.count();
let reject_rules = self
.rules
.iter()
.filter(|rule| rule.action == RuleAction::Reject)
.count();
let inbound_rules = self
.rules
.iter()
.filter(|rule| rule.direction == Direction::In)
.count();
let outbound_rules = self
.rules
.iter()
.filter(|rule| rule.direction == Direction::Out)
.count();
let known_ports = self.candidates.len();
let mut known_blocked = 0;
let mut known_allowed = 0;
let mut known_open = 0;
let mut high_risk_blocks = 0;
let mut high_risk_total = 0;
let mut at_risk_open = 0;
let mut at_risk_allowed = 0usize;
let mut reviewed_exceptions = 0usize;
for candidate in &self.candidates {
let status = self.candidate_status_for(candidate);
match status {
CandidateStatus::Blocked => known_blocked += 1,
CandidateStatus::Allowed => known_allowed += 1,
CandidateStatus::Open => known_open += 1,
}
if matches!(candidate.risk, "high" | "data" | "admin") {
high_risk_total += 1;
match status {
CandidateStatus::Blocked => high_risk_blocks += 1,
CandidateStatus::Open => at_risk_open += 1,
CandidateStatus::Allowed => {
at_risk_allowed += 1;
if self.has_review_exception(candidate) {
reviewed_exceptions += 1;
}
}
}
}
}
let unreviewed_at_risk_allowed = at_risk_allowed.saturating_sub(reviewed_exceptions);
let high_risk_total = high_risk_total.max(1);
let coverage_percent = ((high_risk_blocks * 100) / high_risk_total) as u8;
let policy_score = match self.default_incoming {
DefaultPolicy::Deny => 25,
DefaultPolicy::Allow => 0,
};
let logging_score = if self.logging { 10 } else { 0 };
let rule_score = (enabled_rules * 8).min(45);
let coverage_score = (coverage_percent as usize / 5).min(20);
let risk_percent =
(policy_score + logging_score + rule_score + coverage_score).min(100) as u8;
FirewallStats {
total_rules,
enabled_rules,
allow_rules,
deny_rules,
reject_rules,
inbound_rules,
outbound_rules,
known_ports,
known_blocked,
known_allowed,
known_open,
at_risk_open,
at_risk_allowed,
reviewed_exceptions,
unreviewed_at_risk_allowed,
high_risk_blocks,
plan_commands: self.plan().len(),
coverage_percent,
risk_percent,
}
}
pub fn toggle_default_incoming(&mut self) {
self.save_checkpoint();
self.default_incoming = self.default_incoming.next();
self.audit_log.push(format!(
"Default incoming policy changed to {}.",
self.default_incoming.label()
));
}
pub fn toggle_default_outgoing(&mut self) {
self.save_checkpoint();
self.default_outgoing = self.default_outgoing.next();
self.audit_log.push(format!(
"Default outgoing policy changed to {}.",
self.default_outgoing.label()
));
}
pub fn clear_rules(&mut self) -> usize {
let removed = self.rules.len();
if removed == 0 {
self.audit_log.push("No staged rules to clear.".into());
return 0;
}
self.save_checkpoint();
self.rules.clear();
self.review_exceptions.clear();
self.audit_log
.push(format!("Cleared {removed} staged dry-run rule(s)."));
removed
}
pub fn reset_to_starter_plan(&mut self) -> usize {
self.save_checkpoint();
self.backend = Backend::Ufw;
self.default_incoming = DefaultPolicy::Deny;
self.default_outgoing = DefaultPolicy::Allow;
self.logging = true;
self.rules.clear();
self.review_exceptions.clear();
self.next_id = 1;
self.stage_starter_rules();
let restored = self.rules.len();
self.audit_log.push(format!(
"Restored starter dry-run plan with {restored} staged rule(s)."
));
restored
}
pub fn cycle_backend(&mut self) {
self.save_checkpoint();
self.backend = self.backend.next();
self.audit_log
.push(format!("Preview backend changed to {}.", self.backend));
}
pub fn toggle_logging(&mut self) {
self.save_checkpoint();
self.logging = !self.logging;
self.audit_log.push(format!(
"Planned firewall logging is now {}.",
if self.logging { "enabled" } else { "disabled" }
));
}
pub fn toggle_rule(&mut self, index: usize) {
if index >= self.rules.len() {
return;
}
self.save_checkpoint();
let Some(rule) = self.rules.get_mut(index) else {
return;
};
rule.enabled = !rule.enabled;
self.audit_log.push(format!(
"Rule #{} is now {}.",
rule.id,
if rule.enabled { "enabled" } else { "disabled" }
));
self.prune_review_exceptions();
}
pub fn remove_rule(&mut self, index: usize) {
if index >= self.rules.len() {
return;
}
self.save_checkpoint();
let removed = self.rules.remove(index);
self.prune_review_exceptions();
self.audit_log
.push(format!("Removed staged rule #{}.", removed.id));
}
pub fn block_candidate(&mut self, index: usize) {
let Some(candidate) = self.candidates.get(index).cloned() else {
return;
};
if self.has_rule(
RuleAction::Deny,
Direction::In,
candidate.protocol,
candidate.port,
) {
self.audit_log.push(format!(
"Block for {} {} already staged.",
candidate.name, candidate.port
));
return;
}
self.save_checkpoint();
self.remove_candidate_action(candidate.protocol, candidate.port, RuleAction::Allow);
self.remove_review_exception_for(&candidate);
self.stage_rule(
RuleAction::Deny,
Direction::In,
candidate.protocol,
candidate.port,
&format!("Block {}", candidate.name),
);
}
pub fn allow_candidate(&mut self, index: usize) {
let Some(candidate) = self.candidates.get(index).cloned() else {
return;
};
if self.has_rule(
RuleAction::Allow,
Direction::In,
candidate.protocol,
candidate.port,
) {
self.audit_log.push(format!(
"Allow for {} {} already staged.",
candidate.name, candidate.port
));
return;
}
self.save_checkpoint();
self.remove_candidate_blocks(candidate.protocol, candidate.port);
self.stage_rule(
RuleAction::Allow,
Direction::In,
candidate.protocol,
candidate.port,
&format!("Allow {}", candidate.name),
);
}
pub fn toggle_candidate_block(&mut self, index: usize) -> Option<bool> {
let candidate = self.candidates.get(index).cloned()?;
self.save_checkpoint();
if self.candidate_status_for(&candidate) == CandidateStatus::Blocked {
let removed = self.remove_candidate_blocks(candidate.protocol, candidate.port);
self.prune_review_exceptions();
self.audit_log.push(format!(
"Unblocked {} {} by removing {} staged block rule(s).",
candidate.name, candidate.port, removed
));
return Some(false);
}
self.remove_candidate_action(candidate.protocol, candidate.port, RuleAction::Allow);
self.remove_review_exception_for(&candidate);
self.stage_rule(
RuleAction::Deny,
Direction::In,
candidate.protocol,
candidate.port,
&format!("Block {}", candidate.name),
);
Some(true)
}
pub fn toggle_candidate_exception(&mut self, index: usize) -> Option<bool> {
let candidate = self.candidates.get(index).cloned()?;
if !matches!(candidate.risk, "high" | "data" | "admin") {
self.audit_log.push(format!(
"{} is not classified as a risky exception candidate.",
candidate.name
));
return None;
}
if self.candidate_status_for(&candidate) != CandidateStatus::Allowed {
self.audit_log.push(format!(
"Allow {} before recording a reviewed exception.",
candidate.name
));
return None;
}
self.save_checkpoint();
if self.remove_review_exception_for(&candidate) {
self.audit_log.push(format!(
"Removed reviewed exception for {} {}.",
candidate.name, candidate.port
));
Some(false)
} else {
self.review_exceptions
.push(ReviewException::from_candidate(&candidate));
self.audit_log.push(format!(
"Recorded reviewed exception for {} {}.",
candidate.name, candidate.port
));
Some(true)
}
}
pub fn block_high_risk_candidates(&mut self) -> usize {
self.save_checkpoint();
let candidates = self
.candidates
.iter()
.filter(|candidate| matches!(candidate.risk, "high" | "data" | "admin"))
.cloned()
.collect::<Vec<_>>();
let mut staged = 0;
for candidate in candidates {
if self.candidate_status_for(&candidate) == CandidateStatus::Blocked {
continue;
}
self.remove_candidate_action(candidate.protocol, candidate.port, RuleAction::Allow);
self.stage_rule(
RuleAction::Deny,
Direction::In,
candidate.protocol,
candidate.port,
&format!("Block {}", candidate.name),
);
staged += 1;
}
if staged == 0 {
self.audit_log
.push("All high-risk known ports already have staged blocks.".into());
} else {
self.audit_log
.push(format!("Hardened {} high-risk known port(s).", staged));
}
staged
}
pub fn block_candidate_group(&mut self, index: usize) -> usize {
let Some(group) = self.candidate_group(index) else {
return 0;
};
self.save_checkpoint();
let candidates = self
.candidates
.iter()
.filter(|candidate| candidate.group == group)
.cloned()
.collect::<Vec<_>>();
let mut staged = 0;
for candidate in candidates {
if self.block_candidate_value(&candidate) {
staged += 1;
}
}
self.audit_log.push(format!(
"Blocked {} known port(s) in group {group}.",
staged
));
staged
}
pub fn block_candidate_risk(&mut self, index: usize) -> usize {
let Some(risk) = self.candidate_risk(index) else {
return 0;
};
self.save_checkpoint();
let candidates = self
.candidates
.iter()
.filter(|candidate| candidate.risk == risk)
.cloned()
.collect::<Vec<_>>();
let mut staged = 0;
for candidate in candidates {
if self.block_candidate_value(&candidate) {
staged += 1;
}
}
self.audit_log.push(format!(
"Blocked {} known port(s) in risk class {risk}.",
staged
));
staged
}
pub fn block_candidate_indexes(&mut self, indexes: &[usize]) -> usize {
let candidates = indexes
.iter()
.filter_map(|index| self.candidates.get(*index))
.cloned()
.collect::<Vec<_>>();
if candidates.is_empty() {
return 0;
}
self.save_checkpoint();
let mut staged = 0;
for candidate in candidates {
if self.block_candidate_value(&candidate) {
staged += 1;
}
}
self.audit_log.push(format!(
"Blocked {} known port(s) from active filter.",
staged
));
staged
}
pub fn allow_candidate_indexes(&mut self, indexes: &[usize]) -> usize {
let candidates = indexes
.iter()
.filter_map(|index| self.candidates.get(*index))
.cloned()
.collect::<Vec<_>>();
if candidates.is_empty() {
return 0;
}
self.save_checkpoint();
let mut staged = 0;
for candidate in candidates {
if self.allow_candidate_value(&candidate) {
staged += 1;
}
}
self.audit_log.push(format!(
"Allowed {} known port(s) from active filter.",
staged
));
staged
}
pub fn apply_profile(&mut self, profile: FirewallProfile) -> usize {
self.save_checkpoint();
let mut staged = 0;
let candidates = self.candidates.clone();
for candidate in &candidates {
let block = match profile {
FirewallProfile::Workstation => matches!(
candidate.group,
"legacy" | "file" | "data" | "admin" | "dev" | "orchestration"
),
FirewallProfile::WebServer => matches!(
candidate.group,
"legacy" | "file" | "data" | "dev" | "orchestration" | "observability"
),
FirewallProfile::Developer => matches!(
candidate.group,
"legacy" | "file" | "data" | "orchestration"
),
FirewallProfile::Lockdown => true,
};
if block && self.block_candidate_value(candidate) {
staged += 1;
}
}
if profile == FirewallProfile::WebServer {
staged += self.allow_candidate_named("HTTP") as usize;
staged += self.allow_candidate_named("HTTPS") as usize;
}
self.audit_log.push(format!(
"Applied {} dry-run profile with {} staged change(s).",
profile.label(),
staged
));
staged
}
pub fn apply_recommended_profile(&mut self) -> (FirewallProfile, usize) {
let profile = self.recommended_profile().profile;
let staged = self.apply_profile(profile);
self.audit_log.push(format!(
"Applied recommended {} dry-run profile.",
profile.label()
));
(profile, staged)
}
pub fn apply_safe_recommended_profile(&mut self) -> Option<(FirewallProfile, usize)> {
let profile = self.safe_recommended_profile()?.profile;
let staged = self.apply_profile(profile);
self.audit_log.push(format!(
"Applied management-preserving recommended {} dry-run profile.",
profile.label()
));
Some((profile, staged))
}
pub fn apply_advisor_quick_fix(&mut self) -> usize {
self.save_checkpoint();
let mut changes = 0;
if self.default_incoming != DefaultPolicy::Deny {
self.default_incoming = DefaultPolicy::Deny;
changes += 1;
}
if !self.logging {
self.logging = true;
changes += 1;
}
let candidates = self
.candidates
.iter()
.filter(|candidate| matches!(candidate.risk, "high" | "data" | "admin"))
.cloned()
.collect::<Vec<_>>();
for candidate in candidates {
if self.block_candidate_value(&candidate) {
changes += 1;
}
}
self.audit_log.push(format!(
"Advisor quick fix staged {} dry-run change(s).",
changes
));
changes
}
pub fn undo(&mut self) -> bool {
let Some(snapshot) = self.history.pop() else {
self.audit_log.push("Nothing to undo.".into());
return false;
};
self.backend = snapshot.backend;
self.default_incoming = snapshot.default_incoming;
self.default_outgoing = snapshot.default_outgoing;
self.logging = snapshot.logging;
self.rules = snapshot.rules;
self.review_exceptions = snapshot.review_exceptions;
self.next_id = snapshot.next_id;
self.audit_log.push("Undid last staged change.".into());
true
}
pub fn advisor_findings(&self) -> Vec<AdvisorFinding> {
let mut findings = Vec::new();
let management_access = self.management_access_summary();
if self.default_incoming == DefaultPolicy::Allow {
findings.push(AdvisorFinding {
severity: AdvisorSeverity::Critical,
title: "Default incoming policy allows traffic".into(),
detail: "A replacement for ufw should default-deny inbound traffic unless explicitly opened.".into(),
recommendation: "Press I or use advisor quick fix to stage a default deny incoming policy.".into(),
});
}
if self.default_outgoing == DefaultPolicy::Deny {
findings.push(AdvisorFinding {
severity: AdvisorSeverity::Warning,
title: "Default outgoing policy denies traffic".into(),
detail: "A strict egress default can break updates, package installs, DNS, and package mirrors unless allow rules are planned.".into(),
recommendation: "Press O if this host should keep the common allow-outgoing baseline.".into(),
});
}
if !self.logging {
findings.push(AdvisorFinding {
severity: AdvisorSeverity::Warning,
title: "Firewall logging is disabled in the plan".into(),
detail: "You will have less visibility into rejected traffic.".into(),
recommendation: "Press l or use advisor quick fix to stage logging.".into(),
});
}
if self.history_depth() > 0 {
findings.push(AdvisorFinding {
severity: AdvisorSeverity::Info,
title: "Undo checkpoints are available".into(),
detail: format!(
"{} in-memory checkpoint(s) can be restored without touching the system.",
self.history_depth()
),
recommendation: "Press u to undo the last staged dry-run change.".into(),
});
}
for candidate in &self.candidates {
let status = self.candidate_status_for(candidate);
if matches!(candidate.risk, "high" | "data" | "admin")
&& status == CandidateStatus::Open
{
findings.push(AdvisorFinding {
severity: AdvisorSeverity::Warning,
title: format!("{} is not blocked", candidate.name),
detail: format!(
"{}/{} ({}) is a {} service: {}.",
candidate.port, candidate.protocol, candidate.group, candidate.risk, candidate.detail
),
recommendation: "Select it in Known Ports and press Enter, or press h to harden all high-risk ports.".into(),
});
}
if matches!(candidate.risk, "high" | "data" | "admin")
&& status == CandidateStatus::Allowed
{
if self.has_review_exception(candidate) {
findings.push(AdvisorFinding {
severity: AdvisorSeverity::Warning,
title: format!("{} has a reviewed allow exception", candidate.name),
detail: format!(
"{}/{} remains staged open as a reviewed {} exception: {}.",
candidate.port, candidate.protocol, candidate.risk, REVIEW_EXCEPTION_NOTE
),
recommendation: "Verify owner, source scope, and expiry before any future live executor uses this plan.".into(),
});
} else {
findings.push(AdvisorFinding {
severity: AdvisorSeverity::Critical,
title: format!("{} is explicitly allowed", candidate.name),
detail: format!(
"{}/{} is staged open even though it is classified as {}.",
candidate.port, candidate.protocol, candidate.risk
),
recommendation: "Block it or press X to record a reviewed exception for an intentional exposure.".into(),
});
}
}
}
if management_access.all_blocked() {
findings.push(AdvisorFinding {
severity: AdvisorSeverity::Warning,
title: "Management access is fully blocked".into(),
detail: format!(
"All {} known management/control-plane access path(s) are blocked in the dry-run plan.",
management_access.total
),
recommendation: "Confirm local console, break-glass, or out-of-band access before any future live use.".into(),
});
}
for (left_idx, left) in self.rules.iter().enumerate() {
if !left.enabled {
continue;
}
for right in self
.rules
.iter()
.skip(left_idx + 1)
.filter(|rule| rule.enabled)
{
let same_target = left.direction == right.direction
&& left.protocol == right.protocol
&& left.port == right.port;
let conflict = matches!(
(left.action, right.action),
(RuleAction::Allow, RuleAction::Deny | RuleAction::Reject)
| (RuleAction::Deny | RuleAction::Reject, RuleAction::Allow)
);
if same_target && conflict {
findings.push(AdvisorFinding {
severity: AdvisorSeverity::Critical,
title: format!("Conflicting rules for {}/{}", left.port, left.protocol),
detail: format!(
"Rule #{} and #{} target the same direction/protocol/port with opposite actions.",
left.id, right.id
),
recommendation: "Remove or disable the stale rule before applying anything live.".into(),
});
}
}
}
if findings.is_empty() {
findings.push(AdvisorFinding {
severity: AdvisorSeverity::Good,
title: "Dry-run plan has no obvious high-risk gaps".into(),
detail: "Known high-risk ports are covered and no conflicting staged rules were found.".into(),
recommendation: "Review the generated commands before any future live executor is added.".into(),
});
} else {
findings.sort_by_key(|finding| match finding.severity {
AdvisorSeverity::Critical => 0,
AdvisorSeverity::Warning => 1,
AdvisorSeverity::Info => 2,
AdvisorSeverity::Good => 3,
});
}
findings
}
pub fn advisor_summary(&self) -> AdvisorSeveritySummary {
AdvisorSeveritySummary::from_findings(&self.advisor_findings())
}
pub fn advisor_next_action(&self) -> AdvisorNextAction {
AdvisorNextAction::from_findings(&self.advisor_findings())
}
pub fn review_gates(&self) -> Vec<ReviewGate> {
let stats = self.stats();
let findings = self.advisor_findings();
let advisor_summary = AdvisorSeveritySummary::from_findings(&findings);
let backend_summaries = self.backend_plan_summaries();
let management_access = self.management_access_summary();
let mut gates = Vec::new();
gates.push(ReviewGate {
status: ReviewGateStatus::Pass,
title: "Dry-run safety lock".into(),
detail: "No live executor exists; generated firewall commands are review-only.".into(),
action: "Keep exports in review flow before any future guarded live executor.".into(),
});
gates.push(ReviewGate {
status: if self.default_incoming == DefaultPolicy::Deny {
ReviewGateStatus::Pass
} else {
ReviewGateStatus::Block
},
title: "Default incoming policy".into(),
detail: format!(
"Planned default incoming policy is {}.",
self.default_incoming.label()
),
action: if self.default_incoming == DefaultPolicy::Deny {
"Default-deny inbound baseline is staged.".into()
} else {
"Press I or run advisor quick fix before review sign-off.".into()
},
});
gates.push(ReviewGate {
status: if advisor_summary.critical == 0 {
ReviewGateStatus::Pass
} else {
ReviewGateStatus::Block
},
title: "Critical advisor findings".into(),
detail: format!("{} critical finding(s) remain.", advisor_summary.critical),
action: if advisor_summary.critical == 0 {
"No critical advisor findings are currently present.".into()
} else {
"Resolve critical advisor findings before review sign-off.".into()
},
});
gates.push(ReviewGate {
status: if stats.at_risk_open == 0 {
ReviewGateStatus::Pass
} else {
ReviewGateStatus::Warn
},
title: "At-risk open known ports".into(),
detail: format!(
"{} admin/high/data known port(s) remain open.",
stats.at_risk_open
),
action: if stats.at_risk_open == 0 {
"No at-risk known ports remain open.".into()
} else {
"Use n, M, h, or Ports filters to block or document the exposure.".into()
},
});
let exception_status = if stats.unreviewed_at_risk_allowed > 0 {
ReviewGateStatus::Block
} else if stats.reviewed_exceptions > 0 {
ReviewGateStatus::Warn
} else {
ReviewGateStatus::Pass
};
gates.push(ReviewGate {
status: exception_status,
title: "Risky allow exceptions".into(),
detail: format!(
"{} reviewed exception(s), {} unreviewed risky allow(s).",
stats.reviewed_exceptions, stats.unreviewed_at_risk_allowed
),
action: match exception_status {
ReviewGateStatus::Pass => "No risky allow exceptions require review.".into(),
ReviewGateStatus::Warn => {
"Validate owner, source scope, and expiry for each reviewed exception.".into()
}
ReviewGateStatus::Block => {
"Block the risky allow or press X to record a reviewed exception.".into()
}
},
});
gates.push(ReviewGate {
status: if management_access.all_blocked() {
ReviewGateStatus::Warn
} else {
ReviewGateStatus::Pass
},
title: "Management access continuity".into(),
detail: format!(
"{} management/control-plane path(s): {} blocked, {} allowed, {} open.",
management_access.total,
management_access.blocked,
management_access.allowed,
management_access.open
),
action: if management_access.all_blocked() {
"Confirm local console, break-glass, or out-of-band access before any future live use.".into()
} else {
"Management access is not fully blocked; review exposure separately.".into()
},
});
let recommended_profile = self.recommended_profile();
let safe_recommended_profile = self.safe_recommended_profile();
gates.push(ReviewGate {
status: if recommended_profile.management_all_blocked {
ReviewGateStatus::Warn
} else {
ReviewGateStatus::Pass
},
title: "Recommended profile safety".into(),
detail: format!(
"Strongest {} profile would leave management access at {} blocked, {} allowed, {} open.",
recommended_profile.profile.label(),
recommended_profile.management_blocked,
recommended_profile.management_allowed,
recommended_profile.management_open
),
action: if recommended_profile.management_all_blocked {
safe_recommended_profile
.map(|recommendation| {
format!(
"Press y for the management-preserving {} profile, or confirm console access before Y.",
recommendation.profile.label()
)
})
.unwrap_or_else(|| {
"Confirm console, break-glass, or out-of-band access before pressing Y."
.into()
})
} else {
"Strongest recommendation preserves at least one management path.".into()
},
});
gates.push(ReviewGate {
status: if stats.plan_commands > 0 {
ReviewGateStatus::Pass
} else {
ReviewGateStatus::Warn
},
title: "Command preview generated".into(),
detail: format!(
"{} command(s) generated for {} preview.",
stats.plan_commands,
self.backend()
),
action: if stats.plan_commands > 0 {
"Review the Plan tab or exported script before any manual use.".into()
} else {
"Stage rules or defaults before relying on an empty preview.".into()
},
});
let missing_backend = backend_summaries
.iter()
.find(|summary| summary.commands == 0)
.map(|summary| summary.backend.to_string());
gates.push(ReviewGate {
status: if missing_backend.is_none() {
ReviewGateStatus::Pass
} else {
ReviewGateStatus::Warn
},
title: "Backend parity preview".into(),
detail: backend_summaries
.iter()
.map(|summary| format!("{} {}", summary.backend, summary.commands))
.collect::<Vec<_>>()
.join(" | "),
action: missing_backend
.map(|backend| format!("Review why {backend} generated no preview commands."))
.unwrap_or_else(|| {
"UFW, iptables, and nftables previews all produce commands.".into()
}),
});
gates.push(ReviewGate {
status: ReviewGateStatus::Pass,
title: "Artifact fingerprint".into(),
detail: format!("Current review fingerprint is {}.", self.plan_fingerprint()),
action: "Compare this fingerprint across report, manifest, and script exports.".into(),
});
gates
}
pub fn review_gate_summary(&self) -> ReviewGateSummary {
ReviewGateSummary::from_gates(&self.review_gates())
}
pub fn next_review_gate(&self) -> ReviewGate {
self.review_gates()
.into_iter()
.find(|gate| gate.status != ReviewGateStatus::Pass)
.unwrap_or_else(|| ReviewGate {
status: ReviewGateStatus::Pass,
title: "All review gates passed".into(),
detail: "No blocker or warning gates remain in the dry-run review.".into(),
action: "Export the review bundle and compare fingerprints.".into(),
})
}
pub fn priority_ports(&self, limit: usize) -> Vec<PortPriority> {
let mut priorities = self
.candidates
.iter()
.enumerate()
.filter_map(|(index, candidate)| {
let status = self.candidate_status_for(candidate);
let (score, reason) = self.priority_score_for(candidate, status)?;
Some(PortPriority {
index,
name: candidate.name,
group: candidate.group,
protocol: candidate.protocol,
port: candidate.port,
risk: candidate.risk,
status,
score,
reason,
recommendation: self.candidate_recommendation_for(candidate, status),
})
})
.collect::<Vec<_>>();
priorities.sort_by(|left, right| {
right
.score
.cmp(&left.score)
.then(left.index.cmp(&right.index))
});
priorities.truncate(limit);
priorities
}
fn review_order_steps(
&self,
findings: &[AdvisorFinding],
stats: FirewallStats,
plan_commands: usize,
) -> Vec<ReviewOrderStep> {
let advisor_summary = AdvisorSeveritySummary::from_findings(findings);
let mut steps = Vec::new();
if advisor_summary.critical > 0 {
steps.push(ReviewOrderStep {
title: "Resolve critical advisor findings".into(),
detail: format!(
"{} critical finding(s) can expose traffic or create conflicting rules.",
advisor_summary.critical
),
});
} else if advisor_summary.warning > 0 {
steps.push(ReviewOrderStep {
title: "Review advisor warnings".into(),
detail: format!(
"{} warning finding(s) remain before any future live executor is added.",
advisor_summary.warning
),
});
} else {
steps.push(ReviewOrderStep {
title: "Confirm advisor clean state".into(),
detail: "No critical or warning advisor findings are present.".into(),
});
}
if stats.at_risk_open > 0 {
steps.push(ReviewOrderStep {
title: "Close at-risk open known ports".into(),
detail: format!(
"{} admin/high/data known port(s) are still open in the dry-run state.",
stats.at_risk_open
),
});
} else {
steps.push(ReviewOrderStep {
title: "Confirm at-risk coverage".into(),
detail: "No admin/high/data known ports remain open.".into(),
});
}
if stats.unreviewed_at_risk_allowed > 0 {
steps.push(ReviewOrderStep {
title: "Document risky allow exceptions".into(),
detail: format!(
"{} admin/high/data allow rule(s) need a reviewed exception or a block.",
stats.unreviewed_at_risk_allowed
),
});
} else if stats.reviewed_exceptions > 0 {
steps.push(ReviewOrderStep {
title: "Validate reviewed exceptions".into(),
detail: format!(
"{} reviewed exception(s) still require owner, source scope, and expiry validation.",
stats.reviewed_exceptions
),
});
}
if stats.allow_rules > 0 {
steps.push(ReviewOrderStep {
title: "Validate explicit allow rules".into(),
detail: format!(
"{} allow rule(s) should match intentional service exposure.",
stats.allow_rules
),
});
}
steps.push(ReviewOrderStep {
title: "Review command preview".into(),
detail: format!(
"{} firewall command(s) are generated for the {} backend preview.",
plan_commands,
self.backend()
),
});
steps.push(ReviewOrderStep {
title: "Record review fingerprint".into(),
detail: format!(
"Use fingerprint {} to compare report, manifest, and script exports.",
self.plan_fingerprint()
),
});
steps
}
pub fn plan(&self) -> Vec<PlannedCommand> {
let mut plan = self.base_policy_plan();
if self.logging {
plan.extend(self.logging_plan());
}
for rule in self.rules.iter().filter(|rule| rule.enabled) {
plan.extend(self.rule_plan(rule));
}
plan
}
pub fn plan_for_backend(&self, backend: Backend) -> Vec<PlannedCommand> {
let mut controller = self.clone();
controller.backend = backend;
controller.plan()
}
pub fn backend_plan_summaries(&self) -> Vec<BackendPlanSummary> {
Backend::all()
.into_iter()
.map(|backend| {
let plan = self.plan_for_backend(backend);
let first_command = plan
.first()
.map(PlannedCommand::shell_line)
.unwrap_or_else(|| "no commands".into());
BackendPlanSummary {
backend,
commands: plan.len(),
first_command,
}
})
.collect()
}
pub fn profile_impact_summaries(&self) -> Vec<ProfileImpactSummary> {
FirewallProfile::all()
.into_iter()
.map(|profile| {
let mut controller = self.clone();
let staged_changes = controller.apply_profile(profile);
let stats = controller.stats();
let management_access = controller.management_access_summary();
ProfileImpactSummary {
profile,
staged_changes,
resulting_rules: stats.total_rules,
coverage_percent: stats.coverage_percent,
at_risk_open: stats.at_risk_open,
management_total: management_access.total,
management_blocked: management_access.blocked,
management_allowed: management_access.allowed,
management_open: management_access.open,
management_all_blocked: management_access.all_blocked(),
}
})
.collect()
}
pub fn recommended_profile(&self) -> ProfileRecommendation {
let mut impacts = self.profile_impact_summaries();
impacts.sort_by(|left, right| {
left.at_risk_open
.cmp(&right.at_risk_open)
.then(right.coverage_percent.cmp(&left.coverage_percent))
.then(left.staged_changes.cmp(&right.staged_changes))
.then(left.resulting_rules.cmp(&right.resulting_rules))
});
let impact = impacts
.first()
.copied()
.expect("built-in profile set is not empty");
self.profile_recommendation_from_impact(impact)
}
pub fn safe_recommended_profile(&self) -> Option<ProfileRecommendation> {
let mut recommendations = self
.profile_impact_summaries()
.into_iter()
.map(|impact| self.profile_recommendation_from_impact(impact))
.filter(|recommendation| !recommendation.management_all_blocked)
.collect::<Vec<_>>();
recommendations.sort_by(|left, right| {
left.at_risk_open
.cmp(&right.at_risk_open)
.then(right.coverage_percent.cmp(&left.coverage_percent))
.then(left.staged_changes.cmp(&right.staged_changes))
.then(left.resulting_rules.cmp(&right.resulting_rules))
});
recommendations.into_iter().next()
}
fn profile_recommendation_from_impact(
&self,
impact: ProfileImpactSummary,
) -> ProfileRecommendation {
let score = profile_recommendation_score(impact);
let reason = if impact.at_risk_open == 0 {
format!(
"eliminates at-risk open ports with {} staged change(s) and {}% coverage",
impact.staged_changes, impact.coverage_percent
)
} else {
format!(
"best available profile leaves {} at-risk open port(s) with {}% coverage",
impact.at_risk_open, impact.coverage_percent
)
};
let warning = impact.management_all_blocked.then(|| {
format!(
"{} blocks all {} known management/control-plane access path(s); confirm console or break-glass access before use",
impact.profile.label(),
impact.management_total
)
});
ProfileRecommendation {
profile: impact.profile,
staged_changes: impact.staged_changes,
resulting_rules: impact.resulting_rules,
coverage_percent: impact.coverage_percent,
at_risk_open: impact.at_risk_open,
management_blocked: impact.management_blocked,
management_allowed: impact.management_allowed,
management_open: impact.management_open,
management_all_blocked: impact.management_all_blocked,
score,
reason,
warning,
}
}
pub fn profile_change_preview(&self, profile: FirewallProfile) -> Vec<ProfileChangePreview> {
let mut controller = self.clone();
controller.apply_profile(profile);
self.candidates
.iter()
.enumerate()
.filter_map(|(index, candidate)| {
let before = self.candidate_status_for(candidate);
let after = controller
.candidate_status(index)
.expect("candidate index from current catalog exists in clone");
(before != after).then(|| ProfileChangePreview {
profile,
candidate_index: index,
name: candidate.name,
group: candidate.group,
protocol: candidate.protocol,
port: candidate.port,
risk: candidate.risk,
before,
after,
reason: profile_change_reason(profile, candidate, after),
})
})
.collect()
}
pub fn recommended_profile_changes(&self) -> Vec<ProfileChangePreview> {
self.profile_change_preview(self.recommended_profile().profile)
}
pub fn safe_recommended_profile_changes(&self) -> Vec<ProfileChangePreview> {
self.safe_recommended_profile()
.map(|recommendation| self.profile_change_preview(recommendation.profile))
.unwrap_or_default()
}
pub fn plan_fingerprint(&self) -> String {
let mut hash = FNV_OFFSET;
fingerprint_write(&mut hash, "kwall-review-v1");
fingerprint_write(&mut hash, &self.mode().to_string());
fingerprint_write(&mut hash, &self.backend().to_string());
fingerprint_write(&mut hash, self.default_incoming().label());
fingerprint_write(&mut hash, self.default_outgoing().label());
fingerprint_write(
&mut hash,
if self.logging() {
"logging-on"
} else {
"logging-off"
},
);
for rule in &self.rules {
fingerprint_write(&mut hash, &rule.id.to_string());
fingerprint_write(&mut hash, if rule.enabled { "on" } else { "off" });
fingerprint_write(&mut hash, rule.action.label());
fingerprint_write(&mut hash, rule.direction.label());
fingerprint_write(&mut hash, rule.protocol.label());
fingerprint_write(&mut hash, &rule.port.to_string());
fingerprint_write(&mut hash, &rule.note);
}
for exception in &self.review_exceptions {
fingerprint_write(&mut hash, exception.name);
fingerprint_write(&mut hash, exception.group);
fingerprint_write(&mut hash, exception.protocol.label());
fingerprint_write(&mut hash, &exception.port.to_string());
fingerprint_write(&mut hash, exception.risk);
fingerprint_write(&mut hash, exception.note);
}
for command in self.plan() {
fingerprint_write(&mut hash, &command.backend.to_string());
fingerprint_write(&mut hash, command.program);
for arg in &command.args {
fingerprint_write(&mut hash, arg);
}
fingerprint_write(&mut hash, &command.reason);
}
format!("{hash:016x}")
}
pub fn dry_run_report(&self) -> String {
let stats = self.stats();
let findings = self.advisor_findings();
let advisor_summary = AdvisorSeveritySummary::from_findings(&findings);
let advisor_next = AdvisorNextAction::from_findings(&findings);
let plan = self.plan();
let review_order = self.review_order_steps(&findings, stats, plan.len());
let review_gates = self.review_gates();
let review_gate_summary = ReviewGateSummary::from_gates(&review_gates);
let priority_port_queue = self.priority_ports(5);
let backend_summaries = self.backend_plan_summaries();
let profile_impacts = self.profile_impact_summaries();
let recommended_profile = self.recommended_profile();
let recommended_profile_changes = self.recommended_profile_changes();
let safe_recommended_profile = self.safe_recommended_profile();
let safe_recommended_profile_changes = self.safe_recommended_profile_changes();
let management_access = self.management_access_summary();
let group_coverage = self.group_coverage_summaries();
let risk_coverage = self.risk_coverage_summaries();
let at_risk_open_ports = self
.candidates
.iter()
.filter(|candidate| {
matches!(candidate.risk, "high" | "data" | "admin")
&& self.candidate_status_for(candidate) == CandidateStatus::Open
})
.collect::<Vec<_>>();
let at_risk_allowed_ports = self
.candidates
.iter()
.filter(|candidate| {
matches!(candidate.risk, "high" | "data" | "admin")
&& self.candidate_status_for(candidate) == CandidateStatus::Allowed
})
.collect::<Vec<_>>();
let mut report = String::new();
let _ = writeln!(report, "# Kwall Dry-Run Report");
let _ = writeln!(report);
let _ = writeln!(report, "Review-only artifact. No commands were executed.");
let _ = writeln!(report);
let _ = writeln!(report, "## Summary");
let _ = writeln!(report, "- mode: {}", self.mode());
let _ = writeln!(report, "- backend preview: {}", self.backend());
let _ = writeln!(
report,
"- defaults: incoming {}, outgoing {}",
self.default_incoming().label(),
self.default_outgoing().label()
);
let _ = writeln!(
report,
"- logging: {}",
if self.logging() {
"enabled"
} else {
"disabled"
}
);
let _ = writeln!(report, "- staged rules: {}", stats.total_rules);
let _ = writeln!(report, "- active rules: {}", stats.enabled_rules);
let _ = writeln!(report, "- planned commands: {}", stats.plan_commands);
let _ = writeln!(
report,
"- advisor findings: {} total, {} critical, {} warning, {} info, {} good",
advisor_summary.total(),
advisor_summary.critical,
advisor_summary.warning,
advisor_summary.info,
advisor_summary.good
);
let _ = writeln!(
report,
"- review gates: {} pass, {} warn, {} block",
review_gate_summary.pass, review_gate_summary.warn, review_gate_summary.block
);
let _ = writeln!(
report,
"- next advisor action: [{}] {} - {}",
advisor_next.severity.label(),
advisor_next.title,
advisor_next.recommendation
);
let _ = writeln!(
report,
"- known ports: {} total, {} blocked, {} allowed, {} open, {} at-risk open",
stats.known_ports,
stats.known_blocked,
stats.known_allowed,
stats.known_open,
stats.at_risk_open
);
let _ = writeln!(
report,
"- risky allow exceptions: {} reviewed, {} unreviewed",
stats.reviewed_exceptions, stats.unreviewed_at_risk_allowed
);
let _ = writeln!(
report,
"- management access: {} blocked, {} allowed, {} open",
management_access.blocked, management_access.allowed, management_access.open
);
let _ = writeln!(
report,
"- recommended profile: {} (score {}, +{} changes, {}% coverage, {} at-risk open)",
recommended_profile.profile.label(),
recommended_profile.score,
recommended_profile.staged_changes,
recommended_profile.coverage_percent,
recommended_profile.at_risk_open
);
if let Some(warning) = &recommended_profile.warning {
let _ = writeln!(report, "- recommended profile warning: {warning}");
}
if let Some(recommendation) = &safe_recommended_profile {
let _ = writeln!(
report,
"- management-preserving profile: {} (score {}, +{} changes, {}% coverage, {} at-risk open)",
recommendation.profile.label(),
recommendation.score,
recommendation.staged_changes,
recommendation.coverage_percent,
recommendation.at_risk_open
);
} else {
let _ = writeln!(
report,
"- management-preserving profile: none available without keeping all management paths blocked"
);
}
let _ = writeln!(report, "- review fingerprint: {}", self.plan_fingerprint());
let _ = writeln!(
report,
"- high-risk known-port coverage: {}%",
stats.coverage_percent
);
let _ = writeln!(
report,
"- review readiness: {}% ({} / {})",
stats.risk_percent,
stats.readiness_grade(),
stats.readiness_label()
);
let _ = writeln!(report);
let _ = writeln!(report, "## Suggested Review Order");
for (index, step) in review_order.iter().enumerate() {
let _ = writeln!(report, "{}. {} - {}", index + 1, step.title, step.detail);
}
let _ = writeln!(report);
let _ = writeln!(report, "## Review Gates");
for gate in &review_gates {
let _ = writeln!(report, "- [{}] {}", gate.status.label(), gate.title);
let _ = writeln!(report, " detail: {}", gate.detail);
let _ = writeln!(report, " action: {}", gate.action);
}
let _ = writeln!(report);
let _ = writeln!(report, "## Priority Port Queue");
if priority_port_queue.is_empty() {
let _ = writeln!(report, "- none");
} else {
for (index, priority) in priority_port_queue.iter().enumerate() {
let _ = writeln!(
report,
"{}. [{}] {} {}/{} {}/{} - {} | score {} | reason: {}",
index + 1,
priority.status.label(),
priority.name,
priority.protocol.label(),
priority.port,
priority.group,
priority.risk,
priority.recommendation,
priority.score,
priority.reason
);
}
}
let _ = writeln!(report);
let _ = writeln!(report, "## Reviewed Exceptions");
if self.review_exceptions().is_empty() {
let _ = writeln!(report, "- none");
} else {
for exception in self.review_exceptions() {
let _ = writeln!(
report,
"- {} {}/{} {}/{} - {}",
exception.name,
exception.protocol.label(),
exception.port,
exception.group,
exception.risk,
exception.note
);
}
}
let _ = writeln!(report);
let _ = writeln!(report, "## Staged Rules");
if self.rules.is_empty() {
let _ = writeln!(report, "- none");
} else {
for rule in &self.rules {
let _ = writeln!(
report,
"- #{:02} [{}] {} {} {} {} - {}",
rule.id,
if rule.enabled { "on" } else { "off" },
rule.action.label(),
rule.direction.label(),
rule.protocol,
rule.port,
rule.note
);
}
}
let _ = writeln!(report);
let _ = writeln!(report, "## Advisor Findings");
for finding in findings {
let _ = writeln!(report, "- [{}] {}", finding.severity.label(), finding.title);
let _ = writeln!(report, " detail: {}", finding.detail);
let _ = writeln!(report, " recommendation: {}", finding.recommendation);
}
let _ = writeln!(report);
let _ = writeln!(report, "## Management Access Guard");
let _ = writeln!(
report,
"- {} management/control-plane path(s): {} blocked, {} allowed, {} open",
management_access.total,
management_access.blocked,
management_access.allowed,
management_access.open
);
let _ = writeln!(
report,
"- action: {}",
if management_access.all_blocked() {
"confirm local console, break-glass, or out-of-band access"
} else {
"review exposed management paths separately"
}
);
for candidate in self
.candidates
.iter()
.filter(|candidate| matches!(candidate.name, "SSH" | "RDP" | "VNC" | "Kubernetes"))
{
let status = self.candidate_status_for(candidate);
let _ = writeln!(
report,
"- [{}] {} {}/{} {}/{} - {}",
status.label(),
candidate.name,
candidate.protocol.label(),
candidate.port,
candidate.group,
candidate.risk,
candidate.detail
);
}
let _ = writeln!(report);
let _ = writeln!(report, "## Known Port Groups");
for group in group_coverage {
let _ = writeln!(
report,
"- {}: {} total, {} blocked, {} allowed, {} open, {} at-risk open",
group.group,
group.total,
group.blocked,
group.allowed,
group.open,
group.at_risk_open
);
}
let _ = writeln!(report);
let _ = writeln!(report, "## Known Port Risks");
for risk in risk_coverage {
let _ = writeln!(
report,
"- {}: {} total, {} blocked, {} allowed, {} open",
risk.risk, risk.total, risk.blocked, risk.allowed, risk.open
);
}
let _ = writeln!(report);
let _ = writeln!(report, "## At-Risk Open Ports");
if at_risk_open_ports.is_empty() {
let _ = writeln!(report, "- none");
} else {
for candidate in at_risk_open_ports {
let _ = writeln!(
report,
"- {} {}/{} {}/{} - {} | action: {}",
candidate.name,
candidate.protocol.label(),
candidate.port,
candidate.group,
candidate.risk,
candidate.detail,
self.candidate_recommendation_for(candidate, CandidateStatus::Open)
);
}
}
let _ = writeln!(report);
let _ = writeln!(report, "## At-Risk Allowed Ports");
if at_risk_allowed_ports.is_empty() {
let _ = writeln!(report, "- none");
} else {
for candidate in at_risk_allowed_ports {
let _ = writeln!(
report,
"- {} {}/{} {}/{} - {} | action: {}",
candidate.name,
candidate.protocol.label(),
candidate.port,
candidate.group,
candidate.risk,
candidate.detail,
self.candidate_recommendation_for(candidate, CandidateStatus::Allowed)
);
}
}
let _ = writeln!(report);
let _ = writeln!(report, "## Known Port Catalog");
for candidate in &self.candidates {
let status = self.candidate_status_for(candidate);
let _ = writeln!(
report,
"- [{}] {} {}/{} {}/{} aliases {} - {} | action: {}",
status.label(),
candidate.name,
candidate.protocol.label(),
candidate.port,
candidate.group,
candidate.risk,
candidate.aliases.join(", "),
candidate.detail,
self.candidate_recommendation_for(candidate, status)
);
}
let _ = writeln!(report);
let _ = writeln!(report, "## Command Preview");
for (index, command) in plan.iter().enumerate() {
let _ = writeln!(report, "{}. {}", index + 1, command.shell_line());
let _ = writeln!(report, " reason: {}", command.reason);
}
let _ = writeln!(report);
let _ = writeln!(report, "## Backend Comparison");
for summary in backend_summaries {
let _ = writeln!(
report,
"- {}: {} command(s), first: {}",
summary.backend, summary.commands, summary.first_command
);
}
let _ = writeln!(report);
let _ = writeln!(report, "## Recommended Profile");
let _ = writeln!(
report,
"- strongest hardening: {}: score {}, +{} staged change(s), {} resulting rule(s), {}% coverage, {} at-risk open",
recommended_profile.profile.label(),
recommended_profile.score,
recommended_profile.staged_changes,
recommended_profile.resulting_rules,
recommended_profile.coverage_percent,
recommended_profile.at_risk_open
);
let _ = writeln!(report, " reason: {}", recommended_profile.reason);
if let Some(warning) = &recommended_profile.warning {
let _ = writeln!(report, " warning: {warning}");
}
let _ = writeln!(
report,
" management access after profile: {} blocked, {} allowed, {} open",
recommended_profile.management_blocked,
recommended_profile.management_allowed,
recommended_profile.management_open
);
if let Some(recommendation) = &safe_recommended_profile {
let _ = writeln!(
report,
"- management-preserving alternative: {}: score {}, +{} staged change(s), {} resulting rule(s), {}% coverage, {} at-risk open",
recommendation.profile.label(),
recommendation.score,
recommendation.staged_changes,
recommendation.resulting_rules,
recommendation.coverage_percent,
recommendation.at_risk_open
);
let _ = writeln!(report, " reason: {}", recommendation.reason);
if let Some(warning) = &recommendation.warning {
let _ = writeln!(report, " warning: {warning}");
}
let _ = writeln!(
report,
" management access after profile: {} blocked, {} allowed, {} open",
recommendation.management_blocked,
recommendation.management_allowed,
recommendation.management_open
);
} else {
let _ = writeln!(
report,
"- management-preserving alternative: none available from built-in profiles"
);
}
let _ = writeln!(report);
let _ = writeln!(report, "## Recommended Profile Changes");
if recommended_profile_changes.is_empty() {
let _ = writeln!(report, "- none");
} else {
for change in &recommended_profile_changes {
let _ = writeln!(
report,
"- {} {}/{} {}/{}: {} -> {} | {}",
change.name,
change.protocol.label(),
change.port,
change.group,
change.risk,
change.before.label(),
change.after.label(),
change.reason
);
}
}
let _ = writeln!(report);
let _ = writeln!(report, "## Management-Preserving Profile Changes");
if safe_recommended_profile_changes.is_empty() {
let _ = writeln!(report, "- none");
} else {
for change in &safe_recommended_profile_changes {
let _ = writeln!(
report,
"- {} {}/{} {}/{}: {} -> {} | {}",
change.name,
change.protocol.label(),
change.port,
change.group,
change.risk,
change.before.label(),
change.after.label(),
change.reason
);
}
}
let _ = writeln!(report);
let _ = writeln!(report, "## Profile Impact Preview");
for impact in profile_impacts {
let _ = writeln!(
report,
"- {}: {} staged change(s), {} resulting rule(s), {}% coverage, {} at-risk open, management {} blocked/{} allowed/{} open{}",
impact.profile.label(),
impact.staged_changes,
impact.resulting_rules,
impact.coverage_percent,
impact.at_risk_open,
impact.management_blocked,
impact.management_allowed,
impact.management_open,
if impact.management_all_blocked {
" (full lockout)"
} else {
""
}
);
}
let _ = writeln!(report);
let _ = writeln!(report, "## Recent Activity");
for message in self.audit_log.iter().rev().take(12).rev() {
let _ = writeln!(report, "- {message}");
}
report
}
pub fn dry_run_script(&self) -> String {
let plan = self.plan();
let mut script = String::new();
let _ = writeln!(script, "#!/bin/sh");
let _ = writeln!(script, "set -eu");
let _ = writeln!(script);
let _ = writeln!(script, "cat <<'KNOTT_FIREWALL_NOTICE'");
let _ = writeln!(script, "Kwall dry-run preview only.");
let _ = writeln!(script, "No firewall commands are executed by this file.");
let _ = writeln!(
script,
"Review the commented commands below before any manual use."
);
let _ = writeln!(script, "KNOTT_FIREWALL_NOTICE");
let _ = writeln!(script, "exit 0");
let _ = writeln!(script);
let _ = writeln!(script, "# Backend preview: {}", self.backend());
let _ = writeln!(script, "# Mode: {}", self.mode());
let _ = writeln!(script, "# Review fingerprint: {}", self.plan_fingerprint());
let _ = writeln!(
script,
"# Defaults: incoming {}, outgoing {}, logging {}",
self.default_incoming().label(),
self.default_outgoing().label(),
if self.logging() { "on" } else { "off" }
);
let _ = writeln!(script);
let _ = writeln!(script, "# Command preview. Lines below are comments.");
for (index, command) in plan.iter().enumerate() {
let _ = writeln!(script, "# {:02}. {}", index + 1, command.reason);
let _ = writeln!(script, "# {}", command.shell_line());
}
script
}
pub fn dry_run_manifest(&self) -> String {
let stats = self.stats();
let findings = self.advisor_findings();
let advisor_summary = AdvisorSeveritySummary::from_findings(&findings);
let advisor_next = AdvisorNextAction::from_findings(&findings);
let plan = self.plan();
let review_order = self.review_order_steps(&findings, stats, plan.len());
let review_gates = self.review_gates();
let review_gate_summary = ReviewGateSummary::from_gates(&review_gates);
let priority_port_queue = self.priority_ports(5);
let backend_summaries = self.backend_plan_summaries();
let profile_impacts = self.profile_impact_summaries();
let recommended_profile = self.recommended_profile();
let recommended_profile_changes = self.recommended_profile_changes();
let safe_recommended_profile = self.safe_recommended_profile();
let safe_recommended_profile_changes = self.safe_recommended_profile_changes();
let management_access = self.management_access_summary();
let group_coverage = self.group_coverage_summaries();
let risk_coverage = self.risk_coverage_summaries();
let at_risk_open_ports = self
.candidates
.iter()
.filter(|candidate| {
matches!(candidate.risk, "high" | "data" | "admin")
&& self.candidate_status_for(candidate) == CandidateStatus::Open
})
.collect::<Vec<_>>();
let at_risk_allowed_ports = self
.candidates
.iter()
.filter(|candidate| {
matches!(candidate.risk, "high" | "data" | "admin")
&& self.candidate_status_for(candidate) == CandidateStatus::Allowed
})
.collect::<Vec<_>>();
let mut manifest = String::new();
let _ = writeln!(manifest, "{{");
let _ = writeln!(
manifest,
" \"schema\": {},",
json_string("knott.firewall.dry-run.v1")
);
let _ = writeln!(manifest, " \"review_only\": true,");
let _ = writeln!(manifest, " \"executes_commands\": false,");
let _ = writeln!(
manifest,
" \"fingerprint\": {},",
json_string(&self.plan_fingerprint())
);
let _ = writeln!(
manifest,
" \"mode\": {},",
json_string(&self.mode().to_string())
);
let _ = writeln!(
manifest,
" \"backend\": {},",
json_string(&self.backend().to_string())
);
let _ = writeln!(manifest, " \"defaults\": {{");
let _ = writeln!(
manifest,
" \"incoming\": {},",
json_string(self.default_incoming().label())
);
let _ = writeln!(
manifest,
" \"outgoing\": {}",
json_string(self.default_outgoing().label())
);
let _ = writeln!(manifest, " }},");
let _ = writeln!(manifest, " \"logging\": {},", self.logging());
let _ = writeln!(manifest, " \"stats\": {{");
let _ = writeln!(manifest, " \"total_rules\": {},", stats.total_rules);
let _ = writeln!(manifest, " \"enabled_rules\": {},", stats.enabled_rules);
let _ = writeln!(manifest, " \"allow_rules\": {},", stats.allow_rules);
let _ = writeln!(manifest, " \"deny_rules\": {},", stats.deny_rules);
let _ = writeln!(manifest, " \"reject_rules\": {},", stats.reject_rules);
let _ = writeln!(manifest, " \"inbound_rules\": {},", stats.inbound_rules);
let _ = writeln!(
manifest,
" \"outbound_rules\": {},",
stats.outbound_rules
);
let _ = writeln!(
manifest,
" \"high_risk_blocks\": {},",
stats.high_risk_blocks
);
let _ = writeln!(manifest, " \"known_ports\": {},", stats.known_ports);
let _ = writeln!(manifest, " \"known_blocked\": {},", stats.known_blocked);
let _ = writeln!(manifest, " \"known_allowed\": {},", stats.known_allowed);
let _ = writeln!(manifest, " \"known_open\": {},", stats.known_open);
let _ = writeln!(manifest, " \"at_risk_open\": {},", stats.at_risk_open);
let _ = writeln!(
manifest,
" \"at_risk_allowed\": {},",
stats.at_risk_allowed
);
let _ = writeln!(
manifest,
" \"reviewed_exceptions\": {},",
stats.reviewed_exceptions
);
let _ = writeln!(
manifest,
" \"unreviewed_at_risk_allowed\": {},",
stats.unreviewed_at_risk_allowed
);
let _ = writeln!(manifest, " \"plan_commands\": {},", stats.plan_commands);
let _ = writeln!(
manifest,
" \"coverage_percent\": {},",
stats.coverage_percent
);
let _ = writeln!(manifest, " \"risk_percent\": {},", stats.risk_percent);
let _ = writeln!(
manifest,
" \"readiness_grade\": {},",
json_string(stats.readiness_grade())
);
let _ = writeln!(
manifest,
" \"readiness_label\": {}",
json_string(stats.readiness_label())
);
let _ = writeln!(manifest, " }},");
let _ = writeln!(manifest, " \"advisor_next_action\": {{");
let _ = writeln!(
manifest,
" \"severity\": {},",
json_string(advisor_next.severity.label())
);
let _ = writeln!(
manifest,
" \"title\": {},",
json_string(&advisor_next.title)
);
let _ = writeln!(
manifest,
" \"recommendation\": {}",
json_string(&advisor_next.recommendation)
);
let _ = writeln!(manifest, " }},");
let _ = writeln!(manifest, " \"advisor_summary\": {{");
let _ = writeln!(manifest, " \"total\": {},", advisor_summary.total());
let _ = writeln!(manifest, " \"critical\": {},", advisor_summary.critical);
let _ = writeln!(manifest, " \"warning\": {},", advisor_summary.warning);
let _ = writeln!(manifest, " \"info\": {},", advisor_summary.info);
let _ = writeln!(manifest, " \"good\": {}", advisor_summary.good);
let _ = writeln!(manifest, " }},");
let _ = writeln!(manifest, " \"management_access\": {{");
let _ = writeln!(manifest, " \"total\": {},", management_access.total);
let _ = writeln!(manifest, " \"blocked\": {},", management_access.blocked);
let _ = writeln!(manifest, " \"allowed\": {},", management_access.allowed);
let _ = writeln!(manifest, " \"open\": {},", management_access.open);
let _ = writeln!(
manifest,
" \"all_blocked\": {},",
management_access.all_blocked()
);
let _ = writeln!(manifest, " \"candidates\": [");
let management_candidates = self
.candidates
.iter()
.filter(|candidate| matches!(candidate.name, "SSH" | "RDP" | "VNC" | "Kubernetes"))
.collect::<Vec<_>>();
for (index, candidate) in management_candidates.iter().enumerate() {
let comma = if index + 1 == management_candidates.len() {
""
} else {
","
};
let status = self.candidate_status_for(candidate);
let _ = writeln!(
manifest,
" {{\"name\": {}, \"group\": {}, \"risk\": {}, \"protocol\": {}, \"port\": {}, \"status\": {}}}{}",
json_string(candidate.name),
json_string(candidate.group),
json_string(candidate.risk),
json_string(candidate.protocol.label()),
json_string(&candidate.port.to_string()),
json_string(status.label()),
comma
);
}
let _ = writeln!(manifest, " ]");
let _ = writeln!(manifest, " }},");
let _ = writeln!(manifest, " \"review_gate_summary\": {{");
let _ = writeln!(manifest, " \"pass\": {},", review_gate_summary.pass);
let _ = writeln!(manifest, " \"warn\": {},", review_gate_summary.warn);
let _ = writeln!(manifest, " \"block\": {}", review_gate_summary.block);
let _ = writeln!(manifest, " }},");
let _ = writeln!(manifest, " \"review_order\": [");
for (index, step) in review_order.iter().enumerate() {
let comma = if index + 1 == review_order.len() {
""
} else {
","
};
let _ = writeln!(
manifest,
" {{\"index\": {}, \"title\": {}, \"detail\": {}}}{}",
index + 1,
json_string(&step.title),
json_string(&step.detail),
comma
);
}
let _ = writeln!(manifest, " ],");
let _ = writeln!(manifest, " \"review_gates\": [");
for (index, gate) in review_gates.iter().enumerate() {
let comma = if index + 1 == review_gates.len() {
""
} else {
","
};
let _ = writeln!(
manifest,
" {{\"index\": {}, \"status\": {}, \"title\": {}, \"detail\": {}, \"action\": {}}}{}",
index + 1,
json_string(gate.status.label()),
json_string(&gate.title),
json_string(&gate.detail),
json_string(&gate.action),
comma
);
}
let _ = writeln!(manifest, " ],");
let _ = writeln!(manifest, " \"priority_port_queue\": [");
for (index, priority) in priority_port_queue.iter().enumerate() {
let comma = if index + 1 == priority_port_queue.len() {
""
} else {
","
};
let _ = writeln!(
manifest,
" {{\"index\": {}, \"name\": {}, \"group\": {}, \"risk\": {}, \"protocol\": {}, \"port\": {}, \"status\": {}, \"candidate_index\": {}, \"score\": {}, \"reason\": {}, \"recommendation\": {}}}{}",
index + 1,
json_string(priority.name),
json_string(priority.group),
json_string(priority.risk),
json_string(priority.protocol.label()),
json_string(&priority.port.to_string()),
json_string(priority.status.label()),
priority.index,
priority.score,
json_string(&priority.reason),
json_string(priority.recommendation),
comma
);
}
let _ = writeln!(manifest, " ],");
let _ = writeln!(manifest, " \"reviewed_exceptions\": [");
for (index, exception) in self.review_exceptions().iter().enumerate() {
let comma = if index + 1 == self.review_exceptions().len() {
""
} else {
","
};
let _ = writeln!(
manifest,
" {{\"name\": {}, \"group\": {}, \"risk\": {}, \"protocol\": {}, \"port\": {}, \"note\": {}}}{}",
json_string(exception.name),
json_string(exception.group),
json_string(exception.risk),
json_string(exception.protocol.label()),
json_string(&exception.port.to_string()),
json_string(exception.note),
comma
);
}
let _ = writeln!(manifest, " ],");
let _ = writeln!(manifest, " \"rules\": [");
for (index, rule) in self.rules.iter().enumerate() {
let comma = if index + 1 == self.rules.len() {
""
} else {
","
};
let _ = writeln!(
manifest,
" {{\"id\": {}, \"enabled\": {}, \"action\": {}, \"direction\": {}, \"protocol\": {}, \"port\": {}, \"note\": {}}}{}",
rule.id,
rule.enabled,
json_string(rule.action.label()),
json_string(rule.direction.label()),
json_string(rule.protocol.label()),
json_string(&rule.port.to_string()),
json_string(&rule.note),
comma
);
}
let _ = writeln!(manifest, " ],");
let _ = writeln!(manifest, " \"known_port_catalog\": [");
for (index, candidate) in self.candidates.iter().enumerate() {
let comma = if index + 1 == self.candidates.len() {
""
} else {
","
};
let status = self.candidate_status_for(candidate);
let _ = writeln!(
manifest,
" {{\"name\": {}, \"group\": {}, \"risk\": {}, \"aliases\": [{}], \"protocol\": {}, \"port\": {}, \"status\": {}, \"review_exception\": {}, \"detail\": {}, \"recommendation\": {}}}{}",
json_string(candidate.name),
json_string(candidate.group),
json_string(candidate.risk),
json_array(candidate.aliases),
json_string(candidate.protocol.label()),
json_string(&candidate.port.to_string()),
json_string(status.label()),
self.has_review_exception(candidate),
json_string(candidate.detail),
json_string(self.candidate_recommendation_for(candidate, status)),
comma
);
}
let _ = writeln!(manifest, " ],");
let _ = writeln!(manifest, " \"at_risk_open_ports\": [");
for (index, candidate) in at_risk_open_ports.iter().enumerate() {
let comma = if index + 1 == at_risk_open_ports.len() {
""
} else {
","
};
let _ = writeln!(
manifest,
" {{\"name\": {}, \"group\": {}, \"risk\": {}, \"protocol\": {}, \"port\": {}, \"status\": {}, \"detail\": {}, \"recommendation\": {}}}{}",
json_string(candidate.name),
json_string(candidate.group),
json_string(candidate.risk),
json_string(candidate.protocol.label()),
json_string(&candidate.port.to_string()),
json_string(CandidateStatus::Open.label()),
json_string(candidate.detail),
json_string(self.candidate_recommendation_for(candidate, CandidateStatus::Open)),
comma
);
}
let _ = writeln!(manifest, " ],");
let _ = writeln!(manifest, " \"at_risk_allowed_ports\": [");
for (index, candidate) in at_risk_allowed_ports.iter().enumerate() {
let comma = if index + 1 == at_risk_allowed_ports.len() {
""
} else {
","
};
let _ = writeln!(
manifest,
" {{\"name\": {}, \"group\": {}, \"risk\": {}, \"protocol\": {}, \"port\": {}, \"status\": {}, \"review_exception\": {}, \"detail\": {}, \"recommendation\": {}}}{}",
json_string(candidate.name),
json_string(candidate.group),
json_string(candidate.risk),
json_string(candidate.protocol.label()),
json_string(&candidate.port.to_string()),
json_string(CandidateStatus::Allowed.label()),
self.has_review_exception(candidate),
json_string(candidate.detail),
json_string(self.candidate_recommendation_for(candidate, CandidateStatus::Allowed)),
comma
);
}
let _ = writeln!(manifest, " ],");
let _ = writeln!(manifest, " \"group_coverage\": [");
for (index, group) in group_coverage.iter().enumerate() {
let comma = if index + 1 == group_coverage.len() {
""
} else {
","
};
let _ = writeln!(
manifest,
" {{\"group\": {}, \"total\": {}, \"blocked\": {}, \"allowed\": {}, \"open\": {}, \"at_risk_open\": {}}}{}",
json_string(group.group),
group.total,
group.blocked,
group.allowed,
group.open,
group.at_risk_open,
comma
);
}
let _ = writeln!(manifest, " ],");
let _ = writeln!(manifest, " \"risk_coverage\": [");
for (index, risk) in risk_coverage.iter().enumerate() {
let comma = if index + 1 == risk_coverage.len() {
""
} else {
","
};
let _ = writeln!(
manifest,
" {{\"risk\": {}, \"total\": {}, \"blocked\": {}, \"allowed\": {}, \"open\": {}}}{}",
json_string(risk.risk),
risk.total,
risk.blocked,
risk.allowed,
risk.open,
comma
);
}
let _ = writeln!(manifest, " ],");
let _ = writeln!(manifest, " \"backend_comparison\": [");
for (index, summary) in backend_summaries.iter().enumerate() {
let comma = if index + 1 == backend_summaries.len() {
""
} else {
","
};
let _ = writeln!(
manifest,
" {{\"backend\": {}, \"commands\": {}, \"first_command\": {}}}{}",
json_string(&summary.backend.to_string()),
summary.commands,
json_string(&summary.first_command),
comma
);
}
let _ = writeln!(manifest, " ],");
let _ = writeln!(manifest, " \"recommended_profile\": {{");
let _ = writeln!(
manifest,
" \"profile\": {},",
json_string(recommended_profile.profile.label())
);
let _ = writeln!(manifest, " \"score\": {},", recommended_profile.score);
let _ = writeln!(
manifest,
" \"staged_changes\": {},",
recommended_profile.staged_changes
);
let _ = writeln!(
manifest,
" \"resulting_rules\": {},",
recommended_profile.resulting_rules
);
let _ = writeln!(
manifest,
" \"coverage_percent\": {},",
recommended_profile.coverage_percent
);
let _ = writeln!(
manifest,
" \"at_risk_open\": {},",
recommended_profile.at_risk_open
);
let _ = writeln!(
manifest,
" \"management_blocked\": {},",
recommended_profile.management_blocked
);
let _ = writeln!(
manifest,
" \"management_allowed\": {},",
recommended_profile.management_allowed
);
let _ = writeln!(
manifest,
" \"management_open\": {},",
recommended_profile.management_open
);
let _ = writeln!(
manifest,
" \"management_all_blocked\": {},",
recommended_profile.management_all_blocked
);
let warning = recommended_profile
.warning
.as_deref()
.map(json_string)
.unwrap_or_else(|| "null".into());
let _ = writeln!(manifest, " \"warning\": {},", warning);
let _ = writeln!(
manifest,
" \"reason\": {}",
json_string(&recommended_profile.reason)
);
let _ = writeln!(manifest, " }},");
if let Some(recommendation) = &safe_recommended_profile {
let _ = writeln!(manifest, " \"safe_recommended_profile\": {{");
let _ = writeln!(
manifest,
" \"profile\": {},",
json_string(recommendation.profile.label())
);
let _ = writeln!(manifest, " \"score\": {},", recommendation.score);
let _ = writeln!(
manifest,
" \"staged_changes\": {},",
recommendation.staged_changes
);
let _ = writeln!(
manifest,
" \"resulting_rules\": {},",
recommendation.resulting_rules
);
let _ = writeln!(
manifest,
" \"coverage_percent\": {},",
recommendation.coverage_percent
);
let _ = writeln!(
manifest,
" \"at_risk_open\": {},",
recommendation.at_risk_open
);
let _ = writeln!(
manifest,
" \"management_blocked\": {},",
recommendation.management_blocked
);
let _ = writeln!(
manifest,
" \"management_allowed\": {},",
recommendation.management_allowed
);
let _ = writeln!(
manifest,
" \"management_open\": {},",
recommendation.management_open
);
let _ = writeln!(
manifest,
" \"management_all_blocked\": {},",
recommendation.management_all_blocked
);
let warning = recommendation
.warning
.as_deref()
.map(json_string)
.unwrap_or_else(|| "null".into());
let _ = writeln!(manifest, " \"warning\": {},", warning);
let _ = writeln!(
manifest,
" \"reason\": {}",
json_string(&recommendation.reason)
);
let _ = writeln!(manifest, " }},");
} else {
let _ = writeln!(manifest, " \"safe_recommended_profile\": null,");
}
let _ = writeln!(manifest, " \"recommended_profile_changes\": [");
for (index, change) in recommended_profile_changes.iter().enumerate() {
let comma = if index + 1 == recommended_profile_changes.len() {
""
} else {
","
};
let _ = writeln!(
manifest,
" {{\"index\": {}, \"candidate_index\": {}, \"name\": {}, \"group\": {}, \"risk\": {}, \"protocol\": {}, \"port\": {}, \"before\": {}, \"after\": {}, \"reason\": {}}}{}",
index + 1,
change.candidate_index,
json_string(change.name),
json_string(change.group),
json_string(change.risk),
json_string(change.protocol.label()),
json_string(&change.port.to_string()),
json_string(change.before.label()),
json_string(change.after.label()),
json_string(&change.reason),
comma
);
}
let _ = writeln!(manifest, " ],");
let _ = writeln!(manifest, " \"safe_recommended_profile_changes\": [");
for (index, change) in safe_recommended_profile_changes.iter().enumerate() {
let comma = if index + 1 == safe_recommended_profile_changes.len() {
""
} else {
","
};
let _ = writeln!(
manifest,
" {{\"index\": {}, \"candidate_index\": {}, \"name\": {}, \"group\": {}, \"risk\": {}, \"protocol\": {}, \"port\": {}, \"before\": {}, \"after\": {}, \"reason\": {}}}{}",
index + 1,
change.candidate_index,
json_string(change.name),
json_string(change.group),
json_string(change.risk),
json_string(change.protocol.label()),
json_string(&change.port.to_string()),
json_string(change.before.label()),
json_string(change.after.label()),
json_string(&change.reason),
comma
);
}
let _ = writeln!(manifest, " ],");
let _ = writeln!(manifest, " \"profile_impacts\": [");
for (index, impact) in profile_impacts.iter().enumerate() {
let comma = if index + 1 == profile_impacts.len() {
""
} else {
","
};
let _ = writeln!(
manifest,
" {{\"profile\": {}, \"staged_changes\": {}, \"resulting_rules\": {}, \"coverage_percent\": {}, \"at_risk_open\": {}, \"management_total\": {}, \"management_blocked\": {}, \"management_allowed\": {}, \"management_open\": {}, \"management_all_blocked\": {}}}{}",
json_string(impact.profile.label()),
impact.staged_changes,
impact.resulting_rules,
impact.coverage_percent,
impact.at_risk_open,
impact.management_total,
impact.management_blocked,
impact.management_allowed,
impact.management_open,
impact.management_all_blocked,
comma
);
}
let _ = writeln!(manifest, " ],");
let _ = writeln!(manifest, " \"advisor_findings\": [");
for (index, finding) in findings.iter().enumerate() {
let comma = if index + 1 == findings.len() { "" } else { "," };
let _ = writeln!(
manifest,
" {{\"severity\": {}, \"title\": {}, \"detail\": {}, \"recommendation\": {}}}{}",
json_string(finding.severity.label()),
json_string(&finding.title),
json_string(&finding.detail),
json_string(&finding.recommendation),
comma
);
}
let _ = writeln!(manifest, " ],");
let _ = writeln!(manifest, " \"commands\": [");
for (index, command) in plan.iter().enumerate() {
let comma = if index + 1 == plan.len() { "" } else { "," };
let args = command
.args
.iter()
.map(|arg| json_string(arg))
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(
manifest,
" {{\"index\": {}, \"backend\": {}, \"program\": {}, \"args\": [{}], \"shell\": {}, \"reason\": {}}}{}",
index + 1,
json_string(&command.backend.to_string()),
json_string(command.program),
args,
json_string(&command.shell_line()),
json_string(&command.reason),
comma
);
}
let _ = writeln!(manifest, " ]");
let _ = writeln!(manifest, "}}");
manifest
}
fn stage_rule(
&mut self,
action: RuleAction,
direction: Direction,
protocol: Protocol,
port: PortSpec,
note: &str,
) {
let rule = FirewallRule {
id: self.next_id,
enabled: true,
action,
direction,
protocol,
port,
note: note.to_string(),
};
self.next_id += 1;
self.audit_log.push(format!(
"Staged {} {} {} {}.",
action.label(),
direction.label(),
protocol.label(),
port
));
self.rules.push(rule);
}
fn stage_starter_rules(&mut self) {
self.stage_rule(
RuleAction::Deny,
Direction::In,
Protocol::Tcp,
PortSpec::Single(23),
"Block Telnet by default",
);
self.stage_rule(
RuleAction::Deny,
Direction::In,
Protocol::Tcp,
PortSpec::Single(445),
"Block SMB exposure",
);
self.stage_rule(
RuleAction::Reject,
Direction::Out,
Protocol::Tcp,
PortSpec::Single(25),
"Reject outbound SMTP by default",
);
}
fn save_checkpoint(&mut self) {
self.history.push(ControllerSnapshot {
backend: self.backend,
default_incoming: self.default_incoming,
default_outgoing: self.default_outgoing,
logging: self.logging,
rules: self.rules.clone(),
review_exceptions: self.review_exceptions.clone(),
next_id: self.next_id,
});
if self.history.len() > 64 {
self.history.remove(0);
}
}
fn has_rule(
&self,
action: RuleAction,
direction: Direction,
protocol: Protocol,
port: PortSpec,
) -> bool {
self.rules.iter().any(|rule| {
rule.action == action
&& rule.direction == direction
&& rule.protocol == protocol
&& rule.port == port
})
}
fn candidate_status_for(&self, candidate: &PortCandidate) -> CandidateStatus {
if self.rules.iter().any(|rule| {
rule.enabled
&& rule.direction == Direction::In
&& matches!(rule.action, RuleAction::Deny | RuleAction::Reject)
&& rule.protocol == candidate.protocol
&& rule.port == candidate.port
}) {
return CandidateStatus::Blocked;
}
if self.rules.iter().any(|rule| {
rule.enabled
&& rule.direction == Direction::In
&& rule.action == RuleAction::Allow
&& rule.protocol == candidate.protocol
&& rule.port == candidate.port
}) {
return CandidateStatus::Allowed;
}
CandidateStatus::Open
}
fn has_review_exception(&self, candidate: &PortCandidate) -> bool {
self.review_exceptions
.iter()
.any(|exception| exception.matches_candidate(candidate))
}
fn remove_review_exception_for(&mut self, candidate: &PortCandidate) -> bool {
let before = self.review_exceptions.len();
self.review_exceptions
.retain(|exception| !exception.matches_candidate(candidate));
before != self.review_exceptions.len()
}
fn prune_review_exceptions(&mut self) {
let allowed_targets = self
.candidates
.iter()
.filter(|candidate| {
matches!(candidate.risk, "high" | "data" | "admin")
&& self.candidate_status_for(candidate) == CandidateStatus::Allowed
})
.map(|candidate| (candidate.protocol, candidate.port))
.collect::<Vec<_>>();
self.review_exceptions.retain(|exception| {
allowed_targets
.iter()
.any(|(protocol, port)| exception.protocol == *protocol && exception.port == *port)
});
}
fn candidate_recommendation_for(
&self,
candidate: &PortCandidate,
status: CandidateStatus,
) -> &'static str {
let at_risk = matches!(candidate.risk, "high" | "data" | "admin");
match (at_risk, status) {
(true, CandidateStatus::Allowed) if self.has_review_exception(candidate) => {
"reviewed exception recorded; verify scope and expiry"
}
(true, CandidateStatus::Allowed) => "block unless intentionally exposed",
(true, CandidateStatus::Open) => "block or document an exception",
(_, CandidateStatus::Blocked) => "covered by block",
(false, CandidateStatus::Allowed) => "confirm service exposure is intentional",
(false, CandidateStatus::Open) => "review if this service should be exposed",
}
}
fn priority_score_for(
&self,
candidate: &PortCandidate,
status: CandidateStatus,
) -> Option<(u16, String)> {
if !matches!(candidate.risk, "high" | "data" | "admin")
|| status == CandidateStatus::Blocked
|| (status == CandidateStatus::Allowed && self.has_review_exception(candidate))
{
return None;
}
let mut score = 0;
let mut reasons = Vec::new();
match status {
CandidateStatus::Allowed => {
score += 120;
reasons.push("unreviewed explicit allow");
}
CandidateStatus::Open => {
score += 70;
reasons.push("open known service");
}
CandidateStatus::Blocked => {}
}
match candidate.risk {
"admin" => {
score += 35;
reasons.push("admin-control risk");
}
"data" => {
score += 30;
reasons.push("data exposure risk");
}
"high" => {
score += 25;
reasons.push("high-risk service");
}
_ => {}
}
match candidate.group {
"admin" => {
score += 15;
reasons.push("admin group");
}
"data" => {
score += 12;
reasons.push("data group");
}
"file" | "legacy" | "orchestration" => {
score += 10;
reasons.push("broad exposure group");
}
_ => {}
}
if matches!(candidate.port, PortSpec::Range(_, _)) {
score += 8;
reasons.push("port range");
}
Some((score, reasons.join("; ")))
}
fn block_candidate_value(&mut self, candidate: &PortCandidate) -> bool {
if self.candidate_status_for(candidate) == CandidateStatus::Blocked {
return false;
}
self.remove_candidate_action(candidate.protocol, candidate.port, RuleAction::Allow);
self.remove_review_exception_for(candidate);
self.stage_rule(
RuleAction::Deny,
Direction::In,
candidate.protocol,
candidate.port,
&format!("Block {}", candidate.name),
);
true
}
fn allow_candidate_named(&mut self, name: &str) -> bool {
let Some(candidate) = self
.candidates
.iter()
.find(|candidate| candidate.name == name)
.cloned()
else {
return false;
};
if self.candidate_status_for(&candidate) == CandidateStatus::Allowed {
return false;
}
self.allow_candidate_value(&candidate)
}
fn allow_candidate_value(&mut self, candidate: &PortCandidate) -> bool {
if self.candidate_status_for(candidate) == CandidateStatus::Allowed {
return false;
}
self.remove_candidate_blocks(candidate.protocol, candidate.port);
self.stage_rule(
RuleAction::Allow,
Direction::In,
candidate.protocol,
candidate.port,
&format!("Allow {}", candidate.name),
);
true
}
fn remove_candidate_blocks(&mut self, protocol: Protocol, port: PortSpec) -> usize {
let before = self.rules.len();
self.rules.retain(|rule| {
!(rule.direction == Direction::In
&& matches!(rule.action, RuleAction::Deny | RuleAction::Reject)
&& rule.protocol == protocol
&& rule.port == port)
});
before.saturating_sub(self.rules.len())
}
fn remove_candidate_action(
&mut self,
protocol: Protocol,
port: PortSpec,
action: RuleAction,
) -> usize {
let before = self.rules.len();
self.rules.retain(|rule| {
!(rule.direction == Direction::In
&& rule.action == action
&& rule.protocol == protocol
&& rule.port == port)
});
before.saturating_sub(self.rules.len())
}
fn base_policy_plan(&self) -> Vec<PlannedCommand> {
match self.backend {
Backend::Ufw => vec![
PlannedCommand {
backend: self.backend,
program: "ufw",
args: vec![
"default".into(),
self.default_incoming.label().into(),
"incoming".into(),
],
reason: "default incoming policy".into(),
},
PlannedCommand {
backend: self.backend,
program: "ufw",
args: vec![
"default".into(),
self.default_outgoing.label().into(),
"outgoing".into(),
],
reason: "default outgoing policy".into(),
},
],
Backend::Iptables => vec![
PlannedCommand {
backend: self.backend,
program: "iptables",
args: vec![
"-P".into(),
"INPUT".into(),
self.default_incoming.iptables_target().into(),
],
reason: "default incoming policy".into(),
},
PlannedCommand {
backend: self.backend,
program: "iptables",
args: vec![
"-P".into(),
"OUTPUT".into(),
self.default_outgoing.iptables_target().into(),
],
reason: "default outgoing policy".into(),
},
],
Backend::Nftables => vec![
PlannedCommand {
backend: self.backend,
program: "nft",
args: vec![
"add".into(),
"chain".into(),
"inet".into(),
"filter".into(),
"input".into(),
"{ type filter hook input priority 0; policy".into(),
self.default_incoming.nft_policy().into(),
"; }".into(),
],
reason: "default incoming policy".into(),
},
PlannedCommand {
backend: self.backend,
program: "nft",
args: vec![
"add".into(),
"chain".into(),
"inet".into(),
"filter".into(),
"output".into(),
"{ type filter hook output priority 0; policy".into(),
self.default_outgoing.nft_policy().into(),
"; }".into(),
],
reason: "default outgoing policy".into(),
},
],
}
}
fn logging_plan(&self) -> Vec<PlannedCommand> {
match self.backend {
Backend::Ufw => vec![PlannedCommand {
backend: self.backend,
program: "ufw",
args: vec!["logging".into(), "on".into()],
reason: "enable firewall logging".into(),
}],
Backend::Iptables => vec![PlannedCommand {
backend: self.backend,
program: "iptables",
args: vec![
"-A".into(),
"INPUT".into(),
"-m".into(),
"limit".into(),
"--limit".into(),
"6/min".into(),
"-j".into(),
"LOG".into(),
"--log-prefix".into(),
"knott-fw denied: ".into(),
],
reason: "rate-limited deny logging".into(),
}],
Backend::Nftables => vec![PlannedCommand {
backend: self.backend,
program: "nft",
args: vec![
"add".into(),
"rule".into(),
"inet".into(),
"filter".into(),
"input".into(),
"limit".into(),
"rate".into(),
"6/minute".into(),
"log".into(),
"prefix".into(),
"knott-fw denied: ".into(),
],
reason: "rate-limited deny logging".into(),
}],
}
}
fn rule_plan(&self, rule: &FirewallRule) -> Vec<PlannedCommand> {
match self.backend {
Backend::Ufw => self.ufw_rule_plan(rule),
Backend::Iptables => self.iptables_rule_plan(rule),
Backend::Nftables => self.nft_rule_plan(rule),
}
}
fn ufw_rule_plan(&self, rule: &FirewallRule) -> Vec<PlannedCommand> {
let mut args = vec![rule.action.ufw_arg().into(), rule.direction.label().into()];
if let Some(port) = rule.port.ufw_value(rule.protocol) {
args.push(port);
}
vec![PlannedCommand {
backend: self.backend,
program: "ufw",
args,
reason: rule.note.clone(),
}]
}
fn iptables_rule_plan(&self, rule: &FirewallRule) -> Vec<PlannedCommand> {
let protocols: &[Protocol] = match rule.protocol {
Protocol::Any if rule.port != PortSpec::Any => &[Protocol::Tcp, Protocol::Udp],
Protocol::Any => &[Protocol::Any],
Protocol::Tcp => &[Protocol::Tcp],
Protocol::Udp => &[Protocol::Udp],
};
protocols
.iter()
.map(|protocol| {
let mut args = vec!["-A".into(), rule.direction.iptables_chain().into()];
if *protocol != Protocol::Any {
args.push("-p".into());
args.push(protocol.label().into());
}
if let Some(port) = rule.port.netfilter_value() {
args.push("--dport".into());
args.push(port);
}
args.push("-j".into());
args.push(rule.action.netfilter_target().into());
PlannedCommand {
backend: self.backend,
program: "iptables",
args,
reason: rule.note.clone(),
}
})
.collect()
}
fn nft_rule_plan(&self, rule: &FirewallRule) -> Vec<PlannedCommand> {
let protocols: &[Protocol] = match rule.protocol {
Protocol::Any if rule.port != PortSpec::Any => &[Protocol::Tcp, Protocol::Udp],
Protocol::Any => &[Protocol::Any],
Protocol::Tcp => &[Protocol::Tcp],
Protocol::Udp => &[Protocol::Udp],
};
protocols
.iter()
.map(|protocol| {
let mut args = vec![
"add".into(),
"rule".into(),
"inet".into(),
"filter".into(),
rule.direction.nft_chain().into(),
];
if *protocol != Protocol::Any {
args.push(protocol.label().into());
}
if let Some(port) = rule.port.netfilter_value() {
args.push("dport".into());
args.push(port);
}
args.push(rule.action.nft_verdict().into());
PlannedCommand {
backend: self.backend,
program: "nft",
args,
reason: rule.note.clone(),
}
})
.collect()
}
}
fn default_candidates() -> Vec<PortCandidate> {
vec![
PortCandidate {
name: "SSH",
group: "admin",
protocol: Protocol::Tcp,
port: PortSpec::Single(22),
risk: "admin",
aliases: &["openssh", "sshd", "secure shell"],
detail: "remote shell access",
},
PortCandidate {
name: "Telnet",
group: "legacy",
protocol: Protocol::Tcp,
port: PortSpec::Single(23),
risk: "high",
aliases: &["telnetd", "plaintext shell"],
detail: "plaintext remote shell",
},
PortCandidate {
name: "SMTP",
group: "mail",
protocol: Protocol::Tcp,
port: PortSpec::Single(25),
risk: "mail",
aliases: &["mail", "postfix", "sendmail"],
detail: "mail transfer exposure",
},
PortCandidate {
name: "DNS",
group: "infra",
protocol: Protocol::Udp,
port: PortSpec::Single(53),
risk: "infra",
aliases: &["domain", "bind", "named"],
detail: "resolver amplification risk",
},
PortCandidate {
name: "HTTP",
group: "web",
protocol: Protocol::Tcp,
port: PortSpec::Single(80),
risk: "web",
aliases: &["www", "nginx", "apache"],
detail: "public web traffic",
},
PortCandidate {
name: "HTTPS",
group: "web",
protocol: Protocol::Tcp,
port: PortSpec::Single(443),
risk: "web",
aliases: &["tls", "ssl", "https-alt"],
detail: "public TLS traffic",
},
PortCandidate {
name: "SMB",
group: "file",
protocol: Protocol::Tcp,
port: PortSpec::Single(445),
risk: "high",
aliases: &["samba", "cifs", "windows share"],
detail: "Windows file sharing",
},
PortCandidate {
name: "NFS",
group: "file",
protocol: Protocol::Tcp,
port: PortSpec::Single(2049),
risk: "high",
aliases: &["nfsd", "rpc mount"],
detail: "Unix file sharing",
},
PortCandidate {
name: "RDP",
group: "admin",
protocol: Protocol::Tcp,
port: PortSpec::Single(3389),
risk: "admin",
aliases: &["remote desktop", "termservice"],
detail: "remote desktop access",
},
PortCandidate {
name: "VNC",
group: "admin",
protocol: Protocol::Tcp,
port: PortSpec::Range(5900, 5999),
risk: "admin",
aliases: &["remote framebuffer", "screen sharing"],
detail: "remote desktop range",
},
PortCandidate {
name: "MySQL",
group: "data",
protocol: Protocol::Tcp,
port: PortSpec::Single(3306),
risk: "data",
aliases: &["mariadb", "mysqld"],
detail: "database listener",
},
PortCandidate {
name: "Postgres",
group: "data",
protocol: Protocol::Tcp,
port: PortSpec::Single(5432),
risk: "data",
aliases: &["postgresql", "postgres"],
detail: "database listener",
},
PortCandidate {
name: "Redis",
group: "data",
protocol: Protocol::Tcp,
port: PortSpec::Single(6379),
risk: "data",
aliases: &["redis-server", "cache"],
detail: "cache/database listener",
},
PortCandidate {
name: "MongoDB",
group: "data",
protocol: Protocol::Tcp,
port: PortSpec::Single(27017),
risk: "data",
aliases: &["mongod", "document db"],
detail: "document database listener",
},
PortCandidate {
name: "Elasticsearch",
group: "data",
protocol: Protocol::Tcp,
port: PortSpec::Single(9200),
risk: "data",
aliases: &["elastic", "opensearch", "search api"],
detail: "search database API",
},
PortCandidate {
name: "Docker API",
group: "orchestration",
protocol: Protocol::Tcp,
port: PortSpec::Single(2375),
risk: "high",
aliases: &["dockerd", "container api"],
detail: "unencrypted Docker API",
},
PortCandidate {
name: "Kubernetes",
group: "orchestration",
protocol: Protocol::Tcp,
port: PortSpec::Single(6443),
risk: "admin",
aliases: &["k8s", "apiserver", "control plane"],
detail: "cluster control plane",
},
PortCandidate {
name: "Prometheus",
group: "observability",
protocol: Protocol::Tcp,
port: PortSpec::Single(9090),
risk: "infra",
aliases: &["metrics", "prom"],
detail: "metrics database UI/API",
},
PortCandidate {
name: "Grafana",
group: "observability",
protocol: Protocol::Tcp,
port: PortSpec::Single(3000),
risk: "infra",
aliases: &["dashboard", "grafana-server"],
detail: "dashboard web UI",
},
PortCandidate {
name: "Jupyter",
group: "dev",
protocol: Protocol::Tcp,
port: PortSpec::Single(8888),
risk: "dev",
aliases: &["notebook", "jupyterlab"],
detail: "notebook execution server",
},
PortCandidate {
name: "Dev Range",
group: "dev",
protocol: Protocol::Tcp,
port: PortSpec::Range(3000, 3999),
risk: "dev",
aliases: &["node dev", "vite", "rails dev"],
detail: "local app servers",
},
]
}
fn shell_quote(value: &str) -> String {
if value.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'/' | b':' | b'=')
}) {
return value.to_string();
}
format!("'{}'", value.replace('\'', "'\\''"))
}
fn json_string(value: &str) -> String {
let mut escaped = String::with_capacity(value.len() + 2);
escaped.push('"');
for character in value.chars() {
match character {
'"' => escaped.push_str("\\\""),
'\\' => escaped.push_str("\\\\"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
character if character.is_control() => {
let _ = write!(escaped, "\\u{:04x}", character as u32);
}
character => escaped.push(character),
}
}
escaped.push('"');
escaped
}
fn json_array(values: &[&str]) -> String {
values
.iter()
.map(|value| json_string(value))
.collect::<Vec<_>>()
.join(", ")
}
fn profile_recommendation_score(impact: ProfileImpactSummary) -> i32 {
let lockout_penalty = if impact.management_all_blocked { 25 } else { 0 };
i32::from(impact.coverage_percent) * 2
- impact.at_risk_open as i32 * 40
- impact.staged_changes as i32
- lockout_penalty
}
fn profile_change_reason(
profile: FirewallProfile,
candidate: &PortCandidate,
after: CandidateStatus,
) -> String {
let action = match after {
CandidateStatus::Blocked => "blocks",
CandidateStatus::Allowed => "allows",
CandidateStatus::Open => "leaves open",
};
format!(
"{} profile {} {} service exposure",
profile.label(),
action,
candidate.group
)
}
fn fingerprint_write(hash: &mut u64, value: &str) {
for byte in value.len().to_le_bytes() {
*hash ^= u64::from(byte);
*hash = hash.wrapping_mul(FNV_PRIME);
}
for byte in value.bytes() {
*hash ^= u64::from(byte);
*hash = hash.wrapping_mul(FNV_PRIME);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dry_run_report_contains_review_context_and_plan() {
let controller = FirewallController::demo();
let report = controller.dry_run_report();
assert!(report.contains("Review-only artifact. No commands were executed."));
assert!(report.contains("## Suggested Review Order"));
assert!(report.contains("## Review Gates"));
assert!(report.contains("## Priority Port Queue"));
assert!(report.contains("## Reviewed Exceptions"));
assert!(report.contains("## Management Access Guard"));
assert!(report.contains("## Advisor Findings"));
assert!(report.contains("## Known Port Groups"));
assert!(report.contains("## Known Port Risks"));
assert!(report.contains("## At-Risk Open Ports"));
assert!(report.contains("## At-Risk Allowed Ports"));
assert!(report.contains("## Known Port Catalog"));
assert!(report.contains("## Backend Comparison"));
assert!(report.contains("## Recommended Profile"));
assert!(report.contains("## Recommended Profile Changes"));
assert!(report.contains("## Management-Preserving Profile Changes"));
assert!(report.contains("## Profile Impact Preview"));
assert!(report.contains("- advisor findings: "));
assert!(report.contains("- next advisor action: "));
assert!(report.contains("1. Review advisor warnings - 11 warning finding(s) remain"));
assert!(
report.contains("2. Close at-risk open known ports - 11 admin/high/data known port(s)")
);
assert!(report
.contains("1. [open] VNC tcp/5900-5999 admin/admin - block or document an exception"));
assert!(report.contains(
"1. [open] VNC tcp/5900-5999 admin/admin - block or document an exception | score 128 | reason: open known service; admin-control risk; admin group; port range"
));
assert!(report.contains("- known ports: "));
assert!(report.contains("- high: 4 total, 2 blocked, 0 allowed, 2 open"));
assert!(report.contains("- review readiness: 62% (C / needs hardening)"));
assert!(report.contains("- review gates: 8 pass, 2 warn, 0 block"));
assert!(report.contains("- [warn] Recommended profile safety"));
assert!(report.contains(
" action: Press y for the management-preserving developer profile, or confirm console access before Y."
));
assert!(report.contains("- risky allow exceptions: 0 reviewed, 0 unreviewed"));
assert!(report.contains("- management access: 0 blocked, 0 allowed, 4 open"));
assert!(report.contains(
"- recommended profile: workstation (score 162, +13 changes, 100% coverage, 0 at-risk open)"
));
assert!(report.contains(
"- recommended profile warning: workstation blocks all 4 known management/control-plane access path(s); confirm console or break-glass access before use"
));
assert!(report.contains(
"- management-preserving profile: developer (score 24, +8 changes, 76% coverage, 3 at-risk open)"
));
assert!(report.contains(
"- strongest hardening: workstation: score 162, +13 staged change(s), 16 resulting rule(s), 100% coverage, 0 at-risk open"
));
assert!(report.contains(" management access after profile: 4 blocked, 0 allowed, 0 open"));
assert!(report.contains(
"- management-preserving alternative: developer: score 24, +8 staged change(s), 11 resulting rule(s), 76% coverage, 3 at-risk open"
));
assert!(report.contains(" management access after profile: 1 blocked, 0 allowed, 3 open"));
assert!(report.contains(
"- SSH tcp/22 admin/admin: open -> blocked | workstation profile blocks admin service exposure"
));
assert!(report.contains(
"- NFS tcp/2049 file/high: open -> blocked | developer profile blocks file service exposure"
));
assert!(report.contains(
"- workstation: 13 staged change(s), 16 resulting rule(s), 100% coverage, 0 at-risk open, management 4 blocked/0 allowed/0 open (full lockout)"
));
assert!(report.contains(
"- developer: 8 staged change(s), 11 resulting rule(s), 76% coverage, 3 at-risk open, management 1 blocked/0 allowed/3 open"
));
assert!(
report.contains("- 4 management/control-plane path(s): 0 blocked, 0 allowed, 4 open")
);
assert!(report
.contains("- [open] Kubernetes tcp/6443 orchestration/admin - cluster control plane"));
assert!(report.contains("- [warn] At-risk open known ports"));
assert!(report.contains("- SSH tcp/22 admin/admin - remote shell access"));
assert!(report.contains("- MySQL tcp/3306 data/data - database listener"));
assert!(report.contains(
"- [blocked] Telnet tcp/23 legacy/high aliases telnetd, plaintext shell - plaintext remote shell"
));
assert!(report.contains(
"- [open] SSH tcp/22 admin/admin aliases openssh, sshd, secure shell - remote shell access"
));
assert!(report.contains("action: block or document an exception"));
assert!(report.contains("action: covered by block"));
assert!(report.contains("- review fingerprint: "));
assert!(report.contains("ufw default deny incoming"));
assert!(report.contains("iptables:"));
}
#[test]
fn dry_run_script_exits_before_commented_commands() {
let controller = FirewallController::demo();
let script = controller.dry_run_script();
assert!(script.contains("No firewall commands are executed by this file."));
assert!(script.contains("exit 0\n\n# Backend preview: ufw"));
assert!(script.contains("# Review fingerprint: "));
assert!(script.contains("# ufw default deny incoming"));
assert!(!script
.lines()
.any(|line| line == "ufw default deny incoming"));
}
#[test]
fn dry_run_manifest_contains_review_only_machine_readable_plan() {
let controller = FirewallController::demo();
let manifest = controller.dry_run_manifest();
assert!(manifest.contains("\"schema\": \"knott.firewall.dry-run.v1\""));
assert!(manifest.contains("\"review_only\": true"));
assert!(manifest.contains("\"executes_commands\": false"));
assert!(manifest.contains("\"advisor_next_action\": {"));
assert!(manifest.contains("\"recommendation\": "));
assert!(manifest.contains("\"advisor_summary\": {"));
assert!(manifest.contains("\"critical\": "));
assert!(manifest.contains("\"review_gate_summary\": {"));
assert!(manifest.contains("\"pass\": 8,"));
assert!(manifest.contains("\"warn\": 2,"));
assert!(manifest.contains("\"block\": 0"));
assert!(manifest.contains("\"management_access\": {"));
assert!(manifest.contains("\"total\": 4,"));
assert!(manifest.contains("\"blocked\": 0,"));
assert!(manifest.contains("\"open\": 4,"));
assert!(manifest.contains("\"all_blocked\": false,"));
assert!(manifest.contains(
"\"name\": \"Kubernetes\", \"group\": \"orchestration\", \"risk\": \"admin\", \"protocol\": \"tcp\", \"port\": \"6443\", \"status\": \"open\""
));
assert!(manifest.contains("\"review_gates\": ["));
assert!(manifest.contains("\"status\": \"warn\", \"title\": \"At-risk open known ports\""));
assert!(
manifest.contains("\"status\": \"warn\", \"title\": \"Recommended profile safety\"")
);
assert!(manifest.contains(
"\"action\": \"Press y for the management-preserving developer profile, or confirm console access before Y.\""
));
assert!(manifest.contains("\"review_order\": ["));
assert!(manifest.contains(
"\"index\": 1, \"title\": \"Review advisor warnings\", \"detail\": \"11 warning finding(s) remain"
));
assert!(manifest.contains("\"title\": \"Close at-risk open known ports\""));
assert!(manifest.contains("\"priority_port_queue\": ["));
assert!(manifest.contains("\"reviewed_exceptions\": ["));
assert!(manifest.contains("\"reviewed_exceptions\": 0"));
assert!(manifest.contains(
"\"index\": 1, \"name\": \"VNC\", \"group\": \"admin\", \"risk\": \"admin\", \"protocol\": \"tcp\", \"port\": \"5900-5999\", \"status\": \"open\""
));
assert!(manifest.contains("\"score\": 128"));
assert!(manifest.contains(
"\"reason\": \"open known service; admin-control risk; admin group; port range\""
));
assert!(manifest.contains("\"known_ports\": "));
assert!(manifest.contains("\"readiness_grade\": \"C\""));
assert!(manifest.contains("\"readiness_label\": \"needs hardening\""));
assert!(manifest.contains("\"at_risk_open\": "));
assert!(manifest.contains(&format!(
"\"fingerprint\": \"{}\"",
controller.plan_fingerprint()
)));
assert!(manifest.contains("\"commands\": ["));
assert!(manifest.contains("\"known_port_catalog\": ["));
assert!(manifest.contains("\"name\": \"SSH\""));
assert!(manifest.contains("\"detail\": \"remote shell access\""));
assert!(manifest.contains("\"aliases\": [\"openssh\", \"sshd\", \"secure shell\"]"));
assert!(manifest.contains("\"recommendation\": \"block or document an exception\""));
assert!(manifest.contains("\"recommendation\": \"covered by block\""));
assert!(manifest.contains(
"\"name\": \"Telnet\", \"group\": \"legacy\", \"risk\": \"high\", \"aliases\": [\"telnetd\", \"plaintext shell\"], \"protocol\": \"tcp\", \"port\": \"23\", \"status\": \"blocked\""
));
assert!(manifest.contains("\"at_risk_open_ports\": ["));
assert!(manifest.contains(
"\"name\": \"SSH\", \"group\": \"admin\", \"risk\": \"admin\", \"protocol\": \"tcp\", \"port\": \"22\", \"status\": \"open\""
));
assert!(manifest.contains("\"at_risk_allowed_ports\": ["));
assert!(manifest.contains("\"group_coverage\": ["));
assert!(manifest.contains("\"group\": \"admin\""));
assert!(manifest.contains("\"risk_coverage\": ["));
assert!(manifest.contains("\"risk\": \"admin\""));
assert!(manifest.contains("\"backend_comparison\": ["));
assert!(manifest.contains("\"recommended_profile\": {"));
assert!(manifest.contains("\"profile\": \"workstation\""));
assert!(manifest.contains("\"score\": 162"));
assert!(manifest.contains("\"staged_changes\": 13"));
assert!(manifest.contains("\"resulting_rules\": 16"));
assert!(manifest.contains("\"management_blocked\": 4"));
assert!(manifest.contains("\"management_allowed\": 0"));
assert!(manifest.contains("\"management_open\": 0"));
assert!(manifest.contains("\"management_all_blocked\": true"));
assert!(manifest.contains(
"\"warning\": \"workstation blocks all 4 known management/control-plane access path(s); confirm console or break-glass access before use\""
));
assert!(manifest.contains(
"\"reason\": \"eliminates at-risk open ports with 13 staged change(s) and 100% coverage\""
));
assert!(manifest.contains("\"safe_recommended_profile\": {"));
assert!(manifest.contains("\"profile\": \"developer\""));
assert!(manifest.contains("\"score\": 24"));
assert!(manifest.contains("\"staged_changes\": 8"));
assert!(manifest.contains("\"resulting_rules\": 11"));
assert!(manifest.contains("\"coverage_percent\": 76"));
assert!(manifest.contains("\"at_risk_open\": 3"));
assert!(manifest.contains("\"management_blocked\": 1"));
assert!(manifest.contains("\"management_open\": 3"));
assert!(manifest.contains("\"management_all_blocked\": false"));
assert!(manifest.contains(
"\"reason\": \"best available profile leaves 3 at-risk open port(s) with 76% coverage\""
));
assert!(manifest.contains("\"recommended_profile_changes\": ["));
assert!(manifest.contains(
"\"index\": 1, \"candidate_index\": 0, \"name\": \"SSH\", \"group\": \"admin\", \"risk\": \"admin\", \"protocol\": \"tcp\", \"port\": \"22\", \"before\": \"open\", \"after\": \"blocked\""
));
assert!(
manifest.contains("\"reason\": \"workstation profile blocks admin service exposure\"")
);
assert!(manifest.contains("\"safe_recommended_profile_changes\": ["));
assert!(manifest.contains(
"\"index\": 1, \"candidate_index\": 7, \"name\": \"NFS\", \"group\": \"file\", \"risk\": \"high\", \"protocol\": \"tcp\", \"port\": \"2049\", \"before\": \"open\", \"after\": \"blocked\""
));
assert!(manifest.contains("\"reason\": \"developer profile blocks file service exposure\""));
assert!(manifest.contains("\"profile_impacts\": ["));
assert!(manifest.contains("\"profile\": \"lockdown\""));
assert!(manifest.contains(
"\"profile\": \"developer\", \"staged_changes\": 8, \"resulting_rules\": 11, \"coverage_percent\": 76, \"at_risk_open\": 3, \"management_total\": 4, \"management_blocked\": 1, \"management_allowed\": 0, \"management_open\": 3, \"management_all_blocked\": false"
));
assert!(manifest.contains(
"\"profile\": \"workstation\", \"staged_changes\": 13, \"resulting_rules\": 16, \"coverage_percent\": 100, \"at_risk_open\": 0, \"management_total\": 4, \"management_blocked\": 4, \"management_allowed\": 0, \"management_open\": 0, \"management_all_blocked\": true"
));
assert!(manifest.contains("\"backend\": \"nftables\""));
assert!(manifest.contains("\"args\": [\"default\", \"deny\", \"incoming\"]"));
assert!(manifest.contains("\"shell\": \"ufw default deny incoming\""));
}
#[test]
fn dry_run_exports_surface_at_risk_allowed_ports() {
let mut controller = FirewallController::demo();
let mysql = controller
.candidates()
.iter()
.position(|candidate| candidate.name == "MySQL")
.expect("known test candidate");
controller.allow_candidate(mysql);
let report = controller.dry_run_report();
let manifest = controller.dry_run_manifest();
assert!(report.contains("## At-Risk Allowed Ports"));
assert!(report.contains("- MySQL tcp/3306 data/data - database listener"));
assert!(report.contains(
"- [allowed] MySQL tcp/3306 data/data aliases mariadb, mysqld - database listener"
));
assert!(report.contains("action: block unless intentionally exposed"));
assert!(manifest.contains(
"\"name\": \"MySQL\", \"group\": \"data\", \"risk\": \"data\", \"protocol\": \"tcp\", \"port\": \"3306\", \"status\": \"allowed\""
));
assert!(manifest.contains(
"\"index\": 1, \"name\": \"MySQL\", \"group\": \"data\", \"risk\": \"data\", \"protocol\": \"tcp\", \"port\": \"3306\", \"status\": \"allowed\""
));
assert!(manifest.contains("\"score\": 162"));
assert!(manifest
.contains("\"reason\": \"unreviewed explicit allow; data exposure risk; data group\""));
assert!(manifest.contains("\"recommendation\": \"block unless intentionally exposed\""));
}
#[test]
fn priority_ports_are_scored_ranked_and_skip_reviewed_exceptions() {
let mut controller = FirewallController::demo();
let initial = controller.priority_ports(3);
assert_eq!(initial[0].name, "VNC");
assert_eq!(initial[0].status, CandidateStatus::Open);
assert_eq!(initial[0].score, 128);
assert_eq!(
initial[0].reason,
"open known service; admin-control risk; admin group; port range"
);
let mysql = controller
.candidates()
.iter()
.position(|candidate| candidate.name == "MySQL")
.expect("known test candidate");
controller.allow_candidate(mysql);
let exposed = controller.priority_ports(2);
assert_eq!(exposed[0].name, "MySQL");
assert_eq!(exposed[0].status, CandidateStatus::Allowed);
assert_eq!(exposed[0].score, 162);
assert_eq!(
exposed[0].reason,
"unreviewed explicit allow; data exposure risk; data group"
);
assert_eq!(controller.toggle_candidate_exception(mysql), Some(true));
let reviewed = controller.priority_ports(2);
assert_eq!(reviewed[0].name, "VNC");
assert!(reviewed.iter().all(|priority| priority.name != "MySQL"));
controller.apply_advisor_quick_fix();
assert!(controller.priority_ports(1).is_empty());
}
#[test]
fn reviewed_exceptions_are_undoable_and_exported() {
let mut controller = FirewallController::demo();
let mysql = controller
.candidates()
.iter()
.position(|candidate| candidate.name == "MySQL")
.expect("known test candidate");
controller.allow_candidate(mysql);
let allowed_fingerprint = controller.plan_fingerprint();
assert_eq!(controller.stats().at_risk_allowed, 1);
assert_eq!(controller.stats().unreviewed_at_risk_allowed, 1);
assert_eq!(controller.advisor_summary().critical, 1);
assert_eq!(controller.toggle_candidate_exception(mysql), Some(true));
assert!(controller.candidate_has_review_exception(mysql));
assert_ne!(allowed_fingerprint, controller.plan_fingerprint());
let stats = controller.stats();
assert_eq!(stats.at_risk_allowed, 1);
assert_eq!(stats.reviewed_exceptions, 1);
assert_eq!(stats.unreviewed_at_risk_allowed, 0);
assert_eq!(controller.review_exceptions().len(), 1);
assert_eq!(controller.advisor_summary().critical, 0);
assert!(controller.advisor_findings().iter().any(|finding| {
finding.title == "MySQL has a reviewed allow exception"
&& finding.recommendation.contains("source scope")
}));
let report = controller.dry_run_report();
let manifest = controller.dry_run_manifest();
assert!(report.contains("## Reviewed Exceptions"));
assert!(report.contains(
"- MySQL tcp/3306 data/data - reviewed dry-run exception; verify owner, source scope, and expiry before live apply"
));
assert!(report.contains("action: reviewed exception recorded; verify scope and expiry"));
assert!(manifest.contains(
"\"name\": \"MySQL\", \"group\": \"data\", \"risk\": \"data\", \"protocol\": \"tcp\", \"port\": \"3306\", \"note\": \"reviewed dry-run exception; verify owner, source scope, and expiry before live apply\""
));
assert!(manifest.contains(
"\"name\": \"MySQL\", \"group\": \"data\", \"risk\": \"data\", \"protocol\": \"tcp\", \"port\": \"3306\", \"status\": \"allowed\", \"review_exception\": true"
));
controller.block_candidate(mysql);
assert_eq!(
controller.candidate_status(mysql),
Some(CandidateStatus::Blocked)
);
assert!(!controller.candidate_has_review_exception(mysql));
assert_eq!(controller.review_exceptions().len(), 0);
assert!(controller.undo());
assert_eq!(
controller.candidate_status(mysql),
Some(CandidateStatus::Allowed)
);
assert!(controller.candidate_has_review_exception(mysql));
assert!(controller.undo());
assert_eq!(
controller.candidate_status(mysql),
Some(CandidateStatus::Allowed)
);
assert!(!controller.candidate_has_review_exception(mysql));
assert_eq!(controller.plan_fingerprint(), allowed_fingerprint);
}
#[test]
fn review_gates_track_blockers_warnings_and_clean_state() {
let mut controller = FirewallController::demo();
let summary = controller.review_gate_summary();
assert_eq!(summary.pass, 8);
assert_eq!(summary.warn, 2);
assert_eq!(summary.block, 0);
assert_eq!(
controller.next_review_gate().title,
"At-risk open known ports"
);
assert!(controller.review_gates().iter().any(|gate| {
gate.title == "Recommended profile safety"
&& gate.status == ReviewGateStatus::Warn
&& gate.action.contains("Press y")
}));
let mysql = controller
.candidates()
.iter()
.position(|candidate| candidate.name == "MySQL")
.expect("known test candidate");
controller.allow_candidate(mysql);
let exposed_gates = controller.review_gates();
assert!(exposed_gates.iter().any(|gate| {
gate.title == "Critical advisor findings" && gate.status == ReviewGateStatus::Block
}));
assert!(exposed_gates.iter().any(|gate| {
gate.title == "Risky allow exceptions" && gate.status == ReviewGateStatus::Block
}));
assert_eq!(controller.toggle_candidate_exception(mysql), Some(true));
let reviewed_gates = controller.review_gates();
assert!(reviewed_gates.iter().any(|gate| {
gate.title == "Risky allow exceptions" && gate.status == ReviewGateStatus::Warn
}));
assert_eq!(controller.review_gate_summary().block, 0);
controller.apply_advisor_quick_fix();
let clean = controller.review_gate_summary();
assert_eq!(clean.block, 0);
assert_eq!(clean.warn, 2);
assert_eq!(
controller.next_review_gate().title,
"Management access continuity"
);
}
#[test]
fn management_access_summary_warns_after_full_admin_lockdown() {
let mut controller = FirewallController::demo();
let initial = controller.management_access_summary();
assert_eq!(initial.total, 4);
assert_eq!(initial.blocked, 0);
assert_eq!(initial.open, 4);
assert!(!initial.all_blocked());
controller.apply_advisor_quick_fix();
let locked = controller.management_access_summary();
assert_eq!(locked.total, 4);
assert_eq!(locked.blocked, 4);
assert_eq!(locked.open, 0);
assert!(locked.all_blocked());
assert!(controller.advisor_findings().iter().any(|finding| {
finding.title == "Management access is fully blocked"
&& finding.recommendation.contains("break-glass")
}));
assert!(controller.review_gates().iter().any(|gate| {
gate.title == "Management access continuity" && gate.status == ReviewGateStatus::Warn
}));
}
#[test]
fn dry_run_artifacts_share_review_fingerprint() {
let controller = FirewallController::demo();
let fingerprint = controller.plan_fingerprint();
let report = controller.dry_run_report();
let script = controller.dry_run_script();
let manifest = controller.dry_run_manifest();
assert!(report.contains(&format!("review fingerprint: {fingerprint}")));
assert!(script.contains(&format!("# Review fingerprint: {fingerprint}")));
assert!(manifest.contains(&format!("\"fingerprint\": \"{fingerprint}\"")));
}
#[test]
fn backend_plan_summaries_compare_without_changing_selected_backend() {
let mut controller = FirewallController::demo();
controller.cycle_backend();
let selected_backend = controller.backend();
let summaries = controller.backend_plan_summaries();
assert_eq!(controller.backend(), selected_backend);
assert_eq!(summaries.len(), Backend::all().len());
assert!(summaries
.iter()
.any(|summary| summary.backend == Backend::Ufw
&& summary.first_command == "ufw default deny incoming"));
assert!(summaries
.iter()
.any(|summary| summary.backend == Backend::Iptables
&& summary.first_command == "iptables -P INPUT DROP"));
assert!(summaries
.iter()
.any(|summary| summary.backend == Backend::Nftables
&& summary
.first_command
.starts_with("nft add chain inet filter input")));
}
#[test]
fn profile_impact_summaries_compare_without_changing_current_plan() {
let mut controller = FirewallController::demo();
controller.cycle_backend();
let before_backend = controller.backend();
let before_fingerprint = controller.plan_fingerprint();
let before_history = controller.history_depth();
let impacts = controller.profile_impact_summaries();
assert_eq!(controller.backend(), before_backend);
assert_eq!(controller.plan_fingerprint(), before_fingerprint);
assert_eq!(controller.history_depth(), before_history);
assert_eq!(impacts.len(), FirewallProfile::all().len());
assert!(impacts
.iter()
.all(|impact| impact.resulting_rules >= controller.rules().len()));
let developer = impacts
.iter()
.find(|impact| impact.profile == FirewallProfile::Developer)
.expect("developer profile impact");
assert_eq!(developer.management_total, 4);
assert_eq!(developer.management_blocked, 1);
assert_eq!(developer.management_allowed, 0);
assert_eq!(developer.management_open, 3);
assert!(!developer.management_all_blocked);
assert!(impacts.iter().any(|impact| {
impact.profile == FirewallProfile::Lockdown
&& impact.coverage_percent == 100
&& impact.at_risk_open == 0
&& impact.management_blocked == 4
&& impact.management_open == 0
&& impact.management_all_blocked
}));
}
#[test]
fn recommended_profile_selects_strongest_least_change_hardening_without_mutation() {
let mut controller = FirewallController::demo();
controller.cycle_backend();
let before_backend = controller.backend();
let before_fingerprint = controller.plan_fingerprint();
let before_history = controller.history_depth();
let recommendation = controller.recommended_profile();
assert_eq!(recommendation.profile, FirewallProfile::Workstation);
assert_eq!(recommendation.staged_changes, 13);
assert_eq!(recommendation.resulting_rules, 16);
assert_eq!(recommendation.coverage_percent, 100);
assert_eq!(recommendation.at_risk_open, 0);
assert_eq!(recommendation.management_blocked, 4);
assert_eq!(recommendation.management_allowed, 0);
assert_eq!(recommendation.management_open, 0);
assert!(recommendation.management_all_blocked);
assert_eq!(recommendation.score, 162);
assert_eq!(
recommendation.reason,
"eliminates at-risk open ports with 13 staged change(s) and 100% coverage"
);
assert_eq!(
recommendation.warning.as_deref(),
Some(
"workstation blocks all 4 known management/control-plane access path(s); confirm console or break-glass access before use"
)
);
assert_eq!(controller.backend(), before_backend);
assert_eq!(controller.plan_fingerprint(), before_fingerprint);
assert_eq!(controller.history_depth(), before_history);
}
#[test]
fn safe_recommended_profile_preserves_management_access_without_mutation() {
let mut controller = FirewallController::demo();
controller.cycle_backend();
let before_backend = controller.backend();
let before_fingerprint = controller.plan_fingerprint();
let before_history = controller.history_depth();
let recommendation = controller
.safe_recommended_profile()
.expect("demo plan has a management-preserving profile");
let changes = controller.safe_recommended_profile_changes();
assert_eq!(recommendation.profile, FirewallProfile::Developer);
assert_eq!(recommendation.staged_changes, 8);
assert_eq!(recommendation.resulting_rules, 11);
assert_eq!(recommendation.coverage_percent, 76);
assert_eq!(recommendation.at_risk_open, 3);
assert_eq!(recommendation.management_blocked, 1);
assert_eq!(recommendation.management_allowed, 0);
assert_eq!(recommendation.management_open, 3);
assert!(!recommendation.management_all_blocked);
assert_eq!(recommendation.score, 24);
assert_eq!(
recommendation.reason,
"best available profile leaves 3 at-risk open port(s) with 76% coverage"
);
assert_eq!(recommendation.warning, None);
assert_eq!(changes.len(), 8);
assert_eq!(changes[0].profile, FirewallProfile::Developer);
assert_eq!(changes[0].candidate_index, 7);
assert_eq!(changes[0].name, "NFS");
assert_eq!(changes[0].before, CandidateStatus::Open);
assert_eq!(changes[0].after, CandidateStatus::Blocked);
assert_eq!(controller.backend(), before_backend);
assert_eq!(controller.plan_fingerprint(), before_fingerprint);
assert_eq!(controller.history_depth(), before_history);
let mut locked = controller.clone();
locked.apply_profile(FirewallProfile::Lockdown);
assert_eq!(locked.safe_recommended_profile(), None);
}
#[test]
fn recommended_profile_changes_preview_lists_deltas_without_mutation() {
let controller = FirewallController::demo();
let before_fingerprint = controller.plan_fingerprint();
let before_history = controller.history_depth();
let changes = controller.recommended_profile_changes();
assert_eq!(changes.len(), 13);
assert_eq!(changes[0].profile, FirewallProfile::Workstation);
assert_eq!(changes[0].candidate_index, 0);
assert_eq!(changes[0].name, "SSH");
assert_eq!(changes[0].before, CandidateStatus::Open);
assert_eq!(changes[0].after, CandidateStatus::Blocked);
assert_eq!(
changes[0].reason,
"workstation profile blocks admin service exposure"
);
assert!(changes.iter().any(|change| {
change.name == "MySQL"
&& change.before == CandidateStatus::Open
&& change.after == CandidateStatus::Blocked
}));
assert_eq!(controller.plan_fingerprint(), before_fingerprint);
assert_eq!(controller.history_depth(), before_history);
}
#[test]
fn apply_recommended_profile_stages_selected_profile_and_is_undoable() {
let mut controller = FirewallController::demo();
let before_fingerprint = controller.plan_fingerprint();
let (profile, staged) = controller.apply_recommended_profile();
assert_eq!(profile, FirewallProfile::Workstation);
assert_eq!(staged, 13);
assert_eq!(controller.stats().at_risk_open, 0);
assert_eq!(
candidate_status_named(&controller, "SSH"),
CandidateStatus::Blocked
);
assert_eq!(
candidate_status_named(&controller, "MySQL"),
CandidateStatus::Blocked
);
assert!(controller
.audit_log()
.iter()
.any(|message| message == "Applied recommended workstation dry-run profile."));
assert!(controller.undo());
assert_eq!(controller.plan_fingerprint(), before_fingerprint);
assert_eq!(controller.stats().at_risk_open, 11);
}
#[test]
fn apply_safe_recommended_profile_stages_management_preserving_profile_and_is_undoable() {
let mut controller = FirewallController::demo();
let before_fingerprint = controller.plan_fingerprint();
let (profile, staged) = controller
.apply_safe_recommended_profile()
.expect("demo plan has a management-preserving profile");
assert_eq!(profile, FirewallProfile::Developer);
assert_eq!(staged, 8);
assert_eq!(controller.stats().at_risk_open, 3);
assert_eq!(
candidate_status_named(&controller, "SSH"),
CandidateStatus::Open
);
assert_eq!(
candidate_status_named(&controller, "Kubernetes"),
CandidateStatus::Blocked
);
assert_eq!(
candidate_status_named(&controller, "MySQL"),
CandidateStatus::Blocked
);
assert!(controller.audit_log().iter().any(|message| {
message == "Applied management-preserving recommended developer dry-run profile."
}));
assert!(controller.undo());
assert_eq!(controller.plan_fingerprint(), before_fingerprint);
assert_eq!(controller.stats().at_risk_open, 11);
}
#[test]
fn apply_safe_recommended_profile_returns_none_when_all_profiles_lock_management() {
let mut controller = FirewallController::demo();
controller.apply_profile(FirewallProfile::Lockdown);
let locked_fingerprint = controller.plan_fingerprint();
let locked_history = controller.history_depth();
assert_eq!(controller.apply_safe_recommended_profile(), None);
assert_eq!(controller.plan_fingerprint(), locked_fingerprint);
assert_eq!(controller.history_depth(), locked_history);
}
#[test]
fn json_string_escapes_manifest_values() {
assert_eq!(
json_string("quoted \"rule\"\\path\nnext"),
"\"quoted \\\"rule\\\"\\\\path\\nnext\""
);
}
#[test]
fn plan_fingerprint_is_stable_and_changes_with_review_state() {
let mut controller = FirewallController::demo();
let initial = controller.plan_fingerprint();
assert_eq!(initial.len(), 16);
assert!(initial
.chars()
.all(|character| character.is_ascii_hexdigit()));
assert_eq!(initial, controller.plan_fingerprint());
controller.toggle_logging();
assert_ne!(initial, controller.plan_fingerprint());
}
#[test]
fn default_policy_toggles_update_plan_and_undo() {
let mut controller = FirewallController::demo();
controller.toggle_default_incoming();
controller.toggle_default_outgoing();
assert_eq!(controller.default_incoming(), DefaultPolicy::Allow);
assert_eq!(controller.default_outgoing(), DefaultPolicy::Deny);
assert!(controller
.plan()
.iter()
.any(|command| command.shell_line() == "ufw default allow incoming"));
assert!(controller
.advisor_findings()
.iter()
.any(|finding| finding.title == "Default outgoing policy denies traffic"));
assert!(controller.undo());
assert_eq!(controller.default_incoming(), DefaultPolicy::Allow);
assert_eq!(controller.default_outgoing(), DefaultPolicy::Allow);
assert!(controller.undo());
assert_eq!(controller.default_incoming(), DefaultPolicy::Deny);
assert_eq!(controller.default_outgoing(), DefaultPolicy::Allow);
}
#[test]
fn known_port_stats_track_coverage_counts() {
let mut controller = FirewallController::demo();
let stats = controller.stats();
assert_eq!(stats.known_ports, controller.candidates().len());
assert_eq!(
stats.known_blocked + stats.known_allowed + stats.known_open,
stats.known_ports
);
assert_eq!(stats.known_blocked, 2);
assert_eq!(stats.known_allowed, 0);
assert_eq!(stats.at_risk_open, 11);
assert_eq!(stats.at_risk_allowed, 0);
assert_eq!(stats.reviewed_exceptions, 0);
assert_eq!(stats.unreviewed_at_risk_allowed, 0);
assert_eq!(stats.readiness_grade(), "C");
assert_eq!(stats.readiness_label(), "needs hardening");
controller.apply_advisor_quick_fix();
let hardened = controller.stats();
assert_eq!(hardened.at_risk_open, 0);
assert!(hardened.known_blocked > stats.known_blocked);
assert!(hardened.coverage_percent > stats.coverage_percent);
assert_eq!(hardened.readiness_grade(), "A");
assert_eq!(hardened.readiness_label(), "ready for review");
}
#[test]
fn advisor_summary_counts_findings_by_severity() {
let mut controller = FirewallController::demo();
let summary = controller.advisor_summary();
assert_eq!(summary.total(), controller.advisor_findings().len());
assert_eq!(summary.critical, 0);
assert_eq!(summary.warning, controller.stats().at_risk_open);
assert_eq!(summary.info, 0);
assert_eq!(summary.good, 0);
controller.toggle_default_incoming();
let exposed = controller.advisor_summary();
assert!(exposed.critical > summary.critical);
assert_eq!(exposed.total(), controller.advisor_findings().len());
}
#[test]
fn advisor_next_action_tracks_highest_priority_finding() {
let mut controller = FirewallController::demo();
let next = controller.advisor_next_action();
assert_eq!(next.severity, AdvisorSeverity::Warning);
assert!(next.recommendation.contains("Known Ports"));
controller.toggle_default_incoming();
let exposed = controller.advisor_next_action();
assert_eq!(exposed.severity, AdvisorSeverity::Critical);
assert_eq!(exposed.title, "Default incoming policy allows traffic");
}
#[test]
fn clear_rules_is_undoable() {
let mut controller = FirewallController::demo();
let initial_rules = controller.rules().len();
assert_eq!(controller.clear_rules(), initial_rules);
assert!(controller.rules().is_empty());
assert!(controller.undo());
assert_eq!(controller.rules().len(), initial_rules);
}
#[test]
fn starter_plan_reset_is_undoable() {
let mut controller = FirewallController::demo();
controller.apply_profile(FirewallProfile::Lockdown);
controller.cycle_backend();
controller.toggle_default_outgoing();
let before_reset = controller.plan_fingerprint();
let before_count = controller.rules().len();
let restored = controller.reset_to_starter_plan();
assert_eq!(restored, 3);
assert_eq!(controller.backend(), Backend::Ufw);
assert_eq!(controller.default_incoming(), DefaultPolicy::Deny);
assert_eq!(controller.default_outgoing(), DefaultPolicy::Allow);
assert!(controller.logging());
assert_eq!(controller.rules().len(), 3);
assert_ne!(before_reset, controller.plan_fingerprint());
assert!(controller.undo());
assert_eq!(controller.rules().len(), before_count);
assert_eq!(controller.plan_fingerprint(), before_reset);
}
#[test]
fn candidate_filters_select_expected_ports() {
let mut controller = FirewallController::demo();
assert_eq!(
controller
.filtered_candidate_indexes(CandidateFilter::All, None, None)
.len(),
controller.candidates().len()
);
assert!(controller
.filtered_candidate_indexes(CandidateFilter::Blocked, None, None)
.iter()
.any(|index| controller.candidates()[*index].name == "Telnet"));
assert!(controller
.filtered_candidate_indexes(CandidateFilter::AtRisk, None, None)
.iter()
.any(|index| controller.candidates()[*index].name == "SSH"));
let named = controller.filtered_candidate_indexes(CandidateFilter::Named, None, None);
assert!(named
.iter()
.any(|index| controller.candidates()[*index].name == "Postgres"));
assert!(named
.iter()
.any(|index| controller.candidates()[*index].name == "Docker API"));
assert!(named
.iter()
.any(|index| controller.candidates()[*index].aliases.contains(&"nginx")));
assert!(controller
.filtered_candidate_indexes(CandidateFilter::Open, None, None)
.iter()
.any(|index| controller.candidates()[*index].name == "HTTP"));
controller.allow_candidate_named("HTTPS");
assert!(controller
.filtered_candidate_indexes(CandidateFilter::Allowed, None, None)
.iter()
.any(|index| controller.candidates()[*index].name == "HTTPS"));
}
#[test]
fn group_filters_select_catalog_groups() {
let controller = FirewallController::demo();
let groups = controller.candidate_groups();
assert_eq!(groups.first(), Some(&"admin"));
assert!(groups.contains(&"web"));
assert!(groups.contains(&"data"));
let web_candidates =
controller.filtered_candidate_indexes(CandidateFilter::All, Some("web"), None);
assert_eq!(web_candidates.len(), 2);
assert!(web_candidates
.iter()
.all(|index| controller.candidates()[*index].group == "web"));
}
#[test]
fn risk_filters_select_catalog_risks() {
let controller = FirewallController::demo();
let risks = controller.candidate_risks();
assert_eq!(risks.first(), Some(&"admin"));
assert!(risks.contains(&"high"));
assert!(risks.contains(&"data"));
let data_candidates =
controller.filtered_candidate_indexes(CandidateFilter::All, None, Some("data"));
assert_eq!(data_candidates.len(), 5);
assert!(data_candidates
.iter()
.all(|index| controller.candidates()[*index].risk == "data"));
let high_file_candidates =
controller.filtered_candidate_indexes(CandidateFilter::All, Some("file"), Some("high"));
assert_eq!(high_file_candidates.len(), 2);
assert!(high_file_candidates
.iter()
.all(|index| controller.candidates()[*index].group == "file"
&& controller.candidates()[*index].risk == "high"));
let high_web_candidates =
controller.filtered_candidate_indexes(CandidateFilter::All, Some("web"), Some("high"));
assert!(high_web_candidates.is_empty());
}
#[test]
fn group_coverage_summaries_count_known_port_statuses() {
let mut controller = FirewallController::demo();
let coverage = controller.group_coverage_summaries();
let admin = group_coverage_named(&coverage, "admin");
assert_eq!(admin.total, 3);
assert_eq!(admin.blocked, 0);
assert_eq!(admin.open, 3);
assert_eq!(admin.at_risk_open, 3);
let file = group_coverage_named(&coverage, "file");
assert_eq!(file.total, 2);
assert_eq!(file.blocked, 1);
assert_eq!(file.open, 1);
assert_eq!(file.at_risk_open, 1);
controller.apply_advisor_quick_fix();
let hardened = controller.group_coverage_summaries();
assert!(hardened.iter().all(|group| group.at_risk_open == 0));
}
#[test]
fn risk_coverage_summaries_count_known_port_statuses() {
let mut controller = FirewallController::demo();
let coverage = controller.risk_coverage_summaries();
assert_eq!(coverage.len(), 7);
let high = risk_coverage_named(&coverage, "high");
assert_eq!(high.total, 4);
assert_eq!(high.blocked, 2);
assert_eq!(high.allowed, 0);
assert_eq!(high.open, 2);
let data = risk_coverage_named(&coverage, "data");
assert_eq!(data.total, 5);
assert_eq!(data.blocked, 0);
assert_eq!(data.allowed, 0);
assert_eq!(data.open, 5);
controller.apply_advisor_quick_fix();
let hardened = controller.risk_coverage_summaries();
assert_eq!(risk_coverage_named(&hardened, "high").open, 0);
assert_eq!(risk_coverage_named(&hardened, "data").open, 0);
assert_eq!(risk_coverage_named(&hardened, "admin").open, 0);
assert_eq!(risk_coverage_named(&hardened, "web").open, 2);
}
#[test]
fn block_candidate_risk_blocks_matching_risk_and_is_undoable() {
let mut controller = FirewallController::demo();
let mysql = controller
.candidates()
.iter()
.position(|candidate| candidate.name == "MySQL")
.expect("known test candidate");
let staged = controller.block_candidate_risk(mysql);
assert_eq!(staged, 5);
let coverage = controller.risk_coverage_summaries();
let data = risk_coverage_named(&coverage, "data");
assert_eq!(data.blocked, 5);
assert_eq!(data.open, 0);
assert_eq!(risk_coverage_named(&coverage, "web").open, 2);
assert!(controller
.audit_log()
.iter()
.any(|message| message == "Blocked 5 known port(s) in risk class data."));
assert!(controller.undo());
let restored = controller.risk_coverage_summaries();
assert_eq!(risk_coverage_named(&restored, "data").blocked, 0);
assert_eq!(risk_coverage_named(&restored, "data").open, 5);
}
#[test]
fn block_candidate_indexes_blocks_active_filter_and_is_undoable() {
let mut controller = FirewallController::demo();
let filtered = controller.filtered_candidate_indexes(
CandidateFilter::Open,
Some("data"),
Some("data"),
);
assert_eq!(filtered.len(), 5);
let staged = controller.block_candidate_indexes(&filtered);
assert_eq!(staged, 5);
let coverage = controller.risk_coverage_summaries();
assert_eq!(risk_coverage_named(&coverage, "data").blocked, 5);
assert_eq!(risk_coverage_named(&coverage, "data").open, 0);
assert_eq!(risk_coverage_named(&coverage, "web").open, 2);
assert!(controller
.audit_log()
.iter()
.any(|message| message == "Blocked 5 known port(s) from active filter."));
assert!(controller.undo());
let restored = controller.risk_coverage_summaries();
assert_eq!(risk_coverage_named(&restored, "data").blocked, 0);
assert_eq!(risk_coverage_named(&restored, "data").open, 5);
}
#[test]
fn allow_candidate_indexes_allows_active_filter_and_is_undoable() {
let mut controller = FirewallController::demo();
let filtered =
controller.filtered_candidate_indexes(CandidateFilter::Open, Some("web"), Some("web"));
assert_eq!(filtered.len(), 2);
let staged = controller.allow_candidate_indexes(&filtered);
assert_eq!(staged, 2);
let coverage = controller.risk_coverage_summaries();
let web = risk_coverage_named(&coverage, "web");
assert_eq!(web.allowed, 2);
assert_eq!(web.open, 0);
assert_eq!(
candidate_status_named(&controller, "HTTP"),
CandidateStatus::Allowed
);
assert_eq!(
candidate_status_named(&controller, "HTTPS"),
CandidateStatus::Allowed
);
assert!(controller
.audit_log()
.iter()
.any(|message| message == "Allowed 2 known port(s) from active filter."));
assert!(controller.undo());
let restored = controller.risk_coverage_summaries();
assert_eq!(risk_coverage_named(&restored, "web").allowed, 0);
assert_eq!(risk_coverage_named(&restored, "web").open, 2);
}
#[test]
fn developer_profile_blocks_dev_risk_dependencies_without_admin_shells() {
let mut controller = FirewallController::demo();
controller.clear_rules();
let staged = controller.apply_profile(FirewallProfile::Developer);
assert!(staged > 0);
assert_eq!(
candidate_status_named(&controller, "Docker API"),
CandidateStatus::Blocked
);
assert_eq!(
candidate_status_named(&controller, "MySQL"),
CandidateStatus::Blocked
);
assert_eq!(
candidate_status_named(&controller, "SSH"),
CandidateStatus::Open
);
assert_eq!(
candidate_status_named(&controller, "Jupyter"),
CandidateStatus::Open
);
}
#[test]
fn advisor_quick_fix_restores_default_deny_incoming() {
let mut controller = FirewallController::demo();
controller.toggle_default_incoming();
let changes = controller.apply_advisor_quick_fix();
assert!(changes > 0);
assert_eq!(controller.default_incoming(), DefaultPolicy::Deny);
}
fn candidate_status_named(controller: &FirewallController, name: &str) -> CandidateStatus {
let index = controller
.candidates()
.iter()
.position(|candidate| candidate.name == name)
.expect("known test candidate");
controller
.candidate_status(index)
.expect("candidate status for known index")
}
fn group_coverage_named<'a>(
coverage: &'a [GroupCoverageSummary],
group: &str,
) -> &'a GroupCoverageSummary {
coverage
.iter()
.find(|summary| summary.group == group)
.expect("known test group")
}
fn risk_coverage_named<'a>(
coverage: &'a [RiskCoverageSummary],
risk: &str,
) -> &'a RiskCoverageSummary {
coverage
.iter()
.find(|summary| summary.risk == risk)
.expect("known test risk")
}
}