Merge reviewed PowerShell transcription into WEF integration

This commit is contained in:
Shirofune-Security committed 2026-09-19 11:45:59 +09:00
commit f1b5be8bf2
11 files changed
+630 -2

No files matched your search

@@ -0,0 +1,35 @@
name: Windows PowerShell transcription regressions
on:
push:
branches: ['**']
paths:
- 'WELA.ps1'
- 'scripts/Configuration.ps1'
- 'scripts/PowerShellTranscription.ps1'
- 'tests/PowerShellTranscription*'
- '.github/workflows/powershell-transcription.yml'
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
transcription:
strategy:
fail-fast: false
matrix:
os: [windows-2022, windows-2025]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Mocked policy and safety regressions in Windows PowerShell 5.1
shell: powershell
run: ./tests/PowerShellTranscription.Tests.ps1
- name: Native private-directory and 5.1 transcript evidence with verified restoration
shell: powershell
run: ./tests/PowerShellTranscription.Windows.Tests.ps1 -AllowDisposablePolicyWrite
- name: Mocked policy and safety regressions from PowerShell 7
shell: pwsh
run: ./tests/PowerShellTranscription.Tests.ps1
- name: Native 5.1 transcript evidence from PowerShell 7 host with restoration
shell: pwsh
run: ./tests/PowerShellTranscription.Windows.Tests.ps1 -AllowDisposablePolicyWrite
+1
View File
@@ -5,6 +5,7 @@
**改善:**
- Windows標準のドメイン/Kerberos環境向けに、送信元設定と明示的に選択した収集サーバーのサブスクリプションを監査・計画・設定する任意実行の`wef-source`と`wec-collector`を追加しました。実際の収集先ID、既存リスナーと範囲を限定した受信規則、送信元SID、メンバーホストでの読み取り権限の追加、明示的なASDのWSMan強化設定と共通チャネル設定を、復旧記録・変更検出・読み戻しで確認します。DCのグループ管理権限と異なる既存サブスクリプションは変更しません。JSONにはクエリ・チャネル・実行状態の証拠を保持し、実効読み取り権限・イベント到着・転送後のSigma検知範囲は未検証と表示します。隔離Windows環境での配備検証は別途必要です。 (#406) (@Shirofune-Security)
- CIS v4.0.0 Level 2向けに、Windows PowerShell 5.1トランスクリプトの明示的な監査・計画・設定機能を追加しました。管理者が選択した既存の出力ディレクトリを確認し、ポリシーとは分けて報告します。型を含むレジストリ変更前の状態を保存し、共有される32/64ビットのビューと変更後の状態を検証します。呼び出しヘッダーの設定は保持し、ACL・共有・保存期間は変更せず、Sigma EVTX検知範囲の向上も自動加算しません。使い捨て環境の実トランスクリプトテストでは元のポリシーを復元します。中央保存先の権限と収集は別途検証が必要です。 (#405) (@Shirofune-Security)
- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。任意のdMSA前提条件が不明な場合は未確認のスキップ項目として報告し、独立した他の5種類のドメインクラスの監査ACEは引き続き設定します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security)
- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (#401) (@Shirofune-Security)
+1
View File
@@ -5,6 +5,7 @@
**Improvements:**
- Added separate opt-in `wef-source` and `wec-collector` audit, plan and configure commands for native domain/Kerberos source settings and explicitly selected collector subscriptions. Actual collector identity, scoped existing ingress/listener prerequisites, explicit source SIDs, additive member-host read permissions, optional ASD WSMan hardening and shared channel controls are checked with journals, drift guards and readback. DC group authority and different existing subscriptions are preserved. JSON retains query/channel/runtime evidence without claiming effective read access, event arrival or forwarded Sigma coverage; isolated Windows deployment validation remains pending. (#406) (@Shirofune-Security)
- Added explicit CIS v4.0.0 Level 2 Windows PowerShell 5.1 transcription audit, plan and configure actions. An operator-selected existing output directory is checked and reported separately from policy; typed canonical registry writes are journaled, verified through shared 32/64-bit views and checked for drift while preserving invocation-header preferences. No ACL/share/retention changes or automatic Sigma EVTX credit are introduced; disposable native transcript tests restore original policy, and central authorization/collection remains a deployment check. (#405) (@Shirofune-Security)
- Added opt-in `ad-object-sacl` audit, plan, configure and conservative rollback actions for MDI domain/Exchange Configuration auditing and explicitly selected certificate template/enrollment service objects. Exact DC binding, schema GUID checks, additive SACL-only changes, pre-write SDDL/ACE receipts and read-back preserve existing security entries. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security)
- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security)
+23 -1
View File
@@ -39,6 +39,8 @@
[ValidateSet('List', 'Audit', 'Plan', 'Configure')][string]$WmiAction = 'List',
[string[]]$WmiNamespace,
[switch]$WmiIncludeChildren,
[ValidateSet('Audit', 'Plan', 'Configure')][string]$TranscriptionAction = 'Audit',
[string]$TranscriptDirectory,
[switch]$Help
)
@@ -58,6 +60,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json"
. (Join-Path $ScriptRoot "scripts/AdObjectSacl.ps1")
. (Join-Path $ScriptRoot "scripts/AppLockerReadiness.ps1")
. (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1")
. (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1")
Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/NativeProviders.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorAction Stop
@@ -1716,6 +1719,7 @@ Usage:
./WELA.ps1 smb-auditing -SmbAction Audit -ResultsPath smb-audit.json
./WELA.ps1 smb-auditing -SmbAction Plan
./WELA.ps1 smb-auditing -SmbAction Configure -DryRun
./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
# SMB auditing is opt-in and never changes signing/encryption requirements or guest access.
@@ -1766,6 +1770,10 @@ if ($PSBoundParameters.ContainsKey('SaclMode') -and
throw '-SaclMode requires -Profile with plan, audit, audit-settings or configure. It does not control configure-sacl. No command was run.'
}
# Reject unsupported dry-run requests before reaching any command's mutation path.
if ($Cmd -ne 'powershell-transcription' -and
($PSBoundParameters.ContainsKey('TranscriptionAction') -or $PSBoundParameters.ContainsKey('TranscriptDirectory'))) {
throw 'Transcription options require the dedicated powershell-transcription command. No command was run.'
}
if ($Cmd -ne 'ad-object-sacl' -and @($PSBoundParameters.Keys | Where-Object {
$_ -in @('AdSaclAction', 'AdServer', 'AdSaclProfile', 'AdObjectDn', 'AdReceiptPath')
}).Count) {
@@ -1774,11 +1782,12 @@ if ($Cmd -ne 'ad-object-sacl' -and @($PSBoundParameters.Keys | Where-Object {
if ($DryRun -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and
-not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and
-not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and
-not ($Cmd -eq 'powershell-transcription' -and $TranscriptionAction -eq 'Configure') -and
-not ($Cmd -eq 'channel-settings' -and $ChannelAction -eq 'Configure') -and
-not ($Cmd -in @('wef-source','wec-collector') -and $WefAction -eq 'Configure') -and
-not ($Cmd -eq 'ad-object-sacl' -and $AdSaclAction -in @('Configure', 'Rollback')) -and
-not ($Cmd -eq 'wmi-auditing' -and $WmiAction -eq 'Configure')) {
throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure, smb-auditing -SmbAction Configure, wmi-auditing -WmiAction Configure, channel-settings -ChannelAction Configure, wef-source/wec-collector -WefAction Configure, applocker-readiness -AppLockerAction Import, and ad-object-sacl -AdSaclAction Configure|Rollback. No command was run."
throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure, smb-auditing -SmbAction Configure, powershell-transcription -TranscriptionAction Configure, wmi-auditing -WmiAction Configure, channel-settings -ChannelAction Configure, wef-source/wec-collector -WefAction Configure, applocker-readiness -AppLockerAction Import, and ad-object-sacl -AdSaclAction Configure|Rollback. No command was run."
}
if (($WmiNamespace -or $WmiIncludeChildren -or $PSBoundParameters.ContainsKey('WmiAction')) -and $Cmd -ne 'wmi-auditing') {
throw '-WmiAction, -WmiNamespace and -WmiIncludeChildren require wmi-auditing. No command was run.'
@@ -1873,6 +1882,19 @@ switch ($Cmd.ToLower()) {
if ($report.ExitCode) { exit $report.ExitCode }
} catch { Write-Host "[Failed] SMB auditing: $_" -ForegroundColor Red; exit 1 }
}
'powershell-transcription' {
if ($Help) {
Write-Host 'Usage: ./WELA.ps1 powershell-transcription [-TranscriptionAction Audit|Plan|Configure] [-TranscriptDirectory absolute-existing-directory] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]'
Write-Host 'Explicit CIS v4.0.0 Level 2 option for Windows PowerShell 5.1. Plan/Configure require an operator-reviewed output directory; ACLs, quotas and retention are not changed. Text transcripts provide no automatic Sigma EVTX credit. See docs/powershell-transcription.md.'
return
}
if ($Profile -or $Baseline) { throw 'powershell-transcription is an explicit Level 2 option; -Profile and -Baseline select separate Security audit policies.' }
try {
$report = Invoke-WelaTranscriptCommand -Action $TranscriptionAction -OutputDirectory $TranscriptDirectory -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath
$report
if ($report.ExitCode) { exit $report.ExitCode }
} catch { Write-Host "[Failed] PowerShell transcription: $_" -ForegroundColor Red; exit 1 }
}
'ad-object-sacl' {
if ($Help) {
Write-Host 'Usage: ./WELA.ps1 ad-object-sacl -AdServer exact-dc-fqdn [-AdSaclAction Audit|Plan|Configure] -AdSaclProfile MdiDomain|MdiConfiguration|PkiObjects [-AdObjectDn exact-dn] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]'
+73
View File
@@ -0,0 +1,73 @@
# Windows PowerShell transcription (CIS Level 2)
`powershell-transcription` provides an **explicit, optional Level 2** transcription policy for Windows PowerShell 5.1. It is separate from advanced audit-policy profiles and is never automatically enabled by an L1 profile, `configure`, or script-block/module logging. The reviewed CIS Windows 11 Enterprise and Windows Server 2022 **v4.0.0**, section **18.10.87.2**, require `EnableTranscripting=1` at Level 2. This command is not a complete CIS compliance assessment and does not claim the latest benchmark version.
```powershell
# Read current machine/current-user policy and destination observations.
./WELA.ps1 powershell-transcription -TranscriptionAction Audit -ResultsPath transcription-audit.json
# Select and review an existing destination before configuration.
./WELA.ps1 powershell-transcription -TranscriptionAction Plan `
-TranscriptDirectory C:\Transcripts -ResultsPath transcription-plan.json
./WELA.ps1 powershell-transcription -TranscriptionAction Configure `
-TranscriptDirectory C:\Transcripts -DryRun -ResultsPath transcription-preview.json
# Configure only after reviewing the directory's intended writers and collectors.
./WELA.ps1 powershell-transcription -TranscriptionAction Configure `
-TranscriptDirectory '\\collector.example.test\Transcripts' `
-Auto -BackupPath .\new-transcription-backup -ResultsPath transcription-result.json
```
`Plan` and `Configure` require `-TranscriptDirectory`. The path must be an existing literal absolute drive or UNC directory. Relative paths, environment variables, wildcards, dot segments, device paths, alternate streams and observed reparse-point components are rejected. The command never provisions a directory, share or ACL. `-Auto` accepts the ordinary configuration prompt; it does not prove that destination permissions are suitable. `-Profile` and `-Baseline` are rejected by this command, and transcription-specific parameters are rejected on unrelated commands. `-DryRun` is supported only with `Configure` and writes neither policy nor a recovery directory.
## Policy and engine scope
The machine policy key is:
```text
HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription
```
| Value | Requested state | Treatment |
| --- | --- | --- |
| `EnableTranscripting` | REG_DWORD `1` | Explicitly enabled for CIS L2 |
| `OutputDirectory` | REG_SZ containing the operator's exact absolute path | Verified before enablement |
| `EnableInvocationHeader` | Existing value, type or absence | Observed and preserved; not required by this CIS check |
An explicit configuration repairs incorrect types such as REG_SZ `"1"` for `EnableTranscripting`. Unrelated values and current-user policy are preserved. Computer policy takes precedence over user policy, but this command reports observed registry state and does not claim GPO/MDM ownership or persistence. A policy refresh can override a direct registry write; manage the authoritative policy separately. [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), [ADMX mapping](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-admx-powershellexecutionpolicy#enabletranscripting).
The required explicit output-directory review is a WELA deployment safeguard; the cited CIS check itself requires enablement and does not mandate a particular output path or invocation-header choice.
Microsoft documents `HKLM\SOFTWARE\Policies` as **shared** by 32-bit and 64-bit processes. WELA reads the canonical path through both registry views on a 64-bit OS and refuses inconsistent observations. It writes once through the native view; it does not create a literal `Wow6432Node` policy subtree. [WOW registry sharing documentation](https://learn.microsoft.com/en-us/windows/win32/winprog64/shared-registry-keys).
Installed Windows PowerShell 5.1 is checked using its engine registry version and executable presence. WELA can run under Windows PowerShell 5.1 or PowerShell 7 on Windows, but the target remains **Windows PowerShell 5.1**. PowerShell 7 uses separate PowerShell Core policy/configuration, including an optional Windows-policy fallback; this command does not configure or assess that fallback. No PowerShell 7 session coverage is inferred. Existing sessions are not restarted or asserted to adopt a changed policy. [PowerShell 7 policy documentation](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_group_policy_settings?view=powershell-7.5).
## Destination review and collection
Transcripts contain command input and output and can contain credentials or other sensitive data. The command deliberately requires a selected output location instead of silently accepting each user's Documents directory. Microsoft recommends restricting access when transcripts are centralized. [Microsoft transcription guidance](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_group_policy_settings?view=powershell-5.1#turn-on-powershell-transcription).
The plan/result includes the directory's resolved path, creation time, attributes, owner, DACL SDDL and recognized ACEs. A null/empty DACL, or an identified broad read/list or modification grant, blocks configuration. Broad principals include Everyone, Authenticated Users, Users, Guests, Anonymous, Network and Interactive. Grant inspection is deliberately conservative: a deny ACE does not automatically cancel a reported broad allow. Conditional or uninterpreted access ACEs and failed reads remain `Unknown` and block configuration. WELA makes no ACL changes to work around these findings.
`Destination.Status=Observed` means the directory was read without those identified grants. **It is not an effective-access or storage-security certification.** Arbitrary group membership, each run-as identity, protected child ACLs, ownership, deny interactions and a collector's access remain deployment checks. For UNC destinations, the readable filesystem DACL is observed while `ShareAuthorization=Unknown` remains explicit; the client does not inspect remote SMB share permissions or server quotas. Explicit `Configure` may proceed with this UNC uncertainty after the operator reviews the deployment. `WriterAuthorization` and `CollectorAuthorization` remain `Unknown` in all normal reports.
Before rollout, provision a dedicated destination and verify that intended writers can create the date subdirectories and transcript files, while unauthorized users cannot read, replace or delete other users' transcripts. Limit collector/reviewer access, test both NTFS and SMB permissions for actual identities, and use an appropriate collection/retention design. A local administrator-only directory is suitable for the disposable CI test, not proof that ordinary user sessions can write centrally. WELA does not copy an ACL recipe across deployments or claim append-only/immutable storage.
Plan capacity and retention separately: estimate daily transcript volume and peak sessions; set and monitor storage quotas/free space; define retention, rotation, archival and deletion ownership; and confirm the collector reads complete files with the correct identity and protects the destination. No quota, scheduled cleanup, SMB permission, retention policy, encryption or collection configuration is changed. Local transcript production cannot establish central ingestion.
## Verification and recovery
One configuration control journals the original typed machine/current-user values from both views and destination observations to `before.jsonl` before any registry mutation. A fresh policy/directory check after the prompt refuses drift. The reviewed `OutputDirectory` is written and reread **before** `EnableTranscripting` is enabled. Post-write and final reads verify the requested types/values, both views and preservation of the invocation-header preference. This is not a transaction or an atomic directory lock; another administrator, GPO or filesystem writer can change state after a check.
`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.
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
Transcript files are **text**, separate from PowerShell EVTX events **4103/4104**. Reports set `SigmaEvtxCredit=0` and contain no transcript event-ID claim. Enabling transcription does not automatically make an EVTX Sigma rule usable or add to reported Sigma coverage.
The mock suite covers typed values, shared views, idempotence, ordering, destination/policy races, denied reads/writes, partial failure, recovery journaling, header preservation and report limits. The Windows workflow targets disposable Server 2022/2025 runners under both WELA hosts (5.1 and 7). Its explicitly gated native test creates only owned private directories, saves original policy, configures transcription, launches fresh native Windows PowerShell 5.1 sessions (native/x86 where installed), and searches for benign markers in automatically produced transcript files. It restores exact original policy in `finally`, verifies restoration, and removes its generated files. If restoration fails, it fails the job and retains the private recovery evidence. PowerShell 7 is a test host, not a transcript-generation target.
Native CI passed on both Server 2022 and Server 2025 under Windows PowerShell 5.1 and PowerShell 7 in [run 35439090461](https://github.com/Yamato-Security/WELA/actions/runs/35439090461): 14 native assertions per OS/host combination, including fresh x64/x86 Windows PowerShell 5.1 transcript markers and verified restoration (56 native assertions total). Production/central validation still requires actual client, server, DC and service identities: test a benign new session, record the transcript and effective policy, verify unauthorized read/modify attempts fail, check collection and quotas/retention, and verify recovery. CI's local private folder does not satisfy the central authorization/ingestion acceptance criterion. Sysmon and external telemetry are out of scope.
Reviewed CIS references: [Windows 11 Enterprise v4.0.0, PDF pages 1286–1287](https://rayasec.com/wp-content/uploads/CIS-Benchmark/Microsoft-Windows-Desktop/CIS_Microsoft_Windows_11_Enterprise_Benchmark_v4.0.0.pdf#page=1286), [Windows Server 2022 v4.0.0, PDF pages 1029–1030](https://rayasec.com/wp-content/uploads/CIS-Benchmark/Microsoft-Windows-Server/CIS_Microsoft_Windows_Server_2022_Benchmark_v4.0.0.pdf#page=1029).
+1 -1
View File
@@ -94,7 +94,7 @@ function Invoke-WelaConfigurationControl {
function Complete-WelaConfiguration {
param($Context, [string]$ResultsPath, $Plan,
[ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "wef-source-configuration-only", "wec-collector-subscriptions-only")]
[ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only")]
[string]$Scope = "native-windows-configuration",
[string]$SuccessMessage = 'Configuration completed; all requested controls verified.')
# A second read detects a value that was compliant earlier but changed during
+246
View File
@@ -0,0 +1,246 @@
# Optional Windows PowerShell 5.1 transcription. Text files, not EVTX coverage.
function Get-WelaTranscriptRegistryValue {
param([ValidateSet('LocalMachine', 'CurrentUser')][string]$Hive = 'LocalMachine',
[ValidateSet('Registry64', 'Registry32')][string]$View = 'Registry64',
[string]$SubKey = 'SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription', [string]$Name)
$base = $null; $key = $null
try {
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::$Hive, [Microsoft.Win32.RegistryView]::$View)
$key = $base.OpenSubKey($SubKey, $false)
$exists = $null -ne $key -and $key.GetValueNames() -contains $Name
[pscustomobject]@{ KeyExists = $null -ne $key; ValueExists = [bool]$exists;
Value = $(if ($exists) { $key.GetValue($Name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) } else { $null });
Type = $(if ($exists) { $key.GetValueKind($Name).ToString() } else { $null }) }
} finally { if ($key) { $key.Dispose() }; if ($base) { $base.Dispose() } }
}
function Set-WelaTranscriptRegistryValue {
param([ValidateSet('EnableTranscripting', 'OutputDirectory')][string]$Name, $Value,
[ValidateSet('DWord', 'String')][string]$Type)
$base = $null; $key = $null
try {
# SOFTWARE\Policies is shared by Registry32/Registry64 on supported Windows.
# Do not create a literal Wow6432Node policy subtree.
$view = if ([Environment]::Is64BitOperatingSystem) { [Microsoft.Win32.RegistryView]::Registry64 } else { [Microsoft.Win32.RegistryView]::Registry32 }
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
$key = $base.CreateSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription')
$key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::$Type)
} finally { if ($key) { $key.Dispose() }; if ($base) { $base.Dispose() } }
}
function Get-WelaTranscriptCapability {
$result = [pscustomobject]@{ Status = 'Unknown'; TargetEngine = 'Windows PowerShell 5.1'; EngineVersion = $null;
WelaHostEdition = [string]$PSVersionTable.PSEdition; WelaHostVersion = $PSVersionTable.PSVersion.ToString(); Views = @(); Diagnostic = '' }
try {
if ($env:OS -ne 'Windows_NT') { throw 'This command requires Windows; PowerShell 7 on non-Windows cannot configure Windows PowerShell policy.' }
$result.Views = if ([Environment]::Is64BitOperatingSystem) { @('Registry64', 'Registry32') } else { @('Registry32') }
$engine = Get-WelaTranscriptRegistryValue -View $result.Views[0] -SubKey 'SOFTWARE\Microsoft\PowerShell\3\PowerShellEngine' -Name PowerShellVersion
if (-not $engine.ValueExists -or $engine.Type -ne 'String' -or [string]$engine.Value -notmatch '^5\.1(?:\.|$)') { throw 'Installed Windows PowerShell 5.1 engine could not be confirmed from its registry version.' }
$result.EngineVersion = [string]$engine.Value
$executable = Join-Path $env:windir 'System32\WindowsPowerShell\v1.0\powershell.exe'
if (-not (Test-Path -LiteralPath $executable -PathType Leaf -ErrorAction Stop)) { throw 'Native Windows PowerShell executable is missing.' }
$result.Status = 'Supported'
$result.Diagnostic = 'Windows PowerShell 5.1 policy only. PowerShell 7 has separate policy/configuration; its sessions are not assessed or configured.'
} catch { $result.Diagnostic = $_.Exception.Message }
return $result
}
function Get-WelaTranscriptPolicy {
param([string[]]$Views)
foreach ($view in $Views) {
$machine = [ordered]@{}; $user = [ordered]@{}
foreach ($name in @('EnableTranscripting', 'OutputDirectory', 'EnableInvocationHeader')) {
$machine[$name] = Get-WelaTranscriptRegistryValue -View $view -Name $name
$user[$name] = Get-WelaTranscriptRegistryValue -Hive CurrentUser -View $view -Name $name
}
[pscustomobject]@{ View = $view; Machine = [pscustomobject]$machine; CurrentUser = [pscustomobject]$user }
}
}
function Test-WelaTranscriptSharedPolicy {
param([array]$Policy)
if (-not $Policy.Count) { throw 'No registry view was observed.' }
if ($Policy.Count -gt 1) {
foreach ($hive in @('Machine', 'CurrentUser')) {
foreach ($name in @('EnableTranscripting', 'OutputDirectory', 'EnableInvocationHeader')) {
foreach ($field in @('KeyExists', 'ValueExists', 'Type', 'Value')) {
if ((ConvertTo-Json -InputObject $Policy[0].$hive.$name.$field -Compress) -cne
(ConvertTo-Json -InputObject $Policy[1].$hive.$name.$field -Compress)) { throw "Shared policy views differ: $hive/$name/$field. No architecture coverage is assumed." }
}
}
}
}
}
function Test-WelaTranscriptDirectoryPath {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path) -or $Path -match '[*?%\x00-\x1f]' -or
$Path -notmatch '^(?:[A-Za-z]:\\|\\\\[^\\:]+\\[^\\:]+(?:\\|$))' -or
$Path -match '(?:^|\\)\.\.?($|\\)' -or $Path.Substring(2).Contains(':')) {
throw 'Supply a literal absolute drive or UNC directory, without wildcards, environment variables, device paths, alternate streams or dot segments.'
}
}
function Get-WelaTranscriptAclObservation {
param([string]$Path)
$acl = Get-Acl -LiteralPath $Path -ErrorAction Stop
$sddl = $acl.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]'Owner,Group,Access')
$sd = [Security.AccessControl.RawSecurityDescriptor]::new($sddl)
$entries = @(); $risks = @(); $unknown = @()
if ($null -eq $sd.DiscretionaryAcl) { $risks += 'Null DACL permits unrestricted access.' }
elseif ($sd.DiscretionaryAcl.Count -eq 0) { $risks += 'Empty DACL permits no transcript writers.' }
foreach ($ace in $sd.DiscretionaryAcl) {
if ($ace -isnot [Security.AccessControl.CommonAce] -or $ace.IsCallback) { $unknown += 'Uninterpreted or conditional access ACE; authorization needs deployment review.'; continue }
$sid = $ace.SecurityIdentifier.Value
$mask = [int64]$ace.AccessMask; $flags = [int]$ace.AceFlags
$entries += [pscustomobject]@{ Sid = $sid; RightsMask = $mask; AceFlags = $flags; Type = $ace.AceQualifier.ToString() }
if ($ace.AceQualifier -ne [Security.AccessControl.AceQualifier]::AccessAllowed) { continue }
$broad = $sid -in @('S-1-1-0', 'S-1-5-7', 'S-1-5-11', 'S-1-5-32-545', 'S-1-5-32-546', 'S-1-5-2', 'S-1-5-4')
if (-not $broad) { continue }
# Conservative grant inspection, not a token/group/deny-aware AccessCheck.
# GenericRead/GenericAll and ReadData/ListDirectory expose transcript data/names.
if ($mask -band 2415919105) { $risks += "Broad principal $sid has a read/list grant; other users' transcripts may be exposed." }
# File-inheritable write/append, delete, change-permissions/owner, or generic write/all.
if (($mask -band 1343029312) -or (($flags -band 1) -and ($mask -band 6))) {
$risks += "Broad principal $sid has a modification grant; existing transcripts may be alterable."
}
}
[pscustomobject]@{ Sddl = $sddl; Owner = [string]$sd.Owner; Entries = $entries; Risks = $risks; Unknown = $unknown;
Assessment = 'Conservative ACL observations only; effective writer/collector access and authorized group membership require deployment validation.' }
}
function Get-WelaTranscriptDestination {
param([string]$Path)
$result = [pscustomobject]@{ RequestedPath = $Path; Path = $null; Status = 'Unknown'; ConfigureAllowed = $false;
IsUnc = $Path.StartsWith('\\'); CreationTimeUtc = $null; Attributes = $null; Acl = $null;
ShareAuthorization = 'NotApplicable'; WriterAuthorization = 'Unknown'; CollectorAuthorization = 'Unknown'; Diagnostic = '' }
try {
Test-WelaTranscriptDirectoryPath $Path
$item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
if ($item -isnot [IO.DirectoryInfo]) { throw 'Transcript destination must be an existing filesystem directory.' }
$result.Path = $item.FullName
$result.CreationTimeUtc = $item.CreationTimeUtc.ToString('o')
$result.Attributes = [int]$item.Attributes
# Refuse local or UNC path components that are observed reparse points.
$ancestor = $item
while ($null -ne $ancestor) {
if ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Reparse-point destination component requires separate review: $($ancestor.FullName)." }
$ancestor = $ancestor.Parent
}
$result.Acl = Get-WelaTranscriptAclObservation $result.Path
if ($result.Acl.Risks.Count) { $result.Status = 'Blocked'; throw ($result.Acl.Risks -join ' ') }
if ($result.Acl.Unknown.Count) { throw ($result.Acl.Unknown -join ' ') }
$result.Status = 'Observed'; $result.ConfigureAllowed = $true
$result.Diagnostic = 'Directory and DACL observed without identified broad access grants. This does not prove effective access, protected child files, append-only storage, retention or collection; review the deployment before Configure.'
if ($result.IsUnc) {
$result.ShareAuthorization = 'Unknown'
$result.Diagnostic += ' UNC share permissions, remote identities and server-side quotas are not evaluated.'
}
} catch { $result.Diagnostic = $_.Exception.Message }
return $result
}
function Get-WelaTranscriptState {
param([string]$OutputDirectory)
$capability = Get-WelaTranscriptCapability
if ($capability.Status -ne 'Supported') { throw $capability.Diagnostic }
$policy = @(Get-WelaTranscriptPolicy $capability.Views)
Test-WelaTranscriptSharedPolicy $policy
$observedPath = $OutputDirectory
if (-not $observedPath -and $policy[0].Machine.OutputDirectory.ValueExists -and $policy[0].Machine.OutputDirectory.Type -eq 'String') {
$observedPath = [string]$policy[0].Machine.OutputDirectory.Value
}
$destination = if ($observedPath) { Get-WelaTranscriptDestination $observedPath } else {
[pscustomobject]@{ RequestedPath = $null; Path = $null; Status = 'Unknown'; ConfigureAllowed = $false;
Diagnostic = 'No explicit machine output directory was observed; per-user defaults/current-user policy and destination authorization are not established.' }
}
[pscustomobject]@{ Capability = $capability; Policy = $policy; Destination = $destination;
TranscriptGeneration = 'Unverified'; PowerShell7Sessions = 'NotAssessed'; Retention = 'Unknown'; Collection = 'Unknown' }
}
function Test-WelaTranscriptConfigured {
param($Snapshot, [string]$OutputDirectory)
if (-not $Snapshot.Destination.ConfigureAllowed) { return $false }
foreach ($view in $Snapshot.Policy) {
if (-not $view.Machine.EnableTranscripting.ValueExists -or $view.Machine.EnableTranscripting.Type -ne 'DWord' -or
$view.Machine.EnableTranscripting.Value -ne 1 -or -not $view.Machine.OutputDirectory.ValueExists -or
$view.Machine.OutputDirectory.Type -ne 'String' -or [string]$view.Machine.OutputDirectory.Value -cne $OutputDirectory) { return $false }
}
return $Snapshot.Policy.Count -gt 0
}
function Set-WelaTranscriptControl {
param($Context, [string]$OutputDirectory)
$state = @{ OutputDirectory = $OutputDirectory; Observed = $null; PreservedHeader = $null }
$read = { param($state)
$snapshot = Get-WelaTranscriptState $state.OutputDirectory
if (-not $snapshot.Destination.ConfigureAllowed) { throw "Transcript destination cannot be configured: $($snapshot.Destination.Diagnostic)" }
if ($null -ne $state.PreservedHeader -and
($snapshot.Policy[0].Machine.EnableInvocationHeader | Select-Object ValueExists, Type, Value | ConvertTo-Json -Compress) -cne $state.PreservedHeader) {
throw 'Invocation-header preference changed during configuration; no header change was requested.'
}
$state.Observed = $snapshot
return $snapshot
}
$test = { param($snapshot, $state) Test-WelaTranscriptConfigured $snapshot $state.OutputDirectory }
$apply = { param($state)
$before = $state.Observed
$fresh = Get-WelaTranscriptState $state.OutputDirectory
if (-not $fresh.Destination.ConfigureAllowed -or
($fresh.Policy | ConvertTo-Json -Depth 10 -Compress) -cne ($before.Policy | ConvertTo-Json -Depth 10 -Compress) -or
($fresh.Destination | ConvertTo-Json -Depth 10 -Compress) -cne ($before.Destination | ConvertTo-Json -Depth 10 -Compress)) {
throw 'Policy or destination changed after the recovery snapshot; no write was sent. Review and retry.'
}
$state.PreservedHeader = $before.Policy[0].Machine.EnableInvocationHeader | Select-Object ValueExists, Type, Value | ConvertTo-Json -Compress
# Establish the reviewed location before enabling new-session transcription.
if (-not $before.Policy[0].Machine.OutputDirectory.ValueExists -or $before.Policy[0].Machine.OutputDirectory.Type -ne 'String' -or
[string]$before.Policy[0].Machine.OutputDirectory.Value -cne $state.OutputDirectory) {
Set-WelaTranscriptRegistryValue -Name OutputDirectory -Value $state.OutputDirectory -Type String
}
$location = Get-WelaTranscriptRegistryValue -View $before.Capability.Views[0] -Name OutputDirectory
if (-not $location.ValueExists -or $location.Type -ne 'String' -or [string]$location.Value -cne $state.OutputDirectory) {
throw 'OutputDirectory write did not verify; EnableTranscripting was not changed.'
}
if (-not $before.Policy[0].Machine.EnableTranscripting.ValueExists -or $before.Policy[0].Machine.EnableTranscripting.Type -ne 'DWord' -or
$before.Policy[0].Machine.EnableTranscripting.Value -ne 1) {
Set-WelaTranscriptRegistryValue -Name EnableTranscripting -Value 1 -Type DWord
}
'Windows PowerShell machine transcription policy written. New-session transcript generation, writer/collector authorization and collection remain unverified. No invocation-header, ACL, share, quota or retention setting was changed.'
}
Invoke-WelaConfigurationControl -Context $Context -Id 'PowerShellTranscription/CisV4L2' -Kind PowerShellTranscription `
-Target @{ Hive = 'LocalMachine'; SubKey = 'SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription'; OutputDirectory = $OutputDirectory } `
-Desired @{ EnableTranscripting = @{ Type = 'DWord'; Value = 1 }; OutputDirectory = @{ Type = 'String'; Value = $OutputDirectory }; EnableInvocationHeader = 'Preserve' } `
-Read $read -Compliant $test -Apply $apply -CallbackState $state `
-Description 'Enable CIS Level 2 Windows PowerShell transcription using this explicitly reviewed destination. Transcript text can contain sensitive input/output.'
}
function Invoke-WelaTranscriptCommand {
param([ValidateSet('Audit', 'Plan', 'Configure')][string]$Action = 'Audit', [string]$OutputDirectory,
[switch]$Auto, [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath)
if ($DryRun -and $Action -ne 'Configure') { throw 'DryRun applies only to transcription Configure.' }
if ($Action -in @('Plan', 'Configure') -and -not $OutputDirectory) { throw 'Transcription Plan/Configure requires an explicit -TranscriptDirectory to review.' }
if ($Action -eq 'Configure') {
$context = New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
Set-WelaTranscriptControl $context $OutputDirectory
$report = Complete-WelaConfiguration -Context $context -Scope 'windows-powershell-transcription-policy-only' `
-SuccessMessage 'Windows PowerShell transcription policy verified; transcript generation and destination authorization/collection remain separate checks.'
} else {
$snapshot = $null; $diagnostic = ''; $status = 'Unknown'
try {
$snapshot = Get-WelaTranscriptState $OutputDirectory
$targetPath = if ($OutputDirectory) { $OutputDirectory } else { [string]$snapshot.Policy[0].Machine.OutputDirectory.Value }
$status = if (-not $snapshot.Destination.ConfigureAllowed) { $snapshot.Destination.Status }
elseif (Test-WelaTranscriptConfigured $snapshot $targetPath) { 'PolicyConfigured' } else { 'ChangeRequired' }
$diagnostic = $snapshot.Destination.Diagnostic
} catch { $diagnostic = $_.Exception.Message }
$report = [pscustomobject]@{ Scope = 'windows-powershell-transcription-policy-only';
ExitCode = $(if ($status -in @('Unknown', 'Blocked')) { 1 } else { 0 });
Results = @([pscustomobject]@{ Status = $status; DesiredDirectory = $OutputDirectory; Before = $snapshot; Diagnostic = $diagnostic }) }
}
$report | Add-Member NoteProperty Action $Action
$report | Add-Member NoteProperty Benchmark 'CIS Windows 11 Enterprise / Windows Server 2022 v4.0.0, 18.10.87.2, Level 2 only; this is not a complete CIS assessment.'
$report | Add-Member NoteProperty VerificationScope 'Windows PowerShell 5.1 machine registry policy and destination observations only. Existing sessions, other identities, PowerShell 7 sessions, transcript generation, quota/retention and central collection are unverified.'
$report | Add-Member NoteProperty Telemetry @{ Format = 'Text transcript files'; EventIds = @(); SigmaEvtxCredit = 0; RelationTo4103And4104 = 'Separate output; no automatic EVTX rule applicability or coverage uplift.' }
if ($ResultsPath) { $report | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop }
return $report
}
+137
View File
@@ -0,0 +1,137 @@
$ErrorActionPreference = 'Stop'
$repo = Split-Path $PSScriptRoot -Parent
$script:ScriptRoot = $repo
. (Join-Path $repo 'scripts/Configuration.ps1')
. (Join-Path $repo 'scripts/PowerShellTranscription.ps1')
$script:checks = 0
function Assert($Condition, [string]$Message) { if (-not $Condition) { throw "FAIL: $Message" }; $script:checks++ }
function Assert-Throws([scriptblock]$Action, [string]$Message) { $thrown = $false; try { & $Action } catch { $thrown = $true }; Assert $thrown $Message }
$root = Join-Path ([IO.Path]::GetTempPath()) ('wela-transcription-' + [guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Path $root
$originalCapability = ${function:Get-WelaTranscriptCapability}
function New-Value($Value = $null, [string]$Type = '') {
[pscustomobject]@{ KeyExists = [bool]$Type; ValueExists = [bool]$Type; Value = $Value; Type = $(if ($Type) { $Type } else { $null }) }
}
function Reset-Mocks {
$script:machine = @{ EnableTranscripting = New-Value; OutputDirectory = New-Value; EnableInvocationHeader = New-Value }
$script:user = @{ EnableTranscripting = New-Value; OutputDirectory = New-Value; EnableInvocationHeader = New-Value }
$script:writes = @(); $script:reads = 0; $script:destinationReads = 0; $script:registryFailure = $false
$script:viewMismatch = $false; $script:destinationFailure = $false; $script:destinationRisk = $false
$script:race = $false; $script:drift = $false; $script:writeFailure = ''; $script:locationReadbackFailure = $false
$script:headerDrift = $false; $script:onPrompt = $null; $script:version = '5.1.26100.0'
}
function Get-WelaTranscriptCapability {
[pscustomobject]@{ Status = 'Supported'; TargetEngine = 'Windows PowerShell 5.1'; Views = @('Registry64', 'Registry32'); Diagnostic = 'Mock engine' }
}
function Get-WelaTranscriptRegistryValue {
param($Hive = 'LocalMachine', $View, $SubKey, $Name)
$script:reads++
if ($script:registryFailure) { throw 'Registry read denied' }
if ($Name -eq 'PowerShellVersion') { return New-Value $script:version String }
$values = if ($Hive -eq 'CurrentUser') { $script:user } else { $script:machine }
$result = $values[$Name] | ConvertTo-Json -Depth 8 | ConvertFrom-Json
if ($script:viewMismatch -and $View -eq 'Registry32' -and $Name -eq 'EnableTranscripting' -and $Hive -eq 'LocalMachine') { $result = New-Value 2 DWord }
if ($script:drift -and $script:destinationReads -ge 3 -and $Name -eq 'EnableTranscripting' -and $Hive -eq 'LocalMachine') { $result = New-Value 0 DWord }
if ($script:headerDrift -and $script:writes.Count -gt 0 -and $Name -eq 'EnableInvocationHeader' -and $Hive -eq 'LocalMachine') { $result = New-Value 0 DWord }
return $result
}
function Get-WelaTranscriptDestination {
param($Path)
$script:destinationReads++
$sddl = if ($script:race -and $script:destinationReads -gt 1) { 'changed' } else { 'private-directory-acl' }
[pscustomobject]@{ RequestedPath = $Path; Path = $Path; Status = $(if ($script:destinationRisk) { 'Blocked' } elseif ($script:destinationFailure) { 'Unknown' } else { 'Observed' });
ConfigureAllowed = -not ($script:destinationFailure -or $script:destinationRisk); Acl = @{ Sddl = $sddl };
ShareAuthorization = $(if ($Path.StartsWith('\\')) { 'Unknown' } else { 'NotApplicable' }); WriterAuthorization = 'Unknown'; CollectorAuthorization = 'Unknown';
Diagnostic = 'Mock directory; effective access remains unverified' }
}
function Set-WelaTranscriptRegistryValue {
param($Name, $Value, $Type)
if ($Name -eq $script:writeFailure) { throw "Mock $Name write denied" }
$script:writes += $Name
if ($Name -eq 'OutputDirectory' -and $script:locationReadbackFailure) { return }
$script:machine[$Name] = New-Value $Value $Type
foreach ($entry in $script:machine.Values) { $entry.KeyExists = $true }
}
function Read-Host { param($Prompt) if ($script:onPrompt) { & $script:onPrompt }; return 'y' }
function Configure([switch]$DryRun, [switch]$Prompt, [string]$Path = 'C:\ReviewedTranscripts') {
Invoke-WelaTranscriptCommand -Action Configure -OutputDirectory $Path -Auto:(-not $Prompt) -DryRun:$DryRun `
-BackupPath (Join-Path $root ([guid]::NewGuid().ToString('N')))
}
try {
foreach ($path in @('C:\ReviewedTranscripts', '\\collector.example.test\Transcripts', '\\collector\Transcripts\Windows')) {
Test-WelaTranscriptDirectoryPath $path; Assert $true "absolute path accepted: $path"
}
foreach ($path in @('', '.\transcripts', 'C:relative', 'C:\..\transcripts', '%TEMP%\transcripts', 'C:\foo*', '\\?\C:\transcripts', '\\.\pipe\name', 'C:\transcripts:stream', 'https://collector/logs')) {
Assert-Throws { Test-WelaTranscriptDirectoryPath $path } "unsafe/ambiguous path rejected: $path"
}
Reset-Mocks
Assert-Throws { Invoke-WelaTranscriptCommand -Action Plan } 'plan requires an explicit reviewed path'
Assert-Throws { Invoke-WelaTranscriptCommand -Action Configure -Auto } 'configure cannot fall back to per-user Documents'
Assert-Throws { Invoke-WelaTranscriptCommand -Action Audit -DryRun } 'dry-run only applies to configure'
$report = Invoke-WelaTranscriptCommand -Action Audit
Assert ($report.Results[0].Status -eq 'Unknown' -and $script:writes.Count -eq 0) 'missing output policy remains unknown during audit'
$report = Invoke-WelaTranscriptCommand -Action Plan -OutputDirectory 'C:\ReviewedTranscripts'
Assert ($report.Results[0].Status -eq 'ChangeRequired' -and $report.Benchmark -like '*Level 2 only*') 'opt-in plan explicitly identifies Level 2'
Assert ($report.Telemetry.SigmaEvtxCredit -eq 0 -and $report.Telemetry.EventIds.Count -eq 0 -and $report.VerificationScope -like '*PowerShell 7*unverified*') 'transcript text gives no EVTX or PowerShell 7 session credit'
Reset-Mocks; $report = Configure -DryRun
Assert ($report.DryRun -and $report.Skipped -eq 1 -and $script:writes.Count -eq 0 -and -not (Test-Path $report.BackupPath)) 'dry-run makes no registry or recovery-directory writes'
Reset-Mocks; $report = Configure
Assert ($report.ExitCode -eq 0 -and $report.Results[0].Status -eq 'Applied') 'new policy applies and verifies'
Assert (($script:writes -join ',') -eq 'OutputDirectory,EnableTranscripting') 'reviewed location is written and verified before enabling transcription'
Assert ($script:machine.EnableTranscripting.Type -eq 'DWord' -and $script:machine.EnableTranscripting.Value -eq 1 -and $script:machine.OutputDirectory.Type -eq 'String') 'exact canonical registry types'
Assert (-not $script:machine.EnableInvocationHeader.ValueExists) 'absent invocation-header preference remains absent even when key is created'
$journal = Get-Content (Join-Path $report.BackupPath 'before.jsonl') -Raw | ConvertFrom-Json
Assert ($journal.Before.Policy.Count -eq 2 -and -not $journal.Before.Policy[0].Machine.OutputDirectory.ValueExists -and $journal.Before.Destination.Acl.Sddl -eq 'private-directory-acl') 'journal records both views and original destination security observations'
$script:destinationReads = 0; $again = Configure
Assert ($again.Results[0].Status -eq 'AlreadyCompliant' -and $script:writes.Count -eq 2) 'repeat configuration is idempotent'
Reset-Mocks; $script:machine.EnableInvocationHeader = New-Value 1 DWord
$script:user.EnableTranscripting = New-Value 0 DWord
$report = Configure
Assert ($report.ExitCode -eq 0 -and $script:machine.EnableInvocationHeader.Value -eq 1 -and $script:user.EnableTranscripting.Value -eq 0) 'existing header and user policy are preserved'
Reset-Mocks; $script:machine.EnableTranscripting = New-Value '1' String
$script:machine.OutputDirectory = New-Value 'C:\ReviewedTranscripts' ExpandString
$report = Configure
Assert ($report.ExitCode -eq 0 -and $script:machine.EnableTranscripting.Type -eq 'DWord' -and $script:machine.OutputDirectory.Type -eq 'String') 'numeric strings and ExpandString are repaired by explicit configure'
Reset-Mocks; $script:viewMismatch = $true; $report = Configure
Assert ($report.ExitCode -eq 1 -and $script:writes.Count -eq 0) 'shared-view mismatch fails closed'
Reset-Mocks; $script:registryFailure = $true; $report = Configure
Assert ($report.ExitCode -eq 1 -and $script:writes.Count -eq 0) 'registry read failure cannot be mistaken for absent configuration'
foreach ($risk in @('destinationFailure', 'destinationRisk')) {
Reset-Mocks; Set-Variable -Scope Script -Name $risk -Value $true; $report = Configure
Assert ($report.ExitCode -eq 1 -and $script:writes.Count -eq 0) 'unreadable or known-unsafe destination blocks writes'
}
Reset-Mocks; $report = Configure -Path '\\collector\Transcripts'
Assert ($report.ExitCode -eq 0 -and $report.Results[0].After.Destination.ShareAuthorization -eq 'Unknown' -and $report.Results[0].After.Destination.WriterAuthorization -eq 'Unknown') 'explicit UNC configure retains unknown share and writer authorization'
Reset-Mocks; $script:race = $true; $report = Configure
Assert ($report.ExitCode -eq 1 -and $script:writes.Count -eq 0 -and $report.Results[0].Diagnostic -like '*changed after*') 'destination ACL race fails before writing'
Reset-Mocks; $script:locationReadbackFailure = $true; $report = Configure
Assert ($report.ExitCode -eq 1 -and ($script:writes -join ',') -eq 'OutputDirectory') 'bad location readback prevents enablement'
Reset-Mocks; $script:writeFailure = 'EnableTranscripting'; $report = Configure
Assert ($report.ExitCode -eq 1 -and (Test-Path (Join-Path $report.BackupPath 'before.jsonl')) -and $script:machine.OutputDirectory.ValueExists) 'partial write failure retains accurate recovery evidence'
Reset-Mocks; $script:drift = $true; $report = Configure
Assert ($report.ExitCode -eq 1 -and $report.Results[0].Status -eq 'Overridden') 'final policy drift is overridden'
Reset-Mocks; $script:headerDrift = $true; $report = Configure
Assert ($report.ExitCode -eq 1 -and $report.Results[0].Diagnostic -like '*Invocation-header*') 'unrequested header change fails verification'
Reset-Mocks
$script:onPrompt = { Get-ChildItem $root -Directory | Remove-Item -Recurse -Force }
$report = Configure -Prompt
Assert ($report.ExitCode -eq 1 -and $script:writes.Count -eq 0) 'journal write failure prevents policy changes'
Reset-Mocks
$path = Join-Path $root 'report.json'
$null = Invoke-WelaTranscriptCommand -Action Plan -OutputDirectory 'C:\ReviewedTranscripts' -ResultsPath $path
$export = Get-Content $path -Raw | ConvertFrom-Json
Assert ($export.Telemetry.SigmaEvtxCredit -eq 0 -and $export.Results[0].Before.TranscriptGeneration -eq 'Unverified') 'JSON retains generation and coverage limits'
# Test actual capability classification through stubbed OS filesystem/registry reads.
Set-Item function:Get-WelaTranscriptCapability $originalCapability
$savedOs = $env:OS; $savedWindir = $env:windir
try {
$env:OS = 'Windows_NT'; $env:windir = $root
function Test-Path { param($LiteralPath, $PathType, $ErrorAction) return $true }
$script:version = '5.1.26100.0'; Assert ((Get-WelaTranscriptCapability).Status -eq 'Supported') '5.1 engine recognized from PowerShell 7 host'
$script:version = '4.0'; Assert ((Get-WelaTranscriptCapability).Status -eq 'Unknown') 'older engine is not assumed to support the target policy'
} finally { $env:OS = $savedOs; $env:windir = $savedWindir }
$tokens = $null; $errors = $null
[void][Management.Automation.Language.Parser]::ParseFile((Join-Path $repo 'WELA.ps1'), [ref]$tokens, [ref]$errors)
Assert ($errors.Count -eq 0) 'combined WELA entry point parses'
Write-Host "Passed $script:checks transcription mock assertions. No Windows policy, ACL or share was changed."
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
@@ -0,0 +1,111 @@
param([switch]$AllowDisposablePolicyWrite)
$ErrorActionPreference = 'Stop'
if ($env:OS -ne 'Windows_NT') { Write-Host 'Skipped: native Windows ACL/registry tests require Windows.'; exit 0 }
if ($AllowDisposablePolicyWrite -and ($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted')) {
throw 'Native policy mutation is restricted to this explicitly opted-in disposable GitHub-hosted runner test.'
}
$repo = Split-Path $PSScriptRoot -Parent
$script:ScriptRoot = $repo
. (Join-Path $repo 'scripts/Configuration.ps1')
. (Join-Path $repo 'scripts/PowerShellTranscription.ps1')
$script:checks = 0
function Assert($Condition, [string]$Message) { if (-not $Condition) { throw "FAIL: $Message" }; $script:checks++ }
$root = Join-Path ([IO.Path]::GetTempPath()) ('wela-native-transcription-' + [guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Path $root
$policyTouched = $false; $restored = $true; $before = $null
function Set-PrivateDirectoryAcl([string]$Path) {
$acl = [Security.AccessControl.DirectorySecurity]::new()
$acl.SetAccessRuleProtection($true, $false)
$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User
$acl.SetOwner($sid)
foreach ($identity in @($sid.Value, 'S-1-5-18', 'S-1-5-32-544') | Select-Object -Unique) {
$rule = [Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($identity),
[Security.AccessControl.FileSystemRights]::FullControl, [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit',
[Security.AccessControl.PropagationFlags]::None, [Security.AccessControl.AccessControlType]::Allow)
$acl.AddAccessRule($rule)
}
Set-Acl -LiteralPath $Path -AclObject $acl
}
function Restore-OriginalPolicy($Policy) {
$base = $null; $key = $null
try {
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]([string]$Policy[0].View))
$key = $base.CreateSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription')
# Stop new-session transcription while restoring the previous location.
$key.SetValue('EnableTranscripting', 0, [Microsoft.Win32.RegistryValueKind]::DWord)
foreach ($name in @('OutputDirectory', 'EnableTranscripting')) {
$original = $Policy[0].Machine.$name
if ($original.ValueExists) { $key.SetValue($name, $original.Value, [Microsoft.Win32.RegistryValueKind]([string]$original.Type)) }
else { $key.DeleteValue($name, $false) }
}
$deleteEmptyKey = -not $Policy[0].Machine.EnableTranscripting.KeyExists -and $key.GetValueNames().Count -eq 0 -and $key.GetSubKeyNames().Count -eq 0
$key.Dispose(); $key = $null
if ($deleteEmptyKey) { $base.DeleteSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription', $false) }
} finally { if ($key) { $key.Dispose() }; if ($base) { $base.Dispose() } }
}
try {
Set-PrivateDirectoryAcl $root
$destination = Get-WelaTranscriptDestination $root
Assert ($destination.ConfigureAllowed -and $destination.Status -eq 'Observed') 'actual private directory passes conservative ACL observations'
Assert ($destination.Acl.Sddl -and $destination.WriterAuthorization -eq 'Unknown') 'SDDL is captured without claiming all writers have access'
$unsafe = Join-Path $root 'unsafe-fixture'; $null = New-Item -ItemType Directory -Path $unsafe
$unsafeAcl = Get-Acl -LiteralPath $unsafe
$unsafeAcl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new('S-1-1-0'),
[Security.AccessControl.FileSystemRights]::ReadAndExecute, [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit',
[Security.AccessControl.PropagationFlags]::None, [Security.AccessControl.AccessControlType]::Allow))
Set-Acl -LiteralPath $unsafe -AclObject $unsafeAcl
$blocked = Get-WelaTranscriptDestination $unsafe
Assert (-not $blocked.ConfigureAllowed -and $blocked.Status -eq 'Blocked' -and $blocked.Acl.Risks.Count -gt 0) 'actual broad-read ACL fixture is blocked'
$missing = Get-WelaTranscriptDestination (Join-Path $root 'not-created')
Assert (-not $missing.ConfigureAllowed -and $missing.Status -eq 'Unknown') 'missing destination never auto-created'
$capability = Get-WelaTranscriptCapability
Assert ($capability.Status -eq 'Supported') 'actual native Windows PowerShell 5.1 installation detected'
$before = @(Get-WelaTranscriptPolicy $capability.Views)
Test-WelaTranscriptSharedPolicy $before
Assert ($before.Count -eq 2) 'actual Windows runner exposes shared 64/32 policy views'
if ($AllowDisposablePolicyWrite) {
$output = Join-Path $root 'transcripts'; $null = New-Item -ItemType Directory -Path $output
$before | ConvertTo-Json -Depth 12 | Set-Content (Join-Path $root 'original-policy.json') -Encoding UTF8
$policyTouched = $true; $restored = $false
$report = Invoke-WelaTranscriptCommand -Action Configure -OutputDirectory $output -Auto -BackupPath (Join-Path $root 'backup')
Assert ($report.ExitCode -eq 0 -and $report.Results[0].Status -eq 'Applied') 'actual policy writes and both-view readback succeed'
$again = Invoke-WelaTranscriptCommand -Action Configure -OutputDirectory $output -Auto -BackupPath (Join-Path $root 'repeat-backup')
Assert ($again.ExitCode -eq 0 -and $again.Results[0].Status -eq 'AlreadyCompliant') 'actual repeated policy configuration is idempotent'
$executables = @((Join-Path $env:windir 'System32\WindowsPowerShell\v1.0\powershell.exe'))
$x86 = Join-Path $env:windir 'SysWOW64\WindowsPowerShell\v1.0\powershell.exe'
if (Test-Path -LiteralPath $x86 -PathType Leaf) { $executables += $x86 }
foreach ($executable in $executables) {
$marker = 'WELA-BENIGN-TRANSCRIPT-' + [guid]::NewGuid().ToString('N')
$command = "if (`$PSVersionTable.PSVersion.Major -ne 5 -or `$PSVersionTable.PSVersion.Minor -ne 1) { exit 9 }; Write-Output '$marker'"
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command))
$start = [Diagnostics.ProcessStartInfo]::new()
$start.FileName = $executable; $start.Arguments = '-NoLogo -NoProfile -NonInteractive -EncodedCommand ' + $encoded
$start.UseShellExecute = $false; $start.RedirectStandardOutput = $true; $start.RedirectStandardError = $true
$process = [Diagnostics.Process]::Start($start)
try {
if (-not $process.WaitForExit(30000)) { $process.Kill(); throw 'Benign Windows PowerShell child timed out.' }
$childOutput = $process.StandardOutput.ReadToEnd(); $childError = $process.StandardError.ReadToEnd()
Assert ($process.ExitCode -eq 0 -and $childOutput.Contains($marker)) "benign native 5.1 session succeeded: $executable; $childError"
} finally { $process.Dispose() }
$found = $false
foreach ($file in @(Get-ChildItem -LiteralPath $output -Filter 'PowerShell_transcript*.txt' -Recurse -File)) {
if ((Get-Content -LiteralPath $file.FullName -Raw).Contains($marker)) { $found = $true }
}
Assert $found 'policy-created transcript contains the benign marker without Start-Transcript in the child'
}
Assert ($report.Telemetry.SigmaEvtxCredit -eq 0) 'native text generation still provides no automatic Sigma EVTX credit'
} else { Write-Host 'Native policy mutation skipped. Use the explicit disposable CI switch only on GitHub-hosted runners.' }
} finally {
try {
if ($policyTouched) {
Restore-OriginalPolicy $before
$after = @(Get-WelaTranscriptPolicy @($before.View))
$restored = ($after | ConvertTo-Json -Depth 12 -Compress) -ceq ($before | ConvertTo-Json -Depth 12 -Compress)
Assert $restored 'exact original machine/current-user values, types and policy-key presence restored'
}
} finally {
if ($restored) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction Stop }
else { Write-Host "Policy restoration was not verified. Private evidence directory retained: $root" -ForegroundColor Red }
}
}
Write-Host "Passed $script:checks Windows transcription assertions. Native policy was restored; fixtures were removed. UNC authorization and collection remain lab checks."
+1
View File
@@ -8,6 +8,7 @@
**改善:**
- Windows標準のドメイン/Kerberos環境向けに、送信元設定と明示的に選択した収集サーバーのサブスクリプションを監査・計画・設定する任意実行の`wef-source`と`wec-collector`を追加しました。実際の収集先ID、既存リスナーと範囲を限定した受信規則、送信元SID、メンバーホストでの読み取り権限の追加、明示的なASDのWSMan強化設定と共通チャネル設定を、復旧記録・変更検出・読み戻しで確認します。DCのグループ管理権限と異なる既存サブスクリプションは変更しません。JSONにはクエリ・チャネル・実行状態の証拠を保持し、実効読み取り権限・イベント到着・転送後のSigma検知範囲は未検証と表示します。隔離Windows環境での配備検証は別途必要です。 (#406) (@Shirofune-Security)
- CIS v4.0.0 Level 2向けに、Windows PowerShell 5.1トランスクリプトの明示的な監査・計画・設定機能を追加しました。管理者が選択した既存の出力ディレクトリを確認し、ポリシーとは分けて報告します。型を含むレジストリ変更前の状態を保存し、共有される32/64ビットのビューと変更後の状態を検証します。呼び出しヘッダーの設定は保持し、ACL・共有・保存期間は変更せず、Sigma EVTX検知範囲の向上も自動加算しません。使い捨て環境の実トランスクリプトテストでは元のポリシーを復元します。中央保存先の権限と収集は別途検証が必要です。 (#405) (@Shirofune-Security)
- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。任意のdMSA前提条件が不明な場合は未確認のスキップ項目として報告し、独立した他の5種類のドメインクラスの監査ACEは引き続き設定します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security)
- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (#401) (@Shirofune-Security)
+1
View File
@@ -8,6 +8,7 @@
**Improvements:**
- Added separate opt-in `wef-source` and `wec-collector` audit, plan and configure commands for native domain/Kerberos source settings and explicitly selected collector subscriptions. Actual collector identity, scoped existing ingress/listener prerequisites, explicit source SIDs, additive member-host read permissions, optional ASD WSMan hardening and shared channel controls are checked with journals, drift guards and readback. DC group authority and different existing subscriptions are preserved. JSON retains query/channel/runtime evidence without claiming effective read access, event arrival or forwarded Sigma coverage; isolated Windows deployment validation remains pending. (#406) (@Shirofune-Security)
- Added explicit CIS v4.0.0 Level 2 Windows PowerShell 5.1 transcription audit, plan and configure actions. An operator-selected existing output directory is checked and reported separately from policy; typed canonical registry writes are journaled, verified through shared 32/64-bit views and checked for drift while preserving invocation-header preferences. No ACL/share/retention changes or automatic Sigma EVTX credit are introduced; disposable native transcript tests restore original policy, and central authorization/collection remains a deployment check. (#405) (@Shirofune-Security)
- Added opt-in `ad-object-sacl` audit, plan, configure and conservative rollback actions for MDI domain/Exchange Configuration auditing and explicitly selected certificate template/enrollment service objects. Exact DC binding, schema GUID checks, additive SACL-only changes, pre-write SDDL/ACE receipts and read-back preserve existing security entries. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security)
- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security)