MSIX.RemediationPlan.ps1
|
# ============================================================================= # Remediation plan round-trip # ----------------------------------------------------------------------------- # Serialise, validate, and replay a structured remediation plan so operators # can persist the plan, route it through change-control, and apply it # deterministically against a later package build. # # Schema (YAML, all keys under a top-level `remediation:` block): # # version: 1 # generatedAt: <ISO-8601> # generatedBy: MSIX.PowerShell <version> # packageFingerprint: # identityName: <string> # publisher: <string> # sha256: <string> # findings: # - category: <string> # confidence: <double> # symptom: <string> # appliedFixes: # - cmdlet: <cmdlet-name> # args: # <key>: <value> # approval: # requiredBy: <string> # notes: <string> # # SECURITY: only cmdlets exported by this module may appear in appliedFixes. # The same defence-in-depth guard used by the playbook bus is applied here. # ============================================================================= $script:_MsixPlanVersion = 1 # --------------------------------------------------------------------------- # Private: minimal YAML scalar helpers # --------------------------------------------------------------------------- function _MsixYamlScalar([object]$val) { if ($null -eq $val) { return 'null' } if ($val -is [bool]) { return $val.ToString().ToLower() } if ($val -is [datetime]) { return $val.ToString('o') } if ($val -is [System.DateTimeOffset]) { return $val.ToString('o') } if ($val -is [System.ValueType]) { return [string]$val } $s = [string]$val # Quote strings that contain YAML special chars, leading/trailing space, # or that look like YAML booleans/nulls to avoid ambiguity. if ($s -match '[:#\[\]{}|>&*!,]' -or $s -match '^\s|\s$' -or $s -match '^(true|false|yes|no|null|~)$' -or $s -eq '') { return "'" + $s.Replace("'", "''") + "'" } return $s } # Emit a hashtable/pscustomobject as YAML with the given indentation. # Returns a string (with trailing newline per line). function _MsixYamlBlock([object]$obj, [int]$indent) { $pad = ' ' * $indent $sb = [System.Text.StringBuilder]::new() $props = if ($obj -is [System.Collections.IDictionary]) { $obj.GetEnumerator() | ForEach-Object { [pscustomobject]@{ Key = $_.Key; Value = $_.Value } } } else { $obj.PSObject.Properties | Where-Object MemberType -eq 'NoteProperty' | ForEach-Object { [pscustomobject]@{ Key = $_.Name; Value = $_.Value } } } foreach ($p in $props) { $k = $p.Key $v = $p.Value if ($null -eq $v) { $sb.AppendLine("${pad}${k}: null") | Out-Null } elseif ($v -is [array] -or ($v -is [System.Collections.IEnumerable] -and $v -isnot [string])) { $sb.AppendLine("${pad}${k}:") | Out-Null $items = @($v) foreach ($item in $items) { if ($item -is [System.Collections.IDictionary] -or $item -is [pscustomobject]) { # First key of the nested object gets the list bullet. $inner = _MsixYamlBlock $item ($indent + 4) $lines = @($inner -split "`r?`n" | Where-Object { $_ -ne '' }) if ($lines.Count -gt 0) { $sb.AppendLine("$pad - $($lines[0].TrimStart())") | Out-Null for ($i = 1; $i -lt $lines.Count; $i++) { $sb.AppendLine("$pad $($lines[$i].TrimStart())") | Out-Null } } } else { $sb.AppendLine("$pad - $(_MsixYamlScalar $item)") | Out-Null } } } elseif ($v -is [System.Collections.IDictionary] -or $v -is [pscustomobject]) { $sb.AppendLine("${pad}${k}:") | Out-Null $sb.Append((_MsixYamlBlock $v ($indent + 2))) | Out-Null } else { $sb.AppendLine("${pad}${k}: $(_MsixYamlScalar $v)") | Out-Null } } return $sb.ToString() } # --------------------------------------------------------------------------- # Private: minimal YAML parser # Handles: scalar key: value, nested mappings (indented), and sequences (-). # Intentionally restricted — no tags, anchors, multi-doc, or object # instantiation. Same security stance as ConvertFrom-MsixYamlAccelerator. # --------------------------------------------------------------------------- function _MsixParseYaml([string[]]$Lines, [ref]$Pos, [int]$MinIndent) { $result = [ordered]@{} while ($Pos.Value -lt $Lines.Count) { $rawLine = $Lines[$Pos.Value] $trimmed = $rawLine.TrimStart() # Skip blank lines and comments. if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { $Pos.Value++; continue } $currentIndent = $rawLine.Length - $trimmed.Length # Dedent means we've left this block — return to caller. if ($currentIndent -lt $MinIndent) { break } # Sequence item at this level. if ($trimmed.StartsWith('- ')) { # Caller handles sequence items; break so the sequence loop in the # caller can collect them. break } $Pos.Value++ # Key: value if ($trimmed -match '^([^:]+):\s*(.*)$') { $key = $matches[1].Trim() $value = $matches[2].Trim() if ($value -eq '' -or $value -eq 'null') { # Could be a nested mapping or sequence — peek ahead. if ($Pos.Value -lt $Lines.Count) { $nextRaw = $Lines[$Pos.Value] $nextTrimmed = $nextRaw.TrimStart() $nextIndent = $nextRaw.Length - $nextTrimmed.Length if ($nextIndent -gt $currentIndent -and $nextTrimmed.StartsWith('- ')) { # Sequence. $seq = @() while ($Pos.Value -lt $Lines.Count) { $seqRaw = $Lines[$Pos.Value] $seqTrim = $seqRaw.TrimStart() $seqInd = $seqRaw.Length - $seqTrim.Length if ($seqInd -lt $nextIndent -or -not $seqTrim.StartsWith('- ')) { break } $Pos.Value++ $firstVal = $seqTrim.Substring(2).Trim() # Peek for nested block after the '- ' line. if ($Pos.Value -lt $Lines.Count) { $peekRaw = $Lines[$Pos.Value] $peekInd = $peekRaw.Length - $peekRaw.TrimStart().Length if ($peekInd -gt $seqInd) { # The '-' introduced a nested mapping. Parse it. $itemObj = [ordered]@{} # First key may be on the same line as '-'. if ($firstVal -match '^([^:]+):\s*(.*)$') { $fk = $matches[1].Trim(); $fv = $matches[2].Trim() $itemObj[$fk] = if ($fv -eq '' -or $fv -eq 'null') { $null } else { _MsixUnquoteYaml $fv } } $nested = _MsixParseYaml $Lines $Pos ($seqInd + 1) foreach ($nk in $nested.Keys) { $itemObj[$nk] = $nested[$nk] } $seq += [pscustomobject]$itemObj } else { $seq += if ($firstVal -eq 'null') { $null } else { _MsixUnquoteYaml $firstVal } } } else { $seq += if ($firstVal -eq 'null') { $null } else { _MsixUnquoteYaml $firstVal } } } $result[$key] = $seq continue } elseif ($nextIndent -gt $currentIndent) { # Nested mapping. $result[$key] = _MsixParseYaml $Lines $Pos $nextIndent continue } } $result[$key] = $null } else { $result[$key] = _MsixUnquoteYaml $value } } } return $result } function _MsixUnquoteYaml([string]$s) { $s = $s.Trim() if ($s.StartsWith("'") -and $s.EndsWith("'")) { return $s.Substring(1, $s.Length - 2).Replace("''", "'") } if ($s.StartsWith('"') -and $s.EndsWith('"')) { return $s.Substring(1, $s.Length - 2).Replace('\"', '"') } if ($s -eq 'null' -or $s -eq '~') { return $null } if ($s -match '^\d+\.\d+$') { return [double]$s } if ($s -match '^\d+$') { return [int]$s } if ($s -in 'true','yes') { return $true } if ($s -in 'false','no') { return $false } return $s } # --------------------------------------------------------------------------- # Private: cmdlet safety guard — same pattern as Invoke-MsixPlaybook. # --------------------------------------------------------------------------- function _MsixGuardPlanCmdlet([string]$CmdletName, [int]$StepIndex) { $cmd = Get-Command $CmdletName -ErrorAction SilentlyContinue if (-not $cmd) { throw "appliedFixes[$StepIndex] references unknown cmdlet '$CmdletName'." } if ($cmd.Source -ne 'MSIX' -and $cmd.ModuleName -ne 'MSIX') { throw "appliedFixes[$StepIndex] references '$CmdletName' which is not from the MSIX module (source: $($cmd.Source)). Execution blocked." } return $cmd } # --------------------------------------------------------------------------- # Public cmdlets # --------------------------------------------------------------------------- function New-MsixRemediationPlan { <# .SYNOPSIS Creates a new (in-memory) remediation plan object ready to be populated and exported. .DESCRIPTION Constructs the structured plan object that Export-MsixRemediationPlan serialises and Invoke-MsixRemediationPlan replays. You can build the plan from the output of Invoke-MsixAutoFixFromAnalysis -DryRun, or construct it manually. .PARAMETER PackagePath Package the plan targets. Used to compute the identity fingerprint (Name, Publisher) so Test-MsixRemediationPlan can detect drift. .PARAMETER Findings Findings from Get-MsixStaticAnalysis / Get-MsixCompatibilityReport that motivated the fixes. Stored for audit purposes only — not replayed. .PARAMETER AppliedFixes Array of hashtables, each with 'Cmdlet' and 'Args' keys. This is the same shape produced by Invoke-MsixAutoFixFromAnalysis when -DryRun is set (the plan's Stages property). Example: @{ Cmdlet = 'Add-MsixPsfV2'; Args = @{ WorkingDirectory = 'VFS\...' } } .OUTPUTS [pscustomobject] with PSTypeName 'MsixRemediationPlan'. #> [CmdletBinding(SupportsShouldProcess)] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string]$PackagePath, [object[]]$Findings = @(), [hashtable[]]$AppliedFixes = @() ) [xml]$manifest = Get-MsixManifest -Path $PackagePath $id = $manifest.Package.Identity $sha = if (Test-Path -LiteralPath $PackagePath -PathType Leaf) { try { (Get-FileHash -LiteralPath $PackagePath -Algorithm SHA256).Hash.ToLowerInvariant() } catch { $null } } else { $null } $modVer = (Get-Module -Name MSIX -ErrorAction SilentlyContinue).Version if (-not $modVer) { $modVer = '0.0.0' } $findingSummaries = @($Findings | Where-Object { $_ } | ForEach-Object { [pscustomobject][ordered]@{ category = [string]$_.Category confidence = if ($_.PSObject.Properties['Confidence']) { [double]$_.Confidence } else { $null } symptom = [string]$_.Symptom } }) $fixSummaries = @($AppliedFixes | Where-Object { $_ } | ForEach-Object { [pscustomobject][ordered]@{ cmdlet = [string]$_.Cmdlet args = if ($_.Args) { $_.Args } else { @{} } } }) $plan = [pscustomobject][ordered]@{ version = $script:_MsixPlanVersion generatedAt = (Get-Date -Format 'o') generatedBy = "MSIX.PowerShell $modVer" packageFingerprint = [pscustomobject][ordered]@{ identityName = $id.Name publisher = $id.Publisher sha256 = $sha } findings = $findingSummaries appliedFixes = $fixSummaries approval = [pscustomobject][ordered]@{ requiredBy = $null notes = $null } } $plan.PSObject.TypeNames.Insert(0, 'MsixRemediationPlan') return $plan } function Export-MsixRemediationPlan { <# .SYNOPSIS Serialises a remediation plan to a YAML file. .DESCRIPTION Writes a human-readable, change-control-friendly YAML file that can be reviewed, edited (approval.notes / approval.requiredBy), and later replayed via Import-MsixRemediationPlan + Invoke-MsixRemediationPlan. The YAML emitter is intentionally restricted (no external dependency, no dynamic object instantiation). The same safety stance as the Accelerator YAML parser. .PARAMETER Plan A MsixRemediationPlan object from New-MsixRemediationPlan. .PARAMETER Path Destination file (e.g. .\remediation.yaml). UTF-8 without BOM. .EXAMPLE $plan = New-MsixRemediationPlan -PackagePath app.msix -AppliedFixes $stages Export-MsixRemediationPlan -Plan $plan -Path .\app-remediation.yaml #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [psobject]$Plan, [Parameter(Mandatory)] [string]$Path ) $yaml = "# MSIX Remediation Plan - do not edit version or packageFingerprint`n" $yaml += "remediation:`n" $yaml += _MsixYamlBlock $Plan 2 if ($PSCmdlet.ShouldProcess($Path, 'Write remediation plan')) { [IO.File]::WriteAllText($Path, $yaml, [Text.UTF8Encoding]::new($false)) Write-MsixLog Info "Remediation plan written: $Path" } } function Import-MsixRemediationPlan { <# .SYNOPSIS Parses a YAML remediation plan file and returns the plan object. .DESCRIPTION Reads and validates the YAML produced by Export-MsixRemediationPlan. Validation checks: - version field is present and equals the current schema version. - appliedFixes cmdlets are all known AND from the MSIX module. - Required top-level keys are present. Refuses to load a plan that fails any of these checks so a tampered or stale plan is caught before Invoke-MsixRemediationPlan runs. .PARAMETER Path Path to the .yaml file. .OUTPUTS [pscustomobject] with PSTypeName 'MsixRemediationPlan'. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string]$Path ) if (-not (Test-Path -LiteralPath $Path)) { throw "Remediation plan not found: $Path" } $lines = @(Get-Content -LiteralPath $Path -ErrorAction Stop) # Strip comment lines and find the 'remediation:' root key. $startIdx = -1 for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i] -match '^\s*remediation\s*:') { $startIdx = $i + 1; break } } if ($startIdx -lt 0) { throw "Invalid remediation plan: missing 'remediation:' root key." } $pos = [ref]$startIdx $inner = _MsixParseYaml $lines $pos 2 # --- Schema validation --- foreach ($req in 'version','generatedAt','generatedBy','packageFingerprint','appliedFixes') { if (-not $inner.Contains($req)) { throw "Invalid remediation plan: missing required key '$req'." } } if ([int]$inner['version'] -ne $script:_MsixPlanVersion) { throw ("Remediation plan version {0} is not supported (expected {1})." -f $inner['version'], $script:_MsixPlanVersion) } $fixes = @($inner['appliedFixes']) for ($i = 0; $i -lt $fixes.Count; $i++) { $fix = $fixes[$i] $name = if ($fix -is [hashtable]) { $fix['cmdlet'] } else { $fix.cmdlet } if (-not $name) { throw "appliedFixes[$i] is missing the 'cmdlet' key." } _MsixGuardPlanCmdlet -CmdletName $name -StepIndex $i | Out-Null } # --- Reconstruct the plan object --- $fp = $inner['packageFingerprint'] $apv = $inner['approval'] $findingsList = @($inner['findings'] | Where-Object { $_ } | ForEach-Object { $f = $_ [ordered]@{ category = if ($f -is [hashtable]) { $f['category'] } else { $f.category } confidence = if ($f -is [hashtable]) { $f['confidence'] } else { $f.confidence } symptom = if ($f -is [hashtable]) { $f['symptom'] } else { $f.symptom } } }) $fixesList = @($fixes | Where-Object { $_ } | ForEach-Object { $fx = $_ $cmdlet = if ($fx -is [hashtable]) { $fx['cmdlet'] } else { $fx.cmdlet } $argMap = if ($fx -is [hashtable]) { $fx['args'] } else { $fx.args } [ordered]@{ cmdlet = $cmdlet args = if ($argMap) { $argMap } else { @{} } } }) $plan = [pscustomobject][ordered]@{ version = [int]$inner['version'] generatedAt = [string]$inner['generatedAt'] generatedBy = [string]$inner['generatedBy'] packageFingerprint = [pscustomobject][ordered]@{ identityName = if ($fp -is [hashtable]) { $fp['identityName'] } else { $fp.identityName } publisher = if ($fp -is [hashtable]) { $fp['publisher'] } else { $fp.publisher } sha256 = if ($fp -is [hashtable]) { $fp['sha256'] } else { $fp.sha256 } } findings = $findingsList appliedFixes = $fixesList approval = [pscustomobject][ordered]@{ requiredBy = if ($apv) { if ($apv -is [hashtable]) { $apv['requiredBy'] } else { $apv.requiredBy } } else { $null } notes = if ($apv) { if ($apv -is [hashtable]) { $apv['notes'] } else { $apv.notes } } else { $null } } } $plan.PSObject.TypeNames.Insert(0, 'MsixRemediationPlan') return $plan } function Test-MsixRemediationPlan { <# .SYNOPSIS Validates that a remediation plan is still applicable to a package. .DESCRIPTION Checks: 1. Package identity (Name + Publisher) still matches the fingerprint recorded in the plan. 2. Every cmdlet in appliedFixes is still exported by the module. 3. The SHA-256 of the package matches (with -StrictFingerprint). Returns a structured result so callers can decide whether to proceed. .PARAMETER Plan MsixRemediationPlan object (from Import-MsixRemediationPlan or New-MsixRemediationPlan). .PARAMETER PackagePath Package to validate against. .PARAMETER StrictFingerprint When set, also requires the package SHA-256 to match the plan's recorded value. Off by default so a re-signed package still passes. .OUTPUTS [pscustomobject] @{ IsValid; Errors[] }. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [psobject]$Plan, [Parameter(Mandatory)] [string]$PackagePath, [switch]$StrictFingerprint ) $errors = [System.Collections.Generic.List[string]]::new() if (-not (Test-Path -LiteralPath $PackagePath)) { $errors.Add("Package not found: $PackagePath") } else { [xml]$manifest = Get-MsixManifest -Path $PackagePath $id = $manifest.Package.Identity $fp = $Plan.packageFingerprint if ($fp.identityName -and $id.Name -ne $fp.identityName) { $errors.Add("Identity.Name mismatch: plan='$($fp.identityName)' package='$($id.Name)'.") } if ($fp.publisher -and $id.Publisher -ne $fp.publisher) { $errors.Add("Identity.Publisher mismatch: plan='$($fp.publisher)' package='$($id.Publisher)'.") } if ($StrictFingerprint -and $fp.sha256) { $actual = (Get-FileHash -LiteralPath $PackagePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -ne $fp.sha256) { $errors.Add("SHA-256 mismatch: plan='$($fp.sha256)' package='$actual'. Package may have been rebuilt.") } } } $i = 0 foreach ($fix in @($Plan.appliedFixes)) { $name = if ($fix -is [hashtable]) { $fix['cmdlet'] } else { $fix.cmdlet } try { _MsixGuardPlanCmdlet -CmdletName $name -StepIndex $i | Out-Null } catch { $errors.Add($_.Exception.Message) } $i++ } return [pscustomobject]@{ IsValid = $errors.Count -eq 0 Errors = [string[]]$errors } } function Invoke-MsixRemediationPlan { <# .SYNOPSIS Replays a validated remediation plan against a package. .DESCRIPTION Applies every step in appliedFixes against -PackagePath, using the same single-sign-at-end semantics as Invoke-MsixPlaybook: 1. Validate the plan against the package (Test-MsixRemediationPlan). Throw if validation fails. 2. For each fix step, verify the cmdlet is from this module. 3. Force -SkipSigning on every intermediate step. 4. Sign once at the end (unless -SkipSigning is set). -DryRun prints the resolved call sequence without executing anything. .PARAMETER Plan MsixRemediationPlan to apply. .PARAMETER PackagePath .msix file to act on. .PARAMETER OutputPath Write the result here (default: overwrite PackagePath). .PARAMETER DryRun Print the plan and exit without writing anything. .PARAMETER SkipSigning / NoSign / Pfx / PfxPassword Signing controls forwarded to the final Invoke-MsixSigning call. .EXAMPLE $plan = Import-MsixRemediationPlan .\app-remediation.yaml Invoke-MsixRemediationPlan -Plan $plan -PackagePath app.msix ` -Pfx cert.pfx -PfxPassword $pw #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [psobject]$Plan, [Parameter(Mandatory)] [string]$PackagePath, [string]$OutputPath, [switch]$DryRun, [Alias('NoSign')] [switch]$SkipSigning, [string]$Pfx, [SecureString]$PfxPassword ) # --- Validation --- $check = Test-MsixRemediationPlan -Plan $Plan -PackagePath $PackagePath if (-not $check.IsValid) { throw "Remediation plan validation failed:`n" + ($check.Errors -join "`n") } $fixes = @($Plan.appliedFixes | Where-Object { $_ }) Write-MsixLog Info "Remediation plan: $($fixes.Count) step(s) from '$($Plan.generatedBy)'" if ($DryRun) { foreach ($fix in $fixes) { $name = if ($fix -is [hashtable]) { $fix['cmdlet'] } else { $fix.cmdlet } $argMap = if ($fix -is [hashtable]) { $fix['args'] } else { $fix.args } $argsStr = if ($argMap) { ($argMap.GetEnumerator() | ForEach-Object { "-$($_.Key) '$($_.Value)'" }) -join ' ' } else { '' } Write-MsixLog Info " [DryRun] $name $argsStr" } return } $current = if ($OutputPath -and $OutputPath -ne $PackagePath) { if ($PSCmdlet.ShouldProcess($OutputPath, 'Copy package for remediation')) { Copy-Item -LiteralPath $PackagePath -Destination $OutputPath -Force } $OutputPath } else { $PackagePath } $i = 0 foreach ($fix in $fixes) { $i++ $name = if ($fix -is [hashtable]) { $fix['cmdlet'] } else { $fix.cmdlet } $argMap = if ($fix -is [hashtable]) { $fix['args'] } else { $fix.args } $cmd = _MsixGuardPlanCmdlet -CmdletName $name -StepIndex ($i - 1) $callArgs = [ordered]@{} if ($argMap) { foreach ($k in $argMap.Keys) { $callArgs[$k] = $argMap[$k] } } if (-not $callArgs.ContainsKey('PackagePath') -and $cmd.Parameters.ContainsKey('PackagePath')) { $callArgs['PackagePath'] = $current } if ($cmd.Parameters.ContainsKey('SkipSigning') -and -not $callArgs.ContainsKey('SkipSigning')) { $callArgs['SkipSigning'] = $true } Write-MsixLog Info " Step $i / $($fixes.Count): $name" if ($PSCmdlet.ShouldProcess($current, "Remediation plan step ${i}: $name")) { & $cmd @callArgs } } # --- Final sign --- if (-not $SkipSigning -and $Pfx) { if ($PSCmdlet.ShouldProcess($current, 'Sign package')) { Invoke-MsixSigning -PackagePath $current -Pfx $Pfx -PfxPassword $PfxPassword } } elseif (-not $SkipSigning -and -not $Pfx) { Write-MsixLog Warning 'No -Pfx supplied - package left unsigned. Pass -SkipSigning to suppress this warning.' } } |