diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e912d6c..3acc784a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: Copy-Item -Recurse -Path ./scripts -Destination release-binaries/ Copy-Item -Recurse -Path ./modules -Destination release-binaries/ New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null - Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md -Destination release-binaries/docs/ + Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/.github/workflows/transcription-recovery.yml b/.github/workflows/transcription-recovery.yml new file mode 100644 index 00000000..c8c189fc --- /dev/null +++ b/.github/workflows/transcription-recovery.yml @@ -0,0 +1,45 @@ +name: Native transcription policy recovery +on: + push: + branches: ['**'] + paths: + - 'WELA.ps1' + - 'scripts/TranscriptionRecovery.ps1' + - 'scripts/PowerShellTranscription.ps1' + - 'scripts/AuditRecovery.ps1' + - 'tests/TranscriptionRecovery*' + - '.github/workflows/transcription-recovery.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + transcription-recovery: + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Focused and native public CLI recovery in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/TranscriptionRecovery.Tests.ps1 + ./tests/TranscriptionRecovery.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Focused and native public CLI recovery in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/TranscriptionRecovery.Tests.ps1 + ./tests/TranscriptionRecovery.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Retain native policy and cleanup evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: transcription-recovery-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-transcription-recovery-*/ + if-no-files-found: error diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index a6ab89ad..cf5689b8 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,9 @@ **改善:** +- `transcription-recovery` を追加し、完了した Windows PowerShell 文字起こし設定を、再構築したハッシュ付き計画から型を保持して復元できるようにしました。ローカル保存先、ユーザー・ヘッダー・他のポリシーを確認し、一時停止は明示的な同意を求め、変更順序と途中結果を永続記録します。使い捨て環境の実 CLI テストで復元とドリフト拒否を検証し、本番セッション・集中管理先の権限と収集・Sigma 対応は未検証とします。 (#376) (@Shirofune-Security) + + - `wmi-probe` の親プロセスとワーカーの操作時刻を Windows の高精度 UTC 時計に統一しました。時計情報と起動・完了時刻を検証し、イベントの許容時間範囲を広げずに正確な照合を維持します。ミリ秒未満の境界テストとネイティブ公開 CLI の反復テストを追加しました。(@Shirofune-Security) - 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index c13c6111..afd235d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added explicit `transcription-recovery` for one completed Windows PowerShell transcription configuration, with independently rebuilt hashed plans, typed local-directory restoration, preserved user/header/other policy, explicit temporary-suspension consent and durable ordered receipts. Disposable native public-CLI tests verify restoration and drift refusal; production sessions, central authorization/collection and Sigma readiness remain unverified. (#376) (@Shirofune-Security) + - Fixed `wmi-probe` operation timing to use the native precise UTC clock consistently in the parent and worker. Explicit clock receipts and launch/completion bounds preserve exact event correlation; sub-millisecond boundary tests and repeated native public-CLI runs cover timing failures without widening the accepted interval. (@Shirofune-Security) - Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index fb027456..1ffb4d00 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -104,6 +104,13 @@ [string]$EvtxProbePath, [string]$EvtxArchivePath, [string]$EvtxOutputPath, + [ValidateSet('Plan','Restore')][string]$TranscriptRecoveryAction = 'Plan', + [string]$TranscriptRecoveryJournalPath, + [string]$TranscriptRecoveryOriginalResultsPath, + [string]$TranscriptRecoveryPlanPath, + [string]$TranscriptRecoveryPlanHash, + [string]$TranscriptRecoveryOutputPath, + [switch]$TranscriptRecoveryAllowTemporarySuspension, [ValidateSet('Plan','Restore')][string]$RecoveryAction = 'Plan', [string]$RecoveryJournalPath, [string]$RecoveryOriginalResultsPath, @@ -192,6 +199,7 @@ Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorActi . (Join-Path $ScriptRoot "scripts/EventMeasurement.ps1") . (Join-Path $ScriptRoot "scripts/GpoCreation.ps1") . (Join-Path $ScriptRoot "scripts/AuditRecovery.ps1") +. (Join-Path $ScriptRoot "scripts/TranscriptionRecovery.ps1") # 64bit の PowerShell と GPO が読むのは Wow6432Node の無いパス。32bit 用に両方を扱う。 $PowerShellPolicyRoots = @( @@ -1928,6 +1936,7 @@ Usage: ./WELA.ps1 event-measurement -MeasurementChannel Security -MeasurementAction Run -MeasurementOutputPath C:\Evidence\new-sample -MeasurementExportEvtx ./WELA.ps1 rule-eligibility -RuleEvidencePath reviewed-lab-evidence.json -ResultsPath evidence-review.json ./WELA.ps1 smb-auditing -SmbAction Configure -DryRun + ./WELA.ps1 transcription-recovery -Help ./WELA.ps1 powershell-transcription -TranscriptionAction Plan -TranscriptDirectory C:\Transcripts -ResultsPath transcription-plan.json ./WELA.ps1 applocker-readiness -ResultsPath applocker.json ./WELA.ps1 applocker-readiness -AppLockerAction Plan -AppLockerPolicyPath operator-audit.xml @@ -2026,6 +2035,8 @@ if ($Cmd -eq 'intune-export' -and @($PSBoundParameters.Keys | Where-Object { $_ if ($Cmd -ne 'evtx-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Evtx*'}).Count) {throw 'EVTX options require evtx-recovery. No command was run.'} if ($Cmd -eq 'evtx-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','EvtxAction','EvtxProbePath','EvtxArchivePath','EvtxOutputPath','Help')}).Count) {throw 'evtx-recovery accepts only its dedicated options. No command was run.'} +if ($Cmd -ne 'transcription-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'TranscriptRecovery*'}).Count) {throw 'TranscriptRecovery options require transcription-recovery.'} +if ($Cmd -eq 'transcription-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','TranscriptRecoveryAction','TranscriptRecoveryJournalPath','TranscriptRecoveryOriginalResultsPath','TranscriptRecoveryPlanPath','TranscriptRecoveryPlanHash','TranscriptRecoveryOutputPath','TranscriptRecoveryAllowTemporarySuspension','Auto','DryRun','Help')}).Count) {throw 'transcription-recovery accepts only its dedicated options, Auto and DryRun.'} if ($Cmd -ne 'audit-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Recovery*'}).Count) {throw 'Recovery options require audit-recovery. No command was run.'} if ($Cmd -eq 'audit-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','RecoveryAction','RecoveryJournalPath','RecoveryOriginalResultsPath','RecoveryControlId','RecoveryPlanPath','RecoveryOutputPath','Auto','DryRun','Help')}).Count) {throw 'audit-recovery accepts only dedicated recovery options, Auto and DryRun. No command was run.'} @@ -2115,7 +2126,7 @@ if ($Cmd -ne 'ad-object-sacl' -and @($PSBoundParameters.Keys | Where-Object { }).Count) { throw 'AD object SACL options require the dedicated ad-object-sacl command. No command was run.' } -if ($DryRun -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and +if ($DryRun -and -not ($Cmd -eq 'transcription-recovery' -and $TranscriptRecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and -not ($Cmd -eq 'provider-packs' -and $ProviderAction -eq 'Configure') -and -not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and -not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and @@ -2231,6 +2242,12 @@ switch ($Cmd.ToLower()) { $report if ($report.ExitCode) {exit $report.ExitCode} } + 'transcription-recovery' { + if ($Help) {Write-Host 'Usage: transcription-recovery -TranscriptRecoveryAction Plan -TranscriptRecoveryJournalPath before.jsonl -TranscriptRecoveryOriginalResultsPath results.json -TranscriptRecoveryOutputPath new-directory; Restore uses -TranscriptRecoveryPlanPath, -TranscriptRecoveryPlanHash and new -TranscriptRecoveryOutputPath [-Auto] [-TranscriptRecoveryAllowTemporarySuspension]. DryRun omits output. See docs/transcription-recovery.md.';return} + $report=Invoke-WelaTranscriptRecovery -Action $TranscriptRecoveryAction -JournalPath $TranscriptRecoveryJournalPath -OriginalResultsPath $TranscriptRecoveryOriginalResultsPath -PlanPath $TranscriptRecoveryPlanPath -PlanHash $TranscriptRecoveryPlanHash -OutputPath $TranscriptRecoveryOutputPath -AllowTemporarySuspension:$TranscriptRecoveryAllowTemporarySuspension -Auto:$Auto -DryRun:$DryRun + $report | ConvertTo-Json -Depth 24 | Write-Output + exit ([int]$report.ExitCode) + } 'audit-recovery' { if ($Help) {Write-Host 'Usage: audit-recovery [-RecoveryAction Plan] -RecoveryJournalPath before.jsonl -RecoveryOriginalResultsPath results.json -RecoveryControlId IDs -RecoveryOutputPath new-directory; then -RecoveryAction Restore -RecoveryPlanPath reviewed-plan.json -RecoveryOutputPath new-directory [-Auto], or -DryRun without output. See docs/audit-recovery.md.';return} $report=Invoke-WelaAuditRecovery -Action $RecoveryAction -JournalPath $RecoveryJournalPath -OriginalResultsPath $RecoveryOriginalResultsPath -ControlId $RecoveryControlId -PlanPath $RecoveryPlanPath -OutputPath $RecoveryOutputPath -Auto:$Auto -DryRun:$DryRun diff --git a/docs/powershell-transcription.md b/docs/powershell-transcription.md index 3905d869..e310b299 100644 --- a/docs/powershell-transcription.md +++ b/docs/powershell-transcription.md @@ -60,6 +60,8 @@ One configuration control journals the original typed machine/current-user value `Applied`/`AlreadyCompliant` mean the machine policy and directory observations passed these checks. They do not prove transcript generation or access for another identity. `Failed` covers read/write problems, unsafe/unknown destination state and verification errors; `Overridden` covers later detected policy drift. `Skipped` includes dry runs and operator-declined changes. Exit 0 means no failed or overridden controls, including runs with skips; it is not a transcript-generation or CIS-wide compliance result. +For a completed `Applied` local-directory configuration, [transcription-recovery](transcription-recovery.md) provides reviewed typed restoration with durable receipts and explicit temporary-suspension consent. Failed/partial configuration runs and unsupported original values still require manual review. + If a later write fails, an earlier `OutputDirectory` write can remain. Review `before.jsonl`, the current policy and the authoritative GPO/MDM source. To recover, restore **only** `OutputDirectory` and `EnableTranscripting` from `Before.Policy[0].Machine`, preserving each original value's registry type; remove a value when its original `ValueExists` was false. If necessary, temporarily set `EnableTranscripting` to DWORD `0` while restoring the previous location, then restore its original value/type or absence last. Leave invocation-header and unrelated values untouched. Remove a newly created `Transcription` key only if it was originally absent and is still empty; do not delete a whole policy subtree or restore old ACLs over later changes. The journal contains policy paths/security information and should be protected as administrator recovery data. ## Evidence and limits diff --git a/docs/transcription-recovery.md b/docs/transcription-recovery.md new file mode 100644 index 00000000..7c38673b --- /dev/null +++ b/docs/transcription-recovery.md @@ -0,0 +1,51 @@ +# Recover Windows PowerShell transcription policy + +`transcription-recovery` reviews and restores the two machine values changed by one completed `powershell-transcription -TranscriptionAction Configure` run. It requires that run's original `before.jsonl` and final JSON result, exactly one `Applied` control named `PowerShellTranscription/CisV4L2`, and current policy/directory observations that still match its final `After` evidence. Failed, partial, skipped and already-compliant configuration records require manual review. + +```powershell +./WELA.ps1 transcription-recovery -TranscriptRecoveryAction Plan ` + -TranscriptRecoveryJournalPath C:\Recovery\original\before.jsonl ` + -TranscriptRecoveryOriginalResultsPath C:\Recovery\original-result.json ` + -TranscriptRecoveryOutputPath C:\Recovery\new-plan + +# Review every step in plan.json, including RequiresTemporarySuspension. +$reviewedHash = (Get-FileHash C:\Recovery\new-plan\plan.json -Algorithm SHA256).Hash.ToLowerInvariant() +./WELA.ps1 transcription-recovery -TranscriptRecoveryAction Restore ` + -TranscriptRecoveryPlanPath C:\Recovery\new-plan\plan.json ` + -TranscriptRecoveryPlanHash $reviewedHash -DryRun ` + -TranscriptRecoveryAllowTemporarySuspension + +./WELA.ps1 transcription-recovery -TranscriptRecoveryAction Restore ` + -TranscriptRecoveryPlanPath C:\Recovery\new-plan\plan.json ` + -TranscriptRecoveryPlanHash $reviewedHash ` + -TranscriptRecoveryOutputPath C:\Recovery\new-attempt ` + -TranscriptRecoveryAllowTemporarySuspension -Auto +``` + +Omit `-TranscriptRecoveryAllowTemporarySuspension` when the reviewed plan does not require it. `-Auto` accepts the ordinary confirmation; it never supplies suspension consent. `DryRun` validates all bindings and consent, returns the proposed steps and creates no directory. Plan and real Restore require new private output directories. All evidence and transcript directory paths must be literal absolute paths on local fixed drives. UNC paths, mapped drives, alternate streams and observed reparse components are rejected. + +## Supported restoration and ordering + +The target is the existing shared machine registry key `HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription`. The command supports original `OutputDirectory` REG_SZ values or absence, and original `EnableTranscripting` DWORD `0`, DWORD `1`, or absence. Other original types and values require manual recovery. Both Registry64 and Registry32 must agree. Recovery retains the existing key, removes only values that were originally absent, and never deletes policy subtrees. + +When the original enablement was DWORD `0`, recovery restores that disabled state before changing the destination. If a destination change is followed by restoring DWORD `1` or removing the enablement value, the plan requires explicit temporary suspension: write DWORD `0`, restore the destination, then restore the original enablement or absence. A failure may leave that temporary disabled state in place; the command reports this as an incomplete attempt and stops subsequent writes. It does not silently re-enable with an unverified destination. An originally absent destination is supported only with original DWORD `0`; enabled/default-user destinations require manual recovery. + +Computer policy takes precedence over user policy, and policy-enabled transcription applies to PowerShell sessions. Removing a machine value can expose user/default policy; the command restores the recorded registry state without asserting session adoption. Manual `Start-Transcript` remains possible when automatic policy transcription is disabled. [Microsoft Windows PowerShell policy documentation](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_group_policy_settings?view=powershell-5.1). `HKLM\SOFTWARE\Policies` is shared across the registry views; recovery writes through Registry64 once and verifies both observations. [Microsoft WOW64 registry documentation](https://learn.microsoft.com/en-us/windows/win32/winprog64/shared-registry-keys). + +Existing sessions are not stopped or restarted. Transcript files, their ACLs, shares, retention, collection, module logging, script-block logging, invocation-header preferences, current-user policy and all unrelated PowerShell policy values are preserved. The command inventories the other machine/current-user PowerShell policy tree with explicit bounds and stops when it changes. + +## Evidence and failure handling + +The reviewed plan binds original file hashes, the exact current host/MachineGuid and OS context, the elevated primary-token user/group/logon observations, current implementation hashes, both registry views, and the old/new directory observations. Restore verifies the separately supplied plan hash and independently rebuilds the plan from its original evidence and current observations. It checks those bindings after the prompt, before each write, during readback and at completion. A changed directory, policy, reader, source, plan or implementation stops the run. + +Version-1 Configure journals record only the historical `ComputerName`. Current MachineGuid/logon/code bindings do **not** establish historical identity or authenticate supplied records. Hashes establish consistency. Keep original evidence and the reviewed hash under administrator control, review the authoritative GPO/MDM policy separately, and do not treat local registry restoration as proof of policy ownership or persistence. + +Each mutation has a flushed, new `NNN-pending.json` receipt written before it and a separate `NNN-confirmed.json` only after verified readback. `result.json` contains actual observed final policy and confirmed steps. A pending receipt without confirmation is an uncertain step; inspect current native policy and preserve all receipts before manual recovery. A write may have succeeded even when its readback/receipt failed. Failed attempts and replay after a completed restore are refused by the original final-state guard; this command does not resume partial attempts or accept a new baseline silently. Failure to persist the result fails outward while existing evidence remains. + +These are bounded point-in-time checks, not an atomic registry/filesystem lock. Another administrator or policy refresh may change state after a check. Private output guards observe ACL and directory identity metadata; they do not provide adversarial filesystem locking or central storage authorization proof. + +## Validation scope + +Portable tests exercise typed restoration, absent values, ordering, consent, preview, unsupported history, duplicate JSON, plan/source/host/directory/policy drift, prompt-time races and partial failures. The explicitly gated disposable native matrix targets Server 2022/2025 with Windows PowerShell 5.1 and PowerShell 7 as WELA hosts. It performs actual public Configure/Plan/Restore, checks native typed values and preserved policy, captures receipts, tests real drift refusal, and restores the fixture's exact original policy in `finally`. A fresh Windows PowerShell 5.1 session checks a benign transcript marker at the restored private local destination. Artifacts retain that fixture evidence and `cleanup.json`; PowerShell 7 remains only a WELA host. + +`Restored` means the selected typed registry values passed final verification. Production transcript generation, existing/future session behavior, other identities, client/DC roles, central read/modify authorization, collection and retention remain separate validation. The report grants `SigmaEvtxCredit=0`; transcript text is separate from 4103/4104 EVTX. This advances recovery for [issue #376](https://github.com/Yamato-Security/WELA/issues/376) without completing its central authorization/ingestion acceptance. diff --git a/scripts/TranscriptionRecovery.ps1 b/scripts/TranscriptionRecovery.ps1 new file mode 100644 index 00000000..f193853d --- /dev/null +++ b/scripts/TranscriptionRecovery.ps1 @@ -0,0 +1,233 @@ +# Explicit recovery of one completed Windows PowerShell transcription policy write. +function Get-WelaTranscriptRecoverySources { + $sources=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/TranscriptionRecovery.ps1','scripts/PowerShellTranscription.ps1','scripts/Configuration.ps1','scripts/AuditRecovery.ps1','scripts/ControlApplicability.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1')) { + $sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + } + [pscustomobject]$sources +} +function Get-WelaTranscriptRecoveryContext { + $hostState=Get-WelaRecoveryHost + $reader=Get-WelaChannelReader + if(-not $reader.ElevatedAdministrator){throw 'Transcription recovery requires an elevated administrator primary token.'} + # A reviewed plan can be consumed by a new process in the same logon session. + [pscustomobject][ordered]@{Host=$hostState;Reader=($reader|Select-Object UserSid,UserName,AuthenticationId,GroupSids,ElevatedAdministrator,TokenType,Impersonation)} +} +function Assert-WelaTranscriptRecoveryLocalPath { + param([string]$Path) + Test-WelaTranscriptDirectoryPath $Path + if($Path -notmatch '^[A-Za-z]:\\' -or (Get-WelaRecoveryOutputDriveType ([IO.Path]::GetPathRoot($Path))) -ne [IO.DriveType]::Fixed){throw 'Transcription recovery supports ordinary local fixed-drive paths only; UNC and mapped drives require manual recovery.'} +} +function Read-WelaTranscriptRecoveryFile { + param([string]$Path) + Assert-WelaTranscriptRecoveryLocalPath $Path + Get-WelaRecoveryFile $Path +} +function Get-WelaTranscriptRecoveryProtectedPolicy { + # Inventory the complete PowerShell policy tree, excluding only the two owned + # machine values. No policy, header, module/script-block or user writes occur. + $rows=New-Object 'System.Collections.Generic.List[object]' + foreach($hive in @('LocalMachine','CurrentUser')) { + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::$hive,[Microsoft.Win32.RegistryView]::Registry64) + try { + $queue=New-Object 'System.Collections.Generic.Queue[string]';$queue.Enqueue('') + while($queue.Count) { + $relative=$queue.Dequeue();$path='SOFTWARE\Policies\Microsoft\Windows\PowerShell'+$relative + $key=$base.OpenSubKey($path,$false) + try { + $values=@();$children=@() + if($null -ne $key) { + $children=@($key.GetSubKeyNames()|Sort-Object) + foreach($name in ($key.GetValueNames()|Sort-Object)) { + if($hive -eq 'LocalMachine' -and $relative -eq '\Transcription' -and $name -in @('EnableTranscripting','OutputDirectory')){continue} + $values += [pscustomobject][ordered]@{Name=$name;Type=$key.GetValueKind($name).ToString();Value=$key.GetValue($name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)} + } + } + $rows.Add([pscustomobject][ordered]@{Hive=$hive;Path=$relative;Exists=($null -ne $key);Values=$values;Children=$children}) + if($rows.Count -gt 128 -or $queue.Count+$children.Count -gt 128 -or $relative.Length -gt 1024 -or $values.Count -gt 256){throw 'PowerShell policy inventory exceeded bounded recovery scope.'} + foreach($child in $children){$queue.Enqueue($relative+'\'+$child)} + } finally {if($key){$key.Dispose()}} + } + } finally {$base.Dispose()} + } + $result=@($rows.ToArray()) + if((Get-WelaRecoveryKey $result).Length -gt 1048576){throw 'PowerShell policy inventory exceeded 1 MiB.'} + return ,$result +} +function Assert-WelaTranscriptRecoveryValue { + param($Value,[string]$Name) + if($null -eq $Value -or $Value.KeyExists -isnot [bool] -or $Value.ValueExists -isnot [bool]){throw 'Missing typed transcription value state.'} + if(-not $Value.ValueExists) { + if($null -ne $Value.Type -or $null -ne $Value.Value){throw 'Absent transcription value has inconsistent state.'} + } elseif(-not $Value.KeyExists){throw 'A present transcription value requires an existing key.'} + elseif($Name -eq 'EnableTranscripting') { + if($Value.Type -cne 'DWord' -or ($Value.Value -isnot [int] -and $Value.Value -isnot [long]) -or $Value.Value -notin @(0,1)){throw 'Only DWORD 0/1 or absent enablement can be restored; other types require manual recovery.'} + } elseif($Value.Type -cne 'String' -or $Value.Value -isnot [string] -or -not $Value.Value){throw 'Only a nonempty REG_SZ or absent output directory can be restored.'} +} +function Get-WelaTranscriptRecoveryTypedKey { + param($Value) + Get-WelaRecoveryKey ($Value|Select-Object ValueExists,Type,Value) +} +function Get-WelaTranscriptRecoveryDestinations { + param([string[]]$Paths) + foreach($path in ($Paths|Sort-Object -Unique)) { + Assert-WelaTranscriptRecoveryLocalPath $path + $directory=Get-WelaTranscriptDestination $path + if(-not $directory.ConfigureAllowed -or $directory.Status -cne 'Observed'){throw "Recovery destination cannot be verified: $($directory.Diagnostic)"} + $directory + } +} +function New-WelaTranscriptRecoveryPlan { + param([string]$JournalPath,[string]$OriginalResultsPath) + $context=Get-WelaTranscriptRecoveryContext;$sources=Get-WelaTranscriptRecoverySources + $journal=Read-WelaTranscriptRecoveryFile $JournalPath;$resultFile=Read-WelaTranscriptRecoveryFile $OriginalResultsPath + $entries=@($journal.Text -split '\r?\n'|Where-Object {$_ -match '\S'}|ForEach-Object {ConvertFrom-WelaRecoveryJson $_}) + $results=ConvertFrom-WelaRecoveryJson $resultFile.Text + if($entries.Count -ne 1 -or $results.Results -isnot [array] -or $results.Results.Count -ne 1 -or $results.DryRun -isnot [bool] -or $results.DryRun -or + $results.ExitCode -ne 0 -or $results.Failed -ne 0 -or $results.Skipped -ne 0 -or $results.Action -cne 'Configure' -or $results.Scope -cne 'windows-powershell-transcription-policy-only'){throw 'Recovery requires one completed Applied transcription Configure journal/result, without other controls or partial outcomes.'} + $entry=$entries[0];$last=$results.Results[0] + if($entry.Version -ne 1 -or $entry.ComputerName -isnot [string] -or $entry.ComputerName -ine $context.Host.Computer -or $entry.Id -cne 'PowerShellTranscription/CisV4L2' -or + $entry.Kind -cne 'PowerShellTranscription' -or $last.Status -cne 'Applied' -or $last.Id -cne $entry.Id -or $last.Kind -cne $entry.Kind){throw 'Wrong host, control, schema or incomplete transcription history.'} + $time=[datetimeoffset]::MinValue + if($entry.RecordedUtc -isnot [string] -or $entry.RecordedUtc -notmatch '(Z|\+00:00)$' -or -not [datetimeoffset]::TryParse($entry.RecordedUtc,[ref]$time) -or $time -gt [datetimeoffset]::UtcNow.AddMinutes(1)){throw 'Original journal requires a valid UTC timestamp.'} + foreach($field in @('Before','Target','Desired')){if((Get-WelaRecoveryKey $entry.$field) -cne (Get-WelaRecoveryKey $last.$field)){throw "Original journal/result $field differs."}} + if($entry.Target.Hive -cne 'LocalMachine' -or $entry.Target.SubKey -cne 'SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription' -or $entry.Desired.EnableTranscripting.Type -cne 'DWord' -or $entry.Desired.EnableTranscripting.Value -ne 1 -or + $entry.Desired.OutputDirectory.Type -cne 'String' -or $entry.Desired.OutputDirectory.Value -cne $entry.Target.OutputDirectory -or $entry.Desired.EnableInvocationHeader -cne 'Preserve'){throw 'Unsupported transcription target or desired state.'} + $before=$entry.Before;$after=$last.After + foreach($snapshot in @($before,$after)) { + if($snapshot.Capability.Status -cne 'Supported' -or $snapshot.Policy -isnot [array] -or $snapshot.Policy.Count -ne 2 -or + $snapshot.Policy[0].View -cne 'Registry64' -or $snapshot.Policy[1].View -cne 'Registry32'){throw 'Both canonical shared registry views are required.'} + Test-WelaTranscriptSharedPolicy $snapshot.Policy + foreach($name in @('EnableTranscripting','OutputDirectory')){Assert-WelaTranscriptRecoveryValue $snapshot.Policy[0].Machine.$name $name} + } + if(-not (Test-WelaTranscriptConfigured $after $entry.Target.OutputDirectory)){throw 'Final transcription policy was not the requested enabled state.'} + if((Get-WelaRecoveryKey $before.Policy[0].CurrentUser) -cne (Get-WelaRecoveryKey $after.Policy[0].CurrentUser) -or + (Get-WelaTranscriptRecoveryTypedKey $before.Policy[0].Machine.EnableInvocationHeader) -cne (Get-WelaTranscriptRecoveryTypedKey $after.Policy[0].Machine.EnableInvocationHeader)){throw 'Original configuration did not preserve user/header policy.'} + $current=Get-WelaTranscriptState $entry.Target.OutputDirectory + if((Get-WelaRecoveryKey $current.Policy) -cne (Get-WelaRecoveryKey $after.Policy) -or (Get-WelaRecoveryKey $current.Destination) -cne (Get-WelaRecoveryKey $after.Destination)){throw 'Current policy/destination differs from the original final After state.'} + $target=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $after.Policy) + foreach($view in $target){foreach($name in @('EnableTranscripting','OutputDirectory')) { + $view.Machine.$name=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $before.Policy[0].Machine.$name) + # Keep the existing key; absence recovery removes only the selected value. + $view.Machine.$name.KeyExists=$true + }} + $prior=$before.Policy[0].Machine;$paths=@([string]$entry.Target.OutputDirectory) + if($prior.OutputDirectory.ValueExists){$paths += [string]$prior.OutputDirectory.Value} + elseif(-not ($prior.EnableTranscripting.ValueExists -and $prior.EnableTranscripting.Value -eq 0)) { + throw 'Restoring an absent output directory requires explicit prior DWORD 0; user/default destinations require manual recovery.' + } + $directories=@(Get-WelaTranscriptRecoveryDestinations $paths) + $outputChanges=(Get-WelaTranscriptRecoveryTypedKey $prior.OutputDirectory) -cne (Get-WelaTranscriptRecoveryTypedKey $after.Policy[0].Machine.OutputDirectory) + $suspend=$outputChanges -and -not ($prior.EnableTranscripting.ValueExists -and $prior.EnableTranscripting.Value -eq 0) + $steps=New-Object 'System.Collections.Generic.List[object]' + if($outputChanges) { + $off=if($suspend){[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=0;Type='DWord'}}else{$target[0].Machine.EnableTranscripting} + $steps.Add([pscustomobject]@{Name='EnableTranscripting';Value=$off;Purpose=$(if($suspend){'Explicit temporary suspension'}else{'Restore disabled state before destination'})}) + $steps.Add([pscustomobject]@{Name='OutputDirectory';Value=$target[0].Machine.OutputDirectory;Purpose='Restore original destination value or absence'}) + if($suspend){$steps.Add([pscustomobject]@{Name='EnableTranscripting';Value=$target[0].Machine.EnableTranscripting;Purpose='Restore original enablement value or absence'})} + } elseif((Get-WelaTranscriptRecoveryTypedKey $prior.EnableTranscripting) -cne (Get-WelaTranscriptRecoveryTypedKey $after.Policy[0].Machine.EnableTranscripting)) { + $steps.Add([pscustomobject]@{Name='EnableTranscripting';Value=$target[0].Machine.EnableTranscripting;Purpose='Restore original enablement value or absence'}) + } + if(-not $steps.Count){throw 'Original Applied evidence contains no recoverable typed changes.'} + $protected=Get-WelaTranscriptRecoveryProtectedPolicy + [pscustomobject][ordered]@{Kind='WelaTranscriptionRecoveryPlan';SchemaVersion=1;Context=$context;Sources=$sources; + Journal=[pscustomobject]@{Path=$journal.Path;Sha256=$journal.Sha256};OriginalResults=[pscustomobject]@{Path=$resultFile.Path;Sha256=$resultFile.Sha256}; + ExpectedPolicy=$after.Policy;RecoverTo=$target;Directories=$directories;ProtectedPolicy=$protected;RequiresTemporarySuspension=[bool]$suspend;Steps=@($steps.ToArray()); + HistoricalIdentity='Version-1 configuration journals record ComputerName only. Current host/reader/code bindings do not authenticate historical identity or evidence.';SigmaEvtxCredit=0} +} +function Assert-WelaTranscriptRecoveryBindings { + param($Plan,$Policy,[string]$PlanPath,[string]$PlanHash) + foreach($source in @($Plan.Journal,$Plan.OriginalResults)){if((Read-WelaTranscriptRecoveryFile $source.Path).Sha256 -cne $source.Sha256){throw 'Original transcription recovery evidence changed.'}} + if($PlanPath -and (Read-WelaTranscriptRecoveryFile $PlanPath).Sha256 -cne $PlanHash){throw 'Reviewed transcription recovery plan changed.'} + if((Get-WelaRecoveryKey (Get-WelaTranscriptRecoveryContext)) -cne (Get-WelaRecoveryKey $Plan.Context) -or (Get-WelaRecoveryKey (Get-WelaTranscriptRecoverySources)) -cne (Get-WelaRecoveryKey $Plan.Sources)){throw 'Actual host, reader or recovery implementation changed.'} + if((Get-WelaRecoveryKey (Get-WelaTranscriptRecoveryProtectedPolicy)) -cne (Get-WelaRecoveryKey $Plan.ProtectedPolicy)){throw 'Preserved PowerShell policy changed; recovery stopped.'} + if((Get-WelaRecoveryKey @(Get-WelaTranscriptRecoveryDestinations @($Plan.Directories.RequestedPath))) -cne (Get-WelaRecoveryKey $Plan.Directories)){throw 'A reviewed transcript directory changed.'} + $capability=Get-WelaTranscriptCapability + if($capability.Status -cne 'Supported'){throw 'Windows PowerShell capability changed.'} + $current=@(Get-WelaTranscriptPolicy $capability.Views);Test-WelaTranscriptSharedPolicy $current + if((Get-WelaRecoveryKey $current) -cne (Get-WelaRecoveryKey $Policy)){throw 'Current typed transcription policy drifted from the expected recovery step.'} +} +function Set-WelaTranscriptRecoveryValue { + param([ValidateSet('EnableTranscripting','OutputDirectory')][string]$Name,$Value) + Assert-WelaTranscriptRecoveryValue $Value $Name + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64) + $key=$null + try { + $key=$base.OpenSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription',$true) + if($null -eq $key){throw 'Existing transcription key disappeared; it will not be recreated.'} + if($Value.ValueExists){$key.SetValue($Name,$Value.Value,[Microsoft.Win32.RegistryValueKind]([string]$Value.Type))} + else{$key.DeleteValue($Name,$false)} + $key.Flush() + } finally {if($key){$key.Dispose()};$base.Dispose()} +} +function Write-WelaTranscriptRecoveryArtifact { + param($Directory,[string]$Name,$Value) + $fresh=Get-WelaTranscriptDestination $Directory.RequestedPath + if(-not $fresh.ConfigureAllowed -or (Get-WelaRecoveryKey $fresh) -cne (Get-WelaRecoveryKey $Directory)){throw 'Private recovery output directory changed.'} + Write-WelaRecoveryArtifact (Join-Path $Directory.Path $Name) $Value +} +function Invoke-WelaTranscriptRecovery { + param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$JournalPath,[string]$OriginalResultsPath,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath,[switch]$AllowTemporarySuspension,[switch]$Auto,[switch]$DryRun) + $ErrorActionPreference='Stop' + if($Action -eq 'Plan') { + if($PlanPath -or $PlanHash -or $Auto -or $DryRun -or $AllowTemporarySuspension){throw 'Plan takes original journal/results and new output only; consent flags are Restore-only.'} + $plan=New-WelaTranscriptRecoveryPlan $JournalPath $OriginalResultsPath + Assert-WelaTranscriptRecoveryBindings $plan $plan.ExpectedPolicy + Assert-WelaTranscriptRecoveryLocalPath $OutputPath + $output=New-WelaRecoveryOutput $OutputPath + $outputObservation=Get-WelaTranscriptDestination $output + Write-WelaTranscriptRecoveryArtifact $outputObservation 'plan.json' $plan + $hash=(Read-WelaTranscriptRecoveryFile (Join-Path $output 'plan.json')).Sha256 + return [pscustomobject]@{Status='Planned';ExitCode=0;OutputPath=$output;PlanSha256=$hash;RequiresTemporarySuspension=$plan.RequiresTemporarySuspension;SigmaEvtxCredit=0} + } + if($JournalPath -or $OriginalResultsPath -or -not $PlanPath -or $PlanHash -cnotmatch '^[0-9a-f]{64}$'){throw 'Restore consumes a reviewed plan path, its exact SHA-256 and a new output directory.'} + $source=Read-WelaTranscriptRecoveryFile $PlanPath + if($source.Sha256 -cne $PlanHash){throw 'Supplied reviewed plan hash differs.'} + $plan=ConvertFrom-WelaRecoveryJson $source.Text + if($plan.Kind -cne 'WelaTranscriptionRecoveryPlan' -or $plan.SchemaVersion -ne 1){throw 'Unsupported transcription recovery plan.'} + $rebuilt=New-WelaTranscriptRecoveryPlan $plan.Journal.Path $plan.OriginalResults.Path + if((Get-WelaRecoveryKey $rebuilt) -cne (Get-WelaRecoveryKey $plan)){throw 'Reviewed plan differs from independently rebuilt original evidence and current observations.'} + Assert-WelaTranscriptRecoveryBindings $plan $plan.ExpectedPolicy $source.Path $source.Sha256 + if($plan.RequiresTemporarySuspension -and -not $AllowTemporarySuspension){throw 'Restoring this destination requires explicit -TranscriptRecoveryAllowTemporarySuspension consent, including for preview.'} + if($DryRun) { + if($OutputPath){throw 'DryRun writes no directory; omit OutputPath.'} + return [pscustomobject]@{Status='WouldRestore';ExitCode=0;DryRun=$true;Steps=$plan.Steps;SigmaEvtxCredit=0} + } + Assert-WelaTranscriptRecoveryLocalPath $OutputPath + $output=New-WelaRecoveryOutput $OutputPath + $outputObservation=Get-WelaTranscriptDestination $output + $report=[pscustomobject][ordered]@{Status='Failed';ExitCode=1;OutputPath=$output;PlanSha256=$source.Sha256;Steps=@();Before=$plan.ExpectedPolicy;After=$null;Diagnostic='';SigmaEvtxCredit=0;Scope='Two typed Windows PowerShell machine transcription values only; no transcript, session adoption, central collection or policy persistence proof.'} + $expected=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $plan.ExpectedPolicy) + try { + if(-not $Auto -and (Read-Host 'Restore the reviewed transcription values, including any explicitly consented temporary suspension? (y/N)') -cnotin @('y','Y')){$report.Status='Declined';$report.ExitCode=0} + else { + Write-WelaTranscriptRecoveryArtifact $outputObservation 'plan.json' $plan + $sequence=0 + foreach($step in $plan.Steps) { + $sequence++ + Assert-WelaTranscriptRecoveryBindings $plan $expected $source.Path $source.Sha256 + $receipt=[pscustomobject]@{Sequence=$sequence;Status='Pending';RecordedUtc=[datetime]::UtcNow.ToString('o');PlanSha256=$source.Sha256;Step=$step;Before=(ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $expected));After=$null} + Write-WelaTranscriptRecoveryArtifact $outputObservation ('{0:d3}-pending.json' -f $sequence) $receipt + Assert-WelaTranscriptRecoveryBindings $plan $expected $source.Path $source.Sha256 + Set-WelaTranscriptRecoveryValue $step.Name $step.Value + foreach($view in $expected){$view.Machine.($step.Name)=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $step.Value)} + Assert-WelaTranscriptRecoveryBindings $plan $expected $source.Path $source.Sha256 + $receipt.Status='Confirmed';$receipt.After=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $expected) + Write-WelaTranscriptRecoveryArtifact $outputObservation ('{0:d3}-confirmed.json' -f $sequence) $receipt + $report.Steps += [pscustomobject]@{Sequence=$sequence;Name=$step.Name;Status='Confirmed';Value=$step.Value} + } + Assert-WelaTranscriptRecoveryBindings $plan $plan.RecoverTo $source.Path $source.Sha256 + $report.Status='Restored';$report.ExitCode=0 + } + } catch {$report.Diagnostic=$_.Exception.Message} + try { + $report.After=@(Get-WelaTranscriptPolicy (Get-WelaTranscriptCapability).Views) + if($report.Status -eq 'Restored') { + if((Get-WelaRecoveryKey $report.After) -cne (Get-WelaRecoveryKey $plan.RecoverTo)){throw 'Final returned policy differs from the recovery target.'} + Assert-WelaTranscriptRecoveryBindings $plan $plan.RecoverTo $source.Path $source.Sha256 + } + }catch{$report.Diagnostic+=' Final policy verification failed: '+$_.Exception.Message;$report.Status='Failed';$report.ExitCode=1} + # A failed result write fails outward; pending/confirmed receipts remain intact. + Write-WelaTranscriptRecoveryArtifact $outputObservation 'result.json' $report + return $report +} diff --git a/tests/TranscriptionRecovery.Tests.ps1 b/tests/TranscriptionRecovery.Tests.ps1 new file mode 100644 index 00000000..cdeb5eb1 --- /dev/null +++ b/tests/TranscriptionRecovery.Tests.ps1 @@ -0,0 +1,139 @@ +$ErrorActionPreference='Stop' +$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +. (Join-Path $script:ScriptRoot 'scripts/Configuration.ps1') +. (Join-Path $script:ScriptRoot 'scripts/AuditRecovery.ps1') +. (Join-Path $script:ScriptRoot 'scripts/PowerShellTranscription.ps1') +. (Join-Path $script:ScriptRoot 'scripts/TranscriptionRecovery.ps1') +$script:artifactWriter=(Get-Command Write-WelaRecoveryArtifact).ScriptBlock +function Write-WelaRecoveryArtifact { + param($Path,$Value) + if($script:failArtifact -and [IO.Path]::GetFileName($Path) -eq $script:failArtifact){throw 'injected durable artifact failure'} + & $script:artifactWriter $Path $Value +} +$script:checks=0;$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-transcript-recovery-test-'+[guid]::NewGuid().ToString('N')) +$null=New-Item -ItemType Directory $root +function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++} +function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{& $Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected '$Pattern', got '$message'"} +function Copy-Value($Value){ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey $Value)} +function Typed($Value,$Type='DWord'){[pscustomobject]@{KeyExists=$true;ValueExists=($null -ne $Value);Value=$Value;Type=$(if($null -ne $Value){$Type}else{$null})}} +function Get-WelaTranscriptRecoveryContext {[pscustomobject]@{Host=[pscustomobject]@{Computer='fixture';MachineGuid=$script:machine};Reader='fixture-reader'}} +function Get-WelaTranscriptRecoverySources {[pscustomobject]@{Code=$script:code}} +function Assert-WelaTranscriptRecoveryLocalPath {param($Path) if(-not $Path -or $Path.StartsWith('\\')){throw 'local path fixture refusal'}} +function Get-WelaTranscriptRecoveryProtectedPolicy {return ,$script:protected} +function Get-WelaTranscriptCapability {[pscustomobject]@{Status='Supported';Views=@('Registry64','Registry32')}} +function Get-WelaTranscriptPolicy {param($Views) Copy-Value $script:policy} +function Get-WelaTranscriptDestination {param($Path) [pscustomobject]@{RequestedPath=$Path;Path=$Path;Status='Observed';ConfigureAllowed=$true;CreationTimeUtc='fixture';Acl=$script:acl}} +function Get-WelaTranscriptState {param($OutputDirectory) [pscustomobject]@{Capability=(Get-WelaTranscriptCapability);Policy=(Get-WelaTranscriptPolicy);Destination=(Get-WelaTranscriptDestination $OutputDirectory)}} +function Set-WelaTranscriptRecoveryValue { + param($Name,$Value) + $script:writes++ + Assert (Test-Path (Join-Path $script:restoreOutput ('{0:d3}-pending.json' -f $script:writes))) 'each actual write has a durable pending receipt first' + if($script:writes -eq $script:failWrite){throw 'injected write failure'} + foreach($view in $script:policy){$view.Machine.$Name=Copy-Value $Value} + if($script:writes -eq $script:driftWrite){$script:protected=@('changed independent module policy')} +} +function Read-Host {param($Prompt) if($script:promptDrift){$script:policy[0].Machine.EnableInvocationHeader=Typed 1;$script:policy[1].Machine.EnableInvocationHeader=Typed 1};'y'} +function New-Fixture($Enable=1,$Directory='C:\Old') { + $script:machine='stable';$script:code='stable';$script:acl='private';$script:protected=@('module','script-block','unrelated');$script:writes=0;$script:failWrite=-1;$script:driftWrite=-1;$script:promptDrift=$false;$script:failArtifact=$null + $script:fixture=Join-Path $root ([guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $script:fixture + $beforePolicy=@(foreach($view in @('Registry64','Registry32')){[pscustomobject]@{View=$view;Machine=[pscustomobject]@{EnableTranscripting=(Typed $Enable);OutputDirectory=(Typed $Directory String);EnableInvocationHeader=(Typed 0)};CurrentUser=[pscustomobject]@{EnableTranscripting=(Typed $null);OutputDirectory=(Typed $null);EnableInvocationHeader=(Typed $null)}}}) + $script:policy=Copy-Value $beforePolicy + foreach($view in $script:policy){$view.Machine.EnableTranscripting=Typed 1;$view.Machine.OutputDirectory=Typed 'C:\New' String} + $before=[pscustomobject]@{Capability=(Get-WelaTranscriptCapability);Policy=$beforePolicy;Destination=(Get-WelaTranscriptDestination 'C:\New')} + $after=Get-WelaTranscriptState 'C:\New' + $target=[pscustomobject]@{Hive='LocalMachine';SubKey='SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription';OutputDirectory='C:\New'} + $desired=[pscustomobject]@{EnableTranscripting=[pscustomobject]@{Type='DWord';Value=1};OutputDirectory=[pscustomobject]@{Type='String';Value='C:\New'};EnableInvocationHeader='Preserve'} + $script:entry=[pscustomobject]@{Version=1;ComputerName='fixture';RecordedUtc=[datetime]::UtcNow.ToString('o');Id='PowerShellTranscription/CisV4L2';Kind='PowerShellTranscription';Before=$before;Target=$target;Desired=$desired} + $script:original=[pscustomobject]@{ExitCode=0;Failed=0;Skipped=0;DryRun=$false;Action='Configure';Scope='windows-powershell-transcription-policy-only';Results=@([pscustomobject]@{Id=$script:entry.Id;Kind=$script:entry.Kind;Before=$before;After=$after;Target=$target;Desired=$desired;Status='Applied'})} + Save-History + $script:restoreOutput=Join-Path $script:fixture 'restore' +} +function Save-History { + $script:journal=Join-Path $script:fixture 'before.jsonl';$script:originalPath=Join-Path $script:fixture 'original.json' + Get-WelaRecoveryKey $script:entry|Set-Content -LiteralPath $script:journal -Encoding UTF8 + Get-WelaRecoveryKey $script:original|Set-Content -LiteralPath $script:originalPath -Encoding UTF8 +} +function Plan-Fixture { + $script:planResult=Invoke-WelaTranscriptRecovery -JournalPath $script:journal -OriginalResultsPath $script:originalPath -OutputPath (Join-Path $script:fixture 'plan') + $script:planPath=Join-Path $script:planResult.OutputPath 'plan.json' + $script:restoreParameters=@{Action='Restore';PlanPath=$script:planPath;PlanHash=$script:planResult.PlanSha256;OutputPath=$script:restoreOutput;Auto=$true} +} +try { + New-Fixture;Plan-Fixture + Assert ($script:planResult.RequiresTemporarySuspension -and $script:writes -eq 0) 'plan exposes a required temporary suspension without changes' + Reject {Invoke-WelaTranscriptRecovery @script:restoreParameters} 'explicit.*TemporarySuspension' + Assert (-not (Test-Path $script:restoreOutput)) 'missing suspension consent creates no recovery output' + $report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension + Assert ($report.Status -eq 'Restored' -and $report.ExitCode -eq 0 -and $script:writes -eq 3) 'enabled destination recovery completes three verified writes' + $confirmed=@(Get-ChildItem $script:restoreOutput '*-confirmed.json'|ForEach-Object {ConvertFrom-WelaRecoveryJson (Get-Content $_.FullName -Raw)}) + Assert ($confirmed[0].Step.Name -eq 'EnableTranscripting' -and $confirmed[0].Step.Value.Value -eq 0 -and $confirmed[1].Step.Name -eq 'OutputDirectory' -and $confirmed[2].Step.Value.Value -eq 1) 'explicit suspension precedes destination and original enablement comes last' + Assert ($confirmed[0].Before[0].Machine.EnableTranscripting.Value -eq 1 -and $confirmed[0].After[0].Machine.EnableTranscripting.Value -eq 0) 'confirmed receipts retain distinct before/after step snapshots' + Assert ($script:policy[0].Machine.OutputDirectory.Value -eq 'C:\Old' -and $script:policy[0].Machine.EnableInvocationHeader.Value -eq 0) 'original directory restored and header retained' + Assert ($report.SigmaEvtxCredit -eq 0) 'recovery grants no EVTX credit' + Reject {Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension} 'Current policy/destination differs' + foreach($before in @(0,$null)) { + New-Fixture $before;Plan-Fixture + $report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension + Assert ($report.Status -eq 'Restored' -and $script:policy[0].Machine.EnableTranscripting.Value -eq $before) 'disabled or absent original enablement is recovered exactly' + Assert ($script:writes -eq $(if($null -eq $before){3}else{2})) 'only required ordered steps are written' + } + New-Fixture 0 $null;Plan-Fixture + $report=Invoke-WelaTranscriptRecovery @script:restoreParameters + Assert ($report.Status -eq 'Restored' -and -not $script:policy[0].Machine.OutputDirectory.ValueExists -and $script:policy[0].Machine.OutputDirectory.KeyExists) 'absent output value restored with key retained' + New-Fixture 1 $null + Reject {Plan-Fixture} 'absent output directory requires' + New-Fixture 0 'C:\New';Plan-Fixture + $preview=$script:restoreParameters.Clone();$preview.Remove('OutputPath') + $report=Invoke-WelaTranscriptRecovery @preview -DryRun + Assert ($report.Status -eq 'WouldRestore' -and $script:writes -eq 0 -and -not (Test-Path $script:restoreOutput)) 'preview is read-only' + $report=Invoke-WelaTranscriptRecovery @script:restoreParameters + Assert ($report.Status -eq 'Restored' -and $script:writes -eq 1) 'unchanged destination restores enablement only' + foreach($alter in @('wrong-host','failed','mismatch','type','duplicate','shared-view')) { + New-Fixture + switch($alter){ + 'wrong-host' {$script:entry.ComputerName='other'} + 'failed' {$script:original.Results[0].Status='Failed'} + 'mismatch' {$script:original.Results[0].Desired=Copy-Value $script:original.Results[0].Desired;$script:original.Results[0].Desired.EnableTranscripting.Value=0} + 'type' {$script:entry.Before.Policy[0].Machine.EnableTranscripting=Typed '1' String;$script:entry.Before.Policy[1].Machine.EnableTranscripting=Typed '1' String} + 'duplicate' {$script:original.Results += $script:original.Results[0]} + 'shared-view' {$script:entry.Before.Policy[1].Machine.EnableTranscripting=Typed 0} + } + Save-History + Reject {Plan-Fixture} 'history|Applied|differs|DWORD|shared|Shared' + Assert ($script:writes -eq 0) 'unsupported or inconsistent source evidence never mutates' + } + foreach($alter in @('source','host','policy','directory','protected','plan')) { + New-Fixture;Plan-Fixture + switch($alter){ + 'source' {$script:code='changed'} + 'host' {$script:machine='changed'} + 'policy' {foreach($view in $script:policy){$view.Machine.EnableTranscripting=Typed 0}} + 'directory' {$script:acl='changed'} + 'protected' {$script:protected=@('changed')} + 'plan' {Add-Content -LiteralPath $script:planPath ' '} + } + Reject {Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension} 'differs|different' + Assert ($script:writes -eq 0) 'drift before restore causes no mutation' + } + New-Fixture;Plan-Fixture;$script:promptDrift=$true;$script:restoreParameters.Auto=$false + $report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension + Assert ($report.ExitCode -eq 1 -and $script:writes -eq 0) 'prompt-time typed policy drift blocks the first write' + New-Fixture;Plan-Fixture;$script:failWrite=2 + $report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension + Assert ($report.ExitCode -eq 1 -and $script:writes -eq 2 -and $script:policy[0].Machine.EnableTranscripting.Value -eq 0 -and $script:policy[0].Machine.OutputDirectory.Value -eq 'C:\New') 'partial failure stops and reports the actual suspended state' + Assert ((Test-Path (Join-Path $script:restoreOutput '001-confirmed.json')) -and (Test-Path (Join-Path $script:restoreOutput '002-pending.json')) -and -not (Test-Path (Join-Path $script:restoreOutput '003-pending.json'))) 'partial receipts preserve confirmed versus uncertain steps' + New-Fixture;Plan-Fixture;$script:driftWrite=1 + $report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension + Assert ($report.ExitCode -eq 1 -and $script:writes -eq 1 -and $report.Diagnostic -match 'Preserved PowerShell policy changed') 'independent policy drift after a write stops all later writes' + foreach($name in @('001-pending.json','001-confirmed.json')) { + New-Fixture;Plan-Fixture;$script:failArtifact=$name + $report=Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension + Assert ($report.ExitCode -eq 1 -and $script:writes -eq $(if($name -like '*pending*'){0}else{1})) 'durable receipt failure stops before any further native writes' + } + New-Fixture;Plan-Fixture;$script:failArtifact='result.json' + Reject {Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension} 'durable artifact failure' + Assert ($script:writes -eq 3 -and (Test-Path (Join-Path $script:restoreOutput '003-confirmed.json'))) 'result persistence failure fails outward while durable final confirmation remains' + Reject {ConvertFrom-WelaRecoveryJson '{"x":1,"X":2}'} 'Duplicate' + Reject {ConvertFrom-WelaRecoveryJson '{x:1}'} 'strict JSON' + Write-Host "Passed $script:checks transcription recovery assertions; no Windows policy changes." +} finally {Remove-Item -LiteralPath $root -Recurse -Force} diff --git a/tests/TranscriptionRecovery.Windows.Tests.ps1 b/tests/TranscriptionRecovery.Windows.Tests.ps1 new file mode 100644 index 00000000..9cf84743 --- /dev/null +++ b/tests/TranscriptionRecovery.Windows.Tests.ps1 @@ -0,0 +1,116 @@ +param([switch]$AllowDisposablePolicyWrite) +$ErrorActionPreference='Stop' +if($env:OS -ne 'Windows_NT'){Write-Host 'Skipped: actual Windows transcription recovery requires Windows.';exit 0} +if(-not $AllowDisposablePolicyWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'This native mutation fixture requires explicit consent on a disposable GitHub-hosted runner.'} +$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +foreach($file in @('Configuration','AuditRecovery','PowerShellTranscription','TranscriptionRecovery')){. (Join-Path $script:ScriptRoot ('scripts/'+$file+'.ps1'))} +$script:checks=0 +function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++} +$root=New-WelaRecoveryOutput (Join-Path $env:RUNNER_TEMP ('wela-transcription-recovery-'+[guid]::NewGuid().ToString('N'))) +$before=@(Get-WelaTranscriptPolicy @('Registry64','Registry32')) +$protectedBefore=Get-WelaTranscriptRecoveryProtectedPolicy +Write-WelaRecoveryArtifact (Join-Path $root 'original-policy.json') $before +Write-WelaRecoveryArtifact (Join-Path $root 'original-protected-policy.json') $protectedBefore +$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64) +$policyRoot='SOFTWARE\Policies\Microsoft\Windows\PowerShell' +$originalParents=@{} +foreach($path in @($policyRoot,($policyRoot+'\Transcription'))){$key=$base.OpenSubKey($path);$originalParents[$path]=($null -ne $key);if($key){$key.Dispose()}} +$base.Dispose() +$hostExe=Join-Path $PSHOME $(if($PSVersionTable.PSEdition -eq 'Desktop'){'powershell.exe'}else{'pwsh.exe'}) +$native51=Join-Path $env:windir 'System32\WindowsPowerShell\v1.0\powershell.exe' +$restored=$false;$touched=$false +function Set-FixtureValue([string]$Name,$Value,[string]$Type='DWord') { + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64) + $key=$base.CreateSubKey($policyRoot+'\Transcription') + try{if($null -eq $Value){$key.DeleteValue($Name,$false)}else{$key.SetValue($Name,$Value,[Microsoft.Win32.RegistryValueKind]$Type)};$key.Flush()}finally{$key.Dispose();$base.Dispose()} +} +function Invoke-WelaTranscriptFixtureCli { + param([string[]]$Parameters,[string]$Log,[switch]$ExpectFailure) + $global:LASTEXITCODE=$null + $priorPreference=$ErrorActionPreference + try { + $ErrorActionPreference='Continue' + & $hostExe -NoLogo -NoProfile -ExecutionPolicy Bypass -File (Join-Path $script:ScriptRoot 'WELA.ps1') @Parameters *> $Log + $code=$global:LASTEXITCODE + } finally {$ErrorActionPreference=$priorPreference} + if($ExpectFailure){Assert ($null -ne $code -and $code -ne 0) 'native public CLI refuses unsupported or stale recovery'} + elseif($code -ne 0){throw "Public CLI failed ($code): $(Get-Content $Log -Raw)"} + $global:LASTEXITCODE=0 +} +try { + foreach($scenario in @('Enabled','DisabledAbsentDirectory','AbsentEnablement','Drift')) { + $case=New-WelaRecoveryOutput (Join-Path $root $scenario) + $old=New-WelaRecoveryOutput (Join-Path $case 'old-transcripts') + $new=New-WelaRecoveryOutput (Join-Path $case 'new-transcripts') + $touched=$true + Set-FixtureValue EnableTranscripting 0 + Set-FixtureValue OutputDirectory $(if($scenario -eq 'DisabledAbsentDirectory'){$null}else{$old}) String + Set-FixtureValue EnableTranscripting $(if($scenario -eq 'AbsentEnablement'){$null}elseif($scenario -eq 'DisabledAbsentDirectory'){0}else{1}) + $caseBefore=@(Get-WelaTranscriptPolicy @('Registry64','Registry32')) + $preserved=Get-WelaTranscriptRecoveryProtectedPolicy + Write-WelaRecoveryArtifact (Join-Path $case 'fixture-before.json') $caseBefore + $backup=Join-Path $case 'configure-backup';$original=Join-Path $case 'configure-result.json' + Invoke-WelaTranscriptFixtureCli @('powershell-transcription','-TranscriptionAction','Configure','-TranscriptDirectory',$new,'-Auto','-BackupPath',$backup,'-ResultsPath',$original) (Join-Path $case 'configure.log') + $configured=ConvertFrom-WelaRecoveryJson (Get-Content $original -Raw) + Assert ($configured.Results.Count -eq 1 -and $configured.Results[0].Status -eq 'Applied') 'actual public Configure creates the exact completed composite history' + $planDirectory=Join-Path $case 'plan';$planPath=Join-Path $planDirectory 'plan.json' + Invoke-WelaTranscriptFixtureCli @('transcription-recovery','-TranscriptRecoveryJournalPath',(Join-Path $backup 'before.jsonl'),'-TranscriptRecoveryOriginalResultsPath',$original,'-TranscriptRecoveryOutputPath',$planDirectory) (Join-Path $case 'plan.log') + $plan=ConvertFrom-WelaRecoveryJson (Get-Content $planPath -Raw) + $planHash=(Get-FileHash $planPath -Algorithm SHA256).Hash.ToLowerInvariant() + Assert ($plan.Context.Reader.UserSid -and $plan.Sources.'scripts/TranscriptionRecovery.ps1' -and $plan.SigmaEvtxCredit -eq 0) 'native plan binds reader/code and grants no EVTX credit' + $restoreDirectory=Join-Path $case 'restore' + $restoreArguments=@('transcription-recovery','-TranscriptRecoveryAction','Restore','-TranscriptRecoveryPlanPath',$planPath,'-TranscriptRecoveryPlanHash',$planHash,'-TranscriptRecoveryOutputPath',$restoreDirectory,'-Auto') + if($scenario -eq 'Drift') { + Set-FixtureValue EnableTranscripting 0 + Invoke-WelaTranscriptFixtureCli ($restoreArguments+@('-TranscriptRecoveryAllowTemporarySuspension')) (Join-Path $case 'drift-refusal.log') -ExpectFailure + Assert (-not (Test-Path $restoreDirectory) -and (Get-WelaTranscriptRegistryValue -Name EnableTranscripting).Value -eq 0) 'actual changed native policy is preserved before any output/write' + continue + } + if($plan.RequiresTemporarySuspension) { + Invoke-WelaTranscriptFixtureCli $restoreArguments (Join-Path $case 'consent-refusal.log') -ExpectFailure + Assert (-not (Test-Path $restoreDirectory) -and (Get-WelaTranscriptRegistryValue -Name EnableTranscripting).Value -eq 1) 'no suspension consent preserves the enabled policy' + $restoreArguments += '-TranscriptRecoveryAllowTemporarySuspension' + } + $previewArguments=@('transcription-recovery','-TranscriptRecoveryAction','Restore','-TranscriptRecoveryPlanPath',$planPath,'-TranscriptRecoveryPlanHash',$planHash,'-DryRun') + if($plan.RequiresTemporarySuspension){$previewArguments += '-TranscriptRecoveryAllowTemporarySuspension'} + Invoke-WelaTranscriptFixtureCli $previewArguments (Join-Path $case 'preview.log') + Assert ((Get-WelaRecoveryKey @(Get-WelaTranscriptPolicy @('Registry64','Registry32'))) -ceq (Get-WelaRecoveryKey $plan.ExpectedPolicy)) 'actual public preview leaves both native registry views unchanged' + Invoke-WelaTranscriptFixtureCli $restoreArguments (Join-Path $case 'restore.log') + $report=ConvertFrom-WelaRecoveryJson (Get-Content (Join-Path $restoreDirectory 'result.json') -Raw) + Assert ($report.Status -eq 'Restored' -and $report.ExitCode -eq 0) 'actual public Restore completes' + Assert ((Get-WelaRecoveryKey @(Get-WelaTranscriptPolicy @('Registry64','Registry32'))) -ceq (Get-WelaRecoveryKey $caseBefore)) 'native restore matches original typed policy including value absence in both views' + Assert ((Get-WelaRecoveryKey (Get-WelaTranscriptRecoveryProtectedPolicy)) -ceq (Get-WelaRecoveryKey $preserved)) 'all other machine/user PowerShell policy remains exact' + $pending=@(Get-ChildItem $restoreDirectory '*-pending.json');$confirmed=@(Get-ChildItem $restoreDirectory '*-confirmed.json') + Assert ($pending.Count -eq $plan.Steps.Count -and $confirmed.Count -eq $plan.Steps.Count) 'every actual native write has separate durable pending and confirmed receipts' + if($scenario -eq 'Enabled') { + $marker='WELA_RECOVERED_TRANSCRIPT_'+[guid]::NewGuid().ToString('N') + & $native51 -NoLogo -NoProfile -Command "Write-Output '$marker'" *> (Join-Path $case 'benign-session.log') + Assert ($LASTEXITCODE -eq 0) 'fresh built-in Windows PowerShell session completes after recovery' + $matching=@(Get-ChildItem -LiteralPath $old -Recurse -File -Filter '*.txt'|Where-Object {(Get-Content $_.FullName -Raw).Contains($marker)}) + Assert ($matching.Count -eq 1) 'one real fresh Windows PowerShell transcript contains the benign marker at the restored destination' + Write-WelaRecoveryArtifact (Join-Path $case 'transcript-marker.json') ([pscustomobject]@{Marker=$marker;Path=$matching[0].FullName;Sha256=(Get-FileHash $matching[0].FullName).Hash;Scope='Disposable local fixture only; no production/central assertion'}) + } + } +} finally { + if($touched) { + Set-FixtureValue EnableTranscripting 0 + foreach($name in @('OutputDirectory','EnableInvocationHeader','EnableTranscripting')) { + $value=$before[0].Machine.$name + Set-FixtureValue $name $(if($value.ValueExists){$value.Value}else{$null}) $(if($value.ValueExists){$value.Type}else{'DWord'}) + } + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64) + try { + foreach($path in @(($policyRoot+'\Transcription'),$policyRoot)) { + if($originalParents[$path]){continue} + $key=$base.OpenSubKey($path) + $empty=$null -ne $key -and $key.GetValueNames().Count -eq 0 -and $key.GetSubKeyNames().Count -eq 0 + if($key){$key.Dispose()};if($empty){$base.DeleteSubKey($path,$false)} + } + } finally {$base.Dispose()} + } + $after=@(Get-WelaTranscriptPolicy @('Registry64','Registry32')) + $restored=(Get-WelaRecoveryKey $after) -ceq (Get-WelaRecoveryKey $before) -and (Get-WelaRecoveryKey (Get-WelaTranscriptRecoveryProtectedPolicy)) -ceq (Get-WelaRecoveryKey $protectedBefore) + Write-WelaRecoveryArtifact (Join-Path $root 'cleanup.json') ([pscustomobject]@{CleanupVerified=$restored;Checks=$script:checks;Engine=$PSVersionTable.PSVersion.ToString();Computer=$env:COMPUTERNAME;After=$after}) + if(-not $restored){throw "Exact native policy cleanup failed; retained private evidence at $root"} +} +Write-Host "Passed $script:checks actual native transcription recovery assertions; exact policy cleanup verified. Evidence: $root" diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 2756f6c3..aabe0385 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,9 @@ **改善:** +- `transcription-recovery` を追加し、完了した Windows PowerShell 文字起こし設定を、再構築したハッシュ付き計画から型を保持して復元できるようにしました。ローカル保存先、ユーザー・ヘッダー・他のポリシーを確認し、一時停止は明示的な同意を求め、変更順序と途中結果を永続記録します。使い捨て環境の実 CLI テストで復元とドリフト拒否を検証し、本番セッション・集中管理先の権限と収集・Sigma 対応は未検証とします。 (#376) (@Shirofune-Security) + + - `wmi-probe` の親プロセスとワーカーの操作時刻を Windows の高精度 UTC 時計に統一しました。時計情報と起動・完了時刻を検証し、イベントの許容時間範囲を広げずに正確な照合を維持します。ミリ秒未満の境界テストとネイティブ公開 CLI の反復テストを追加しました。(@Shirofune-Security) - 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 91d49131..744d3e47 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added explicit `transcription-recovery` for one completed Windows PowerShell transcription configuration, with independently rebuilt hashed plans, typed local-directory restoration, preserved user/header/other policy, explicit temporary-suspension consent and durable ordered receipts. Disposable native public-CLI tests verify restoration and drift refusal; production sessions, central authorization/collection and Sigma readiness remain unverified. (#376) (@Shirofune-Security) + - Fixed `wmi-probe` operation timing to use the native precise UTC clock consistently in the parent and worker. Explicit clock receipts and launch/completion bounds preserve exact event correlation; sub-millisecond boundary tests and repeated native public-CLI runs cover timing failures without widening the accepted interval. (@Shirofune-Security) - Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security)