diff --git a/crates/spt-daemon/src/bootstrap_firewall/windows.rs b/crates/spt-daemon/src/bootstrap_firewall/windows.rs index 56ff9176..c7cc6786 100644 --- a/crates/spt-daemon/src/bootstrap_firewall/windows.rs +++ b/crates/spt-daemon/src/bootstrap_firewall/windows.rs @@ -29,7 +29,7 @@ const RULE_GROUP: &str = "spt-core bootstrap TCP"; /// and `desired_specs` all derive from these constants instead of repeating them. const RULE_NAMES: &[&str] = &[RULE_NAME_TAILNET, RULE_NAME_LAN]; -/// The one ActiveStore enforcement code that certifies a rule is in force. +/// The positive ActiveStore code, combined with PrimaryStatus and the code-20 veto. /// /// A NUMBER, NEVER A SPELLING. The host renders this code as a display string, and /// which string is a property of the host rather than of the rule: measured on @@ -41,19 +41,12 @@ const RULE_NAMES: &[&str] = &[RULE_NAME_TAILNET, RULE_NAME_LAN]; // [impl->REQ-BOOTSTRAP-FIREWALL-ENFORCEMENT-CODES] const ENFORCEMENT_SUCCESS: u16 = 1; -/// The COMPLETE certified evidence: exactly one element, the success code. -/// -/// STRICTNESS IS THE REQUIREMENT, not an implementation detail. This is compared with -/// `!=` against the whole slice, so `[1, 1]` and `[1, 5]` both refuse. Equality must -/// never become membership: a success code sitting beside another code means the host -/// reported something else as well. What an array of several codes MEANS is not -/// resolved -- the documented values describe individual codes and say nothing about -/// how a multi-element array should be read -- so this policy refuses rather than -/// interpreting. Empty, unknown and additional values all refuse here, and -/// anything the query could not transport as a number never reaches this comparison at -/// all -- it fails deserialization and surfaces as a query error instead. +/// Run-02 on HFENDULEAM measured OK/[5,1] without a package binding and +/// Inactive/[5,20] with `-Package Any`. A multi-profile rule need not have a +/// singleton enforcement array: the inactive Domain profile contributes 5. // [impl->REQ-BOOTSTRAP-FIREWALL-ENFORCEMENT-CODES] -const ENFORCEMENT_CERTIFIED: [u16; 1] = [ENFORCEMENT_SUCCESS]; +const ENFORCEMENT_NO_LOCAL_USER: u16 = 20; +const PRIMARY_STATUS_OK: u16 = 1; // Enumerate then compare the exact immutable name: a failed query must not be // confused with NetSecurity's non-terminating 'named object not found' error. @@ -74,11 +67,11 @@ function Value($object, $name) { # verdict compared against "Full" -- so the success value itself was refused and no host # state could satisfy the arm. The repair is this ACCESSOR, not a cast: casting the # adapted value throws on "Enforced". CimInstanceProperties is the unadapted CIM view. -function RawValue($object, $name) { +function RawValue($object, $name, $expectedType = 'UInt16Array') { $property = $object.CimInstanceProperties[$name] if ($null -eq $property) { throw "ENFORCEMENT_REPRESENTATION_FAULT: NetSecurity omitted required CIM property $name" } - if ([string]$property.CimType -ne 'UInt16Array') { - throw "ENFORCEMENT_REPRESENTATION_FAULT: CIM property $name has type $($property.CimType), expected UInt16Array" + if ([string]$property.CimType -ne $expectedType) { + throw "ENFORCEMENT_REPRESENTATION_FAULT: CIM property $name has type $($property.CimType), expected $expectedType" } $property.Value } @@ -142,12 +135,14 @@ function Describe($rule) { # NOT: it is reported below and decided in Rust, because a policy expressed # only as text handed to another interpreter cannot be unit-tested, which is # how two contradictory amendments coexisted with nothing red (releases#304). + # Run-02 measured an empty Package when New-NetFirewallRule omitted -Package. + # Literal Any is a binding here, unlike the Program filter's unrestricted spelling. $hygiene = (Is $rule 'Enabled' 'True') -and (Is $rule 'Direction' 'Inbound') -and (Is $rule 'Action' 'Allow') -and (Empty $rule 'Platform') -and (Empty $rule 'Owner') -and (Is $port 'RemotePort' 'Any') -and (Is $port 'DynamicTarget' 'Any') -and (Is $address 'LocalAddress' 'Any') -and - (Is $app 'Package' 'Any') -and (Is $service 'Service' 'Any') -and + (Empty $app 'Package') -and (Is $service 'Service' 'Any') -and (Is $interface 'InterfaceAlias' 'Any') -and (Is $type 'InterfaceType' 'Any') -and (Is $security 'Authentication' 'NotRequired') -and (Is $security 'Encryption' 'NotRequired') -and (Is $security 'OverrideBlockRules' 'False') -and @@ -185,6 +180,10 @@ function Describe($rule) { } [int]$_ }) + $primaryStatusRaw = RawValue $rule 'PrimaryStatus' 'UInt16' + if ($null -eq $primaryStatusRaw -or $primaryStatusRaw -isnot [uint16]) { + throw "ENFORCEMENT_REPRESENTATION_FAULT: invalid raw PrimaryStatus for $name, expected System.UInt16" + } # WHERE THE EFFECTIVE RULE CAME FROM. Reported, never judged here: the Rust # side decides what counts as persistent. 'Local' names the local persistent # store as this effective rule's source, which is how ONE ActiveStore pass @@ -199,6 +198,7 @@ function Describe($rule) { remotes = @(Value $address 'RemoteAddress' | ForEach-Object { [string]$_ }) hygiene = [bool]$hygiene enforcement = $enforcement + primaryStatus = [int]$primaryStatusRaw } } # ONE STORE PASS, NOT TWO. The old query enumerated PersistentStore and @@ -259,14 +259,12 @@ struct Rule { /// anything the query cannot transport as a number -- a null, a display /// string, a negative -- fails deserialization and surfaces as a query error /// rather than arriving as a code the verdict would then judge. - /// - /// NO NAME IS ATTACHED TO ANY CODE, here or anywhere downstream. The captured - /// class on the measuring host exposes ValueMap (0..25) with the Values - /// qualifier ABSENT, so no code-to-name mapping is derivable from the host at - /// all -- including for the success code. Naming one would be documentation - /// smuggled in as an observation. + /// PrimaryStatus is transported separately so a success code cannot override + /// the provider reporting the rule inactive. // [impl->REQ-BOOTSTRAP-FIREWALL-ENFORCEMENT-CODES] enforcement: Vec, + #[serde(rename = "primaryStatus")] + primary_status: u16, /// Which store this EFFECTIVE rule came from, as NetSecurity reports it: /// `Local` for the local persistent store, `GroupPolicy` for domain policy, /// `Dynamic` for a rule that exists only until reboot. Reported here and @@ -456,6 +454,8 @@ fn desired_specs(binder_normalized: &str, bound_port: u16, lan: &LanScope) -> Ve /// the emitted rule text can be compared against the spec WITHOUT a live /// NetSecurity, and an effector whose output nothing can read is how a scope /// policy expressed as script text went unchecked in the first place. +/// Run-02 isolated `-Package Any` as the argument making the rule inactive. +/// Omit the binding; Get-NetFirewallApplicationFilter reports an empty Package. // [impl->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] fn render_writes(want: &[RuleSpec]) -> String { want.iter() @@ -465,7 +465,7 @@ fn render_writes(want: &[RuleSpec]) -> String { r#" New-NetFirewallRule -PolicyStore PersistentStore -Name '{name}' -DisplayName '{name}' -Group $ruleGroup ` -Enabled True -Direction Inbound -Action Allow -Profile {profile} -Protocol TCP -LocalPort {port} ` - -RemotePort Any -LocalAddress Any -RemoteAddress {remotes} {program_arg}-Package Any -Service Any ` + -RemotePort Any -LocalAddress Any -RemoteAddress {remotes} {program_arg}-Service Any ` -InterfaceAlias Any -InterfaceType Any -Authentication NotRequired -Encryption NotRequired ` -LocalUser Any -RemoteUser Any -RemoteMachine Any -OverrideBlockRules $false -DynamicTarget Any ` -EdgeTraversalPolicy Block -ErrorAction Stop | Out-Null @@ -832,11 +832,15 @@ fn decide(state: &Snapshot, expected_program: &str, port: u16) -> ResultREQ-BOOTSTRAP-FIREWALL-ENFORCEMENT-CODES] - if rule.enforcement.as_slice() != ENFORCEMENT_CERTIFIED { + if rule.primary_status != PRIMARY_STATUS_OK + || !rule.enforcement.contains(&ENFORCEMENT_SUCCESS) + || rule.enforcement.contains(&ENFORCEMENT_NO_LOCAL_USER) + { return Err(format!( - "Bootstrap rule {} is configured but ActiveStore enforcement codes are {:?}, \ - not exactly [{}]", - rule.name, rule.enforcement, ENFORCEMENT_SUCCESS + "Bootstrap rule {} is configured but ActiveStore PrimaryStatus is {} and \ + enforcement codes are {:?}; expected PrimaryStatus {} with code {} present and code {} absent", + rule.name, rule.primary_status, rule.enforcement, PRIMARY_STATUS_OK, + ENFORCEMENT_SUCCESS, ENFORCEMENT_NO_LOCAL_USER )); } } @@ -1029,10 +1033,10 @@ mod tests { "active": [ {"name": RULE_NAME_TAILNET, "program": "Any", "ports": ["29470"], "profile": "Any", "remotes": ["100.64.0.0/10"], "hygiene": true, - "enforcement": [1], "sourceType": "Local"}, + "enforcement": [1], "primaryStatus": 1, "sourceType": "Local"}, {"name": RULE_NAME_LAN, "program": "Any", "ports": ["29470"], "profile": "Private,Domain", "remotes": ["192.168.1.0/24"], - "hygiene": true, "enforcement": [1], "sourceType": "Local"} + "hygiene": true, "enforcement": [5, 1], "primaryStatus": 1, "sourceType": "Local"} ], "addresses": addresses }).to_string(); @@ -1157,6 +1161,7 @@ mod tests { remotes: remotes.iter().map(|r| r.to_string()).collect(), hygiene: true, enforcement: vec![ENFORCEMENT_SUCCESS], + primary_status: PRIMARY_STATUS_OK, source_type: "Local".to_string(), } } @@ -1658,6 +1663,33 @@ mod tests { ); } + /// The run-02 scratch control isolates `-Package Any`: adding it to an + /// otherwise identical rule changes OK/[5,1] to Inactive/[5,20]. + /// An unrestricted bootstrap rule must omit that package binding entirely. + // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] + #[test] + fn unrestricted_rules_omit_the_package_parameter() { + let want = desired_specs("c:/spt/spt.exe", 5470, &one_lan()); + let rendered = render_writes(&want); + let mut calls = rendered.split("New-NetFirewallRule").skip(1); + for spec in &want { + let call = calls + .next() + .expect("each admission half must have a create call"); + assert!( + call.contains(spec.name), + "the assertion must inspect the named half" + ); + assert!( + !call + .split_ascii_whitespace() + .any(|argument| argument == "-Package"), + "{} must not acquire the package restriction measured to disable admission: {call}", + spec.name + ); + } + } + // ---- observed-spelling regression cells (hertz, releases#304 W2) ---- // // Appended onto FOLD-4 (10d18b7f). Boundary agreed with todlando: everything @@ -1719,23 +1751,46 @@ mod tests { } } - /// H2 — AN UNENFORCED RULE IS STILL REFUSED, AFTER THE PAIR MATCHES. - /// - /// Enforcement is decided at its own `decide` arm, on a field the FOLD-4 - /// normalizers never see, and it returns `Err` rather than `Ok(false)`. No - /// sibling asserts it: the source-store arm has :1136, this one had nothing. - /// It exists so a comparison repair cannot fold enforcement into a WEAKER - /// comparison — a rule that is configured but not in force must stay a LOUD - /// refusal and not a quiet non-match. That intent is unchanged; only its - /// mechanism moved. It once guarded against folding enforcement into STRING - /// handling, because the field was Vec; the field is now Vec and - /// the live risk is equality silently becoming MEMBERSHIP, so the cases below - /// include a repeated success code and a success code beside a non-success one. - /// - /// No case here asserts a NAME for any code. The host that produced the A7 - /// capture rendered its codes as display text and exposed no Values qualifier, - /// so no code-to-name mapping is derivable from it; what makes 1 the success - /// code is the documented specification, asserted nowhere but in the constant. + // [unit->REQ-BOOTSTRAP-FIREWALL-ENFORCEMENT-CODES] + #[test] + fn an_ok_multi_profile_rule_with_codes_5_and_1_is_enforced() { + let want = desired_specs("c:/spt/spt.exe", 29470, &one_lan()); + let mut active = reconciled_store(&want); + active[1].enforcement = vec![5, 1]; + let state = Snapshot { + active, + addresses: one_lan_census(), + }; + assert_eq!(decide(&state, "c:/spt/spt.exe", 29470), Ok(true)); + } + + // [unit->REQ-BOOTSTRAP-FIREWALL-ENFORCEMENT-CODES] + #[test] + fn an_inactive_rule_with_codes_5_and_20_is_refused() { + let want = desired_specs("c:/spt/spt.exe", 29470, &one_lan()); + let mut active = reconciled_store(&want); + active[1].primary_status = 2; + active[1].enforcement = vec![5, 20]; + let state = Snapshot { + active, + addresses: one_lan_census(), + }; + assert!(decide(&state, "c:/spt/spt.exe", 29470).is_err()); + } + + // [unit->REQ-BOOTSTRAP-FIREWALL-ENFORCEMENT-CODES] + #[test] + fn an_ok_rule_with_code_1_is_enforced() { + let want = desired_specs("c:/spt/spt.exe", 29470, &one_lan()); + let state = Snapshot { + active: reconciled_store(&want), + addresses: one_lan_census(), + }; + assert_eq!(decide(&state, "c:/spt/spt.exe", 29470), Ok(true)); + } + + /// A matching rule still needs positive enforcement and no code-20 veto. + /// Null/type faults remain a separate query/deserialization failure. // [unit->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] #[test] fn an_unenforced_rule_is_refused_loudly_after_the_pair_matches() { @@ -1751,28 +1806,15 @@ mod tests { "control: the unperturbed snapshot decides reconciled" ); - // Every case is a REFUSAL, and the list is the requirement's acceptance - // table written as literals: [0], [2], [5], [20], [1,1], [1,5] and []. - // [5,20] is carried beyond that list because two non-success codes is a - // distinct shape from one. The arms that matter after the repair are the - // REPEATED success code and the success code accompanied by a non-success - // one — they fail only if `as_slice() != ENFORCEMENT_CERTIFIED` is ever - // softened into "contains the success code". - // - // WHAT THIS FILE CANNOT COVER, so that it is not read as covered: the - // requirement's null arms and its distinguishable REPRESENTATION FAULT - // live in the PowerShell query, before any JSON exists. Their Rust-side - // shadow is the deserialization cell below — a null element and a null - // field both refuse there; the faults themselves are the - // extraction-boundary exercise's subject. + // An empty or non-success array cannot certify admission. Code 20 vetoes + // even an otherwise positive array; inactive-profile code 5 alone is not success. // [unit->REQ-BOOTSTRAP-FIREWALL-ENFORCEMENT-CODES] for status in [ vec![0u16], vec![2u16], vec![5u16], vec![20u16], - vec![ENFORCEMENT_SUCCESS, ENFORCEMENT_SUCCESS], - vec![ENFORCEMENT_SUCCESS, 5u16], + vec![ENFORCEMENT_SUCCESS, ENFORCEMENT_NO_LOCAL_USER], vec![5u16, 20u16], Vec::new(), ] { @@ -1787,6 +1829,16 @@ mod tests { (status {status:?}): {refusal}" ); } + let mut inactive = effective; + inactive[0].primary_status = 2; + let state = Snapshot { + active: inactive, + addresses: one_lan_census(), + }; + assert!( + decide(&state, "c:/spt/spt.exe", 29470).is_err(), + "a success code cannot override non-OK PrimaryStatus" + ); } @@ -1807,7 +1859,7 @@ mod tests { format!( r#"{{"name":"n","program":"Any","ports":["29470"],"profile":"Any", "remotes":["Any"],"hygiene":true,"enforcement":{enforcement}, - "sourceType":"Local"}}"# + "primaryStatus":1,"sourceType":"Local"}}"# ) } @@ -1859,14 +1911,12 @@ mod tests { // filters plus DynamicTarget and the Platform/Owner emptiness checks. Those // cmdlets were never run, so the inputs do not exist. `true` here ISOLATES // the representation axes; it does not assert what the host would report. - // - `enforcement`: SYNTHETIC. The capture reads {ProfileInactive, NoLocalUser} in - // ActiveStore. Those are the host’s ADAPTED DISPLAY NAMES, not codes, and THE - // BOOTSTRAP PAIR’S NUMERIC VALUES REMAIN UNKNOWN: the raw UInt16 1 was measured - // on a SEPARATE, pre-existing 5470 rule, not on either bootstrap rule. So nothing - // here may be read as "the pair really carried 1 and only rendered badly". - // ENFORCEMENT_SUCCESS is used because it keeps these cells about the COMPARISON - // axes; it asserts nothing about what that host would have reported. The captured - // value is a SEPARATE FINDING, reported to doyle, not smuggled into a fixture. + // - `enforcement` and `primary_status`: the September 12 display-name capture + // was not a successful pair. Window 6 subsequently measured its raw [5,20] + // refusal. These comparison fixtures now use OK/[5,1], measured on the + // Private+Domain scratch rule without -Package in run-02 (2026-09-14). + // It is deliberately COMPOSED evidence: not a new capture of these named + // bootstrap rules, and not a measured all-profile/tailnet array. // - the port: SYNTHETIC. Its captured value is not in hand. The port axis is not an // equivalence axis and R5 covers its drift, so the spec's port is used. @@ -1880,7 +1930,8 @@ mod tests { profile: "Any".to_string(), // captured remotes: vec!["100.64.0.0/255.192.0.0".to_string()], // captured — MASK form hygiene: true, // SYNTHETIC, not captured — see above - enforcement: vec![ENFORCEMENT_SUCCESS], // SYNTHETIC, not captured — see above + enforcement: vec![5, ENFORCEMENT_SUCCESS], // run-02 enforced-shape composition + primary_status: PRIMARY_STATUS_OK, source_type: "Local".to_string(), // captured } } @@ -1895,7 +1946,8 @@ mod tests { profile: "Domain, Private".to_string(), // captured remotes: vec!["192.168.1.0/255.255.255.0".to_string()], // captured — MASK form hygiene: true, // SYNTHETIC, not captured — see above - enforcement: vec![ENFORCEMENT_SUCCESS], // SYNTHETIC, not captured — see above + enforcement: vec![5, ENFORCEMENT_SUCCESS], // run-02 enforced-shape composition + primary_status: PRIMARY_STATUS_OK, source_type: "Local".to_string(), // captured } }