From f1c1f7416629b081ad336a4f17d06b5bebf00844 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: Sun, 20 Sep 2026 19:34:03 +0900 Subject: [PATCH] Guard AD CS audit configuration and collect native request evidence (#421) * Add guarded native CA auditing and disposable request evidence * Link AD CS changelog to PR 421 * Retain primary native CA failure before cleanup diagnostics * Normalize native CA certificate hashes and record pending feature removal * Emit bounded disposable CA request matching diagnostics * Match observed version 1 CA request events with exact pending disposition --- .gitattributes | 3 + .github/workflows/adcs-auditing.yml | 40 ++++ CHANGELOG-Japanese.md | 1 + CHANGELOG.md | 1 + WELA.ps1 | 19 ++ docs/adcs-auditing.md | 49 +++++ scripts/AdcsAuditing.ps1 | 277 +++++++++++++++++++++++++ scripts/Configuration.ps1 | 37 +--- tests/AdcsAuditing.Tests.ps1 | 137 ++++++++++++ tests/AdcsAuditing.Windows.Tests.ps1 | 177 ++++++++++++++++ tests/Test-ConfigurationResults.ps1 | 39 +--- tests/fixtures/adcs-4886-v1.xml | 3 + tests/fixtures/adcs-4889-v1.xml | 3 + tests/fixtures/adcs-pending-probe.csr | 16 ++ website/docs/resources/changelog.ja.md | 1 + website/docs/resources/changelog.md | 1 + 16 files changed, 739 insertions(+), 65 deletions(-) create mode 100644 .github/workflows/adcs-auditing.yml create mode 100644 docs/adcs-auditing.md create mode 100644 scripts/AdcsAuditing.ps1 create mode 100644 tests/AdcsAuditing.Tests.ps1 create mode 100644 tests/AdcsAuditing.Windows.Tests.ps1 create mode 100644 tests/fixtures/adcs-4886-v1.xml create mode 100644 tests/fixtures/adcs-4889-v1.xml create mode 100644 tests/fixtures/adcs-pending-probe.csr diff --git a/.gitattributes b/.gitattributes index af995659..3c7203c6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -14,3 +14,6 @@ /modules/AuditProfiles.psm1 text eol=lf # Full upstream rule artifacts retain their exact pinned bytes on every platform. /config/provider_rule_sources/*.yml -text whitespace=-blank-at-eol + +# Fixed public pending-request fixture is pinned by its exact byte hash. +/tests/fixtures/adcs-pending-probe.csr text eol=lf diff --git a/.github/workflows/adcs-auditing.yml b/.github/workflows/adcs-auditing.yml new file mode 100644 index 00000000..9df055a0 --- /dev/null +++ b/.github/workflows/adcs-auditing.yml @@ -0,0 +1,40 @@ +name: Native AD CS auditing and pending-request evidence +on: + push: + branches: ['**'] + paths: + - 'WELA.ps1' + - 'scripts/AdcsAuditing.ps1' + - 'scripts/Configuration.ps1' + - 'modules/AuditProfiles.psm1' + - 'config/audit_profiles.json' + - 'tests/AdcsAuditing*' + - 'tests/fixtures/adcs-pending-probe.csr' + - 'tests/fixtures/adcs-*-v1.xml' + - '.github/workflows/adcs-auditing.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + adcs: + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Guard and event-correlation fixtures (Windows PowerShell) + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/AdcsAuditing.Tests.ps1 + - name: Guard and event-correlation fixtures (PowerShell 7) + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/AdcsAuditing.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 d5a7ba11..b69b5429 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,7 @@ **改善:** +- 既存CA向けにネイティブの`adcs-auditing`監査・計画・出典付き設定を追加しました。CAと証明書の識別、監査の前提条件、型付き復旧記録、変更直前と読戻しの検証を共有し、従来のCA設定も同じ処理を使用します。停止中のCAは起動せず、専用コマンドでのフィルター変更には再起動の明示指定を求めます。設定一致・再起動の観測・イベント証拠を区別し、Sigma利用可能数には加算しません。使い捨てのスタンドアロンCAテストで保留要求の4886/4889 XMLを関連付け、元の監査設定と作成した資源を復元・削除します。エンタープライズCA・DC・収集基盤の検証は別途必要です。 (#421) (@Shirofune-Security) - `evtx-recovery` を追加し、検証済みのネイティブ Security プローブを EVTX に出力して Windows イベント API で再読込できるようにしました。実際の読取アカウントによる検証、入力・イベントの厳密な比較、新規出力の保護、ハッシュとドリフト検出で空または変更された記録を拒否します。使い捨て Windows テストで実際の出力・復旧を検証し、全体の保存期間、他アカウントのアクセス、Sigma 対応とは区別します。 (#420) (@Shirofune-Security) - `audit-recovery` を追加し、完了した監査サブカテゴリと優先設定の変更を明示選択して計画・復元できるようにしました。元の記録と結果、ホストと入力の再検証、復元前の記録、最終確認でドリフトを検出し、最小設定の独立した追加ビットを保持します。優先設定は最後に復元します。使い捨て Windows 環境で実際の復元を検証し、過去のホスト同一性、GPO 永続性、Sigma 対応の証明とは区別します。 出力先はローカル固定ドライブに限定し、ネットワークドライブと代替データストリームを拒否します。 (#419) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index d791d066..68bc85f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ **Improvements:** +- Added dedicated native `adcs-auditing` audit, plan and source-profile configuration, with pinned CA/certificate identity, verified audit prerequisites, typed journals and fresh/readback guards. Legacy CA configuration uses the same engine; stopped CAs are preserved and dedicated filter changes require explicit restart consent. Policy matches, observed restarts and request events remain separate, with no Sigma credit. Disposable standalone-CA tests collect correlated pending-request 4886/4889 XML and restore policy/created resources; enterprise/DC/backend acceptance remains separate. (#421) (@Shirofune-Security) - Added opt-in `evtx-recovery` to export one validated native Security probe and reopen its EVTX through Windows event APIs, with a separate verification action under the actual reader. Strict source/component checks, exact event comparison, protected new output, archive hashes and reader/context drift checks reject empty or changed evidence. Disposable Windows tests cover real export/recovery and empty archives; full retention, other-reader access and Sigma readiness remain separate. (#420) (@Shirofune-Security) - Added opt-in `audit-recovery` planning and restoration for selected completed audit subcategory and typed precedence writes. Matched journals/results, independently rebuilt plans, actual host/source guards, durable receipts on local fixed drives and final readback refuse drift; minimum masks preserve independent additions and precedence restores last. Native disposable Windows tests verify exact restoration, without historical-identity, policy-persistence or Sigma claims. (#419) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 9b9d30ad..33a47672 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -77,6 +77,9 @@ [ValidateSet('Plan','Run')][string]$ProbeAction = 'Plan', [string]$ProbeOutputPath, [ValidateRange(1,30)][int]$ProbeTimeoutSeconds = 15, + [ValidateSet('Audit','Plan','Configure')][string]$AdcsAction = 'Audit', + [string]$AdcsProfile, + [switch]$AllowRestart, [ValidateSet('Export','Verify')][string]$EvtxAction = 'Verify', [string]$EvtxProbePath, [string]$EvtxArchivePath, @@ -103,6 +106,7 @@ $EidMappingPath = Join-Path $ScriptRoot "config/eid_subcategory_mapping.csv" $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/AuditIntegrity.ps1") . (Join-Path $ScriptRoot "scripts/FirewallLogging.ps1") . (Join-Path $ScriptRoot "scripts/SmbAuditing.ps1") @@ -1891,6 +1895,7 @@ Usage: ./WELA.ps1 control-applicability # Read-only historical native feature/build assessment ./WELA.ps1 default-evidence -Help # Exact-context observed snapshots and reviewed reference comparison ./WELA.ps1 audit-notifications -Help # OneSettings audit and Security warning policy + ./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 wef-arrival -Help # Verify exact native probe presence on the local collector @@ -1906,6 +1911,13 @@ Write-Host "" Write-Host "WELA v$WELAVersion - $WELAReleaseName" Write-Host "" +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.' +} +if ($Cmd -eq 'adcs-auditing' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','AdcsAction','AdcsProfile','AllowRestart','Auto','DryRun','BackupPath','ResultsPath','Help') }).Count) { + throw 'adcs-auditing accepts only dedicated CA action/source/consent/recovery/report options. No command was run.' +} + if ($Cmd -ne 'score' -and @($PSBoundParameters.Keys | Where-Object { $_ -in @('ScoreProfile','ScoreEvidencePath') }).Count) { throw 'Scoring options require score. No command was run.' } @@ -2042,6 +2054,13 @@ if ($Profile -and $Cmd.ToLower() -in @('plan', 'audit', 'audit-settings', 'confi } switch ($Cmd.ToLower()) { + '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 + $report | Select-Object Action,PolicyState,Activation,EventGeneration,ExitCode | Format-List + if ($report.ExitCode -ne 0) { exit $report.ExitCode } + } + 'score' { if ($Help) { Write-Host 'Usage: ./WELA.ps1 score -ScoreProfile profile-id [-Role role -Build build] [-IncludeOptional] [-ScoreEvidencePath evidence.json] [-ResultsPath new.json] [-HtmlPath new.html]. Explicit role/build is an offline scenario; omit both to observe this Windows host. Two separate measures, no overall security grade. See docs/audit-scoring.md.'; return } if (-not $ScoreProfile) { throw 'score requires an explicit -ScoreProfile. Use profiles to list built-in profiles.' } diff --git a/docs/adcs-auditing.md b/docs/adcs-auditing.md new file mode 100644 index 00000000..b9c17ed1 --- /dev/null +++ b/docs/adcs-auditing.md @@ -0,0 +1,49 @@ +# Native AD CS auditing + +`adcs-auditing` observes or configures the audit settings of an existing, dedicated Windows Server 2022/2025 certification authority. Its default action is read-only. It never installs a CA, submits a request or approves a certificate. Sysmon is excluded. + +```powershell +.\WELA.ps1 adcs-auditing -ResultsPath C:\Evidence\ca-audit.json +.\WELA.ps1 adcs-auditing -AdcsAction Plan -AdcsProfile microsoft-identity-ca-2026-09 +.\WELA.ps1 adcs-auditing -AdcsAction Configure -AdcsProfile microsoft-identity-ca-2026-09 -DryRun +# In an approved CA maintenance window, on a CA that is already running: +.\WELA.ps1 adcs-auditing -AdcsAction Configure -AdcsProfile microsoft-identity-ca-2026-09 -AllowRestart -Auto -BackupPath C:\Evidence\ca-journal -ResultsPath C:\Evidence\ca-result.json +``` + +The report parent must already exist on a local fixed drive. Results and backup paths must be new; existing files and reparse-point directories are refused. Dedicated configuration protects its new journal directory for the current user, SYSTEM and Administrators. Use a suitably protected existing parent for the JSON report. + +## Source and prerequisites + +The explicit `microsoft-identity-ca-2026-09` profile selects native CA requirements from [Microsoft Defender for Identity event collection guidance](https://learn.microsoft.com/en-us/defender-for-identity/deploy/event-collection-overview): Certification Services Success and Failure, `AuditFilter=127`, and a Certificate Services restart after changing that filter. The shared advanced-audit source is `microsoft-identity-reviewed-2026-09`; its file fingerprint and canonical Certification Services GUID are checked again before changes. `SCENoApplyLegacyAuditPolicy=1` is verified before the advanced subcategory, and both audit prerequisites are verified before changing the CA. This is a CA audit component, not a complete MDI deployment or baseline. + +The native observation records the exact build/patch/edition and machine role, Active CA name and registry path, CA type, configured CA certificate hashes and public certificate fingerprints, typed filter and precedence values, effective normalized audit mask, and Certificate Services process/start time/dependents. Configured certificate identities must resolve in LocalMachine/My. The configuration target is the pinned registry child, rather than an implicit `certutil CA` target that could change between planning and execution. + +An absent local CA is `NotApplicable`. An unreadable or ambiguous state, unreviewed build, combined DC/CA role, unsupported CA type, or unknown registry type/filter bits is `Unknown` and blocks changes. The command supports dedicated server CAs on builds 20348 and 26100; enterprise and standalone CA types remain distinct in evidence. Windows clients with no CA are not configured. + +## Changes, restart and evidence + +Every changed control uses the shared configuration runner: consent, typed before-state journal, a fresh observation after journaling, selected-field readback, preservation checks and final verification. Other CA identity, certificate, policy and service changes block continuation. The critical Active CA-A to CA-B race is tested through the legacy entry point as well as the dedicated engine. + +`-AllowRestart` authorizes restarting an already-running CA only when its filter changes. A stopped/disabled CA is never started, and running dependent services block changes. Restart uses no `-Force`. Microsoft warns that auditing start/stop on a large CA database can make service operations slow; schedule an appropriate maintenance window. Administrative changes are not atomic with Windows registry/service operations, so avoid concurrent CA/GPO administration during this procedure. + +The existing `configure` path delegates to the same guarded engine. Its existing confirmation or `-Auto` authorizes the historical filter-and-restart operation; no additional restart flag is imposed there. Failed or declined preceding audit prerequisites block its CA write. A dry run describes proposed operations without changing settings or restarting services. + +`PolicyMatches` means the observed three settings match. It does not establish that a pre-existing filter is active in the current process. An unchanged filter is not restarted and activation stays `Unverified`, even if CertSvc is Running. After a changed filter, `RestartObservedAfterWrite` requires a newer process start plus unchanged identity/prerequisites; event generation still remains unverified. The report grants no usable Sigma-rule credit. + +## Failure and recovery + +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. + +## Disposable native validation + +`tests/AdcsAuditing.Tests.ps1` exercises safe mocks, public CLI option guards and exact XML correlation. `tests/AdcsAuditing.Windows.Tests.ps1` additionally requires explicit `-AllowDisposableCA`, a GitHub-hosted Windows runner, a supported workgroup server and no existing CA. The workflow uses Server 2022/2025 and public CLI runs under PowerShell 5.1/7. Feature installation requiring reboot or unavailable native features fails the job instead of manufacturing acceptance. + +Only that opt-in test provisions a uniquely named short-lived standalone root CA with private directories. It performs real public configuration/readback/idempotence tests, including malformed filter preservation and native no-auditing normalization. It submits the fixed public PKCS#10 fixture with a random request attribute and requires numeric COM disposition 5 (pending), a positive request ID, and exactly correlated local Security 4886/4889 XML. The corresponding CSR private key was discarded; no leaf certificate is approved, retrieved or installed. There is no domain publication, template change or auto-issuance switch. The script restores exact earlier audit/precedence policy, verifies all audit subcategories, and removes only its newly created CA, certificates, keys and feature additions. Cleanup failures fail the job and retain a private receipt. + +After successful CA-resource removal and audit restoration, feature removal is requested only for additions made by the test. A successful Windows feature-removal result can require a reboot: its exact `RestartNeeded` value is recorded separately in a receipt and the CI log. Such pending removal relies on disposal of the GitHub-hosted VM after the job, not a verified complete OS feature rollback. An unsuccessful or unknown result, failed CA/key cleanup or failed audit restoration still fails the job. The test never restarts the runner, and this lifecycle allowance cannot turn a failed native test into success. + +The native component XML and context/hashes are printed to the disposable CI log after matching. They demonstrate only that request on that host and run. The matcher supports reviewed event versions 0 and 1, retaining the additional version 1 fields and requiring the same provider, host, time, request ID, requester and nonce; 4889 also requires pending disposition 5. The version 1 XML fixtures preserve actual Server 2022 output from [the disposable diagnostic run](https://github.com/Yamato-Security/WELA/actions/runs/35503379380/job/106058973322). Unknown versions remain unverified. Enterprise template events, a combined DC/CA, Windows 11, forwarding, backend queries, volume, recovery across reboots and complete detection readiness remain separate lab acceptance. A checked-in test is not itself evidence that the native job passed; inspect the workflow result and recorded components. + +Microsoft references: [standalone CA behavior and pending requests](https://learn.microsoft.com/en-us/windows-server/identity/ad-cs/certification-authority-role), [CA installation parameters](https://learn.microsoft.com/en-us/powershell/module/adcsdeployment/install-adcscertificationauthority?view=windowsserver2025-ps), [Certification Services event fields](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2012-r2-and-2012/dn786423(v=ws.11)), [ICertRequest::Submit](https://learn.microsoft.com/en-us/windows/win32/api/certcli/nf-certcli-icertrequest-submit), [GetRequestId](https://learn.microsoft.com/en-us/windows/win32/api/certcli/nf-certcli-icertrequest-getrequestid), [pending disposition value](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-wcce/dbb2e78f-7630-4615-92c4-6734fccfc5a6), and [CA type values](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-wcce/4fa5241c-d10e-4011-87e0-c74753d725a3). diff --git a/scripts/AdcsAuditing.ps1 b/scripts/AdcsAuditing.ps1 new file mode 100644 index 00000000..b0a507df --- /dev/null +++ b/scripts/AdcsAuditing.ps1 @@ -0,0 +1,277 @@ +# Native CA auditing; production functions never provision a CA or submit requests. +function Get-WelaAdcsSource { + param([string]$Profile='microsoft-identity-ca-2026-09') + if ($Profile -cne 'microsoft-identity-ca-2026-09') { throw 'Unknown AD CS source profile. Use microsoft-identity-ca-2026-09.' } + $path=Join-Path $PSScriptRoot '../config/audit_profiles.json' + $before=(Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + $plan=Get-WelaAuditProfilePlan -Profile microsoft-identity-reviewed-2026-09 -Role ADCS -Build 20348 + $selected=@($plan.policies | Where-Object { $_.mode -in @('exact','minimum') }) + if ($selected.Count -ne 1 -or $selected[0].id -cne 'Certification Services' -or $selected[0].guid -ine '0cce9221-69ae-11d9-bed3-505054503030' -or $selected[0].mode -ne 'minimum' -or $selected[0].requiredMask -ne 3 -or + $plan.schemaSha256.ToLowerInvariant() -cne $before -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -cne $before) { throw 'Reviewed Certification Services source profile changed or is inconsistent.' } + [pscustomobject]@{Id=$Profile;AdvancedProfile=$plan.profile;SchemaPath=$path;SchemaSha256=$before;AuditGuid=$selected[0].guid;AuditMask=3;AuditMode='minimum';Precedence=1;AuditFilter=127;SourceUrl='https://learn.microsoft.com/en-us/defender-for-identity/deploy/event-collection-overview';Reviewed='2026-09-20';Scope='Native CA audit settings only; no MDI sensor, template, AD-object or complete baseline configuration.'} +} +function Assert-WelaAdcsSource { + param($Source) + if ((Get-FileHash -LiteralPath $Source.SchemaPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() -cne $Source.SchemaSha256) { throw 'AD CS source profile changed after planning.' } +} +function ConvertTo-WelaAdcsThumbprints { + param($Values) + $items=@($Values) + if($items.Count -lt 1){throw 'CA certificate hash list is empty.'} + $seen=New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) + foreach($value in $items){ + # Native CACertHash REG_MULTI_SZ uses twenty space-separated octets; + # certificate-store thumbprints use the same forty hex digits unspaced. + if($value -isnot [string] -or $value -notmatch '^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{2}(?: [0-9a-fA-F]{2}){19})$'){throw 'CA certificate hash list is malformed.'} + $normalized=$value.Replace(' ','').ToUpperInvariant() + if(-not $seen.Add($normalized)){throw 'CA certificate hash identity is duplicated.'} + $normalized + } +} +function Get-WelaAdcsCertificates { + param([string[]]$Thumbprints) + $store=New-Object Security.Cryptography.X509Certificates.X509Store('My','LocalMachine') + $store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly -bor [Security.Cryptography.X509Certificates.OpenFlags]::OpenExistingOnly) + try { + foreach ($thumbprint in $Thumbprints) { + $certificates=@($store.Certificates.Find([Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint,$thumbprint,$false)) + if ($certificates.Count -ne 1) { throw 'Configured CA certificate is absent or ambiguous in LocalMachine/My.' } + $certificate=$certificates[0];$hash=[Security.Cryptography.SHA256]::Create() + try { $sha=([BitConverter]::ToString($hash.ComputeHash($certificate.RawData))).Replace('-','').ToLowerInvariant() } finally { $hash.Dispose() } + [pscustomobject]@{Thumbprint=$certificate.Thumbprint.ToUpperInvariant();Sha256=$sha;Subject=$certificate.Subject;SerialNumber=$certificate.SerialNumber} + } + } finally { $store.Close() } +} +function Get-WelaAdcsSnapshot { + $result=[pscustomobject][ordered]@{Status='Unknown';Diagnostic='';CapturedUtc=[DateTime]::UtcNow.ToString('o');Host=$null;Active=$null;Path=$null;CaType=$null;CertificateHashes=$null;Certificates=@();Filter=$null;Service=$null;AuditMask=$null;Precedence=$null} + try { + if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess) { throw '64-bit Windows is required for native CA observation.' } + $os=Get-CimInstance Win32_OperatingSystem -ErrorAction Stop + $computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop + if ($os.ProductType -notin @(1,2,3) -or ($os.ProductType -eq 1 -and $computer.DomainRole -notin @(0,1)) -or ($os.ProductType -eq 2 -and $computer.DomainRole -notin @(4,5)) -or ($os.ProductType -eq 3 -and $computer.DomainRole -notin @(2,3)) -or $computer.PartOfDomain -isnot [bool]) { throw 'Windows role observations are incomplete or contradictory.' } + $version=Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction Stop + if ([string]$os.BuildNumber -notmatch '^\d+$' -or $null -eq $version.UBR -or -not $version.EditionID -or -not $computer.Name) { throw 'Exact Windows build/patch/edition/host identity is unavailable.' } + $result.Host=[pscustomobject]@{Computer=[string]$computer.Name;DnsHostName=[string]$computer.DNSHostName;Build=[int]$os.BuildNumber;UBR=[int]$version.UBR;Edition=[string]$version.EditionID;ProductType=[int]$os.ProductType;DomainRole=[int]$computer.DomainRole;DomainJoined=[bool]$computer.PartOfDomain;Domain=[string]$computer.Domain} + $root='HKLM:\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration' + if (-not (Test-Path -LiteralPath $root -ErrorAction Stop)) { $result.Status='NotApplicable';$result.Diagnostic='No configured local CA. No CA is installed by this command.';return $result } + if ($os.ProductType -ne 3 -or $computer.DomainRole -notin @(2,3)) { throw 'Only a dedicated server CA is supported; client and combined DC/CA configuration are refused.' } + if ($result.Host.Build -notin @(20348,26100)) { throw 'CA host build is outside the reviewed Server 2022/2025 families.' } + $result.Active=Get-WelaRegistryState -Path $root -Name Active + if (-not $result.Active.ValueExists -or $result.Active.Type -ne 'String' -or $result.Active.Value -isnot [string] -or [string]::IsNullOrWhiteSpace($result.Active.Value) -or $result.Active.Value -match '[\\/\x00-\x1f]' -or $result.Active.Value -in @('.','..')) { throw 'The active CA name must identify exactly one existing registry child.' } + $result.Path=$root+'\'+$result.Active.Value + $result.CaType=Get-WelaRegistryState -Path $result.Path -Name CAType + if (-not $result.CaType.ValueExists -or $result.CaType.Type -ne 'DWord' -or $result.CaType.Value -notin @(0,1,3,4)) { throw 'CA type is missing or unsupported.' } + $result.CertificateHashes=Get-WelaRegistryState -Path $result.Path -Name CACertHash + if (-not $result.CertificateHashes.ValueExists -or $result.CertificateHashes.Type -ne 'MultiString') { throw 'CA certificate identity is unavailable.' } + $hashes=@(ConvertTo-WelaAdcsThumbprints $result.CertificateHashes.Value) + $result.Certificates=@(Get-WelaAdcsCertificates $hashes) + $result.Filter=Get-WelaRegistryState -Path $result.Path -Name AuditFilter + if (-not $result.Filter.KeyExists -or ($result.Filter.ValueExists -and ($result.Filter.Type -ne 'DWord' -or ($result.Filter.Value -isnot [int] -and $result.Filter.Value -isnot [long]) -or $result.Filter.Value -lt 0 -or $result.Filter.Value -gt 127))) { throw 'Unknown CA AuditFilter type/bits are preserved for manual review.' } + $services=@(Get-CimInstance Win32_Service -Filter "Name='CertSvc'" -ErrorAction Stop) + if ($services.Count -ne 1 -or $services[0].StartMode -notin @('Auto','Manual','Disabled') -or $services[0].State -notin @('Running','Stopped')) { throw 'Certificate Services state is absent or transitional.' } + $service=$services[0];$start=$null + if ($service.State -eq 'Running') { + if (-not $service.ProcessId) { throw 'Running CA service has no process identity.' } + $start=(Get-Process -Id $service.ProcessId -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o') + } + $dependents=@((Get-Service -Name CertSvc -ErrorAction Stop).DependentServices | ForEach-Object { [pscustomobject]@{Name=[string]$_.Name;Status=[string]$_.Status} } | Sort-Object Name) + $result.Service=[pscustomobject]@{Name='CertSvc';Status=[string]$service.State;StartMode=[string]$service.StartMode;ProcessId=[long]$service.ProcessId;StartUtc=$start;Dependents=$dependents} + $result.AuditMask=Get-WelaAuditPolicyMask -Guid '0cce9221-69ae-11d9-bed3-505054503030' + if (($result.AuditMask -isnot [int] -and $result.AuditMask -isnot [long]) -or $result.AuditMask -notin @(0,1,2,3)) { throw 'Effective Certification Services audit mask is unknown.' } + $result.Precedence=Get-WelaRegistryState -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name SCENoApplyLegacyAuditPolicy + if ($result.Precedence.ValueExists -and ($result.Precedence.Type -ne 'DWord' -or $result.Precedence.Value -notin @(0,1))) { throw 'Unknown audit precedence type/value is preserved.' } + $result.Status='Supported';$result.Diagnostic='CA identity and current settings observed. Service activation of AuditFilter and event generation remain unverified.' + } catch { $result.Diagnostic=$_.Exception.Message } + return $result +} +function Get-WelaAdcsStateKey { + param($State,[ValidateSet('None','Precedence','AuditMask','Filter','Restart')][string]$Omit='None') + $service=$State.Service + if ($Omit -eq 'Restart' -and $service) { $service=[ordered]@{Name=$service.Name;Status=$service.Status;StartMode=$service.StartMode;Dependents=@($service.Dependents)} } + [ordered]@{Status=$State.Status;Host=$State.Host;Active=$State.Active;Path=$State.Path;CaType=$State.CaType;CertificateHashes=$State.CertificateHashes;Certificates=@($State.Certificates);Filter=$(if($Omit -ne 'Filter'){$State.Filter});Service=$service;AuditMask=$(if($Omit -ne 'AuditMask'){$State.AuditMask});Precedence=$(if($Omit -ne 'Precedence'){$State.Precedence})} | ConvertTo-Json -Depth 12 -Compress +} +function Test-WelaAdcsPrecedence { + param($State) + return $State.Precedence.ValueExists -and $State.Precedence.Type -eq 'DWord' -and $State.Precedence.Value -eq 1 +} +function Assert-WelaAdcsPrerequisites { + param($State) + if ($State.Status -ne 'Supported') { throw "CA unavailable: $($State.Diagnostic)" } + if (-not (Test-WelaAdcsPrecedence $State) -or $State.AuditMask -ne 3) { throw 'Effective Certification Services Success+Failure and typed audit precedence are required before CA changes.' } + if ($State.Service.Status -ne 'Running' -or $State.Service.StartMode -eq 'Disabled') { throw 'CertSvc must already be running; WELA never starts a stopped or disabled CA.' } +} +function Test-WelaAdcsControl { + param($Snapshot,[string]$Control) + switch ($Control) { + 'Precedence' { return (Test-WelaAdcsPrecedence $Snapshot) } + 'AuditMask' { return $Snapshot.AuditMask -eq 3 } + 'Filter' { return $Snapshot.Filter.ValueExists -and $Snapshot.Filter.Type -eq 'DWord' -and $Snapshot.Filter.Value -eq 127 } + } +} +function Restart-WelaAdcsService { + # No Force: dependent services must not be stopped implicitly. + Restart-Service -Name CertSvc -ErrorAction Stop + (Get-Service -Name CertSvc -ErrorAction Stop).WaitForStatus([ServiceProcess.ServiceControllerStatus]::Running,[TimeSpan]::FromSeconds(30)) +} +function Set-WelaAdcsControls { + param($Context,$Source,$Snapshot,[switch]$ConfigurePrerequisites,[switch]$AllowRestart) + $shared=@{Expected=$Snapshot;Source=$Source;Activation='Unverified';Blocked=$false;AllowRestart=[bool]$AllowRestart} + $definitions=@() + if ($ConfigurePrerequisites) { $definitions+=@('Precedence','AuditMask') } + $definitions+='Filter' + $filterChange=-not (Test-WelaAdcsControl $Snapshot Filter) + $failure=$null + try { + Assert-WelaAdcsSource $Source + if ($Snapshot.Status -ne 'Supported') { throw "CA unavailable: $($Snapshot.Diagnostic)" } + if ($Snapshot.Service.Status -ne 'Running' -or $Snapshot.Service.StartMode -eq 'Disabled') { throw 'CertSvc must already be running; no stopped CA is started.' } + if (-not $ConfigurePrerequisites) { + if (-not $Context.DryRun) { Assert-WelaAdcsPrerequisites $Snapshot } + if (@($Context.Results | Where-Object { ($_.Id -eq 'AuditPolicy/Certification Services' -or $_.Id -like '*SCENoApplyLegacyAuditPolicy') -and $_.Status -notin @('Applied','AlreadyCompliant') -and -not ($Context.DryRun -and $_.Status -eq 'Skipped' -and $_.Diagnostic -like 'Dry run:*') }).Count) { throw 'Earlier audit prerequisite control was not verified; CA changes are blocked.' } + } + if ($filterChange -and -not $AllowRestart -and -not $Context.DryRun) { throw 'An AuditFilter change requires explicit -AllowRestart on the dedicated command; no settings were changed.' } + if ($filterChange -and @($Snapshot.Service.Dependents | Where-Object Status -ne 'Stopped').Count) { throw 'Running dependent services prevent an isolated CertSvc restart; no settings were changed.' } + } catch { $failure=$_.Exception.Message } + if ($failure) { + $Context.Results.Add([pscustomobject]@{Id='ADCS/Prerequisites';Kind='AdcsAudit';Target=$Snapshot.Path;Desired=$Source;Before=$Snapshot;After=$null;Status='Failed';Diagnostic=$failure}) + return [pscustomobject]@{Activation='Unverified';State=$Snapshot} + } + foreach ($control in $definitions) { + if ($shared.Blocked) { $Context.Results.Add([pscustomobject]@{Id="ADCS/$control";Kind='AdcsAudit';Target=$Snapshot.Path;Desired=$control;Before=$null;After=$null;Status='Skipped';Diagnostic='A prior CA/audit control failed or was declined.'});continue } + $state=@{Shared=$shared;Control=$control} + $read={ param($state) + Assert-WelaAdcsSource $state.Shared.Source + $current=Get-WelaAdcsSnapshot + if ($current.Status -ne 'Supported' -or (Get-WelaAdcsStateKey $current) -cne (Get-WelaAdcsStateKey $state.Shared.Expected)) { throw "CA identity, certificate, filter, service or audit state changed: $($current.Diagnostic)" } + return $current + } + $test={ param($current,$state) Test-WelaAdcsControl $current $state.Control } + $apply={ param($state) + Assert-WelaAdcsSource $state.Shared.Source + $fresh=Get-WelaAdcsSnapshot + if ($fresh.Status -ne 'Supported' -or (Get-WelaAdcsStateKey $fresh) -cne (Get-WelaAdcsStateKey $state.Shared.Expected)) { throw 'CA state changed after journaling; write refused.' } + if ($state.Control -eq 'AuditMask' -and -not (Test-WelaAdcsPrecedence $fresh)) { throw 'Audit precedence was not verified.' } + if ($state.Control -eq 'Filter') { Assert-WelaAdcsPrerequisites $fresh } + switch ($state.Control) { + 'Precedence' { Set-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name SCENoApplyLegacyAuditPolicy -Value 1 -Type DWord -ErrorAction Stop } + 'AuditMask' { Set-WelaEffectiveAuditPolicy -Guid $state.Shared.Source.AuditGuid -Mask 3 -Mode minimum } + 'Filter' { Set-ItemProperty -LiteralPath $fresh.Path -Name AuditFilter -Value 127 -Type DWord -ErrorAction Stop } + } + $after=Get-WelaAdcsSnapshot + if ($after.Status -ne 'Supported' -or (Get-WelaAdcsStateKey $fresh $state.Control) -cne (Get-WelaAdcsStateKey $after $state.Control) -or -not (Test-WelaAdcsControl $after $state.Control)) { throw 'CA control readback or preservation check failed; inspect the journal.' } + $state.Shared.Expected=$after + if ($state.Control -eq 'Filter') { + $state.Shared.Activation='RestartPending' + Assert-WelaAdcsSource $state.Shared.Source + $ready=Get-WelaAdcsSnapshot;Assert-WelaAdcsPrerequisites $ready + if ((Get-WelaAdcsStateKey $ready) -cne (Get-WelaAdcsStateKey $after)) { throw 'CA state changed before restart; service was not restarted.' } + if (-not $state.Shared.AllowRestart) { throw 'Restart was not authorized.' } + Restart-WelaAdcsService + $restarted=Get-WelaAdcsSnapshot;Assert-WelaAdcsPrerequisites $restarted + if ((Get-WelaAdcsStateKey $ready Restart) -cne (Get-WelaAdcsStateKey $restarted Restart) -or $ready.Service.StartUtc -ceq $restarted.Service.StartUtc -or [DateTime]$restarted.Service.StartUtc -le [DateTime]$ready.Service.StartUtc) { throw 'CA restart/readback or preservation could not be verified.' } + $state.Shared.Expected=$restarted;$state.Shared.Activation='RestartObservedAfterWrite; event generation unverified' + } + } + $target=if ($control -eq 'Filter') { [ordered]@{Path=$Snapshot.Path;Name='AuditFilter';Service='CertSvc';ActiveCa=$Snapshot.Active.Value;Certificates=$Snapshot.Certificates} } elseif ($control -eq 'AuditMask') { @{Guid=$Source.AuditGuid} } else { @{Path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';Name='SCENoApplyLegacyAuditPolicy'} } + $desired=if($control -eq 'Filter'){@{Value=127;Type='DWord';RestartIfChanged=[bool]$AllowRestart}} elseif($control -eq 'AuditMask'){@{Mask=3;Mode='minimum'}} else {@{Value=1;Type='DWord'}} + Invoke-WelaConfigurationControl -Context $Context -Id "ADCS/$control" -Kind AdcsAudit -Target $target -Desired $desired -Read $read -Compliant $test -Apply $apply -CallbackState $state -Description $(if($control -eq 'Filter'){'Set the reviewed CA AuditFilter and restart this running Certificate Services instance.'}else{'Apply the source-required audit prerequisite.'}) + $row=$Context.Results[$Context.Results.Count-1] + $row | Add-Member NoteProperty Source $Source + $row | Add-Member NoteProperty VerificationScope 'Current settings and preservation only; registry 127 and Running do not establish service activation or events.' + if ($row.Status -eq 'Failed' -or ($row.Status -eq 'Skipped' -and -not $Context.DryRun)) { $shared.Blocked=$true } + } + return [pscustomobject]@{Activation=$shared.Activation;State=$shared.Expected} +} +function Invoke-WelaLegacyAdcsControl { + param($Context) + try { + $snapshot=Get-WelaAdcsSnapshot + if ($snapshot.Status -eq 'NotApplicable') { $Context.Results.Add([pscustomobject]@{Id='ADCS/AuditFilter';Kind='AdcsAudit';Target=$null;Desired=127;Before=$snapshot;After=$snapshot;Status='Skipped';Diagnostic=$snapshot.Diagnostic});return } + $source=Get-WelaAdcsSource + $outcome=Set-WelaAdcsControls -Context $Context -Source $source -Snapshot $snapshot -AllowRestart + foreach ($row in @($Context.Results | Where-Object { $_.Kind -eq 'AdcsAudit' })) { $row | Add-Member NoteProperty Activation $outcome.Activation -Force } + } catch { $Context.Results.Add([pscustomobject]@{Id='ADCS/AuditFilter';Kind='AdcsAudit';Target=$null;Desired=127;Before=$snapshot;After=$null;Status='Failed';Diagnostic=$_.Exception.Message}) } +} +function Get-WelaAdcsReportPath { + param([string]$Path) + $provider=$null;$drive=$null + $full=$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path,[ref]$provider,[ref]$drive) + if ($provider.Name -ne 'FileSystem' -or $full -match '^[\\/]{2}' -or (Test-Path -LiteralPath $full -ErrorAction Stop)) { throw 'AD CS reports require a new local filesystem file.' } + if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT -and ([IO.DriveInfo]::new([IO.Path]::GetPathRoot($full))).DriveType -ne [IO.DriveType]::Fixed) { throw 'AD CS evidence requires a local fixed drive.' } + $parent=[IO.DirectoryInfo]([IO.Path]::GetDirectoryName($full)) + if (-not $parent.Exists) { throw 'AD CS report parent must exist.' } + while($parent){if($parent.Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'AD CS output cannot traverse a reparse-point directory.'};$parent=$parent.Parent} + return $full +} +function Protect-WelaAdcsDirectory { + param([string]$Path) + if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { return } + $acl=New-Object Security.AccessControl.DirectorySecurity + $acl.SetAccessRuleProtection($true,$false) + foreach($sid in @([Security.Principal.WindowsIdentity]::GetCurrent().User.Value,'S-1-5-18','S-1-5-32-544')|Select-Object -Unique){$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid),'FullControl','ContainerInherit,ObjectInherit','None','Allow'))} + Set-Acl -LiteralPath $Path -AclObject $acl -ErrorAction Stop +} +function Invoke-WelaAdcsCommand { + param([ValidateSet('Audit','Plan','Configure')][string]$Action='Audit',[string]$Profile,[switch]$AllowRestart,[switch]$Auto,[switch]$DryRun,[string]$BackupPath,[string]$ResultsPath) + if ($Action -ne 'Audit' -and -not $Profile) { throw 'AD CS Plan/Configure requires explicit -AdcsProfile microsoft-identity-ca-2026-09.' } + if ($Action -ne 'Configure' -and ($AllowRestart -or $Auto -or $DryRun -or $BackupPath)) { throw 'Restart, consent, dry-run and backup options require AD CS Configure.' } + if (-not $Profile) {$Profile='microsoft-identity-ca-2026-09'} + $source=Get-WelaAdcsSource $Profile + if($ResultsPath){$ResultsPath=Get-WelaAdcsReportPath $ResultsPath} + $before=Get-WelaAdcsSnapshot + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaAdcsAuditing';Action=$Action;Source=$source;Before=$before;After=$before;ExitCode=$(if($before.Status -eq 'Unknown'){1}else{0});Results=@();Activation='Unverified';EventGeneration='Unverified';UsableRuleCredit=0;Scope='Native local CA auditing only. Sysmon, enrollment/template permissions, AD objects, leaf issuance and forwarding excluded.'} + $report | Add-Member NoteProperty Plan @( + foreach($control in @('Precedence','AuditMask','Filter')) { + $current=switch($control){'Precedence'{$before.Precedence}'AuditMask'{$before.AuditMask}'Filter'{$before.Filter}} + [pscustomobject]@{Control=$control;Current=$current;Desired=$(if($control -eq 'Filter'){'DWORD127, restart after change'}elseif($control -eq 'AuditMask'){'Success+Failure, minimum3'}else{'DWORD1'});State=$(if($before.Status -ne 'Supported'){$before.Status}elseif(Test-WelaAdcsControl $before $control){'PolicyMatches'}else{'ChangeRequired'})} + } + ) + if($Action -eq 'Configure' -and $before.Status -ne 'NotApplicable'){ + if([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT){ + $identity=[Security.Principal.WindowsIdentity]::GetCurrent() + try{if(-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'Elevated administrator rights are required for CA configuration.'}}finally{$identity.Dispose()} + } + + if(-not $DryRun){ + if(-not $BackupPath){$BackupPath=Join-Path $script:ScriptRoot ('wela-adcs-backup-'+[guid]::NewGuid().ToString('N'))} + $BackupPath=Get-WelaAdcsReportPath $BackupPath + } + $context=New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath + if(-not $DryRun){Protect-WelaAdcsDirectory $context.BackupPath} + $outcome=Set-WelaAdcsControls -Context $context -Source $source -Snapshot $before -ConfigurePrerequisites -AllowRestart:$AllowRestart + $completed=Complete-WelaConfiguration -Context $context -Scope adcs-audit-settings-only -SuccessMessage 'CA settings verified. Activation and event evidence are reported separately.' + $report.Results=$completed.Results;$report.ExitCode=$completed.ExitCode;$report.Activation=$outcome.Activation + $report | Add-Member NoteProperty Configuration $completed + $report.After=Get-WelaAdcsSnapshot + if($report.After.Status -ne 'Supported' -or (Get-WelaAdcsStateKey $report.After) -cne (Get-WelaAdcsStateKey $outcome.State)){$report.ExitCode=1;$report.Activation='Unverified: final CA state changed'} + } + $report | Add-Member NoteProperty PolicyState $(if($report.After.Status -ne 'Supported'){$report.After.Status}elseif((Test-WelaAdcsControl $report.After Filter) -and (Test-WelaAdcsPrecedence $report.After) -and $report.After.AuditMask -eq 3){'PolicyMatches'}else{'ChangeRequired'}) + if($ResultsPath){$full=Get-WelaAdcsReportPath $ResultsPath;$bytes=([Text.UTF8Encoding]::new($false)).GetBytes(($report|ConvertTo-Json -Depth 22));$stream=[IO.File]::Open($full,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None);try{$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)}finally{$stream.Dispose()}} + return $report +} + +# Validate only components of a pending-request event. This is not Sigma/backend +# evidence and never submits, approves, retrieves or installs a certificate. +function Test-WelaAdcsRequestEvent { + param([string]$Xml,$Expected,[ValidateSet(4886,4889)][int]$EventId) + try { + $settings=New-Object Xml.XmlReaderSettings;$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null + $reader=[Xml.XmlReader]::Create((New-Object IO.StringReader($Xml)),$settings) + try{$doc=New-Object Xml.XmlDocument;$doc.XmlResolver=$null;$doc.Load($reader)}finally{$reader.Dispose()} + $ns=New-Object Xml.XmlNamespaceManager($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event') + $system=$doc.SelectSingleNode('/e:Event/e:System',$ns) + if(-not $system){return $false} + $provider=$system.SelectSingleNode('e:Provider',$ns) + if($provider.GetAttribute('Name') -cne 'Microsoft-Windows-Security-Auditing' -or [guid]$provider.GetAttribute('Guid') -ne [guid]'54849625-5478-4994-a5ba-3e3b0328c30d' -or + $system.SelectSingleNode('e:EventID',$ns).InnerText -cne [string]$EventId -or $system.SelectSingleNode('e:Version',$ns).InnerText -cnotin @('0','1') -or $system.SelectSingleNode('e:Channel',$ns).InnerText -cne 'Security' -or + $system.SelectSingleNode('e:Computer',$ns).InnerText -ine $Expected.Computer -or $system.SelectSingleNode('e:Keywords',$ns).InnerText -ine '0x8020000000000000'){return $false} + $utc=[DateTimeOffset]::Parse($system.SelectSingleNode('e:TimeCreated',$ns).GetAttribute('SystemTime'),[Globalization.CultureInfo]::InvariantCulture).UtcDateTime + if($utc -lt ([DateTime]$Expected.StartUtc).ToUniversalTime() -or $utc -gt ([DateTime]$Expected.EndUtc).ToUniversalTime()){return $false} + $data=New-Object 'System.Collections.Generic.Dictionary[string,string]' ([StringComparer]::Ordinal) + foreach($node in @($doc.SelectNodes('/e:Event/e:EventData/e:Data',$ns))){$name=$node.GetAttribute('Name');if(-not $name -or $data.ContainsKey($name)){return $false};$data.Add($name,$node.InnerText)} + if(-not $data.ContainsKey('RequestId') -or $data['RequestId'] -cne [string]$Expected.RequestId -or -not $data.ContainsKey('Requester') -or $data['Requester'] -ine $Expected.Requester -or -not $data.ContainsKey('Attributes')){return $false} + if($EventId -eq 4889 -and (-not $data.ContainsKey('Disposition') -or $data['Disposition'] -cne '5')){return $false} + # Retain and match the random request attribute without localized message parsing. + return @($data['Attributes'] -split '\r?\n' | Where-Object { $_.Trim() -ceq ('WELAProbe:'+$Expected.Nonce) }).Count -eq 1 + }catch{return $false} +} diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index f72c1fd0..1e7ead1f 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -108,7 +108,7 @@ function Invoke-WelaConfigurationControl { function Complete-WelaConfiguration { param($Context, [string]$ResultsPath, $Plan, - [ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only")] + [ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only", "adcs-audit-settings-only")] [string]$Scope = "native-windows-configuration", [string]$SuccessMessage = 'Configuration completed; all requested controls verified.') if ($Context.PSObject.Properties['CustomProfileGuard']) { @@ -450,38 +450,9 @@ function Set-WelaAuditPrecedenceControl { function Set-WelaCertificateAuditControl { param($Context) - $root = 'HKLM:\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration' - try { - if (-not (Test-Path -LiteralPath $root -ErrorAction Stop)) { - $Context.Results.Add([pscustomobject]@{ Id = 'ADCS/AuditFilter'; Kind = 'CertificateService'; Target = $root; Desired = 127; Before = $null; After = $null; Status = 'Skipped'; Diagnostic = 'No configured local CA.' }) - return - } - $caName = (Get-ItemProperty -LiteralPath $root -Name Active -ErrorAction Stop).Active - if (-not $caName) { throw 'CA configuration has no active CA name.' } - $path = Join-Path $root $caName - $state = @{ Path = $path } - $read = { - param($state) - [pscustomobject]@{ - Registry = Get-WelaRegistryState -Path $state.Path -Name AuditFilter - ServiceStatus = (Get-Service -Name CertSvc -ErrorAction Stop).Status.ToString() - } - } - $test = { param($value) $value.Registry.ValueExists -and $value.Registry.Value -eq 127 -and $value.Registry.Type -eq 'DWord' -and $value.ServiceStatus -eq 'Running' } - $apply = { - $state = Get-Service -Name CertSvc -ErrorAction Stop - if ($state.Status -ne 'Running') { throw 'CertSvc is not running; refusing to start a previously stopped CA. Start it deliberately before retrying.' } - Invoke-WelaNative -FilePath 'certutil.exe' -Arguments @('-setreg', 'CA\AuditFilter', '127') - Restart-Service -Name CertSvc -Force -ErrorAction Stop - $service = Get-Service -Name CertSvc -ErrorAction Stop - $service.WaitForStatus([System.ServiceProcess.ServiceControllerStatus]::Running, [TimeSpan]::FromSeconds(30)) - } - Invoke-WelaConfigurationControl -Context $Context -Id 'ADCS/AuditFilter' -Kind CertificateService ` - -Target @{ Path = $path; Name = 'AuditFilter'; Service = 'CertSvc' } -Desired 127 ` - -Read $read -Compliant $test -Apply $apply -Description 'Set AuditFilter=127 and restart Certificate Services.' -CallbackState $state - } catch { - $Context.Results.Add([pscustomobject]@{ Id = 'ADCS/AuditFilter'; Kind = 'CertificateService'; Target = $root; Desired = 127; Before = $null; After = $null; Status = 'Failed'; Diagnostic = $_.ToString() }) - } + # Existing configure prompt/Auto covers its historical set-and-restart action. + # The shared CA engine verifies identity and prerequisites before every write. + Invoke-WelaLegacyAdcsControl -Context $Context } function Set-WelaNtlmConfigurationControl { diff --git a/tests/AdcsAuditing.Tests.ps1 b/tests/AdcsAuditing.Tests.ps1 new file mode 100644 index 00000000..aaf5e53b --- /dev/null +++ b/tests/AdcsAuditing.Tests.ps1 @@ -0,0 +1,137 @@ +$ErrorActionPreference='Stop' +$root=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$root +. (Join-Path $root 'scripts/Configuration.ps1') +. (Join-Path $root 'scripts/AdcsAuditing.ps1') +Import-Module (Join-Path $root 'modules/AuditProfiles.psm1') -Force +$script:count=0 +function Assert($Condition,$Message){if(-not $Condition){throw $Message};$script:count++} +function Throws($Action,$Pattern){$message='';try{&$Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern; got $message"} +function Copy-State($Value){$Value|ConvertTo-Json -Depth 20|ConvertFrom-Json} +function Reg($Value,$Type='DWord'){[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=$Value;Type=$Type}} +$source=Get-WelaAdcsSource +Assert ($source.AuditGuid -ieq '0cce9221-69ae-11d9-bed3-505054503030' -and $source.AuditMask -eq 3 -and $source.AuditMode -eq 'minimum' -and $source.AuditFilter -eq 127) 'CA requirements bind the canonical shared identity profile and official filter.' +Throws {Get-WelaAdcsSource unknown} 'Unknown AD CS' +$nativeHash='0c e5 0d fc 5a f0 70 1d ee 73 90 dd a7 6b 14 bf 97 9a bc 27' +Assert ((ConvertTo-WelaAdcsThumbprints @($nativeHash)) -ceq '0CE50DFC5AF0701DEE7390DDA76B14BF979ABC27') 'Actual native twenty-octet CACertHash normalizes for certificate lookup.' +Assert ((ConvertTo-WelaAdcsThumbprints @($nativeHash.Replace(' ',''))) -ceq '0CE50DFC5AF0701DEE7390DDA76B14BF979ABC27') 'Certificate-store contiguous form retains the same identity.' +Throws {ConvertTo-WelaAdcsThumbprints @($nativeHash,$nativeHash.Replace(' ','').ToUpperInvariant())} 'duplicated' +foreach($invalid in @($nativeHash.Replace(' ','-'),($nativeHash+' 00'),$nativeHash.Substring(3),$nativeHash.Replace(' ',' '),(' '+$nativeHash),42)){ + Throws {ConvertTo-WelaAdcsThumbprints @($invalid)} 'malformed' +} +Throws {ConvertTo-WelaAdcsThumbprints @()} 'empty' +$base=[pscustomobject]@{Status='Supported';Diagnostic='fixture';CapturedUtc='2026-09-20T00:00:00Z';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 0);Service=[pscustomobject]@{Name='CertSvc';Status='Running';StartMode='Auto';ProcessId=100;StartUtc='2026-09-19T00:00:00Z';Dependents=@()};AuditMask=0;Precedence=(Reg 0)} +$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-ca-fixtures-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory -Path $temp +$script:ordinal=0 +function Reset { + $script:state=Copy-State $base;$script:writes=@();$script:restarts=0;$script:reads=0;$script:promptAction=$null;$script:readAction=$null;$script:writeAction=$null;$script:restartFail=$false;$script:auditFail=$false;$script:filterFail=$false;$script:decline=$false +} +function Get-WelaAdcsSnapshot {$script:reads++;if($script:readAction){&$script:readAction};Copy-State $script:state} +function Set-ItemProperty {param($LiteralPath,$Name,$Value,$Type,$ErrorAction) + if($Name -eq 'AuditFilter' -and $script:filterFail){throw 'Injected CA filter write failure'} + $script:writes+=@([pscustomobject]@{Path=$LiteralPath;Name=$Name;Value=$Value;Type=$Type}) + if($Name -eq 'AuditFilter') {if($LiteralPath -cne $script:state.Path){throw 'Wrong CA target'};$script:state.Filter=Reg $Value $Type} + elseif($Name -eq 'SCENoApplyLegacyAuditPolicy'){$script:state.Precedence=Reg $Value $Type} + else{throw 'Unexpected registry write'} + if($script:writeAction){&$script:writeAction $Name} +} +function Set-WelaEffectiveAuditPolicy {param($Guid,$Mask,$Mode) + if($script:auditFail){throw 'Audit policy rejected'} + if($Guid -ine $source.AuditGuid -or $Mask -ne 3 -or $Mode -ne 'minimum'){throw 'Unexpected audit mutation'} + $script:writes+=@([pscustomobject]@{Name='AuditMask';Value=$Mask});$script:state.AuditMask=$script:state.AuditMask -bor $Mask +} +function Restart-WelaAdcsService {$script:restarts++;if($script:restartFail){throw 'Injected restart failure'};$script:state.Service.ProcessId=101;$script:state.Service.StartUtc='2026-09-20T01:00:00Z'} +function Read-Host {param($Prompt)if($script:promptAction){&$script:promptAction};if($script:decline){'n'}else{'y'}} +function Context([switch]$DryRun,[switch]$Prompt){$script:ordinal++;New-WelaConfigurationContext -Auto:(-not $Prompt) -DryRun:$DryRun -BackupPath (Join-Path $temp "backup-$script:ordinal")} +function Run($Context,[switch]$Legacy,[switch]$NoRestart){ + if($Legacy){Invoke-WelaLegacyAdcsControl $Context;$null}else{Set-WelaAdcsControls -Context $Context -Source $source -Snapshot (Copy-State $script:state) -ConfigurePrerequisites -AllowRestart:(-not $NoRestart)} +} +try { + Reset;$ctx=Context -DryRun;$outcome=Run $ctx + Assert ($script:writes.Count -eq 0 -and $script:restarts -eq 0 -and -not (Test-Path $ctx.BackupPath)) 'Dry run creates no recovery directory or Windows changes.' + Assert ($ctx.Results.Count -eq 3 -and @($ctx.Results|Where-Object Status -eq 'Skipped').Count -eq 3) 'Dry-run lists both prerequisites and filter without pretending applied.' + Reset;$ctx=Context;$outcome=Run $ctx -NoRestart + Assert ($script:writes.Count -eq 0 -and $ctx.Results[0].Diagnostic -match 'AllowRestart') 'Dedicated filter change without restart consent refuses every setting write.' + Reset;$ctx=Context;$outcome=Run $ctx;$done=Complete-WelaConfiguration $ctx + Assert ($done.ExitCode -eq 0 -and $script:writes.Count -eq 3 -and $script:restarts -eq 1 -and $script:state.AuditMask -eq 3) 'Source prerequisites precede explicit CA filter write and one restart.' + Assert (($script:writes.Name -join ',') -eq 'SCENoApplyLegacyAuditPolicy,AuditMask,AuditFilter' -and $script:writes[2].Path -ceq $base.Path) 'Writes use canonical order and pinned CA key rather than implicit active certutil selection.' + Assert ($outcome.Activation -match '^RestartObservedAfterWrite' -and $outcome.Activation -match 'unverified') 'Restart evidence never establishes event generation.' + $journal=@(Get-Content -LiteralPath (Join-Path $ctx.BackupPath 'before.jsonl')|ForEach-Object{$_|ConvertFrom-Json}) + Assert ($journal.Count -eq 3 -and $journal[2].Before.Active.Value -ceq 'CA-A' -and $journal[2].Before.Certificates[0].Sha256 -ceq ('b'*64) -and $journal[2].Before.Filter.Value -eq 0 -and $journal[2].Before.Precedence.Value -eq 1 -and $journal[2].Before.AuditMask -eq 3) 'Journal binds exact CA identity/certificate/typed filter and verified prerequisites before mutation.' + $before=Copy-State $script:state;$priorWriteCount=$script:writes.Count;$ctx=Context;$outcome=Run $ctx;$done=Complete-WelaConfiguration $ctx + Assert ($done.ExitCode -eq 0 -and $script:writes.Count -eq $priorWriteCount -and $script:restarts -eq 1 -and $outcome.Activation -eq 'Unverified') 'Idempotent 127+Running observes policy matches without another restart or invented activation.' + foreach($mutation in @({$script:state.Active.Value='CA-B';$script:state.Path=$script:state.Path.Replace('CA-A','CA-B')},{$script:state.CaType.Value=4},{$script:state.Certificates[0].Sha256='c'*64},{$script:state.Filter.Value=64},{$script:state.Service.StartMode='Manual'},{$script:state.Service.ProcessId=222},{$script:state.AuditMask=2},{$script:state.Precedence.Value=1})){ + Reset;$script:promptAction=$mutation;$ctx=Context -Prompt;$outcome=Run $ctx + Assert ($script:writes.Count -eq 0 -and $script:restarts -eq 0 -and @($ctx.Results|Where-Object Status -eq 'Failed').Count -eq 1) 'Prompt-time CA identity/type/certificate/filter/service/prerequisite drift blocks writes.' + } + # Exact legacy regression: CA-A was read and journaled, Active then changes to + # CA-B during confirmation. No registry write may affect either identity. + Reset;$script:state.AuditMask=3;$script:state.Precedence=Reg 1;$script:promptAction={$script:state.Active.Value='CA-B';$script:state.Path=$script:state.Path.Replace('CA-A','CA-B')};$ctx=Context -Prompt;Run $ctx -Legacy + Assert ($ctx.Results[0].Status -eq 'Failed' -and $script:writes.Count -eq 0 -and $script:restarts -eq 0) 'Legacy CA-A to CA-B race fails before any filter write/restart.' + foreach($scenario in @('stopped','disabled','unknown','dependencies')){ + Reset + switch($scenario){'stopped'{$script:state.Service.Status='Stopped'}'disabled'{$script:state.Service.StartMode='Disabled'}'unknown'{$script:state.Status='Unknown';$script:state.Diagnostic='Unknown type'}'dependencies'{$script:state.Service.Dependents=@([pscustomobject]@{Name='OtherService';Status='Running'})}} + $ctx=Context;$outcome=Run $ctx + Assert ($script:writes.Count -eq 0 -and $script:restarts -eq 0 -and $ctx.Results[0].Status -eq 'Failed') 'Unavailable/unsafe CA states never start a service or change prerequisites.' + } + Reset;$script:auditFail=$true;$ctx=Context;$outcome=Run $ctx + Assert ($script:writes.Count -eq 1 -and $script:restarts -eq 0 -and $script:state.Filter.Value -eq 0) 'Failed Certification Services prerequisite blocks CA filter and restart.' + Reset;$script:state.AuditMask=3;$script:state.Precedence=Reg 1;$script:filterFail=$true;$ctx=Context;Run $ctx -Legacy;$done=Complete-WelaConfiguration $ctx + Assert ($done.ExitCode -eq 1 -and $script:state.Filter.Value -eq 0 -and $script:restarts -eq 0) 'Actual legacy engine filter write failure cannot restart the CA or report success.' + Reset;$ctx=Context -DryRun;Run $ctx -Legacy + Assert ($script:writes.Count -eq 0 -and $script:restarts -eq 0 -and $ctx.Results[0].Status -eq 'Skipped') 'Actual legacy engine dry run never writes or restarts even with planned prerequisites.' + Reset;$script:decline=$true;$ctx=Context -Prompt;$outcome=Run $ctx + Assert ($script:writes.Count -eq 0 -and $script:restarts -eq 0 -and $ctx.Results.Count -eq 3) 'Declined prerequisite blocks dependent changes.' + Reset;$script:state.AuditMask=3;$script:state.Precedence=Reg 1;$script:writeAction={param($Name)if($Name -eq 'AuditFilter'){$script:state.AuditMask=0}};$ctx=Context;$outcome=Run $ctx + Assert ($script:state.Filter.Value -eq 127 -and $script:restarts -eq 0 -and $ctx.Results[-1].Status -eq 'Failed') 'Prerequisite drift after filter write prevents restart and retains failure evidence.' + Reset;$script:state.AuditMask=3;$script:state.Precedence=Reg 1;$script:readAction={if($script:reads -eq 6){$script:state.Active.Value='CA-B';$script:state.Path=$script:state.Path.Replace('CA-A','CA-B')}};$ctx=Context;$outcome=Run $ctx + Assert ($script:restarts -eq 0 -and $ctx.Results[-1].Status -eq 'Failed') 'CA identity drift in immediate pre-restart read refuses restart.' + Reset;$script:restartFail=$true;$ctx=Context;$outcome=Run $ctx;$done=Complete-WelaConfiguration $ctx + Assert ($done.ExitCode -eq 1 -and $script:state.Filter.Value -eq 127 -and $outcome.Activation -eq 'RestartPending') 'Restart failure retains configured filter but never claims activation.' + Reset;$ctx=Context;$outcome=Run $ctx;$script:state.Filter.Value=0;$done=Complete-WelaConfiguration $ctx + Assert ($done.ExitCode -eq 1 -and @($done.Results|Where-Object Status -eq 'Failed').Count -eq 3) 'Final drift invalidates previously observed controls.' + Reset;$script:state.AuditMask=3;$script:state.Precedence=Reg 1;$ctx=Context + $ctx.Results.Add([pscustomobject]@{Id='AuditPolicy/Certification Services';Status='Failed';Kind='AuditPolicy'}) + Run $ctx -Legacy + Assert ($script:writes.Count -eq 0 -and $script:restarts -eq 0 -and $ctx.Results[-1].Status -eq 'Failed') 'Earlier legacy prerequisite failure blocks CA writes even if a later sample matches.' + Reset;$script:state.Status='NotApplicable';$ctx=Context;Run $ctx -Legacy + Assert ($ctx.Results[0].Status -eq 'Skipped' -and $script:writes.Count -eq 0) 'Legacy non-CA remains a no-op.' + Reset;$script:state.AuditMask=3;$script:state.Precedence=Reg 1;$script:state.Filter=Reg 127 + $json=Join-Path $temp 'report.json';$report=Invoke-WelaAdcsCommand -ResultsPath $json + Assert ($report.PolicyState -eq 'PolicyMatches' -and $report.Activation -eq 'Unverified' -and $report.UsableRuleCredit -eq 0 -and $script:writes.Count -eq 0) 'Read-only public report preserves settings/activation/evidence distinctions.' + Throws {Invoke-WelaAdcsCommand -ResultsPath $json} 'new local' + Throws {Invoke-WelaAdcsCommand -Action Plan} 'explicit' + Throws {Invoke-WelaAdcsCommand -AllowRestart} 'require AD CS Configure' + $hash=(Get-FileHash -LiteralPath $json).Hash;$alias=Join-Path $temp 'alias.json';$null=New-Item -ItemType HardLink -Path $alias -Value $json + Throws {Invoke-WelaAdcsCommand -ResultsPath $alias} 'new local' + Assert ((Get-FileHash -LiteralPath $json).Hash -ceq $hash) 'Output aliases cannot overwrite previous evidence.' + $badSource=Copy-State $source;$badSource.SchemaSha256='0'*64;Reset;$ctx=Context + $outcome=Set-WelaAdcsControls $ctx $badSource (Copy-State $script:state) -ConfigurePrerequisites -AllowRestart + Assert ($ctx.Results[0].Status -eq 'Failed' -and $script:writes.Count -eq 0) 'Changed source fingerprint is refused before writes.' + # Delegate through the actual legacy wrapper; no alternate implementation. + $script:delegated=$false + function Invoke-WelaLegacyAdcsControl {param($Context)$script:delegated=$true} + Set-WelaCertificateAuditControl (Context -DryRun) + Assert $script:delegated 'Legacy helper invokes the same dedicated CA engine.' + $exe=(Get-Process -Id $PID).Path + foreach($arguments in @(@('configure','-AllowRestart'),@('configure','-AdcsProfile','microsoft-identity-ca-2026-09'),@('adcs-auditing','-Profile','wela-2.2.0'),@('adcs-auditing','-Role','ADCS'),@('adcs-auditing','-AdcsAction','Plan'))){ + $ErrorActionPreference='Continue';try{$output=&$exe -NoProfile -File (Join-Path $root 'WELA.ps1') @arguments 2>&1;$code=$LASTEXITCODE}finally{$ErrorActionPreference='Stop'} + Assert ($code -ne 0 -and ($output -join ' ') -match 'No command|explicit -AdcsProfile') 'Actual CLI rejects unrelated controls, role overrides and absent source.' + } + $expected=[pscustomobject]@{Computer='CAHOST';RequestId=42;Requester='CAHOST\runner';Nonce='abc-123';StartUtc='2026-09-20T00:00:00Z';EndUtc='2026-09-20T00:01:00Z'} + $xml='48860SecurityCAHOST0x802000000000000042CAHOST\runnerWELAProbe:abc-123' + Assert (Test-WelaAdcsRequestEvent $xml $expected 4886) 'Exact native request event identity/context/outcome/nonce matches.' + $pendingXml=$xml.Replace('4886','4889').Replace('','5') + Assert (Test-WelaAdcsRequestEvent $pendingXml $expected 4889) 'Pending event is independently correlated to the same numeric request and disposition5.' + Assert (-not (Test-WelaAdcsRequestEvent $pendingXml.Replace('>5<','>3<') $expected 4889)) 'A non-pending event disposition cannot establish the pending-request evidence.' + $nativeExpected=[pscustomobject]@{Computer='runnervmibwwn';RequestId=2;Requester='runnervmibwwn\runneradmin';Nonce='e5d9f00053f64487bc4890e16c043ae0';StartUtc='2026-09-20T09:51:08.1233882Z';EndUtc='2026-09-20T09:51:37.9537209Z'} + foreach($id in @(4886,4889)){ + $nativeXml=[IO.File]::ReadAllText((Join-Path $PSScriptRoot "fixtures/adcs-$id-v1.xml")) + Assert (Test-WelaAdcsRequestEvent $nativeXml $nativeExpected $id) 'Actual Server2022 version1 XML with authentication metadata preserves exact request correlation.' + Assert (-not (Test-WelaAdcsRequestEvent $nativeXml.Replace('1','2') $nativeExpected $id)) 'Unknown native event versions remain unverified.' + } + foreach($badXml in @($xml.Replace('>42<','>43<'),$xml.Replace('CAHOST','OTHER'),$xml.Replace('abc-123','other'),$xml.Replace('CAHOST\runner','CAHOST\other'),$xml.Replace('0','2'),$xml.Replace('0x8020000000000000','0x8010000000000000'),$xml.Replace('2026-09-20T00:00:01Z','2026-09-19T00:00:01Z'),$xml.Replace('','42'),(']>'+$xml))) { + Assert (-not (Test-WelaAdcsRequestEvent $badXml $expected 4886)) 'Mismatched/ambiguous/unsafe request XML earns no native evidence.' + } + Write-Host "PASS: $script:count AD CS assertions; native writes and service restarts mocked." +}finally{Remove-Item -LiteralPath $temp -Recurse -Force} +$global:LASTEXITCODE=0 diff --git a/tests/AdcsAuditing.Windows.Tests.ps1 b/tests/AdcsAuditing.Windows.Tests.ps1 new file mode 100644 index 00000000..19a5020d --- /dev/null +++ b/tests/AdcsAuditing.Windows.Tests.ps1 @@ -0,0 +1,177 @@ +param([switch]$AllowDisposableCA,[ValidateSet('powershell','pwsh')][string]$TestEngine='powershell') +$ErrorActionPreference='Stop' +if(-not $AllowDisposableCA -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Explicit disposable CA opt-in on a GitHub-hosted Windows runner is required.'} +$root=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$root +. (Join-Path $root 'scripts/Configuration.ps1') +. (Join-Path $root 'scripts/AdcsAuditing.ps1') +Import-Module (Join-Path $root 'modules/AuditProfiles.psm1') -Force +$computer=Get-CimInstance Win32_ComputerSystem +$os=Get-CimInstance Win32_OperatingSystem +$caRoot='HKLM:\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration' +if($computer.PartOfDomain -or $computer.DomainRole -ne 2 -or $os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100) -or (Test-Path -LiteralPath $caRoot)){throw 'Disposable test refuses domain/DC, unknown OS or pre-existing CA configuration.'} +$beforeReport=Invoke-WelaAdcsCommand +if($beforeReport.PolicyState -ne 'NotApplicable'){throw 'Native non-CA observation did not classify the absent CA.'} +$engine=(Get-Command $TestEngine -ErrorAction Stop).Source +$nonce=[guid]::NewGuid().ToString('N');$caName='WELA-CI-'+$nonce +$privateRoot=Join-Path $env:TEMP ('wela-adcs-'+$nonce) +$null=New-Item -ItemType Directory -Path $privateRoot;Protect-WelaAdcsDirectory $privateRoot +$beforePolicies=Get-WelaEffectiveAuditPolicy +$auditGuid='0cce9221-69ae-11d9-bed3-505054503030' +$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' +$precedenceBefore=Get-WelaRegistryState -Path $precedencePath -Name SCENoApplyLegacyAuditPolicy +$beforeFeatures=@(Get-WindowsFeature | Where-Object Installed | ForEach-Object Name) +$beforeCerts=@(Get-ChildItem Cert:\LocalMachine\My,Cert:\LocalMachine\Root,Cert:\LocalMachine\CA | ForEach-Object Thumbprint) +$createdKeys=@();$createdCerts=@();$installedFeature=$false;$attemptedCA=$false;$passed=$false +[pscustomobject]@{BeforePolicies=$beforePolicies;Precedence=$precedenceBefore;Features=$beforeFeatures;CertificateThumbprints=$beforeCerts;CaName=$caName}|ConvertTo-Json -Depth 10|Set-Content -LiteralPath (Join-Path $privateRoot 'before.json') -Encoding UTF8 +function Invoke-TestCli { + param([string[]]$Arguments,[int]$ExpectedExit=0) + $ErrorActionPreference='Continue' + try{$text=@(& $engine -NoProfile -File (Join-Path $root 'WELA.ps1') @Arguments 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference='Stop'} + $text|ForEach-Object{Write-Host $_} + if($code -ne $ExpectedExit){throw "Public CA CLI exit $code; expected $ExpectedExit."} + $global:LASTEXITCODE=0 +} +function Find-CreatedCertificates { + foreach($store in @('My','Root','CA')){ + foreach($cert in @(Get-ChildItem ("Cert:\LocalMachine\"+$store)|Where-Object{$_.Subject -ceq ('CN='+$caName) -and $_.Thumbprint -notin $beforeCerts})){ + [pscustomobject]@{Store=$store;Thumbprint=$cert.Thumbprint;Certificate=$cert} + } + } +} +try { + $installedFeature=$true + $feature=Install-WindowsFeature ADCS-Cert-Authority -IncludeManagementTools -ErrorAction Stop + if(-not $feature.Success -or [string]$feature.RestartNeeded -ne 'No'){throw "CA feature is unavailable or requires restart: $($feature | Out-String). No native acceptance claim."} + $database=Join-Path $privateRoot 'database';$logs=Join-Path $privateRoot 'database-logs' + $null=New-Item -ItemType Directory -Path $database,$logs + $attemptedCA=$true + $installation=Install-AdcsCertificationAuthority -CAType StandaloneRootCA -CACommonName $caName -CryptoProviderName 'RSA#Microsoft Software Key Storage Provider' -KeyLength 2048 -HashAlgorithmName SHA256 -ValidityPeriod Days -ValidityPeriodUnits 1 -DatabaseDirectory $database -LogDirectory $logs -Force -ErrorAction Stop + $installation|Out-String|Write-Host + if($installation.ErrorId -and $installation.ErrorId -ne 0){throw 'Disposable standalone CA installation reported failure.'} + $native=Get-WelaAdcsSnapshot + if($native.Status -ne 'Supported' -or $native.Active.Value -cne $caName -or $native.CaType.Value -ne 3 -or $native.Host.DomainJoined){throw ($native|ConvertTo-Json -Depth 15)} + $createdCerts=@(Find-CreatedCertificates) + foreach($entry in $createdCerts){ + if($entry.Certificate.HasPrivateKey){ + $rsa=[Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($entry.Certificate) + try{if($rsa -isnot [Security.Cryptography.RSACng]){throw 'Unexpected disposable CA key provider.'};$createdKeys+=@($rsa.Key.KeyName)}finally{if($rsa){$rsa.Dispose()}} + } + } + $createdKeys=@($createdKeys|Select-Object -Unique) + # Test only: malformed types are observed/preserved by the real native reader. + $null=New-ItemProperty -LiteralPath $native.Path -Name AuditFilter -Value '127' -PropertyType String -Force + $malformedPath=Join-Path $privateRoot 'malformed.json' + Invoke-TestCli -Arguments @('adcs-auditing','-ResultsPath',$malformedPath) -ExpectedExit 1 + $malformed=Get-Content -LiteralPath $malformedPath -Raw -Encoding UTF8|ConvertFrom-Json + if($malformed.After.Status -ne 'Unknown' -or (Get-WelaRegistryState $native.Path AuditFilter).Type -ne 'String'){throw 'Native unknown filter type was changed or credited.'} + # Force an actual guarded change on this newly created CA, not an idempotent-only test. + $null=New-ItemProperty -LiteralPath $native.Path -Name AuditFilter -Value 0 -PropertyType DWord -Force + Set-WelaEffectiveAuditPolicy -Guid $auditGuid -Mask 0 -Mode exact + $null=New-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Value 0 -PropertyType DWord -Force + $disabled=Get-WelaAdcsSnapshot + if($disabled.Status -ne 'Supported' -or $disabled.AuditMask -ne 0){throw 'Actual disabled audit state (native NONE flag4) was not normalized to mask0.'} + $applyPath=Join-Path $privateRoot 'configured.json' + Invoke-TestCli -Arguments @('adcs-auditing','-AdcsAction','Configure','-AdcsProfile','microsoft-identity-ca-2026-09','-AllowRestart','-Auto','-BackupPath',(Join-Path $privateRoot 'journal'),'-ResultsPath',$applyPath) + $applied=Get-Content -LiteralPath $applyPath -Raw -Encoding UTF8|ConvertFrom-Json + if($applied.PolicyState -ne 'PolicyMatches' -or $applied.Activation -notmatch '^RestartObservedAfterWrite' -or $applied.UsableRuleCredit -ne 0){throw 'Native configuration did not verify the intended state/restart boundary.'} + $stable=Get-WelaAdcsSnapshot;$stableKey=Get-WelaAdcsStateKey $stable + $repeatPath=Join-Path $privateRoot 'repeated.json' + 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.'} + # 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' + if((Get-FileHash -LiteralPath $csrPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne 'f39e219e1ccafed480524980d07356ab6b55c1dc85cd796553d2a4a79241958b'){throw 'Fixed benign CSR changed.'} + $request=[IO.File]::ReadAllText($csrPath) + $startUtc=[DateTime]::UtcNow + $client=New-Object -ComObject CertificateAuthority.Request + try{ + # CR_IN_PKCS10 (0x100) + CR_IN_BASE64HEADER (0); source CertCli.h. + $disposition=$client.Submit(0x100,$request,('WELAProbe:'+$nonce),($env:COMPUTERNAME+'\'+$caName)) + $requestId=$client.GetRequestId() + }finally{if($client){$null=[Runtime.InteropServices.Marshal]::FinalReleaseComObject($client)}} + if($disposition -ne 5 -or $requestId -le 0){throw "Expected pending disposition5, got $disposition / request$requestId. No approval, retrieval or leaf installation was performed."} + $identity=[Security.Principal.WindowsIdentity]::GetCurrent() + try{$requester=$identity.Name}finally{$identity.Dispose()} + $expected=[pscustomobject]@{Computer=$env:COMPUTERNAME;RequestId=[int]$requestId;Requester=$requester;Nonce=$nonce;StartUtc=$startUtc.ToString('o');EndUtc=$null} + $matched=@{};$deadline=[DateTime]::UtcNow.AddSeconds(30) + do{ + $expected.EndUtc=[DateTime]::UtcNow.ToString('o') + $events=@(Get-WinEvent -FilterHashtable @{LogName='Security';Id=@(4886,4889);StartTime=$startUtc} -MaxEvents 512 -ErrorAction SilentlyContinue -ErrorVariable queryErrors) + if(@($queryErrors|Where-Object{$_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*'}).Count){throw ($queryErrors|Out-String)} + if($events.Count -ge 512){throw 'Native request event query cap reached; no complete-match claim.'} + foreach($id in @(4886,4889)){ + $matches=@(foreach($event in $events){if($event.Id -eq $id){$xml=[string]::Concat($event.ToXml());if(Test-WelaAdcsRequestEvent $xml $expected $id){$xml}}}) + if($matches.Count -gt 1){throw "Ambiguous native request event$id."} + if($matches.Count -eq 1){$matched[$id]=$matches[0]} + } + if($matched.Count -eq 2){break};Start-Sleep -Milliseconds 500 + }while([DateTime]::UtcNow -lt $deadline) + if($matched.Count -ne 2){ + $events|ForEach-Object{$_.ToXml()}|Set-Content -LiteralPath (Join-Path $privateRoot 'unmatched-request-events.xml') -Encoding UTF8 + # This workgroup CA and query window belong solely to the disposable + # test; emit bounded diagnostics before the hosted VM is discarded. + $expected|ConvertTo-Json -Depth 5|Write-Host + Write-Host "Native 4886/4889 records in bounded window: $($events.Count); matched: $($matched.Count)." + $events|ForEach-Object{Write-Host $_.ToXml()} + throw 'Both correlated native 4886 and4889 XML events were not observed. Raw bounded diagnostics retained locally.' + } + if((Get-WelaAdcsStateKey (Get-WelaAdcsSnapshot)) -cne $stableKey){throw 'CA identity/policy/service drifted while collecting native request events.'} + foreach($id in @(4886,4889)){[IO.File]::WriteAllText((Join-Path $privateRoot ("event-$id.xml")),$matched[$id],[Text.UTF8Encoding]::new($false))} + [pscustomobject]@{Kind='WelaAdcsNativeRequestComponents';Context=$stable;Expected=$expected;Disposition=$disposition;ReadyRuleCredit=0;Artifacts=@(foreach($id in @(4886,4889)){$path=Join-Path $privateRoot ("event-$id.xml");[pscustomobject]@{Path=[IO.Path]::GetFileName($path);Sha256=(Get-FileHash -LiteralPath $path).Hash.ToLowerInvariant()}});Scope='Local pending request only; no enterprise-template/DC/Sigma/backend proof'}|ConvertTo-Json -Depth 18|Set-Content -LiteralPath (Join-Path $privateRoot 'native-components.json') -Encoding UTF8 + foreach($id in @(4886,4889)){Write-Host $matched[$id]} + Get-Content -LiteralPath (Join-Path $privateRoot 'native-components.json') -Raw -Encoding UTF8|Write-Host + $passed=$true + Write-Host "Observed correlated Security4886/4889 request$requestId on disposable Server$($os.BuildNumber) via $TestEngine; no certificate was approved." +}catch{ + # Preserve the primary native failure even if cleanup independently fails. + Write-Host ('Native CA validation failed before cleanup: '+($_|Out-String)) + Write-Host $_.ScriptStackTrace + throw +}finally{ + $cleanupErrors=@() + if($attemptedCA){ + try{ + $current=Get-WelaRegistryState -Path $caRoot -Name Active + if($current.ValueExists -and $current.Value -cne $caName){throw 'Active CA no longer belongs to this test; cleanup refused.'} + $createdCerts=@(Find-CreatedCertificates) + foreach($entry in $createdCerts){if($entry.Certificate.HasPrivateKey){$rsa=[Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($entry.Certificate);try{if($rsa -is [Security.Cryptography.RSACng]){$createdKeys+=@($rsa.Key.KeyName)}}finally{if($rsa){$rsa.Dispose()}}}} + Uninstall-AdcsCertificationAuthority -Force -ErrorAction Stop|Out-Null + foreach($entry in $createdCerts){$path='Cert:\LocalMachine\'+$entry.Store+'\'+$entry.Thumbprint;if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -ErrorAction Stop}} + foreach($keyName in @($createdKeys|Select-Object -Unique)){ + $provider=[Security.Cryptography.CngProvider]::MicrosoftSoftwareKeyStorageProvider + if([Security.Cryptography.CngKey]::Exists($keyName,$provider,[Security.Cryptography.CngKeyOpenOptions]::MachineKey)){$key=[Security.Cryptography.CngKey]::Open($keyName,$provider,[Security.Cryptography.CngKeyOpenOptions]::MachineKey);try{$key.Delete()}finally{$key.Dispose()}} + } + }catch{$cleanupErrors+=$_.Exception.Message} + } + try{ + Set-WelaEffectiveAuditPolicy -Guid $auditGuid -Mask $beforePolicies[$auditGuid] -Mode exact + if($precedenceBefore.ValueExists){$null=New-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Value $precedenceBefore.Value -PropertyType $precedenceBefore.Type -Force}else{Remove-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -ErrorAction SilentlyContinue} + $after=Get-WelaRegistryState -Path $precedencePath -Name SCENoApplyLegacyAuditPolicy + if(($after|ConvertTo-Json -Compress) -cne ($precedenceBefore|ConvertTo-Json -Compress)){throw 'Audit precedence restoration differs.'} + $afterPolicies=Get-WelaEffectiveAuditPolicy + foreach($guid in $beforePolicies.Keys){if($afterPolicies[$guid] -ne $beforePolicies[$guid]){throw "Audit policy restoration differs: $guid"}} + }catch{$cleanupErrors+=$_.Exception.Message} + $featureRemoval=[pscustomobject]@{CaAndAuditRestored=($cleanupErrors.Count -eq 0);Attempted=$false;Features=@();Success=$null;RestartNeeded=$null;Boundary='OS feature removal is separate from CA/audit restoration and can require disposal of the hosted runner.'} + if($installedFeature -and $cleanupErrors.Count -eq 0){ + try{ + if((Get-WelaRegistryState -Path $caRoot -Name Active).ValueExists){throw 'A configured CA remains; feature cleanup refused.'} + $added=@(Get-WindowsFeature|Where-Object{$_.Installed -and $_.Name -notin $beforeFeatures -and ($_.Name -like 'ADCS-*' -or $_.Name -in @('AD-Certificate','RSAT-ADCS','RSAT-ADCS-Mgmt'))}|ForEach-Object Name) + if($added.Count){ + $featureRemoval.Attempted=$true;$featureRemoval.Features=$added + $removed=Uninstall-WindowsFeature -Name $added -ErrorAction Stop + $featureRemoval.Success=[bool]$removed.Success;$featureRemoval.RestartNeeded=[string]$removed.RestartNeeded + if(-not $removed.Success -or [string]$removed.RestartNeeded -notin @('No','Yes')){throw 'Created CA feature removal failed or returned an unknown restart status.'} + # GitHub destroys this isolated VM after the job. No production + # restart and no complete OS feature-restoration claim are made. + } + }catch{$cleanupErrors+=$_.Exception.Message} + } + $featureRemoval|ConvertTo-Json -Depth 5|Set-Content -LiteralPath (Join-Path $privateRoot 'feature-removal.json') -Encoding UTF8 + $featureRemoval|ConvertTo-Json -Depth 5|Write-Host + if($cleanupErrors.Count){throw "Disposable CA cleanup failed; receipt retained at $privateRoot : $($cleanupErrors -join '; ')"} + 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.' diff --git a/tests/Test-ConfigurationResults.ps1 b/tests/Test-ConfigurationResults.ps1 index 2e552e56..faced37a 100644 --- a/tests/Test-ConfigurationResults.ps1 +++ b/tests/Test-ConfigurationResults.ps1 @@ -171,42 +171,17 @@ try { $global:LASTEXITCODE = 0 Assert ($childExit -eq 1) 'The actual configure dispatcher returns nonzero for a failed control report' - # CA-specific wrapper: registry read succeeds, certutil succeeds, restart - # fails. All APIs below are mocks, including Test-Path for the mock CA only. - $realTestPath = (Get-Command Test-Path).Name - function global:Test-Path { - param($LiteralPath, $Path, $ErrorAction) - if ($LiteralPath -like 'HKLM:*') { return $true } - Microsoft.PowerShell.Management\Test-Path -LiteralPath $(if ($LiteralPath) { $LiteralPath } else { $Path }) - } - function global:Get-ItemProperty { param($LiteralPath, $Name, $ErrorAction) [pscustomobject]@{ Active = 'MockCA' } } - function global:Join-Path { - param($Path, $ChildPath) - if ($Path -like 'HKLM:*') { return "$Path\$ChildPath" } - Microsoft.PowerShell.Management\Join-Path -Path $Path -ChildPath $ChildPath - } - $script:filter = 0; $script:restartCalls = 0; $script:filterType = 'DWord' - function global:Get-WelaRegistryState { param($Path, $Name) [pscustomobject]@{ ValueExists = $true; Value = $script:filter; Type = $script:filterType; KeyExists = $true } } - function global:Get-Service { param($Name, $ErrorAction) [pscustomobject]@{ Status = 'Running' } } - function global:Restart-Service { param($Name, [switch]$Force, $ErrorAction) $script:restartCalls++; throw 'Injected CertSvc restart failure' } - function global:Invoke-WelaNative { param($FilePath, $Arguments) $script:filter = 127; [pscustomobject]@{ ExitCode = 0; Diagnostic = 'mock certutil' } } + # The legacy wrapper delegates to the same guarded CA engine as the dedicated + # command. Actual identity/prerequisite/write/restart cases have focused tests + # in AdcsAuditing.Tests.ps1 and the disposable native CA workflow. + $script:forwardedContext = $null + function global:Invoke-WelaLegacyAdcsControl { param($Context) $script:forwardedContext = $Context } $c = New-TestContext Set-WelaCertificateAuditControl $c - $r = Complete-WelaConfiguration $c - Assert ($r.ExitCode -eq 1 -and $r.Results[0].Diagnostic -match 'restart failure') 'CA restart failure cannot report success even when registry now equals 127' - $script:filter = 0; $script:restartCalls = 0 - function global:Invoke-WelaNative { param($FilePath, $Arguments) throw 'certutil failed (exit: 5)' } - $c = New-TestContext - Set-WelaCertificateAuditControl $c - Assert ($c.Results[0].Status -eq 'Failed' -and $script:restartCalls -eq 0) 'Failed certutil never restarts the CA' + Assert ([object]::ReferenceEquals($c, $script:forwardedContext)) 'Legacy CA forwards its existing prompt/Auto/recovery context to the shared engine' $c = New-TestContext -DryRun Set-WelaCertificateAuditControl $c - Assert ($c.Results[0].Status -eq 'Skipped' -and $script:restartCalls -eq 0) 'CA dry run never writes or restarts' - - $script:filter = '127'; $script:filterType = 'String' - $c = New-TestContext -DryRun - Set-WelaCertificateAuditControl $c - Assert ($c.Results[0].Status -eq 'Skipped') 'REG_SZ 127 is not accepted as a compliant CA DWORD AuditFilter' + Assert ($script:forwardedContext.DryRun) 'Legacy CA forwards dry-run without a separate native implementation' Write-Host "$script:passed configuration-result regression assertions passed. No Windows settings changed." } finally { diff --git a/tests/fixtures/adcs-4886-v1.xml b/tests/fixtures/adcs-4886-v1.xml new file mode 100644 index 00000000..9dbba3ce --- /dev/null +++ b/tests/fixtures/adcs-4886-v1.xml @@ -0,0 +1,3 @@ +4886101280500x80200000000000001513128Securityrunnervmibwwn2runnervmibwwn\runneradmin +WELAProbe:e5d9f00053f64487bc4890e16c043ae0 +ccm:runnervmibwwnCN=WELA native pending audit probeNTLMPrivacyDCOM diff --git a/tests/fixtures/adcs-4889-v1.xml b/tests/fixtures/adcs-4889-v1.xml new file mode 100644 index 00000000..43ca17ed --- /dev/null +++ b/tests/fixtures/adcs-4889-v1.xml @@ -0,0 +1,3 @@ +4889101280500x80200000000000001513129Securityrunnervmibwwn2runnervmibwwn\runneradmin +WELAProbe:e5d9f00053f64487bc4890e16c043ae0 +ccm:runnervmibwwn53c 20 68 7b b1 01 c5 00 4a a7 bf 4d fe e6 82 f4 76 8c 45 9cNTLMPrivacyDCOM diff --git a/tests/fixtures/adcs-pending-probe.csr b/tests/fixtures/adcs-pending-probe.csr new file mode 100644 index 00000000..ecd78c23 --- /dev/null +++ b/tests/fixtures/adcs-pending-probe.csr @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICbzCCAVcCAQAwKjEoMCYGA1UEAwwfV0VMQSBuYXRpdmUgcGVuZGluZyBhdWRp +dCBwcm9iZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAIq1JG26mOP1 +Euc4kxbtrqTLfesIMwIGUJ1vFbKMQIKPU5vt5Sg0b0DLpFajbY52mHv3hec0f8Ww +bUIv8yFzxDkqlyTAMnx1L6nX+em6Ufy1pi2tbdL9fIUwZwV+7JrO/SLwSxubrD/J +Z1fH84AuhW3Ocm3jerSo4SqslY767TkIJOo0Oo80VlZ6bNuNpIECAeMwUYMwlY6D +z30DAAOKBM11Qr8BVmk3RTao0ru0ufVqIwR8Ze0V+SK/sSx/TViEbuxCxKMWSLIW +WWxn5dExXxvdKDrBPpH3fSqdie6m7Yl1Z2jiuORnD0/t72VNz0Ob2lhx9zlKIPSB +Iq4SYPLzC1MCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQA2SyHZgnVBiRpFKHkI +Nk+a7mRuEY+hi0hV1AB5xmhY7q98Y+UWlhSvN7IYzj1tavni/YbtMKpviBxbHZgT ++T0ZucV7hxFfHY3uwXQSgGBNhaDmgyndLSagMcj07Oc2X6VcOdOfEVJfmhC26yvO +9PriVB2GUsqyXeRXrwEr4BbaBloWYtF9VoHFGQpg1mbAqoFqzKLIIXsr/Hp1n0gW +xzZRbzXQIKn1lSfuDjZILyxyLRbKcS74zQtVT6rHDEouHdHWXTMzVC1fVI1B+uwn +wDyWZdO3VIVKyNQHGVs9EiTTO66XGPxTLQnpmCSIS1VAftygxwWw5cjZtYp205KN +4Re7 +-----END CERTIFICATE REQUEST----- diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 1aa8db99..ae56ed18 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,7 @@ **改善:** +- 既存CA向けにネイティブの`adcs-auditing`監査・計画・出典付き設定を追加しました。CAと証明書の識別、監査の前提条件、型付き復旧記録、変更直前と読戻しの検証を共有し、従来のCA設定も同じ処理を使用します。停止中のCAは起動せず、専用コマンドでのフィルター変更には再起動の明示指定を求めます。設定一致・再起動の観測・イベント証拠を区別し、Sigma利用可能数には加算しません。使い捨てのスタンドアロンCAテストで保留要求の4886/4889 XMLを関連付け、元の監査設定と作成した資源を復元・削除します。エンタープライズCA・DC・収集基盤の検証は別途必要です。 (#421) (@Shirofune-Security) - `evtx-recovery` を追加し、検証済みのネイティブ Security プローブを EVTX に出力して Windows イベント API で再読込できるようにしました。実際の読取アカウントによる検証、入力・イベントの厳密な比較、新規出力の保護、ハッシュとドリフト検出で空または変更された記録を拒否します。使い捨て Windows テストで実際の出力・復旧を検証し、全体の保存期間、他アカウントのアクセス、Sigma 対応とは区別します。 (#420) (@Shirofune-Security) - `audit-recovery` を追加し、完了した監査サブカテゴリと優先設定の変更を明示選択して計画・復元できるようにしました。元の記録と結果、ホストと入力の再検証、復元前の記録、最終確認でドリフトを検出し、最小設定の独立した追加ビットを保持します。優先設定は最後に復元します。使い捨て Windows 環境で実際の復元を検証し、過去のホスト同一性、GPO 永続性、Sigma 対応の証明とは区別します。 出力先はローカル固定ドライブに限定し、ネットワークドライブと代替データストリームを拒否します。 (#419) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 33a52357..4e46e49e 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,7 @@ **Improvements:** +- Added dedicated native `adcs-auditing` audit, plan and source-profile configuration, with pinned CA/certificate identity, verified audit prerequisites, typed journals and fresh/readback guards. Legacy CA configuration uses the same engine; stopped CAs are preserved and dedicated filter changes require explicit restart consent. Policy matches, observed restarts and request events remain separate, with no Sigma credit. Disposable standalone-CA tests collect correlated pending-request 4886/4889 XML and restore policy/created resources; enterprise/DC/backend acceptance remains separate. (#421) (@Shirofune-Security) - Added opt-in `evtx-recovery` to export one validated native Security probe and reopen its EVTX through Windows event APIs, with a separate verification action under the actual reader. Strict source/component checks, exact event comparison, protected new output, archive hashes and reader/context drift checks reject empty or changed evidence. Disposable Windows tests cover real export/recovery and empty archives; full retention, other-reader access and Sigma readiness remain separate. (#420) (@Shirofune-Security) - Added opt-in `audit-recovery` planning and restoration for selected completed audit subcategory and typed precedence writes. Matched journals/results, independently rebuilt plans, actual host/source guards, durable receipts on local fixed drives and final readback refuse drift; minimum masks preserve independent additions and precedence restores last. Native disposable Windows tests verify exact restoration, without historical-identity, policy-persistence or Sigma claims. (#419) (@Shirofune-Security)