mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-08 15:25:24 +02:00
Merge pull request #443 from Shirofune-Security/feat/376-transcription-recovery
Add reviewed native transcription policy recovery
This commit is contained in:
13 files changed
+709
-2
No files matched your search
@@ -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, ./docs/eventlog-recovery.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/ipsec-prerequisites.md, ./docs/wec-ingress.md, ./docs/failed-logon-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/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-ingress.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md -Destination release-binaries/docs/
|
||||
|
||||
- name: Set Artifact Name
|
||||
if: contains(matrix.info.os, 'windows') == true
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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.Cli.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.Cli.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
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
**改善:**
|
||||
|
||||
- `transcription-recovery` を追加し、完了した Windows PowerShell 文字起こし設定を、再構築したハッシュ付き計画から型を保持して復元できるようにしました。ローカル保存先、ユーザー・ヘッダー・他のポリシーを確認し、一時停止は明示的な同意を求め、変更順序と途中結果を永続記録します。ヘルプと復旧手順には、一時停止中のエラーや中断でマシンの文字起こしが無効のまま残り、自動ロールバックや再有効化を行わないことを明記しました。使い捨て環境の実 CLI テストで復元とドリフト拒否を検証し、本番セッション・集中管理先の権限と収集・Sigma 対応は未検証とします。 (#376) (@Shirofune-Security)
|
||||
|
||||
- 完了したログ容量・保持モード設定を1件ずつ戻す `eventlog-recovery` を追加しました。元の記録と変更直前の記録、現在のチャネル・実行環境・コードを照合し、縮小と保持モード変更には個別の明示指定を必要とします。永続記録とネイティブ読戻しで無関係な設定を保持し、状態変化や再適用を拒否します。失われたイベント、長期保持、Sigma 利用可能性の証明は加算しません。 (@Shirofune-Security)
|
||||
|
||||
- `failed-logon-probe` を追加しました。存在しないことを確認したランダムなローカル SAM アカウントに対し、固定のネイティブログオン種別・プロバイダーで一度だけ認証を試行し、正確な時刻・プロセス・アカウント情報で Security4625 を照合します。監査設定を変更せず、保護された証跡を保存します。実際の資格情報、ドメインコントローラー、リモート認証、Sigma 対応率の加算は対象外です。使い捨て Windows 環境で公開コマンドと設定復元を検証します。(@Shirofune-Security)
|
||||
|
||||
@@ -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. Help and recovery guidance warn that interrupted suspension can leave machine transcription disabled without automatic rollback or re-enable. Disposable native public-CLI tests verify restoration and drift refusal; production sessions, central authorization/collection and Sigma readiness remain unverified. (#376) (@Shirofune-Security)
|
||||
|
||||
- Added reviewed `eventlog-recovery` for one completed profile size/retention write. Matched original and immediate-prewrite evidence, current channel/context/source guards, separate shrink/retention consent, durable pending receipts and native readback preserve unrelated channel settings and refuse drift or replay. Windows fixtures restore original settings; lost events, sustained retention and Sigma readiness are not inferred. (@Shirofune-Security)
|
||||
|
||||
- Added opt-in `failed-logon-probe` for one generated, confirmed nonexistent local SAM account attempt with fixed native logon type/provider, precise worker timing and exact Security4625 correlation. Protected receipts preserve raw evidence and unchanged audit/channel/token context; real credentials, domain controllers, remote authentication and Sigma credit are excluded. Disposable Windows tests cover native public runs and exact fixture cleanup. (@Shirofune-Security)
|
||||
|
||||
@@ -109,6 +109,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]$EventRecoveryAction = 'Plan',
|
||||
[string]$EventRecoveryJournalPath,
|
||||
[string]$EventRecoveryOriginalResultsPath,
|
||||
@@ -225,6 +232,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 = @(
|
||||
@@ -1966,6 +1974,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
|
||||
@@ -2070,6 +2079,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 ($args.Count -gt 0 -or @($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.'}
|
||||
|
||||
@@ -2167,7 +2178,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
|
||||
@@ -2284,6 +2295,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. Temporary suspension can leave machine transcription disabled after an error, drift refusal or process termination; there is no automatic rollback or re-enable. Inspect receipts, current policy and the destination before manual recovery. 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# 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. Status, control, target and registry-type discriminators require actual strings; schema and outcome counters require integers. Boolean values cannot stand in for those fields. 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. An originally absent destination is supported only with original DWORD `0`; enabled/default-user destinations require manual recovery.
|
||||
|
||||
**Temporary suspension can leave machine transcription disabled.** By supplying `-TranscriptRecoveryAllowTemporarySuspension`, you accept that a write error, drift refusal or terminated process after the disable step and before final restoration can leave `EnableTranscripting=0`, even when the recovery target enables transcription. There is no automatic rollback or re-enable. A handled failure reports an incomplete attempt and stops subsequent writes; a terminated process may leave only pending/confirmed receipts without a final result. Inspect those receipts and the current native policy, verify the destination, and manually recover the intended enablement before relying on automatic transcription again. Do not re-enable blindly with an unverified destination.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,250 @@
|
||||
# Explicit recovery of one completed Windows PowerShell transcription policy write.
|
||||
function Copy-WelaTranscriptRecoveryValue {
|
||||
param($Value)
|
||||
# Windows PowerShell 5.1 annotates a root array emitted by ConvertFrom-Json;
|
||||
# serializing that annotated array can introduce synthetic value/count keys.
|
||||
# Keep arrays nested during the JSON roundtrip and emit their actual items.
|
||||
$holder=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey ([pscustomobject]@{Data=$Value}))
|
||||
$holder.Data
|
||||
}
|
||||
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 -isnot [string] -or $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 -isnot [string] -or $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
|
||||
foreach($field in @('ExitCode','Failed','Skipped')) {
|
||||
if(($results.$field -isnot [int] -and $results.$field -isnot [long]) -or $results.$field -ne 0){throw 'Completed transcription history requires integer zero exit/failure/skipped counters.'}
|
||||
}
|
||||
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.Action -isnot [string] -or $results.Action -cne 'Configure' -or $results.Scope -isnot [string] -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 -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.ComputerName -isnot [string] -or $entry.ComputerName -ine $context.Host.Computer -or
|
||||
$entry.Id -isnot [string] -or $entry.Id -cne 'PowerShellTranscription/CisV4L2' -or $entry.Kind -isnot [string] -or $entry.Kind -cne 'PowerShellTranscription' -or
|
||||
$last.Status -isnot [string] -or $last.Status -cne 'Applied' -or $last.Id -isnot [string] -or $last.Id -cne $entry.Id -or $last.Kind -isnot [string] -or $last.Kind -cne $entry.Kind){throw 'Wrong host, control, schema or incomplete transcription history.'}
|
||||
$time=ConvertTo-WelaArrivalUtc $entry.RecordedUtc
|
||||
if($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 -isnot [string] -or $entry.Target.Hive -cne 'LocalMachine' -or $entry.Target.SubKey -isnot [string] -or $entry.Target.SubKey -cne 'SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription' -or $entry.Target.OutputDirectory -isnot [string] -or
|
||||
$entry.Desired.EnableTranscripting.Type -isnot [string] -or $entry.Desired.EnableTranscripting.Type -cne 'DWord' -or ($entry.Desired.EnableTranscripting.Value -isnot [int] -and $entry.Desired.EnableTranscripting.Value -isnot [long]) -or $entry.Desired.EnableTranscripting.Value -ne 1 -or
|
||||
$entry.Desired.OutputDirectory.Type -isnot [string] -or $entry.Desired.OutputDirectory.Type -cne 'String' -or $entry.Desired.OutputDirectory.Value -isnot [string] -or $entry.Desired.OutputDirectory.Value -cne $entry.Target.OutputDirectory -or
|
||||
$entry.Desired.EnableInvocationHeader -isnot [string] -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 -isnot [string] -or $snapshot.Capability.Status -cne 'Supported' -or $snapshot.Policy -isnot [array] -or $snapshot.Policy.Count -ne 2 -or
|
||||
$snapshot.Policy[0].View -isnot [string] -or $snapshot.Policy[0].View -cne 'Registry64' -or $snapshot.Policy[1].View -isnot [string] -or $snapshot.Policy[1].View -cne 'Registry32'){
|
||||
$policyType=if($null -eq $snapshot.Policy){'<null>'}else{$snapshot.Policy.GetType().FullName}
|
||||
throw "Both canonical shared registry views are required. Capability=$($snapshot.Capability.Status); PolicyType=$policyType; Count=$(@($snapshot.Policy).Count); Views=$(@($snapshot.Policy.View) -join ','); Observation=$(Get-WelaRecoveryKey $snapshot)"
|
||||
}
|
||||
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=Copy-WelaTranscriptRecoveryValue $after.Policy
|
||||
foreach($view in $target){foreach($name in @('EnableTranscripting','OutputDirectory')) {
|
||||
$view.Machine.$name=Copy-WelaTranscriptRecoveryValue $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 -isnot [string] -or $plan.Kind -cne 'WelaTranscriptionRecoveryPlan' -or ($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -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=Copy-WelaTranscriptRecoveryValue $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=(Copy-WelaTranscriptRecoveryValue $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)=Copy-WelaTranscriptRecoveryValue $step.Value}
|
||||
Assert-WelaTranscriptRecoveryBindings $plan $expected $source.Path $source.Sha256
|
||||
$receipt.Status='Confirmed';$receipt.After=Copy-WelaTranscriptRecoveryValue $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
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
$cases=@(
|
||||
@{Args=@('transcription-recovery','-Help');Code=0;Pattern='Usage: transcription-recovery'},
|
||||
@{Args=@('transcription-recovery','-TranscriptRecoveryAction','Restore','-Help');Code=0;Pattern='TranscriptRecoveryPlanHash'},
|
||||
@{Args=@('transcription-recovery','-Profile','CisV4L2');Code=1;Pattern='dedicated options'},
|
||||
@{Args=@('help','-TranscriptRecoveryAction','Plan');Code=1;Pattern='require transcription-recovery'},
|
||||
@{Args=@('transcription-recovery','-TranscriptRecoveryAction','Restore','-TranscriptRecoveryPlanHash','bad');Code=1;Pattern='Restore consumes'},
|
||||
@{Args=@('transcription-recovery','-Auto');Code=1;Pattern='Plan takes'},
|
||||
@{Args=@('transcription-recovery','-TranscriptRecoveryAllowTemporarySuspension');Code=1;Pattern='Plan takes'},
|
||||
@{Args=@('transcription-recovery','-TranscriptRecoveryAction','Restore','-WhatIf');Code=1;Pattern='dedicated options'},
|
||||
@{Args=@('transcription-recovery','-TranscriptRecoveryAction','Restore','-DryRnu');Code=1;Pattern='dedicated options'}
|
||||
)
|
||||
foreach($case in $cases) {
|
||||
$prior=$ErrorActionPreference
|
||||
try{$ErrorActionPreference='Continue';$output=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') @($case.Args) 2>&1);$code=$LASTEXITCODE}
|
||||
finally{$ErrorActionPreference=$prior}
|
||||
if($code -ne $case.Code -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI failure: $($case.Args -join ' ') -> $code / $($output -join ' ')"}
|
||||
}
|
||||
Write-Host "Passed $($cases.Count) public transcription recovery CLI checks."
|
||||
# Expected child refusals must not become the enclosing Actions step result.
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,191 @@
|
||||
$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/WefArrival.ps1')
|
||||
. (Join-Path $script:ScriptRoot 'scripts/PowerShellTranscription.ps1')
|
||||
. (Join-Path $script:ScriptRoot 'scripts/TranscriptionRecovery.ps1')
|
||||
$script:artifactWriter=(Get-Command Write-WelaRecoveryArtifact).ScriptBlock
|
||||
$script:jsonReader=(Get-Command ConvertFrom-WelaRecoveryJson).ScriptBlock
|
||||
function ConvertFrom-WelaRecoveryJson {
|
||||
param($Text)
|
||||
$value=& $script:jsonReader $Text
|
||||
# Older PowerShell 7 JSON readers materialize an explicit UTC timestamp.
|
||||
if($script:legacyJsonDate -and $value.RecordedUtc -is [string]){$value.RecordedUtc=[datetime]::Parse($value.RecordedUtc,[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind)}
|
||||
$value
|
||||
}
|
||||
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){Copy-WelaTranscriptRecoveryValue $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:legacyJsonDate=$false
|
||||
$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 @('status','action','scope','id','kind','result-id','result-kind','version','exit','failed-count','skipped-count','hive','subkey','target-directory','desired-enable-type','desired-enable-value','desired-output-type','desired-output-value','header-intent','capability','view64','view32','before-enable-type','before-output-type')) {
|
||||
New-Fixture
|
||||
switch($alter){
|
||||
'status' {$script:original.Results[0].Status=$true}
|
||||
'action' {$script:original.Action=$true}
|
||||
'scope' {$script:original.Scope=$true}
|
||||
'id' {$script:entry.Id=$true;$script:original.Results[0].Id=$true}
|
||||
'kind' {$script:entry.Kind=$true;$script:original.Results[0].Kind=$true}
|
||||
'result-id' {$script:original.Results[0].Id=$true}
|
||||
'result-kind' {$script:original.Results[0].Kind=$true}
|
||||
'version' {$script:entry.Version=$true}
|
||||
'exit' {$script:original.ExitCode=$false}
|
||||
'failed-count' {$script:original.Failed=$false}
|
||||
'skipped-count' {$script:original.Skipped=$false}
|
||||
'hive' {$script:entry.Target.Hive=$true}
|
||||
'subkey' {$script:entry.Target.SubKey=$true}
|
||||
'target-directory' {$script:entry.Target.OutputDirectory=$true}
|
||||
'desired-enable-type' {$script:entry.Desired.EnableTranscripting.Type=$true}
|
||||
'desired-enable-value' {$script:entry.Desired.EnableTranscripting.Value=$true}
|
||||
'desired-output-type' {$script:entry.Desired.OutputDirectory.Type=$true}
|
||||
'desired-output-value' {$script:entry.Desired.OutputDirectory.Value=$true}
|
||||
'header-intent' {$script:entry.Desired.EnableInvocationHeader=$true}
|
||||
'capability' {$script:entry.Before.Capability.Status=$true}
|
||||
'view64' {$script:entry.Before.Policy[0].View=$true}
|
||||
'view32' {$script:entry.Before.Policy[1].View=$true}
|
||||
'before-enable-type' {foreach($view in $script:entry.Before.Policy){$view.Machine.EnableTranscripting.Type=$true}}
|
||||
'before-output-type' {foreach($view in $script:entry.Before.Policy){$view.Machine.OutputDirectory.Type=$true}}
|
||||
}
|
||||
Save-History
|
||||
Reject {Plan-Fixture} 'history|Applied|Unsupported|registry views|DWORD|REG_SZ'
|
||||
Assert ($script:writes -eq 0 -and -not (Test-Path (Join-Path $script:fixture 'plan'))) "Boolean $alter evidence is rejected before plan creation or mutation"
|
||||
}
|
||||
New-Fixture;$script:legacyJsonDate=$true;Plan-Fixture
|
||||
Assert ($script:planResult.Status -eq 'Planned' -and $script:writes -eq 0) 'explicit UTC DateTime from older PowerShell JSON readers remains valid history'
|
||||
Reject {ConvertTo-WelaArrivalUtc ([datetime]::SpecifyKind([datetime]::Now,[DateTimeKind]::Unspecified))} 'explicit UTC'
|
||||
foreach($alter in @('Kind','SchemaVersion')) {
|
||||
New-Fixture;Plan-Fixture
|
||||
$tampered=ConvertFrom-WelaRecoveryJson (Get-Content -LiteralPath $script:planPath -Raw);$tampered.$alter=$true
|
||||
Get-WelaRecoveryKey $tampered|Set-Content -LiteralPath $script:planPath -Encoding UTF8
|
||||
$script:restoreParameters.PlanHash=(Get-FileHash -LiteralPath $script:planPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Reject {Invoke-WelaTranscriptRecovery @script:restoreParameters -AllowTemporarySuspension} 'Unsupported transcription recovery plan'
|
||||
Assert ($script:writes -eq 0 -and -not (Test-Path $script:restoreOutput)) "Boolean reviewed plan $alter is rejected before output or mutation"
|
||||
}
|
||||
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}
|
||||
@@ -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"
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
**改善:**
|
||||
|
||||
- `transcription-recovery` を追加し、完了した Windows PowerShell 文字起こし設定を、再構築したハッシュ付き計画から型を保持して復元できるようにしました。ローカル保存先、ユーザー・ヘッダー・他のポリシーを確認し、一時停止は明示的な同意を求め、変更順序と途中結果を永続記録します。ヘルプと復旧手順には、一時停止中のエラーや中断でマシンの文字起こしが無効のまま残り、自動ロールバックや再有効化を行わないことを明記しました。使い捨て環境の実 CLI テストで復元とドリフト拒否を検証し、本番セッション・集中管理先の権限と収集・Sigma 対応は未検証とします。 (#376) (@Shirofune-Security)
|
||||
|
||||
- 完了したログ容量・保持モード設定を1件ずつ戻す `eventlog-recovery` を追加しました。元の記録と変更直前の記録、現在のチャネル・実行環境・コードを照合し、縮小と保持モード変更には個別の明示指定を必要とします。永続記録とネイティブ読戻しで無関係な設定を保持し、状態変化や再適用を拒否します。失われたイベント、長期保持、Sigma 利用可能性の証明は加算しません。 (@Shirofune-Security)
|
||||
|
||||
- `failed-logon-probe` を追加しました。存在しないことを確認したランダムなローカル SAM アカウントに対し、固定のネイティブログオン種別・プロバイダーで一度だけ認証を試行し、正確な時刻・プロセス・アカウント情報で Security4625 を照合します。監査設定を変更せず、保護された証跡を保存します。実際の資格情報、ドメインコントローラー、リモート認証、Sigma 対応率の加算は対象外です。使い捨て Windows 環境で公開コマンドと設定復元を検証します。(@Shirofune-Security)
|
||||
|
||||
@@ -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. Help and recovery guidance warn that interrupted suspension can leave machine transcription disabled without automatic rollback or re-enable. Disposable native public-CLI tests verify restoration and drift refusal; production sessions, central authorization/collection and Sigma readiness remain unverified. (#376) (@Shirofune-Security)
|
||||
|
||||
- Added reviewed `eventlog-recovery` for one completed profile size/retention write. Matched original and immediate-prewrite evidence, current channel/context/source guards, separate shrink/retention consent, durable pending receipts and native readback preserve unrelated channel settings and refuse drift or replay. Windows fixtures restore original settings; lost events, sustained retention and Sigma readiness are not inferred. (@Shirofune-Security)
|
||||
|
||||
- Added opt-in `failed-logon-probe` for one generated, confirmed nonexistent local SAM account attempt with fixed native logon type/provider, precise worker timing and exact Security4625 correlation. Protected receipts preserve raw evidence and unchanged audit/channel/token context; real credentials, domain controllers, remote authentication and Sigma credit are excluded. Disposable Windows tests cover native public runs and exact fixture cleanup. (@Shirofune-Security)
|
||||
|
||||
Reference in new issue
Block a user