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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion resources/windows_firewall/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "windows_firewall"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
Comment thread
SteveL-MSFT marked this conversation as resolved.

[package.metadata.i18n]
Expand Down
2 changes: 2 additions & 0 deletions resources/windows_firewall/locales/en-us.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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}'"
66 changes: 62 additions & 4 deletions resources/windows_firewall/src/firewall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -400,7 +400,7 @@ pub fn get_rules(input: &FirewallRuleList) -> Result<FirewallRuleList, FirewallE
}
}

Ok(FirewallRuleList { rules: results })
Ok(FirewallRuleList { rules: results, unspecified_rules_action: None })
}

fn project_rule(current: &FirewallRule, desired: &FirewallRule) -> FirewallRule {
Expand Down Expand Up @@ -497,7 +497,65 @@ pub fn set_rules(input: &FirewallRuleList, what_if: bool) -> Result<FirewallRule
}
}

Ok(FirewallRuleList { rules: results })
// Handle unspecified_rules_action: Disable or Remove rules not explicitly listed
match &input.unspecified_rules_action {
Some(UnspecifiedRulesAction::Disable) | Some(UnspecifiedRulesAction::Remove) => {
let is_remove = matches!(&input.unspecified_rules_action, Some(UnspecifiedRulesAction::Remove));
let specified_names: std::collections::HashSet<String> = 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,
};

// 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;
}

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<FirewallRuleList, FirewallError> {
Expand All @@ -517,5 +575,5 @@ pub fn export_rules(filters: Option<&FirewallRuleList>) -> Result<FirewallRuleLi
}
}

Ok(FirewallRuleList { rules: results })
Ok(FirewallRuleList { rules: results, unspecified_rules_action: None })
}
10 changes: 10 additions & 0 deletions resources/windows_firewall/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,19 @@ pub enum RuleAction {
Block,
}

#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[serde(rename_all = "camelCase")]
pub enum UnspecifiedRulesAction {
Ignore,
Disable,
Remove,
}

#[derive(Debug, Default, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct FirewallRuleList {
#[serde(skip_serializing_if = "Option::is_none")]
pub unspecified_rules_action: Option<UnspecifiedRulesAction>,
pub rules: Vec<FirewallRule>,
}

Expand Down
162 changes: 162 additions & 0 deletions resources/windows_firewall/tests/windows_firewall_set.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
13 changes: 12 additions & 1 deletion resources/windows_firewall/windows_firewall.dsc.resource.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"Windows",
"Firewall"
],
"version": "0.1.1",
"version": "0.2.0",
"get": {
"executable": "windows_firewall",
"args": [
Expand Down Expand Up @@ -62,6 +62,17 @@
"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.",
"default": "ignore",
"enum": [
"ignore",
"disable",
"remove"
]
},
"rules": {
"type": "array",
"title": "Rules",
Expand Down
Loading