Integrate reviewed event-log recovery and failed-logon changes

This commit is contained in:
Shirofune-Security committed 2026-09-21 22:20:02 +09:00
commit c4e9e765ff
20 files changed
+877 -1

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
name: Guarded event-log size and mode recovery
on:
push:
branches: ['**']
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
eventlog-recovery:
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: [windows-2022, windows-2025]
engine: [powershell, pwsh]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Fixtures and public guards in Windows PowerShell5.1
if: matrix.engine == 'powershell'
shell: powershell
run: |
./tests/EventLogRecovery.Tests.ps1
./tests/EventLogRecovery.Cli.Tests.ps1
- name: Native channel restoration in Windows PowerShell5.1
if: matrix.engine == 'powershell'
shell: powershell
run: ./tests/EventLogRecovery.Windows.Tests.ps1 -AllowDisposableChannelWrite
- name: Fixtures and public guards in PowerShell7
if: matrix.engine == 'pwsh'
shell: pwsh
run: |
./tests/EventLogRecovery.Tests.ps1
./tests/EventLogRecovery.Cli.Tests.ps1
- name: Native channel restoration in PowerShell7
if: matrix.engine == 'pwsh'
shell: pwsh
run: ./tests/EventLogRecovery.Windows.Tests.ps1 -AllowDisposableChannelWrite
- name: Retain owned fixture evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: eventlog-recovery-${{ matrix.os }}-${{ matrix.engine }}
path: ${{ runner.temp }}/wela-event-recovery-*/
if-no-files-found: warn
retention-days: 7
+47
View File
@@ -0,0 +1,47 @@
name: Native local failed-logon probe
on:
push:
branches: ['**']
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
failed-logon-probe:
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: [windows-2022, windows-2025]
engine: [powershell, pwsh]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Fixtures and public guards in Windows PowerShell5.1
if: matrix.engine == 'powershell'
shell: powershell
run: |
./tests/FailedLogonProbe.Tests.ps1
./tests/FailedLogonProbe.Cli.Tests.ps1
- name: Native local account failure and4625 in Windows PowerShell5.1
if: matrix.engine == 'powershell'
shell: powershell
run: ./tests/FailedLogonProbe.Windows.Tests.ps1 -AllowDisposableAuditWrite
- name: Fixtures and public guards in PowerShell7
if: matrix.engine == 'pwsh'
shell: pwsh
run: |
./tests/FailedLogonProbe.Tests.ps1
./tests/FailedLogonProbe.Cli.Tests.ps1
- name: Native local account failure and4625 in PowerShell7
if: matrix.engine == 'pwsh'
shell: pwsh
run: ./tests/FailedLogonProbe.Windows.Tests.ps1 -AllowDisposableAuditWrite
- name: Preserve bounded native evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: failed-logon-${{ matrix.os }}-${{ matrix.engine }}
path: ${{ runner.temp }}/wela-failed-logon-native-*/**
retention-days: 7
if-no-files-found: warn
+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/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-ingress.md -Destination release-binaries/docs/
Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-ingress.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md -Destination release-binaries/docs/
- name: Set Artifact Name
if: contains(matrix.info.os, 'windows') == true
+4
View File
@@ -6,6 +6,10 @@
- `transcription-recovery` を追加し、完了した Windows PowerShell 文字起こし設定を、再構築したハッシュ付き計画から型を保持して復元できるようにしました。ローカル保存先、ユーザー・ヘッダー・他のポリシーを確認し、一時停止は明示的な同意を求め、変更順序と途中結果を永続記録します。ヘルプと復旧手順には、一時停止中のエラーや中断でマシンの文字起こしが無効のまま残り、自動ロールバックや再有効化を行わないことを明記しました。使い捨て環境の実 CLI テストで復元とドリフト拒否を検証し、本番セッション・集中管理先の権限と収集・Sigma 対応は未検証とします。 (#376) (@Shirofune-Security)
- 完了したログ容量・保持モード設定を1件ずつ戻す `eventlog-recovery` を追加しました。元の記録と変更直前の記録、現在のチャネル・実行環境・コードを照合し、縮小と保持モード変更には個別の明示指定を必要とします。永続記録とネイティブ読戻しで無関係な設定を保持し、状態変化や再適用を拒否します。失われたイベント、長期保持、Sigma 利用可能性の証明は加算しません。 (@Shirofune-Security)
- `failed-logon-probe` を追加しました。存在しないことを確認したランダムなローカル SAM アカウントに対し、固定のネイティブログオン種別・プロバイダーで一度だけ認証を試行し、正確な時刻・プロセス・アカウント情報で Security4625 を照合します。監査設定を変更せず、保護された証跡を保存します。実際の資格情報、ドメインコントローラー、リモート認証、Sigma 対応率の加算は対象外です。使い捨て Windows 環境で公開コマンドと設定復元を検証します。(@Shirofune-Security)
- `wec-ingress` の Plan/Apply を追加し、明示した IPv4 範囲から Domain プロファイルの TCP5985 を許可する新規ルールを作成します。実ホスト・ログオン・プロファイル・コードと計画ハッシュ、変更前の永続記録、両ストアとフィルターの読戻しで変更や既存名を拒否します。既存の収集サーバー前提条件も、範囲を広げずに同等のIPv4ネットマスク表記・IPv6表記を照合します。使い捨て Windows テストは作成・名前衝突・再実行拒否・既存前提条件との連携・削除を検証し、リスナー・配送・Sigma の証明は加算しません。 (@Shirofune-Security)
- `smb-runtime` を追加し、Windows 標準の SMB 監査スイッチ6個を明示的に有効化します。モジュール・ビルド・ADMX、型付きポリシーの競合、設定全体の変化を確認し、各変更の意図と確認結果を永続的に記録します。署名・暗号化・ゲスト接続・サービス設定を保持し、現在の有効化とイベント・永続性・Sigma の証明を区別します。使い捨て Server 2025 の有効化と復元、Server 2022 の拒否を PowerShell 5.1/7 で検証します。 (#441) (@Shirofune-Security)
+4
View File
@@ -6,6 +6,10 @@
- Added explicit `transcription-recovery` for one completed Windows PowerShell transcription configuration, with independently rebuilt hashed plans, typed local-directory restoration, preserved user/header/other policy, explicit temporary-suspension consent and durable ordered receipts. Help and recovery guidance warn that interrupted suspension can leave machine transcription disabled without automatic rollback or re-enable. Disposable native public-CLI tests verify restoration and drift refusal; production sessions, central authorization/collection and Sigma readiness remain unverified. (#376) (@Shirofune-Security)
- Added reviewed `eventlog-recovery` for one completed profile size/retention write. Matched original and immediate-prewrite evidence, current channel/context/source guards, separate shrink/retention consent, durable pending receipts and native readback preserve unrelated channel settings and refuse drift or replay. Windows fixtures restore original settings; lost events, sustained retention and Sigma readiness are not inferred. (@Shirofune-Security)
- Added opt-in `failed-logon-probe` for one generated, confirmed nonexistent local SAM account attempt with fixed native logon type/provider, precise worker timing and exact Security4625 correlation. Protected receipts preserve raw evidence and unchanged audit/channel/token context; real credentials, domain controllers, remote authentication and Sigma credit are excluded. Disposable Windows tests cover native public runs and exact fixture cleanup. (@Shirofune-Security)
- Added explicit `wec-ingress` Plan/Apply for one new, narrowly scoped Domain TCP5985 collector firewall rule. Actual host/logon/profile/source guards, reviewed hashes, flushed pending evidence and both-store/filter readback refuse drift and existing names; partial creation remains explicit. The existing collector prerequisite recognizes equivalent native dotted netmasks and IPv6 spellings without broadening accepted scopes. Disposable native tests cover creation, collision, replay, collector integration and cleanup without listener, delivery or Sigma claims. (@Shirofune-Security)
- Added explicit `smb-runtime` activation of six native SMB audit switches, with reviewed module/build/ADMX capabilities, typed policy conflicts, complete configuration drift guards and durable per-switch receipts. Signing, encryption, guest access and service settings are preserved; runtime verification stays separate from events, persistence and Sigma credit. Disposable Server 2025 activation/restoration and Server 2022 refusal tests cover PowerShell 5.1/7. (#441) (@Shirofune-Security)
+34
View File
@@ -44,6 +44,9 @@
[ValidateSet('Audit', 'Plan', 'Import')][string]$AppLockerAction = 'Audit',
[string]$AppLockerPolicyPath,
[ValidateSet('List', 'Audit', 'Plan', 'Configure')][string]$WmiAction = 'List',
[ValidateSet('Plan','Run')][string]$FailedLogonAction = 'Plan',
[string]$FailedLogonOutputPath,
[ValidateRange(1,30)][int]$FailedLogonTimeoutSeconds = 15,
[ValidateSet('Plan','Run')][string]$WmiProbeAction = 'Plan',
[string]$WmiProbeNamespace,
[string]$WmiProbeOutputPath,
@@ -113,6 +116,15 @@
[string]$TranscriptRecoveryPlanHash,
[string]$TranscriptRecoveryOutputPath,
[switch]$TranscriptRecoveryAllowTemporarySuspension,
[ValidateSet('Plan','Restore')][string]$EventRecoveryAction = 'Plan',
[string]$EventRecoveryJournalPath,
[string]$EventRecoveryOriginalResultsPath,
[string]$EventRecoveryLog,
[string]$EventRecoveryPlanPath,
[string]$EventRecoveryPlanHash,
[string]$EventRecoveryOutputPath,
[switch]$EventRecoveryAllowShrink,
[switch]$EventRecoveryAllowRetentionChange,
[ValidateSet('Plan','Restore')][string]$RecoveryAction = 'Plan',
[string]$RecoveryJournalPath,
[string]$RecoveryOriginalResultsPath,
@@ -191,6 +203,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json"
. (Join-Path $ScriptRoot "scripts/AppLockerProbe.ps1")
. (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1")
. (Join-Path $ScriptRoot "scripts/WmiProbe.ps1")
. (Join-Path $ScriptRoot "scripts/FailedLogonProbe.ps1")
. (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1")
Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/RuleEligibility.psm1") -ErrorAction Stop
@@ -198,6 +211,7 @@ Import-Module (Join-Path $ScriptRoot "modules/AuditCatalog.psm1") -ErrorAction S
Import-Module (Join-Path $ScriptRoot "modules/NativeProviders.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorAction Stop
. (Join-Path $ScriptRoot "scripts/EventLogConfiguration.ps1")
. (Join-Path $ScriptRoot "scripts/EventLogRecovery.ps1")
Import-Module (Join-Path $ScriptRoot "modules/NativeChannelAccess.psm1") -ErrorAction Stop
. (Join-Path $ScriptRoot "scripts/NativeChannelConfiguration.ps1")
. (Join-Path $ScriptRoot "scripts/ChannelRead.ps1")
@@ -1994,9 +2008,11 @@ Usage:
./WELA.ps1 score -Help # Separate configuration compliance and evidence-qualified readiness
./WELA.ps1 intune-export -Help # Offline native audit OMA-URI/Graph artifacts; no tenant changes
./WELA.ps1 adcs-resume -Help # Review a pending CA auditing restart
./WELA.ps1 eventlog-recovery -Help # Review restoration of one completed log size/mode write
./WELA.ps1 wec-ingress -Help # Review scoped collector firewall rule creation
./WELA.ps1 wec-state -Help # Review enable/disable of one existing subscription
./WELA.ps1 wec-update -Help # Review query/description updates on a disabled subscription
./WELA.ps1 failed-logon-probe -Help # Fixed nonexistent local account and matched Security4625 evidence
./WELA.ps1 wmi-probe -Help # Fixed local read and matched namespace Security4662 evidence
./WELA.ps1 applocker-probe -Help # Collect a fixed native AppLocker EXE event
./WELA.ps1 wef-arrival -Help # Verify exact native probe presence on the local collector
@@ -2076,6 +2092,8 @@ if ($PSBoundParameters.ContainsKey('ProfileFile')) {
if ($Cmd -eq 'profiles' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','ProfileFile','Help') }).Count) { throw 'profiles -ProfileFile lists the selected file and accepts no assessment/configuration options.' }
}
if ($Cmd -ne 'eventlog-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'EventRecovery*'}).Count) {throw 'EventRecovery options require eventlog-recovery.'}
if ($Cmd -eq 'eventlog-recovery' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','EventRecoveryAction','EventRecoveryJournalPath','EventRecoveryOriginalResultsPath','EventRecoveryLog','EventRecoveryPlanPath','EventRecoveryPlanHash','EventRecoveryOutputPath','EventRecoveryAllowShrink','EventRecoveryAllowRetentionChange','Help')}).Count)) {throw 'eventlog-recovery accepts only dedicated options.'}
if ($Cmd -ne 'wec-ingress' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecIngress*'}).Count) {throw 'WecIngress options require wec-ingress.'}
if ($Cmd -eq 'wec-ingress' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecIngressAction','WecIngressName','WecIngressLocalAddress','WecIngressRemoteAddress','WecIngressPlanPath','WecIngressPlanHash','WecIngressOutputPath','Help')}).Count) {throw 'wec-ingress accepts only dedicated options.'}
if ($Cmd -ne 'wec-state' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecState*'}).Count) {throw 'WecState options require wec-state.'}
@@ -2088,6 +2106,8 @@ if ($Cmd -ne 'wec-runtime' -and @($PSBoundParameters.Keys | Where-Object {$_ -li
if ($Cmd -eq 'wec-runtime' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecRuntimeId','WecRuntimeMaximumSources','ResultsPath','Help')}).Count) {
throw 'wec-runtime accepts only selected runtime IDs, source cap and a new result path. No command was run.'
}
if ($Cmd -ne 'failed-logon-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'FailedLogon*'}).Count) {throw 'FailedLogon options require failed-logon-probe.'}
if ($Cmd -eq 'failed-logon-probe' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','FailedLogonAction','FailedLogonOutputPath','FailedLogonTimeoutSeconds','Help')}).Count)) {throw 'failed-logon-probe accepts only dedicated probe options.'}
if ($Cmd -ne 'wmi-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WmiProbe*'}).Count) {throw 'WmiProbe options require wmi-probe.'}
if ($Cmd -eq 'wmi-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WmiProbeAction','WmiProbeNamespace','WmiProbeOutputPath','WmiProbeTimeoutSeconds','Help')}).Count) {throw 'wmi-probe accepts only dedicated probe options.'}
if ($Cmd -ne 'applocker-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -in @('AppLockerProbeAction','AppLockerProbeOutputPath','AppLockerProbeTimeoutSeconds')}).Count) {throw 'AppLocker probe options require applocker-probe.'}
@@ -2287,6 +2307,14 @@ switch ($Cmd.ToLower()) {
$report
if ($report.ExitCode) {exit $report.ExitCode}
}
'eventlog-recovery' {
if ($Help) {Write-Host 'Usage: eventlog-recovery [-EventRecoveryAction Plan] -EventRecoveryJournalPath before.jsonl -EventRecoveryOriginalResultsPath results.json -EventRecoveryLog channel -EventRecoveryOutputPath new-directory; then Restore with -EventRecoveryPlanPath plan.json -EventRecoveryPlanHash SHA256 -EventRecoveryOutputPath new-directory and applicable -EventRecoveryAllowShrink / -EventRecoveryAllowRetentionChange. See docs/eventlog-recovery.md.';return}
$arguments=@{Action=$EventRecoveryAction;OutputPath=$EventRecoveryOutputPath;AllowShrink=$EventRecoveryAllowShrink;AllowRetentionChange=$EventRecoveryAllowRetentionChange}
$map=@{EventRecoveryJournalPath='JournalPath';EventRecoveryOriginalResultsPath='OriginalResultsPath';EventRecoveryLog='Log';EventRecoveryPlanPath='PlanPath';EventRecoveryPlanHash='PlanHash'}
foreach($name in $map.Keys){if($PSBoundParameters.ContainsKey($name)){$arguments[$map[$name]]=$PSBoundParameters[$name]}}
$report=Invoke-WelaEventLogRecovery @arguments;$report
if($report.ExitCode){exit $report.ExitCode}
}
'wec-ingress' {
if ($Help) {Write-Host 'Usage: wec-ingress [-WecIngressAction Plan] -WecIngressName WELA-WEC-name -WecIngressLocalAddress IPv4 -WecIngressRemoteAddress IPv4/CIDR -WecIngressOutputPath new-directory; then Apply with -WecIngressPlanPath plan.json -WecIngressPlanHash SHA256 -WecIngressOutputPath new-directory. Creates one new Domain TCP5985 rule. See docs/wec-ingress.md.';return}
$arguments=@{Action=$WecIngressAction;OutputPath=$WecIngressOutputPath}
@@ -2313,6 +2341,12 @@ switch ($Cmd.ToLower()) {
$report
if($report.ExitCode){exit $report.ExitCode}
}
'failed-logon-probe' {
if ($Help) {Write-Host 'Usage: failed-logon-probe [-FailedLogonAction Plan|Run] [-FailedLogonOutputPath new-private-directory] [-FailedLogonTimeoutSeconds 1..30]. One fixed nonexistent local account attempt under existing failure auditing. Domain controllers excluded. No real credentials or configuration changes. See docs/failed-logon-probe.md.';return}
$report=Invoke-WelaFailedLogonProbe -Action $FailedLogonAction -OutputPath $FailedLogonOutputPath -TimeoutSeconds $FailedLogonTimeoutSeconds
$report
if($report.ExitCode){exit $report.ExitCode}
}
'wmi-probe' {
if ($Help) {Write-Host 'Usage: wmi-probe [-WmiProbeAction Plan|Run] -WmiProbeNamespace root\default [-WmiProbeOutputPath new-private-directory] [-WmiProbeTimeoutSeconds 1..30]. Fixed local read only; requires existing matching SACL and auditing. No policy changes, remote access or Sigma credit. See docs/wmi-probe.md.';return}
$report=Invoke-WelaWmiProbe -Action $WmiProbeAction -Namespace $WmiProbeNamespace -OutputPath $WmiProbeOutputPath -TimeoutSeconds $WmiProbeTimeoutSeconds
+32
View File
@@ -0,0 +1,32 @@
# Reviewed event-log size and retention recovery
`eventlog-recovery` restores the size and retention mode immediately before one completed WELA profile operation. It supports administrative and operational channels in the bundled event-log profiles on reviewed Windows 11 / Server 2022 and 2025 builds. This is part of issues #379 and #365; it does not recover records already lost.
Use the original `before.jsonl` and final results from `configure-eventlogs` or the same profile helper used by `configure`. The selected result must be `Applied`, with both the initial and `ImmediatePreWrite` journal entries and matching final `BeforeWrite`. Failed, overridden, incomplete, legacy scalar writes and unexplained changes require manual investigation. Other journaled controls are not restored.
```powershell
./WELA.ps1 eventlog-recovery -EventRecoveryJournalPath C:\Evidence\original\before.jsonl `
-EventRecoveryOriginalResultsPath C:\Evidence\original-results.json `
-EventRecoveryLog ForwardedEvents -EventRecoveryOutputPath C:\Evidence\recovery-plan
# Inspect plan.json: current and original sizes, modes, channel guard and consent flags.
# Supply the exact PlanHash shown by Plan after reviewing that file.
./WELA.ps1 eventlog-recovery -EventRecoveryAction Restore `
-EventRecoveryPlanPath C:\Evidence\recovery-plan\plan.json `
-EventRecoveryPlanHash '<reviewed SHA256>' -EventRecoveryOutputPath C:\Evidence\recovery-run `
-EventRecoveryAllowShrink -EventRecoveryAllowRetentionChange
```
The last two switches are separate consent for the effects actually identified by the plan. Omit them when inapplicable. **Shrinking can discard existing events.** Changing to Circular allows older records to be overwritten; changing to Retain can discard incoming records when full; leaving AutoBackup stops automatic archival. Review storage and recovery requirements before consenting. Plan writes review evidence but changes no Windows settings. Restore does not export or clear logs, restore an archive, alter channel enablement/ACL/path/provider settings or restart services.
Each output must be a fresh directory on a local fixed drive, with an existing parent. Evidence is protected for the current operator, Administrators and SYSTEM. The plan is bound to the actual current host/MachineGuid, operator logon, original input bytes and implementation/catalog hashes. Use the same checkout and elevated operator logon for Restore. Winmgmt and EventLog must already be running; host observations use the existing reviewed-build gate. The original version-1 journal records only historical ComputerName: current host bindings and hashes do not authenticate that history.
The plan is rebuilt from original evidence on Restore. Minimum-size writes are checked against the immediate-prewrite size so an independent increase during prompting is preserved. An unexplained larger final size is refused. Current size/mode/enable state must match the confirmed post-configuration state. Current channel path, ACL, isolation, type, owning provider and classic-log flag are captured when planning and must remain unchanged. Live event count and EVTX file allocation are intentionally not treated as configuration guards.
Restore flushes a Pending receipt before one fixed local `wevtutil sl` operation, rechecks the inputs and current channel, changes only the required size/mode arguments, and records final native readback. There is no atomic compare-and-set in this interface. A concurrent policy refresh or writer can still intervene, and verified values do not prove persistence.
`RestoredAndVerified` means the requested size/mode and preserved configuration matched during readback. `Refused` means no native write was attempted. `RestoreAttemptedUnverified` means a write may have partly succeeded; inspect the Pending receipt and any after-state evidence before further action. Automatic rollback and replay against the already-restored state are refused. A fatal evidence-write error may leave only a Pending receipt; keep it for investigation.
The Windows fixture uses genuine public configuration of the disposable runner's ForwardedEvents channel, then public planning, consent refusal, actual drift refusal, restoration and replay refusal. It restores the original channel configuration and compares every original audit mask. Matrices cover Server 2022/2025 and PowerShell 5.1/7; the test proves configuration behavior, not historical record preservation, achieved retention, forwarding or Sigma readiness. Sysmon is excluded.
Reference: [Microsoft wevtutil size, retention and auto-backup options](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/wevtutil).
+26
View File
@@ -0,0 +1,26 @@
# Native local failed-logon probe
`failed-logon-probe` checks whether one fixed native local authentication failure can be correlated with its Security4625 event. It does not enable auditing. Sysmon is outside this workflow.
```powershell
.\WELA.ps1 failed-logon-probe
.\WELA.ps1 failed-logon-probe -FailedLogonAction Run -FailedLogonOutputPath C:\Evidence\failed-logon-01
```
The default Plan reads prerequisites and the actual latest Security record. It creates no files and makes no authentication attempt. Run requires a new private output directory and an elevated, unimpersonated 64-bit primary token. Existing Logon failure auditing, DWORD1 `SCENoApplyLegacyAuditPolicy`, an enabled/readable Security channel and running native observation/authentication services are prerequisites. Reviewed Windows11 and Server2022/2025 clients, standalone and member servers are accepted. Domain controllers are excluded because their account database is the domain database; native CI covers disposable workgroup Server2022/2025 under PowerShell5.1 and7, not client/domain policy variants.
Run generates a20-character account name from a fresh GUID and calls `NetUserGetInfo` against the local database. Only exact `NERR_UserNotFound` permits the next step. A fixed native `LogonUserW` call uses domain `.` (local account database only), network logon type3 and the NTLM provider2. The actual local native event identifies its package as `MICROSOFT_AUTHENTICATION_PACKAGE_V1_0`, which the matcher requires exactly; the provider choice does not imply that the XML field is the literal `NTLM`. A fixed public dummy string is not a real credential. There is exactly one attempt, with no retry, account creation, remote target or user-selected credential. The expected native result is failure1326. Any unexpected success closes the returned token without using it and remains unverified. The worker never impersonates.
The worker uses the current PowerShell executable and process-only execution-policy Bypass to load its fixed script. It has a20-second process bound. The optional `-FailedLogonTimeoutSeconds 1..30` controls event-delivery polling only; it never repeats authentication. Precise native UTC timestamps bound the actual authentication call without padding. A fresh Security record boundary, worker process/path, caller SID/logon session, exact generated account/domain, logon type/provider, and failure status/substatus must match exactly one provider/version0 Security4625. Provider schema differences, missing events, duplicate matches, denied reads, caps, token changes, policy/source/host/channel drift and a backwards record boundary remain unverified.
Evidence includes a durable `intent.json` before launching, the native receipt, before/after observations, exact raw XML, and a manifest with SHA256 artifact hashes. A timeout or failed receipt leaves the intent so an operator can see that an attempt may have occurred; absence of a successful report does not establish that no attempt happened. Record boundaries and hashes detect selected inconsistencies; they are not a tamper-proof log-continuity or machine-attestation mechanism. Keep the entire directory together. No policy, channel, service, account or trust configuration is written by the product.
The observed result proves this one local nonexistent-account failure only. It does not prove remote or domain authentication, real-account password failures, lockout handling, forwarding, SIEM parsing, every failed-logon variant or Sigma rule readiness. `ReadyRuleCredit` remains0. The attempt creates expected authentication telemetry and may be visible to local monitoring.
## Validation and references
Portable fixtures test exact100ns time boundaries, account/status/process/token mismatches, malformed XML, duplicate events, source drift, caps, failures and public CLI guards. The native workflow prepares only the disposable fixture's Logon failure mask and audit precedence, executes two independent public runs, validates actual4625 XML/receipt hashes, verifies unchanged local accounts and restores all59 audit masks and the original typed precedence.
Microsoft documents the local-domain behavior and native return contract in [LogonUserW](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-logonuserw), account lookup in [NetUserGetInfo](https://learn.microsoft.com/en-us/windows/win32/api/lmaccess/nf-lmaccess-netusergetinfo), and the event fields/statuses in [4625: An account failed to log on](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/auditing/event-4625).
The local package behavior is described in Microsoft's [MSV1_0 authentication package documentation](https://learn.microsoft.com/en-us/windows/win32/secauthn/msv1-0-authentication-package). Exact event-package spelling is additionally verified from retained native XML.
+148
View File
@@ -0,0 +1,148 @@
# Restore one completed profile size/mode write; never replay arbitrary wevtutil arguments.
function Get-WelaEventRecoverySources {
$sources=[ordered]@{}
foreach($name in @('WELA.ps1','scripts/EventLogRecovery.ps1','scripts/EventLogConfiguration.ps1','modules/EventLogSettings.psm1','config/eventlog_profiles.json','scripts/Configuration.ps1','scripts/ControlApplicability.ps1','scripts/AuditRecovery.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){
$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
}
$sources|ConvertTo-Json -Compress
}
function Get-WelaEventRecoveryContext {
foreach($name in @('Winmgmt','EventLog')){if((Get-Service -Name $name -ErrorAction Stop).Status -ne 'Running'){throw 'Native observation services must already be running.'}}
$reader=Get-WelaChannelReader
if(-not $reader.ElevatedAdministrator){throw 'An elevated native Windows operator is required.'}
[pscustomobject][ordered]@{Host=(Get-WelaRecoveryHost);ReviewedHost=(Get-WelaChannelReadHost);Reader=[ordered]@{Sid=$reader.UserSid;Logon=$reader.AuthenticationId;Groups=$reader.GroupSids}}
}
function Read-WelaEventRecoveryChannel {
param([string]$Log)
$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new($Log)
try {
if($channel.LogName -cne $Log -or [string]$channel.LogType -notin @('Administrative','Operational')){throw 'An exact administrative or operational channel is required.'}
[pscustomobject][ordered]@{
Log=$channel.LogName;MaximumSizeInBytes=[long]$channel.MaximumSizeInBytes;LogMode=[string]$channel.LogMode
Guard=[ordered]@{IsEnabled=[bool]$channel.IsEnabled;LogType=[string]$channel.LogType;Isolation=[string]$channel.LogIsolation;Path=[string]$channel.LogFilePath;SecurityDescriptor=[string]$channel.SecurityDescriptor;Provider=[string]$channel.OwningProviderName;Classic=[bool]$channel.IsClassicLog}
}
}finally{$channel.Dispose()}
}
function Assert-WelaEventRecoveryText {
param($Value,[string[]]$Names)
foreach($name in $Names){if($Value.$name -isnot [string]){throw ('Missing or mistyped recovery text field: '+$name)}}
}
function Assert-WelaEventRecoveryState {
param($State,[string]$Log)
Assert-WelaEventRecoveryText $State @('Log','ReadStatus','Diagnostic','LogMode')
if($State.Log -cne $Log -or $State.ReadStatus -cne 'Available' -or $State.Diagnostic -cne '' -or $State.IsEnabled -isnot [bool] -or
($State.MaximumSizeInBytes -isnot [int] -and $State.MaximumSizeInBytes -isnot [long]) -or $State.MaximumSizeInBytes -lt 1048576 -or $State.MaximumSizeInBytes -gt 2199023255552 -or $State.MaximumSizeInBytes % 65536 -ne 0 -or $State.LogMode -cnotin @('Circular','Retain','AutoBackup')){throw 'Original channel state is unavailable, mistyped or unsupported.'}
}
function Get-WelaEventRecoveryPair {param($Value) [pscustomobject][ordered]@{MaximumSizeInBytes=[long]$Value.MaximumSizeInBytes;LogMode=[string]$Value.LogMode}}
function Get-WelaEventRecoveryDefinition {
param([string]$JournalPath,[string]$ResultsPath,[string]$Log)
$catalog=Import-WelaEventLogProfiles
if($Log -cnotin @($catalog.profiles.controls.log)){throw 'Select an exact channel in the bundled event-log profiles.'}
$context=Get-WelaEventRecoveryContext
$journal=Read-WelaWecUpdateFile $JournalPath;$resultFile=Read-WelaWecUpdateFile $ResultsPath
$entries=@($journal.Text -split '\r?\n'|Where-Object {$_ -match '\S'}|ForEach-Object {ConvertFrom-WelaArrivalJson $_})
if($entries.Count -lt 1 -or $entries.Count -gt 1024){throw 'Expected 1-1024 bounded journal entries.'}
$result=ConvertFrom-WelaArrivalJson $resultFile.Text
Assert-WelaEventRecoveryText $result @('Scope')
if($result.DryRun -isnot [bool] -or $result.DryRun -or $result.Results -isnot [array] -or $result.Results.Count -gt 2048 -or $result.Scope -cnotin @('native-windows-configuration','event-log-size-and-mode-only')){throw 'Expected original non-dry-run event-log configuration results.'}
$id='EventLog/'+$Log+'/ProfileSettings'
$rows=@($result.Results|Where-Object Id -eq $id);$matching=@($entries|Where-Object Id -eq $id)
if($rows.Count -ne 1 -or $matching.Count -ne 2){throw 'Exactly one result and its original/immediate-prewrite journal pair are required.'}
$row=$rows[0];$initial=$matching[0];$fresh=$matching[1]
Assert-WelaEventRecoveryText $row @('Status','Kind','Id')
Assert-WelaEventRecoveryText $fresh @('Phase')
if($initial.PSObject.Properties['Phase'] -or $fresh.Phase -cne 'ImmediatePreWrite' -or $row.Status -cne 'Applied' -or $row.Kind -cne 'EventLog' -or $row.Id -cne $id){throw 'Only completed Applied profile writes with ordered immediate-prewrite evidence are supported.'}
foreach($entry in $matching){
Assert-WelaEventRecoveryText $entry @('ComputerName','Kind','Id')
if(($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.ComputerName -ine $context.Host.Computer -or $entry.Kind -cne 'EventLog' -or $entry.Id -cne $id){throw 'Unknown or wrong-host event-log journal.'}
$time=ConvertTo-WelaArrivalUtc $entry.RecordedUtc;if($time -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Future journal timestamp.'}
}
if((ConvertTo-WelaArrivalUtc $fresh.RecordedUtc) -lt (ConvertTo-WelaArrivalUtc $initial.RecordedUtc)){throw 'Journal times are reversed.'}
foreach($field in @('Before','Target','Desired')){if((Get-WelaRecoveryKey $initial.$field) -cne (Get-WelaRecoveryKey $row.$field)){throw "Original/result $field differs."}}
Assert-WelaArrivalObject $initial.Target @('Log','Profile');Assert-WelaArrivalObject $fresh.Target @('Log')
Assert-WelaEventRecoveryText $initial.Target @('Log','Profile');Assert-WelaEventRecoveryText $fresh.Target @('Log')
if($initial.Target.Log -cne $Log -or $fresh.Target.Log -cne $Log -or $initial.Target.Profile -isnot [string]){throw 'Contradictory channel identity.'}
$profile=Get-WelaEventLogProfile $initial.Target.Profile;$control=@($profile.controls|Where-Object log -ceq $Log)
if($control.Count -ne 1){throw 'Channel is not selected by the original bundled profile.'}
Assert-WelaArrivalObject $initial.Desired @('MaximumSizeInBytes','SizeMode','LogMode')
Assert-WelaEventRecoveryText $initial.Desired @('SizeMode');Assert-WelaEventRecoveryText $fresh.Desired @('SizeMode')
if($null -ne $initial.Desired.LogMode){Assert-WelaEventRecoveryText $initial.Desired @('LogMode')}
if((Get-WelaRecoveryKey $initial.Desired) -cne (Get-WelaRecoveryKey $fresh.Desired) -or $initial.Desired.SizeMode -cnotin @('Exact','Minimum') -or ($null -ne $initial.Desired.LogMode -and $initial.Desired.LogMode -cne $control[0].mode) -or
($initial.Desired.MaximumSizeInBytes -isnot [int] -and $initial.Desired.MaximumSizeInBytes -isnot [long]) -or $initial.Desired.MaximumSizeInBytes -ne (ConvertTo-WelaEventLogBytes $control[0].minimumBytes)){throw 'Desired configuration differs from the canonical profile operation.'}
foreach($state in @($initial.Before,$fresh.Before,$row.After)){Assert-WelaEventRecoveryState $state $Log}
if((Get-WelaRecoveryKey $fresh.Before) -cne (Get-WelaRecoveryKey $row.BeforeWrite)){throw 'Immediate prewrite and final BeforeWrite evidence differ.'}
if($row.After.IsEnabled -ne $fresh.Before.IsEnabled){throw 'Channel enable state changed during original operation.'}
$bytes=if($initial.Desired.SizeMode -ceq 'Exact'){$initial.Desired.MaximumSizeInBytes}else{[math]::Max([long]$fresh.Before.MaximumSizeInBytes,[long]$initial.Desired.MaximumSizeInBytes)}
$mode=if($null -ne $initial.Desired.LogMode){$initial.Desired.LogMode}else{$fresh.Before.LogMode}
if($row.After.MaximumSizeInBytes -ne $bytes -or $row.After.LogMode -cne $mode){throw 'Final state includes unexplained drift beyond the original size/mode write.'}
$expected=Get-WelaEventRecoveryPair $row.After;$recover=Get-WelaEventRecoveryPair $fresh.Before
if((Get-WelaRecoveryKey $expected) -ceq (Get-WelaRecoveryKey $recover)){throw 'No completed size/mode change exists to recover.'}
[pscustomobject][ordered]@{
Log=$Log;Profile=$profile.id;Journal=[ordered]@{Path=$journal.Path;Hash=$journal.Hash};OriginalResults=[ordered]@{Path=$resultFile.Path;Hash=$resultFile.Hash}
Expected=$expected;RecoverTo=$recover;ExpectedEnabled=$row.After.IsEnabled
RequiresShrinkConsent=($recover.MaximumSizeInBytes -lt $expected.MaximumSizeInBytes);RequiresModeConsent=($recover.LogMode -cne $expected.LogMode)
HistoricalIdentity='Version1 records bind historical ComputerName only. Current host/logon and source hashes do not authenticate historical ownership or configuration.'
}
}
function Assert-WelaEventRecoveryCurrent {
param($Definition,$Observed,$Guard)
if($Observed.Log -cne $Definition.Log -or $Observed.Guard.IsEnabled -ne $Definition.ExpectedEnabled -or
(Get-WelaRecoveryKey (Get-WelaEventRecoveryPair $Observed)) -cne (Get-WelaRecoveryKey $Definition.Expected) -or
($null -ne $Guard -and (Get-WelaRecoveryKey $Observed.Guard) -cne (Get-WelaRecoveryKey $Guard))){throw 'Current channel size, mode, identity or preserved properties differ from reviewed post-configuration state.'}
}
function Set-WelaEventRecoveryChannel {
param($Definition)
$arguments=@('sl',$Definition.Log)
if($Definition.RecoverTo.MaximumSizeInBytes -ne $Definition.Expected.MaximumSizeInBytes){$arguments+='/ms:'+ $Definition.RecoverTo.MaximumSizeInBytes}
if($Definition.RecoverTo.LogMode -cne $Definition.Expected.LogMode){
switch($Definition.RecoverTo.LogMode){'Circular'{$arguments+=@('/rt:false','/ab:false')};'Retain'{$arguments+=@('/rt:true','/ab:false')};'AutoBackup'{$arguments+=@('/rt:true','/ab:true')};default{throw 'Unsupported recovery mode.'}}
}
if($arguments.Count -le 2){throw 'No fixed recovery argument was selected.'}
$null=Invoke-WelaNative -FilePath (Join-Path ([Environment]::GetFolderPath('System')) 'wevtutil.exe') -Arguments $arguments
}
function Invoke-WelaEventLogRecovery {
param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$JournalPath,[string]$OriginalResultsPath,[string]$Log,[string]$PlanPath,[string]$PlanHash,[Parameter(Mandatory)][string]$OutputPath,[switch]$AllowShrink,[switch]$AllowRetentionChange)
$ErrorActionPreference='Stop'
if($Action -eq 'Plan'){
if(-not $JournalPath -or -not $OriginalResultsPath -or -not $Log -or $PlanPath -or $PlanHash -or $AllowShrink -or $AllowRetentionChange){throw 'Plan requires original journal/results, exact channel and new output; restore-only options are not accepted.'}
$source=Read-WelaWecUpdateFile $JournalPath
}else{
if(-not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or $JournalPath -or $OriginalResultsPath -or $Log){throw 'Restore requires only reviewed plan path/hash, new output and applicable explicit loss/retention consent.'}
$source=Read-WelaWecUpdateFile $PlanPath
}
$output=New-WelaArrivalOutput $OutputPath $source.Path
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaEventLogRecovery';Action=$Action;Status='Refused';ExitCode=1;OutputPath=$output;PlanHash=$null;NativeWriteAttempted=$false;After=$null;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;Scope='One completed profile size/mode operation. Shrinking or changing retention may discard events or stop archival; existing records and sustained retention are not proven. Sysmon excluded.'}
try{
$context=Get-WelaEventRecoveryContext;$contextKey=Get-WelaRecoveryKey $context;$sources=Get-WelaEventRecoverySources
if($Action -eq 'Plan'){
$definition=Get-WelaEventRecoveryDefinition $JournalPath $OriginalResultsPath $Log
$observed=Read-WelaEventRecoveryChannel $Log;Assert-WelaEventRecoveryCurrent $definition $observed $null
$plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaEventLogRecoveryPlan';Definition=$definition;ContextKey=$contextKey;Sources=$sources;Guard=$observed.Guard}
if((Get-WelaRecoveryKey (Get-WelaEventRecoveryDefinition $JournalPath $OriginalResultsPath $Log)) -cne (Get-WelaRecoveryKey $definition) -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryContext)) -cne $contextKey -or (Get-WelaEventRecoverySources) -cne $sources){throw 'Input, host or code changed while planning.'}
Assert-WelaEventRecoveryCurrent $definition (Read-WelaEventRecoveryChannel $Log) $plan.Guard
$artifact=Write-WelaWecUpdateArtifact $output 'plan.json' ($plan|ConvertTo-Json -Depth 20);$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired';$report.ExitCode=0
}else{
if($source.Hash -cne $PlanHash){throw 'Reviewed plan hash differs.'}
$plan=ConvertFrom-WelaArrivalJson $source.Text;Assert-WelaArrivalObject $plan @('SchemaVersion','Kind','Definition','ContextKey','Sources','Guard')
Assert-WelaEventRecoveryText $plan @('Kind','ContextKey','Sources')
if(($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1 -or $plan.Kind -cne 'WelaEventLogRecoveryPlan' -or $plan.ContextKey -cne $contextKey -or $plan.Sources -cne $sources){throw 'Reviewed plan schema, context or code differs.'}
$definition=Get-WelaEventRecoveryDefinition $plan.Definition.Journal.Path $plan.Definition.OriginalResults.Path $plan.Definition.Log
if((Get-WelaRecoveryKey $definition) -cne (Get-WelaRecoveryKey $plan.Definition)){throw 'Recovery plan differs from independently rebuilt original evidence.'}
if($definition.RequiresShrinkConsent -and -not $AllowShrink){throw 'Restoring the original smaller buffer requires explicit AllowShrink; existing events may be discarded.'}
if($definition.RequiresModeConsent -and -not $AllowRetentionChange){throw 'Restoring a different retention mode requires explicit AllowRetentionChange.'}
Assert-WelaEventRecoveryCurrent $definition (Read-WelaEventRecoveryChannel $definition.Log) $plan.Guard
$report.PlanHash=$PlanHash;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $source.Text
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'before-restore.json' ([ordered]@{Status='Pending';Definition=$definition;Guard=$plan.Guard;Context=$context;AllowShrink=[bool]$AllowShrink;AllowRetentionChange=[bool]$AllowRetentionChange;RecordedUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 20)
if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaEventRecoverySources) -cne $sources -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryContext)) -cne $contextKey -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryDefinition $definition.Journal.Path $definition.OriginalResults.Path $definition.Log)) -cne (Get-WelaRecoveryKey $definition)){throw 'Plan, source, context or original evidence changed immediately before restore.'}
foreach($artifact in $report.Artifacts){if((Read-WelaWecUpdateFile (Join-Path $output $artifact.Name)).Hash -cne $artifact.Sha256){throw 'Saved recovery evidence changed before write.'}}
Assert-WelaEventRecoveryCurrent $definition (Read-WelaEventRecoveryChannel $definition.Log) $plan.Guard
$report.NativeWriteAttempted=$true;Set-WelaEventRecoveryChannel $definition
$report.After=Read-WelaEventRecoveryChannel $definition.Log
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after.json' ($report.After|ConvertTo-Json -Depth 12)
if((Get-WelaRecoveryKey (Get-WelaEventRecoveryPair $report.After)) -cne (Get-WelaRecoveryKey $definition.RecoverTo) -or (Get-WelaRecoveryKey $report.After.Guard) -cne (Get-WelaRecoveryKey $plan.Guard) -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryContext)) -cne $contextKey -or (Get-WelaEventRecoverySources) -cne $sources -or (Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Restored size/mode or preserved properties, context or sources differ.'}
$report.Status='RestoredAndVerified';$report.ExitCode=0
}
}catch{$report.Status=if($report.NativeWriteAttempted){'RestoreAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message}
$null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 24)
$report
}
+151
View File
@@ -0,0 +1,151 @@
# One local nonexistent-account attempt under already configured failure auditing.
function Initialize-WelaFailedLogonNative {
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Native 64-bit Windows is required.'}
$path=Join-Path $PSScriptRoot 'FailedLogonProbeNative.cs';$hash=(Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash
if(-not ('Wela.FailedLogonProbe.Native' -as [type])){Add-Type -Path $path -ErrorAction Stop;$script:WelaFailedLogonHash=$hash}
if($script:WelaFailedLogonHash -cne $hash){throw 'Loaded failed-logon helper differs from source; start a fresh process.'}
}
function Get-WelaFailedLogonSources {
$sources=[ordered]@{}
foreach($name in @('WELA.ps1','scripts/FailedLogonProbe.ps1','scripts/FailedLogonProbeWorker.ps1','scripts/FailedLogonProbeNative.cs','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/Configuration.ps1','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
$sources|ConvertTo-Json -Compress
}
function Get-WelaFailedLogonTokenKey {
param($Token,[switch]$AuthorizationOnly)
foreach($name in @('UserSid','AuthenticationId','TokenId','ModifiedId','TokenType','Impersonation')){if($Token.$name -isnot [string]){throw 'Incomplete elevated primary-token observation.'}}
if($Token.ElevatedAdministrator -isnot [bool]){throw 'Incomplete elevated primary-token observation.'}
if($Token.UserSid -cnotmatch '^S-1-\d+(-\d+)+$' -or $Token.AuthenticationId -cnotmatch '^[a-f0-9]{16}$' -or -not $Token.ElevatedAdministrator -or $Token.TokenType -cne 'Primary' -or $Token.Impersonation -cne 'Absent' -or $Token.GroupSids -isnot [array]){throw 'Incomplete elevated primary-token observation.'}
foreach($name in @('TokenId','ModifiedId')){if($Token.$name -cnotmatch '^[a-f0-9]{16}$'){throw 'Incomplete elevated primary-token observation.'}}
foreach($name in @('GroupCount','PrivilegeCount','ProcessId')){if($Token.$name -isnot [int] -and $Token.$name -isnot [long] -and $Token.$name -isnot [uint32]){throw 'Incomplete elevated primary-token observation.'};if($Token.$name -lt 1){throw 'Incomplete elevated primary-token observation.'}}
if(@($Token.GroupSids|Where-Object {$_ -isnot [string] -or $_ -cnotmatch '^S-1-\d+(-\d+)+$'}).Count){throw 'Incomplete elevated primary-token observation.'}
$key=[ordered]@{Sid=$Token.UserSid;Logon=$Token.AuthenticationId;Groups=$Token.GroupSids;GroupCount=$Token.GroupCount;PrivilegeCount=$Token.PrivilegeCount}
if(-not $AuthorizationOnly){$key.TokenId=$Token.TokenId;$key.ModifiedId=$Token.ModifiedId;$key.ProcessId=$Token.ProcessId}
$key|ConvertTo-Json -Depth 8 -Compress
}
function Get-WelaFailedLogonState {
Initialize-WelaFailedLogonNative
foreach($name in @('EventLog','Winmgmt','SamSs','RpcSs')){if((Get-Service -Name $name -ErrorAction Stop).Status -ne 'Running'){throw 'Required native observation/authentication services must already be running.'}}
$reader=Get-WelaChannelReader;$hostState=Get-WelaChannelReadHost
if($hostState.ProductType -notin @(1,3) -or $hostState.DomainRole -notin @(0,1,2,3)){throw 'A local SAM Windows client or member/standalone server is required; domain controllers are excluded.'}
$policies=Get-WelaEffectiveAuditPolicy;$precedence=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy
$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Security')
try{$log=[ordered]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;Size=$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode;Path=$channel.LogFilePath;SecurityDescriptor=$channel.SecurityDescriptor}}finally{$channel.Dispose()}
$engine=(Get-Process -Id $PID).Path
$state=[pscustomobject][ordered]@{Host=$hostState;Token=$reader;AuditPolicies=$policies;Precedence=$precedence;Channel=$log;Engine=$engine;EngineHash=(Get-FileHash -LiteralPath $engine -Algorithm SHA256).Hash;Sources=(Get-WelaFailedLogonSources)}
if((Get-WelaFailedLogonTokenKey (Get-WelaChannelReader)) -cne (Get-WelaFailedLogonTokenKey $reader)){throw 'Reader changed during prerequisite observation.'}
$state
}
function Get-WelaFailedLogonStateKey {
param($State)
$null=Get-WelaFailedLogonTokenKey $State.Token
$mask=$State.AuditPolicies['0cce9215-69ae-11d9-bed3-505054503030']
if(($mask -isnot [int] -and $mask -isnot [long]) -or $mask -notin @(2,3) -or $State.Precedence.Type -cne 'DWord' -or -not $State.Precedence.ValueExists -or $State.Precedence.Value -ne 1 -or -not $State.Channel.Enabled){throw 'Logon failure auditing, DWORD1 audit precedence and enabled/readable Security channel must already be configured.'}
$State|ConvertTo-Json -Depth 12 -Compress
}
function Get-WelaFailedLogonWatermark {
$record=Get-WinEvent -LogName Security -MaxEvents 1 -ErrorAction Stop
try{if($null -eq $record.RecordId -or $record.RecordId -lt 1){throw 'Unknown Security record boundary.'};[long]$record.RecordId}finally{$record.Dispose()}
}
function Assert-WelaFailedLogonOperation {
param($Operation,$State,[string]$Nonce,[int]$ProcessId,[DateTimeOffset]$Launch,[DateTimeOffset]$Observed)
$a=$Operation.Attempt
foreach($name in @('Nonce','Executable')){if($Operation.$name -isnot [string]){throw 'Unexpected fixed local authentication receipt type.'}}
foreach($name in @('UserName','Domain','Clock')){if($a.$name -isnot [string]){throw 'Unexpected fixed local authentication receipt type.'}}
foreach($name in @('MissingAccountStatus','LogonType','LogonProvider','NativeError')){if($a.$name -isnot [int] -and $a.$name -isnot [long]){throw 'Unexpected fixed local authentication receipt type.'}}
if($Operation.ProcessId -isnot [int] -and $Operation.ProcessId -isnot [long]){throw 'Unexpected fixed local authentication receipt type.'}
if($Operation.Nonce -cne $Nonce -or $Operation.ProcessId -ne $ProcessId -or $Operation.Executable -ine $State.Engine -or $a.UserName -cne ('WL'+$Nonce.Substring(0,18)) -or $a.Domain -cne '.' -or $a.MissingAccountStatus -ne 2221 -or $a.LogonType -ne 3 -or $a.LogonProvider -ne 2 -or $a.Succeeded -isnot [bool] -or $a.Succeeded -or $a.NativeError -ne 1326 -or $a.Clock -cne 'GetSystemTimePreciseAsFileTime'){throw 'Unexpected fixed local authentication result; no failed-logon proof is granted.'}
$start=ConvertTo-WelaArrivalUtc $a.StartedUtc;$end=ConvertTo-WelaArrivalUtc $a.CompletedUtc
if($Launch -gt $Observed -or $start -lt $Launch -or $start -gt $end -or $end -gt $Observed -or ($end-$start).TotalSeconds -gt 20){throw 'Invalid exact native operation interval.'}
if((Get-WelaFailedLogonTokenKey $Operation.BeforeToken) -cne (Get-WelaFailedLogonTokenKey $Operation.AfterToken) -or (Get-WelaFailedLogonTokenKey $Operation.BeforeToken -AuthorizationOnly) -cne (Get-WelaFailedLogonTokenKey $State.Token -AuthorizationOnly)){throw 'Worker token changed or differs from the observed caller.'}
$a.StartedUtc=$start.UtcDateTime.ToString('o');$a.CompletedUtc=$end.UtcDateTime.ToString('o')
}
function Start-WelaFailedLogonAttempt {
param($State,[string]$OutputPath)
if((Get-WelaFailedLogonStateKey (Get-WelaFailedLogonState)) -cne (Get-WelaFailedLogonStateKey $State)){throw 'Prerequisites changed before the fixed attempt.'}
$nonce=[guid]::NewGuid().ToString('N');$watermark=Get-WelaFailedLogonWatermark
$null=Write-WelaWecUpdateArtifact $OutputPath 'intent.json' ([ordered]@{Nonce=$nonce;LocalAccount=('WL'+$nonce.Substring(0,18));Domain='.';Attempts=1;SecurityRecordIdBefore=$watermark}|ConvertTo-Json)
$worker=Join-Path $PSScriptRoot 'FailedLogonProbeWorker.ps1'
$info=New-Object Diagnostics.ProcessStartInfo;$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=$null
try{
$launch=[DateTimeOffset][Wela.FailedLogonProbe.Native]::UtcNow()
$process=[Diagnostics.Process]::Start($info);$output=$process.StandardOutput.ReadToEndAsync();$errors=$process.StandardError.ReadToEndAsync()
if(-not $process.WaitForExit(20000)){$process.Kill();$null=$process.WaitForExit(1000);throw 'Fixed local authentication worker exceeded twenty seconds.'}
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($output,$errors),1000)){throw 'Worker output did not complete.'}
if($output.Result.Length -gt 65536 -or $errors.Result.Length -gt 65536){throw 'Worker output exceeded its bound.'}
if($process.ExitCode -ne 0 -or $errors.Result){throw ('Fixed local authentication worker failed: '+$errors.Result)}
$operation=ConvertFrom-WelaArrivalJson $output.Result
Assert-WelaFailedLogonOperation $operation $State $nonce $process.Id $launch ([DateTimeOffset][Wela.FailedLogonProbe.Native]::UtcNow())
$operation|Add-Member NoteProperty SecurityRecordIdBefore $watermark
$operation
}finally{if($process){try{if(-not $process.HasExited){$process.Kill();$null=$process.WaitForExit(1000)}}finally{$process.Dispose()}}}
}
function Read-WelaFailedLogonEvents {
param($Operation)
$a=$Operation.Attempt
$query="*[System[Provider[@Name='Microsoft-Windows-Security-Auditing'] and EventID=4625 and EventRecordID>$($Operation.SecurityRecordIdBefore) and TimeCreated[@SystemTime>='$($a.StartedUtc)' and @SystemTime<='$($a.CompletedUtc)']]]"
$records=@();$xml=@()
try{
try{$records=@(Get-WinEvent -LogName Security -FilterXPath $query -MaxEvents 256 -ErrorAction Stop)}catch{if($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*'){throw}}
foreach($record in $records){$text=[string]$record.ToXml();if($text.Length -gt 131072){throw 'Native event exceeded its bound.'};$xml+=$text}
[pscustomobject]@{Xml=$xml;Capped=($records.Count -ge 256);Query=$query}
}finally{foreach($record in $records){$record.Dispose()}}
}
function Test-WelaFailedLogonEvent {
param([string]$Xml,$Operation,$State)
$reader=$null
try{
if($Xml.Length -gt 131072){return $false}
$settings=New-Object Xml.XmlReaderSettings;$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=131072
$reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=New-Object Xml.XmlDocument;$doc.XmlResolver=$null;$doc.Load($reader)
$ns=New-Object Xml.XmlNamespaceManager($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event')
if($doc.DocumentElement.LocalName -cne 'Event' -or $doc.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $doc.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:EventData',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:UserData',$ns).Count){return $false}
$system=@{};foreach($name in @('Provider','EventID','Version','Keywords','EventRecordID','Channel','Computer','TimeCreated')){$nodes=$doc.SelectNodes("/e:Event/e:System/e:$name",$ns);if($nodes.Count -ne 1){return $false};$system[$name]=$nodes[0]}
if($system.Provider.GetAttribute('Name') -cne 'Microsoft-Windows-Security-Auditing' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine '54849625-5478-4994-a5ba-3e3b0328c30d' -or $system.EventID.InnerText -cne '4625' -or $system.Version.InnerText -cne '0' -or $system.Channel.InnerText -cne 'Security' -or $system.Keywords.InnerText -ine '0x8010000000000000' -or $system.EventRecordID.InnerText -cnotmatch '^[1-9][0-9]*$' -or [long]$system.EventRecordID.InnerText -le $Operation.SecurityRecordIdBefore){return $false}
$computers=@($State.Host.Computer);if($State.Host.DomainJoined){$computers+=$State.Host.Computer+'.'+$State.Host.Domain};if($system.Computer.InnerText -notin $computers){return $false}
$time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime')
if($time -lt (ConvertTo-WelaArrivalUtc $Operation.Attempt.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.Attempt.CompletedUtc)){return $false}
$map=@{}
foreach($node in $doc.SelectSingleNode('/e:Event/e:EventData',$ns).ChildNodes){
if($node.NodeType -eq 'Whitespace'){continue}
if($node.NodeType -ne 'Element' -or $node.LocalName -cne 'Data' -or $node.NamespaceURI -cne $ns.LookupNamespace('e') -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false}
$name=$node.GetAttribute('Name');if(-not $name -or $map.ContainsKey($name)){return $false};$map[$name]=$node.InnerText
}
if($map.TargetUserName -cne $Operation.Attempt.UserName -or $map.TargetDomainName -notin @('.',$State.Host.Computer) -or $map.TargetUserSid -cne 'S-1-0-0' -or $map.LogonType -cne '3' -or $map.AuthenticationPackageName -cne 'MICROSOFT_AUTHENTICATION_PACKAGE_V1_0' -or $map.Status -ine '0xc000006d' -or $map.SubStatus -ine '0xc0000064' -or $map.ProcessName -ine $Operation.Executable -or $map.SubjectUserSid -cne $Operation.BeforeToken.UserSid){return $false}
if($map.ProcessId -cnotmatch '^0x[0-9a-fA-F]+$' -or $map.SubjectLogonId -cnotmatch '^0x[0-9a-fA-F]+$' -or [Convert]::ToInt64($map.ProcessId.Substring(2),16) -ne $Operation.ProcessId -or [Convert]::ToUInt64($map.SubjectLogonId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.BeforeToken.AuthenticationId,16)){return $false}
return $true
}catch{return $false}finally{if($reader){$reader.Dispose()}}
}
function Invoke-WelaFailedLogonProbe {
param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15)
$ErrorActionPreference='Stop'
if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new FailedLogonOutputPath; Plan creates no files.'}
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaLocalFailedLogonProbe';Action=$Action;Status='Unverified';ExitCode=1;Before=$null;After=$null;Operation=$null;Candidates=0;Matches=0;Artifacts=@();Diagnostic='';OutputPath=$null;PolicyChanges=0;AccountChanges=0;ReadyRuleCredit=0;Scope='One fixed local SAM nonexistent-account network-logon-type attempt only. No remote/domain authentication, real credentials, account creation, impersonation, forwarding or Sigma proof. Sysmon excluded.'}
if($Action -eq 'Run'){$report.OutputPath=New-WelaArrivalOutput $OutputPath $PSScriptRoot}
try{
$before=Get-WelaFailedLogonState;$report.Before=$before;$key=Get-WelaFailedLogonStateKey $before
if($Action -eq 'Plan'){$null=Get-WelaFailedLogonWatermark;$report.After=$before;$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report}
$report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 16)
$operation=Start-WelaFailedLogonAttempt $before $report.OutputPath;$report.Operation=$operation
$report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'operation.json' ($operation|ConvertTo-Json -Depth 12)
$timer=[Diagnostics.Stopwatch]::StartNew();$matches=@()
do{
$batch=Read-WelaFailedLogonEvents $operation;$report.Candidates=@($batch.Xml).Count
if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'Candidate completeness is unknown or the 256-event cap was reached.'}
$matches=@($batch.Xml|Where-Object {Test-WelaFailedLogonEvent $_ $operation $before})
if($matches.Count){break};Start-Sleep -Milliseconds 250
}while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds)
$report.Matches=$matches.Count
if($matches.Count -ne 1){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath ('candidate-'+$i+'.xml') $xml};throw 'Exactly one matching local nonexistent-account Security4625 was not observed.'}
$report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'event.xml' $matches[0]
if((Get-WelaFailedLogonWatermark) -lt $operation.SecurityRecordIdBefore){throw 'Security record boundary moved backwards; continuity is unknown.'}
$after=Get-WelaFailedLogonState;$report.After=$after
if((Get-WelaFailedLogonStateKey $after) -cne $key){throw 'Host, token, policies, channel, engine or sources changed during collection.'}
$report.Status='LocalFailedLogonObserved';$report.ExitCode=0
}catch{$report.Diagnostic=$_.Exception.Message}
finally{if($report.Before -and -not $report.After){try{$report.After=Get-WelaFailedLogonState}catch{$report.Diagnostic+=' Final observation failed: '+$_.Exception.Message}}}
if($report.OutputPath){if($report.After){$report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'after.json' ($report.After|ConvertTo-Json -Depth 16)};$null=Write-WelaWecUpdateArtifact $report.OutputPath 'manifest.json' ($report|ConvertTo-Json -Depth 24)}
$report
}
+39
View File
@@ -0,0 +1,39 @@
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
namespace Wela.FailedLogonProbe {
public sealed class Attempt {
public string UserName, Domain, StartedUtc, CompletedUtc, Clock;
public int MissingAccountStatus, LogonType, LogonProvider, NativeError;
public bool Succeeded;
}
public static class Native {
[DllImport("kernel32.dll", ExactSpelling=true)] private static extern void GetSystemTimePreciseAsFileTime(out long value);
[DllImport("Netapi32.dll", CharSet=CharSet.Unicode, ExactSpelling=true)] private static extern int NetUserGetInfo(string server,string user,int level,out IntPtr buffer);
[DllImport("Netapi32.dll", ExactSpelling=true)] private static extern int NetApiBufferFree(IntPtr buffer);
[DllImport("advapi32.dll", CharSet=CharSet.Unicode, ExactSpelling=true, SetLastError=true)]
[return:MarshalAs(UnmanagedType.Bool)] private static extern bool LogonUserW(string user,string domain,string password,int type,int provider,out IntPtr token);
[DllImport("kernel32.dll", ExactSpelling=true, SetLastError=true)]
[return:MarshalAs(UnmanagedType.Bool)] private static extern bool CloseHandle(IntPtr handle);
public static DateTime UtcNow(){long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);}
public static Attempt Run(string nonce){
if(!Regex.IsMatch(nonce??"","\\A[a-f0-9]{32}\\z"))throw new ArgumentException("A generated lowercase GUID nonce is required.");
string user="WL"+nonce.Substring(0,18);IntPtr buffer=IntPtr.Zero;
int missing;
try{missing=NetUserGetInfo(null,user,0,out buffer);}finally{if(buffer!=IntPtr.Zero)NetApiBufferFree(buffer);}
// Never attempt a known or unreadable real account, and never query a domain server.
if(missing!=2221)throw new InvalidOperationException("Exact local account absence is not established; NetUserGetInfo="+missing);
Attempt result=new Attempt();result.UserName=user;result.Domain=".";result.MissingAccountStatus=missing;result.LogonType=3;result.LogonProvider=2;result.Clock="GetSystemTimePreciseAsFileTime";
IntPtr token=IntPtr.Zero;
result.StartedUtc=UtcNow().ToString("o");
try{
// This fixed public dummy is not a credential. There is exactly one attempt.
result.Succeeded=LogonUserW(user,".","WELA-public-noncredential",3,2,out token);
result.NativeError=result.Succeeded?0:Marshal.GetLastWin32Error();
result.CompletedUtc=UtcNow().ToString("o");
}finally{if(token!=IntPtr.Zero && !CloseHandle(token))throw new Win32Exception(Marshal.GetLastWin32Error());}
return result;
}
}
}
+14
View File
@@ -0,0 +1,14 @@
param([Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{32}$')][string]$Nonce)
$ErrorActionPreference='Stop';[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
. "$PSScriptRoot/WefArrival.ps1"
. "$PSScriptRoot/ChannelRead.ps1"
. "$PSScriptRoot/FailedLogonProbe.ps1"
Initialize-WelaFailedLogonNative
foreach($name in @('EventLog','Winmgmt','SamSs','RpcSs')){if((Get-Service -Name $name -ErrorAction Stop).Status -ne 'Running'){throw 'Required native services must already be running.'}}
$hostState=Get-WelaChannelReadHost
if($hostState.ProductType -notin @(1,3) -or $hostState.DomainRole -notin @(0,1,2,3)){throw 'A local SAM client or member/standalone server is required.'}
$before=Get-WelaChannelReader
$result=[Wela.FailedLogonProbe.Native]::Run($Nonce)
$after=Get-WelaChannelReader
[pscustomobject][ordered]@{Nonce=$Nonce;ProcessId=$PID;Executable=(Get-Process -Id $PID).Path;BeforeToken=$before;AfterToken=$after;Attempt=$result}|ConvertTo-Json -Depth 10 -Compress
+16
View File
@@ -0,0 +1,16 @@
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent
$engine=(Get-Process -Id $PID).Path;$count=0
$cases=@(
@{Args=@('eventlog-recovery','-EventRecoveryAction','Restore','-WhatIf');Code=1;Pattern='only dedicated'},
@{Args=@('eventlog-recovery','-Help');Code=0;Pattern='AllowShrink'},
@{Args=@('configure','-EventRecoveryAction','Restore','-Auto');Code=1;Pattern='require eventlog-recovery'},
@{Args=@('eventlog-recovery','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'},
@{Args=@('eventlog-recovery','-Help','-WefAction','Configure');Code=1;Pattern='only dedicated'},
@{Args=@('eventlog-recovery','-Help','-Auto');Code=1;Pattern='only dedicated'},
@{Args=@('eventlog-recovery','-Help','-DryRun');Code=1;Pattern='only dedicated'},
@{Args=@('eventlog-recovery','-EventRecoveryAction','Restore','-EventRecoveryOutputPath','not-created');Code=1;Pattern='reviewed plan'},
@{Args=@('eventlog-recovery','-EventRecoveryOutputPath','not-created');Code=1;Pattern='Plan requires'}
)
foreach($case in $cases){$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior};if(($case.Code -eq 0 -and $code -ne 0) -or ($case.Code -ne 0 -and $code -eq 0) -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI failure: $($case.Args -join ' ') -> $code / $($output -join ' ')"};$count++}
Write-Host "Event-log recovery CLI: $count checks passed."
$global:LASTEXITCODE=0
+79
View File
@@ -0,0 +1,79 @@
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
Import-Module "$repo/modules/EventLogSettings.psm1" -Force
. "$repo/scripts/WefArrival.ps1"
. "$repo/scripts/WecUpdate.ps1"
. "$repo/scripts/AuditRecovery.ps1"
. "$repo/scripts/Configuration.ps1"
. "$repo/scripts/EventLogConfiguration.ps1"
. "$repo/scripts/EventLogRecovery.ps1"
$count=0
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{&$Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern; got $message"}
$sources=ConvertFrom-WelaArrivalJson (Get-WelaEventRecoverySources)
Assert ($sources.'scripts/ControlApplicability.ps1' -ceq (Get-FileHash "$repo/scripts/ControlApplicability.ps1").Hash.ToLowerInvariant()) 'Actual host identity/context implementation is fingerprinted.'
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-event-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
$oldComputer=$env:COMPUTERNAME;$env:COMPUTERNAME='TEST'
function Get-WelaEventRecoveryContext {[pscustomobject][ordered]@{Host=[ordered]@{Computer='TEST';MachineGuid='1'};Reader='S-1-5-21-fixture'}}
function Get-WelaEventLogState {param($Log);[pscustomobject]@{Log=$Log;ReadStatus='Available';MaximumSizeInBytes=$script:bytes;LogMode=$script:mode;FileSize=123;IsEnabled=$false;Diagnostic=''}}
function Invoke-WelaNative {param($FilePath,$Arguments);foreach($arg in $Arguments){if($arg -like '/ms:*'){$script:bytes=[long]$arg.Substring(4)}};if($Arguments -contains '/ab:true'){$script:mode='AutoBackup'}}
function Read-WelaEventRecoveryChannel {param($Log);$script:reads++;if($script:scenario -eq 'fresh-drift' -and $script:reads -eq 2){$script:bytes+=65536};[pscustomobject]@{Log=$Log;MaximumSizeInBytes=$script:bytes;LogMode=$script:mode;Guard=[ordered]@{IsEnabled=$false;Path='Original';SecurityDescriptor=$script:acl}}}
function Set-WelaEventRecoveryChannel {param($Definition);Assert (Test-Path $script:pending) 'Pending receipt precedes write';$script:writes++;if($script:scenario -eq 'native-fail'){throw 'native failure'};if($script:scenario -ne 'false-success'){$script:bytes=$Definition.RecoverTo.MaximumSizeInBytes;$script:mode=$Definition.RecoverTo.LogMode};if($script:scenario -eq 'preservation'){$script:acl='changed'}}
try {
foreach($case in @('ok','no-shrink','no-mode','hash','tamper','duplicate','drift','fresh-drift','source','native-fail','false-success','preservation','historical-drift')){
$script:scenario='';$script:bytes=33554432L;$script:mode='Retain';$script:acl='Original';$script:writes=0;$script:reads=0
$dir=Join-Path $root $case;$null=New-Item -ItemType Directory $dir
$context=New-WelaConfigurationContext -Auto -BackupPath "$dir/journal"
Set-WelaEventLogProfileControls -Context $context -Profile 'asd-collector-archive-2021-10' -ApplyLogMode
$result=Complete-WelaConfiguration -Context $context -Scope 'event-log-size-and-mode-only' -ResultsPath "$dir/original.json"
Assert ($result.ExitCode -eq 0 -and $result.Results[0].Status -eq 'Applied') 'Genuine configuration callback creates completed evidence'
$plan=Invoke-WelaEventLogRecovery Plan -JournalPath "$dir/journal/before.jsonl" -OriginalResultsPath "$dir/original.json" -Log ForwardedEvents -OutputPath "$dir/plan"
Assert ($plan.Status -eq 'ReviewRequired' -and $plan.ExitCode -eq 0) "Plan $case : $($plan.Diagnostic)"
Assert ($script:writes -eq 0) 'Plan never restores'
$planPath="$dir/plan/plan.json";$hash=$plan.PlanHash
if($case -eq 'hash'){$hash='a'*64}
if($case -in @('tamper','duplicate')){
$text=[IO.File]::ReadAllText($planPath)
if($case -eq 'tamper'){$text=$text.Replace('33554432','67108864')}else{$text=$text.Replace('"SchemaVersion":','"SchemaVersion":1,"SchemaVersion":')}
[IO.File]::WriteAllText($planPath,$text);$hash=(Get-FileHash $planPath).Hash.ToLowerInvariant()
}
if($case -eq 'source'){[IO.File]::AppendAllText("$dir/original.json",' ')}
if($case -eq 'historical-drift'){
$original=Get-Content "$dir/original.json" -Raw|ConvertFrom-Json;$original.Results[0].After.MaximumSizeInBytes+=65536;$original|ConvertTo-Json -Depth 15|Set-Content "$dir/original.json"
$bad=Invoke-WelaEventLogRecovery Plan -JournalPath "$dir/journal/before.jsonl" -OriginalResultsPath "$dir/original.json" -Log ForwardedEvents -OutputPath "$dir/bad-plan"
Assert ($bad.Status -eq 'Refused' -and $bad.Diagnostic -match 'unexplained drift') 'Independent postwrite buffer growth cannot be undone as WELA-owned change'
}
$script:scenario=$case;$script:reads=0;$script:pending="$dir/restore/before-restore.json"
if($case -eq 'drift'){$script:bytes+=65536}
$restore=Invoke-WelaEventLogRecovery Restore -PlanPath $planPath -PlanHash $hash -OutputPath "$dir/restore" -AllowShrink:($case -ne 'no-shrink') -AllowRetentionChange:($case -ne 'no-mode')
Assert (($restore.ExitCode -eq 0) -eq ($case -eq 'ok')) "Restore $case : $($restore.Diagnostic)"
Assert (Test-Path "$dir/restore/manifest.json") 'Manifest retained'
if($case -eq 'ok'){
Assert ($script:bytes -eq 33554432 -and $script:mode -eq 'Retain' -and $restore.Status -eq 'RestoredAndVerified' -and $restore.ReadyRuleCredit -eq 0) 'Original immediate-prewrite size/mode restored'
$replay=Invoke-WelaEventLogRecovery Restore -PlanPath $planPath -PlanHash $hash -OutputPath "$dir/replay" -AllowShrink -AllowRetentionChange
Assert ($replay.Status -eq 'Refused' -and $script:writes -eq 1) 'Old post-configuration plan is not replayed'
}elseif($case -in @('native-fail','false-success','preservation')){Assert ($restore.Status -eq 'RestoreAttemptedUnverified' -and $script:writes -eq 1) 'Partial failure explicit'}
else{Assert ($script:writes -eq 0 -and -not $restore.NativeWriteAttempted) 'Refusal occurs before write'}
}
# Reject PowerShell boolean-to-string comparison coercion in completed evidence.
$goodResult=[IO.File]::ReadAllText("$root/ok/original.json");$goodJournal=[IO.File]::ReadAllText("$root/ok/journal/before.jsonl")
foreach($field in @('Status','Kind','Id','Scope','ComputerName','Phase','StateLog','ReadStatus','TargetLog','DesiredMode')){
$r=ConvertFrom-WelaArrivalJson $goodResult;$j=@($goodJournal -split '\r?\n'|Where-Object {$_ -match '\S'}|ForEach-Object {ConvertFrom-WelaArrivalJson $_})
switch($field){
Status {$r.Results[0].Status=$true}
Kind {$r.Results[0].Kind=$true}
Id {$r.Results[0].Id=$true}
Scope {$r.Scope=$true}
ComputerName {$j[0].ComputerName=$true}
Phase {$j[1].Phase=$true}
StateLog {$r.Results[0].After.Log=$true}
ReadStatus {$r.Results[0].After.ReadStatus=$true}
TargetLog {$j[0].Target.Log=$true;$r.Results[0].Target.Log=$true}
DesiredMode {$j[0].Desired.SizeMode=$true;$r.Results[0].Desired.SizeMode=$true}
}
$r|ConvertTo-Json -Depth 20|Set-Content "$root/typed-result.json"
@($j|ForEach-Object {$_|ConvertTo-Json -Depth 20 -Compress})|Set-Content "$root/typed-journal.jsonl"
Reject {Get-WelaEventRecoveryDefinition "$root/typed-journal.jsonl" "$root/typed-result.json" ForwardedEvents} 'mistyped recovery text|Exactly one result'
}
}finally{$env:COMPUTERNAME=$oldComputer;Remove-Item $root -Recurse -Force}
Write-Host "Event-log recovery passed: $count assertions."
+72
View File
@@ -0,0 +1,72 @@
param([switch]$AllowDisposableChannelWrite)
$ErrorActionPreference='Stop'
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableChannelWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable hosted Windows opt-in required.'}
$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
Import-Module "$repo/modules/EventLogSettings.psm1" -Force
. "$repo/scripts/Configuration.ps1"
. "$repo/scripts/ControlApplicability.ps1"
. "$repo/scripts/WefArrival.ps1"
. "$repo/scripts/WecUpdate.ps1"
. "$repo/scripts/AuditRecovery.ps1"
. "$repo/scripts/ChannelRead.ps1"
. "$repo/scripts/EventLogRecovery.ps1"
$count=0
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
$engine=(Get-Process -Id $PID).Path
function Invoke-RecoveryFixtureCli {param([string[]]$Arguments,[int]$Expected=0)
$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$lines=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @Arguments 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior}
if(($Expected -eq 0 -and $code -ne 0) -or ($Expected -ne 0 -and $code -eq 0)){throw "Public CLI $code : $($lines -join ' ')"}
}
$log='ForwardedEvents';$before=Read-WelaEventRecoveryChannel $log;$policies=Get-WelaEffectiveAuditPolicy
$root=Join-Path $env:RUNNER_TEMP ('wela-event-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
$primary=$null
try{
$null=Invoke-WelaNative wevtutil.exe @('sl',$log,'/ms:33554432','/rt:true','/ab:false')
$prepared=Read-WelaEventRecoveryChannel $log
Assert ((Get-WelaRecoveryKey $prepared.Guard) -ceq (Get-WelaRecoveryKey $before.Guard)) 'Preparation preserves enable/path/ACL/provider fields'
Invoke-RecoveryFixtureCli @('configure-eventlogs','-LogProfile','asd-collector-archive-2021-10','-ApplyLogMode','-Auto','-BackupPath',"$root/journal",'-ResultsPath',"$root/original.json")
$original=Get-Content "$root/original.json" -Raw|ConvertFrom-Json
Assert ($original.Results.Count -eq 1 -and $original.Results[0].Status -eq 'Applied') 'Genuine public Configure evidence'
$configured=Read-WelaEventRecoveryChannel $log
Assert ($configured.MaximumSizeInBytes -eq 2147483648 -and $configured.LogMode -eq 'AutoBackup') 'Native configured size/mode observed'
Invoke-RecoveryFixtureCli @('eventlog-recovery','-EventRecoveryJournalPath',"$root/journal/before.jsonl",'-EventRecoveryOriginalResultsPath',"$root/original.json",'-EventRecoveryLog',$log,'-EventRecoveryOutputPath',"$root/plan")
$plan=Get-Content "$root/plan/manifest.json" -Raw|ConvertFrom-Json
Assert ($plan.Status -eq 'ReviewRequired' -and (Get-WelaRecoveryKey (Read-WelaEventRecoveryChannel $log)) -ceq (Get-WelaRecoveryKey $configured)) 'Public Plan makes no channel changes'
$apply=@('eventlog-recovery','-EventRecoveryAction','Restore','-EventRecoveryPlanPath',"$root/plan/plan.json",'-EventRecoveryPlanHash',$plan.PlanHash)
Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/unknown-option",'-EventRecoveryAllowShrink','-EventRecoveryAllowRetentionChange','-WhatIf')) 1
Assert (-not (Test-Path "$root/unknown-option") -and (Get-WelaRecoveryKey (Read-WelaEventRecoveryChannel $log)) -ceq (Get-WelaRecoveryKey $configured)) 'Unknown WhatIf refuses before output or native restoration'
Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/without-consent")) 1
$refused=Get-Content "$root/without-consent/manifest.json" -Raw|ConvertFrom-Json
Assert ($refused.Status -eq 'Refused' -and -not $refused.NativeWriteAttempted) 'Shrinking requires independent explicit consent'
# Actual concurrent-size drift, then exact fixture restoration, exercises public refusal.
$null=Invoke-WelaNative wevtutil.exe @('sl',$log,'/ms:2147549184')
Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/drift",'-EventRecoveryAllowShrink','-EventRecoveryAllowRetentionChange')) 1
$drift=Get-Content "$root/drift/manifest.json" -Raw|ConvertFrom-Json
Assert ($drift.Status -eq 'Refused' -and -not $drift.NativeWriteAttempted) 'Actual native size drift refuses restoration'
$null=Invoke-WelaNative wevtutil.exe @('sl',$log,'/ms:2147483648')
Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/restored",'-EventRecoveryAllowShrink','-EventRecoveryAllowRetentionChange'))
$restored=Get-Content "$root/restored/manifest.json" -Raw|ConvertFrom-Json
Assert ($restored.Status -eq 'RestoredAndVerified' -and $restored.NativeWriteAttempted -and $restored.ReadyRuleCredit -eq 0) 'Native public restoration verified'
Assert ((Get-WelaRecoveryKey (Read-WelaEventRecoveryChannel $log)) -ceq (Get-WelaRecoveryKey $prepared)) 'Exact prepared size/mode and all preserved fields restored'
Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/replay",'-EventRecoveryAllowShrink','-EventRecoveryAllowRetentionChange')) 1
$replay=Get-Content "$root/replay/manifest.json" -Raw|ConvertFrom-Json
Assert ($replay.Status -eq 'Refused' -and -not $replay.NativeWriteAttempted) 'Consumed plan cannot overwrite recovered state'
Write-Host "Native event-log recovery passed $count assertions; no record preservation or sustained retention claim."
}catch{$primary=$_}
finally{
$errorText=''
try{
$arguments=@('sl',$log,('/ms:'+$before.MaximumSizeInBytes))
switch($before.LogMode){'Circular'{$arguments+=@('/rt:false','/ab:false')};'Retain'{$arguments+=@('/rt:true','/ab:false')};'AutoBackup'{$arguments+=@('/rt:true','/ab:true')}}
$null=Invoke-WelaNative wevtutil.exe $arguments
if((Get-WelaRecoveryKey (Read-WelaEventRecoveryChannel $log)) -cne (Get-WelaRecoveryKey $before)){throw 'Original channel configuration differs after cleanup.'}
$now=Get-WelaEffectiveAuditPolicy;foreach($guid in $policies.Keys){if($now[$guid] -ne $policies[$guid]){throw 'Original audit mask changed.'}}
}catch{$errorText=$_.Exception.Message}
$cleanup=[ordered]@{CleanupVerified=($errorText -eq '');Before=$before;After=(Read-WelaEventRecoveryChannel $log);AuditMasksCompared=$policies.Count;Diagnostic=$errorText}
$cleanup|ConvertTo-Json -Depth 12|Set-Content "$root/cleanup.json" -Encoding UTF8
if($errorText){throw "Cleanup failed: $errorText; primary: $primary"}
Write-Host 'Original channel size/mode, enable/path/ACL/provider fields and all audit masks restored.'
}
if($primary){throw $primary}
$global:LASTEXITCODE=0
+14
View File
@@ -0,0 +1,14 @@
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0
$cases=@(
@{Args=@('failed-logon-probe','-FailedLogonAction','Run','-WhatIf');Code=1;Pattern='only dedicated'},
@{Args=@('failed-logon-probe','-Help');Code=0;Pattern='nonexistent local account'},
@{Args=@('configure','-FailedLogonAction','Run','-Auto');Code=1;Pattern='require failed-logon-probe'},
@{Args=@('failed-logon-probe','-Help','-Auto');Code=1;Pattern='only dedicated'},
@{Args=@('failed-logon-probe','-Help','-DryRun');Code=1;Pattern='only dedicated'},
@{Args=@('failed-logon-probe','-Help','-ResultsPath','unused');Code=1;Pattern='only dedicated'},
@{Args=@('failed-logon-probe','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'},
@{Args=@('failed-logon-probe','-FailedLogonAction','Run');Code=1;Pattern='requires a new'},
@{Args=@('failed-logon-probe','-FailedLogonOutputPath','unused');Code=1;Pattern='Plan creates no files'})
foreach($case in $cases){$ErrorActionPreference='Continue';$output=& $engine -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1|Out-String;$code=$LASTEXITCODE;$ErrorActionPreference='Stop';if($code -ne $case.Code -or $output -notmatch $case.Pattern){throw "CLI failed: $($case.Args -join ' ') [$code] $output"};$count++}
Write-Host "PASS: $count failed-logon public CLI checks."
$global:LASTEXITCODE=0
+90
View File
@@ -0,0 +1,90 @@
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
. "$repo/scripts/WefArrival.ps1"
. "$repo/scripts/WecUpdate.ps1"
. "$repo/scripts/FailedLogonProbe.ps1"
Add-Type -Path "$repo/scripts/FailedLogonProbeNative.cs" -ErrorAction Stop
$script:count=0
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
function Reject($Code,$Pattern){$message='';try{&$Code|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern, got $message"}
function Clone($Value){ConvertFrom-WelaArrivalJson ($Value|ConvertTo-Json -Depth 24 -Compress)}
$token=[pscustomobject][ordered]@{UserSid='S-1-5-21-1-2-3-1001';AuthenticationId='0000000000000123';TokenId='0000000000001000';ModifiedId='0000000000001001';ElevatedAdministrator=$true;TokenType='Primary';Impersonation='Absent';GroupSids=@('S-1-1-0','S-1-5-32-544');GroupCount=2;PrivilegeCount=12;ProcessId=1234}
$state=[pscustomobject][ordered]@{Host=[pscustomobject]@{Computer='LAB';DomainJoined=$false;Domain='WORKGROUP'};Token=$token;AuditPolicies=@{'0cce9215-69ae-11d9-bed3-505054503030'=2};Precedence=[pscustomobject]@{ValueExists=$true;Type='DWord';Value=1};Channel=[pscustomobject]@{Enabled=$true};Engine='C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe';Sources='fixed-sources'}
$nonce='abcdef0123456789abcdef0123456789ab';$worker=Clone $token;$worker.ProcessId=456;$worker.TokenId='0000000000002000'
$operation=[pscustomobject]@{Nonce=$nonce;ProcessId=456;Executable=$state.Engine;BeforeToken=$worker;AfterToken=(Clone $worker);SecurityRecordIdBefore=100;Attempt=[pscustomobject]@{UserName=('WL'+$nonce.Substring(0,18));Domain='.';MissingAccountStatus=2221;LogonType=3;LogonProvider=2;Succeeded=$false;NativeError=1326;Clock='GetSystemTimePreciseAsFileTime';StartedUtc='2025-01-02T03:04:05.1234500Z';CompletedUtc='2025-01-02T03:04:06.1234500Z'}}
$launch=[DateTimeOffset]'2025-01-02T03:04:05Z';$observed=[DateTimeOffset]'2025-01-02T03:04:07Z'
Assert-WelaFailedLogonOperation $operation $state $nonce 456 $launch $observed
Assert $true 'A fixed typed receipt under the same inherited authorization is accepted.'
# PowerShell7 before DateKind support may materialize ISO UTC JSON as DateTime.
$dated=Clone $operation;$dated.Attempt.StartedUtc=[datetime]::Parse('2025-01-02T03:04:05.1234500Z',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind);$dated.Attempt.CompletedUtc=[datetime]::Parse('2025-01-02T03:04:06.1234500Z',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind)
Assert-WelaFailedLogonOperation $dated $state $nonce 456 $launch $observed
Assert ($dated.Attempt.StartedUtc -ceq $operation.Attempt.StartedUtc) 'Supported UTC DateTime parsing preserves the exact native interval.'
foreach($field in @('UserName','Domain','MissingAccountStatus','LogonType','LogonProvider','Succeeded','NativeError','Clock','StartedUtc','CompletedUtc','Nonce','ProcessId','Executable','Token','TokenType')){
$bad=Clone $operation
switch($field){
UserName {$bad.Attempt.UserName='Administrator'}
Domain {$bad.Attempt.Domain='example.test'}
MissingAccountStatus {$bad.Attempt.MissingAccountStatus=0}
LogonType {$bad.Attempt.LogonType=2}
LogonProvider {$bad.Attempt.LogonProvider=0}
Succeeded {$bad.Attempt.Succeeded=$true}
NativeError {$bad.Attempt.NativeError='1326'}
Clock {$bad.Attempt.Clock='DateTime.UtcNow'}
StartedUtc {$bad.Attempt.StartedUtc='2025-01-02T03:04:04.9999999Z'}
CompletedUtc {$bad.Attempt.CompletedUtc='2025-01-02T03:04:07.0000001Z'}
Nonce {$bad.Nonce='f'*32}
ProcessId {$bad.ProcessId=457}
Executable {$bad.Executable='C:\other.exe'}
Token {$bad.AfterToken.ModifiedId='0000000000001002'}
TokenType {$bad.BeforeToken.GroupCount='2'}
}
Reject {Assert-WelaFailedLogonOperation $bad $state $nonce 456 $launch $observed} 'Unexpected|interval|token|observation'
}
foreach($field in @('Nonce','Executable','UserName','Domain','Clock','TokenType','Impersonation','ElevatedAdministrator')){
$bad=Clone $operation
if($field -in @('Nonce','Executable')){$bad.$field=$true}
elseif($field -in @('TokenType','Impersonation')){$bad.BeforeToken.$field=$true}
elseif($field -eq 'ElevatedAdministrator'){$bad.BeforeToken.ElevatedAdministrator='true'}
else{$bad.Attempt.$field=$true}
Reject {Assert-WelaFailedLogonOperation $bad $state $nonce 456 $launch $observed} 'receipt type|primary-token observation'
}
foreach($api in @('LogonUserW','NetUserGetInfo','GetSystemTimePreciseAsFileTime')){
$import=[Wela.FailedLogonProbe.Native].GetMethod($api,[Reflection.BindingFlags]'NonPublic,Static').GetCustomAttributes([Runtime.InteropServices.DllImportAttribute],$false)[0]
Assert ($import.ExactSpelling -and $import.EntryPoint -ceq $api) ('Exact native binding '+$api)
}
Reject {[Wela.FailedLogonProbe.Native]::Run('Administrator')} 'GUID nonce'
$xml='<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"><System><Provider Name="Microsoft-Windows-Security-Auditing" Guid="{54849625-5478-4994-a5ba-3e3b0328c30d}"/><EventID>4625</EventID><Version>0</Version><Keywords>0x8010000000000000</Keywords><EventRecordID>101</EventRecordID><Channel>Security</Channel><Computer>LAB</Computer><TimeCreated SystemTime="2025-01-02T03:04:05.5000000Z"/></System><EventData><Data Name="SubjectUserSid">S-1-5-21-1-2-3-1001</Data><Data Name="SubjectLogonId">0x123</Data><Data Name="TargetUserSid">S-1-0-0</Data><Data Name="TargetUserName">WLabcdef0123456789ab</Data><Data Name="TargetDomainName">LAB</Data><Data Name="Status">0xc000006d</Data><Data Name="SubStatus">0xc0000064</Data><Data Name="LogonType">3</Data><Data Name="AuthenticationPackageName">MICROSOFT_AUTHENTICATION_PACKAGE_V1_0</Data><Data Name="ProcessName">C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe</Data><Data Name="ProcessId">0x1c8</Data></EventData></Event>'
Assert (Test-WelaFailedLogonEvent $xml $operation $state) 'Exact synthetic4625 matches.'
foreach($edge in @(@('03:04:05.1234500Z',$true),@('03:04:06.1234500Z',$true),@('03:04:05.1234499Z',$false),@('03:04:06.1234501Z',$false))){Assert ((Test-WelaFailedLogonEvent $xml.Replace('03:04:05.5000000Z',$edge[0]) $operation $state) -eq $edge[1]) 'Exact100ns operation boundary.'}
foreach($pair in @(@('4625','4624'),@('>0</Version>','>1</Version>'),@('WLabcdef0123456789ab','Administrator'),@('0xc0000064','0xc000006a'),@('0xc000006d','0x0'),@('>3</Data>','>2</Data>'),@('>MICROSOFT_AUTHENTICATION_PACKAGE_V1_0<','>Kerberos<'),@('>MICROSOFT_AUTHENTICATION_PACKAGE_V1_0<','>NTLM<'),@('0x1c8','0x1c9'),@('0x123','0x124'),@('S-1-5-21-1-2-3-1001','S-1-5-18'),@('>LAB<','>OTHER<'),@('S-1-0-0','S-1-5-18'),@('>101<','>100<'),@('0x8010000000000000','0x8020000000000000'),@('>Security<','>Application<'),@('powershell.exe','other.exe'),@('54849625-5478-4994-a5ba-3e3b0328c30d','54849625-5478-4994-a5ba-3e3b0328c30e'))){$bad=$xml.Replace($pair[0],$pair[1]);Assert ($bad -cne $xml) 'Mutation changes fixture';Assert (-not(Test-WelaFailedLogonEvent $bad $operation $state)) ('Mismatch refused '+$pair[0])}
Assert (-not(Test-WelaFailedLogonEvent $xml.Replace('</EventData>','<Data Name="LogonType">3</Data></EventData>') $operation $state)) 'Duplicate payload refused.'
Assert (-not(Test-WelaFailedLogonEvent $xml.Replace('</System>','<EventID>4625</EventID></System>') $operation $state)) 'Duplicate System field refused.'
Assert (-not(Test-WelaFailedLogonEvent ('<!DOCTYPE Event [<!ENTITY x "LAB">]>'+$xml.Replace('>LAB<','>&x;<')) $operation $state)) 'DTD refused.'
$null=Get-WelaFailedLogonStateKey $state
foreach($mask in @(0,1,4,'2')){$bad=Clone $state;$bad.AuditPolicies=@{'0cce9215-69ae-11d9-bed3-505054503030'=$mask};Reject {Get-WelaFailedLogonStateKey $bad} 'failure auditing'}
$bad=Clone $state;$bad.Precedence.Type='String';Reject {Get-WelaFailedLogonStateKey $bad} 'failure auditing'
$bad=Clone $state;$bad.Channel.Enabled=$false;Reject {Get-WelaFailedLogonStateKey $bad} 'failure auditing'
Reject {Invoke-WelaFailedLogonProbe -Action Run} 'requires a new'
Reject {Invoke-WelaFailedLogonProbe -OutputPath 'unused'} 'Plan creates no files'
# Production orchestration with only native boundaries mocked; no authentication here.
$script:mode='Success';$script:reads=0;$script:attempts=0
function Get-WelaFailedLogonState {$script:reads++;$copy=Clone $state;$copy.AuditPolicies=@{'0cce9215-69ae-11d9-bed3-505054503030'=2};if($script:mode -eq 'Blocked'){$copy.AuditPolicies['0cce9215-69ae-11d9-bed3-505054503030']=0};if($script:mode -eq 'Drift' -and $script:reads -gt 1){$copy.Sources='changed'};$copy}
function Start-WelaFailedLogonAttempt {param($State,$OutputPath);$script:attempts++;$operation}
function Read-WelaFailedLogonEvents {param($Operation);if($script:mode -eq 'ReadError'){throw 'native read failed'};$events=@($xml);if($script:mode -eq 'Duplicate'){$events+= $xml};[pscustomobject]@{Xml=$events;Capped=($script:mode -eq 'Cap')}}
function Get-WelaFailedLogonWatermark {if($script:mode -eq 'Clear'){99}else{101}}
$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-failed-logon-fixtures-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp
try{
$plan=Invoke-WelaFailedLogonProbe;Assert ($plan.Status -eq 'PrerequisitesObserved' -and $script:attempts -eq 0) 'Plan never authenticates.'
foreach($mode in @('Success','Blocked','Cap','ReadError','Drift','Clear','Duplicate')){
$script:mode=$mode;$script:reads=0;$script:attempts=0;$dir=Join-Path $temp $mode
$result=Invoke-WelaFailedLogonProbe -Action Run -OutputPath $dir -TimeoutSeconds 1
$manifest=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $dir 'manifest.json')))
Assert ($manifest.ReadyRuleCredit -eq 0 -and $manifest.PolicyChanges -eq 0 -and $manifest.AccountChanges -eq 0) 'No audit or readiness claim.'
Assert ($null -ne $manifest.After) 'Final state retained.'
foreach($artifact in $manifest.Artifacts){Assert ($artifact.Sha256 -ceq (Get-FileHash -LiteralPath (Join-Path $dir $artifact.Name)).Hash.ToLowerInvariant()) 'Exact artifact hash.'}
if($mode -eq 'Success'){Assert ($result.Status -eq 'LocalFailedLogonObserved' -and $result.Matches -eq 1 -and $result.ExitCode -eq 0 -and $script:attempts -eq 1) 'Only one worker attempt.'}else{Assert ($result.Status -eq 'Unverified' -and $result.ExitCode -eq 1 -and $result.Diagnostic) ('Unverified '+$mode)}
if($mode -eq 'Blocked'){Assert ($script:attempts -eq 0) 'Missing prerequisites never attempt authentication.'}
}
}finally{Remove-Item -LiteralPath $temp -Recurse -Force}
Write-Host "PASS: $script:count failed-logon fixtures; authentication was mocked."
$global:LASTEXITCODE=0
+51
View File
@@ -0,0 +1,51 @@
# Native public CLI only: fixture prepares auditing, product never changes it.
param([switch]$AllowDisposableAuditWrite,[ValidateRange(1,3)][int]$ProbeRuns=2)
$ErrorActionPreference='Stop'
if(-not $AllowDisposableAuditWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Explicit disposable GitHub-hosted Windows opt-in is required.'}
$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
. "$repo/scripts/Configuration.ps1"
. "$repo/scripts/ControlApplicability.ps1"
. "$repo/scripts/WefArrival.ps1"
. "$repo/scripts/ChannelRead.ps1"
. "$repo/scripts/FailedLogonProbe.ps1"
$context=Get-WelaDefaultContext
if(-not(Test-WelaDefaultContextComplete $context) -or $context.Build -notin @(20348,26100) -or $context.ProductType -ne 3 -or $context.DomainRole -ne 2 -or $context.DomainJoined){throw 'Only observed disposable workgroup Server2022/2025 is permitted.'}
$script:count=0
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
function PolicyKey($Map){(@($Map.Keys|Sort-Object|ForEach-Object{"$_=$($Map[$_])"}) -join ';')}
function AccountsKey {(@(Get-LocalUser -ErrorAction Stop|ForEach-Object {"$($_.SID.Value)=$($_.Name)=$($_.Enabled)"}|Sort-Object) -join ';')}
$engine=(Get-Process -Id $PID).Path;$guid='0CCE9215-69AE-11D9-BED3-505054503030'
$path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$name='SCENoApplyLegacyAuditPolicy'
$policies=Get-WelaEffectiveAuditPolicy;$precedence=Get-WelaRegistryState $path $name;$accounts=AccountsKey
$root=Join-Path $env:RUNNER_TEMP ('wela-failed-logon-native-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
$failure=$null;$cleanupErrors=@()
try{
Set-ItemProperty -LiteralPath $path -Name $name -Type DWord -Value 1
Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 2 -Mode minimum
$preparedPolicies=PolicyKey (Get-WelaEffectiveAuditPolicy)
for($trial=1;$trial -le $ProbeRuns;$trial++){
$out=Join-Path $root ('probe-'+$trial)
$ErrorActionPreference='Continue';$cli=&$engine -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$repo/WELA.ps1" failed-logon-probe -FailedLogonAction Run -FailedLogonOutputPath $out -FailedLogonTimeoutSeconds 20 2>&1|Out-String;$code=$LASTEXITCODE;$ErrorActionPreference='Stop'
$manifest=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $out 'manifest.json')))
Write-Host ($manifest|ConvertTo-Json -Depth 18)
foreach($file in @(Get-ChildItem -LiteralPath $out -Filter '*.xml')){Write-Host ([IO.File]::ReadAllText($file.FullName))}
Assert ($code -eq 0 -and $manifest.Status -eq 'LocalFailedLogonObserved' -and $manifest.ExitCode -eq 0) ('Native public probe failed with exit '+$code+': '+$manifest.Diagnostic)
Assert ($manifest.Matches -eq 1 -and $manifest.ReadyRuleCredit -eq 0 -and $manifest.PolicyChanges -eq 0 -and $manifest.AccountChanges -eq 0) 'One exact event, no mutation or Sigma credit.'
Assert ($manifest.Operation.Attempt.NativeError -eq 1326 -and $manifest.Operation.Attempt.MissingAccountStatus -eq 2221 -and -not $manifest.Operation.Attempt.Succeeded) 'Actual local account absence and failed LogonUser receipt.'
Assert (Test-WelaFailedLogonEvent ([IO.File]::ReadAllText((Join-Path $out 'event.xml'))) $manifest.Operation $manifest.Before) 'Actual4625 satisfies exact identity/process/type/status/time checks.'
foreach($artifact in $manifest.Artifacts){Assert ($artifact.Sha256 -ceq (Get-FileHash -LiteralPath (Join-Path $out $artifact.Name)).Hash.ToLowerInvariant()) 'Native evidence hash verified.'}
Assert ((PolicyKey (Get-WelaEffectiveAuditPolicy)) -ceq $preparedPolicies) 'Product leaves every audit mask unchanged.'
Assert ((AccountsKey) -ceq $accounts) 'Local account names/SIDs/enabled states unchanged.'
Assert ((Get-Acl -LiteralPath $out).AreAccessRulesProtected) 'Evidence directory inheritance is protected.'
}
}catch{$failure=$_}
finally{
try{Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $policies[$guid] -Mode exact}catch{$cleanupErrors+='Audit restoration: '+$_.Exception.Message}
try{if($precedence.ValueExists){Set-ItemProperty -LiteralPath $path -Name $name -Type $precedence.Type -Value $precedence.Value}else{Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop}}catch{$cleanupErrors+='Precedence restoration: '+$_.Exception.Message}
try{Assert ((PolicyKey (Get-WelaEffectiveAuditPolicy)) -ceq (PolicyKey $policies)) 'All59 original audit masks restored.';Assert (((Get-WelaRegistryState $path $name)|ConvertTo-Json -Compress) -ceq ($precedence|ConvertTo-Json -Compress)) 'Typed original precedence restored.';Assert ((AccountsKey) -ceq $accounts) 'No local accounts changed.'}catch{$cleanupErrors+='Verification: '+$_.Exception.Message}
[ordered]@{CleanupVerified=($cleanupErrors.Count -eq 0);AuditMasksCompared=$policies.Count;ProbeRuns=$ProbeRuns;AssertionCount=$count;Failure=$(if($failure){$failure.Exception.Message}else{$null});CleanupErrors=$cleanupErrors}|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $root 'cleanup.json') -Encoding UTF8
}
if($failure){throw $failure};if($cleanupErrors.Count){throw ($cleanupErrors -join '; ')}
Write-Host "PASS: $script:count actual native4625/public CLI assertions across $ProbeRuns independent runs; original policies restored, no local account changes."
$global:LASTEXITCODE=0
+4
View File
@@ -9,6 +9,10 @@
- `transcription-recovery` を追加し、完了した Windows PowerShell 文字起こし設定を、再構築したハッシュ付き計画から型を保持して復元できるようにしました。ローカル保存先、ユーザー・ヘッダー・他のポリシーを確認し、一時停止は明示的な同意を求め、変更順序と途中結果を永続記録します。ヘルプと復旧手順には、一時停止中のエラーや中断でマシンの文字起こしが無効のまま残り、自動ロールバックや再有効化を行わないことを明記しました。使い捨て環境の実 CLI テストで復元とドリフト拒否を検証し、本番セッション・集中管理先の権限と収集・Sigma 対応は未検証とします。 (#376) (@Shirofune-Security)
- 完了したログ容量・保持モード設定を1件ずつ戻す `eventlog-recovery` を追加しました。元の記録と変更直前の記録、現在のチャネル・実行環境・コードを照合し、縮小と保持モード変更には個別の明示指定を必要とします。永続記録とネイティブ読戻しで無関係な設定を保持し、状態変化や再適用を拒否します。失われたイベント、長期保持、Sigma 利用可能性の証明は加算しません。 (@Shirofune-Security)
- `failed-logon-probe` を追加しました。存在しないことを確認したランダムなローカル SAM アカウントに対し、固定のネイティブログオン種別・プロバイダーで一度だけ認証を試行し、正確な時刻・プロセス・アカウント情報で Security4625 を照合します。監査設定を変更せず、保護された証跡を保存します。実際の資格情報、ドメインコントローラー、リモート認証、Sigma 対応率の加算は対象外です。使い捨て Windows 環境で公開コマンドと設定復元を検証します。(@Shirofune-Security)
- `wec-ingress` の Plan/Apply を追加し、明示した IPv4 範囲から Domain プロファイルの TCP5985 を許可する新規ルールを作成します。実ホスト・ログオン・プロファイル・コードと計画ハッシュ、変更前の永続記録、両ストアとフィルターの読戻しで変更や既存名を拒否します。既存の収集サーバー前提条件も、範囲を広げずに同等のIPv4ネットマスク表記・IPv6表記を照合します。使い捨て Windows テストは作成・名前衝突・再実行拒否・既存前提条件との連携・削除を検証し、リスナー・配送・Sigma の証明は加算しません。 (@Shirofune-Security)
- `smb-runtime` を追加し、Windows 標準の SMB 監査スイッチ6個を明示的に有効化します。モジュール・ビルド・ADMX、型付きポリシーの競合、設定全体の変化を確認し、各変更の意図と確認結果を永続的に記録します。署名・暗号化・ゲスト接続・サービス設定を保持し、現在の有効化とイベント・永続性・Sigma の証明を区別します。使い捨て Server 2025 の有効化と復元、Server 2022 の拒否を PowerShell 5.1/7 で検証します。 (#441) (@Shirofune-Security)
+4
View File
@@ -9,6 +9,10 @@
- Added explicit `transcription-recovery` for one completed Windows PowerShell transcription configuration, with independently rebuilt hashed plans, typed local-directory restoration, preserved user/header/other policy, explicit temporary-suspension consent and durable ordered receipts. Help and recovery guidance warn that interrupted suspension can leave machine transcription disabled without automatic rollback or re-enable. Disposable native public-CLI tests verify restoration and drift refusal; production sessions, central authorization/collection and Sigma readiness remain unverified. (#376) (@Shirofune-Security)
- Added reviewed `eventlog-recovery` for one completed profile size/retention write. Matched original and immediate-prewrite evidence, current channel/context/source guards, separate shrink/retention consent, durable pending receipts and native readback preserve unrelated channel settings and refuse drift or replay. Windows fixtures restore original settings; lost events, sustained retention and Sigma readiness are not inferred. (@Shirofune-Security)
- Added opt-in `failed-logon-probe` for one generated, confirmed nonexistent local SAM account attempt with fixed native logon type/provider, precise worker timing and exact Security4625 correlation. Protected receipts preserve raw evidence and unchanged audit/channel/token context; real credentials, domain controllers, remote authentication and Sigma credit are excluded. Disposable Windows tests cover native public runs and exact fixture cleanup. (@Shirofune-Security)
- Added explicit `wec-ingress` Plan/Apply for one new, narrowly scoped Domain TCP5985 collector firewall rule. Actual host/logon/profile/source guards, reviewed hashes, flushed pending evidence and both-store/filter readback refuse drift and existing names; partial creation remains explicit. The existing collector prerequisite recognizes equivalent native dotted netmasks and IPv6 spellings without broadening accepted scopes. Disposable native tests cover creation, collision, replay, collector integration and cleanup without listener, delivery or Sigma claims. (@Shirofune-Security)
- Added explicit `smb-runtime` activation of six native SMB audit switches, with reviewed module/build/ADMX capabilities, typed policy conflicts, complete configuration drift guards and durable per-switch receipts. Signing, encryption, guest access and service settings are preserved; runtime verification stays separate from events, persistence and Sigma credit. Disposable Server 2025 activation/restoration and Server 2022 refusal tests cover PowerShell 5.1/7. (#441) (@Shirofune-Security)