mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-01 03:54:44 +02:00
* Add reviewed recovery for a pending AD CS auditing restart * Link pending CA restart recovery changelogs to PR 431
207 lines
19 KiB
PowerShell
207 lines
19 KiB
PowerShell
param([switch]$AllowDisposableCA,[ValidateSet('powershell','pwsh')][string]$TestEngine='powershell')
|
|
$ErrorActionPreference='Stop'
|
|
if(-not $AllowDisposableCA -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Explicit disposable CA opt-in on a GitHub-hosted Windows runner is required.'}
|
|
$root=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$root
|
|
. (Join-Path $root 'scripts/Configuration.ps1')
|
|
. (Join-Path $root 'scripts/AdcsAuditing.ps1')
|
|
Import-Module (Join-Path $root 'modules/AuditProfiles.psm1') -Force
|
|
$computer=Get-CimInstance Win32_ComputerSystem
|
|
$os=Get-CimInstance Win32_OperatingSystem
|
|
$caRoot='HKLM:\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration'
|
|
if($computer.PartOfDomain -or $computer.DomainRole -ne 2 -or $os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100) -or (Test-Path -LiteralPath $caRoot)){throw 'Disposable test refuses domain/DC, unknown OS or pre-existing CA configuration.'}
|
|
$beforeReport=Invoke-WelaAdcsCommand
|
|
if($beforeReport.PolicyState -ne 'NotApplicable'){throw 'Native non-CA observation did not classify the absent CA.'}
|
|
$engine=(Get-Command $TestEngine -ErrorAction Stop).Source
|
|
$nonce=[guid]::NewGuid().ToString('N');$caName='WELA-CI-'+$nonce
|
|
$privateRoot=Join-Path $env:TEMP ('wela-adcs-'+$nonce)
|
|
$null=New-Item -ItemType Directory -Path $privateRoot;Protect-WelaAdcsDirectory $privateRoot
|
|
$beforePolicies=Get-WelaEffectiveAuditPolicy
|
|
$auditGuid='0cce9221-69ae-11d9-bed3-505054503030'
|
|
$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa'
|
|
$precedenceBefore=Get-WelaRegistryState -Path $precedencePath -Name SCENoApplyLegacyAuditPolicy
|
|
$beforeFeatures=@(Get-WindowsFeature | Where-Object Installed | ForEach-Object Name)
|
|
$beforeCerts=@(Get-ChildItem Cert:\LocalMachine\My,Cert:\LocalMachine\Root,Cert:\LocalMachine\CA | ForEach-Object Thumbprint)
|
|
$createdKeys=@();$createdCerts=@();$installedFeature=$false;$attemptedCA=$false;$passed=$false
|
|
[pscustomobject]@{BeforePolicies=$beforePolicies;Precedence=$precedenceBefore;Features=$beforeFeatures;CertificateThumbprints=$beforeCerts;CaName=$caName}|ConvertTo-Json -Depth 10|Set-Content -LiteralPath (Join-Path $privateRoot 'before.json') -Encoding UTF8
|
|
function Invoke-TestCli {
|
|
param([string[]]$Arguments,[int]$ExpectedExit=0)
|
|
$ErrorActionPreference='Continue'
|
|
try{$text=@(& $engine -NoProfile -File (Join-Path $root 'WELA.ps1') @Arguments 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference='Stop'}
|
|
$text|ForEach-Object{Write-Host $_}
|
|
if($code -ne $ExpectedExit){throw "Public CA CLI exit $code; expected $ExpectedExit."}
|
|
$global:LASTEXITCODE=0
|
|
}
|
|
function Find-CreatedCertificates {
|
|
foreach($store in @('My','Root','CA')){
|
|
foreach($cert in @(Get-ChildItem ("Cert:\LocalMachine\"+$store)|Where-Object{$_.Subject -ceq ('CN='+$caName) -and $_.Thumbprint -notin $beforeCerts})){
|
|
[pscustomobject]@{Store=$store;Thumbprint=$cert.Thumbprint;Certificate=$cert}
|
|
}
|
|
}
|
|
}
|
|
try {
|
|
$installedFeature=$true
|
|
$feature=Install-WindowsFeature ADCS-Cert-Authority -IncludeManagementTools -ErrorAction Stop
|
|
if(-not $feature.Success -or [string]$feature.RestartNeeded -ne 'No'){throw "CA feature is unavailable or requires restart: $($feature | Out-String). No native acceptance claim."}
|
|
$database=Join-Path $privateRoot 'database';$logs=Join-Path $privateRoot 'database-logs'
|
|
$null=New-Item -ItemType Directory -Path $database,$logs
|
|
$attemptedCA=$true
|
|
$installation=Install-AdcsCertificationAuthority -CAType StandaloneRootCA -CACommonName $caName -CryptoProviderName 'RSA#Microsoft Software Key Storage Provider' -KeyLength 2048 -HashAlgorithmName SHA256 -ValidityPeriod Days -ValidityPeriodUnits 1 -DatabaseDirectory $database -LogDirectory $logs -Force -ErrorAction Stop
|
|
$installation|Out-String|Write-Host
|
|
if($installation.ErrorId -and $installation.ErrorId -ne 0){throw 'Disposable standalone CA installation reported failure.'}
|
|
$native=Get-WelaAdcsSnapshot
|
|
if($native.Status -ne 'Supported' -or $native.Active.Value -cne $caName -or $native.CaType.Value -ne 3 -or $native.Host.DomainJoined){throw ($native|ConvertTo-Json -Depth 15)}
|
|
$createdCerts=@(Find-CreatedCertificates)
|
|
foreach($entry in $createdCerts){
|
|
if($entry.Certificate.HasPrivateKey){
|
|
$rsa=[Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($entry.Certificate)
|
|
try{if($rsa -isnot [Security.Cryptography.RSACng]){throw 'Unexpected disposable CA key provider.'};$createdKeys+=@($rsa.Key.KeyName)}finally{if($rsa){$rsa.Dispose()}}
|
|
}
|
|
}
|
|
$createdKeys=@($createdKeys|Select-Object -Unique)
|
|
# Test only: malformed types are observed/preserved by the real native reader.
|
|
$null=New-ItemProperty -LiteralPath $native.Path -Name AuditFilter -Value '127' -PropertyType String -Force
|
|
$malformedPath=Join-Path $privateRoot 'malformed.json'
|
|
Invoke-TestCli -Arguments @('adcs-auditing','-ResultsPath',$malformedPath) -ExpectedExit 1
|
|
$malformed=Get-Content -LiteralPath $malformedPath -Raw -Encoding UTF8|ConvertFrom-Json
|
|
if($malformed.After.Status -ne 'Unknown' -or (Get-WelaRegistryState $native.Path AuditFilter).Type -ne 'String'){throw 'Native unknown filter type was changed or credited.'}
|
|
# Force an actual guarded change on this newly created CA, not an idempotent-only test.
|
|
$null=New-ItemProperty -LiteralPath $native.Path -Name AuditFilter -Value 0 -PropertyType DWord -Force
|
|
Set-WelaEffectiveAuditPolicy -Guid $auditGuid -Mask 0 -Mode exact
|
|
$null=New-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Value 0 -PropertyType DWord -Force
|
|
$disabled=Get-WelaAdcsSnapshot
|
|
if($disabled.Status -ne 'Supported' -or $disabled.AuditMask -ne 0){throw 'Actual disabled audit state (native NONE flag4) was not normalized to mask0.'}
|
|
$applyPath=Join-Path $privateRoot 'configured.json'
|
|
Invoke-TestCli -Arguments @('adcs-auditing','-AdcsAction','Configure','-AdcsProfile','microsoft-identity-ca-2026-09','-AllowRestart','-Auto','-BackupPath',(Join-Path $privateRoot 'journal'),'-ResultsPath',$applyPath)
|
|
$applied=Get-Content -LiteralPath $applyPath -Raw -Encoding UTF8|ConvertFrom-Json
|
|
if($applied.PolicyState -ne 'PolicyMatches' -or $applied.Activation -notmatch '^RestartObservedAfterWrite' -or $applied.UsableRuleCredit -ne 0){throw 'Native configuration did not verify the intended state/restart boundary.'}
|
|
$stable=Get-WelaAdcsSnapshot;$stableKey=Get-WelaAdcsStateKey $stable
|
|
$repeatPath=Join-Path $privateRoot 'repeated.json'
|
|
Invoke-TestCli -Arguments @('adcs-auditing','-AdcsAction','Configure','-AdcsProfile','microsoft-identity-ca-2026-09','-AllowRestart','-Auto','-BackupPath',(Join-Path $privateRoot 'repeat-journal'),'-ResultsPath',$repeatPath)
|
|
$repeated=Get-Content -LiteralPath $repeatPath -Raw -Encoding UTF8|ConvertFrom-Json
|
|
if($repeated.Activation -ne 'Unverified' -or @($repeated.Results|Where-Object Status -eq 'Applied').Count -or (Get-WelaAdcsStateKey (Get-WelaAdcsSnapshot)) -cne $stableKey){throw 'Native repeat changed state or claimed historical activation.'}
|
|
# Test-only fault injection: create an authentic failed Configure result by
|
|
# refusing its restart. Registry writes/observations and the resumed restart
|
|
# use real adapters. No injected helper enters the public child CLI process.
|
|
$null=New-ItemProperty -LiteralPath $stable.Path -Name AuditFilter -Value 0 -PropertyType DWord -Force
|
|
Restart-WelaAdcsService
|
|
$pendingPath=Join-Path $privateRoot 'restart-pending.json'
|
|
$pendingJournal=Join-Path $privateRoot 'restart-pending-journal'
|
|
$restartImplementation=(Get-Command Restart-WelaAdcsService).ScriptBlock
|
|
try {
|
|
function Restart-WelaAdcsService { throw 'Disposable fixture: restart deliberately refused after real filter write.' }
|
|
$pendingResult=Invoke-WelaAdcsCommand -Action Configure -Profile microsoft-identity-ca-2026-09 -AllowRestart -Auto -BackupPath $pendingJournal -ResultsPath $pendingPath
|
|
} finally { Set-Item -Path Function:Restart-WelaAdcsService -Value $restartImplementation }
|
|
if($pendingResult.ExitCode -ne 1 -or $pendingResult.Activation -cne 'RestartPending' -or $pendingResult.PolicyState -cne 'PolicyMatches'){throw 'Injected restart refusal did not leave authentic pending evidence with real AuditFilter127.'}
|
|
$pendingKey=Get-WelaAdcsStateKey (Get-WelaAdcsSnapshot)
|
|
$resumePlanRoot=Join-Path $privateRoot 'restart-plan'
|
|
Invoke-TestCli -Arguments @('adcs-resume','-AdcsResumeJournalPath',(Join-Path $pendingJournal 'before.jsonl'),'-AdcsResumeResultsPath',$pendingPath,'-AdcsResumeOutputPath',$resumePlanRoot)
|
|
$resumePlanPath=Join-Path $resumePlanRoot 'plan.json'
|
|
$resumeHash=(Get-FileHash -LiteralPath $resumePlanPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
Invoke-TestCli -Arguments @('adcs-resume','-AdcsResumeAction','Resume','-AdcsResumePlanPath',$resumePlanPath,'-AdcsResumePlanHash',$resumeHash,'-DryRun')
|
|
if((Get-WelaAdcsStateKey (Get-WelaAdcsSnapshot)) -cne $pendingKey){throw 'Public resume DryRun changed the pending CA.'}
|
|
$resumeOutput=Join-Path $privateRoot 'restart-receipts'
|
|
Invoke-TestCli -Arguments @('adcs-resume','-AdcsResumeAction','Resume','-AdcsResumePlanPath',$resumePlanPath,'-AdcsResumePlanHash',$resumeHash,'-AdcsResumeAllowRestart','-AdcsResumeOutputPath',$resumeOutput)
|
|
$resumed=Get-Content -LiteralPath (Join-Path $resumeOutput 'result.json') -Raw -Encoding UTF8|ConvertFrom-Json
|
|
if($resumed.ExitCode -ne 0 -or $resumed.Status -cne 'RestartObserved' -or -not $resumed.RestartAttempted -or $resumed.ReadyRuleCredit -ne 0 -or $resumed.EventGeneration -cne 'Unverified'){throw 'Public resume did not verify the actual restart with explicit evidence limits.'}
|
|
foreach($artifact in $resumed.Artifacts){if((Get-FileHash -LiteralPath (Join-Path $resumeOutput $artifact.Name)).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'Public resume receipt hash mismatch.'}}
|
|
$stable=Get-WelaAdcsSnapshot;$stableKey=Get-WelaAdcsStateKey $stable
|
|
Invoke-TestCli -Arguments @('adcs-resume','-AdcsResumeAction','Resume','-AdcsResumePlanPath',$resumePlanPath,'-AdcsResumePlanHash',$resumeHash,'-AdcsResumeAllowRestart','-AdcsResumeOutputPath',(Join-Path $privateRoot 'replay')) -ExpectedExit 1
|
|
if((Get-WelaAdcsStateKey (Get-WelaAdcsSnapshot)) -cne $stableKey -or (Test-Path -LiteralPath (Join-Path $privateRoot 'replay'))){throw 'Consumed pending plan restarted the CA again or created output.'}
|
|
Write-Host 'Native pending-restart recovery passed: real filter write, injected refusal, public plan/dry-run, actual service restart, hashed receipts and replay rejection.'
|
|
# This public CSR has no corresponding private key in the repository or runner.
|
|
# A pending request cannot produce a usable leaf certificate; never approve it.
|
|
$csrPath=Join-Path $PSScriptRoot 'fixtures/adcs-pending-probe.csr'
|
|
if((Get-FileHash -LiteralPath $csrPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne 'f39e219e1ccafed480524980d07356ab6b55c1dc85cd796553d2a4a79241958b'){throw 'Fixed benign CSR changed.'}
|
|
$request=[IO.File]::ReadAllText($csrPath)
|
|
$startUtc=[DateTime]::UtcNow
|
|
$client=New-Object -ComObject CertificateAuthority.Request
|
|
try{
|
|
# CR_IN_PKCS10 (0x100) + CR_IN_BASE64HEADER (0); source CertCli.h.
|
|
$disposition=$client.Submit(0x100,$request,('WELAProbe:'+$nonce),($env:COMPUTERNAME+'\'+$caName))
|
|
$requestId=$client.GetRequestId()
|
|
}finally{if($client){$null=[Runtime.InteropServices.Marshal]::FinalReleaseComObject($client)}}
|
|
if($disposition -ne 5 -or $requestId -le 0){throw "Expected pending disposition5, got $disposition / request$requestId. No approval, retrieval or leaf installation was performed."}
|
|
$identity=[Security.Principal.WindowsIdentity]::GetCurrent()
|
|
try{$requester=$identity.Name}finally{$identity.Dispose()}
|
|
$expected=[pscustomobject]@{Computer=$env:COMPUTERNAME;RequestId=[int]$requestId;Requester=$requester;Nonce=$nonce;StartUtc=$startUtc.ToString('o');EndUtc=$null}
|
|
$matched=@{};$deadline=[DateTime]::UtcNow.AddSeconds(30)
|
|
do{
|
|
$expected.EndUtc=[DateTime]::UtcNow.ToString('o')
|
|
$events=@(Get-WinEvent -FilterHashtable @{LogName='Security';Id=@(4886,4889);StartTime=$startUtc} -MaxEvents 512 -ErrorAction SilentlyContinue -ErrorVariable queryErrors)
|
|
if(@($queryErrors|Where-Object{$_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*'}).Count){throw ($queryErrors|Out-String)}
|
|
if($events.Count -ge 512){throw 'Native request event query cap reached; no complete-match claim.'}
|
|
foreach($id in @(4886,4889)){
|
|
$matches=@(foreach($event in $events){if($event.Id -eq $id){$xml=[string]::Concat($event.ToXml());if(Test-WelaAdcsRequestEvent $xml $expected $id){$xml}}})
|
|
if($matches.Count -gt 1){throw "Ambiguous native request event$id."}
|
|
if($matches.Count -eq 1){$matched[$id]=$matches[0]}
|
|
}
|
|
if($matched.Count -eq 2){break};Start-Sleep -Milliseconds 500
|
|
}while([DateTime]::UtcNow -lt $deadline)
|
|
if($matched.Count -ne 2){
|
|
$events|ForEach-Object{$_.ToXml()}|Set-Content -LiteralPath (Join-Path $privateRoot 'unmatched-request-events.xml') -Encoding UTF8
|
|
# This workgroup CA and query window belong solely to the disposable
|
|
# test; emit bounded diagnostics before the hosted VM is discarded.
|
|
$expected|ConvertTo-Json -Depth 5|Write-Host
|
|
Write-Host "Native 4886/4889 records in bounded window: $($events.Count); matched: $($matched.Count)."
|
|
$events|ForEach-Object{Write-Host $_.ToXml()}
|
|
throw 'Both correlated native 4886 and4889 XML events were not observed. Raw bounded diagnostics retained locally.'
|
|
}
|
|
if((Get-WelaAdcsStateKey (Get-WelaAdcsSnapshot)) -cne $stableKey){throw 'CA identity/policy/service drifted while collecting native request events.'}
|
|
foreach($id in @(4886,4889)){[IO.File]::WriteAllText((Join-Path $privateRoot ("event-$id.xml")),$matched[$id],[Text.UTF8Encoding]::new($false))}
|
|
[pscustomobject]@{Kind='WelaAdcsNativeRequestComponents';Context=$stable;Expected=$expected;Disposition=$disposition;ReadyRuleCredit=0;Artifacts=@(foreach($id in @(4886,4889)){$path=Join-Path $privateRoot ("event-$id.xml");[pscustomobject]@{Path=[IO.Path]::GetFileName($path);Sha256=(Get-FileHash -LiteralPath $path).Hash.ToLowerInvariant()}});Scope='Local pending request only; no enterprise-template/DC/Sigma/backend proof'}|ConvertTo-Json -Depth 18|Set-Content -LiteralPath (Join-Path $privateRoot 'native-components.json') -Encoding UTF8
|
|
foreach($id in @(4886,4889)){Write-Host $matched[$id]}
|
|
Get-Content -LiteralPath (Join-Path $privateRoot 'native-components.json') -Raw -Encoding UTF8|Write-Host
|
|
$passed=$true
|
|
Write-Host "Observed correlated Security4886/4889 request$requestId on disposable Server$($os.BuildNumber) via $TestEngine; no certificate was approved."
|
|
}catch{
|
|
# Preserve the primary native failure even if cleanup independently fails.
|
|
Write-Host ('Native CA validation failed before cleanup: '+($_|Out-String))
|
|
Write-Host $_.ScriptStackTrace
|
|
throw
|
|
}finally{
|
|
$cleanupErrors=@()
|
|
if($attemptedCA){
|
|
try{
|
|
$current=Get-WelaRegistryState -Path $caRoot -Name Active
|
|
if($current.ValueExists -and $current.Value -cne $caName){throw 'Active CA no longer belongs to this test; cleanup refused.'}
|
|
$createdCerts=@(Find-CreatedCertificates)
|
|
foreach($entry in $createdCerts){if($entry.Certificate.HasPrivateKey){$rsa=[Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($entry.Certificate);try{if($rsa -is [Security.Cryptography.RSACng]){$createdKeys+=@($rsa.Key.KeyName)}}finally{if($rsa){$rsa.Dispose()}}}}
|
|
Uninstall-AdcsCertificationAuthority -Force -ErrorAction Stop|Out-Null
|
|
foreach($entry in $createdCerts){$path='Cert:\LocalMachine\'+$entry.Store+'\'+$entry.Thumbprint;if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -ErrorAction Stop}}
|
|
foreach($keyName in @($createdKeys|Select-Object -Unique)){
|
|
$provider=[Security.Cryptography.CngProvider]::MicrosoftSoftwareKeyStorageProvider
|
|
if([Security.Cryptography.CngKey]::Exists($keyName,$provider,[Security.Cryptography.CngKeyOpenOptions]::MachineKey)){$key=[Security.Cryptography.CngKey]::Open($keyName,$provider,[Security.Cryptography.CngKeyOpenOptions]::MachineKey);try{$key.Delete()}finally{$key.Dispose()}}
|
|
}
|
|
}catch{$cleanupErrors+=$_.Exception.Message}
|
|
}
|
|
try{
|
|
Set-WelaEffectiveAuditPolicy -Guid $auditGuid -Mask $beforePolicies[$auditGuid] -Mode exact
|
|
if($precedenceBefore.ValueExists){$null=New-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Value $precedenceBefore.Value -PropertyType $precedenceBefore.Type -Force}else{Remove-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -ErrorAction SilentlyContinue}
|
|
$after=Get-WelaRegistryState -Path $precedencePath -Name SCENoApplyLegacyAuditPolicy
|
|
if(($after|ConvertTo-Json -Compress) -cne ($precedenceBefore|ConvertTo-Json -Compress)){throw 'Audit precedence restoration differs.'}
|
|
$afterPolicies=Get-WelaEffectiveAuditPolicy
|
|
foreach($guid in $beforePolicies.Keys){if($afterPolicies[$guid] -ne $beforePolicies[$guid]){throw "Audit policy restoration differs: $guid"}}
|
|
}catch{$cleanupErrors+=$_.Exception.Message}
|
|
$featureRemoval=[pscustomobject]@{CaAndAuditRestored=($cleanupErrors.Count -eq 0);Attempted=$false;Features=@();Success=$null;RestartNeeded=$null;Boundary='OS feature removal is separate from CA/audit restoration and can require disposal of the hosted runner.'}
|
|
if($installedFeature -and $cleanupErrors.Count -eq 0){
|
|
try{
|
|
if((Get-WelaRegistryState -Path $caRoot -Name Active).ValueExists){throw 'A configured CA remains; feature cleanup refused.'}
|
|
$added=@(Get-WindowsFeature|Where-Object{$_.Installed -and $_.Name -notin $beforeFeatures -and ($_.Name -like 'ADCS-*' -or $_.Name -in @('AD-Certificate','RSAT-ADCS','RSAT-ADCS-Mgmt'))}|ForEach-Object Name)
|
|
if($added.Count){
|
|
$featureRemoval.Attempted=$true;$featureRemoval.Features=$added
|
|
$removed=Uninstall-WindowsFeature -Name $added -ErrorAction Stop
|
|
$featureRemoval.Success=[bool]$removed.Success;$featureRemoval.RestartNeeded=[string]$removed.RestartNeeded
|
|
if(-not $removed.Success -or [string]$removed.RestartNeeded -notin @('No','Yes')){throw 'Created CA feature removal failed or returned an unknown restart status.'}
|
|
# GitHub destroys this isolated VM after the job. No production
|
|
# restart and no complete OS feature-restoration claim are made.
|
|
}
|
|
}catch{$cleanupErrors+=$_.Exception.Message}
|
|
}
|
|
$featureRemoval|ConvertTo-Json -Depth 5|Set-Content -LiteralPath (Join-Path $privateRoot 'feature-removal.json') -Encoding UTF8
|
|
$featureRemoval|ConvertTo-Json -Depth 5|Write-Host
|
|
if($cleanupErrors.Count){throw "Disposable CA cleanup failed; receipt retained at $privateRoot : $($cleanupErrors -join '; ')"}
|
|
if($passed){Remove-Item -LiteralPath $privateRoot -Recurse -Force}
|
|
}
|
|
$global:LASTEXITCODE=0
|
|
Write-Host 'PASS: actual public CA configuration, idempotence, pending-restart resume, pending-request events and exact audit/created-CA restoration. Requested OS feature removal can await hosted-runner disposal, as recorded separately.'
|