mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-11-15 17:34:11 +01:00
Merge final dev and verify strict transcription recovery CLI
This commit is contained in:
commit
6bf4360362
36 files changed
+1678
-11
No files matched your search
@@ -0,0 +1,193 @@
|
||||
# Explicit native audit-switch activation. No registry policy, security, share or service writes.
|
||||
function Get-WelaSmbRuntimeKey { param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress }
|
||||
|
||||
function Get-WelaSmbRuntimeSources {
|
||||
$result=[ordered]@{}
|
||||
foreach($name in @('WELA.ps1','scripts/SmbRuntimeActivation.ps1','scripts/SmbAuditing.ps1','scripts/Configuration.ps1','scripts/WefArrival.ps1')) {
|
||||
$result[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash
|
||||
}
|
||||
[pscustomobject]$result
|
||||
}
|
||||
|
||||
function Assert-WelaSmbRuntimeCommand {
|
||||
param($Command,[ValidateSet('Server','Client')][string]$Side,[ValidateSet('Get','Set')][string]$Verb,[string]$ModuleBase)
|
||||
# SmbShare exports functions from these native nested CDXML modules.
|
||||
if($Command.Name -cne "$Verb-Smb${Side}Configuration" -or $Command.ModuleName -cne "Smb${Side}Configuration" -or
|
||||
[string]$Command.CommandType -cne 'Function' -or [IO.Path]::GetFullPath($Command.Module.ModuleBase) -ine $ModuleBase){
|
||||
$observed=[pscustomobject]@{Name=$Command.Name;ModuleName=$Command.ModuleName;ModuleBase=$Command.Module.ModuleBase;Type=[string]$Command.CommandType}
|
||||
throw "SMB commands must resolve to the reviewed native SmbShare CDXML module. Expected $ModuleBase; observed $(Get-WelaSmbRuntimeKey $observed)"
|
||||
}
|
||||
if($Verb -eq 'Set') {
|
||||
$component=if($Side -eq 'Server'){'LanmanServer'}else{'LanmanWorkstation'}
|
||||
foreach($definition in @(Get-WelaSmbAuditDefinitions | Where-Object Component -eq $component)) {
|
||||
if(-not $Command.Parameters.ContainsKey($definition.Name) -or $Command.Parameters[$definition.Name].ParameterType -ne [bool]) {
|
||||
throw "Native setter lacks the exact Boolean parameter $($definition.Name)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-WelaSmbRuntimeCommands {
|
||||
$base=[IO.Path]::GetFullPath((Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell/v1.0/Modules/SmbShare'))
|
||||
$commands=[ordered]@{}
|
||||
foreach($side in @('Server','Client')) {
|
||||
foreach($verb in @('Get','Set')) {
|
||||
$name="SmbShare\$verb-Smb${side}Configuration"
|
||||
$found=@(Get-Command -Name $name -ErrorAction Stop)
|
||||
if($found.Count -ne 1){throw 'Expected exactly one native module-qualified SMB command.'}
|
||||
Assert-WelaSmbRuntimeCommand -Command $found[0] -Side $side -Verb $verb -ModuleBase $base
|
||||
$commands[$name]=[pscustomobject]@{ModuleName=$found[0].ModuleName;ModuleBase=$base;ModuleVersion=$found[0].Module.Version.ToString();CommandType=$found[0].CommandType.ToString()}
|
||||
}
|
||||
}
|
||||
$files=@(Get-ChildItem -LiteralPath $base -File -Recurse -ErrorAction Stop | Where-Object Extension -in @('.psd1','.psm1','.cdxml','.dll','.ps1xml') | Sort-Object FullName)
|
||||
if($files.Count -lt 1 -or $files.Count -gt 100){throw 'Unexpected native SMB module inventory.'}
|
||||
$hashes=[ordered]@{}
|
||||
foreach($file in $files){
|
||||
if($file.Length -gt 16MB -or ($file.Attributes -band [IO.FileAttributes]::ReparsePoint)){throw 'Unsupported SMB module source.'}
|
||||
$hashes[$file.FullName]=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256 -ErrorAction Stop).Hash
|
||||
}
|
||||
[pscustomobject]@{Commands=[pscustomobject]$commands;Files=[pscustomobject]$hashes}
|
||||
}
|
||||
|
||||
function ConvertTo-WelaSmbRuntimeConfiguration {
|
||||
param($Configuration,[ValidateSet('Server','Client')][string]$Side)
|
||||
if($Configuration.CimClass.CimClassName -cne "MSFT_Smb${Side}Configuration"){throw 'Expected one actual native SMB configuration CIM instance.'}
|
||||
$properties=@($Configuration.CimInstanceProperties | Sort-Object Name)
|
||||
if($properties.Count -lt 3 -or $properties.Count -gt 160){throw 'Unexpected SMB configuration property count.'}
|
||||
$result=[ordered]@{}
|
||||
foreach($property in $properties) {
|
||||
if($result.Contains($property.Name)){throw 'Duplicate SMB configuration property.'}
|
||||
$value=$property.Value
|
||||
foreach($item in @($value)) {
|
||||
if($null -ne $item -and $item -isnot [bool] -and $item -isnot [string] -and
|
||||
$item -isnot [byte] -and $item -isnot [uint16] -and $item -isnot [uint32] -and $item -isnot [uint64] -and
|
||||
$item -isnot [int16] -and $item -isnot [int32] -and $item -isnot [int64]){throw "Unsupported native configuration value: $($property.Name)"}
|
||||
if($item -is [string] -and $item.Length -gt 8192){throw 'Native configuration string exceeds bound.'}
|
||||
}
|
||||
if(@($value).Count -gt 128){throw 'Native configuration array exceeds bound.'}
|
||||
$result[$property.Name]=[pscustomobject]@{CimType=$property.CimType.ToString();Value=$value}
|
||||
}
|
||||
$component=if($Side -eq 'Server'){'LanmanServer'}else{'LanmanWorkstation'}
|
||||
foreach($definition in @(Get-WelaSmbAuditDefinitions | Where-Object Component -eq $component)) {
|
||||
if(-not $result.Contains($definition.Name) -or $result[$definition.Name].Value -isnot [bool] -or $result[$definition.Name].CimType -cne 'Boolean') {
|
||||
throw "Native getter lacks the exact Boolean property $($definition.Name)."
|
||||
}
|
||||
}
|
||||
[pscustomobject]$result
|
||||
}
|
||||
|
||||
function Get-WelaSmbRuntimeState {
|
||||
$hostState=Get-WelaSmbAuditHost
|
||||
if($hostState.Status -ne 'Candidate'){throw "SMB runtime activation is $($hostState.Status): $($hostState.Diagnostic)"}
|
||||
$commands=Get-WelaSmbRuntimeCommands
|
||||
$policies=[ordered]@{}
|
||||
foreach($definition in Get-WelaSmbAuditDefinitions) {
|
||||
$capability=Get-WelaSmbAuditCapability -Definition $definition -HostState $hostState
|
||||
if($capability.Status -ne 'Supported'){throw "Unverified $($definition.Component)/$($definition.Name): $($capability.Diagnostic)"}
|
||||
$policies["$($definition.Component)/$($definition.Name)"]=[pscustomobject]@{
|
||||
Path=$definition.Path;Name=$definition.Name;AdmxSha256=$capability.AdmxSha256
|
||||
Policy=Get-WelaRegistryState -Path $definition.Path -Name $definition.Name
|
||||
}
|
||||
}
|
||||
$configurations=[ordered]@{}
|
||||
foreach($side in @('Server','Client')) {
|
||||
$command="SmbShare\Get-Smb${side}Configuration"
|
||||
$native=@(& $command -ErrorAction Stop)
|
||||
if($native.Count -ne 1){throw 'Expected exactly one native SMB configuration.'}
|
||||
$configurations[$side]=ConvertTo-WelaSmbRuntimeConfiguration -Configuration $native[0] -Side $side
|
||||
}
|
||||
[pscustomobject][ordered]@{Computer=[Environment]::MachineName;Host=$hostState;Commands=$commands;Sources=Get-WelaSmbRuntimeSources;Policies=[pscustomobject]$policies;Configurations=[pscustomobject]$configurations}
|
||||
}
|
||||
|
||||
function Get-WelaSmbRuntimePlan {
|
||||
param($State)
|
||||
foreach($definition in Get-WelaSmbAuditDefinitions) {
|
||||
$id="$($definition.Component)/$($definition.Name)"
|
||||
$policy=$State.Policies.$id.Policy
|
||||
$side=if($definition.Component -eq 'LanmanServer'){'Server'}else{'Client'}
|
||||
$value=$State.Configurations.$side.($definition.Name).Value
|
||||
$compatible=($policy.ValueExists -is [bool] -and -not $policy.ValueExists) -or
|
||||
($policy.ValueExists -eq $true -and $policy.Type -ceq 'DWord' -and
|
||||
($policy.Value -is [int] -or $policy.Value -is [long] -or $policy.Value -is [uint32]) -and $policy.Value -eq 1)
|
||||
[pscustomobject][ordered]@{Id=$id;Side=$side;Name=$definition.Name;Before=$value;Desired=$true;Policy=$policy
|
||||
Status=$(if(-not $compatible){'BlockedPolicy'}elseif($value){'AlreadyActive'}else{'ActivationRequired'})
|
||||
Diagnostic=$(if(-not $compatible){'Existing policy is not absent or DWORD 1; review its authority. It will not be overwritten.'}elseif($policy.ValueExists){'Policy DWORD 1 and runtime Boolean are separate observations.'}else{'Policy value is absent; explicit activation changes native local configuration only.'})}
|
||||
}
|
||||
}
|
||||
|
||||
function Set-WelaSmbRuntimeFlag {
|
||||
param([string]$Id)
|
||||
$matches=@(Get-WelaSmbAuditDefinitions | Where-Object {"$($_.Component)/$($_.Name)" -ceq $Id})
|
||||
if($matches.Count -ne 1){throw 'Unknown SMB audit switch.'}
|
||||
$definition=$matches[0]
|
||||
$side=if($definition.Component -eq 'LanmanServer'){'Server'}else{'Client'}
|
||||
$command="SmbShare\Set-Smb${side}Configuration"
|
||||
$parameters=@{Confirm=$false;Force=$true;ErrorAction='Stop'}
|
||||
$parameters[$definition.Name]=$true
|
||||
$null=& $command @parameters
|
||||
}
|
||||
|
||||
function Write-WelaSmbRuntimeReceipt {
|
||||
param([string]$Root,[string]$Name,$Value)
|
||||
if($Name -notmatch '^(plan|result|[1-6]-(pending|confirmed))\.json$'){throw 'Unexpected receipt filename.'}
|
||||
$null=Resolve-WelaArrivalPath $Root
|
||||
$path=Join-Path $Root $Name
|
||||
$bytes=[Text.UTF8Encoding]::new($false).GetBytes((Get-WelaSmbRuntimeKey $Value))
|
||||
if($bytes.Length -gt 4MB){throw 'SMB activation receipt exceeds bound.'}
|
||||
$stream=[IO.File]::Open($path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
|
||||
try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)}finally{$stream.Dispose()}
|
||||
$expected=Get-WelaArrivalHash $bytes
|
||||
if((Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() -cne $expected){throw 'SMB receipt readback differs.'}
|
||||
[pscustomobject]@{Name=$Name;Bytes=$bytes.Length;Sha256=$expected}
|
||||
}
|
||||
|
||||
function Invoke-WelaSmbRuntimeActivation {
|
||||
param([ValidateSet('Plan','Activate')][string]$Action='Plan',[string]$OutputPath,[switch]$Auto,[switch]$DryRun)
|
||||
if($DryRun -and $Action -ne 'Activate'){throw 'DryRun requires SmbRuntimeAction Activate.'}
|
||||
if($Action -eq 'Plan' -and ($Auto -or $OutputPath)){throw 'Plan reads only; Auto and OutputPath apply to Activate.'}
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaSmbRuntimeActivation';Action=$Action;DryRun=[bool]$DryRun;RecordedUtc=[DateTime]::UtcNow.ToString('o')
|
||||
Status='Unverified';ExitCode=1;Before=$null;After=$null;Controls=@();Results=@();Artifacts=@();OutputPath=$null;Diagnostic=''
|
||||
VerificationScope='Native local audit switches at the recorded observations; policy authority and persistence are unknown';ReadyRuleCredit=0;EventGeneration='Not tested';Forwarding='Not tested'}
|
||||
try {
|
||||
$state=Get-WelaSmbRuntimeState;$report.Before=$state
|
||||
$report.Controls=@(Get-WelaSmbRuntimePlan $state)
|
||||
if(@($report.Controls | Where-Object Status -eq BlockedPolicy).Count){throw 'One or more policy values conflict or are malformed. No audit flags were changed.'}
|
||||
if($Action -eq 'Plan' -or $DryRun){$report.Status=if($DryRun){'DryRun'}else{'Planned'};$report.ExitCode=0;return $report}
|
||||
if(-not $OutputPath){throw 'Activate requires a new SmbRuntimeOutputPath on a local fixed drive.'}
|
||||
$output=New-WelaArrivalOutput -Path $OutputPath -SourcePath $script:ScriptRoot;$report.OutputPath=$output
|
||||
$report.Artifacts+=Write-WelaSmbRuntimeReceipt $output 'plan.json' ([pscustomobject]@{State=$state;Controls=$report.Controls})
|
||||
$expectedKey=Get-WelaSmbRuntimeKey $state
|
||||
$index=0;$stopped=$false
|
||||
foreach($control in $report.Controls) {
|
||||
$index++
|
||||
$row=[pscustomobject][ordered]@{Id=$control.Id;Before=$control.Before;After=$null;Status='Skipped';Diagnostic='';PendingReceipt=$null;ConfirmedReceipt=$null}
|
||||
$report.Results+= $row
|
||||
if($stopped){$row.Diagnostic='A prior activation failed; no further changes were attempted.';continue}
|
||||
try {
|
||||
$fresh=Get-WelaSmbRuntimeState
|
||||
if((Get-WelaSmbRuntimeKey $fresh) -cne $expectedKey){throw 'Host, source, policy or native configuration drifted after the snapshot.'}
|
||||
if($control.Before){$row.After=$true;$row.Status='AlreadyActive';continue}
|
||||
if(-not $Auto -and (Read-Host "Activate only SMB audit flag $($control.Id)? (y/N)") -cnotin @('y','Y')){$row.Diagnostic='Declined by operator.';continue}
|
||||
$row.PendingReceipt=Write-WelaSmbRuntimeReceipt $output "$index-pending.json" ([pscustomobject]@{Kind='Pending';Id=$control.Id;Before=$fresh;Desired=$true;RecordedUtc=[DateTime]::UtcNow.ToString('o')})
|
||||
# Re-read after interaction and durable intent, immediately before the setter.
|
||||
if((Get-WelaSmbRuntimeKey (Get-WelaSmbRuntimeState)) -cne $expectedKey){throw 'Context drifted before the native setter; activation refused.'}
|
||||
Set-WelaSmbRuntimeFlag -Id $control.Id
|
||||
$after=Get-WelaSmbRuntimeState;$row.After=$after.Configurations.($control.Side).($control.Name).Value
|
||||
# The only permitted delta is this one Boolean. All policies and every
|
||||
# other native configuration property (including security) must match.
|
||||
$next=Get-WelaSmbRuntimeKey $fresh | ConvertFrom-Json
|
||||
$next.Configurations.($control.Side).($control.Name).Value=$true
|
||||
if((Get-WelaSmbRuntimeKey $after) -cne (Get-WelaSmbRuntimeKey $next)){throw 'Native readback did not show exactly the requested audit-only delta.'}
|
||||
$row.ConfirmedReceipt=Write-WelaSmbRuntimeReceipt $output "$index-confirmed.json" ([pscustomobject]@{Kind='Confirmed';Id=$control.Id;Pending=$row.PendingReceipt;After=$after;RecordedUtc=[DateTime]::UtcNow.ToString('o')})
|
||||
$state=$after;$expectedKey=Get-WelaSmbRuntimeKey $state
|
||||
$row.Status='Activated';$row.Diagnostic='Native Boolean True observed; policy tuple and all other configuration properties preserved.'
|
||||
}catch{$row.Status='Failed';$row.Diagnostic=$_.Exception.Message;$stopped=$true}
|
||||
}
|
||||
$report.After=Get-WelaSmbRuntimeState
|
||||
if((Get-WelaSmbRuntimeKey $report.After) -cne $expectedKey){throw 'Final context differs from the last verified configuration. Review partial receipts; no automatic rollback is attempted.'}
|
||||
if(@($report.Results | Where-Object Status -notin @('Activated','AlreadyActive')).Count){throw 'Some flags were not activated. Inspect per-control results and receipts.'}
|
||||
$report.Status='RuntimeAuditingActive';$report.ExitCode=0
|
||||
}catch{$report.Diagnostic=$_.Exception.Message}
|
||||
if($report.OutputPath){$null=Write-WelaSmbRuntimeReceipt $report.OutputPath 'result.json' $report}
|
||||
$report
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
# Explicit creation of one new, narrowly scoped collector firewall rule.
|
||||
function ConvertTo-WelaIngressAddress {
|
||||
param([string]$Value,[switch]$Remote,[switch]$Observed)
|
||||
if($Value -cnotmatch '^([0-9]{1,3}\.){3}[0-9]{1,3}(/([0-9]{1,2}|([0-9]{1,3}\.){3}[0-9]{1,3}))?$'){throw 'An exact canonical IPv4 address or remote /24-/32 network is required.'}
|
||||
$parts=$Value.Split('/');$octets=$parts[0].Split('.');$number=0L
|
||||
foreach($octet in $octets){if([int]$octet -gt 255 -or ([int]$octet).ToString() -cne $octet){throw 'Noncanonical IPv4 address.'};$number=($number -shl 8)+[int]$octet}
|
||||
if([int]$octets[0] -in @(0,127) -or [int]$octets[0] -ge 224 -or $parts[0] -eq '169.254.0.0'){throw 'Unspecified, loopback or multicast/reserved addresses are unsupported.'}
|
||||
$prefix=32
|
||||
if($parts.Count -eq 2){
|
||||
if(-not $Remote){throw 'Local addresses must be exact assigned IPv4 addresses.'}
|
||||
if($parts[1].Contains('.')){
|
||||
if(-not $Observed){throw 'Use a numeric CIDR prefix.'}
|
||||
$mask=0L;foreach($piece in $parts[1].Split('.')){if([int]$piece -gt 255){throw 'Invalid netmask.'};$mask=($mask -shl 8)+[int]$piece}
|
||||
$prefix=0;while($prefix -lt 32 -and ($mask -band (1L -shl (31-$prefix)))){$prefix++}
|
||||
$expected=if($prefix -eq 0){0L}else{(0xffffffffL -shl (32-$prefix)) -band 0xffffffffL}
|
||||
if($mask -ne $expected){throw 'Noncontiguous netmask.'}
|
||||
}else{$prefix=[int]$parts[1];if($prefix.ToString() -cne $parts[1]){throw 'Noncanonical prefix.'}}
|
||||
if($prefix -lt 24 -or $prefix -gt 32 -or ($number -band ((1L -shl (32-$prefix))-1)) -ne 0){throw 'Remote scopes require aligned /24-/32 networks.'}
|
||||
}
|
||||
if($prefix -eq 32){$parts[0]}else{$parts[0]+'/'+$prefix}
|
||||
}
|
||||
function Get-WelaIngressSelection {
|
||||
param([string]$Name,[object[]]$LocalAddresses,[object[]]$RemoteAddresses)
|
||||
if($Name -cnotmatch '^WELA-WEC-[A-Za-z0-9][A-Za-z0-9-]{0,63}$'){throw 'Rule name must start WELA-WEC- and contain only letters, digits and hyphens.'}
|
||||
if($LocalAddresses.Count -lt 1 -or $LocalAddresses.Count -gt 8 -or $RemoteAddresses.Count -lt 1 -or $RemoteAddresses.Count -gt 16){throw 'Select 1-8 local addresses and 1-16 remote scopes.'}
|
||||
$local=@();$remote=@()
|
||||
foreach($value in $LocalAddresses){if($value -isnot [string]){throw 'Address must be a string.'};$local+=ConvertTo-WelaIngressAddress $value}
|
||||
foreach($value in $RemoteAddresses){if($value -isnot [string]){throw 'Address must be a string.'};$remote+=ConvertTo-WelaIngressAddress $value -Remote}
|
||||
if(@($local|Select-Object -Unique).Count -ne $local.Count -or @($remote|Select-Object -Unique).Count -ne $remote.Count){throw 'Duplicate address scopes are unsupported.'}
|
||||
[pscustomobject][ordered]@{Name=$Name;LocalAddresses=@($local|Sort-Object);RemoteAddresses=@($remote|Sort-Object)}
|
||||
}
|
||||
function Get-WelaIngressSources {
|
||||
$root=Split-Path $PSScriptRoot -Parent;$sources=[ordered]@{}
|
||||
foreach($name in @('WELA.ps1','scripts/WecIngress.ps1','scripts/WecUpdate.ps1','scripts/WefArrival.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){
|
||||
$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $root $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
|
||||
}
|
||||
$sources|ConvertTo-Json -Compress
|
||||
}
|
||||
function Get-WelaIngressContext {
|
||||
$reader=Get-WelaChannelReader;$hostState=Get-WelaChannelReadHost
|
||||
if(-not $reader.ElevatedAdministrator -or $hostState.ProductType -ne 3 -or $hostState.DomainRole -notin @(2,3) -or $hostState.Build -notin @(20348,26100)){throw 'An elevated native Server 2022/2025 member or standalone collector is required.'}
|
||||
$services=@();foreach($name in @('BFE','MpsSvc','WinRM','Wecsvc')){
|
||||
$found=@(Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction Stop)
|
||||
if($found.Count -ne 1 -or ($name -in @('BFE','MpsSvc') -and $found[0].State -ne 'Running')){throw 'Firewall services must run and WinRM/Wecsvc must be installed.'}
|
||||
$services+=[ordered]@{Name=$name;State=[string]$found[0].State;StartMode=[string]$found[0].StartMode}
|
||||
}
|
||||
$profiles=@(NetSecurity\Get-NetFirewallProfile -PolicyStore ActiveStore -ErrorAction Stop|Sort-Object Name|Select-Object Name,Enabled,DefaultInboundAction,DefaultOutboundAction,AllowInboundRules,AllowLocalFirewallRules)
|
||||
$domain=@($profiles|Where-Object Name -eq 'Domain')
|
||||
if($profiles.Count -ne 3 -or $domain.Count -ne 1 -or [string]$domain[0].Enabled -ne 'True' -or [string]$domain[0].AllowLocalFirewallRules -eq 'False' -or [string]$domain[0].AllowInboundRules -eq 'False'){throw 'Domain firewall must be enabled and permit local inbound rules.'}
|
||||
$addresses=@(NetTCPIP\Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop|Where-Object AddressState -eq 'Preferred'|ForEach-Object IPAddress|Sort-Object -Unique)
|
||||
[pscustomobject][ordered]@{Host=$hostState;MachineGuid=(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop).MachineGuid;Reader=[ordered]@{Sid=$reader.UserSid;Logon=$reader.AuthenticationId;Groups=$reader.GroupSids};Services=$services;Profiles=$profiles;Addresses=$addresses}
|
||||
}
|
||||
function Read-WelaIngressRules {
|
||||
param([string]$Store)
|
||||
$rules=@(NetSecurity\Get-NetFirewallRule -PolicyStore $Store -ErrorAction Stop|Select-Object -First 4097)
|
||||
if($rules.Count -gt 4096){throw 'Firewall inventory exceeds the 4096-rule bound.'}
|
||||
$rules
|
||||
}
|
||||
function Assert-WelaIngressAbsent {
|
||||
param([string]$Name)
|
||||
foreach($store in @('PersistentStore','ActiveStore')){if(@(Read-WelaIngressRules $store|Where-Object Name -eq $Name).Count){throw 'The selected rule name already exists; existing rules are never replaced.'}}
|
||||
}
|
||||
function New-WelaIngressNativeRule {
|
||||
param($Selection)
|
||||
$null=NetSecurity\New-NetFirewallRule -PolicyStore PersistentStore -Name $Selection.Name -DisplayName $Selection.Name -Description 'WELA reviewed collector ingress; TCP 5985, Domain profile, explicit IPv4 scopes.' -Group 'WELA reviewed collector ingress' -Enabled True -Profile Domain -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5985 -RemotePort Any -LocalAddress $Selection.LocalAddresses -RemoteAddress $Selection.RemoteAddresses -EdgeTraversalPolicy Block -LooseSourceMapping $false -LocalOnlyMapping $false -Authentication NotRequired -Encryption NotRequired -OverrideBlockRules $false -ErrorAction Stop
|
||||
}
|
||||
function Read-WelaIngressRule {
|
||||
param([string]$Store,[string]$Name)
|
||||
$rule=@(Read-WelaIngressRules $Store|Where-Object Name -eq $Name)
|
||||
if($rule.Count -ne 1){throw 'Exactly one selected rule must be observed.'}
|
||||
$r=$rule[0];$filters=[ordered]@{}
|
||||
foreach($kind in @('Port','Address','Application','Service','Interface','InterfaceType','Security')){
|
||||
$command='NetSecurity\Get-NetFirewall'+$kind+'Filter';$items=@(&$command -AssociatedNetFirewallRule $r -ErrorAction Stop)
|
||||
if($items.Count -ne 1){throw "Ambiguous $kind filter."};$filters[$kind]=$items[0]
|
||||
}
|
||||
[pscustomobject]@{Store=$Store;Rule=$r;Filters=$filters}
|
||||
}
|
||||
function ConvertTo-WelaIngressEvidence {
|
||||
param($Observed)
|
||||
# Project the inspected fields, not recursive CIM class/session metadata.
|
||||
$fields=[ordered]@{
|
||||
Rule=@('Name','DisplayName','Description','Group','Enabled','Profile','Direction','Action','EdgeTraversalPolicy','LooseSourceMapping','LocalOnlyMapping','PolicyStoreSourceType','Owner','Platform','PrimaryStatus','EnforcementStatus')
|
||||
Port=@('Protocol','LocalPort','RemotePort','IcmpType','DynamicTarget')
|
||||
Address=@('LocalAddress','RemoteAddress')
|
||||
Application=@('Program','Package')
|
||||
Service=@('Service');Interface=@('InterfaceAlias');InterfaceType=@('InterfaceType')
|
||||
Security=@('Authentication','Encryption','OverrideBlockRules','LocalUser','RemoteUser','RemoteMachine')
|
||||
}
|
||||
$result=[ordered]@{Store=$Observed.Store}
|
||||
foreach($kind in $fields.Keys){
|
||||
$item=if($kind -eq 'Rule'){$Observed.Rule}else{$Observed.Filters[$kind]};$values=[ordered]@{}
|
||||
foreach($name in $fields[$kind]){
|
||||
$property=$item.PSObject.Properties[$name]
|
||||
$values[$name]=[ordered]@{Present=($null -ne $property);Value=$(if($null -eq $property -or $null -eq $property.Value){$null}else{@($property.Value|ForEach-Object {[string]$_})})}
|
||||
}
|
||||
$result[$kind]=$values
|
||||
}
|
||||
[pscustomobject]$result
|
||||
}
|
||||
function Assert-WelaIngressReadback {
|
||||
param($Observed,$Selection)
|
||||
$r=$Observed.Rule;$f=$Observed.Filters
|
||||
foreach($field in @('Name','DisplayName')){if([string]$r.$field -cne $Selection.Name){throw "Rule $field differs."}}
|
||||
$fixed=@{Description='WELA reviewed collector ingress; TCP 5985, Domain profile, explicit IPv4 scopes.';Group='WELA reviewed collector ingress';Enabled='True';Profile='Domain';Direction='Inbound';Action='Allow';EdgeTraversalPolicy='Block';LooseSourceMapping='False';LocalOnlyMapping='False';PolicyStoreSourceType='Local'}
|
||||
foreach($field in $fixed.Keys){if([string]$r.$field -cne $fixed[$field]){throw "Rule $field differs."}}
|
||||
if($r.Owner -or @($r.Platform|Where-Object {$_}).Count){throw 'Unexpected rule owner or platform restriction.'}
|
||||
if([string]$f.Port.Protocol -notin @('TCP','6') -or [string]$f.Port.LocalPort -ne '5985' -or [string]$f.Port.RemotePort -ne 'Any' -or [string]$f.Port.IcmpType -ne 'Any' -or [string]$f.Port.DynamicTarget -ne 'Any'){throw 'Port filter differs.'}
|
||||
$local=@($f.Address.LocalAddress|ForEach-Object {ConvertTo-WelaIngressAddress $_}|Sort-Object)
|
||||
$remote=@($f.Address.RemoteAddress|ForEach-Object {ConvertTo-WelaIngressAddress $_ -Remote -Observed}|Sort-Object)
|
||||
if(($local -join '|') -cne ($Selection.LocalAddresses -join '|') -or ($remote -join '|') -cne ($Selection.RemoteAddresses -join '|')){throw 'Address filters differ.'}
|
||||
foreach($pair in @(@('Application','Program'),@('Service','Service'),@('Interface','InterfaceAlias'),@('InterfaceType','InterfaceType'),@('Security','LocalUser'),@('Security','RemoteUser'),@('Security','RemoteMachine'))){if([string]$f[$pair[0]].($pair[1]) -ne 'Any'){throw "Unexpected $($pair -join '/') filter: '$($f[$pair[0]].($pair[1]))'."}}
|
||||
# Native Package is a nullable SID, unlike the Program 'Any' alias. A
|
||||
# present empty/null Package means no package restriction; missing is unknown.
|
||||
$package=$f.Application.PSObject.Properties['Package']
|
||||
if($null -eq $package -or ($null -ne $package.Value -and ($package.Value -isnot [string] -or $package.Value -cnotin @('','Any')))){throw 'Unexpected or missing Application/Package filter.'}
|
||||
if([string]$f.Security.Authentication -ne 'NotRequired' -or [string]$f.Security.Encryption -ne 'NotRequired' -or [string]$f.Security.OverrideBlockRules -ne 'False'){throw 'Security filter differs.'}
|
||||
}
|
||||
function Assert-WelaIngressPlan {
|
||||
param($Plan)
|
||||
Assert-WelaArrivalObject $Plan @('SchemaVersion','Kind','Selection','ContextKey','Sources','RecordedUtc')
|
||||
if(($Plan.SchemaVersion -isnot [int] -and $Plan.SchemaVersion -isnot [long]) -or $Plan.SchemaVersion -ne 1 -or $Plan.Kind -cne 'WelaCollectorIngressPlan' -or $Plan.ContextKey -isnot [string] -or $Plan.Sources -isnot [string]){throw 'Unknown ingress plan.'}
|
||||
Assert-WelaArrivalObject $Plan.Selection @('Name','LocalAddresses','RemoteAddresses')
|
||||
if($Plan.Selection.Name -isnot [string] -or $Plan.Selection.LocalAddresses -isnot [array] -or $Plan.Selection.RemoteAddresses -isnot [array]){throw 'Mistyped ingress selection.'}
|
||||
$null=Get-WelaIngressSelection $Plan.Selection.Name $Plan.Selection.LocalAddresses $Plan.Selection.RemoteAddresses
|
||||
$null=ConvertTo-WelaArrivalUtc $Plan.RecordedUtc
|
||||
}
|
||||
function Invoke-WelaWecIngress {
|
||||
param([ValidateSet('Plan','Apply')][string]$Action='Plan',[string]$Name,[string[]]$LocalAddress,[string[]]$RemoteAddress,[string]$PlanPath,[string]$PlanHash,[Parameter(Mandatory)][string]$OutputPath)
|
||||
if($Action -eq 'Plan'){
|
||||
if(-not $Name -or -not $LocalAddress -or -not $RemoteAddress -or $PlanPath -or $PlanHash){throw 'Plan requires a new rule name and explicit local/remote IPv4 scopes, without a prior plan.'}
|
||||
$selection=Get-WelaIngressSelection $Name $LocalAddress $RemoteAddress;$inputFile=$null
|
||||
}else{
|
||||
if(-not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or $Name -or $LocalAddress -or $RemoteAddress){throw 'Apply accepts only a reviewed plan path, SHA256 and new output.'}
|
||||
$inputFile=Read-WelaWecUpdateFile $PlanPath
|
||||
}
|
||||
$ErrorActionPreference='Stop'
|
||||
$sourcePath=if($inputFile){$inputFile.Path}else{Join-Path (Split-Path $PSScriptRoot -Parent) 'WELA.ps1'}
|
||||
$output=New-WelaArrivalOutput $OutputPath $sourcePath
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaCollectorIngress';Action=$Action;Status='Refused';ExitCode=1;OutputPath=$output;PlanHash=$null;NativeCreateAttempted=$false;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;Scope='One local Domain-profile TCP5985 IPv4 allow rule only. Other rules may allow broader access; no listener, service, authentication, subscription, packet delivery or Sigma proof. Sysmon excluded.'}
|
||||
try {
|
||||
$context=Get-WelaIngressContext;$key=$context|ConvertTo-Json -Depth 16 -Compress;$sources=Get-WelaIngressSources
|
||||
if($Action -eq 'Apply'){
|
||||
if($inputFile.Hash -cne $PlanHash){throw 'Reviewed plan hash differs.'}
|
||||
$plan=ConvertFrom-WelaArrivalJson $inputFile.Text;Assert-WelaIngressPlan $plan
|
||||
if($plan.ContextKey -cne $key -or $plan.Sources -cne $sources){throw 'Host, reader, firewall context or source code differs from reviewed plan.'}
|
||||
$selection=Get-WelaIngressSelection $plan.Selection.Name $plan.Selection.LocalAddresses $plan.Selection.RemoteAddresses;$report.PlanHash=$inputFile.Hash
|
||||
}
|
||||
foreach($address in $selection.LocalAddresses){if($address -cnotin $context.Addresses){throw 'Every local address must currently be assigned and Preferred.'}}
|
||||
Assert-WelaIngressAbsent $selection.Name
|
||||
if($Action -eq 'Plan'){
|
||||
$plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaCollectorIngressPlan';Selection=$selection;ContextKey=$key;Sources=$sources;RecordedUtc=[DateTime]::UtcNow.ToString('o')};Assert-WelaIngressPlan $plan
|
||||
if((Get-WelaIngressContext|ConvertTo-Json -Depth 16 -Compress) -cne $key -or (Get-WelaIngressSources) -cne $sources){throw 'Context or code drift during planning.'}
|
||||
Assert-WelaIngressAbsent $selection.Name
|
||||
$artifact=Write-WelaWecUpdateArtifact $output 'plan.json' ($plan|ConvertTo-Json -Depth 20);$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired';$report.ExitCode=0
|
||||
}else{
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $inputFile.Text
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'before-create.json' ([ordered]@{Status='Pending';Selection=$selection;Context=$context;PlanHash=$PlanHash;RecordedUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 20)
|
||||
if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaIngressSources) -cne $sources -or (Get-WelaIngressContext|ConvertTo-Json -Depth 16 -Compress) -cne $key){throw 'Plan, context or source drift immediately before creation.'}
|
||||
Assert-WelaIngressAbsent $selection.Name
|
||||
$report.NativeCreateAttempted=$true;New-WelaIngressNativeRule $selection
|
||||
foreach($store in @('PersistentStore','ActiveStore')){
|
||||
$observed=Read-WelaIngressRule $store $selection.Name
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output ($store+'-after.json') ((ConvertTo-WelaIngressEvidence $observed)|ConvertTo-Json -Depth 8)
|
||||
Assert-WelaIngressReadback $observed $selection
|
||||
}
|
||||
if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaIngressSources) -cne $sources -or (Get-WelaIngressContext|ConvertTo-Json -Depth 16 -Compress) -cne $key){throw 'Context, code or plan changed after creation.'}
|
||||
$report.Status='CreatedAndVerified';$report.ExitCode=0
|
||||
}
|
||||
}catch{$report.Status=if($report.NativeCreateAttempted){'CreateAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message}
|
||||
$null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 20)
|
||||
$report
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
# Reviewed, existing-only Enabled changes; runtime observations are separate evidence.
|
||||
function Initialize-WelaWecStateNative {
|
||||
$path=Join-Path $PSScriptRoot 'WecStateNative.cs';$hash=(Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash
|
||||
if(-not('Wela.WecState.Edit' -as [type])){Add-Type -Path $path -ErrorAction Stop;$script:WelaWecStateNativeHash=$hash}
|
||||
if($script:WelaWecStateNativeHash -cne $hash){throw 'Loaded native state setter differs from source; start a fresh process.'}
|
||||
}
|
||||
function Get-WelaWecStateContext {
|
||||
$context=Get-WelaWecUpdateContext
|
||||
Initialize-WelaWecStateNative
|
||||
$identity=[Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
try {$context.Reader=[ordered]@{Name=$identity.Name;Sid=$identity.User.Value;AuthenticationType=$identity.AuthenticationType;ImpersonationLevel=[string]$identity.ImpersonationLevel;Groups=@($identity.Groups.Value|Sort-Object);TokenStatistics=[Wela.WecState.Edit]::TokenKey($identity.Token)}}finally{$identity.Dispose()}
|
||||
$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents')
|
||||
try {$context|Add-Member NoteProperty DestinationLog ([ordered]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;Mode=[string]$channel.LogMode;MaximumBytes=$channel.MaximumSizeInBytes;Path=$channel.LogFilePath;SecurityDescriptor=$channel.SecurityDescriptor})}finally{$channel.Dispose()}
|
||||
$context
|
||||
}
|
||||
function Get-WelaWecStateReviewKey {
|
||||
param($Context)
|
||||
# Separate CLI invocations can hold different token objects in the same logon.
|
||||
# Bind plan/apply to the actual logon, and compare complete token statistics
|
||||
# within each operation to reject privilege or token changes during writes.
|
||||
$copy=$Context|ConvertTo-Json -Depth 16 -Compress|ConvertFrom-Json
|
||||
$copy.Reader.TokenStatistics=$Context.Reader.TokenStatistics.Substring(16,16)
|
||||
$copy|ConvertTo-Json -Depth 16 -Compress
|
||||
}
|
||||
function Get-WelaWecStateSources {
|
||||
$root=Split-Path $PSScriptRoot -Parent;$sources=[ordered]@{}
|
||||
foreach($name in @('scripts/WecState.ps1','scripts/WecStateNative.cs','scripts/WecUpdate.ps1','modules/WefSubscriptions.psm1','modules/WecSubscriptionXml.cs','scripts/Configuration.ps1','scripts/ControlApplicability.ps1','scripts/WefArrival.ps1','modules/AuditProfiles.psm1','scripts/WecRuntime.ps1','scripts/WecRuntimeNative.cs')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $root $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
|
||||
$sources|ConvertTo-Json -Compress
|
||||
}
|
||||
function Get-WelaWecStateDefinition {
|
||||
param([string]$Xml,[string[]]$SourceSids)
|
||||
$model=ConvertFrom-WelaWefSubscription -Xml $Xml -SourceSids $SourceSids -Observed
|
||||
$doc=Read-WelaWefXml $Xml;$root=$doc.DocumentElement;$whole=Get-WelaWefXmlKey $root
|
||||
$ns=New-Object Xml.XmlNamespaceManager($doc.NameTable);$ns.AddNamespace('s',$root.NamespaceURI)
|
||||
$null=$root.RemoveChild($root.SelectSingleNode('s:Enabled',$ns))
|
||||
[pscustomobject]@{Id=$model.Id;Xml=$Xml;WholeKey=$whole;PreservedKey=(Get-WelaWefXmlKey $root);Enabled=$model.Definition.Enabled;QueryKey=$model.Query.Key;Description=$model.Definition.Description;SourceAuthorization=$model.Definition.SourceAuthorization}
|
||||
}
|
||||
function Read-WelaWecStateDefinition {
|
||||
param([string]$Id,[string[]]$SourceSids)
|
||||
if($Id -cnotmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$'){throw 'Invalid exact subscription ID.'}
|
||||
$definition=Get-WelaWecStateDefinition (Read-WelaWecSubscriptionXml $Id) $SourceSids
|
||||
if($definition.Id -cne $Id){throw 'Native subscription identity differs from the selected ID.'}
|
||||
$definition
|
||||
}
|
||||
function New-WelaWecStateEdit {
|
||||
param($Before)
|
||||
Initialize-WelaWecStateNative
|
||||
$edit=[Wela.WecState.Edit]::new($Before.Id)
|
||||
try {
|
||||
if($edit.OriginalEnabled -ne $Before.Enabled -or (ConvertFrom-WelaWefQuery $edit.OriginalQuery).Key -cne $Before.QueryKey -or $edit.OriginalDescription -cne $Before.Description -or $edit.OriginalAuthorization -cne $Before.SourceAuthorization){throw 'Native handle state differs from the reviewed definition.'}
|
||||
$edit
|
||||
}catch{$edit.Dispose();throw}
|
||||
}
|
||||
function Assert-WelaWecStatePlan {
|
||||
param($Plan)
|
||||
Assert-WelaArrivalObject $Plan @('SchemaVersion','Kind','Id','SourceSids','ContextKey','Sources','BeforeXml','DesiredEnabled','RecordedUtc')
|
||||
if(($Plan.SchemaVersion -isnot [int] -and $Plan.SchemaVersion -isnot [long]) -or $Plan.SchemaVersion -ne 1 -or $Plan.Kind -cne 'WelaWecStatePlan' -or $Plan.Id -isnot [string] -or $Plan.Id -cnotmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$' -or $Plan.SourceSids -isnot [array] -or $Plan.ContextKey -isnot [string] -or $Plan.Sources -isnot [string] -or $Plan.BeforeXml -isnot [string] -or $Plan.DesiredEnabled -isnot [bool]){throw 'Unknown or mistyped state plan.'}
|
||||
$null=ConvertTo-WelaArrivalUtc $Plan.RecordedUtc
|
||||
foreach($sid in $Plan.SourceSids){if($sid -isnot [string]){throw 'Source SID must be a string.'}}
|
||||
$null=Get-WelaWefAuthorization $Plan.SourceSids
|
||||
$before=Get-WelaWecStateDefinition $Plan.BeforeXml $Plan.SourceSids
|
||||
if($before.Id -cne $Plan.Id){throw 'Plan identity contradicts its original subscription.'}
|
||||
}
|
||||
function Read-WelaWecStateRuntime {
|
||||
param([string]$Id)
|
||||
try {Get-WelaWecRuntime -Id $Id -MaximumSources 32}
|
||||
catch {[pscustomobject]@{Status='Unknown';Diagnostic=$_.Exception.Message;ReadyRuleCredit=0}}
|
||||
}
|
||||
function Assert-WelaWecStateArtifacts {
|
||||
param([string]$Root,$Artifacts)
|
||||
foreach($artifact in $Artifacts){if((Get-FileHash -LiteralPath (Join-Path $Root $artifact.Name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'Saved state evidence changed before completion.'}}
|
||||
}
|
||||
function Invoke-WelaWecState {
|
||||
param([ValidateSet('Plan','Apply')][string]$Action='Plan',[string]$Id,[string[]]$SourceSids,[ValidateSet('Enabled','Disabled')][string]$State,[string]$PlanPath,[string]$PlanHash,[Parameter(Mandatory)][string]$OutputPath)
|
||||
$ErrorActionPreference='Stop'
|
||||
if($Action -eq 'Plan'){
|
||||
if(-not $Id -or -not $SourceSids -or -not $State -or $PlanPath -or $PlanHash){throw 'Plan requires exact ID, explicit source SIDs, Enabled or Disabled state and new output; no prior plan.'}
|
||||
$sourceInput=$null;$sourcePath=Join-Path (Split-Path $PSScriptRoot -Parent) 'scripts'
|
||||
}else{
|
||||
if(-not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or $Id -or $SourceSids -or $State){throw 'Apply accepts only a reviewed plan path, its SHA256 and new output.'}
|
||||
$sourceInput=Read-WelaWecUpdateFile $PlanPath;$sourcePath=$sourceInput.Path
|
||||
}
|
||||
$output=New-WelaArrivalOutput $OutputPath $sourcePath
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaWecState';Action=$Action;Status='Refused';ExitCode=1;RecordedUtc=[DateTime]::UtcNow.ToString('o');OutputPath=$output;PlanHash=$null;BeforeEnabled=$null;DesiredEnabled=$null;NativeSaveAttempted=$false;NativeErrorCode=$null;After=$null;RuntimeBefore=$null;RuntimeAfter=$null;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;Delivery='Not established';BookmarkContinuity='Not established';Scope='Only Enabled on one existing native source-initiated subscription. Disable interrupts collection; enable/save activates it. No listener, firewall, service or authorization changes. Sysmon excluded.'}
|
||||
$edit=$null;$plan=$null;$before=$null
|
||||
try {
|
||||
$context=Get-WelaWecStateContext;$contextKey=$context|ConvertTo-Json -Depth 16 -Compress;$sources=Get-WelaWecStateSources
|
||||
if($Action -eq 'Plan'){
|
||||
$before=Read-WelaWecStateDefinition $Id $SourceSids
|
||||
$plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaWecStatePlan';Id=$Id;SourceSids=@($SourceSids);ContextKey=(Get-WelaWecStateReviewKey $context);Sources=$sources;BeforeXml=$before.Xml;DesiredEnabled=($State -eq 'Enabled');RecordedUtc=[DateTime]::UtcNow.ToString('o')}
|
||||
Assert-WelaWecStatePlan $plan
|
||||
if($plan.DesiredEnabled -and -not $context.DestinationLog.Enabled){throw 'ForwardedEvents must already be enabled before planning activation; no channel changes are made.'}
|
||||
$report.RuntimeBefore=Read-WelaWecStateRuntime $Id
|
||||
if((Read-WelaWecStateDefinition $Id $SourceSids).WholeKey -cne $before.WholeKey -or ((Get-WelaWecStateContext|ConvertTo-Json -Depth 16 -Compress) -cne $contextKey) -or (Get-WelaWecStateSources) -cne $sources){throw 'Host, reader, implementation or subscription drift during planning.'}
|
||||
$planText=$plan|ConvertTo-Json -Depth 20
|
||||
if([Text.Encoding]::UTF8.GetByteCount($planText) -gt 4194304){throw 'Reviewed plan exceeds the four-MiB apply limit.'}
|
||||
$artifact=Write-WelaWecUpdateArtifact $output 'plan.json' $planText;$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired'
|
||||
}else{
|
||||
if($sourceInput.Hash -cne $PlanHash){throw 'Reviewed plan hash differs from the selected file bytes.'}
|
||||
$plan=ConvertFrom-WelaArrivalJson $sourceInput.Text;Assert-WelaWecStatePlan $plan;$report.PlanHash=$sourceInput.Hash
|
||||
if($plan.DesiredEnabled -and -not $context.DestinationLog.Enabled){throw 'ForwardedEvents must already be enabled before activation; no channel changes are made.'}
|
||||
if($plan.ContextKey -cne (Get-WelaWecStateReviewKey $context) -or $plan.Sources -cne $sources){throw 'Actual host/reader/token/service or implementation sources differ from the reviewed plan.'}
|
||||
$before=Get-WelaWecStateDefinition $plan.BeforeXml $plan.SourceSids
|
||||
$report.BeforeEnabled=$before.Enabled;$report.DesiredEnabled=$plan.DesiredEnabled
|
||||
$report.RuntimeBefore=Read-WelaWecStateRuntime $plan.Id
|
||||
if((Read-WelaWecStateDefinition $plan.Id $plan.SourceSids).WholeKey -cne $before.WholeKey){throw 'Current subscription differs from the reviewed complete definition.'}
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $sourceInput.Text
|
||||
if($before.Enabled -eq $plan.DesiredEnabled){$report.Status='AlreadyMatches'}else{
|
||||
$edit=New-WelaWecStateEdit $before
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'before-save.json' ([ordered]@{Status='Pending';RecordedUtc=[DateTime]::UtcNow.ToString('o');Context=$context;BeforeXml=$before.Xml;DesiredEnabled=$plan.DesiredEnabled;PlanHash=$sourceInput.Hash}|ConvertTo-Json -Depth 20)
|
||||
Assert-WelaWecStateArtifacts $output $report.Artifacts
|
||||
if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaWecStateSources) -cne $sources -or ((Get-WelaWecStateContext|ConvertTo-Json -Depth 16 -Compress) -cne $contextKey) -or (Read-WelaWecStateDefinition $plan.Id $plan.SourceSids).WholeKey -cne $before.WholeKey){throw 'Plan, code, context or complete subscription changed immediately before save.'}
|
||||
try {$edit.Save($plan.DesiredEnabled)}finally{$report.NativeSaveAttempted=[bool]$edit.SaveAttempted}
|
||||
$report.Status='SavedAwaitingReadback'
|
||||
}
|
||||
$report.RuntimeAfter=Read-WelaWecStateRuntime $plan.Id
|
||||
$after=Read-WelaWecStateDefinition $plan.Id $plan.SourceSids;$report.After=$after
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after.xml' $after.Xml
|
||||
if($after.Enabled -ne $plan.DesiredEnabled -or $after.PreservedKey -cne $before.PreservedKey -or ((Get-WelaWecStateContext|ConvertTo-Json -Depth 16 -Compress) -cne $contextKey) -or (Get-WelaWecStateSources) -cne $sources -or (Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Readback, preserved configuration, context, plan or implementation differs after operation.'}
|
||||
if($report.NativeSaveAttempted){$report.Status='StateChangedAndVerified'}
|
||||
}
|
||||
$report.BeforeEnabled=$before.Enabled;$report.DesiredEnabled=$plan.DesiredEnabled
|
||||
Assert-WelaWecStateArtifacts $output $report.Artifacts
|
||||
$report.ExitCode=0
|
||||
}catch{
|
||||
$report.Status=if($report.NativeSaveAttempted){'SaveAttemptedUnverified'}else{'Refused'};$report.ExitCode=1;$report.Diagnostic=$_.Exception.Message
|
||||
$errorObject=$_.Exception
|
||||
while($errorObject){if($errorObject -is [ComponentModel.Win32Exception]){$report.NativeErrorCode=$errorObject.NativeErrorCode;break};$errorObject=$errorObject.InnerException}
|
||||
if($report.NativeSaveAttempted -and $plan){
|
||||
# A failed activation can still persist Enabled. Never imply rollback.
|
||||
$report.RuntimeAfter=Read-WelaWecStateRuntime $plan.Id
|
||||
try {$report.After=Read-WelaWecStateDefinition $plan.Id $plan.SourceSids;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'failed-after.xml' $report.After.Xml}
|
||||
catch {$report.Diagnostic+=' Final definition unavailable: '+$_.Exception.Message}
|
||||
}
|
||||
}
|
||||
finally{if($edit){$edit.Dispose()}}
|
||||
$null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 32)
|
||||
$report
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Existing-only native WEC Enabled setter. No create/delete or other setters.
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
namespace Wela.WecState {
|
||||
public sealed class Edit : IDisposable {
|
||||
[StructLayout(LayoutKind.Explicit, Size=16)] struct Variant {
|
||||
[FieldOffset(0)] public int Boolean; [FieldOffset(8)] public uint Count; [FieldOffset(12)] public uint Type;
|
||||
}
|
||||
[DllImport("wecapi.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern IntPtr EcOpenSubscription(string name,uint access,uint flags);
|
||||
[DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcGetSubscriptionProperty(IntPtr handle,int property,uint flags,uint size,IntPtr value,out uint used);
|
||||
[DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcSetSubscriptionProperty(IntPtr handle,int property,uint flags,ref Variant value);
|
||||
[DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcSaveSubscription(IntPtr handle,uint flags);
|
||||
[DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcClose(IntPtr handle);
|
||||
[DllImport("advapi32.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool GetTokenInformation(IntPtr token,int information,IntPtr buffer,int size,out int used);
|
||||
IntPtr handle; readonly string name,oldQuery,oldDescription,oldAuthorization; readonly bool oldEnabled;
|
||||
public bool OriginalEnabled {get{return oldEnabled;}}
|
||||
public string OriginalQuery {get{return oldQuery;}}
|
||||
public string OriginalDescription {get{return oldDescription;}}
|
||||
public string OriginalAuthorization {get{return oldAuthorization;}}
|
||||
public bool SaveAttempted {get;private set;}
|
||||
// TOKEN_STATISTICS: TokenId, AuthenticationId and ModifiedId, plus token type.
|
||||
public static string TokenKey(IntPtr token) {
|
||||
IntPtr buffer=Marshal.AllocHGlobal(56);
|
||||
try {int used;if(!GetTokenInformation(token,10,buffer,56,out used))throw new Win32Exception(Marshal.GetLastWin32Error());if(used!=56)throw new InvalidOperationException("Unexpected TOKEN_STATISTICS size.");
|
||||
byte[] bytes=new byte[56];Marshal.Copy(buffer,bytes,0,bytes.Length);return BitConverter.ToString(bytes).Replace("-","");
|
||||
}finally{Marshal.FreeHGlobal(buffer);}
|
||||
}
|
||||
static object Read(IntPtr h,int property) {
|
||||
uint size=16;
|
||||
for(int attempt=0;attempt<3;attempt++) {
|
||||
IntPtr buffer=Marshal.AllocHGlobal((int)size);
|
||||
try {
|
||||
uint used;bool ok=EcGetSubscriptionProperty(h,property,0,size,buffer,out used);int error=Marshal.GetLastWin32Error();
|
||||
if(!ok){if(error!=122)throw new Win32Exception(error);if(used<=size||used>1048576)throw new InvalidOperationException("Invalid native property buffer size.");size=used;continue;}
|
||||
if(used<16||used>size)throw new InvalidOperationException("Invalid native property length.");
|
||||
int type=Marshal.ReadInt32(buffer,12);
|
||||
if(property==0){if(type!=1)throw new InvalidOperationException("Enabled is not a scalar Boolean.");int value=Marshal.ReadInt32(buffer);if(value!=0&&value!=1)throw new InvalidOperationException("Invalid native Boolean.");return value==1;}
|
||||
if(type==0&&property==6)return "";
|
||||
if(type!=4)throw new InvalidOperationException("Expected scalar native string.");
|
||||
IntPtr pointer=Marshal.ReadIntPtr(buffer);long offset=pointer.ToInt64()-buffer.ToInt64();
|
||||
if(pointer==IntPtr.Zero||offset<16||offset>used-2)throw new InvalidOperationException("Native string pointer is outside its buffer.");
|
||||
StringBuilder text=new StringBuilder();
|
||||
for(int i=0;i<524288&&offset+2L*i+2<=used;i++){char c=(char)(ushort)Marshal.ReadInt16(pointer,2*i);if(c==0)return text.ToString();text.Append(c);}
|
||||
throw new InvalidOperationException("Unterminated native string.");
|
||||
}finally{Marshal.FreeHGlobal(buffer);}
|
||||
}
|
||||
throw new InvalidOperationException("Native property changed repeatedly.");
|
||||
}
|
||||
void Check(IntPtr h) {
|
||||
if((bool)Read(h,0)!=oldEnabled||!String.Equals((string)Read(h,10),oldQuery,StringComparison.Ordinal)||!String.Equals((string)Read(h,6),oldDescription,StringComparison.Ordinal)||!String.Equals((string)Read(h,31),oldAuthorization,StringComparison.Ordinal))throw new InvalidOperationException("Native enabled/query/description/authorization changed since review.");
|
||||
}
|
||||
public Edit(string id) {
|
||||
if(String.IsNullOrWhiteSpace(id)||id.Length>128||id.IndexOf('\0')>=0)throw new ArgumentException("Invalid subscription ID.");
|
||||
name=id;handle=EcOpenSubscription(name,3,2);if(handle==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try{oldEnabled=(bool)Read(handle,0);oldQuery=(string)Read(handle,10);oldDescription=(string)Read(handle,6);oldAuthorization=(string)Read(handle,31);}catch{Dispose();throw;}
|
||||
}
|
||||
public void Save(bool enabled) {
|
||||
if(handle==IntPtr.Zero)throw new ObjectDisposedException("Edit");
|
||||
if(SaveAttempted)throw new InvalidOperationException("A native edit may be saved only once.");
|
||||
if(enabled==oldEnabled)throw new InvalidOperationException("Idempotent state must not save or reactivate a subscription.");
|
||||
IntPtr fresh=EcOpenSubscription(name,1,2);if(fresh==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try{Check(fresh);}finally{EcClose(fresh);}
|
||||
Variant value=new Variant{Boolean=enabled?1:0,Count=0,Type=1};
|
||||
if(!EcSetSubscriptionProperty(handle,0,0,ref value))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
SaveAttempted=true;
|
||||
if(!EcSaveSubscription(handle,0))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
public void Dispose(){if(handle!=IntPtr.Zero){EcClose(handle);handle=IntPtr.Zero;}}
|
||||
}
|
||||
}
|
||||
@@ -125,8 +125,8 @@ function Get-WelaWefCollectorPrerequisites {
|
||||
if ($rules.Count -ne 1) { throw 'Expected exactly one existing effective firewall rule.' }
|
||||
$rule=$rules[0]; $ports=@($rule | Get-NetFirewallPortFilter -ErrorAction Stop); $addresses=@($rule | Get-NetFirewallAddressFilter -ErrorAction Stop)
|
||||
$scopeMatches=$addresses.Count -eq 1 -and
|
||||
(@(Compare-Object @($Config.IngressLocalAddresses | Sort-Object -Unique) @($addresses[0].LocalAddress | Sort-Object -Unique)).Count -eq 0) -and
|
||||
(@(Compare-Object @($Config.IngressRemoteAddresses | Sort-Object -Unique) @($addresses[0].RemoteAddress | Sort-Object -Unique)).Count -eq 0)
|
||||
(Test-WelaWefFirewallAddressSet $Config.IngressLocalAddresses @($addresses[0].LocalAddress)) -and
|
||||
(Test-WelaWefFirewallAddressSet $Config.IngressRemoteAddresses @($addresses[0].RemoteAddress))
|
||||
$ok=[string]$rule.Enabled -eq 'True' -and [string]$rule.Direction -eq 'Inbound' -and [string]$rule.Action -eq 'Allow' -and [string]$rule.Profile -eq 'Domain' -and
|
||||
$ports.Count -eq 1 -and [string]$ports[0].Protocol -in @('TCP','6') -and [string]$ports[0].LocalPort -eq '5985' -and $scopeMatches
|
||||
$checks += [pscustomobject]@{ Name='Existing scoped domain ingress rule'; Verified=[bool]$ok; Evidence=@{ Rule=($rule | Select-Object Name,Enabled,Direction,Action,Profile,PolicyStoreSourceType,EnforcementStatus); Ports=$ports | Select-Object Protocol,LocalPort,RemotePort; Addresses=$addresses | Select-Object LocalAddress,RemoteAddress }; Diagnostic='Exact selected rule definition only; other rules, network reachability and effective packet acceptance are not established.' }
|
||||
|
||||
Reference in new issue
Block a user