From 6cab0489960613a80d8656a4f2f47f1057f4526b Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Thu, 25 Jun 2026 20:33:01 -0700 Subject: [PATCH 1/2] Update Microsoft.Windows/FirewallRuleList to support `unspecifiedRulesAction` property --- Cargo.lock | 2 +- resources/windows_firewall/Cargo.toml | 2 +- resources/windows_firewall/locales/en-us.toml | 2 + resources/windows_firewall/src/firewall.rs | 61 ++++++- resources/windows_firewall/src/types.rs | 10 ++ .../tests/windows_firewall_set.tests.ps1 | 162 ++++++++++++++++++ .../windows_firewall.dsc.resource.json | 10 ++ 7 files changed, 243 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c78b2211b..709bb27db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4161,7 +4161,7 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_firewall" -version = "0.1.0" +version = "0.2.0" dependencies = [ "rust-i18n", "serde", diff --git a/resources/windows_firewall/Cargo.toml b/resources/windows_firewall/Cargo.toml index 66ee2c64e..1230f4fe7 100644 --- a/resources/windows_firewall/Cargo.toml +++ b/resources/windows_firewall/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windows_firewall" -version = "0.1.0" +version = "0.2.0" edition = "2024" [package.metadata.i18n] diff --git a/resources/windows_firewall/locales/en-us.toml b/resources/windows_firewall/locales/en-us.toml index 5d848feab..d26b594be 100644 --- a/resources/windows_firewall/locales/en-us.toml +++ b/resources/windows_firewall/locales/en-us.toml @@ -34,3 +34,5 @@ invalidProtocol = "Invalid protocol number '%{value}'. Must be between 0 and 256 [firewall_helper] whatIfCreateRule = "Would create firewall rule '%{name}'" whatIfRemoveRule = "Would remove firewall rule '%{name}'" +whatIfDisableUnspecifiedRule = "Would disable unspecified firewall rule '%{name}'" +whatIfRemoveUnspecifiedRule = "Would remove unspecified firewall rule '%{name}'" diff --git a/resources/windows_firewall/src/firewall.rs b/resources/windows_firewall/src/firewall.rs index 3a88b4662..02c806580 100644 --- a/resources/windows_firewall/src/firewall.rs +++ b/resources/windows_firewall/src/firewall.rs @@ -10,7 +10,7 @@ use windows::Win32::System::Com::{CLSCTX_INPROC_SERVER, CoCreateInstance, CoInit use windows::Win32::System::Ole::IEnumVARIANT; use windows::Win32::System::Variant::{VARIANT, VariantClear}; -use crate::types::{FirewallError, FirewallRule, FirewallRuleList, Metadata, RuleAction, RuleDirection}; +use crate::types::{FirewallError, FirewallRule, FirewallRuleList, Metadata, RuleAction, RuleDirection, UnspecifiedRulesAction}; use crate::util::matches_any_filter; /// RAII wrapper for VARIANT that automatically calls VariantClear on drop @@ -400,7 +400,7 @@ pub fn get_rules(input: &FirewallRuleList) -> Result FirewallRule { @@ -497,7 +497,60 @@ pub fn set_rules(input: &FirewallRuleList, what_if: bool) -> Result { + let is_remove = matches!(&input.unspecified_rules_action, Some(UnspecifiedRulesAction::Remove)); + let specified_names: std::collections::HashSet = input.rules.iter() + .filter_map(|r| r.selector_name().map(|n| n.to_ascii_lowercase())) + .collect(); + + let all_rules = store.enumerate_rules()?; + for rule in &all_rules { + let model = rule_to_model(rule)?; + let rule_name = match &model.name { + Some(name) => name.clone(), + None => continue, + }; + + if specified_names.contains(&rule_name.to_ascii_lowercase()) { + continue; + } + + if is_remove { + if what_if { + let mut projected = model.missing_from_input(); + projected.metadata = Some(Metadata { what_if: Some(vec![t!("firewall_helper.whatIfRemoveUnspecifiedRule", name = rule_name).to_string()]) }); + results.push(projected); + } else { + store.remove_rule(&rule_name)?; + let mut removed = FirewallRule { name: Some(rule_name), ..FirewallRule::default() }; + removed.exist = Some(false); + results.push(removed); + } + } else { + // Disable + if model.enabled == Some(false) { + // Already disabled, no action needed + continue; + } + if what_if { + let mut projected = model.clone(); + projected.enabled = Some(false); + projected.metadata = Some(Metadata { what_if: Some(vec![t!("firewall_helper.whatIfDisableUnspecifiedRule", name = rule_name).to_string()]) }); + results.push(projected); + } else { + unsafe { rule.SetEnabled(VARIANT_BOOL::from(false)) } + .map_err(map_update_err(&rule_name))?; + results.push(rule_to_model(rule)?); + } + } + } + } + _ => {} // None or Ignore — no additional action + } + + Ok(FirewallRuleList { rules: results, unspecified_rules_action: input.unspecified_rules_action.clone() }) } pub fn export_rules(filters: Option<&FirewallRuleList>) -> Result { @@ -517,5 +570,5 @@ pub fn export_rules(filters: Option<&FirewallRuleList>) -> Result, pub rules: Vec, } diff --git a/resources/windows_firewall/tests/windows_firewall_set.tests.ps1 b/resources/windows_firewall/tests/windows_firewall_set.tests.ps1 index 489a453c8..607236491 100644 --- a/resources/windows_firewall/tests/windows_firewall_set.tests.ps1 +++ b/resources/windows_firewall/tests/windows_firewall_set.tests.ps1 @@ -145,3 +145,165 @@ Describe 'Microsoft.Windows/FirewallRuleList - set operation' -Skip:(!$isElevate Remove-NetFirewallRule -Name $secondRuleName -ErrorAction SilentlyContinue } } + +Describe 'Microsoft.Windows/FirewallRuleList - unspecifiedRulesAction (what-if)' -Skip:(!$isElevated) { + BeforeDiscovery { + $isElevated = if ($IsWindows) { + ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } else { + $false + } + } + + BeforeAll { + $testRuleName = 'DSC-WindowsFirewall-Unspecified-Test' + + function Initialize-TestFirewallRule { + $existing = Get-NetFirewallRule -Name $testRuleName -ErrorAction SilentlyContinue + if (-not $existing) { + New-NetFirewallRule -Name $testRuleName -DisplayName $testRuleName -Direction Inbound -Action Allow -Protocol TCP -LocalPort 32789 -Enabled True | Out-Null + } else { + Set-NetFirewallRule -Name $testRuleName -Enabled True + } + } + + Initialize-TestFirewallRule + } + + AfterAll { + Remove-NetFirewallRule -Name $testRuleName -ErrorAction SilentlyContinue + } + + It 'does not affect unspecified rules when unspecifiedRulesAction is ignore' -Skip:(!$isElevated) { + Initialize-TestFirewallRule + + # Specify a different rule name so $testRuleName is "unspecified" + $json = @{ + unspecifiedRulesAction = 'ignore' + rules = @(@{ + name = 'SomeOtherRuleThatMayNotExist' + direction = 'Inbound' + action = 'Allow' + protocol = 6 + enabled = $true + }) + } | ConvertTo-Json -Compress -Depth 5 + + $result = windows_firewall set -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + + # Only the specified rule should appear in results; no unspecified rules affected + $unspecifiedEntries = $result.rules | Where-Object { $_.name -eq $testRuleName } + $unspecifiedEntries | Should -BeNullOrEmpty + } + + It 'does not affect unspecified rules when unspecifiedRulesAction is omitted' -Skip:(!$isElevated) { + Initialize-TestFirewallRule + + $json = @{ + rules = @(@{ + name = 'SomeOtherRuleThatMayNotExist' + direction = 'Inbound' + action = 'Allow' + protocol = 6 + enabled = $true + }) + } | ConvertTo-Json -Compress -Depth 5 + + $result = windows_firewall set -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + + # No unspecified rules in results + $unspecifiedEntries = $result.rules | Where-Object { $_.name -eq $testRuleName } + $unspecifiedEntries | Should -BeNullOrEmpty + } + + It 'reports would disable unspecified rules when unspecifiedRulesAction is disable' -Skip:(!$isElevated) { + Initialize-TestFirewallRule + + # Specify only testRuleName; all other rules are "unspecified" and should be disabled + $json = @{ + unspecifiedRulesAction = 'disable' + rules = @(@{ + name = $testRuleName + enabled = $true + }) + } | ConvertTo-Json -Compress -Depth 5 + + $result = windows_firewall set -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + + # The specified rule should appear without disable metadata + $specifiedEntry = $result.rules | Where-Object { $_.name -eq $testRuleName -and $null -eq $_._metadata } + $specifiedEntry | Should -Not -BeNullOrEmpty + + # At least one unspecified rule should have disable what-if metadata + $disabledEntries = $result.rules | Where-Object { $_._metadata.whatIf -match 'Would disable unspecified firewall rule' } + $disabledEntries.Count | Should -BeGreaterThan 0 + + # All disabled entries should have enabled = false + foreach ($entry in $disabledEntries) { + $entry.enabled | Should -BeFalse + } + + # Verify no actual changes: the test rule is still enabled + $actual = Get-NetFirewallRule -Name $testRuleName -ErrorAction SilentlyContinue + $actual.Enabled | Should -Be 'True' + } + + It 'skips already-disabled rules when unspecifiedRulesAction is disable' -Skip:(!$isElevated) { + Initialize-TestFirewallRule + # Disable the test rule so it is already disabled + Set-NetFirewallRule -Name $testRuleName -Enabled False + + # Use a rule name that matches nothing, making testRuleName "unspecified" + $otherRuleName = 'DSC-WindowsFirewall-Unspecified-Other' + New-NetFirewallRule -Name $otherRuleName -DisplayName $otherRuleName -Direction Inbound -Action Allow -Protocol TCP -LocalPort 32790 -Enabled True -ErrorAction SilentlyContinue | Out-Null + + $json = @{ + unspecifiedRulesAction = 'disable' + rules = @(@{ + name = $otherRuleName + enabled = $true + }) + } | ConvertTo-Json -Compress -Depth 5 + + $result = windows_firewall set -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + + # The already-disabled test rule should NOT appear in results (skipped) + $testEntry = $result.rules | Where-Object { $_.name -eq $testRuleName -and $_._metadata.whatIf -match 'disable' } + $testEntry | Should -BeNullOrEmpty + + Remove-NetFirewallRule -Name $otherRuleName -ErrorAction SilentlyContinue + } + + It 'reports would remove unspecified rules when unspecifiedRulesAction is remove' -Skip:(!$isElevated) { + Initialize-TestFirewallRule + + # Specify a different rule so testRuleName is "unspecified" + # Use a well-known Windows rule that will exist + $knownRule = (Get-NetFirewallRule | Select-Object -First 1).Name + + $json = @{ + unspecifiedRulesAction = 'remove' + rules = @(@{ + name = $knownRule + enabled = $true + }) + } | ConvertTo-Json -Compress -Depth 5 + + $result = windows_firewall set -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + + # The test rule should be among the removed entries + $removedEntry = $result.rules | Where-Object { $_.name -eq $testRuleName -and $_._metadata.whatIf -match 'Would remove unspecified firewall rule' } + $removedEntry | Should -Not -BeNullOrEmpty + $removedEntry._exist | Should -BeFalse + + # Verify no actual removal: the rule still exists + $actual = Get-NetFirewallRule -Name $testRuleName -ErrorAction SilentlyContinue + $actual | Should -Not -BeNullOrEmpty + } +} diff --git a/resources/windows_firewall/windows_firewall.dsc.resource.json b/resources/windows_firewall/windows_firewall.dsc.resource.json index c73aa0e66..f6431bf29 100644 --- a/resources/windows_firewall/windows_firewall.dsc.resource.json +++ b/resources/windows_firewall/windows_firewall.dsc.resource.json @@ -62,6 +62,16 @@ "rules" ], "properties": { + "unspecifiedRulesAction": { + "type": "string", + "title": "Unspecified rules action", + "description": "The action to take on firewall rules not explicitly listed in the rules array. 'ignore' (default) leaves them unchanged, 'disable' disables them, and 'remove' deletes them.", + "enum": [ + "ignore", + "disable", + "remove" + ] + }, "rules": { "type": "array", "title": "Rules", From 1bd4f5db97a42407e77e99bf49a0d817c7b19624 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Thu, 25 Jun 2026 21:14:07 -0700 Subject: [PATCH 2/2] address copilot feedback --- resources/windows_firewall/src/firewall.rs | 5 +++++ .../windows_firewall/windows_firewall.dsc.resource.json | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/resources/windows_firewall/src/firewall.rs b/resources/windows_firewall/src/firewall.rs index 02c806580..8a4b67491 100644 --- a/resources/windows_firewall/src/firewall.rs +++ b/resources/windows_firewall/src/firewall.rs @@ -513,6 +513,11 @@ pub fn set_rules(input: &FirewallRuleList, what_if: bool) -> Result continue, }; + // Skip system rules which can't be located via the COM interface + if rule_name.starts_with("ms-resource://") { + continue; + } + if specified_names.contains(&rule_name.to_ascii_lowercase()) { continue; } diff --git a/resources/windows_firewall/windows_firewall.dsc.resource.json b/resources/windows_firewall/windows_firewall.dsc.resource.json index f6431bf29..61da68d8b 100644 --- a/resources/windows_firewall/windows_firewall.dsc.resource.json +++ b/resources/windows_firewall/windows_firewall.dsc.resource.json @@ -6,7 +6,7 @@ "Windows", "Firewall" ], - "version": "0.1.1", + "version": "0.2.0", "get": { "executable": "windows_firewall", "args": [ @@ -66,6 +66,7 @@ "type": "string", "title": "Unspecified rules action", "description": "The action to take on firewall rules not explicitly listed in the rules array. 'ignore' (default) leaves them unchanged, 'disable' disables them, and 'remove' deletes them.", + "default": "ignore", "enum": [ "ignore", "disable",