From c6da22a2add117c4d6e2410f9f71781e6342ef8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E4=B8=AD=E3=82=B6=E3=83=83=E3=82=AF=20Isaac=20Ma?= =?UTF-8?q?this?= <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:16:39 +0900 Subject: [PATCH] Resume a reviewed pending AD CS auditing restart (#431) * Add reviewed recovery for a pending AD CS auditing restart * Link pending CA restart recovery changelogs to PR 431 --- .gitattributes | 4 + .github/workflows/adcs-auditing.yml | 18 +++- CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + WELA.ps1 | 19 +++- docs/adcs-auditing.md | 2 +- docs/adcs-restart-resume.md | 45 ++++++++ scripts/AdcsRestartResume.ps1 | 142 +++++++++++++++++++++++++ tests/AdcsAuditing.Windows.Tests.ps1 | 31 +++++- tests/AdcsRestartResume.Cli.Tests.ps1 | 19 ++++ tests/AdcsRestartResume.Tests.ps1 | 89 ++++++++++++++++ website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 13 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 docs/adcs-restart-resume.md create mode 100644 scripts/AdcsRestartResume.ps1 create mode 100644 tests/AdcsRestartResume.Cli.Tests.ps1 create mode 100644 tests/AdcsRestartResume.Tests.ps1 diff --git a/.gitattributes b/.gitattributes index 613ba7c3..41a83846 100644 --- a/.gitattributes +++ b/.gitattributes @@ -39,6 +39,10 @@ modules/WecSubscriptionXml.cs text eol=lf scripts/AppLockerProbe.ps1 text eol=lf tests/AppLockerProbe*.ps1 text eol=lf +# Pending AD CS restart plans bind exact implementation bytes. +/scripts/AdcsRestartResume.ps1 text eol=lf +/scripts/AdcsAuditing.ps1 text eol=lf +/tests/AdcsRestartResume*.ps1 text eol=lf # Local delivery catalog and native observer retain reproducible source bytes. config/event_measurement.json text eol=lf scripts/EventMeasurement* text eol=lf diff --git a/.github/workflows/adcs-auditing.yml b/.github/workflows/adcs-auditing.yml index 9df055a0..25d082bc 100644 --- a/.github/workflows/adcs-auditing.yml +++ b/.github/workflows/adcs-auditing.yml @@ -5,10 +5,14 @@ on: paths: - 'WELA.ps1' - 'scripts/AdcsAuditing.ps1' + - 'scripts/AdcsRestartResume.ps1' + - 'scripts/AuditRecovery.ps1' + - 'scripts/WefArrival.ps1' - 'scripts/Configuration.ps1' - 'modules/AuditProfiles.psm1' - 'config/audit_profiles.json' - 'tests/AdcsAuditing*' + - 'tests/AdcsRestartResume*' - 'tests/fixtures/adcs-pending-probe.csr' - 'tests/fixtures/adcs-*-v1.xml' - '.github/workflows/adcs-auditing.yml' @@ -24,7 +28,7 @@ jobs: os: [windows-2022, windows-2025] engine: [powershell, pwsh] runs-on: ${{ matrix.os }} - timeout-minutes: 20 + timeout-minutes: 25 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Guard and event-correlation fixtures (Windows PowerShell) @@ -35,6 +39,18 @@ jobs: if: matrix.engine == 'pwsh' shell: pwsh run: ./tests/AdcsAuditing.Tests.ps1 + - name: Restart resume and public CLI fixtures (Windows PowerShell) + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/AdcsRestartResume.Tests.ps1 + ./tests/AdcsRestartResume.Cli.Tests.ps1 + - name: Restart resume and public CLI fixtures (PowerShell 7) + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/AdcsRestartResume.Tests.ps1 + ./tests/AdcsRestartResume.Cli.Tests.ps1 - name: Disposable standalone CA, public configuration, real pending request, cleanup shell: powershell run: ./tests/AdcsAuditing.Windows.Tests.ps1 -AllowDisposableCA -TestEngine ${{ matrix.engine }} diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 24709680..2e99f5ae 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 専用 CA 監査設定の再起動待ちを確認し、明示的に再開する `adcs-resume` を追加。元の記録・結果、出典、現在の CA と実行者、永続的な意図記録、変更直前・最終確認により古い計画の再実行を拒否し、観測した設定を保持します。ドライランと失敗を区別し、イベントや Sigma の証明は加算しません。使い捨て CA テストでは最初の再起動拒否のみを注入し、公開 CLI による実際の再起動と要求イベントを検証します。既存の専用 CA Configure のドライラン引数制限も修正しました。 (#431) (@Shirofune-Security) + - `event-measurement`を追加。明示した組み込みの管理・運用チャネル1つを一定時間だけ観測し、単調時計によるコールバック到着時間、元のXML・ブックマーク、非公開の証拠、ネイティブEVTXサンプルの厳密な再読み取りを記録します。上限超過、欠落・古い記録、状態変化、不完全なエクスポートは未検証とし、サンプルのバイト数からログ増加量・保持容量・バックエンド到達・Sigma検知可能性を推定しません。 (#430) (@Shirofune-Security) - `targeted-sacl`で子孫への継承を明示的に許可した場合、件数・深さを制限した子孫一覧と記述子を計画・変更直前に照合し、変更前の記録、保護された子孫の保持、各オブジェクトの継承結果を確認するようにしました。上限超過、読み取り拒否、リンク、子孫の追加・消失・変更は処理を停止または失敗として記録し、親のみの既存動作は保持します。使い捨てファイル/レジストリ階層で継承と保護を検証し、子孫ACEの所有権、一括復旧、Sigma利用可能性は主張しません。 (#429) (@Shirofune-Security) - 固定のローカル名前空間読み取りを行う任意実行の `wmi-probe` を追加しました。実トークン・監査ポリシー・完全な SACL を観測し、WMI Security4662 を厳密に照合して、容量制限付きの非公開 XML とコードの指紋を記録します。本番の名前空間やポリシーは変更せず、Sigma の評価には加算しません。WMI 接続は明示的に管理するセキュリティ特権だけを使用し、意図しないスレッド特権の有効化を防ぎます。両 PowerShell エンジンの使い捨て Server 2022/2025 テストで実際のローカル 4662 と監査設定・名前空間の復元を確認しました。リモートアクセス、プロバイダー処理の成否、個々のクエリへの排他的な帰属は未検証です。 (#428) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9b8ddda..e4285b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added `adcs-resume` to review and explicitly resume a dedicated pending CA auditing restart. Original journal/results, source and current CA/operator fingerprints, durable intent receipts and fresh/final checks prevent stale-plan replay and preserve observed settings. Dry-run and failed attempts remain explicit, with no event/Sigma credit. Disposable CA tests inject the initial refusal then verify an actual public-CLI restart and request events. Also fixed the existing dedicated CA Configure dry-run CLI guard. (#431) (@Shirofune-Security) + - Added opt-in `event-measurement` for bounded local callback-delivery windows on one explicit built-in Administrative/Operational channel, with monotonic timing, original XML/bookmarks, private evidence and exact native EVTX sample reopening. Caps, missing/stale records, source drift and incomplete exports remain unverified; sample-file bytes do not imply channel growth, retention capacity, backend ingestion or Sigma readiness. (#430) (@Shirofune-Security) - Extended explicit `targeted-sacl` child consent with bounded reviewed descendant inventories, fresh preflight/pre-write checks, durable child snapshots, protected-subtree preservation and per-child native inheritance outcomes. Caps, denials, links, new/disappeared children and drift block or fail the run; parent-only behavior stays unchanged. Disposable populated file/registry tests verify inheritance and protection without child-ACE ownership, bulk rollback or Sigma credit. (#429) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 559c6f78..83f82160 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -90,6 +90,13 @@ [switch]$TargetSaclIncludeChildren, [ValidateSet('Audit','Plan','Configure')][string]$AdcsAction = 'Audit', [string]$AdcsProfile, + [ValidateSet('Plan','Resume')][string]$AdcsResumeAction = 'Plan', + [string]$AdcsResumeJournalPath, + [string]$AdcsResumeResultsPath, + [string]$AdcsResumePlanPath, + [string]$AdcsResumePlanHash, + [string]$AdcsResumeOutputPath, + [switch]$AdcsResumeAllowRestart, [switch]$AllowRestart, [ValidateSet('Export','Verify')][string]$EvtxAction = 'Verify', [string]$EvtxProbePath, @@ -143,6 +150,7 @@ $AuditpolTxtPath = Join-Path $ScriptRoot "auditpol.txt" $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/Configuration.ps1") . (Join-Path $ScriptRoot "scripts/AdcsAuditing.ps1") +. (Join-Path $ScriptRoot "scripts/AdcsRestartResume.ps1") . (Join-Path $ScriptRoot "scripts/AuditIntegrity.ps1") . (Join-Path $ScriptRoot "scripts/FirewallLogging.ps1") . (Join-Path $ScriptRoot "scripts/SmbAuditing.ps1") @@ -1948,6 +1956,7 @@ Usage: ./WELA.ps1 adcs-auditing -Help # Dedicated local CA audit settings; restart requires explicit consent ./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 wec-update -Help # Review query/description updates on a disabled subscription ./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 @@ -1978,6 +1987,8 @@ if ($Cmd -ne 'targeted-sacl' -and @($PSBoundParameters.Keys | Where-Object { $_ if ($Cmd -eq 'targeted-sacl' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','TargetSaclAction','TargetSaclProfile','TargetSaclId','TargetSaclPlanPath','TargetSaclIncludeChildren','IncludeOptional','Auto','DryRun','BackupPath','ResultsPath','Help') }).Count) { throw 'targeted-sacl accepts only selected-target, consent and report options. No command was run.' } +if ($Cmd -ne 'adcs-resume' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'AdcsResume*'}).Count) {throw 'AdcsResume options require adcs-resume.'} +if ($Cmd -eq 'adcs-resume' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','AdcsResumeAction','AdcsResumeJournalPath','AdcsResumeResultsPath','AdcsResumePlanPath','AdcsResumePlanHash','AdcsResumeOutputPath','AdcsResumeAllowRestart','DryRun','Help')}).Count) {throw 'adcs-resume accepts only its dedicated options.'} if ($Cmd -ne 'adcs-auditing' -and @($PSBoundParameters.Keys | Where-Object { $_ -in @('AdcsAction','AdcsProfile','AllowRestart') }).Count) { throw 'AD CS options require adcs-auditing. No command was run.' } @@ -2097,7 +2108,7 @@ if ($Cmd -ne 'ad-object-sacl' -and @($PSBoundParameters.Keys | Where-Object { }).Count) { throw 'AD object SACL options require the dedicated ad-object-sacl command. No command was run.' } -if ($DryRun -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and +if ($DryRun -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and -not ($Cmd -eq 'provider-packs' -and $ProviderAction -eq 'Configure') -and -not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and -not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and @@ -2163,6 +2174,12 @@ switch ($Cmd.ToLower()) { $report if ($report.ExitCode) { exit $report.ExitCode } } + 'adcs-resume' { + if ($Help) {Write-Host 'Usage: adcs-resume [-AdcsResumeAction Plan] -AdcsResumeJournalPath before.jsonl -AdcsResumeResultsPath failed.json -AdcsResumeOutputPath new-plan; Resume with -AdcsResumePlanPath reviewed-plan.json -AdcsResumePlanHash SHA256 and either -DryRun or -AdcsResumeAllowRestart -AdcsResumeOutputPath new-receipts. Only an unchanged, already-running CA recorded as RestartPending is eligible. See docs/adcs-restart-resume.md.';return} + $report=Invoke-WelaAdcsRestartResume -Action $AdcsResumeAction -JournalPath $AdcsResumeJournalPath -ResultsPath $AdcsResumeResultsPath -PlanPath $AdcsResumePlanPath -PlanHash $AdcsResumePlanHash -OutputPath $AdcsResumeOutputPath -AllowRestart:$AdcsResumeAllowRestart -DryRun:$DryRun + $report + if($report.ExitCode){exit $report.ExitCode} + } 'adcs-auditing' { if ($Help) { Write-Host 'Usage: ./WELA.ps1 adcs-auditing [-AdcsAction Audit|Plan|Configure] [-AdcsProfile microsoft-identity-ca-2026-09] [-AllowRestart] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath new.json]. Audit is read-only; Plan/Configure requires a source. Filter changes require AllowRestart. Existing stopped CAs are never started. See docs/adcs-auditing.md.'; return } $report=Invoke-WelaAdcsCommand -Action $AdcsAction -Profile $AdcsProfile -AllowRestart:$AllowRestart -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath diff --git a/docs/adcs-auditing.md b/docs/adcs-auditing.md index b9c17ed1..9d7ab723 100644 --- a/docs/adcs-auditing.md +++ b/docs/adcs-auditing.md @@ -34,7 +34,7 @@ The existing `configure` path delegates to the same guarded engine. Its existing A failed write, changed identity, readback discrepancy or failed restart produces a failure and a nonzero exit. Earlier verified prerequisites and a written filter can remain changed. The private journal retains exact earlier types/values/absence and CA identity; there is no automatic rollback. -If the filter was written but restart failed, the result records `RestartPending`. A later run finding 127 does not prove activation or silently retry a restart. Review the journal and current CA identity, effective auditing and service/dependency state before a deliberate maintenance restart. Any manual restoration must apply only to that same CA and restore only the specific recorded settings, preserving unrelated policy. Do not replay a journal onto another CA or override intervening administrator/GPO changes. Review both the individual results and the final snapshot after recovery. +If the filter was written but restart failed, the result records `RestartPending`. A later run finding 127 does not prove activation or silently retry a restart. Review the journal and current CA identity, effective auditing and service/dependency state before a deliberate maintenance restart. For an unchanged, still-running pending instance, [the explicit `adcs-resume` review and recovery command](adcs-restart-resume.md) provides a guarded plan, dry run and separately authorized restart. Any manual restoration must apply only to that same CA and restore only the specific recorded settings, preserving unrelated policy. Do not replay a journal onto another CA or override intervening administrator/GPO changes. Review both the individual results and the final snapshot after recovery. ## Disposable native validation diff --git a/docs/adcs-restart-resume.md b/docs/adcs-restart-resume.md new file mode 100644 index 00000000..08d2b588 --- /dev/null +++ b/docs/adcs-restart-resume.md @@ -0,0 +1,45 @@ +# Resume a pending AD CS auditing restart + +`adcs-resume` provides a deliberate recovery path after a dedicated `adcs-auditing Configure` wrote `AuditFilter=127` but recorded `RestartPending`. It supports the same dedicated Server 2022/2025 CA scope as [AD CS auditing](adcs-auditing.md). It does not write registry values, change auditing, start a stopped CA, change dependent services or submit certificate requests. Sysmon is excluded. + +Microsoft requires [restarting Certificate Services after changing its audit filter](https://learn.microsoft.com/en-us/defender-for-identity/deploy/event-collection-overview). Finding 127 on a later audit cannot establish activation. The existing Configure command still leaves an unchanged filter alone; recovery requires its own reviewed plan and explicit restart consent. + +## Review, dry run, resume + +Use an elevated, non-impersonated native 64-bit PowerShell 5.1 or 7 session on the original CA in an appropriate maintenance window. Retain the original dedicated command's `before.jsonl` and failed results JSON. All paths must be on local fixed drives with existing protected parents; the plan and receipt directories must be new. Avoid concurrent CA, policy and evidence-file administration. + +```powershell +.\WELA.ps1 adcs-resume -AdcsResumeJournalPath C:\Evidence\ca-journal\before.jsonl -AdcsResumeResultsPath C:\Evidence\ca-result.json -AdcsResumeOutputPath C:\Evidence\restart-plan + +# Review restart-plan\plan.json and its complete expected CA/operator context. +$plan = 'C:\Evidence\restart-plan\plan.json' +$hash = (Get-FileHash -LiteralPath $plan -Algorithm SHA256).Hash.ToLowerInvariant() +.\WELA.ps1 adcs-resume -AdcsResumeAction Resume -AdcsResumePlanPath $plan -AdcsResumePlanHash $hash -DryRun + +# During the approved maintenance window, from the same operator context: +.\WELA.ps1 adcs-resume -AdcsResumeAction Resume -AdcsResumePlanPath $plan -AdcsResumePlanHash $hash -AdcsResumeAllowRestart -AdcsResumeOutputPath C:\Evidence\restart-receipts +``` + +Plan reads original evidence and current state, then creates a private `plan.json`. DryRun revalidates everything and writes no files or service changes. Resume requires the exact reviewed plan hash and `-AdcsResumeAllowRestart`; generic `-Auto` and `-AllowRestart` are refused. It independently rebuilds the plan, writes and flushes `reviewed-plan.json` and `pending.json`, rechecks the original evidence, implementation, actual operator and CA, and then calls the existing non-force Certificate Services restart. The pending receipt records intent, not completed work. + +## Eligibility and refusal + +The original dedicated result must be a failed, non-dry-run Configure with `RestartPending`, matching settings and one failed filter result that agrees with its journal. The earlier filter must have been a known DWORD below 127 or absent. The current installed source profile must match the original report. Legacy configure output or a plain audit showing 127 is insufficient. + +The current CA must still be running in the exact service instance recorded after that write, with unchanged host/build/role, Active CA, certificate identities, typed filter, effective Certification Services auditing, audit precedence, service start mode and dependent-service states. Running dependents are refused. Plan binds the current machine GUID, operator SID, group SID list and elevation/impersonation context. Group SIDs are context observations, not an access simulation; Windows enforces service access. Repeat observations and pre/post checks refuse detected drift. + +A newer process, reboot, stopped service, changed CA/certificate, replaced input, changed implementation/profile, different operator or altered policy requires separate operator investigation. This command does not generalize old evidence to another CA, recreate a lost service, restore earlier values or automatically retry a failed resumed restart. A successful resume makes the original plan ineligible because its recorded process is no longer current. + +## Results and limitations + +`RestartObserved` means the service has a newer start time within the attempt window, is Running, and all other observed CA/settings/service properties remain equal in readback and final verification. It does not prove request-event generation, forwarding, enterprise template behavior or Sigma/backend readiness; `EventGeneration=Unverified` and `ReadyRuleCredit=0` remain explicit. + +Failure before service mutation returns `Refused`; failure after an attempted restart returns `RestartAttemptedUnverified`. Inspect the result and actual service state rather than retrying blindly. If final receipt writing fails, the command fails and the earlier pending receipt may remain; it is not a success record. No automatic rollback occurs. Slow service operations can exceed the subsequent 30-second Running wait; this is not a strict end-to-end restart timeout. + +Hashes provide byte consistency, not signature/authorship or protection against a local administrator rewriting all evidence. Path, state and source checks are repeated observations rather than an atomic transaction with Windows or the filesystem. Run from a protected checkout/evidence parent and avoid concurrent administration. The default read-only Plan cannot infer why a prior restart failed or that restarting a production CA is operationally safe. + +## Validation + +Safe fixtures exercise historical evidence rejection, fresh CA/policy/dependency drift, explicit consent, changed plans, journaling failures, failed/silent restarts, preserved-state verification and replay rejection. Public CLI fixtures also cover the existing `adcs-auditing Configure -DryRun` guard. + +The gated disposable CA workflow runs Server 2022/2025 with public CLI calls under PowerShell 5.1/7. A test-only helper refuses the initial restart after real filter writes to produce authentic pending output; this is fault injection, not evidence of a naturally occurring service failure. Separate public child processes then plan, dry-run, perform an actual restart, verify hashed receipts and reject replay. A fixed pending certificate request generates correlated native 4886/4889 afterward. Existing exact audit-policy restoration and owned CA/key/certificate cleanup remain required. Feature removal that requests a reboot relies on disposal of the hosted VM, recorded separately. Enterprise/DC and production maintenance-window acceptance remain outside this fixture. diff --git a/scripts/AdcsRestartResume.ps1 b/scripts/AdcsRestartResume.ps1 new file mode 100644 index 00000000..e0d3cb29 --- /dev/null +++ b/scripts/AdcsRestartResume.ps1 @@ -0,0 +1,142 @@ +# Resume only a reviewed, still-running CA instance recorded as RestartPending. +function Get-WelaAdcsResumeSources { + $root=Split-Path $PSScriptRoot -Parent + $items=foreach($name in @('WELA.ps1','scripts/AdcsAuditing.ps1','scripts/AdcsRestartResume.ps1','scripts/Configuration.ps1','scripts/AuditRecovery.ps1','scripts/WefArrival.ps1','modules/AuditProfiles.psm1','config/audit_profiles.json')) { + [pscustomobject][ordered]@{Name=$name;Sha256=(Get-FileHash -LiteralPath (Join-Path $root $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()} + } + @($items) +} +function Read-WelaAdcsResumeFile { + param([string]$Path) + $full=Resolve-WelaArrivalPath $Path + $stream=[IO.File]::Open($full,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + try { + if($stream.Length -lt 1 -or $stream.Length -gt 4194304){throw 'AD CS resume input must contain 1 byte to 4 MiB.'} + $bytes=New-Object byte[] ([int]$stream.Length);$offset=0 + while($offset -lt $bytes.Length){$read=$stream.Read($bytes,$offset,$bytes.Length-$offset);if($read -eq 0){throw 'AD CS resume input ended early.'};$offset+=$read} + if($stream.Length -ne $bytes.Length){throw 'AD CS resume input length changed.'} + [pscustomobject][ordered]@{Path=$full;Sha256=(Get-WelaArrivalHash $bytes);Text=([Text.UTF8Encoding]::new($false,$true)).GetString($bytes).TrimStart([char]0xfeff)} + }finally{$stream.Dispose()} +} +function Get-WelaAdcsResumeContext { + if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'AD CS resume requires native 64-bit Windows.'} + $machine=Get-WelaRegistryState 'HKLM:\SOFTWARE\Microsoft\Cryptography' MachineGuid + $guid=[guid]::Empty + if(-not $machine.ValueExists -or $machine.Type -cne 'String' -or -not [guid]::TryParse([string]$machine.Value,[ref]$guid) -or $guid -eq [guid]::Empty){throw 'Actual machine identity is unavailable.'} + $identity=[Security.Principal.WindowsIdentity]::GetCurrent() + try { + if($identity.ImpersonationLevel -ne [Security.Principal.TokenImpersonationLevel]::None -or -not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'AD CS resume requires a non-impersonated elevated administrator.'} + [pscustomobject][ordered]@{Computer=[Environment]::MachineName;MachineGuid=$guid.ToString();UserSid=$identity.User.Value;UserName=$identity.Name;GroupSids=@($identity.Groups.Value|Sort-Object);ElevatedAdministrator=$true;ImpersonationLevel=[string]$identity.ImpersonationLevel} + }finally{$identity.Dispose()} +} +function Assert-WelaAdcsResumeState { + param($State) + Assert-WelaAdcsPrerequisites $State + if(-not (Test-WelaAdcsControl $State Filter) -or @($State.Service.Dependents|Where-Object Status -ne 'Stopped').Count){throw 'Resume requires AuditFilter DWORD127 and no running dependent services.'} + $null=ConvertTo-WelaArrivalUtc $State.Service.StartUtc + if($State.Service.ProcessId -le 0){throw 'Running CA process identity is unavailable.'} +} +function New-WelaAdcsResumePlan { + param([string]$JournalPath,[string]$ResultsPath) + $source=Get-WelaAdcsSource + $journal=Read-WelaAdcsResumeFile $JournalPath;$results=Read-WelaAdcsResumeFile $ResultsPath + $report=ConvertFrom-WelaRecoveryJson $results.Text + if($report.Kind -cne 'WelaAdcsAuditing' -or ($report.SchemaVersion -isnot [int] -and $report.SchemaVersion -isnot [long]) -or $report.SchemaVersion -ne 1 -or $report.Action -cne 'Configure' -or $report.Activation -cne 'RestartPending' -or ($report.ExitCode -isnot [int] -and $report.ExitCode -isnot [long]) -or $report.ExitCode -ne 1 -or $report.PolicyState -cne 'PolicyMatches' -or $report.Configuration.DryRun -isnot [bool] -or $report.Configuration.DryRun -or $report.Results -isnot [array]){throw 'Original dedicated AD CS results must record a failed, non-dry-run Configure with RestartPending and matching policy.'} + foreach($name in @('Id','SchemaSha256','AuditGuid','AuditMask','AuditMode','Precedence','AuditFilter','SourceUrl')){ + if((Get-WelaRecoveryKey $report.Source.$name) -cne (Get-WelaRecoveryKey $source.$name)){throw "Original AD CS source differs: $name"} + } + if((Get-WelaRecoveryKey $report.Results) -cne (Get-WelaRecoveryKey $report.Configuration.Results)){throw 'Original AD CS result copies disagree.'} + $rows=@($report.Results|Where-Object Id -ceq 'ADCS/Filter') + if($rows.Count -ne 1 -or $rows[0].Kind -cne 'AdcsAudit' -or $rows[0].Status -cne 'Failed'){throw 'One failed ADCS/Filter result is required.'} + $row=$rows[0] + $entries=@($journal.Text -split '\r?\n'|Where-Object {$_ -match '\S'}|ForEach-Object {ConvertFrom-WelaRecoveryJson $_}) + if($entries.Count -lt 1 -or $entries.Count -gt 3){throw 'A dedicated AD CS journal must contain 1..3 entries.'} + $seen=@{} + foreach($entry in $entries){ + if(($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.Kind -cne 'AdcsAudit' -or $entry.Id -cnotin @('ADCS/Precedence','ADCS/AuditMask','ADCS/Filter') -or $seen.ContainsKey($entry.Id) -or $entry.ComputerName -ine $report.After.Host.Computer){throw 'Unexpected, duplicate or wrong-host AD CS journal entry.'} + $seen[$entry.Id]=$entry + $time=ConvertTo-WelaArrivalUtc $entry.RecordedUtc + if($time -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'AD CS journal timestamp is in the future.'} + } + if(-not $seen.ContainsKey('ADCS/Filter')){throw 'The original filter journal entry is missing.'} + $filter=$seen['ADCS/Filter'] + foreach($name in @('Before','Target','Desired')){ + if((Get-WelaRecoveryKey $filter.$name) -cne (Get-WelaRecoveryKey $row.$name)){throw "Original journal/result $name mismatch."} + } + if($filter.Target.Path -cne $filter.Before.Path -or $filter.Target.Name -cne 'AuditFilter' -or $filter.Target.Service -cne 'CertSvc' -or $filter.Target.ActiveCa -cne $filter.Before.Active.Value -or (Get-WelaRecoveryKey $filter.Target.Certificates) -cne (Get-WelaRecoveryKey $filter.Before.Certificates) -or + $filter.Desired.Type -cne 'DWord' -or $filter.Desired.Value -ne 127 -or $filter.Desired.RestartIfChanged -isnot [bool] -or -not $filter.Desired.RestartIfChanged){throw 'Original journal does not select this CA filter and authorized restart.'} + Assert-WelaAdcsPrerequisites $filter.Before + $prior=$filter.Before.Filter + if($prior.KeyExists -isnot [bool] -or -not $prior.KeyExists -or $prior.ValueExists -isnot [bool] -or + ($prior.ValueExists -and ($prior.Type -cne 'DWord' -or ($prior.Value -isnot [int] -and $prior.Value -isnot [long]) -or $prior.Value -lt 0 -or $prior.Value -ge 127)) -or + (-not $prior.ValueExists -and ($null -ne $prior.Value -or $null -ne $prior.Type))){throw 'Original filter state does not demonstrate a supported change to 127.'} + Assert-WelaAdcsResumeState $report.After + if((Get-WelaAdcsStateKey $filter.Before Filter) -cne (Get-WelaAdcsStateKey $report.After Filter)){throw 'Pending evidence contains CA, certificate, prerequisite or service drift.'} + $context=Get-WelaAdcsResumeContext + $actual=Get-WelaAdcsSnapshot;Assert-WelaAdcsResumeState $actual + if($context.Computer -ine $actual.Host.Computer -or (Get-WelaAdcsStateKey $actual) -cne (Get-WelaAdcsStateKey $report.After)){throw 'Current CA no longer matches the recorded pending instance; review it instead of replaying a restart.'} + $last=Get-WelaAdcsSnapshot;Assert-WelaAdcsResumeState $last + if((Get-WelaAdcsStateKey $last) -cne (Get-WelaAdcsStateKey $actual)){throw 'CA changed during restart planning.'} + [pscustomobject][ordered]@{Kind='WelaAdcsRestartPlan';SchemaVersion=1;Journal=[pscustomobject]@{Path=$journal.Path;Sha256=$journal.Sha256};OriginalResults=[pscustomobject]@{Path=$results.Path;Sha256=$results.Sha256};SourceId=$source.Id;Context=$context;Expected=$report.After;Sources=@(Get-WelaAdcsResumeSources);ReadyRuleCredit=0;Scope='Resume one still-running recorded CA after AuditFilter write; no registry, audit-policy, dependent-service or certificate changes. Hashes establish consistency, not authorship.'} +} +function Write-WelaAdcsResumeArtifact { + param([string]$Root,[string]$Name,[string]$Text) + $null=Resolve-WelaArrivalPath $Root + $bytes=[Text.UTF8Encoding]::new($false).GetBytes($Text) + if($bytes.Length -gt 4194304){throw 'AD CS resume artifact exceeds four MiB.'} + $stream=[IO.File]::Open((Join-Path $Root $Name),[IO.FileMode]::CreateNew,[IO.FileAccess]::ReadWrite,[IO.FileShare]::None) + try{$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true);$stream.Position=0;$hash=[Security.Cryptography.SHA256]::Create();try{$readback=([BitConverter]::ToString($hash.ComputeHash($stream))).Replace('-','').ToLowerInvariant()}finally{$hash.Dispose()}}finally{$stream.Dispose()} + if($readback -cne (Get-WelaArrivalHash $bytes)){throw 'AD CS resume artifact readback differs.'} + [pscustomobject]@{Name=$Name;Sha256=$readback;Bytes=$bytes.Length} +} +function Assert-WelaAdcsResumeFresh { + param($Plan,[string]$PlanPath,[string]$PlanHash) + if((Read-WelaAdcsResumeFile $PlanPath).Sha256 -cne $PlanHash){throw 'Reviewed restart plan changed.'} + if((Get-WelaRecoveryKey @(Get-WelaAdcsResumeSources)) -cne (Get-WelaRecoveryKey $Plan.Sources)){throw 'Restart implementation or source profile changed.'} + if((Read-WelaAdcsResumeFile $Plan.Journal.Path).Sha256 -cne $Plan.Journal.Sha256 -or (Read-WelaAdcsResumeFile $Plan.OriginalResults.Path).Sha256 -cne $Plan.OriginalResults.Sha256){throw 'Original restart evidence changed.'} + if((Get-WelaRecoveryKey (Get-WelaAdcsResumeContext)) -cne (Get-WelaRecoveryKey $Plan.Context)){throw 'Actual restart operator or machine identity changed.'} + $current=Get-WelaAdcsSnapshot;Assert-WelaAdcsResumeState $current + if((Get-WelaAdcsStateKey $current) -cne (Get-WelaAdcsStateKey $Plan.Expected)){throw 'Current CA drifted from the reviewed pending instance.'} + $current +} +function Invoke-WelaAdcsRestartResume { + param([ValidateSet('Plan','Resume')][string]$Action='Plan',[string]$JournalPath,[string]$ResultsPath,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath,[switch]$AllowRestart,[switch]$DryRun) + $ErrorActionPreference='Stop' + if($Action -eq 'Plan'){ + if(-not $JournalPath -or -not $ResultsPath -or -not $OutputPath -or $PlanPath -or $PlanHash -or $AllowRestart -or $DryRun){throw 'Restart Plan requires JournalPath, ResultsPath and a new OutputPath only.'} + $plan=New-WelaAdcsResumePlan $JournalPath $ResultsPath + $output=New-WelaArrivalOutput $OutputPath ([IO.Path]::GetDirectoryName($plan.Journal.Path)) + $artifact=Write-WelaAdcsResumeArtifact $output 'plan.json' ($plan|ConvertTo-Json -Depth 20) + return [pscustomobject]@{Status='Planned';ExitCode=0;OutputPath=$output;PlanHash=$artifact.Sha256;Plan=$plan;ReadyRuleCredit=0} + } + if($JournalPath -or $ResultsPath -or -not $PlanPath -or $PlanHash -cnotmatch '^[0-9a-f]{64}$' -or (-not $DryRun -and (-not $AllowRestart -or -not $OutputPath)) -or ($DryRun -and $OutputPath)){throw 'Resume requires a reviewed PlanPath and exact PlanHash; execution additionally requires AllowRestart and a new OutputPath. DryRun writes no files.'} + $inputFile=Read-WelaAdcsResumeFile $PlanPath + if($inputFile.Sha256 -cne $PlanHash){throw 'Reviewed restart plan hash differs.'} + $plan=ConvertFrom-WelaRecoveryJson $inputFile.Text + if($plan.Kind -cne 'WelaAdcsRestartPlan' -or $plan.SchemaVersion -ne 1){throw 'Unsupported restart plan.'} + $rebuilt=New-WelaAdcsResumePlan $plan.Journal.Path $plan.OriginalResults.Path + if((Get-WelaRecoveryKey $rebuilt) -cne (Get-WelaRecoveryKey $plan)){throw 'Restart plan differs from independently rebuilt evidence and current context.'} + $before=Assert-WelaAdcsResumeFresh $plan $inputFile.Path $PlanHash + if($DryRun){return [pscustomobject]@{Status='WouldRestart';ExitCode=0;Before=$before;ReadyRuleCredit=0}} + $output=New-WelaArrivalOutput $OutputPath ([IO.Path]::GetDirectoryName($inputFile.Path)) + $artifacts=New-Object 'System.Collections.Generic.List[object]' + $result=[pscustomobject][ordered]@{Kind='WelaAdcsRestartResult';SchemaVersion=1;Status='Refused';ExitCode=1;OutputPath=$output;PlanHash=$PlanHash;Before=$before;After=$null;RestartAttempted=$false;StartedUtc=$null;FinishedUtc=$null;Diagnostic='';ReadyRuleCredit=0;EventGeneration='Unverified';Artifacts=@()} + try { + $artifacts.Add((Write-WelaAdcsResumeArtifact $output 'reviewed-plan.json' $inputFile.Text)) + $pending=[pscustomobject]@{Kind='WelaAdcsRestartPending';PlanHash=$PlanHash;Before=$before;RecordedUtc=[DateTime]::UtcNow.ToString('o');Scope='Intent receipt only; no restart or ownership claim.'} + $artifacts.Add((Write-WelaAdcsResumeArtifact $output 'pending.json' ($pending|ConvertTo-Json -Depth 20))) + $null=Assert-WelaAdcsResumeFresh $plan $inputFile.Path $PlanHash + $result.StartedUtc=[DateTime]::UtcNow.ToString('o');$result.RestartAttempted=$true + Restart-WelaAdcsService + $after=Get-WelaAdcsSnapshot;$result.After=$after;Assert-WelaAdcsResumeState $after + if((Get-WelaAdcsStateKey $before Restart) -cne (Get-WelaAdcsStateKey $after Restart) -or (ConvertTo-WelaArrivalUtc $after.Service.StartUtc) -le (ConvertTo-WelaArrivalUtc $before.Service.StartUtc) -or (ConvertTo-WelaArrivalUtc $after.Service.StartUtc) -lt (ConvertTo-WelaArrivalUtc $result.StartedUtc).AddSeconds(-1)){throw 'A newer CA process with preserved identity, settings and service state was not verified.'} + if((Get-WelaRecoveryKey (Get-WelaAdcsResumeContext)) -cne (Get-WelaRecoveryKey $plan.Context) -or (Get-WelaRecoveryKey @(Get-WelaAdcsResumeSources)) -cne (Get-WelaRecoveryKey $plan.Sources)){throw 'Operator, host or implementation changed during restart.'} + if((Read-WelaAdcsResumeFile $inputFile.Path).Sha256 -cne $PlanHash -or (Read-WelaAdcsResumeFile $plan.Journal.Path).Sha256 -cne $plan.Journal.Sha256 -or (Read-WelaAdcsResumeFile $plan.OriginalResults.Path).Sha256 -cne $plan.OriginalResults.Sha256){throw 'Reviewed plan or original evidence changed during restart.'} + $final=Get-WelaAdcsSnapshot;Assert-WelaAdcsResumeState $final + if((Get-WelaAdcsStateKey $final) -cne (Get-WelaAdcsStateKey $after)){throw 'Final CA state drifted after restart.'} + $result.After=$final;$result.Status='RestartObserved';$result.ExitCode=0 + }catch{$result.Status=if($result.RestartAttempted){'RestartAttemptedUnverified'}else{'Refused'};$result.Diagnostic=$_.Exception.Message} + $result.FinishedUtc=[DateTime]::UtcNow.ToString('o');$result.Artifacts=@($artifacts.ToArray()) + $null=Write-WelaAdcsResumeArtifact $output 'result.json' ($result|ConvertTo-Json -Depth 20) + $result +} diff --git a/tests/AdcsAuditing.Windows.Tests.ps1 b/tests/AdcsAuditing.Windows.Tests.ps1 index 19a5020d..f902b68a 100644 --- a/tests/AdcsAuditing.Windows.Tests.ps1 +++ b/tests/AdcsAuditing.Windows.Tests.ps1 @@ -79,6 +79,35 @@ try { Invoke-TestCli -Arguments @('adcs-auditing','-AdcsAction','Configure','-AdcsProfile','microsoft-identity-ca-2026-09','-AllowRestart','-Auto','-BackupPath',(Join-Path $privateRoot 'repeat-journal'),'-ResultsPath',$repeatPath) $repeated=Get-Content -LiteralPath $repeatPath -Raw -Encoding UTF8|ConvertFrom-Json if($repeated.Activation -ne 'Unverified' -or @($repeated.Results|Where-Object Status -eq 'Applied').Count -or (Get-WelaAdcsStateKey (Get-WelaAdcsSnapshot)) -cne $stableKey){throw 'Native repeat changed state or claimed historical activation.'} + # Test-only fault injection: create an authentic failed Configure result by + # refusing its restart. Registry writes/observations and the resumed restart + # use real adapters. No injected helper enters the public child CLI process. + $null=New-ItemProperty -LiteralPath $stable.Path -Name AuditFilter -Value 0 -PropertyType DWord -Force + Restart-WelaAdcsService + $pendingPath=Join-Path $privateRoot 'restart-pending.json' + $pendingJournal=Join-Path $privateRoot 'restart-pending-journal' + $restartImplementation=(Get-Command Restart-WelaAdcsService).ScriptBlock + try { + function Restart-WelaAdcsService { throw 'Disposable fixture: restart deliberately refused after real filter write.' } + $pendingResult=Invoke-WelaAdcsCommand -Action Configure -Profile microsoft-identity-ca-2026-09 -AllowRestart -Auto -BackupPath $pendingJournal -ResultsPath $pendingPath + } finally { Set-Item -Path Function:Restart-WelaAdcsService -Value $restartImplementation } + if($pendingResult.ExitCode -ne 1 -or $pendingResult.Activation -cne 'RestartPending' -or $pendingResult.PolicyState -cne 'PolicyMatches'){throw 'Injected restart refusal did not leave authentic pending evidence with real AuditFilter127.'} + $pendingKey=Get-WelaAdcsStateKey (Get-WelaAdcsSnapshot) + $resumePlanRoot=Join-Path $privateRoot 'restart-plan' + Invoke-TestCli -Arguments @('adcs-resume','-AdcsResumeJournalPath',(Join-Path $pendingJournal 'before.jsonl'),'-AdcsResumeResultsPath',$pendingPath,'-AdcsResumeOutputPath',$resumePlanRoot) + $resumePlanPath=Join-Path $resumePlanRoot 'plan.json' + $resumeHash=(Get-FileHash -LiteralPath $resumePlanPath -Algorithm SHA256).Hash.ToLowerInvariant() + Invoke-TestCli -Arguments @('adcs-resume','-AdcsResumeAction','Resume','-AdcsResumePlanPath',$resumePlanPath,'-AdcsResumePlanHash',$resumeHash,'-DryRun') + if((Get-WelaAdcsStateKey (Get-WelaAdcsSnapshot)) -cne $pendingKey){throw 'Public resume DryRun changed the pending CA.'} + $resumeOutput=Join-Path $privateRoot 'restart-receipts' + Invoke-TestCli -Arguments @('adcs-resume','-AdcsResumeAction','Resume','-AdcsResumePlanPath',$resumePlanPath,'-AdcsResumePlanHash',$resumeHash,'-AdcsResumeAllowRestart','-AdcsResumeOutputPath',$resumeOutput) + $resumed=Get-Content -LiteralPath (Join-Path $resumeOutput 'result.json') -Raw -Encoding UTF8|ConvertFrom-Json + if($resumed.ExitCode -ne 0 -or $resumed.Status -cne 'RestartObserved' -or -not $resumed.RestartAttempted -or $resumed.ReadyRuleCredit -ne 0 -or $resumed.EventGeneration -cne 'Unverified'){throw 'Public resume did not verify the actual restart with explicit evidence limits.'} + foreach($artifact in $resumed.Artifacts){if((Get-FileHash -LiteralPath (Join-Path $resumeOutput $artifact.Name)).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'Public resume receipt hash mismatch.'}} + $stable=Get-WelaAdcsSnapshot;$stableKey=Get-WelaAdcsStateKey $stable + Invoke-TestCli -Arguments @('adcs-resume','-AdcsResumeAction','Resume','-AdcsResumePlanPath',$resumePlanPath,'-AdcsResumePlanHash',$resumeHash,'-AdcsResumeAllowRestart','-AdcsResumeOutputPath',(Join-Path $privateRoot 'replay')) -ExpectedExit 1 + if((Get-WelaAdcsStateKey (Get-WelaAdcsSnapshot)) -cne $stableKey -or (Test-Path -LiteralPath (Join-Path $privateRoot 'replay'))){throw 'Consumed pending plan restarted the CA again or created output.'} + Write-Host 'Native pending-restart recovery passed: real filter write, injected refusal, public plan/dry-run, actual service restart, hashed receipts and replay rejection.' # This public CSR has no corresponding private key in the repository or runner. # A pending request cannot produce a usable leaf certificate; never approve it. $csrPath=Join-Path $PSScriptRoot 'fixtures/adcs-pending-probe.csr' @@ -174,4 +203,4 @@ try { if($passed){Remove-Item -LiteralPath $privateRoot -Recurse -Force} } $global:LASTEXITCODE=0 -Write-Host 'PASS: actual public CA configuration, idempotence, pending-request events and exact audit/created-CA restoration. Requested OS feature removal can await hosted-runner disposal, as recorded separately.' +Write-Host 'PASS: actual public CA configuration, idempotence, pending-restart resume, pending-request events and exact audit/created-CA restoration. Requested OS feature removal can await hosted-runner disposal, as recorded separately.' diff --git a/tests/AdcsRestartResume.Cli.Tests.ps1 b/tests/AdcsRestartResume.Cli.Tests.ps1 new file mode 100644 index 00000000..1675ca81 --- /dev/null +++ b/tests/AdcsRestartResume.Cli.Tests.ps1 @@ -0,0 +1,19 @@ +$ErrorActionPreference='Stop' +$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path +$cases=@( + @{Args=@('adcs-resume','-Help');Exit=0;Pattern='Usage:'}, + @{Args=@('adcs-resume','-AdcsResumeAction','Resume','-DryRun','-Help');Exit=0;Pattern='Usage:'}, + @{Args=@('adcs-auditing','-AdcsAction','Configure','-DryRun','-Help');Exit=0;Pattern='Usage:'}, + @{Args=@('adcs-resume','-Auto','-Help');Exit=1;Pattern='dedicated options'}, + @{Args=@('adcs-resume','-AllowRestart','-Help');Exit=1;Pattern='dedicated options'}, + @{Args=@('configure','-AdcsResumeAllowRestart','-Help');Exit=1;Pattern='require adcs-resume'}, + @{Args=@('adcs-auditing','-AdcsResumePlanPath','unread.json','-Help');Exit=1;Pattern='require adcs-resume'}, + @{Args=@('adcs-resume','-DryRun','-Help');Exit=1;Pattern='DryRun'}, + @{Args=@('adcs-resume','-Role','ADCS','-Help');Exit=1;Pattern='dedicated options'} +) +foreach($case in $cases){ + $ErrorActionPreference='Continue';try{$text=@(& $engine -NoProfile -File "$repo/WELA.ps1" @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference='Stop'} + if($code -ne $case.Exit -or ($text -join "`n") -notmatch $case.Pattern){throw "Unexpected CLI result for $($case.Args -join ' '): $code / $text"} +} +Write-Host "AD CS resume public CLI: $($cases.Count) checks passed." +$global:LASTEXITCODE=0 diff --git a/tests/AdcsRestartResume.Tests.ps1 b/tests/AdcsRestartResume.Tests.ps1 new file mode 100644 index 00000000..f30804ce --- /dev/null +++ b/tests/AdcsRestartResume.Tests.ps1 @@ -0,0 +1,89 @@ +$ErrorActionPreference='Stop' +$repo=Split-Path $PSScriptRoot -Parent +. "$repo/scripts/Configuration.ps1" +. "$repo/scripts/AdcsAuditing.ps1" +. "$repo/scripts/AuditRecovery.ps1" +. "$repo/scripts/WefArrival.ps1" +. "$repo/scripts/AdcsRestartResume.ps1" +Import-Module "$repo/modules/AuditProfiles.psm1" -Force +$script:count=0;$script:ordinal=0 +function Assert($Condition,$Message){if(-not $Condition){throw $Message};$script:count++} +function Rejects($Action,$Pattern){$message='';try{&$Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern; got $message"} +function Clone($Value){ConvertFrom-WelaRecoveryJson ($Value|ConvertTo-Json -Depth 20)} +function Reg($Value,$Type='DWord'){[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=$Value;Type=$Type}} +$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-ca-resume-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp +$source=Get-WelaAdcsSource +$script:base=[pscustomobject]@{Status='Supported';Diagnostic='fixture';CapturedUtc=[DateTime]::UtcNow.ToString('o');Host=[pscustomobject]@{Computer='CAHOST';DnsHostName='CAHOST';Build=20348;UBR=1;Edition='ServerDatacenter';ProductType=3;DomainRole=2;DomainJoined=$false;Domain='WORKGROUP'};Active=(Reg 'CA-A' String);Path='HKLM:\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration\CA-A';CaType=(Reg 3);CertificateHashes=(Reg @('A'*40) MultiString);Certificates=@([pscustomobject]@{Thumbprint=('A'*40);Sha256=('b'*64);Subject='CN=CA-A';SerialNumber='01'});Filter=(Reg 127);Service=[pscustomobject]@{Name='CertSvc';Status='Running';StartMode='Auto';ProcessId=100;StartUtc=[DateTime]::UtcNow.AddHours(-1).ToString('o');Dependents=@()};AuditMask=3;Precedence=(Reg 1)} +$script:context=[pscustomobject]@{Computer='CAHOST';MachineGuid='920a91c5-aa3e-4ea4-a685-bff000e13cde';UserSid='S-1-5-21-1-2-3-1000';UserName='CAHOST\Reader';GroupSids=@('S-1-5-32-544');ElevatedAdministrator=$true;ImpersonationLevel='None'} +$script:writer=(Get-Command Write-WelaAdcsResumeArtifact).ScriptBlock +function Get-WelaAdcsResumeContext {Clone $script:context} +function Get-WelaAdcsSnapshot {Clone $script:current} +function Write-WelaAdcsResumeArtifact {param($Root,$Name,$Text) + if($script:failArtifact -ceq $Name){throw 'Injected receipt failure'} + $artifact=&$script:writer $Root $Name $Text + if($Name -ceq 'pending.json'){$script:pendingSeen=$true;if($script:pendingHook){&$script:pendingHook}} + $artifact +} +function Restart-WelaAdcsService { + Assert $script:pendingSeen 'Durable pending receipt precedes every restart.' + $script:restarts++ + if($script:restartFailure){throw 'Injected restart failure'} + if(-not $script:silentNoRestart){$script:current.Service.ProcessId=101;$script:current.Service.StartUtc=[DateTime]::UtcNow.ToString('o')} + if($script:restartHook){&$script:restartHook} +} +function New-Case { + $script:ordinal++;$case=Join-Path $temp ([string]$script:ordinal);$null=New-Item -ItemType Directory $case + $journalDir=Join-Path $case 'original';$null=New-Item -ItemType Directory $journalDir + $script:current=Clone $script:base;$script:restarts=0;$script:pendingSeen=$false;$script:pendingHook=$null;$script:restartHook=$null;$script:restartFailure=$false;$script:silentNoRestart=$false;$script:failArtifact='' + $prior=Clone $script:base;$prior.Filter=Reg 0 + $target=[pscustomobject]@{Path=$prior.Path;Name='AuditFilter';Service='CertSvc';ActiveCa=$prior.Active.Value;Certificates=$prior.Certificates} + $desired=[pscustomobject]@{Value=127;Type='DWord';RestartIfChanged=$true} + $row=[pscustomobject]@{Id='ADCS/Filter';Kind='AdcsAudit';Target=$target;Desired=$desired;Before=$prior;After=$null;Status='Failed';Diagnostic='Injected restart failure';Source=$source} + $entry=[pscustomobject]@{Version=1;ComputerName='CAHOST';RecordedUtc=[DateTime]::UtcNow.ToString('o');Id=$row.Id;Kind=$row.Kind;Target=$target;Before=$prior;Desired=$desired} + $report=[pscustomobject]@{Kind='WelaAdcsAuditing';SchemaVersion=1;Action='Configure';Activation='RestartPending';ExitCode=1;PolicyState='PolicyMatches';Source=$source;Results=@($row);Configuration=[pscustomobject]@{DryRun=$false;Results=@($row)};After=(Clone $script:base)} + $caseInfo=[pscustomobject]@{Root=$case;Journal=(Join-Path $journalDir 'before.jsonl');Results=(Join-Path $case 'original-results.json');Entry=$entry;Report=$report;Plan=$null} + Save-Case $caseInfo + $caseInfo +} +function Save-Case($Case){[IO.File]::WriteAllText($Case.Journal,($Case.Entry|ConvertTo-Json -Depth 20 -Compress));[IO.File]::WriteAllText($Case.Results,($Case.Report|ConvertTo-Json -Depth 20))} +function Plan-Case($Case){$Case.Plan=Invoke-WelaAdcsRestartResume -JournalPath $Case.Journal -ResultsPath $Case.Results -OutputPath (Join-Path $Case.Root 'plan');$Case.Plan} +function Resume-Case($Case,[switch]$DryRun){$params=@{Action='Resume';PlanPath=(Join-Path $Case.Root 'plan/plan.json');PlanHash=$Case.Plan.PlanHash};if($DryRun){$params.DryRun=$true}else{$params.AllowRestart=$true;$params.OutputPath=Join-Path $Case.Root 'resume'};Invoke-WelaAdcsRestartResume @params} +try { + $case=New-Case;$plan=Plan-Case $case + Assert ($plan.Status -eq 'Planned' -and $plan.PlanHash -cmatch '^[0-9a-f]{64}$' -and $script:restarts -eq 0) 'Plan reviews original evidence without a restart.' + $dry=Resume-Case $case -DryRun;Assert ($dry.Status -eq 'WouldRestart' -and $script:restarts -eq 0 -and -not (Test-Path (Join-Path $case.Root 'resume'))) 'DryRun changes no service and writes no receipt.' + $result=Resume-Case $case + Assert ($result.ExitCode -eq 0 -and $result.Status -eq 'RestartObserved' -and $script:restarts -eq 1 -and $result.ReadyRuleCredit -eq 0 -and $result.EventGeneration -eq 'Unverified') 'One observed restart preserves the explicit event/Sigma limit.' + Assert ((Get-WelaAdcsStateKey $result.Before Restart) -ceq (Get-WelaAdcsStateKey $result.After Restart)) 'All observed CA settings and service properties survive.' + foreach($artifact in $result.Artifacts){Assert ((Get-FileHash (Join-Path $result.OutputPath $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Receipt bytes match retained hashes.'} + Rejects {Resume-Case $case} 'no longer matches';Assert ($script:restarts -eq 1) 'Replaying a successfully resumed plan cannot restart again.' + foreach($change in @({param($c)$c.Report.Activation='Unverified'},{param($c)$c.Report.Action='Audit'},{param($c)$c.Report.Configuration.DryRun=$true},{param($c)$c.Report.Source=Clone $c.Report.Source;$c.Report.Source.SchemaSha256='0'*64},{param($c)$c.Entry.ComputerName='OTHER'},{param($c)$c.Entry.Desired=Clone $c.Entry.Desired;$c.Entry.Desired.Value=1},{param($c)$c.Report.Results[0].Status='Applied'},{param($c)$c.Report.After.Service.StartUtc=[DateTime]::UtcNow.AddMinutes(-5).ToString('o')})){ + $case=New-Case;&$change $case;Save-Case $case + Rejects {Plan-Case $case} 'Original|original|journal|source|pending|Pending|failed ADCS/Filter';Assert ($script:restarts -eq 0) 'Invalid historical evidence cannot authorize a restart.' + } + foreach($change in @({$script:current.Active.Value='CA-B'},{$script:current.Certificates[0].Sha256='c'*64},{$script:current.Filter=Reg 64},{$script:current.Filter=Reg 127 String},{$script:current.AuditMask=0},{$script:current.Precedence=Reg 1 String},{$script:current.Service.Status='Stopped'},{$script:current.Service.StartMode='Disabled'},{$script:current.Service.Dependents=@([pscustomobject]@{Name='dependent';Status='Running'})},{$script:current.Service.ProcessId=999})){ + $case=New-Case;$null=Plan-Case $case;&$change + Rejects {Resume-Case $case} 'requires|require|must|matches|CA';Assert ($script:restarts -eq 0) 'Fresh CA/filter/policy/dependency/process drift prevents restart.' + } + $case=New-Case;$null=Plan-Case $case + Rejects {Invoke-WelaAdcsRestartResume -Action Resume -PlanPath (Join-Path $case.Root 'plan/plan.json') -PlanHash $case.Plan.PlanHash -OutputPath (Join-Path $case.Root 'unauthorized')} 'AllowRestart' + Rejects {Invoke-WelaAdcsRestartResume -Action Resume -PlanPath (Join-Path $case.Root 'plan/plan.json') -PlanHash ('0'*64) -AllowRestart -OutputPath (Join-Path $case.Root 'bad-hash')} 'hash differs' + $planPath=Join-Path $case.Root 'plan/plan.json';$bad=ConvertFrom-WelaRecoveryJson ([IO.File]::ReadAllText($planPath));$bad|Add-Member NoteProperty Unexpected 'data';[IO.File]::WriteAllText($planPath,($bad|ConvertTo-Json -Depth 20));$case.Plan.PlanHash=(Get-FileHash $planPath).Hash.ToLowerInvariant() + Rejects {Resume-Case $case} 'independently rebuilt' + foreach($change in @({$script:current.Service.ProcessId=777},{$script:current.AuditMask=1},{$script:current.Filter=Reg 63},{$script:current.Service.Dependents=@([pscustomobject]@{Name='late';Status='Running'})})){ + $case=New-Case;$null=Plan-Case $case;$script:pendingHook=$change;$result=Resume-Case $case + Assert ($result.Status -eq 'Refused' -and $result.ExitCode -eq 1 -and -not $result.RestartAttempted -and $script:restarts -eq 0) 'Drift after durable journaling is refused before service mutation.' + } + $case=New-Case;$null=Plan-Case $case;$script:failArtifact='pending.json';$result=Resume-Case $case + Assert ($result.ExitCode -eq 1 -and $script:restarts -eq 0 -and -not $result.RestartAttempted) 'Failed pending receipt prevents restart.' + foreach($mode in @('fail','silent','drift')){ + $case=New-Case;$null=Plan-Case $case + switch($mode){'fail'{$script:restartFailure=$true}'silent'{$script:silentNoRestart=$true}'drift'{$script:restartHook={$script:current.Filter=Reg 1}}} + $result=Resume-Case $case + Assert ($result.ExitCode -eq 1 -and $result.Status -eq 'RestartAttemptedUnverified' -and $script:restarts -eq 1) 'Restart failure, false success or changed readback remains unverified without rollback.' + } + $case=New-Case;[IO.File]::WriteAllText($case.Results,'{"Kind":"WelaAdcsAuditing","kind":"other"}') + Rejects {Plan-Case $case} 'Duplicate' + Write-Host "AD CS restart resume: $script:count assertions passed. Service operations mocked." +}finally{Remove-Item -LiteralPath $temp -Recurse -Force} +$global:LASTEXITCODE=0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 3249e905..26523c72 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 専用 CA 監査設定の再起動待ちを確認し、明示的に再開する `adcs-resume` を追加。元の記録・結果、出典、現在の CA と実行者、永続的な意図記録、変更直前・最終確認により古い計画の再実行を拒否し、観測した設定を保持します。ドライランと失敗を区別し、イベントや Sigma の証明は加算しません。使い捨て CA テストでは最初の再起動拒否のみを注入し、公開 CLI による実際の再起動と要求イベントを検証します。既存の専用 CA Configure のドライラン引数制限も修正しました。 (#431) (@Shirofune-Security) + - `event-measurement`を追加。明示した組み込みの管理・運用チャネル1つを一定時間だけ観測し、単調時計によるコールバック到着時間、元のXML・ブックマーク、非公開の証拠、ネイティブEVTXサンプルの厳密な再読み取りを記録します。上限超過、欠落・古い記録、状態変化、不完全なエクスポートは未検証とし、サンプルのバイト数からログ増加量・保持容量・バックエンド到達・Sigma検知可能性を推定しません。 (#430) (@Shirofune-Security) - `targeted-sacl`で子孫への継承を明示的に許可した場合、件数・深さを制限した子孫一覧と記述子を計画・変更直前に照合し、変更前の記録、保護された子孫の保持、各オブジェクトの継承結果を確認するようにしました。上限超過、読み取り拒否、リンク、子孫の追加・消失・変更は処理を停止または失敗として記録し、親のみの既存動作は保持します。使い捨てファイル/レジストリ階層で継承と保護を検証し、子孫ACEの所有権、一括復旧、Sigma利用可能性は主張しません。 (#429) (@Shirofune-Security) - 固定のローカル名前空間読み取りを行う任意実行の `wmi-probe` を追加しました。実トークン・監査ポリシー・完全な SACL を観測し、WMI Security4662 を厳密に照合して、容量制限付きの非公開 XML とコードの指紋を記録します。本番の名前空間やポリシーは変更せず、Sigma の評価には加算しません。WMI 接続は明示的に管理するセキュリティ特権だけを使用し、意図しないスレッド特権の有効化を防ぎます。両 PowerShell エンジンの使い捨て Server 2022/2025 テストで実際のローカル 4662 と監査設定・名前空間の復元を確認しました。リモートアクセス、プロバイダー処理の成否、個々のクエリへの排他的な帰属は未検証です。 (#428) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 73593e50..1e5f53e8 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added `adcs-resume` to review and explicitly resume a dedicated pending CA auditing restart. Original journal/results, source and current CA/operator fingerprints, durable intent receipts and fresh/final checks prevent stale-plan replay and preserve observed settings. Dry-run and failed attempts remain explicit, with no event/Sigma credit. Disposable CA tests inject the initial refusal then verify an actual public-CLI restart and request events. Also fixed the existing dedicated CA Configure dry-run CLI guard. (#431) (@Shirofune-Security) + - Added opt-in `event-measurement` for bounded local callback-delivery windows on one explicit built-in Administrative/Operational channel, with monotonic timing, original XML/bookmarks, private evidence and exact native EVTX sample reopening. Caps, missing/stale records, source drift and incomplete exports remain unverified; sample-file bytes do not imply channel growth, retention capacity, backend ingestion or Sigma readiness. (#430) (@Shirofune-Security) - Extended explicit `targeted-sacl` child consent with bounded reviewed descendant inventories, fresh preflight/pre-write checks, durable child snapshots, protected-subtree preservation and per-child native inheritance outcomes. Caps, denials, links, new/disappeared children and drift block or fail the run; parent-only behavior stays unchanged. Disposable populated file/registry tests verify inheritance and protection without child-ACE ownership, bulk rollback or Sigma credit. (#429) (@Shirofune-Security)