Merge pull request #436 from Shirofune-Security/feat/376-current-token-transcript

Verify current-account automatic PowerShell transcripts
This commit is contained in:
田中ザック Isaac Mathis authored and GitHub committed 2026-09-22 15:14:12 +09:00
commit 4eabfe329c
15 files changed
+627 -8

No files matched your search

+4 -7
View File
@@ -67,6 +67,10 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf
/tests/EvtxRecovery*.ps1 text eol=lf
/tests/fixtures/EvtxReader*.ps1 text eol=lf
/scripts/TranscriptProbe* text eol=lf
/scripts/PowerShellTranscription.ps1 text eol=lf
/scripts/WefArrival.ps1 text eol=lf
/tests/TranscriptProbe*.ps1 text eol=lf
# Named registry recovery binds implementation bytes across checkouts.
/scripts/NamedRegistryRecovery* text eol=lf
/scripts/AuditRecovery.ps1 text eol=lf
@@ -75,20 +79,16 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf
/tests/Capi2Probe* text eol=lf
/scripts/CustomAuditProfiles.ps1 text eol=lf
# Reviewed WEC state plans bind native setter and runtime source bytes.
/scripts/WecState* text eol=lf
/scripts/WecRuntime* text eol=lf
/tests/WecState* text eol=lf
/scripts/WecListener* text eol=lf
# Existing-file read receipts bind identical native/worker source bytes.
/scripts/FileAccessProbe* text eol=lf
/tests/FileAccessProbe* text eol=lf
# Disposable native provider configuration fixture
tests/NativeProviderConfigure.Windows.Tests.ps1 text eol=lf
# Disposable public registry lifecycle fixture bytes are retained in evidence.
/tests/RegistrySacl* text eol=lf
/scripts/WecAuthorization* text eol=lf
@@ -96,19 +96,16 @@ tests/NativeProviderConfigure.Windows.Tests.ps1 text eol=lf
# Reviewed channel restoration binds exact installed source bytes.
/scripts/ChannelRecovery.ps1 text eol=lf
/tests/ChannelRecovery*.ps1 text eol=lf
# Registry recovery plans bind identical source bytes across native hosts.
/scripts/RegistrySaclRecovery* text eol=lf
/tests/RegistrySaclRecovery* text eol=lf
/scripts/SelectedSaclDescendants.ps1 text eol=lf
/scripts/AuditRecovery.ps1 text eol=lf
/scripts/EvtxRecovery.ps1 text eol=lf
# Collector inventory reads native UTF16 names and bounded Unicode XML.
/modules/WecSubscriptionInventory.cs text eol=lf
/tests/WecCollectorObservation* text eol=lf
/tests/WecSubscriptionInventory* text eol=lf
/tests/TokenRightAttribution*.ps1 text eol=lf
/tests/TokenRightAttribution*.cs text eol=lf
# Disposable public OneSettings fixture source identity.
+1 -1
View File
@@ -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/file-sacl-recovery.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.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/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/native-filesystem-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./docs/dns-client-probe.md, ./docs/audit-catalog-mappings.md, ./docs/native-token-right-attribution.md, ./docs/audit-notifications.md, ./docs/native-onesettings-acceptance.md, ./docs/ntlm-auditing.md, ./docs/wef-query.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/transcript-probe.md, ./docs/powershell-transcription.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.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/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./docs/audit-catalog-mappings.md, ./docs/native-token-right-attribution.md, ./docs/audit-notifications.md, ./docs/native-onesettings-acceptance.md, ./docs/ntlm-auditing.md, ./docs/wef-query.md, ./docs/native-filesystem-sacl-validation.md, ./docs/file-sacl-recovery.md, ./docs/dns-client-probe.md -Destination release-binaries/docs/
- name: Set Artifact Name
if: contains(matrix.info.os, 'windows') == true
+47
View File
@@ -0,0 +1,47 @@
name: Native automatic transcription probe
on:
push:
paths: ['WELA.ps1', 'scripts/TranscriptProbe*', 'scripts/PowerShellTranscription.ps1', 'scripts/WmiProbe*', 'scripts/ChannelRead.ps1', 'scripts/WefArrival.ps1', 'tests/TranscriptProbe*', '.github/workflows/transcript-probe.yml']
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
actual-writer:
strategy:
fail-fast: false
matrix:
os: [windows-2022, windows-2025]
engine: [powershell, pwsh]
runs-on: ${{ matrix.os }}
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Correlation and refusal fixtures (Windows PowerShell5.1)
if: matrix.engine == 'powershell'
shell: powershell
run: ./tests/TranscriptProbe.Tests.ps1
- name: Actual standard writer, denial and restoration (Windows PowerShell5.1 host)
if: matrix.engine == 'powershell'
shell: powershell
run: ./tests/TranscriptProbe.Windows.Tests.ps1 -AllowDisposableWriter -TestEngine powershell
- name: Correlation and refusal fixtures (PowerShell7)
if: matrix.engine == 'pwsh'
shell: pwsh
run: ./tests/TranscriptProbe.Tests.ps1
- name: Actual standard writer, denial and restoration (PowerShell7 host)
if: matrix.engine == 'pwsh'
shell: pwsh
run: ./tests/TranscriptProbe.Windows.Tests.ps1 -AllowDisposableWriter -TestEngine pwsh
- name: Retain native probe and cleanup evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: transcript-probe-${{ matrix.os }}-${{ matrix.engine }}
path: |
${{ runner.temp }}/wela-transcript-probe-*/acceptance.json
${{ runner.temp }}/wela-transcript-probe-*/policy-before.json
${{ runner.temp }}/wela-transcript-probe-*/writer/
${{ runner.temp }}/wela-transcript-probe-*/transcripts/
if-no-files-found: warn
retention-days: 7
+2
View File
@@ -6,6 +6,8 @@
**改善:**
- 既に有効なローカル保存先で、現在の実行ユーザーによる固定 Windows PowerShell 5.1 子プロセスの完了した自動トランスクリプトを検証する `transcript-probe` を追加しました。実トークン・ログオン、標準ヘッダー/フッター、nonce・PID・時刻、件数を制限したファイル識別と出典確認により不明な結果を保持し、ポリシーやACLの変更・明示的トランスクリプト開始・Sigma/EVTX加算は行いません。Server 2022/2025と両WELA実行エンジン向けに、使い捨て標準ユーザーの成功・拒否テストを追加しました。 (#436) (@Shirofune-Security)
- `file-sacl-recovery` を追加しました。元の計画、変更前後のレシート、成功結果、ソースとホストのハッシュ、実際のファイル識別情報を照合し、変更されていない選択済み単一ファイルに追加した監査 ACE だけを明示的に削除できます。書き込み前の永続記録と SACL 限定の変更・再読込により、無関係な ACE のバイト列と個数、観測した他の記述子要素を保持します。空または null の SACL が存在フラグ付きで残る場合を区別し、元の記述子との完全一致、ポリシー・ディレクトリ・レジストリの復旧、Sigma の利用可能性は保証しません。(#437) (@Shirofune-Security)
- 明示した IPv4 リゾルバーに固定の無害な `wela-<nonce>.wela.test.` A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (#434) (@Shirofune-Security)
+2
View File
@@ -6,6 +6,8 @@
**Improvements:**
- Added opt-in `transcript-probe` to verify a fixed current-account Windows PowerShell 5.1 child produces its own completed automatic transcript in an already enabled local destination. Actual token/logon, native header/footer, nonce/PID/time, bounded file identity and source checks preserve unverified outcomes without changing policy or ACLs, invoking explicit transcription or granting Sigma/EVTX credit. Added disposable standard-writer success/denial tests for Server 2022/2025 under both WELA host engines. (#436) (@Shirofune-Security)
- Added opt-in `file-sacl-recovery` to review and remove one proven explicit audit ACE from an unchanged selected leaf file. Original plans, paired receipts, successful results, source/host hashes and held file identity bind the operation; durable pre-write evidence and SACL-only readback preserve unrelated ACE bytes/counts and observed descriptor components. Empty or null present SACLs are reported without claiming exact historical descriptor restoration; policy, directory/registry recovery and Sigma readiness remain outside this scope. (#437) (@Shirofune-Security)
- Added opt-in `dns-client-probe` for one fixed benign `wela-<nonce>.wela.test.` A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (#434) (@Shirofune-Security)
+14
View File
@@ -83,6 +83,9 @@
[string]$RuleManifestPath,
[ValidateSet('Audit', 'Plan', 'Configure')][string]$TranscriptionAction = 'Audit',
[string]$TranscriptDirectory,
[ValidateSet('Plan','Run')][string]$TranscriptProbeAction = 'Plan',
[string]$TranscriptProbeDirectory,
[string]$TranscriptProbeOutputPath,
[ValidateSet('Audit','Plan','Configure')][string]$LdapAction = 'Audit',
[ValidateSet('Preserve','Diagnostic','MdiCleanup')][string]$LdapMode = 'Preserve',
[ValidateRange(1,2147483647)][int]$LdapSearchTimeMs,
@@ -279,6 +282,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json"
. (Join-Path $ScriptRoot "scripts/Capi2Probe.ps1")
. (Join-Path $ScriptRoot "scripts/FailedLogonProbe.ps1")
. (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1")
. (Join-Path $ScriptRoot "scripts/TranscriptProbe.ps1")
Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/RuleEligibility.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/AuditCatalog.psm1") -ErrorAction Stop
@@ -2062,6 +2066,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 transcript-probe -Help # Verify one automatic native5.1 transcript under the actual identity
./WELA.ps1 file-access-probe -Help
./WELA.ps1 transcription-recovery -Help
./WELA.ps1 powershell-transcription -TranscriptionAction Plan -TranscriptDirectory C:\Transcripts -ResultsPath transcription-plan.json
@@ -2125,6 +2130,9 @@ Write-Host ""
Write-Host "WELA v$WELAVersion - $WELAReleaseName"
Write-Host ""
if ($Cmd -ne 'transcript-probe' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'TranscriptProbe*' }).Count) { throw 'TranscriptProbe options require transcript-probe. No command was run.' }
if ($Cmd -eq 'transcript-probe' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','TranscriptProbeAction','TranscriptProbeDirectory','TranscriptProbeOutputPath','Help') }).Count) { throw 'transcript-probe accepts only dedicated action/directory/output options. No command was run.' }
if ($Cmd -ne 'firewall-recovery' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'FirewallRecovery*' }).Count) {throw 'FirewallRecovery options require firewall-recovery. No command was run.'}
if ($Cmd -eq 'firewall-recovery' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','FirewallRecoveryAction','FirewallRecoveryProfile','FirewallRecoveryJournalPath','FirewallRecoveryResultsPath','FirewallRecoveryPlanPath','FirewallRecoveryPlanHash','FirewallRecoveryOutputPath','Auto','DryRun','Help') }).Count)) {throw 'firewall-recovery accepts only dedicated options, Auto and DryRun. No command was run.'}
if ($Cmd -ne 'channel-read' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'ChannelRead*' }).Count) { throw 'ChannelRead options require channel-read. No command was run.' }
@@ -2776,6 +2784,12 @@ switch ($Cmd.ToLower()) {
if ($report.ExitCode) { exit $report.ExitCode }
} catch { Write-Host "[Failed] SMB auditing: $_" -ForegroundColor Red; exit 1 }
}
'transcript-probe' {
if ($Help) { Write-Host 'Usage: ./WELA.ps1 transcript-probe [-TranscriptProbeAction Plan|Run] -TranscriptProbeDirectory existing-local-policy-directory [-TranscriptProbeOutputPath new-private-directory]. Run starts one fixed native5.1 child using existing automatic transcription policy; no policy or destination changes. See docs/transcript-probe.md.'; return }
$report=Invoke-WelaTranscriptProbe -Action $TranscriptProbeAction -Directory $TranscriptProbeDirectory -OutputPath $TranscriptProbeOutputPath
$report | Select-Object Action,Status,WriterAuthorization,Diagnostic,OutputPath | Format-List | Out-Host
if ($report.ExitCode) { exit $report.ExitCode }
}
'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]'
+2
View File
@@ -73,3 +73,5 @@ The mock suite covers typed values, shared views, idempotence, ordering, destina
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).
For a fixed child under the actual current account, see the optional [automatic transcription probe](transcript-probe.md). It verifies completed local automatic output without changing policy or granting EVTX/Sigma credit.
+55
View File
@@ -0,0 +1,55 @@
# Automatic Windows PowerShell transcription probe
`transcript-probe` checks whether one fixed Windows PowerShell 5.1 child, launched as the current WELA account, produces its own completed **automatic** transcript in an already configured local destination. It does not change policy, ACLs, services or shares. It never calls `Start-Transcript` as a fallback. Transcripts are not EVTX, and the report always grants zero Sigma/EVTX credit.
This is the current-account local-writer acceptance portion of #376. The existing [transcription audit/configure command](powershell-transcription.md) remains separate. UNC/share acceptance, remote collection, retention and testing other writer accounts remain separate work.
## Commands
Run from native 64-bit Windows PowerShell 5.1 or PowerShell 7 on a supported Windows 11, Server 2022 or Server 2025 host. The child being tested is always native Windows PowerShell 5.1; running WELA in PowerShell 7 does not test PowerShell 7 transcription.
```powershell
# Default Plan: inspect the selected destination and prerequisites.
.\WELA.ps1 transcript-probe -TranscriptProbeDirectory C:\Transcripts
# Explicit Run: one fixed child, then verify its completed automatic transcript.
.\WELA.ps1 transcript-probe -TranscriptProbeAction Run `
-TranscriptProbeDirectory C:\Transcripts `
-TranscriptProbeOutputPath C:\Evidence\transcript-unique-run
```
The output directory must be new, have an existing parent, and be outside the transcript destination. WELA creates it with access for the current account, SYSTEM and Administrators. `Plan` launches no probe child and creates no explicit evidence directory. An already enabled transcription policy can naturally transcribe the WELA invocation itself, including a Plan invocation.
An enabled machine `EnableTranscripting` DWORD policy and an explicit literal `OutputDirectory` string must already exist and match the selected local fixed-drive directory. Both shared registry views must agree. Current-user policy and invocation-header settings are also recorded and checked for known types. This initial command does not infer default destinations or accept a current-user-only policy. Winmgmt must already be running for read-only host observations.
The account needs directory/date-folder metadata and listing access, plus read access to the new transcript. A write-only drop-box destination may accept automatic transcripts but cannot be proven by this verifier. Permission failures remain unverified; WELA does not broaden access to obtain proof.
## What a successful result means
`Status: CompletedAutomaticTranscript` and `WriterAuthorization: ObservedForThisChild` mean the local verifier observed exactly one fresh matching completed transcript from the fixed child during this run. The evidence binds the following:
- The actual child PID, executable, PowerShell 5.1 Desktop version, command arguments and loaded engine assembly hash.
- Actual current-account SID, logon authentication ID and group attributes in the parent and child. Full before/after token observations are retained within each process; cross-process matching uses the authorization identity and groups because process startup can change privilege flags.
- Unique standalone begin/end output lines, the native engine's localized header/footer resource templates, header identity/PID/host command, and header/footer timestamps within the observed launch/exit window.
- Existing policy, executable/source hashes, host and time-zone observations, plus handle-based destination/date-folder identity and owner/group/DACL observations before and after the operation.
- Bounded raw matching transcript bytes, SHA-256 hashes and a matching file handle retained through final checks. Reparse points, multi-link files and identity/descriptor drift are refused.
The fixed child uses `-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File` with WELA's bundled worker and a generated nonce. The execution-policy option is local to that process; it does not change stored policy or override enforced Group Policy. No arbitrary command, credential or alternative executable can be supplied to this command.
Output preparation and initial observations precede the parent token interval. The measured interval covers the worker and transcript verification; the report retains both parent token snapshots. The child records its own before/after interval. Token differences, ambiguous transcripts, incomplete output, unexpected formats or context drift fail verification. A completed transcript proves this observed operation, not continuing authorization, other users' access, remote share acceptance, reliable collection or application of every baseline recommendation.
This is local consistency evidence, not tamper-proof attestation against another process controlled by the same account or an administrator. The fixed nonce, PID, command and time checks provide correlation; they do not establish exclusive writer attribution against a malicious local actor.
## Bounds and artifacts
The inventory covers only the previous, current and next local calendar-date folders, with at most 256 entries in total. Existing transcript contents are never read. The verifier considers at most 32 new file identities, reads at most 1 MiB per candidate and 4 MiB in total, and accepts exactly one matching transcript. Unexpected directories, names, encodings or candidate times remain unverified. Busy destinations can exceed these conservative bounds.
The worker has a 30-second deadline. Each redirected output stream retains at most 65,536 characters, with bounded pipe-drain and termination waits. The report includes explicit diagnostics for refusal and incomplete evidence; no fallback obtains a positive result.
A completed Run writes `result.json`, `worker.json` and the exact matching `transcript.txt` bytes into the protected output. The result also retains bounded fixed-worker stdout/stderr and launch/exit observations, including when worker validation fails. Failed runs that reached output preparation keep diagnostic artifacts and exit nonzero. Prerequisite failures can occur before an output directory exists. Source transcripts are retained in their configured destination; WELA never removes them.
## Validation
Portable fixtures exercise the actual native-format matcher, identity/time/nonce/version refusals, localization templates, encoding limits, policy types, drift and dedicated CLI guards. The opt-in hosted Windows fixture provisions only its own standard account and destination, enables a temporary machine transcription policy, and invokes the public command in fresh processes. It tests an allowed writer and then a denied writer, preserves both original typed policy views, restores the original destination ACL and removes only the owned account. This fixture is gated to disposable standalone GitHub-hosted Server 2022/2025 machines and both WELA host engines. It never substitutes an explicit transcript for automatic policy output.
Microsoft documents automatic policy transcription and machine-policy precedence in [Turn on PowerShell Transcription](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 verifier reads the actual installed engine's transcript resource templates rather than assuming an English header.
+214
View File
@@ -0,0 +1,214 @@
# Fixed native automatic-transcription evidence; never provisions a destination or changes policy.
function Initialize-WelaTranscriptProbe {
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'transcript-probe requires native 64-bit Windows.'}
$bytes=[IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'TranscriptProbeNative.cs'));$hash=Get-WelaArrivalHash $bytes
if(-not ('Wela.TranscriptProbe.Item' -as [type])){Add-Type -TypeDefinition ([Text.UTF8Encoding]::new($false,$true).GetString($bytes).Replace('__WELA_TRANSCRIPT_SOURCE_SHA256__',$hash)) -ErrorAction Stop}
if([Wela.TranscriptProbe.Item]::SourceSha256 -cne $hash){throw 'Loaded transcript helper differs from source; start a fresh PowerShell process.'}
Initialize-WelaWmiProbeNative
}
function Get-WelaTranscriptProbeKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 20 -Compress}
function Get-WelaTranscriptProbeSources {
$result=[ordered]@{}
foreach($name in @('WELA.ps1','scripts/TranscriptProbe.ps1','scripts/TranscriptProbeWorker.ps1','scripts/TranscriptProbeNative.cs','scripts/PowerShellTranscription.ps1','scripts/WmiProbe.ps1','scripts/WmiProbeNative.cs','scripts/ChannelRead.ps1','scripts/WefArrival.ps1','modules/AuditProfiles.psm1')){$result[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
[pscustomobject]$result
}
function Get-WelaTranscriptProbeObjectKey {
param($Observation,[switch]$Directory)
$value=[ordered]@{Path=$Observation.Path;Identity=$Observation.Identity;CreatedUtc=$Observation.CreatedUtc;Attributes=$Observation.Attributes;Descriptor=$Observation.Descriptor}
if(-not $Directory){$value.Length=$Observation.Length;$value.WrittenUtc=$Observation.WrittenUtc;$value.Links=$Observation.Links}
Get-WelaTranscriptProbeKey ([pscustomobject]$value)
}
function Assert-WelaTranscriptProbePolicy {
param([array]$Policy,[string]$Directory)
Test-WelaTranscriptSharedPolicy $Policy
if($Policy.Count -ne 2 -or $Policy[0].View -cne 'Registry64' -or $Policy[1].View -cne 'Registry32'){throw 'Both canonical shared policy views are required.'}
foreach($view in $Policy){
$machine=$view.Machine
if(-not $machine.EnableTranscripting.ValueExists -or $machine.EnableTranscripting.Type -cne 'DWord' -or $machine.EnableTranscripting.Value -ne 1 -or -not $machine.OutputDirectory.ValueExists -or $machine.OutputDirectory.Type -cne 'String' -or $machine.OutputDirectory.Value -isnot [string]){throw 'An already enabled machine transcription policy with explicit literal output is required.'}
$path=Resolve-WelaArrivalPath $machine.OutputDirectory.Value
if(-not $path.Equals($Directory,[StringComparison]::OrdinalIgnoreCase)){throw 'Selected destination does not match the current machine transcription policy.'}
foreach($hive in @('Machine','CurrentUser')){
foreach($name in @('EnableTranscripting','EnableInvocationHeader')){$value=$view.$hive.$name;if($value.ValueExists -and ($value.Type -cne 'DWord' -or $value.Value -notin @(0,1))){throw 'Unknown typed transcription policy value.'}}
$value=$view.$hive.OutputDirectory;if($value.ValueExists -and ($value.Type -cne 'String' -or $value.Value -isnot [string])){throw 'Unknown transcription destination value type.'}
}
}
}
function Get-WelaTranscriptProbeState {
param([string]$Directory,$Handle)
if((Get-Service Winmgmt -ErrorAction Stop).Status -ne 'Running'){throw 'Winmgmt must already be running for host observations; no service is started.'}
$capability=Get-WelaTranscriptCapability;if($capability.Status -cne 'Supported'){throw $capability.Diagnostic}
$engine=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::Windows)) 'System32\WindowsPowerShell\v1.0\powershell.exe'
$engine=Resolve-WelaArrivalPath $engine
$policy=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'));Assert-WelaTranscriptProbePolicy $policy $Directory
[pscustomobject][ordered]@{Host=(Get-WelaChannelReadHost);Engine=$engine;EngineHash=(Get-FileHash -LiteralPath $engine -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant();InstalledVersion=$capability.EngineVersion;Policy=$policy;Directory=$Handle.Snapshot();TimeZone=[TimeZoneInfo]::Local.Id;OffsetMinutes=[DateTimeOffset]::Now.Offset.TotalMinutes;Sources=(Get-WelaTranscriptProbeSources)}
}
function Get-WelaTranscriptProbeStateKey {
param($State)
Get-WelaTranscriptProbeKey ([pscustomobject][ordered]@{Host=$State.Host;Engine=$State.Engine;EngineHash=$State.EngineHash;InstalledVersion=$State.InstalledVersion;Policy=$State.Policy;Directory=(Get-WelaTranscriptProbeObjectKey $State.Directory -Directory);TimeZone=$State.TimeZone;OffsetMinutes=$State.OffsetMinutes;Sources=$State.Sources})
}
function Get-WelaTranscriptProbeInventory {
param([string]$Directory,[string[]]$Dates)
$folders=@();$files=@()
foreach($date in $Dates){
if($date -cnotmatch '^\d{8}$'){throw 'Invalid bounded transcript date scope.'}
$path=Join-Path $Directory $date
if(-not [IO.Directory]::Exists($path)){if(Test-Path -LiteralPath $path){throw 'Expected date folder is not a directory.'};$folders+=[pscustomobject]@{Date=$date;Exists=$false;Observation=$null};continue}
$null=Resolve-WelaArrivalPath $path;$handle=[Wela.TranscriptProbe.Item]::Directory($path)
try{
$observation=$handle.Snapshot();$folders+=[pscustomobject]@{Date=$date;Exists=$true;Observation=$observation}
foreach($entry in [IO.Directory]::EnumerateFileSystemEntries($path)){
if($files.Count -ge 256){throw 'Current-date inventory reached its 256-entry limit.'}
$item=Get-Item -LiteralPath $entry -Force -ErrorAction Stop
if($item -isnot [IO.FileInfo] -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)){throw 'Unexpected directory or reparse entry in the current-date scope.'}
$file=[Wela.TranscriptProbe.Item]::Metadata($entry)
try{$files+=$file.Snapshot()}finally{$file.Dispose()}
}
if((Get-WelaTranscriptProbeObjectKey $handle.Snapshot() -Directory) -cne (Get-WelaTranscriptProbeObjectKey $observation -Directory)){throw 'Date-directory identity or descriptor changed during enumeration.'}
}finally{$handle.Dispose()}
}
[pscustomobject]@{Dates=$Dates;Folders=$folders;Files=@($files|Sort-Object Path)}
}
function Assert-WelaTranscriptProbeInventory {
param($Before,$After)
foreach($folder in $Before.Folders|Where-Object Exists){
$match=@($After.Folders|Where-Object Date -eq $folder.Date)
if($match.Count -ne 1 -or -not $match[0].Exists -or (Get-WelaTranscriptProbeObjectKey $folder.Observation -Directory) -cne (Get-WelaTranscriptProbeObjectKey $match[0].Observation -Directory)){throw 'An existing date directory changed or disappeared.'}
}
foreach($file in $Before.Files){
$match=@($After.Files|Where-Object Path -eq $file.Path)
# Existing sessions can append to their transcripts. Their bytes are never read.
if($match.Count -ne 1 -or $match[0].Identity -cne $file.Identity -or $match[0].CreatedUtc -cne $file.CreatedUtc -or $match[0].Descriptor -cne $file.Descriptor){throw 'An existing transcript was replaced, removed or had its descriptor changed.'}
}
}
function Start-WelaTranscriptProbeWorker {
param($State,[string]$Nonce,$ParentToken,$LaunchEvidence)
$worker=Join-Path $PSScriptRoot 'TranscriptProbeWorker.ps1'
$arguments=@('-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',$worker,'-Nonce',$Nonce)
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Engine
$info.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$worker+'" -Nonce '+$Nonce
$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true
$info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false,$true);$info.StandardErrorEncoding=[Text.UTF8Encoding]::new($false,$true)
$process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false;$launched=[DateTime]::UtcNow
try{
if(-not $process.Start()){throw 'Fixed transcript worker did not start.'};$started=$true
$LaunchEvidence.ProcessId=$process.Id;$LaunchEvidence.LaunchedUtc=$launched.ToString('o')
$stdout=[Wela.TranscriptProbe.Item]::Drain($process.StandardOutput,65536);$stderr=[Wela.TranscriptProbe.Item]::Drain($process.StandardError,65536)
if(-not $process.WaitForExit(30000)){throw 'Fixed transcript worker exceeded thirty seconds.'}
$exited=[DateTime]::UtcNow;$LaunchEvidence.ExitedUtc=$exited.ToString('o');$LaunchEvidence.ExitCode=$process.ExitCode
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),3000)){throw 'Worker output pipes did not close within their bound.'}
$LaunchEvidence.Stdout=$stdout.Result;$LaunchEvidence.Stderr=$stderr.Result
if($stdout.Result.Exceeded -or $stderr.Result.Exceeded -or $stdout.Result.Error -or $stderr.Result.Error){throw 'Worker output is oversized or incomplete.'}
if($process.ExitCode -ne 0 -or $stderr.Result.Text){throw ('Fixed native5.1 worker failed; exit '+$process.ExitCode+'. No transcript fallback was attempted.')}
$lines=@(($stdout.Result.Text -replace "`r`n","`n").TrimEnd("`r","`n") -split "`n")
$json=@($lines|Where-Object{$_ -clike 'WELA-WORKER-JSON:*'})
if($lines.Count -ne 3 -or $json.Count -ne 1){throw 'Unexpected worker output framing.'}
$operation=ConvertFrom-WelaArrivalJson $json[0].Substring('WELA-WORKER-JSON:'.Length)
if($operation.Nonce -cne $Nonce -or $operation.ProcessId -ne $process.Id -or $operation.Engine -ine $State.Engine -or $operation.Edition -cne 'Desktop' -or $operation.EngineVersion -cnotmatch '^5\.1\.\d+\.\d+$'){throw 'Fixed worker engine/identity response differs.'}
$actualArgs=@($operation.Arguments|Select-Object -Skip 1)
if((Get-WelaTranscriptProbeKey $actualArgs) -cne (Get-WelaTranscriptProbeKey $arguments) -or $operation.Arguments[0] -ine $State.Engine -or $operation.HeaderCommandLine -cne ($operation.Arguments -join ' ')){throw 'Worker command arguments differ from the fixed launch.'}
if(@($lines|Where-Object{$_ -ceq ('WELA-TRANSCRIPT-BEGIN:'+${Nonce}+':'+$process.Id)}).Count -ne 1 -or @($lines|Where-Object{$_ -ceq ('WELA-TRANSCRIPT-END:'+${Nonce}+':'+$process.Id)}).Count -ne 1){throw 'Fixed worker output markers are missing or ambiguous.'}
if((Get-WelaWmiProbeTokenKey $operation.BeforeToken -AuthorizationOnly) -cne (Get-WelaWmiProbeTokenKey $ParentToken -AuthorizationOnly) -or (Get-WelaWmiProbeTokenKey $operation.BeforeToken) -cne (Get-WelaWmiProbeTokenKey $operation.AfterToken)){throw 'Worker identity/logon/group attributes differ from the parent or changed during output.'}
if((Get-WelaTranscriptProbeKey $operation.PolicyBefore) -cne (Get-WelaTranscriptProbeKey $State.Policy) -or (Get-WelaTranscriptProbeKey $operation.PolicyAfter) -cne (Get-WelaTranscriptProbeKey $State.Policy)){throw 'Native worker policy differs from the observed policy.'}
$begin=ConvertTo-WelaArrivalUtc $operation.StartedUtc;$end=ConvertTo-WelaArrivalUtc $operation.CompletedUtc
if($begin -lt $launched -or $end -lt $begin -or $end -gt $exited -or $operation.StartOffsetMinutes -ne $State.OffsetMinutes -or $operation.EndOffsetMinutes -ne $State.OffsetMinutes -or $operation.Computer -ine $State.Host.Computer -or $operation.HeaderUser -ine $operation.BeforeToken.Name){throw 'Worker time, time-zone or host context differs.'}
$assembly=Resolve-WelaArrivalPath $operation.Assembly.Path
$windows=[Environment]::GetFolderPath([Environment+SpecialFolder]::Windows).TrimEnd('\')+'\'
if(-not $assembly.StartsWith($windows,[StringComparison]::OrdinalIgnoreCase) -or [IO.Path]::GetFileName($assembly) -ine 'System.Management.Automation.dll' -or $operation.Assembly.FullName -cnotlike 'System.Management.Automation, Version=3.0.0.0,*' -or (Get-FileHash -LiteralPath $assembly -Algorithm SHA256).Hash.ToLowerInvariant() -cne $operation.Assembly.Sha256){throw 'Native worker assembly evidence differs.'}
$operation|Add-Member NoteProperty LaunchedUtc $launched.ToString('o');$operation|Add-Member NoteProperty ExitedUtc $exited.ToString('o')
$operation
}finally{try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(3000)){throw 'Fixed worker termination was not confirmed.'}}}finally{$process.Dispose()}}
}
function ConvertFrom-WelaTranscriptProbeBytes {
param([byte[]]$Bytes)
$offset=0;$encoding=[Text.UTF8Encoding]::new($false,$true)
if($Bytes.Length -ge 3 -and $Bytes[0] -eq 239 -and $Bytes[1] -eq 187 -and $Bytes[2] -eq 191){$offset=3}
elseif($Bytes.Length -ge 2 -and $Bytes[0] -eq 255 -and $Bytes[1] -eq 254){$offset=2;$encoding=[Text.UnicodeEncoding]::new($false,$true,$true)}
elseif($Bytes.Length -ge 2 -and $Bytes[0] -eq 254 -and $Bytes[1] -eq 255){$offset=2;$encoding=[Text.UnicodeEncoding]::new($true,$true,$true)}
$text=$encoding.GetString($Bytes,$offset,$Bytes.Length-$offset)
if($text.Contains([string][char]0)){throw 'Transcript contains embedded NUL characters.'}
$text -replace "`r`n","`n"
}
function Test-WelaTranscriptProbeText {
param([string]$Text,$Operation)
$header=($Operation.Resources.TranscriptPrologue -replace "`r`n","`n").TrimEnd("`r","`n")
$footer=($Operation.Resources.TranscriptEpilogue -replace "`r`n","`n").TrimEnd("`r","`n")
if(-not $header -or -not $footer -or $header.Length -gt 8192 -or $footer.Length -gt 8192){throw 'Unknown native transcript resource templates.'}
$pattern=[regex]::Escape($header);$tail=[regex]::Escape($footer)
$fields=[ordered]@{'{0:yyyyMMddHHmmss}'='(?<Start>\d{14})';'{1}'='(?<User>[^\n]{1,512})';'{2}'='(?<RunAs>[^\n]{1,512})';'{3}'='(?<Configuration>[^\n]{0,512})';'{4}'='(?<Machine>[^\n]{1,255})';'{5}'='(?<OS>[^\n]{1,512})';'{6}'='(?<Command>[^\n]{1,4096})';'{7}'='(?<Pid>\d{1,10})';'{8}'='(?<Versions>[\s\S]{1,8192}?)'}
foreach($key in $fields.Keys){$escaped=[regex]::Escape($key);if(-not $pattern.Contains($escaped)){throw 'Unrecognized native transcript prologue schema.'};$pattern=$pattern.Replace($escaped,$fields[$key])}
$tail=$tail.Replace([regex]::Escape('{0:yyyyMMddHHmmss}'),'(?<End>\d{14})')
$match=[regex]::Match($Text,'\A'+$pattern+'\n(?<Body>[\s\S]*?)\n'+$tail+'\n*\z',[Text.RegularExpressions.RegexOptions]::CultureInvariant,[TimeSpan]::FromSeconds(1))
if(-not $match.Success){return $false}
foreach($template in @($header,$footer)){$prefix=(@($template -split "`n"|Select-Object -First 2) -join "`n");if([regex]::Matches($Text,[regex]::Escape($prefix)).Count -ne 1){return $false}}
if($match.Groups['User'].Value -ine $Operation.HeaderUser -or $match.Groups['RunAs'].Value -ine $Operation.BeforeToken.Name -or $match.Groups['Configuration'].Value -cne '' -or $match.Groups['Machine'].Value -ine $Operation.Computer -or $match.Groups['OS'].Value -cne $Operation.OsVersion -or $match.Groups['Command'].Value -cne $Operation.HeaderCommandLine -or [long]$match.Groups['Pid'].Value -ne $Operation.ProcessId){return $false}
$versions=@($match.Groups['Versions'].Value -split "`n")
if(@($versions|Where-Object{$_ -ceq ('PSVersion: '+$Operation.EngineVersion)}).Count -ne 1 -or @($versions|Where-Object{$_ -ceq 'PSEdition: Desktop'}).Count -ne 1){return $false}
$body=@($match.Groups['Body'].Value -split "`n");$begin='WELA-TRANSCRIPT-BEGIN:'+$Operation.Nonce+':'+$Operation.ProcessId;$end='WELA-TRANSCRIPT-END:'+$Operation.Nonce+':'+$Operation.ProcessId
if(@($body|Where-Object{$_ -ceq $begin}).Count -ne 1 -or @($body|Where-Object{$_ -ceq $end}).Count -ne 1 -or [Array]::IndexOf($body,$begin) -ge [Array]::IndexOf($body,$end)){return $false}
$offset=[TimeSpan]::FromMinutes($Operation.StartOffsetMinutes)
$first=[DateTimeOffset]::new([DateTime]::ParseExact($match.Groups['Start'].Value,'yyyyMMddHHmmss',[Globalization.CultureInfo]::InvariantCulture),$offset)
$last=[DateTimeOffset]::new([DateTime]::ParseExact($match.Groups['End'].Value,'yyyyMMddHHmmss',[Globalization.CultureInfo]::InvariantCulture),$offset)
return $first -ge (ConvertTo-WelaArrivalUtc $Operation.LaunchedUtc).AddSeconds(-1) -and $first -le (ConvertTo-WelaArrivalUtc $Operation.StartedUtc).AddSeconds(1) -and $last -ge (ConvertTo-WelaArrivalUtc $Operation.CompletedUtc).AddSeconds(-1) -and $last -le (ConvertTo-WelaArrivalUtc $Operation.ExitedUtc).AddSeconds(1) -and $last -ge $first
}
function Write-WelaTranscriptProbeArtifact {
param([string]$Root,[string]$Name,[byte[]]$Bytes)
if($Bytes.Length -gt 4194304){throw 'Evidence artifact exceeds four MiB.'}
$stream=[IO.File]::Open((Join-Path $Root $Name),[IO.FileMode]::CreateNew,[IO.FileAccess]::ReadWrite,[IO.FileShare]::None)
try{$stream.Write($Bytes,0,$Bytes.Length);$stream.Flush($true);$stream.Position=0;$sha=[Security.Cryptography.SHA256]::Create();try{$hash=([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()};if($hash -cne (Get-WelaArrivalHash $Bytes)){throw 'Written evidence bytes differ.'}}finally{$stream.Dispose()}
[pscustomobject]@{Name=$Name;Bytes=$Bytes.Length;Sha256=$hash}
}
function Invoke-WelaTranscriptProbe {
param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$Directory,[string]$OutputPath)
if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new TranscriptProbeOutputPath; Plan starts no worker or explicit output.'}
if(-not $Directory){throw 'Select the existing local TranscriptProbeDirectory.'}
Initialize-WelaTranscriptProbe
$directoryPath=Resolve-WelaArrivalPath $Directory
if(-not [IO.Directory]::Exists($directoryPath)){throw 'Selected transcript directory must already exist.'}
$handle=[Wela.TranscriptProbe.Item]::Directory($directoryPath);$heldFiles=@();$heldFolders=@();$output=$null
try{
$state=Get-WelaTranscriptProbeState $directoryPath $handle;$stateKey=Get-WelaTranscriptProbeStateKey $state
$dates=@(-1,0,1|ForEach-Object{[DateTime]::Today.AddDays($_).ToString('yyyyMMdd',[Globalization.CultureInfo]::InvariantCulture)})
$before=Get-WelaTranscriptProbeInventory $directoryPath $dates
if($Action -eq 'Plan'){return [pscustomobject]@{SchemaVersion=1;Kind='WelaAutomaticTranscriptPlan';Action='Plan';ExitCode=0;Status='ReadyToProbe';State=$state;Inventory=$before;Token=[Wela.WmiProbe.Native]::Snapshot();ReadyRuleCredit=0;SigmaEvtxCredit=0;WriterAuthorization='Unverified';Scope='One new native Windows PowerShell5.1 automatic transcript under this local current identity only'}}
$output=New-WelaArrivalOutput $OutputPath $directoryPath
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaAutomaticTranscriptProbe';Action='Run';Status='Unverified';ExitCode=1;RecordedUtc=[DateTime]::UtcNow.ToString('o');Before=$state;After=$null;InventoryBefore=$before;InventoryAfter=$null;ParentBefore=$null;ParentAfter=$null;Worker=$null;WorkerLaunch=[pscustomobject]@{ProcessId=$null;LaunchedUtc=$null;ExitedUtc=$null;ExitCode=$null;Stdout=$null;Stderr=$null};Transcript=$null;Artifacts=@();Diagnostic='';OutputPath=$output;ReadyRuleCredit=0;SigmaEvtxCredit=0;ConfigurationChanges=0;WriterAuthorization='Unverified';PowerShell7Sessions='Not assessed';Collection='Not verified';Scope='One fixed native5.1 completed automatic text transcript; no retention, immutable-storage or EVTX/Sigma claim'}
try{
# Prepare metadata and evidence storage before capturing the actual process-token interval.
foreach($folder in $before.Folders|Where-Object Exists){$held=[Wela.TranscriptProbe.Item]::Directory($folder.Observation.Path);$heldFolders+= $held;if((Get-WelaTranscriptProbeObjectKey $held.Snapshot() -Directory) -cne (Get-WelaTranscriptProbeObjectKey $folder.Observation -Directory)){throw 'Date-directory changed before worker.'}}
$fresh=Get-WelaTranscriptProbeState $directoryPath $handle;if((Get-WelaTranscriptProbeStateKey $fresh) -cne $stateKey){throw 'Policy, destination, source or host changed before worker.'}
$inventory=Get-WelaTranscriptProbeInventory $directoryPath $dates
Assert-WelaTranscriptProbeInventory $before $inventory
# Newly created unrelated files during preparation become baseline, never candidate evidence.
$before=$inventory;$report.InventoryBefore=$before
$token=[Wela.WmiProbe.Native]::Snapshot();$report.ParentBefore=$token
$operation=Start-WelaTranscriptProbeWorker $state ([guid]::NewGuid().ToString('N')) $token $report.WorkerLaunch;$report.Worker=$operation
$after=Get-WelaTranscriptProbeInventory $directoryPath $dates;$report.InventoryAfter=$after;Assert-WelaTranscriptProbeInventory $before $after
foreach($folder in $after.Folders|Where-Object Exists){$held=[Wela.TranscriptProbe.Item]::Directory($folder.Observation.Path);$heldFolders+=$held;if((Get-WelaTranscriptProbeObjectKey $held.Snapshot() -Directory) -cne (Get-WelaTranscriptProbeObjectKey $folder.Observation -Directory)){throw 'Date directory changed after worker.'}}
$candidates=@($after.Files|Where-Object{$_.Identity -cnotin @($before.Files.Identity)})
if($candidates.Count -gt 32){throw 'Fresh transcript candidates exceed the 32-file bound.'}
$matches=@();$total=0
foreach($candidate in $candidates){
if([IO.Path]::GetFileName($candidate.Path) -cnotlike 'PowerShell_transcript*.txt'){throw 'Unexpected fresh file in the selected date scope.'}
if((ConvertTo-WelaArrivalUtc $candidate.CreatedUtc) -lt (ConvertTo-WelaArrivalUtc $operation.LaunchedUtc).AddSeconds(-2) -or (ConvertTo-WelaArrivalUtc $candidate.WrittenUtc) -gt (ConvertTo-WelaArrivalUtc $operation.ExitedUtc).AddSeconds(2)){throw 'Fresh transcript file timestamps are outside the worker interval.'}
$file=[Wela.TranscriptProbe.Item]::File($candidate.Path);$heldFiles+=$file
$observation=$file.Snapshot();if((Get-WelaTranscriptProbeObjectKey $observation) -cne (Get-WelaTranscriptProbeObjectKey $candidate)){throw 'Candidate identity or contents changed after enumeration.'}
$bytes=$file.Read(1048576);$total+=$bytes.Length;if($total -gt 4194304){throw 'Fresh transcript reads exceed four MiB.'}
$text=ConvertFrom-WelaTranscriptProbeBytes $bytes
if(Test-WelaTranscriptProbeText $text $operation){$matches+=[pscustomobject]@{Observation=$observation;Bytes=$bytes;Handle=$file}}
}
if($matches.Count -ne 1){throw ('Expected one fresh completed automatic transcript; matching files: '+$matches.Count+'. Writer authorization remains unverified.')}
$report.After=Get-WelaTranscriptProbeState $directoryPath $handle
if((Get-WelaTranscriptProbeStateKey $report.After) -cne $stateKey){throw 'Policy, destination, source or host changed during the probe.'}
$final=Get-WelaTranscriptProbeInventory $directoryPath $dates;Assert-WelaTranscriptProbeInventory $after $final
if((Get-WelaTranscriptProbeKey @($after.Files.Path)) -cne (Get-WelaTranscriptProbeKey @($final.Files.Path))){throw 'Candidate inventory changed before final verification.'}
if((Get-WelaTranscriptProbeObjectKey $matches[0].Handle.Snapshot()) -cne (Get-WelaTranscriptProbeObjectKey $matches[0].Observation)){throw 'Matching transcript changed before evidence capture.'}
$report.ParentAfter=[Wela.WmiProbe.Native]::Snapshot()
if((Get-WelaWmiProbeTokenKey $report.ParentBefore) -cne (Get-WelaWmiProbeTokenKey $report.ParentAfter)){throw 'Parent authorization context changed during the probe.'}
$report.Artifacts+=Write-WelaTranscriptProbeArtifact $output 'transcript.txt' $matches[0].Bytes
$report.Transcript=$matches[0].Observation;$report.Status='CompletedAutomaticTranscript';$report.WriterAuthorization='ObservedForThisChild';$report.ExitCode=0
}catch{$report.Diagnostic=$_.Exception.Message}
$report.Artifacts+=Write-WelaTranscriptProbeArtifact $output 'worker.json' ([Text.UTF8Encoding]::new($false).GetBytes((ConvertTo-Json -InputObject $report.Worker -Depth 18)))
$null=Write-WelaTranscriptProbeArtifact $output 'result.json' ([Text.UTF8Encoding]::new($false).GetBytes(($report|ConvertTo-Json -Depth 22)))
return $report
}finally{foreach($file in $heldFiles){$file.Dispose()};foreach($folder in $heldFolders){$folder.Dispose()};$handle.Dispose()}
}
+66
View File
@@ -0,0 +1,66 @@
// Read-only local identity/descriptor/file access and bounded pipe drains.
using System;
using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Win32.SafeHandles;
namespace Wela.TranscriptProbe {
public sealed class Observation {
public string Path, Identity, CreatedUtc, WrittenUtc, Descriptor;
public uint Attributes, Links; public long Length;
}
public sealed class Capture {public string Text, Error;public bool Exceeded;}
public sealed class Item : IDisposable {
[StructLayout(LayoutKind.Sequential,Pack=4)] struct Info {public uint Attributes;public long Created,Accessed,Written;public uint Volume,SizeHigh,SizeLow,Links,IndexHigh,IndexLow;}
[DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern SafeFileHandle CreateFile(string path,uint access,uint share,IntPtr security,uint disposition,uint flags,IntPtr template);
[DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(SafeFileHandle handle,out Info value);
[DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern uint GetFinalPathNameByHandle(SafeFileHandle handle,StringBuilder text,uint length,uint flags);
[DllImport("advapi32.dll")] static extern uint GetSecurityInfo(SafeFileHandle handle,uint kind,uint flags,out IntPtr owner,out IntPtr group,out IntPtr dacl,out IntPtr sacl,out IntPtr descriptor);
[DllImport("advapi32.dll")] static extern uint GetSecurityDescriptorLength(IntPtr descriptor);
[DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr memory);
SafeFileHandle handle; FileStream stream; string path; bool directory;
public const string SourceSha256 = "__WELA_TRANSCRIPT_SOURCE_SHA256__";
Item(string path,bool directory,bool content) {
this.path=System.IO.Path.GetFullPath(path);this.directory=directory;
handle=CreateFile(this.path,content?0x80020000u:0x20080u,directory?3u:(content?1u:7u),IntPtr.Zero,3,0x02200000,IntPtr.Zero);
if(handle.IsInvalid){int error=Marshal.GetLastWin32Error();handle.Dispose();throw new Win32Exception(error);}
try {Snapshot();if(content)stream=new FileStream(handle,FileAccess.Read,4096,false);}catch{Dispose();throw;}
}
public static Item Directory(string path){return new Item(path,true,false);}
public static Item Metadata(string path){return new Item(path,false,false);}
public static Item File(string path){return new Item(path,false,true);}
public Observation Snapshot() {
Info value;if(!GetFileInformationByHandle(handle,out value))throw new Win32Exception(Marshal.GetLastWin32Error());
if((value.Attributes&1024)!=0||((value.Attributes&16)!=0)!=directory)throw new InvalidOperationException("Unexpected reparse point or object type.");
if(!directory&&value.Links!=1)throw new InvalidOperationException("Transcript files must have one link.");
StringBuilder buffer=new StringBuilder(32768);uint length=GetFinalPathNameByHandle(handle,buffer,(uint)buffer.Capacity,0);
if(length==0||length>=buffer.Capacity)throw new InvalidOperationException("Unknown native object path.");
string final=buffer.ToString();if(final.StartsWith(@"\\?\",StringComparison.Ordinal))final=final.Substring(4);
if(!String.Equals(final.TrimEnd('\\'),path.TrimEnd('\\'),StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Native object path changed or resolves elsewhere.");
IntPtr owner,group,dacl,sacl,sd;uint error=GetSecurityInfo(handle,1,7,out owner,out group,out dacl,out sacl,out sd);
if(error!=0)throw new Win32Exception((int)error);
string descriptor;
try {uint size=GetSecurityDescriptorLength(sd);if(size<20||size>65536)throw new InvalidOperationException("Invalid descriptor bound.");byte[] bytes=new byte[size];Marshal.Copy(sd,bytes,0,bytes.Length);descriptor=Convert.ToBase64String(bytes);}finally{LocalFree(sd);}
return new Observation{Path=final,Identity=value.Volume.ToString("x8")+":"+value.IndexHigh.ToString("x8")+value.IndexLow.ToString("x8"),CreatedUtc=DateTime.FromFileTimeUtc(value.Created).ToString("o"),WrittenUtc=DateTime.FromFileTimeUtc(value.Written).ToString("o"),Attributes=value.Attributes,Links=value.Links,Length=((long)value.SizeHigh<<32)|value.SizeLow,Descriptor=descriptor};
}
public byte[] Read(int maximum) {
if(stream==null)throw new InvalidOperationException("Object was not opened for content.");
Observation before=Snapshot();if(before.Length<1||before.Length>maximum)throw new InvalidOperationException("Transcript is empty or exceeds its byte bound.");
byte[] bytes=new byte[(int)before.Length];stream.Position=0;int offset=0;
while(offset<bytes.Length){int read=stream.Read(bytes,offset,bytes.Length-offset);if(read==0)throw new EndOfStreamException();offset+=read;}
if(stream.ReadByte()!=-1)throw new InvalidOperationException("Transcript grew while reading.");
Observation after=Snapshot();if(before.Length!=after.Length||before.Identity!=after.Identity||before.WrittenUtc!=after.WrittenUtc||before.Descriptor!=after.Descriptor)throw new InvalidOperationException("Transcript changed while reading.");
return bytes;
}
public void Dispose(){if(stream!=null){stream.Dispose();stream=null;}if(handle!=null){handle.Dispose();handle=null;}}
public static Task<Capture> Drain(TextReader reader,int maximum) {
return Task.Factory.StartNew(()=>{Capture result=new Capture();StringBuilder text=new StringBuilder();char[] buffer=new char[2048];
try {int count;while((count=reader.Read(buffer,0,buffer.Length))>0){int retain=Math.Min(count,Math.Max(0,maximum-text.Length));if(retain<count)result.Exceeded=true;if(retain>0)text.Append(buffer,0,retain);}}
catch(Exception error){result.Error=error.GetType().FullName;}
result.Text=text.ToString();return result;});
}
}
}
+45
View File
@@ -0,0 +1,45 @@
# Fixed native5.1 worker. Automatic policy is the sole transcription producer.
param([Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{32}$')][string]$Nonce)
$ErrorActionPreference='Stop'
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
if($PSVersionTable.PSEdition -cne 'Desktop' -or $PSVersionTable.PSVersion.Major -ne 5 -or $PSVersionTable.PSVersion.Minor -ne 1 -or -not [Environment]::Is64BitProcess){throw 'Native Windows PowerShell5.1 is required.'}
# A PowerShell7 parent can pass a PSModulePath without the native5.1 modules.
# Load only the fixed installed native modules, independent of caller module search paths.
foreach($module in @('Microsoft.PowerShell.Utility','Microsoft.PowerShell.Management')){
Import-Module ([IO.Path]::Combine($PSHOME,'Modules',$module,($module+'.psd1'))) -ErrorAction Stop
}
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
. (Join-Path $PSScriptRoot 'WmiProbe.ps1')
. (Join-Path $PSScriptRoot 'PowerShellTranscription.ps1')
Initialize-WelaWmiProbeNative
$assembly=[psobject].Assembly
$types=@($assembly.GetTypes()|Where-Object Name -eq 'InternalHostUserInterfaceStrings')
if($types.Count -ne 1){throw 'Native transcript resource type is unknown.'}
$resources=[ordered]@{}
foreach($name in @('TranscriptPrologue','TranscriptEpilogue')){
$property=$types[0].GetProperty($name,[Reflection.BindingFlags]'Public,NonPublic,Static')
if(-not $property){throw 'Native transcript resource is unavailable.'}
$value=$property.GetValue($null,$null)
if($value -isnot [string] -or $value.Length -gt 8192 -or -not $value.Contains('{0:yyyyMMddHHmmss}')){throw 'Unrecognized native transcript resource.'}
$resources[$name]=$value
}
$assemblyPath=$assembly.Location;$assemblyHash=(Get-FileHash -LiteralPath $assemblyPath -Algorithm SHA256).Hash.ToLowerInvariant()
$policyBefore=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'));Test-WelaTranscriptSharedPolicy $policyBefore
$before=[Wela.WmiProbe.Native]::Snapshot();$start=[DateTimeOffset]::Now
Microsoft.PowerShell.Utility\Write-Output ('WELA-TRANSCRIPT-BEGIN:'+${Nonce}+':'+$PID)
$policyAfter=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))
if(($policyBefore|ConvertTo-Json -Depth 12 -Compress) -cne ($policyAfter|ConvertTo-Json -Depth 12 -Compress)){throw 'Worker policy changed.'}
if((Get-FileHash -LiteralPath $assemblyPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $assemblyHash){throw 'Worker engine assembly changed.'}
$after=[Wela.WmiProbe.Native]::Snapshot()
if((Get-WelaWmiProbeTokenKey $before) -cne (Get-WelaWmiProbeTokenKey $after)){throw 'Worker token changed.'}
Microsoft.PowerShell.Utility\Write-Output ('WELA-TRANSCRIPT-END:'+${Nonce}+':'+$PID)
$end=[DateTimeOffset]::Now
$operation=[pscustomobject][ordered]@{
Nonce=$Nonce;ProcessId=$PID;Engine=(Get-Process -Id $PID).Path;EngineVersion=$PSVersionTable.PSVersion.ToString();Edition=$PSVersionTable.PSEdition
StartedUtc=$start.UtcDateTime.ToString('o');CompletedUtc=$end.UtcDateTime.ToString('o');StartOffsetMinutes=$start.Offset.TotalMinutes;EndOffsetMinutes=$end.Offset.TotalMinutes
BeforeToken=$before;AfterToken=$after;PolicyBefore=$policyBefore;PolicyAfter=$policyAfter
Computer=[Environment]::MachineName;HeaderUser=([Environment]::UserDomainName+'\'+[Environment]::UserName);OsVersion=[Environment]::OSVersion.VersionString
CommandLine=[Environment]::CommandLine;HeaderCommandLine=([Environment]::GetCommandLineArgs() -join ' ');Arguments=@([Environment]::GetCommandLineArgs());UiCulture=[Globalization.CultureInfo]::CurrentUICulture.Name
Assembly=[pscustomobject]@{Path=$assemblyPath;FullName=$assembly.FullName;Sha256=$assemblyHash};Resources=[pscustomobject]$resources
}
[Console]::WriteLine('WELA-WORKER-JSON:'+($operation|ConvertTo-Json -Depth 16 -Compress))
+72
View File
@@ -0,0 +1,72 @@
$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
. (Join-Path $script:ScriptRoot 'scripts/WefArrival.ps1')
. (Join-Path $script:ScriptRoot 'scripts/PowerShellTranscription.ps1')
. (Join-Path $script:ScriptRoot 'scripts/TranscriptProbe.ps1')
$script:passed=0
function Assert($condition,[string]$message){if(-not $condition){throw $message};$script:passed++}
function Rejects([scriptblock]$action){$caught=$false;try{&$action|Out-Null}catch{$caught=$true};Assert $caught 'Expected refusal'}
function Clone($object){$object|ConvertTo-Json -Depth 20|ConvertFrom-Json}
$header="**********************`nWindows PowerShell transcript start`nStart time: {0:yyyyMMddHHmmss}`nUsername: {1}`nRunAs User: {2}`nConfiguration Name: {3}`nMachine: {4} ({5})`nHost Application: {6}`nProcess ID: {7}`n{8}`n**********************"
$footer="**********************`nWindows PowerShell transcript end`nEnd time: {0:yyyyMMddHHmmss}`n**********************"
$operation=[pscustomobject]@{Resources=[pscustomobject]@{TranscriptPrologue=$header;TranscriptEpilogue=$footer};Nonce=('a'*32);ProcessId=123;HeaderUser='HOST\writer';BeforeToken=[pscustomobject]@{Name='HOST\writer'};Computer='HOST';OsVersion='Microsoft Windows NT 10.0.20348.0';CommandLine='"powershell.exe" fixed';HeaderCommandLine='powershell.exe fixed';EngineVersion='5.1.20348.1000';StartOffsetMinutes=0;LaunchedUtc='2026-09-21T00:00:00.1000000Z';StartedUtc='2026-09-21T00:00:01.0000000Z';CompletedUtc='2026-09-21T00:00:02.0000000Z';ExitedUtc='2026-09-21T00:00:03.0000000Z'}
function MakeText($op){
$begin=[string]::Format([Globalization.CultureInfo]::InvariantCulture,$op.Resources.TranscriptPrologue,@([DateTime]::new(2026,9,21,0,0,0),$op.HeaderUser,$op.BeforeToken.Name,'',$op.Computer,$op.OsVersion,$op.HeaderCommandLine,$op.ProcessId,('PSVersion: '+$op.EngineVersion+"`nPSEdition: Desktop`n")))
$end=[string]::Format([Globalization.CultureInfo]::InvariantCulture,$op.Resources.TranscriptEpilogue,[DateTime]::new(2026,9,21,0,0,2))
$begin+"`nWELA-TRANSCRIPT-BEGIN:"+$op.Nonce+':'+$op.ProcessId+"`nWELA-TRANSCRIPT-END:"+$op.Nonce+':'+$op.ProcessId+"`n"+$end+"`n"
}
$text=MakeText $operation
Assert (Test-WelaTranscriptProbeText $text $operation) 'Complete native transcript framing and markers match'
foreach($case in @(
$text.Replace('Process ID: 123','Process ID: 124'),
$text.Replace('Host Application: powershell.exe fixed','Host Application: powershell.exe other'),
$text.Replace('RunAs User: HOST\writer','RunAs User: HOST\other'),
$text.Replace('PSVersion: 5.1.20348.1000','PSVersion: 7.5.0'),
$text.Replace('PSEdition: Desktop','PSEdition: Core'),
$text.Replace('Windows PowerShell transcript end','Unfinished'),
$text.Replace('WELA-TRANSCRIPT-BEGIN:','PS>WELA-TRANSCRIPT-BEGIN:'),
$text.Replace('WELA-TRANSCRIPT-END:','PS>WELA-TRANSCRIPT-END:'),
$text.Replace('Start time: 20260921000000','Start time: 20250921000000'),
$text.Replace('End time: 20260921000002','End time: 20260921000020'),
$text.Replace(('WELA-TRANSCRIPT-END:'+('a'*32)+':123'),('WELA-TRANSCRIPT-END:'+('b'*32)+':123')),
$text.Replace('Configuration Name: ','Configuration Name: remote'),
($text+$text),
($text+'trailing payload')
)){Assert (-not (Test-WelaTranscriptProbeText $case $operation)) 'Incomplete, mismatched or ambiguous content has no transcript proof'}
$marker='WELA-TRANSCRIPT-END:'+('a'*32)+':123'
Assert (-not (Test-WelaTranscriptProbeText ($text.Replace($marker,($marker+"`n"+$marker))) $operation)) 'Duplicate standalone marker is rejected'
$translated=Clone $operation
$translated.Resources.TranscriptPrologue=$header.Replace('Windows PowerShell transcript start','Windows PowerShell トランスクリプト開始').Replace('Username:','ユーザー名:')
$translated.Resources.TranscriptEpilogue=$footer.Replace('Windows PowerShell transcript end','Windows PowerShell トランスクリプト終了')
Assert (Test-WelaTranscriptProbeText (MakeText $translated) $translated) 'Runtime-supplied localized resources drive matching'
foreach($encoding in @([Text.UTF8Encoding]::new($true),[Text.UnicodeEncoding]::new($false,$true),[Text.UnicodeEncoding]::new($true,$true))){$bytes=[byte[]]@($encoding.GetPreamble()+$encoding.GetBytes($text.Replace("`n","`r`n")));Assert ((ConvertFrom-WelaTranscriptProbeBytes $bytes) -ceq $text) 'Supported transcript byte encoding roundtrips'}
Rejects {ConvertFrom-WelaTranscriptProbeBytes ([byte[]]@(0xc3,0x28))}
Rejects {ConvertFrom-WelaTranscriptProbeBytes ([byte[]]@(65,0,66))}
$directory=[pscustomobject]@{Path='C:\T';Identity='id1';CreatedUtc='2026-09-21T00:00:00Z';WrittenUtc='2026-09-21T00:00:00Z';Attributes=16;Descriptor='acl';Length=0;Links=1}
$new=Clone $directory;$new.WrittenUtc='2026-09-21T00:01:00Z'
Assert ((Get-WelaTranscriptProbeObjectKey $directory -Directory) -ceq (Get-WelaTranscriptProbeObjectKey $new -Directory)) 'Directory child creation does not replace directory identity'
foreach($field in @('Identity','Descriptor','CreatedUtc','Path')){$changed=Clone $directory;$changed.$field='changed';Assert ((Get-WelaTranscriptProbeObjectKey $directory -Directory) -cne (Get-WelaTranscriptProbeObjectKey $changed -Directory)) 'Directory identity/descriptor drift is visible'}
$oldFile=Clone $directory;$oldFile.Path='C:\T\20260921\old.txt';$oldFile.Attributes=32;$oldFile.Identity='old-file';$oldFile.Length=100
$inventory=[pscustomobject]@{Folders=@([pscustomobject]@{Date='20260921';Exists=$true;Observation=$directory});Files=@($oldFile)}
$appended=Clone $inventory;$appended.Files[0].Length=200;$appended.Files[0].WrittenUtc='2026-09-21T00:01:00Z'
Assert-WelaTranscriptProbeInventory $inventory $appended;Assert $true 'Existing active transcript append is preserved without reading it'
foreach($field in @('Identity','Descriptor','CreatedUtc','Path')){$changed=Clone $inventory;$changed.Files[0].$field='changed';Rejects {Assert-WelaTranscriptProbeInventory $inventory $changed}}
$changed=Clone $inventory;$changed.Files=@();Rejects {Assert-WelaTranscriptProbeInventory $inventory $changed}
$changed=Clone $inventory;$changed.Folders[0].Exists=$false;Rejects {Assert-WelaTranscriptProbeInventory $inventory $changed}
# Pure typed-policy tests replace only local path resolution, not the policy decision.
function Resolve-WelaArrivalPath {param([string]$Path)if($Path -notmatch '^C:\\'){throw 'Fixture non-local path'};$Path}
function Value($value,$type){[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=$value;Type=$type}}
$machine=[pscustomobject]@{EnableTranscripting=(Value 1 'DWord');OutputDirectory=(Value 'C:\T' 'String');EnableInvocationHeader=(Value 1 'DWord')}
$user=[pscustomobject]@{EnableTranscripting=[pscustomobject]@{KeyExists=$false;ValueExists=$false;Value=$null;Type=$null};OutputDirectory=[pscustomobject]@{KeyExists=$false;ValueExists=$false;Value=$null;Type=$null};EnableInvocationHeader=[pscustomobject]@{KeyExists=$false;ValueExists=$false;Value=$null;Type=$null}}
$policy=@([pscustomobject]@{View='Registry64';Machine=$machine;CurrentUser=$user},[pscustomobject]@{View='Registry32';Machine=$machine;CurrentUser=$user})
Assert-WelaTranscriptProbePolicy $policy 'C:\T';Assert $true 'Known enabled matching machine policy accepted'
foreach($field in @('EnableTranscripting','OutputDirectory','EnableInvocationHeader')){$changed=Clone $policy;$changed[0].Machine.$field.Type='Unknown';$changed[1].Machine.$field.Type='Unknown';Rejects {Assert-WelaTranscriptProbePolicy $changed 'C:\T'}}
$changed=Clone $policy;$changed[0].Machine.EnableTranscripting.Value=0;$changed[1].Machine.EnableTranscripting.Value=0;Rejects {Assert-WelaTranscriptProbePolicy $changed 'C:\T'}
Rejects {Assert-WelaTranscriptProbePolicy $policy 'C:\Other'}
$changed=Clone $policy;$changed[1].Machine.OutputDirectory.Value='C:\Other';Rejects {Assert-WelaTranscriptProbePolicy $changed 'C:\T'}
Rejects {Assert-WelaTranscriptProbePolicy @($policy[0]) 'C:\T'}
$engine=(Get-Process -Id $PID).Path
foreach($case in @(@{Args=@('transcript-probe','-Help');Exit=0},@{Args=@('transcript-probe','-Help','-Auto');Exit=1},@{Args=@('transcript-probe','-Help','-TranscriptDirectory','C:\T');Exit=1},@{Args=@('transcript-probe','-Help','-DryRun');Exit=1},@{Args=@('help','-TranscriptProbeAction','Run');Exit=1})){
$old=$ErrorActionPreference;$ErrorActionPreference='Continue';try{$output=&$engine -NoProfile -File (Join-Path $script:ScriptRoot 'WELA.ps1') @($case.Args) 2>&1;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old}
Assert ($code -eq $case.Exit) ('CLI boundary '+($case.Args -join ' ')+': '+($output|Out-String))
}
$global:LASTEXITCODE=0;Write-Host "Transcript probe fixtures passed: $script:passed"
+99
View File
@@ -0,0 +1,99 @@
param([switch]$AllowDisposableWriter,[ValidateSet('powershell','pwsh')][string]$TestEngine='powershell')
$ErrorActionPreference='Stop'
if(-not $AllowDisposableWriter -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Explicit disposable writer/policy/ACL opt-in on a GitHub-hosted Windows runner required.'}
$computer=Get-CimInstance Win32_ComputerSystem;$os=Get-CimInstance Win32_OperatingSystem
if($computer.PartOfDomain -or $computer.DomainRole -ne 2 -or $os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100)){throw 'Refusing domain, DC or unknown runner.'}
$root=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$root
. (Join-Path $root 'scripts/PowerShellTranscription.ps1')
$nonce=[guid]::NewGuid().ToString('N');$username='WelaT'+$nonce.Substring(0,12)
$fixture=Join-Path $env:RUNNER_TEMP ('wela-transcript-probe-'+$nonce);$null=New-Item -ItemType Directory $fixture
$codeRoot=Join-Path $fixture 'code';$null=New-Item -ItemType Directory $codeRoot
foreach($path in @('WELA.ps1','scripts','modules','config')){Copy-Item -LiteralPath (Join-Path $root $path) -Destination $codeRoot -Recurse}
$readerHome=Join-Path $fixture 'writer';$destination=Join-Path $fixture 'transcripts';$null=New-Item -ItemType Directory $readerHome,$destination
$engine=(Get-Command $TestEngine -ErrorAction Stop).Source
$policyBefore=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'));$policyBefore|ConvertTo-Json -Depth 12|Set-Content -LiteralPath (Join-Path $fixture 'policy-before.json') -Encoding UTF8
$ownedSid=$null;$policyTouched=$false;$passed=$false;$originalAcl=$null
function Restore-Policy {
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$key=$null
try{
$key=$base.CreateSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription')
$key.SetValue('EnableTranscripting',0,[Microsoft.Win32.RegistryValueKind]::DWord)
foreach($name in @('OutputDirectory','EnableInvocationHeader','EnableTranscripting')){
$value=$policyBefore[0].Machine.$name
if($value.ValueExists){$key.SetValue($name,$value.Value,[Microsoft.Win32.RegistryValueKind]([string]$value.Type))}else{$key.DeleteValue($name,$false)}
}
$remove=-not $policyBefore[0].Machine.EnableTranscripting.KeyExists -and $key.GetValueNames().Count -eq 0 -and $key.GetSubKeyNames().Count -eq 0
$key.Dispose();$key=$null
if($remove){$base.DeleteSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription',$false)}
}finally{if($key){$key.Dispose()};$base.Dispose()}
if((@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))|ConvertTo-Json -Depth 12 -Compress) -cne ($policyBefore|ConvertTo-Json -Depth 12 -Compress)){throw 'Exact typed transcription policy/key restoration failed.'}
}
function Invoke-ProbeAsOwnedUser([string]$Label,[int]$ExpectedExit,[ValidateSet('Plan','Run')][string]$Action='Run'){
$output=Join-Path $readerHome $Label
# Credentials are passed as a SecureString through the process API, never command-line text.
$arguments='-NoProfile -ExecutionPolicy Bypass -File "'+(Join-Path $codeRoot 'WELA.ps1')+'" transcript-probe -TranscriptProbeAction '+$Action+' -TranscriptProbeDirectory "'+$destination+'"'+$(if($Action -eq 'Run'){' -TranscriptProbeOutputPath "'+$output+'"'}else{''})
# Own the process handle directly: Windows PowerShell's Start-Process can lose
# ExitCode for alternate-credential children after they exit.
$start=[Diagnostics.ProcessStartInfo]::new();$start.FileName=$engine;$start.Arguments=$arguments
$start.UseShellExecute=$false;$start.CreateNoWindow=$true;$start.WorkingDirectory=$readerHome
$start.UserName=$username;$start.Domain=[Environment]::MachineName;$start.Password=$password;$start.LoadUserProfile=$true
$start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true
$start.EnvironmentVariables['TEMP']=$readerHome;$start.EnvironmentVariables['TMP']=$readerHome
$process=[Diagnostics.Process]::new();$process.StartInfo=$start;$started=$false
try{
if(-not $process.Start()){throw 'Native reader process did not start.'};$started=$true
$stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync()
if(-not $process.WaitForExit(90000)){$process.Kill();$null=$process.WaitForExit(5000);throw 'Reader child exceeded 90 seconds.'}
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Reader output pipes did not close within five seconds of process exit.'}
$exitCode=$process.ExitCode
[IO.File]::WriteAllText((Join-Path $readerHome ($Label+'.stdout')),$stdout.GetAwaiter().GetResult())
[IO.File]::WriteAllText((Join-Path $readerHome ($Label+'.stderr')),$stderr.GetAwaiter().GetResult())
}finally{
try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(5000)){throw 'Reader child termination was not confirmed; no acceptance claim.'}}}finally{$process.Dispose()}
}
if($exitCode -ne $ExpectedExit){Get-Content -LiteralPath (Join-Path $readerHome ($Label+'.stderr'));throw "Reader exit $exitCode expected $ExpectedExit"}
if($Action -eq 'Plan'){if(Test-Path -LiteralPath $output){throw 'Plan wrote explicit evidence output.'};return}
$report=Get-Content -LiteralPath (Join-Path $output 'result.json') -Raw|ConvertFrom-Json
if($report.ReadyRuleCredit -ne 0 -or $report.SigmaEvtxCredit -ne 0 -or $report.ConfigurationChanges -ne 0){throw 'Transcript report overclaims coverage or changed configuration.'}
$report
}
try{
$password=ConvertTo-SecureString ('Wela!7'+[guid]::NewGuid().ToString('N')+'zA#') -AsPlainText -Force
$user=New-LocalUser -Name $username -Password $password -Description ('WELA transcript '+$nonce.Substring(0,20)) -AccountNeverExpires
$ownedSid=$user.SID.Value;Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $user
$acl=Get-Acl $fixture;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'ReadAndExecute','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl $fixture $acl
$acl=Get-Acl $readerHome;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'FullControl','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl $readerHome $acl
$acl=[Security.AccessControl.DirectorySecurity]::new();$acl.SetAccessRuleProtection($true,$false)
foreach($sid in @('S-1-5-18','S-1-5-32-544',$ownedSid)){$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid),'FullControl','ContainerInherit,ObjectInherit','None','Allow'))}
Set-Acl $destination $acl;$originalAcl=Get-Acl $destination
$policyTouched=$true
Set-WelaTranscriptRegistryValue -Name OutputDirectory -Value $destination -Type String
Set-WelaTranscriptRegistryValue -Name EnableTranscripting -Value 1 -Type DWord
# Exercise invocation headers while preserving the real original typed preference.
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64)
try{$key=$base.OpenSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription',$true);try{$key.SetValue('EnableInvocationHeader',1,[Microsoft.Win32.RegistryValueKind]::DWord)}finally{$key.Dispose()}}finally{$base.Dispose()}
$configured=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))|ConvertTo-Json -Depth 12 -Compress
Invoke-ProbeAsOwnedUser 'plan' 0 'Plan'
$allowed=Invoke-ProbeAsOwnedUser 'allowed' 0
if($allowed.Status -ne 'CompletedAutomaticTranscript' -or $allowed.WriterAuthorization -ne 'ObservedForThisChild' -or $allowed.Worker.BeforeToken.Sid -cne $ownedSid -or $allowed.ParentBefore.Sid -cne $ownedSid -or $allowed.Worker.BeforeToken.AuthenticationId -cne $allowed.ParentBefore.AuthenticationId -or @($allowed.Worker.BeforeToken.Groups|Where-Object Sid -eq 'S-1-5-32-544').Count){throw 'No completed automatic native5.1 transcript from the actual owned standard-user logon.'}
if($allowed.Worker.Engine -notlike '*\System32\WindowsPowerShell\v1.0\powershell.exe' -or $allowed.Worker.Edition -cne 'Desktop'){throw 'Wrong transcript engine.'}
$artifact=@($allowed.Artifacts|Where-Object Name -eq 'transcript.txt')
if($artifact.Count -ne 1 -or (Get-FileHash -LiteralPath (Join-Path $allowed.OutputPath 'transcript.txt') -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact[0].Sha256){throw 'Transcript evidence hash mismatch.'}
if((@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))|ConvertTo-Json -Depth 12 -Compress) -cne $configured -or (Get-Acl $destination).Sddl -cne $originalAcl.Sddl){throw 'Probe changed configured policy or root ACL.'}
$deny=Get-Acl $destination
$deny.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'Write','ContainerInherit,ObjectInherit','None','Deny'));Set-Acl $destination $deny
$deniedAcl=(Get-Acl $destination).Sddl
$denied=Invoke-ProbeAsOwnedUser 'denied' 1
if($denied.Status -eq 'CompletedAutomaticTranscript' -or $denied.WriterAuthorization -ne 'Unverified' -or $denied.Transcript){throw 'Denied writer gained positive transcript proof.'}
if((Get-Acl $destination).Sddl -cne $deniedAcl -or (@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))|ConvertTo-Json -Depth 12 -Compress) -cne $configured){throw 'Denied run changed the explicit deny or policy.'}
$passed=$true
}finally{
$errors=@()
if($policyTouched){try{Restore-Policy}catch{$errors+=[string]$_}}
if($originalAcl){try{Set-Acl $destination $originalAcl;if((Get-Acl $destination).Sddl -cne $originalAcl.Sddl){throw 'Owned destination ACL restoration failed.'}}catch{$errors+=[string]$_}}
if($ownedSid){try{$current=Get-LocalUser -Name $username -ErrorAction Stop;if($current.SID.Value -cne $ownedSid){throw 'Account identity changed; refusing deletion.'};Remove-LocalUser -SID $ownedSid -ErrorAction Stop;if(Get-LocalUser -SID $ownedSid -ErrorAction SilentlyContinue){throw 'Owned account remains.'}}catch{$errors+=[string]$_}}
[pscustomobject]@{Passed=$passed;CleanupErrors=$errors;OwnedSid=$ownedSid;PolicyRestored=($errors.Count -eq 0);Scope='Actual local standard-user automatic native5.1 transcript and explicit denied writer; no UNC, PS7-session, collector or Sigma claim'}|ConvertTo-Json -Depth 6|Set-Content -LiteralPath (Join-Path $fixture 'acceptance.json') -Encoding UTF8
Write-Host "Native automatic transcript evidence: $fixture"
if($errors.Count){throw ($errors -join '; ')}
}
if(-not $passed){throw 'Native transcript acceptance incomplete.'}
+2
View File
@@ -9,6 +9,8 @@
**改善:**
- 既に有効なローカル保存先で、現在の実行ユーザーによる固定 Windows PowerShell 5.1 子プロセスの完了した自動トランスクリプトを検証する `transcript-probe` を追加しました。実トークン・ログオン、標準ヘッダー/フッター、nonce・PID・時刻、件数を制限したファイル識別と出典確認により不明な結果を保持し、ポリシーやACLの変更・明示的トランスクリプト開始・Sigma/EVTX加算は行いません。Server 2022/2025と両WELA実行エンジン向けに、使い捨て標準ユーザーの成功・拒否テストを追加しました。 (#436) (@Shirofune-Security)
- `file-sacl-recovery` を追加しました。元の計画、変更前後のレシート、成功結果、ソースとホストのハッシュ、実際のファイル識別情報を照合し、変更されていない選択済み単一ファイルに追加した監査 ACE だけを明示的に削除できます。書き込み前の永続記録と SACL 限定の変更・再読込により、無関係な ACE のバイト列と個数、観測した他の記述子要素を保持します。空または null の SACL が存在フラグ付きで残る場合を区別し、元の記述子との完全一致、ポリシー・ディレクトリ・レジストリの復旧、Sigma の利用可能性は保証しません。(#437) (@Shirofune-Security)
- 明示した IPv4 リゾルバーに固定の無害な `wela-<nonce>.wela.test.` A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (#434) (@Shirofune-Security)
+2
View File
@@ -9,6 +9,8 @@
**Improvements:**
- Added opt-in `transcript-probe` to verify a fixed current-account Windows PowerShell 5.1 child produces its own completed automatic transcript in an already enabled local destination. Actual token/logon, native header/footer, nonce/PID/time, bounded file identity and source checks preserve unverified outcomes without changing policy or ACLs, invoking explicit transcription or granting Sigma/EVTX credit. Added disposable standard-writer success/denial tests for Server 2022/2025 under both WELA host engines. (#436) (@Shirofune-Security)
- Added opt-in `file-sacl-recovery` to review and remove one proven explicit audit ACE from an unchanged selected leaf file. Original plans, paired receipts, successful results, source/host hashes and held file identity bind the operation; durable pre-write evidence and SACL-only readback preserve unrelated ACE bytes/counts and observed descriptor components. Empty or null present SACLs are reported without claiming exact historical descriptor restoration; policy, directory/registry recovery and Sigma readiness remain outside this scope. (#437) (@Shirofune-Security)
- Added opt-in `dns-client-probe` for one fixed benign `wela-<nonce>.wela.test.` A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (#434) (@Shirofune-Security)