mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-08 23:35:28 +02:00
Merge pull request #446 from Shirofune-Security/feat/386-native-capi2-probe
Measure a fixed offline native CAPI2 certificate-chain event
This commit is contained in:
15 files changed
+493
-1
No files matched your search
@@ -59,6 +59,11 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf
|
||||
/scripts/WefArrival.ps1 text eol=lf
|
||||
/tests/WmiProbe*.ps1 text eol=lf
|
||||
|
||||
/scripts/Capi2Probe* text eol=lf
|
||||
/tests/Capi2Probe* text eol=lf
|
||||
|
||||
/scripts/CustomAuditProfiles.ps1 text eol=lf
|
||||
|
||||
# Reviewed WEC state plans bind native setter and runtime source bytes.
|
||||
/scripts/WecState* text eol=lf
|
||||
/scripts/WecRuntime* text eol=lf
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Native offline CAPI2 chain probe
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
capi2-probe:
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-2022, windows-2025]
|
||||
engine: [powershell, pwsh]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
- name: Portable and public guards in Windows PowerShell 5.1
|
||||
if: matrix.engine == 'powershell'
|
||||
shell: powershell
|
||||
run: |
|
||||
./tests/Capi2Probe.Tests.ps1
|
||||
./tests/Capi2Probe.Cli.Tests.ps1
|
||||
- name: Genuine public CAPI2 probe in Windows PowerShell 5.1
|
||||
if: matrix.engine == 'powershell'
|
||||
shell: powershell
|
||||
run: ./tests/Capi2Probe.Windows.Tests.ps1 -AllowDisposableChannelWrite
|
||||
- name: Portable and public guards in PowerShell 7
|
||||
if: matrix.engine == 'pwsh'
|
||||
shell: pwsh
|
||||
run: |
|
||||
./tests/Capi2Probe.Tests.ps1
|
||||
./tests/Capi2Probe.Cli.Tests.ps1
|
||||
- name: Genuine public CAPI2 probe in PowerShell 7
|
||||
if: matrix.engine == 'pwsh'
|
||||
shell: pwsh
|
||||
run: ./tests/Capi2Probe.Windows.Tests.ps1 -AllowDisposableChannelWrite
|
||||
- name: Retain native XML, generated public certificates and cleanup evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: capi2-probe-${{ matrix.os }}-${{ matrix.engine }}
|
||||
path: ${{ runner.temp }}/wela-capi2-native-*/
|
||||
retention-days: 7
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
Copy-Item -Recurse -Path ./scripts -Destination release-binaries/
|
||||
Copy-Item -Recurse -Path ./modules -Destination release-binaries/
|
||||
New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null
|
||||
Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-ingress.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md -Destination release-binaries/docs/
|
||||
Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/ipsec-prerequisites.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md -Destination release-binaries/docs/
|
||||
|
||||
- name: Set Artifact Name
|
||||
if: contains(matrix.info.os, 'windows') == true
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
**改善:**
|
||||
|
||||
- 固定のオフライン一時証明書チェーン構築とローカル CAPI2 イベント11を確認する `capi2-probe` Plan/Run を追加。公開証明書・nonce・PID・トークン・高精度UTCによる照合、ワーカーと収集の時間制限、証拠保存に対応。既存のチャネル、証明書ストア、信頼設定を維持し、TLS、失効確認、リモート転送、Sigma の検証実績は付与しません。
|
||||
|
||||
- `transcription-recovery` を追加し、完了した Windows PowerShell 文字起こし設定を、再構築したハッシュ付き計画から型を保持して復元できるようにしました。ローカル保存先、ユーザー・ヘッダー・他のポリシーを確認し、一時停止は明示的な同意を求め、変更順序と途中結果を永続記録します。ヘルプと復旧手順には、一時停止中のエラーや中断でマシンの文字起こしが無効のまま残り、自動ロールバックや再有効化を行わないことを明記しました。使い捨て環境の実 CLI テストで復元とドリフト拒否を検証し、本番セッション・集中管理先の権限と収集・Sigma 対応は未検証とします。 (#376) (@Shirofune-Security)
|
||||
|
||||
- 完了したログ容量・保持モード設定を1件ずつ戻す `eventlog-recovery` を追加しました。元の記録と変更直前の記録、現在のチャネル・実行環境・コードを照合し、縮小と保持モード変更には個別の明示指定を必要とします。永続記録とネイティブ読戻しで無関係な設定を保持し、状態変化や再適用を拒否します。失われたイベント、長期保持、Sigma 利用可能性の証明は加算しません。 (@Shirofune-Security)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
**Improvements:**
|
||||
|
||||
- Added explicit `capi2-probe` Plan/Run for a fixed offline ephemeral certificate-chain build and exact local CAPI2 event 11 evidence, with public certificate/nonce/PID/token/precise-UTC binding, bounded worker/collection and retained artifacts. Existing channel, certificate-store and trust configuration are preserved; no TLS, revocation, remote delivery or Sigma credit is claimed.
|
||||
|
||||
- Added explicit `transcription-recovery` for one completed Windows PowerShell transcription configuration, with independently rebuilt hashed plans, typed local-directory restoration, preserved user/header/other policy, explicit temporary-suspension consent and durable ordered receipts. Help and recovery guidance warn that interrupted suspension can leave machine transcription disabled without automatic rollback or re-enable. Disposable native public-CLI tests verify restoration and drift refusal; production sessions, central authorization/collection and Sigma readiness remain unverified. (#376) (@Shirofune-Security)
|
||||
|
||||
- Added reviewed `eventlog-recovery` for one completed profile size/retention write. Matched original and immediate-prewrite evidence, current channel/context/source guards, separate shrink/retention consent, durable pending receipts and native readback preserve unrelated channel settings and refuse drift or replay. Windows fixtures restore original settings; lost events, sustained retention and Sigma readiness are not inferred. (@Shirofune-Security)
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
[ValidateSet('Audit', 'Plan', 'Import')][string]$AppLockerAction = 'Audit',
|
||||
[string]$AppLockerPolicyPath,
|
||||
[ValidateSet('List', 'Audit', 'Plan', 'Configure')][string]$WmiAction = 'List',
|
||||
[ValidateSet('Plan','Run')][string]$Capi2ProbeAction = 'Plan',
|
||||
[string]$Capi2ProbeOutputPath,
|
||||
[ValidateRange(1,30)][int]$Capi2ProbeTimeoutSeconds = 15,
|
||||
[ValidateSet('Plan','Run')][string]$FailedLogonAction = 'Plan',
|
||||
[string]$FailedLogonOutputPath,
|
||||
[ValidateRange(1,30)][int]$FailedLogonTimeoutSeconds = 15,
|
||||
@@ -203,6 +206,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json"
|
||||
. (Join-Path $ScriptRoot "scripts/AppLockerProbe.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/WmiProbe.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/Capi2Probe.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/FailedLogonProbe.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1")
|
||||
Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop
|
||||
@@ -2012,6 +2016,7 @@ Usage:
|
||||
./WELA.ps1 wec-ingress -Help # Review scoped collector firewall rule creation
|
||||
./WELA.ps1 wec-state -Help # Review enable/disable of one existing subscription
|
||||
./WELA.ps1 wec-update -Help # Review query/description updates on a disabled subscription
|
||||
./WELA.ps1 capi2-probe -Help # Fixed offline chain and matched CAPI2 event 11 evidence
|
||||
./WELA.ps1 failed-logon-probe -Help # Fixed nonexistent local account and matched Security4625 evidence
|
||||
./WELA.ps1 wmi-probe -Help # Fixed local read and matched namespace Security4662 evidence
|
||||
./WELA.ps1 applocker-probe -Help # Collect a fixed native AppLocker EXE event
|
||||
@@ -2106,6 +2111,8 @@ if ($Cmd -ne 'wec-runtime' -and @($PSBoundParameters.Keys | Where-Object {$_ -li
|
||||
if ($Cmd -eq 'wec-runtime' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecRuntimeId','WecRuntimeMaximumSources','ResultsPath','Help')}).Count) {
|
||||
throw 'wec-runtime accepts only selected runtime IDs, source cap and a new result path. No command was run.'
|
||||
}
|
||||
if ($Cmd -ne 'capi2-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Capi2Probe*'}).Count) {throw 'Capi2Probe options require capi2-probe.'}
|
||||
if ($Cmd -eq 'capi2-probe' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','Capi2ProbeAction','Capi2ProbeOutputPath','Capi2ProbeTimeoutSeconds','Help')}).Count)) {throw 'capi2-probe accepts only dedicated probe options.'}
|
||||
if ($Cmd -ne 'failed-logon-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'FailedLogon*'}).Count) {throw 'FailedLogon options require failed-logon-probe.'}
|
||||
if ($Cmd -eq 'failed-logon-probe' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','FailedLogonAction','FailedLogonOutputPath','FailedLogonTimeoutSeconds','Help')}).Count)) {throw 'failed-logon-probe accepts only dedicated probe options.'}
|
||||
if ($Cmd -ne 'wmi-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WmiProbe*'}).Count) {throw 'WmiProbe options require wmi-probe.'}
|
||||
@@ -2341,6 +2348,12 @@ switch ($Cmd.ToLower()) {
|
||||
$report
|
||||
if($report.ExitCode){exit $report.ExitCode}
|
||||
}
|
||||
'capi2-probe' {
|
||||
if ($Help) {Write-Host 'Usage: capi2-probe [-Capi2ProbeAction Plan|Run] [-Capi2ProbeOutputPath new-private-directory] [-Capi2ProbeTimeoutSeconds 1..30]. Fixed offline ephemeral certificate-chain build; requires an enabled readable CAPI2 channel. No configuration, trust, TLS or Sigma claim. See docs/capi2-probe.md.';return}
|
||||
$report=Invoke-WelaCapi2Probe -Action $Capi2ProbeAction -OutputPath $Capi2ProbeOutputPath -TimeoutSeconds $Capi2ProbeTimeoutSeconds
|
||||
$report
|
||||
if($report.ExitCode){exit $report.ExitCode}
|
||||
}
|
||||
'failed-logon-probe' {
|
||||
if ($Help) {Write-Host 'Usage: failed-logon-probe [-FailedLogonAction Plan|Run] [-FailedLogonOutputPath new-private-directory] [-FailedLogonTimeoutSeconds 1..30]. One fixed nonexistent local account attempt under existing failure auditing. Domain controllers excluded. No real credentials or configuration changes. See docs/failed-logon-probe.md.';return}
|
||||
$report=Invoke-WelaFailedLogonProbe -Action $FailedLogonAction -OutputPath $FailedLogonOutputPath -TimeoutSeconds $FailedLogonTimeoutSeconds
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Fixed local CAPI2 certificate-chain probe
|
||||
|
||||
`capi2-probe` measures one built-in source on Windows Server 2022/2025: an offline native chain build for a newly generated ephemeral self-signed certificate, followed by one matching CAPI2 Operational event 11. The expected chain outcome is an untrusted root. Success means that this local operation and event were observed; it does not mean that the certificate is trusted.
|
||||
|
||||
```powershell
|
||||
./WELA.ps1 capi2-probe -Capi2ProbeAction Plan
|
||||
./WELA.ps1 capi2-probe -Capi2ProbeAction Run -Capi2ProbeOutputPath C:\Evidence\new-capi2-probe
|
||||
```
|
||||
|
||||
Plan reads prerequisites and creates no files. Run requires a new directory on a local fixed drive; its evidence directory blocks inherited broad access. Use an existing token that can read `Microsoft-Windows-CAPI2/Operational`. The channel must already be enabled. Winmgmt, CryptSvc and EventLog must already be running; observing the host or building the chain may not implicitly start them. The probe does not change channel configuration, audit policy, services, certificate stores, trust settings or reader permissions. Existing native-channel configuration commands remain separate.
|
||||
|
||||
Run launches the same PowerShell executable in a fresh worker with a parent-generated nonce and a twenty-second process deadline. The worker creates an unnamed ephemeral Microsoft Software Key Storage Provider RSA-2048 key, signs an in-memory certificate with `CN=WelaCapi2Probe_<nonce>` and a ten-minute validity interval, then calls `CertGetCertificateChain` once. The certificate has no AIA, CRL or other extensions. The key is disposed and never exported; the retained PEM/DER contains only the public certificate.
|
||||
|
||||
The fixed native flags are `0x80002104`: `CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL`, `CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY`, `CERT_CHAIN_DISABLE_AIA` and `CERT_CHAIN_DISABLE_AUTH_ROOT_AUTO_UPDATE`. No revocation-check request, additional store, custom trust engine or end-certificate caching is selected. These per-call flags prevent network retrieval by the chain operation; no machine-wide network or trust policy is altered.
|
||||
|
||||
Evidence must agree on the actual worker PID, caller SID/logon/group context, before/after token observations, generated DER/subject/thumbprint/SHA-256 and nonce. A native precise UTC interval surrounds the chain call; the event must fall within those exact inclusive bounds and after the observed channel record boundary. The matcher requires provider GUID, channel, event11 version0, native task/opcode/keywords, source computer, security SID, certificate references, offline flags, one certificate element and the expected untrusted-root result. Incomplete, ambiguous, capped or changed-context evidence remains `Unverified` and exits nonzero.
|
||||
|
||||
Collection waits up to 15 seconds by default (`-Capi2ProbeTimeoutSeconds 1..30`), queries at most 64 candidates and requires exactly one match. The bundle retains before/after context, worker operation including public certificate DER, public certificate PEM, raw matched event XML and artifact hashes. Failed matching retains up to four bounded candidate XML records. These local hashes detect altered artifacts; they are not a remote attestation or signed chain of custody.
|
||||
|
||||
This probe grants no ready-rule credit. It does not exercise TLS, remote connections, revocation retrieval, certificate enrollment, WEF delivery, the existing CAPI2 pack's event70 mapping, a Sigma rule or backend translation. Issues #386 and #367 have broader remaining acceptance criteria. Sysmon and external telemetry are excluded.
|
||||
|
||||
## Validation
|
||||
|
||||
`tests/Capi2Probe.Tests.ps1` validates certificate binding, native-result constraints, prerequisite guards, exact XML source/field checks and UTC boundaries with portable fixtures. `tests/Capi2Probe.Cli.Tests.ps1` checks public option isolation. Synthetic fixtures do not prove Windows telemetry.
|
||||
|
||||
`tests/Capi2Probe.Windows.Tests.ps1 -AllowDisposableChannelWrite` is restricted to opted-in disposable GitHub-hosted standalone Server 2022/2025. It invokes three independent public probes under Windows PowerShell 5.1 and PowerShell 7. Only the fixture may temporarily enable CAPI2; it retains the original and restored channel configuration, checks CurrentUser/LocalMachine My, Root and CA inventories, preserves all probe bundles and writes cleanup evidence even on failure. Native results must be assessed from the current workflow artifacts. The first complete native checkpoint at `a5f674e` passed all four matrix jobs with 40 assertions and three independent public probes per job ([workflow evidence](https://github.com/Shirofune-Security/WELA/actions/runs/35580490435)). All twelve public certificate identities, sixty artifact hashes and four original/restored channel and selected-store inventories were independently checked.
|
||||
|
||||
## Microsoft API references
|
||||
|
||||
- [CertGetCertificateChain flags and ownership](https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-certgetcertificatechain)
|
||||
- [CERT_CHAIN_PARA](https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/ns-wincrypt-cert_chain_para), [CERT_CHAIN_CONTEXT](https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/ns-wincrypt-cert_chain_context), [CERT_SIMPLE_CHAIN](https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/ns-wincrypt-cert_simple_chain) and [CERT_TRUST_STATUS](https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/ns-wincrypt-cert_trust_status)
|
||||
- [Unnamed CngKey creation is ephemeral](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.cngkey.create)
|
||||
- [CertificateRequest.Create with a signature generator](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.x509certificates.certificaterequest.create)
|
||||
- [GetSystemTimePreciseAsFileTime](https://learn.microsoft.com/en-us/windows/win32/api/sysinfoapi/nf-sysinfoapi-getsystemtimepreciseasfiletime)
|
||||
@@ -0,0 +1,166 @@
|
||||
# Explicit fixed local CAPI2 source measurement. No channel, key-store or trust-policy writes.
|
||||
function Initialize-WelaCapi2ProbeNative {
|
||||
Initialize-WelaWmiProbeNative
|
||||
$source=Join-Path $PSScriptRoot 'Capi2ProbeNative.cs';$hash=(Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash
|
||||
if(-not ('Wela.Capi2Probe.Native' -as [type])){Add-Type -Path $source -ErrorAction Stop;$script:WelaCapi2ProbeNativeHash=$hash}
|
||||
if($script:WelaCapi2ProbeNativeHash -cne $hash){throw 'Loaded CAPI2 helper differs from its source; start a fresh session.'}
|
||||
}
|
||||
function Get-WelaCapi2ProbeSources {
|
||||
$sources=[ordered]@{}
|
||||
foreach($name in @('WELA.ps1','scripts/Capi2Probe.ps1','scripts/Capi2ProbeWorker.ps1','scripts/Capi2ProbeNative.cs','scripts/WmiProbe.ps1','scripts/WmiProbeNative.cs','scripts/ChannelRead.ps1','scripts/WefArrival.ps1','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot ('../'+$name)) -Algorithm SHA256).Hash.ToLowerInvariant()}
|
||||
$sources|ConvertTo-Json -Compress
|
||||
}
|
||||
function Get-WelaCapi2ProbeChannel {
|
||||
$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Microsoft-Windows-CAPI2/Operational')
|
||||
try{[pscustomobject][ordered]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;SecurityDescriptor=$channel.SecurityDescriptor;MaximumSize=$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode;Type=[string]$channel.LogType;Provider=$channel.OwningProviderName}}finally{$channel.Dispose()}
|
||||
}
|
||||
function Get-WelaCapi2ProbeState {
|
||||
Initialize-WelaCapi2ProbeNative
|
||||
$services=@(Get-Service -Name Winmgmt,CryptSvc,EventLog -ErrorAction Stop|Sort-Object Name|ForEach-Object {[pscustomobject]@{Name=$_.Name;Status=[string]$_.Status}})
|
||||
if($services.Count -ne 3 -or @($services|Where-Object Status -ne 'Running').Count){throw 'Winmgmt, CryptSvc and EventLog must already be running; the probe starts no service.'}
|
||||
$token=[Wela.WmiProbe.Native]::Snapshot();$hostState=Get-WelaChannelReadHost
|
||||
$provider=[Diagnostics.Eventing.Reader.ProviderMetadata]::new('Microsoft-Windows-CAPI2')
|
||||
try{$event=@($provider.Events|Where-Object Id -eq 11);$metadata=[pscustomobject]@{Name=$provider.Name;Guid=$provider.Id.ToString();Event11Versions=@($event|ForEach-Object Version);LogNames=@($provider.LogLinks|ForEach-Object LogName|Sort-Object)}}finally{$provider.Dispose()}
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
$state=[pscustomobject][ordered]@{Computer=[Environment]::MachineName;Host=$hostState;Services=$services;Token=$token;Channel=(Get-WelaCapi2ProbeChannel);Provider=$metadata;Engine=$engine;EngineHash=(Get-FileHash -LiteralPath $engine -Algorithm SHA256).Hash.ToLowerInvariant();Sources=(Get-WelaCapi2ProbeSources)}
|
||||
if((Get-WelaWmiProbeTokenKey $token) -cne (Get-WelaWmiProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot()))){throw 'Token changed during CAPI2 prerequisite observation.'}
|
||||
$state
|
||||
}
|
||||
function Get-WelaCapi2ProbeStateKey {
|
||||
param($State)
|
||||
if(@($State.Services).Count -ne 3 -or (@($State.Services.Name|Sort-Object) -join ',') -cne 'CryptSvc,EventLog,Winmgmt' -or @($State.Services|Where-Object Status -cne 'Running').Count){throw 'Required native services must already be running.'}
|
||||
if($State.Host.Build -notin @(20348,26100) -or $State.Host.ProductType -notin @(2,3) -or -not $State.Host.UBR -or $State.Host.Computer -cne $State.Computer){throw 'CAPI2 probe requires an observed Server 2022/2025 build and patch context.'}
|
||||
if($State.Channel.Enabled -isnot [bool] -or -not $State.Channel.Enabled -or $State.Channel.Name -cne 'Microsoft-Windows-CAPI2/Operational' -or $State.Channel.Type -cne 'Operational' -or $State.Channel.Provider -cne 'Microsoft-Windows-CAPI2' -or -not $State.Channel.SecurityDescriptor){throw 'CAPI2 Operational must already be enabled with an observed descriptor.'}
|
||||
if($State.Provider.Name -cne 'Microsoft-Windows-CAPI2' -or $State.Provider.Guid -ine '5bbca4a8-b209-48dc-a8c7-b23d3e5216fb' -or @($State.Provider.Event11Versions).Count -ne 1 -or $State.Provider.Event11Versions[0] -ne 0 -or $State.Channel.Name -cnotin $State.Provider.LogNames){throw 'Unreviewed CAPI2 provider or event 11 schema version.'}
|
||||
$null=Get-WelaWmiProbeTokenKey $State.Token
|
||||
$State|ConvertTo-Json -Depth 16 -Compress
|
||||
}
|
||||
function Get-WelaCapi2ProbeWatermark {
|
||||
$latest=Read-WelaChannelLatest 'Microsoft-Windows-CAPI2/Operational'
|
||||
if($latest.Status -eq 'ReadAllowedEmpty'){return [long]0}
|
||||
if($latest.Status -ne 'EventObserved'){throw ('CAPI2 is not readable: '+$latest.Status+' '+$latest.Diagnostic)}
|
||||
[long]$latest.Event.RecordId
|
||||
}
|
||||
function Assert-WelaCapi2ProbeCertificate {
|
||||
param($Operation,[string]$Nonce)
|
||||
if($Nonce -cnotmatch '^[a-f0-9]{32}$' -or $Operation.Nonce -cne $Nonce -or $Operation.KeyEphemeral -isnot [bool] -or -not $Operation.KeyEphemeral -or $Operation.CertificateDerBase64 -isnot [string] -or $Operation.CertificateDerBase64.Length -gt 12000){throw 'Unexpected generated certificate identity.'}
|
||||
$der=[Convert]::FromBase64String($Operation.CertificateDerBase64)
|
||||
if($der.Length -lt 128 -or $der.Length -gt 8192){throw 'Certificate DER exceeds its evidence bound.'}
|
||||
$certificate=[Security.Cryptography.X509Certificates.X509Certificate2]::new($der)
|
||||
try{
|
||||
if([Convert]::ToBase64String($certificate.RawData) -cne $Operation.CertificateDerBase64){throw 'Public certificate evidence must contain exactly one canonical DER object.'}
|
||||
if($certificate.Subject -cne ('CN=WelaCapi2Probe_'+$Nonce) -or $certificate.Issuer -cne $certificate.Subject -or $Operation.Subject -cne $certificate.Subject -or $Operation.Thumbprint -cne $certificate.Thumbprint -or $certificate.Extensions.Count -ne 0 -or $certificate.HasPrivateKey -or $certificate.SignatureAlgorithm.Value -cne '1.2.840.113549.1.1.11' -or $certificate.PublicKey.Oid.Value -cne '1.2.840.113549.1.1.1'){throw 'Certificate DER does not describe the fixed ephemeral self-signed probe.'}
|
||||
$rsa=[Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($certificate)
|
||||
try{if($rsa.get_KeySize() -ne 2048){throw 'Unexpected probe RSA key size.'}}finally{$rsa.Dispose()}
|
||||
$start=ConvertTo-WelaArrivalUtc $Operation.StartedUtc;$end=ConvertTo-WelaArrivalUtc $Operation.CompletedUtc
|
||||
if($certificate.NotBefore.ToUniversalTime() -gt $start.UtcDateTime -or $certificate.NotAfter.ToUniversalTime() -lt $end.UtcDateTime -or ($certificate.NotAfter-$certificate.NotBefore).TotalMinutes -gt 11){throw 'Certificate validity does not cover the bounded operation.'}
|
||||
if($Operation.Chain.Flags -ne 2147492100 -or $Operation.Chain.ErrorStatus -ne 32 -or $Operation.Chain.Chains -ne 1 -or $Operation.Chain.Elements -ne 1){throw 'Expected one offline untrusted self-signed native chain.'}
|
||||
}finally{$certificate.Dispose()}
|
||||
,$der
|
||||
}
|
||||
function Start-WelaCapi2ProbeBuild {
|
||||
param($State)
|
||||
if((Get-WelaCapi2ProbeStateKey (Get-WelaCapi2ProbeState)) -cne (Get-WelaCapi2ProbeStateKey $State)){throw 'CAPI2 prerequisites changed before the operation.'}
|
||||
$watermark=Get-WelaCapi2ProbeWatermark;$nonce=[guid]::NewGuid().ToString('N')
|
||||
$worker=Join-Path $PSScriptRoot 'Capi2ProbeWorker.ps1'
|
||||
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Engine;$info.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$worker+'" -Nonce '+$nonce
|
||||
$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true
|
||||
$info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false,$true);$info.StandardErrorEncoding=$info.StandardOutputEncoding
|
||||
$process=$null
|
||||
try{
|
||||
$launch=[DateTimeOffset][Wela.WmiProbe.Native]::UtcNow();$process=[Diagnostics.Process]::Start($info);$output=$process.StandardOutput.ReadToEndAsync();$errors=$process.StandardError.ReadToEndAsync()
|
||||
if(-not $process.WaitForExit(20000)){$process.Kill();$null=$process.WaitForExit(1000);throw 'Fixed CAPI2 worker exceeded twenty seconds.'}
|
||||
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($output,$errors),1000)){throw 'Fixed worker output did not complete.'}
|
||||
if($output.Result.Length -gt 262144 -or $errors.Result.Length -gt 65536){throw 'Worker output exceeded its evidence bound.'}
|
||||
if($process.ExitCode -ne 0 -or $errors.Result){throw ('Fixed CAPI2 worker failed: '+$errors.Result)}
|
||||
$operation=ConvertFrom-WelaArrivalJson $output.Result
|
||||
if($operation.ProcessId -ne $process.Id -or $operation.ProcessName -ine [IO.Path]::GetFileName($State.Engine)){throw 'Worker process identity differs.'}
|
||||
$interval=Assert-WelaWmiProbeInterval $operation $launch ([DateTimeOffset][Wela.WmiProbe.Native]::UtcNow())
|
||||
$operation.StartedUtc=$interval.Start.UtcDateTime.ToString('o');$operation.CompletedUtc=$interval.End.UtcDateTime.ToString('o')
|
||||
$der=Assert-WelaCapi2ProbeCertificate $operation $nonce
|
||||
if((Get-WelaWmiProbeTokenKey $operation.BeforeToken) -cne (Get-WelaWmiProbeTokenKey $operation.AfterToken) -or (Get-WelaWmiProbeTokenKey $operation.BeforeToken -AuthorizationOnly) -cne (Get-WelaWmiProbeTokenKey $State.Token -AuthorizationOnly)){throw 'Worker token differs from caller or changed during operation.'}
|
||||
$operation|Add-Member NoteProperty RecordIdBefore $watermark
|
||||
$operation|Add-Member NoteProperty CertificateSha256 (Get-WelaArrivalHash $der)
|
||||
$operation
|
||||
}finally{if($process){try{if(-not $process.HasExited){$process.Kill();$null=$process.WaitForExit(1000)}}finally{$process.Dispose()}}}
|
||||
}
|
||||
function Read-WelaCapi2ProbeEvents {
|
||||
param($Operation)
|
||||
$query="*[System[Provider[@Name='Microsoft-Windows-CAPI2'] and EventID=11 and EventRecordID>$($Operation.RecordIdBefore) and Execution[@ProcessID='$($Operation.ProcessId)'] and TimeCreated[@SystemTime>='$($Operation.StartedUtc)' and @SystemTime<='$($Operation.CompletedUtc)']]]"
|
||||
$records=@();$xml=@()
|
||||
try{try{$records=@(Get-WinEvent -LogName 'Microsoft-Windows-CAPI2/Operational' -FilterXPath $query -MaxEvents 64 -ErrorAction Stop)}catch{if($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*'){throw}}
|
||||
foreach($record in $records){$text=[string]$record.ToXml();if($text.Length -gt 131072){throw 'CAPI2 event exceeds 128 KiB characters.'};$xml+=$text}
|
||||
[pscustomobject]@{Xml=$xml;Capped=($records.Count -ge 64);Query=$query;MaximumEvents=64}
|
||||
}finally{foreach($record in $records){$record.Dispose()}}
|
||||
}
|
||||
function Test-WelaCapi2XmlChildren {
|
||||
param($Node,[string[]]$Names)
|
||||
$children=@($Node.ChildNodes|Where-Object NodeType -eq Element)
|
||||
if($children.Count -ne $Names.Count -or @($Node.ChildNodes|Where-Object {$_.NodeType -notin @('Element','Whitespace')}).Count){return $false}
|
||||
foreach($name in $Names){if(@($children|Where-Object {$_.LocalName -ceq $name -and $_.NamespaceURI -ceq 'http://schemas.microsoft.com/win/2004/08/events/event'}).Count -ne 1){return $false}}
|
||||
$true
|
||||
}
|
||||
function Test-WelaCapi2ProbeEvent {
|
||||
param([string]$Xml,$Operation,$State)
|
||||
$reader=$null
|
||||
try{
|
||||
if($Xml.Length -gt 131072){return $false}
|
||||
$settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=131072
|
||||
$reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.Load($reader)
|
||||
$ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event')
|
||||
if($doc.DocumentElement.LocalName -cne 'Event' -or $doc.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $doc.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:UserData',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:EventData',$ns).Count){return $false}
|
||||
$system=@{};foreach($name in @('Provider','EventID','Version','Level','Task','Opcode','Keywords','EventRecordID','Channel','Computer','TimeCreated','Execution','Security')){$nodes=$doc.SelectNodes("/e:Event/e:System/e:$name",$ns);if($nodes.Count -ne 1){return $false};$system[$name]=$nodes[0]}
|
||||
if($system.Provider.GetAttribute('Name') -cne 'Microsoft-Windows-CAPI2' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine '5bbca4a8-b209-48dc-a8c7-b23d3e5216fb' -or $system.EventID.InnerText -cne '11' -or $system.Version.InnerText -cne '0' -or $system.Level.InnerText -cne '2' -or $system.Task.InnerText -cne '11' -or $system.Opcode.InnerText -cne '2' -or $system.Keywords.InnerText -ine '0x4000000000000003' -or $system.Channel.InnerText -cne 'Microsoft-Windows-CAPI2/Operational' -or $system.EventRecordID.InnerText -cnotmatch '^[1-9][0-9]*$' -or [long]$system.EventRecordID.InnerText -le $Operation.RecordIdBefore){return $false}
|
||||
$computers=@($State.Computer);if($State.Host.DomainJoined){$computers+=$State.Computer+'.'+$State.Host.Domain}
|
||||
if($system.Computer.InnerText -notin $computers -or $system.Execution.GetAttribute('ProcessID') -cne [string]$Operation.ProcessId -or $system.Security.GetAttribute('UserID') -cne $Operation.BeforeToken.Sid){return $false}
|
||||
$time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime');if($time -lt (ConvertTo-WelaArrivalUtc $Operation.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.CompletedUtc)){return $false}
|
||||
$data=$doc.SelectSingleNode('/e:Event/e:UserData',$ns)
|
||||
# Namespace and exact paths are pinned to native event 11, never a recursive name search.
|
||||
if(@($data.ChildNodes|Where-Object NodeType -eq Element).Count -ne 1){return $false}
|
||||
$chain=$data.SelectNodes('e:CertGetCertificateChain',$ns);if($chain.Count -ne 1){return $false};$chain=$chain[0]
|
||||
$names=@('Certificate','ExtendedKeyUsage','URLRetrievalTimeout','Flags','ChainEngineInfo','CertificateChain','EventAuxInfo','CorrelationAuxInfo','Result')
|
||||
if(-not(Test-WelaCapi2XmlChildren $chain $names)){return $false}
|
||||
$fields=@{};foreach($name in $names){$nodes=$chain.SelectNodes("e:$name",$ns);if($nodes.Count -ne 1){return $false};$fields[$name]=$nodes[0]}
|
||||
if($fields.ExtendedKeyUsage.HasChildNodes -or $fields.URLRetrievalTimeout.InnerText -cne 'PT1S' -or -not(Test-WelaCapi2XmlChildren $fields.CertificateChain @('TrustStatus','ChainElement'))){return $false}
|
||||
foreach($flag in @('CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL','CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY','CERT_CHAIN_DISABLE_AUTH_ROOT_AUTO_UPDATE','CERT_CHAIN_DISABLE_AIA')){if($fields.Flags.GetAttribute($flag) -cne 'true'){return $false}}
|
||||
if($fields.EventAuxInfo.HasAttribute('impersonateToken') -and $fields.EventAuxInfo.GetAttribute('impersonateToken') -cne $Operation.BeforeToken.Sid){return $false}
|
||||
$cert=$fields.Certificate
|
||||
if($cert.GetAttribute('fileRef') -cne ($Operation.Thumbprint+'.cer') -or $cert.GetAttribute('subjectName') -cne ('WelaCapi2Probe_'+$Operation.Nonce) -or $fields.Flags.GetAttribute('value') -ine '80002104' -or $fields.ChainEngineInfo.GetAttribute('context') -cne 'user' -or $fields.EventAuxInfo.GetAttribute('ProcessName') -ine $Operation.ProcessName -or $fields.Result.GetAttribute('value') -ine '800B0109'){return $false}
|
||||
$error=$fields.CertificateChain.SelectNodes('e:TrustStatus/e:ErrorStatus',$ns);$elements=$fields.CertificateChain.SelectNodes('e:ChainElement',$ns)
|
||||
if($error.Count -ne 1 -or $error[0].GetAttribute('value') -cne '20' -or $elements.Count -ne 1){return $false}
|
||||
$elementCert=$elements[0].SelectNodes('e:Certificate',$ns);$elementError=$elements[0].SelectNodes('e:TrustStatus/e:ErrorStatus',$ns)
|
||||
if($elementCert.Count -ne 1 -or $elementCert[0].GetAttribute('fileRef') -cne $cert.GetAttribute('fileRef') -or $elementCert[0].GetAttribute('subjectName') -cne $cert.GetAttribute('subjectName') -or $elementError.Count -ne 1 -or $elementError[0].GetAttribute('value') -cne '20'){return $false}
|
||||
if(-not(Test-WelaCapi2XmlChildren $elements[0] @('Certificate','SignatureAlgorithm','PublicKeyAlgorithm','TrustStatus','ApplicationUsage','IssuanceUsage'))){return $false}
|
||||
$signature=$elements[0].SelectSingleNode('e:SignatureAlgorithm',$ns);$publicKey=$elements[0].SelectSingleNode('e:PublicKeyAlgorithm',$ns)
|
||||
if($signature.GetAttribute('oid') -cne '1.2.840.113549.1.1.11' -or $signature.GetAttribute('hashName') -cne 'SHA256' -or $signature.GetAttribute('publicKeyName') -cne 'RSA' -or $publicKey.GetAttribute('oid') -cne '1.2.840.113549.1.1.1' -or $publicKey.GetAttribute('publicKeyLength') -cne '2048'){return $false}
|
||||
return $true
|
||||
}catch{return $false}finally{if($reader){$reader.Dispose()}}
|
||||
}
|
||||
function Invoke-WelaCapi2Probe {
|
||||
param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15)
|
||||
if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new Capi2ProbeOutputPath; Plan creates no files.'}
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaOfflineCapi2ChainProbe';Action=$Action;Status='Unverified';ExitCode=1;Before=$null;After=$null;Operation=$null;Query=$null;Candidates=0;Matches=0;Artifacts=@();Diagnostic='';OutputPath=$null;ChannelChanges=0;StoreChanges=0;TrustPolicyChanges=0;ReadyRuleCredit=0;Scope='One fixed local ephemeral certificate-chain build and matching CAPI2 event 11 only. Untrusted self-signed outcome expected; no TLS, revocation, remote, forwarding, catalog event70 or Sigma/backend validation. Sysmon excluded.'}
|
||||
if($Action -eq 'Run'){$report.OutputPath=New-WelaArrivalOutput $OutputPath $PSScriptRoot}
|
||||
try{
|
||||
$before=Get-WelaCapi2ProbeState;$report.Before=$before;$key=Get-WelaCapi2ProbeStateKey $before;$null=Get-WelaCapi2ProbeWatermark
|
||||
if($Action -eq 'Plan'){$report.After=$before;$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report}
|
||||
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 20)
|
||||
$operation=Start-WelaCapi2ProbeBuild $before;$report.Operation=$operation
|
||||
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'operation.json' ($operation|ConvertTo-Json -Depth 16)
|
||||
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'certificate.pem' ("-----BEGIN CERTIFICATE-----`n"+$operation.CertificateDerBase64+"`n-----END CERTIFICATE-----`n")
|
||||
$timer=[Diagnostics.Stopwatch]::StartNew();$matches=@()
|
||||
do{$batch=Read-WelaCapi2ProbeEvents $operation;$report.Query=$batch.Query;$report.Candidates=@($batch.Xml).Count
|
||||
if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'The 64-event query cap was reached or completeness is unknown.'}
|
||||
$matches=@($batch.Xml|Where-Object {Test-WelaCapi2ProbeEvent $_ $operation $before});if($matches.Count){break};Start-Sleep -Milliseconds 250
|
||||
}while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds)
|
||||
$report.Matches=$matches.Count
|
||||
if($matches.Count -ne 1){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('candidate-'+$i+'.xml') $xml};throw 'Expected exactly one matching CAPI2 event 11 in the fixed operation interval.'}
|
||||
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'event.xml' $matches[0]
|
||||
if((Get-WelaCapi2ProbeWatermark) -lt $operation.RecordIdBefore){throw 'CAPI2 record boundary moved backwards; continuity is unknown.'}
|
||||
$after=Get-WelaCapi2ProbeState;$report.After=$after;if((Get-WelaCapi2ProbeStateKey $after) -cne $key){throw 'Host, token, provider, channel or implementation changed during collection.'}
|
||||
$report.Status='LocalChainEventObserved';$report.ExitCode=0
|
||||
}catch{$report.Diagnostic=$_.Exception.Message}
|
||||
finally{if($report.Before -and -not $report.After){try{$report.After=Get-WelaCapi2ProbeState}catch{$report.Diagnostic+=' Final observation failed: '+$_.Exception.Message}};if($report.OutputPath -and $report.After){$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'after.json' ($report.After|ConvertTo-Json -Depth 20)}}
|
||||
if($report.OutputPath){$null=Write-WelaArrivalArtifact $report.OutputPath 'manifest.json' ($report|ConvertTo-Json -Depth 24)}
|
||||
$report
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Fixed offline chain build. No certificate/key store or policy writes.
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
namespace Wela.Capi2Probe {
|
||||
public sealed class ChainResult { public uint Flags, ErrorStatus, InfoStatus, Chains, Elements; }
|
||||
public static class Native {
|
||||
public static CngKey CreateEphemeralRsa() {
|
||||
CngKeyCreationParameters parameters=new CngKeyCreationParameters();
|
||||
parameters.Provider=CngProvider.MicrosoftSoftwareKeyStorageProvider;
|
||||
parameters.Parameters.Add(new CngProperty("Length",BitConverter.GetBytes(2048),CngPropertyOptions.None));
|
||||
// Literal null is essential: PowerShell converts a null string argument to empty.
|
||||
return CngKey.Create(CngAlgorithm.Rsa,null,parameters);
|
||||
}
|
||||
public const uint OfflineFlags=0x80002104; // cache-only URL/revocation, no AIA, no auth-root auto-update
|
||||
[StructLayout(LayoutKind.Sequential)] struct Usage { public uint Count; public IntPtr Oids; }
|
||||
[StructLayout(LayoutKind.Sequential)] struct Match { public uint Type; public Usage Usage; }
|
||||
[StructLayout(LayoutKind.Sequential)] struct Parameters {
|
||||
public uint Size; public Match RequestedUsage,RequestedIssuancePolicy;
|
||||
public uint UrlTimeout; public int CheckFreshness; public uint Freshness;
|
||||
public IntPtr CacheResync,StrongSign; public uint StrongFlags;
|
||||
}
|
||||
// Both CERT_CHAIN_CONTEXT and CERT_SIMPLE_CHAIN have this documented prefix.
|
||||
[StructLayout(LayoutKind.Sequential)] struct ChainPrefix { public uint Size,Error,Info,Count; public IntPtr Entries; }
|
||||
[DllImport("crypt32.dll",ExactSpelling=true,SetLastError=true)] static extern IntPtr CertCreateCertificateContext(uint encoding,byte[] encoded,uint length);
|
||||
[DllImport("crypt32.dll",ExactSpelling=true,SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool CertGetCertificateChain(IntPtr engine,IntPtr certificate,IntPtr time,IntPtr additionalStore,ref Parameters parameters,uint flags,IntPtr reserved,out IntPtr chain);
|
||||
[DllImport("crypt32.dll",ExactSpelling=true)] static extern void CertFreeCertificateChain(IntPtr chain);
|
||||
[DllImport("crypt32.dll",ExactSpelling=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool CertFreeCertificateContext(IntPtr certificate);
|
||||
public static ChainResult Build(byte[] der) {
|
||||
if(IntPtr.Size!=8 || Marshal.SizeOf(typeof(Parameters))!=96 || Marshal.SizeOf(typeof(ChainPrefix))!=24)throw new InvalidOperationException("Unsupported native chain structure layout.");
|
||||
if(der==null || der.Length<128 || der.Length>8192)throw new ArgumentException("Certificate DER exceeds the fixed bound.");
|
||||
IntPtr certificate=CertCreateCertificateContext(1,der,(uint)der.Length),chain=IntPtr.Zero;
|
||||
if(certificate==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try {
|
||||
Parameters p=new Parameters();p.Size=(uint)Marshal.SizeOf(typeof(Parameters));p.UrlTimeout=1000;
|
||||
// No revocation-check request, additional store, custom trust engine, or caching of the end certificate.
|
||||
if(!CertGetCertificateChain(IntPtr.Zero,certificate,IntPtr.Zero,IntPtr.Zero,ref p,OfflineFlags,IntPtr.Zero,out chain))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
if(chain==IntPtr.Zero)throw new InvalidOperationException("Native chain context is absent.");
|
||||
ChainPrefix c=(ChainPrefix)Marshal.PtrToStructure(chain,typeof(ChainPrefix));
|
||||
if(c.Size<24 || c.Count!=1 || c.Entries==IntPtr.Zero)throw new InvalidOperationException("Unexpected native chain shape.");
|
||||
IntPtr simple=Marshal.ReadIntPtr(c.Entries);if(simple==IntPtr.Zero)throw new InvalidOperationException("Native simple chain is absent.");
|
||||
ChainPrefix s=(ChainPrefix)Marshal.PtrToStructure(simple,typeof(ChainPrefix));
|
||||
if(s.Size<24 || s.Count!=1 || s.Entries==IntPtr.Zero || s.Error!=c.Error)throw new InvalidOperationException("Unexpected native simple chain shape.");
|
||||
return new ChainResult {Flags=OfflineFlags,ErrorStatus=c.Error,InfoStatus=c.Info,Chains=c.Count,Elements=s.Count};
|
||||
} finally {if(chain!=IntPtr.Zero)CertFreeCertificateChain(chain);CertFreeCertificateContext(certificate);}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# Fixed local operation. The parent bounds this process to twenty seconds.
|
||||
param([Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{32}$')][string]$Nonce)
|
||||
$ErrorActionPreference='Stop'
|
||||
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
|
||||
. (Join-Path $PSScriptRoot 'WmiProbe.ps1')
|
||||
. (Join-Path $PSScriptRoot 'Capi2Probe.ps1')
|
||||
Initialize-WelaCapi2ProbeNative
|
||||
$before=[Wela.WmiProbe.Native]::Snapshot()
|
||||
$key=$null;$rsa=$null;$certificate=$null
|
||||
try {
|
||||
$key=[Wela.Capi2Probe.Native]::CreateEphemeralRsa()
|
||||
if(-not $key.IsEphemeral -or $key.KeyName){throw 'The generated CNG key is not ephemeral.'}
|
||||
$rsa=[Security.Cryptography.RSACng]::new($key)
|
||||
$request=[Security.Cryptography.X509Certificates.CertificateRequest]::new(('CN=WelaCapi2Probe_'+$Nonce),$rsa,[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1)
|
||||
$now=[DateTimeOffset][Wela.WmiProbe.Native]::UtcNow()
|
||||
$generator=[Security.Cryptography.X509Certificates.X509SignatureGenerator]::CreateForRSA($rsa,[Security.Cryptography.RSASignaturePadding]::Pkcs1)
|
||||
$certificate=$request.Create($request.SubjectName,$generator,$now.AddMinutes(-5),$now.AddMinutes(5),[guid]::NewGuid().ToByteArray())
|
||||
if($certificate.HasPrivateKey){throw 'Only a public certificate is expected.'}
|
||||
$der=$certificate.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert)
|
||||
$started=[Wela.WmiProbe.Native]::UtcNow()
|
||||
$chain=[Wela.Capi2Probe.Native]::Build($der)
|
||||
$completed=[Wela.WmiProbe.Native]::UtcNow()
|
||||
$after=[Wela.WmiProbe.Native]::Snapshot()
|
||||
if((Get-WelaWmiProbeTokenKey $before) -cne (Get-WelaWmiProbeTokenKey $after)){throw 'Worker token changed during the chain build.'}
|
||||
[pscustomobject]@{Nonce=$Nonce;CertificateDerBase64=[Convert]::ToBase64String($der);Thumbprint=$certificate.Thumbprint;Subject=$certificate.Subject;KeyEphemeral=$key.IsEphemeral;ProcessId=$PID;ProcessName=[IO.Path]::GetFileName((Get-Process -Id $PID).Path);StartedUtc=$started.ToString('o');CompletedUtc=$completed.ToString('o');Clock='GetSystemTimePreciseAsFileTime';BeforeToken=$before;AfterToken=$after;Chain=$chain}|ConvertTo-Json -Depth 12 -Compress
|
||||
}finally{if($certificate){$certificate.Dispose()};if($rsa){$rsa.Dispose()};if($key){$key.Dispose()}}
|
||||
@@ -0,0 +1,18 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0
|
||||
$preview=Join-Path ([IO.Path]::GetTempPath()) ('wela-capi2-whatif-'+[guid]::NewGuid().ToString('N'))
|
||||
$cases=@(
|
||||
@{Args=@('capi2-probe','-Help');Code=0;Pattern='Fixed offline'},
|
||||
@{Args=@('capi2-probe','-Capi2ProbeAction','Run','-Capi2ProbeOutputPath',$preview,'-WhatIf');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('capi2-probe','-Help','unexpected-positional-value');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('configure','-Capi2ProbeAction','Run','-Auto');Code=1;Pattern='require capi2-probe'},
|
||||
@{Args=@('wmi-auditing','-Capi2ProbeAction','Run');Code=1;Pattern='require capi2-probe'},
|
||||
@{Args=@('capi2-probe','-Help','-WmiAction','Configure');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('capi2-probe','-Help','-Auto');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('capi2-probe','-Help','-DryRun');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('capi2-probe','-Help','-ResultsPath','unused');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('capi2-probe','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('capi2-probe','-Capi2ProbeAction','Run');Code=1;Pattern='new Capi2ProbeOutputPath'})
|
||||
foreach($case in $cases){$ErrorActionPreference='Continue';$output=& $engine -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') @($case.Args) 2>&1|Out-String;$code=$LASTEXITCODE;$ErrorActionPreference='Stop';if($code -ne $case.Code -or $output -notmatch $case.Pattern){throw "CLI failed: $($case.Args -join ' ') [$code] $output"};$count++}
|
||||
if(Test-Path -LiteralPath $preview){throw 'Unsupported preview created a probe output directory.'};$count++
|
||||
Write-Host "PASS: $count CAPI2 probe public CLI checks."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,65 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/WmiProbe.ps1"
|
||||
. "$repo/scripts/Capi2Probe.ps1"
|
||||
$count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Reject([scriptblock]$Action,$Message){$failed=$false;try{&$Action|Out-Null}catch{$failed=$true};Assert $failed $Message}
|
||||
function Clone($Value){ConvertFrom-WelaArrivalJson ($Value|ConvertTo-Json -Depth 24)}
|
||||
$sources=Get-WelaCapi2ProbeSources|ConvertFrom-Json
|
||||
Assert ($sources.'scripts/CustomAuditProfiles.ps1' -ceq (Get-FileHash -LiteralPath "$repo/scripts/CustomAuditProfiles.ps1" -Algorithm SHA256).Hash.ToLowerInvariant()) 'Strict worker-receipt parser implementation is included in source identity.'
|
||||
$nonce='0123456789abcdef0123456789abcdef';$now=[DateTime]::UtcNow
|
||||
$token=[pscustomobject]@{Sid='S-1-5-21-1-2-3-1001';Name='HOST\user';AuthenticationId='0x1234';AuthenticationType='NTLM';Groups=@([pscustomobject]@{Sid='S-1-5-32-545';Attributes=7});Privileges=@()}
|
||||
$state=[pscustomobject]@{Computer='HOST';Services=@([pscustomobject]@{Name='CryptSvc';Status='Running'},[pscustomobject]@{Name='EventLog';Status='Running'},[pscustomobject]@{Name='Winmgmt';Status='Running'});Host=[pscustomobject]@{Computer='HOST';Build=20348;UBR=1;ProductType=3;DomainJoined=$false;Domain='WORKGROUP'};Token=$token;Channel=[pscustomobject]@{Name='Microsoft-Windows-CAPI2/Operational';Enabled=$true;SecurityDescriptor='O:SYG:SYD:(A;;1;;;SY)';Type='Operational';Provider='Microsoft-Windows-CAPI2'};Provider=[pscustomobject]@{Name='Microsoft-Windows-CAPI2';Guid='5bbca4a8-b209-48dc-a8c7-b23d3e5216fb';Event11Versions=@(0);LogNames=@('Microsoft-Windows-CAPI2/Operational')}}
|
||||
if([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT){$rsa=[Security.Cryptography.RSACng]::new(2048)}else{$rsa=[Security.Cryptography.RSA]::Create();$rsa.KeySize=2048};$cert=$null
|
||||
try{
|
||||
$request=[Security.Cryptography.X509Certificates.CertificateRequest]::new(('CN=WelaCapi2Probe_'+$nonce),$rsa,[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1)
|
||||
$cert=$request.CreateSelfSigned(([DateTimeOffset]$now).AddMinutes(-5),([DateTimeOffset]$now).AddMinutes(5))
|
||||
$operation=[pscustomobject]@{Nonce=$nonce;CertificateDerBase64=[Convert]::ToBase64String($cert.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert));Subject=$cert.Subject;Thumbprint=$cert.Thumbprint;KeyEphemeral=$true;ProcessId=5678;ProcessName='pwsh.exe';StartedUtc=$now.AddSeconds(-1).ToString('o');CompletedUtc=$now.AddSeconds(1).ToString('o');Clock='GetSystemTimePreciseAsFileTime';RecordIdBefore=10;BeforeToken=$token;AfterToken=$token;Chain=[pscustomobject]@{Flags=2147492100;ErrorStatus=32;Chains=1;Elements=1}}
|
||||
$der=Assert-WelaCapi2ProbeCertificate $operation $nonce;Assert ($der.Length -gt 128) 'Generated test DER is validated.'
|
||||
}finally{if($cert){$cert.Dispose()};$rsa.Dispose()}
|
||||
$bad=Clone $operation;$bad.CertificateDerBase64=[Convert]::ToBase64String(([byte[]]([Convert]::FromBase64String($operation.CertificateDerBase64)+[byte[]]@(0))));Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} 'Reject trailing data after the actual certificate DER.'
|
||||
foreach($field in @('Nonce','Subject','Thumbprint','CertificateDerBase64')){$bad=Clone $operation;$bad.$field='wrong';Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} "Reject certificate $field mismatch"}
|
||||
foreach($value in @($false,'true',$null)){$bad=Clone $operation;$bad.KeyEphemeral=$value;Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} 'Ephemeral key evidence must be true Boolean.'}
|
||||
foreach($field in @('Flags','ErrorStatus','Chains','Elements')){$bad=Clone $operation;$bad.Chain.$field=0;Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} "Reject unexpected native chain $field"}
|
||||
$bad=Clone $operation;$bad.CompletedUtc=$now.AddMinutes(20).ToString('o');Reject {Assert-WelaCapi2ProbeCertificate $bad $nonce} 'Certificate must cover operation.'
|
||||
Assert ([bool](Get-WelaCapi2ProbeStateKey $state)) 'Exact observed prerequisites accepted.'
|
||||
foreach($edit in @({param($s)$s.Services[0].Status='Stopped'},{param($s)$s.Services=@()},{param($s)$s.Host.Build=19045},{param($s)$s.Host.UBR=$null},{param($s)$s.Host.ProductType=1},{param($s)$s.Host.Computer='OTHER'},{param($s)$s.Channel.Enabled=$false},{param($s)$s.Channel.Enabled='true'},{param($s)$s.Channel.Type='Analytical'},{param($s)$s.Channel.Provider='Other'},{param($s)$s.Channel.SecurityDescriptor=$null},{param($s)$s.Provider.Guid=[guid]::Empty.ToString()},{param($s)$s.Provider.Event11Versions=@(1)},{param($s)$s.Provider.Event11Versions=@(0,0)},{param($s)$s.Provider.LogNames=@('Security')})){$bad=Clone $state;&$edit $bad;Reject {Get-WelaCapi2ProbeStateKey $bad} 'Reject incomplete or unsupported prerequisites.'}
|
||||
$xml=@"
|
||||
<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"><System><Provider Name="Microsoft-Windows-CAPI2" Guid="{5bbca4a8-b209-48dc-a8c7-b23d3e5216fb}"/><EventID>11</EventID><Version>0</Version><Level>2</Level><Task>11</Task><Opcode>2</Opcode><Keywords>0x4000000000000003</Keywords><TimeCreated SystemTime="$($now.ToString('o'))"/><EventRecordID>11</EventRecordID><Execution ProcessID="5678" ThreadID="1"/><Channel>Microsoft-Windows-CAPI2/Operational</Channel><Computer>HOST</Computer><Security UserID="$($token.Sid)"/></System><UserData><CertGetCertificateChain><Certificate fileRef="$($operation.Thumbprint).cer" subjectName="WelaCapi2Probe_$nonce"/><ExtendedKeyUsage/><URLRetrievalTimeout>PT1S</URLRetrievalTimeout><Flags value="80002104" CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL="true" CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY="true" CERT_CHAIN_DISABLE_AUTH_ROOT_AUTO_UPDATE="true" CERT_CHAIN_DISABLE_AIA="true"/><ChainEngineInfo context="user"/><CertificateChain><TrustStatus><ErrorStatus value="20"/></TrustStatus><ChainElement><Certificate fileRef="$($operation.Thumbprint).cer" subjectName="WelaCapi2Probe_$nonce"/><SignatureAlgorithm oid="1.2.840.113549.1.1.11" hashName="SHA256" publicKeyName="RSA"/><PublicKeyAlgorithm oid="1.2.840.113549.1.1.1" publicKeyLength="2048"/><TrustStatus><ErrorStatus value="20"/></TrustStatus><ApplicationUsage any="true"/><IssuanceUsage any="true"/></ChainElement></CertificateChain><EventAuxInfo ProcessName="pwsh.exe"/><CorrelationAuxInfo TaskId="{00000000-0000-0000-0000-000000000001}" SeqNumber="3"/><Result value="800B0109"/></CertGetCertificateChain></UserData></Event>
|
||||
"@
|
||||
Assert (Test-WelaCapi2ProbeEvent $xml $operation $state) 'Exact source/certificate/PID/token/time/chain fixture matches.'
|
||||
$changes=@(
|
||||
@('Name="Microsoft-Windows-CAPI2"','Name="Other"'),@('5bbca4a8-b209-48dc-a8c7-b23d3e5216fb','00000000-0000-0000-0000-000000000000'),@('<EventID>11</EventID>','<EventID>70</EventID>'),@('<Version>0</Version>','<Version>1</Version>'),@('<Level>2</Level>','<Level>4</Level>'),@('<Task>11</Task>','<Task>10</Task>'),@('<Opcode>2</Opcode>','<Opcode>1</Opcode>'),@('0x4000000000000003','0x4000000000000001'),@('<EventRecordID>11</EventRecordID>','<EventRecordID>10</EventRecordID>'),@('<EventRecordID>11</EventRecordID>','<EventRecordID>x</EventRecordID>'),@('ProcessID="5678"','ProcessID="5679"'),@( ('UserID="'+$token.Sid+'"'), 'UserID="S-1-5-18"'),@('<Computer>HOST</Computer>','<Computer>OTHER</Computer>'),@('80002104','80000104'),@('800B0109','0'),@('value="20"','value="0"'),@('context="user"','context="machine"'),@('ProcessName="pwsh.exe"','ProcessName="other.exe"'),@($operation.Thumbprint,('0'*40)),@($nonce,('f'*32)),@('<UserData>','<UserData><Other/>'),@('<CertGetCertificateChain>','<CertGetCertificateChain xmlns="urn:other">'),@('<Version>0</Version>','<Version>0</Version><Version>0</Version>'),@('<ExtendedKeyUsage/>','<ExtendedKeyUsage/><ExtendedKeyUsage/>'),@('PT1S','PT2S'),@('CERT_CHAIN_DISABLE_AIA="true"','CERT_CHAIN_DISABLE_AIA="false"'),@('<ChainEngineInfo','<AdditionalStore/><ChainEngineInfo'),@('publicKeyLength="2048"','publicKeyLength="1024"'),@('hashName="SHA256"','hashName="SHA1"'),@('<ApplicationUsage','<RevocationInfo/><ApplicationUsage'),@('</UserData>','</UserData><EventData/>'),@('<Event xmlns=','<!DOCTYPE Event [<!ENTITY test "x">]><Event xmlns=')
|
||||
)
|
||||
foreach($pair in $changes){Assert (-not(Test-WelaCapi2ProbeEvent ($xml.Replace($pair[0],$pair[1])) $operation $state)) ('Reject altered XML '+$pair[0])}
|
||||
foreach($time in @($now.AddSeconds(-2).ToString('o'),$now.AddSeconds(2).ToString('o'),$now.ToString('yyyy-MM-ddTHH:mm:ss'),$now.ToString('yyyy-MM-ddTHH:mm:ss')+'+00:00')){Assert (-not(Test-WelaCapi2ProbeEvent ($xml.Replace($now.ToString('o'),$time)) $operation $state)) 'Reject outside or ambiguous UTC.'}
|
||||
foreach($time in @($operation.StartedUtc,$operation.CompletedUtc)){Assert (Test-WelaCapi2ProbeEvent ($xml.Replace($now.ToString('o'),$time)) $operation $state) 'Accept exact inclusive operation boundary.'}
|
||||
$null=Assert-WelaWmiProbeInterval $operation ([DateTimeOffset]$now.AddSeconds(-2)) ([DateTimeOffset]$now.AddSeconds(2));$count++
|
||||
$bad=Clone $operation;$bad.Clock='UtcNow';Reject {Assert-WelaWmiProbeInterval $bad ([DateTimeOffset]$now.AddSeconds(-2)) ([DateTimeOffset]$now.AddSeconds(2))} 'Require precise native clock.'
|
||||
Reject {Invoke-WelaCapi2Probe -Action Run} 'Run requires a new private output path.'
|
||||
Reject {Invoke-WelaCapi2Probe -Action Plan -OutputPath unused} 'Plan creates no files.'
|
||||
# Lifecycle fixtures test failure receipts and no-operation planning independently of Windows telemetry.
|
||||
$script:FixtureState=$state;$script:FixtureOperation=$operation;$script:FixtureXml=$xml;$script:FixtureMode='success';$script:FixtureStateReads=0;$script:FixtureActions=0
|
||||
function Get-WelaCapi2ProbeState {$script:FixtureStateReads++;$value=Clone $script:FixtureState;if($script:FixtureMode -eq 'drift' -and $script:FixtureStateReads -gt 1){$value.Host.UBR++};$value}
|
||||
function Get-WelaCapi2ProbeWatermark {if($script:FixtureMode -eq 'denied'){throw 'Reader denied.'};if($script:FixtureMode -eq 'rollback' -and $script:FixtureActions){return [long]0};[long]11}
|
||||
function Start-WelaCapi2ProbeBuild {param($State);$script:FixtureActions++;if($script:FixtureMode -eq 'worker'){throw 'Bounded worker failed.'};Clone $script:FixtureOperation}
|
||||
function Read-WelaCapi2ProbeEvents {param($Operation);$items=@($script:FixtureXml);if($script:FixtureMode -eq 'none'){$items=@($script:FixtureXml.Replace('800B0109','0'))};if($script:FixtureMode -eq 'duplicate'){$items=@($script:FixtureXml,$script:FixtureXml)};[pscustomobject]@{Xml=$items;Capped=($script:FixtureMode -eq 'cap');Query='fixed-fixture';MaximumEvents=64}}
|
||||
$planned=Invoke-WelaCapi2Probe
|
||||
Assert ($planned.Status -eq 'PrerequisitesObserved' -and $script:FixtureActions -eq 0 -and -not $planned.OutputPath) 'Plan observes prerequisites without operation or files.'
|
||||
$private=Join-Path ([IO.Path]::GetTempPath()) ('wela-capi2-fixture-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $private
|
||||
try{
|
||||
foreach($mode in @('success','none','duplicate','cap','drift','rollback','worker','denied')){
|
||||
$script:FixtureMode=$mode;$script:FixtureStateReads=0;$script:FixtureActions=0
|
||||
$out=Join-Path $private $mode;$result=Invoke-WelaCapi2Probe -Action Run -OutputPath $out -TimeoutSeconds 1
|
||||
if($mode -eq 'success'){Assert ($result.Status -eq 'LocalChainEventObserved' -and $result.ExitCode -eq 0 -and (Test-Path "$out/event.xml")) 'Success retains one exact matched event.'}
|
||||
else{Assert ($result.Status -eq 'Unverified' -and $result.ExitCode -eq 1 -and $result.Diagnostic) ('Failure remains explicit: '+$mode)}
|
||||
Assert (Test-Path "$out/manifest.json") 'Every started bundle retains its manifest.'
|
||||
Assert ($script:FixtureActions -le 1 -and $result.ChannelChanges -eq 0 -and $result.StoreChanges -eq 0 -and $result.TrustPolicyChanges -eq 0 -and $result.ReadyRuleCredit -eq 0) 'No operation retry or configuration/coverage credit.'
|
||||
foreach($artifact in $result.Artifacts){Assert ($artifact.Sha256 -ceq (Get-FileHash -LiteralPath (Join-Path $out $artifact.Name)).Hash.ToLowerInvariant()) 'Lifecycle artifact hash matches.'}
|
||||
}
|
||||
Reject {Invoke-WelaCapi2Probe -Action Run -OutputPath (Join-Path $private 'success')} 'Existing evidence cannot be overwritten.'
|
||||
}finally{Remove-Item -LiteralPath $private -Recurse -Force}
|
||||
Write-Host "PASS: $count portable CAPI2 assertions. No native event proof is claimed."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,62 @@
|
||||
# Genuine public fixed probe. Only the disposable fixture may toggle the channel.
|
||||
param([switch]$AllowDisposableChannelWrite,[ValidateRange(1,3)][int]$ProbeRuns=3)
|
||||
$ErrorActionPreference='Stop'
|
||||
if(-not $AllowDisposableChannelWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Explicit disposable GitHub-hosted Windows opt-in required.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/ChannelRead.ps1"
|
||||
. "$repo/scripts/WmiProbe.ps1"
|
||||
. "$repo/scripts/Capi2Probe.ps1"
|
||||
$hostState=Get-WelaChannelReadHost
|
||||
if($hostState.ProductType -ne 3 -or $hostState.DomainRole -ne 2 -or $hostState.DomainJoined -or $hostState.Build -notin @(20348,26100)){throw 'A disposable standalone Server 2022/2025 is required.'}
|
||||
$script:count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Key($Value){ConvertTo-Json -InputObject $Value -Depth 16 -Compress}
|
||||
function Read-Stores {
|
||||
$result=[ordered]@{}
|
||||
foreach($location in @('CurrentUser','LocalMachine')){foreach($name in @('My','Root','CertificateAuthority')){
|
||||
$store=[Security.Cryptography.X509Certificates.X509Store]::new([Security.Cryptography.X509Certificates.StoreName]$name,[Security.Cryptography.X509Certificates.StoreLocation]$location)
|
||||
try{$store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly -bor [Security.Cryptography.X509Certificates.OpenFlags]::OpenExistingOnly);$certificates=$store.Certificates;try{$result[$location+'/'+$name]=@($certificates|ForEach-Object Thumbprint|Sort-Object)}finally{foreach($c in $certificates){$c.Dispose()}}}finally{$store.Dispose()}
|
||||
}}
|
||||
[pscustomobject]$result
|
||||
}
|
||||
$engine=(Get-Process -Id $PID).Path;$original=Get-WelaCapi2ProbeChannel;$originalStores=Read-Stores
|
||||
$root=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-capi2-native-'+[guid]::NewGuid().ToString('N'))) $PSScriptRoot
|
||||
$null=Write-WelaArrivalArtifact $root 'channel-original.json' ($original|ConvertTo-Json)
|
||||
$null=Write-WelaArrivalArtifact $root 'stores-original.json' ($originalStores|ConvertTo-Json -Depth 8)
|
||||
$failure=$null;$cleanupErrors=@();$nonces=@();$thumbprints=@();$changed=$false;$channelRestored=$false;$storesPreserved=$false
|
||||
try{
|
||||
$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new($original.Name)
|
||||
try{if(-not $channel.IsEnabled){$changed=$true;$channel.IsEnabled=$true;$channel.SaveChanges()}}finally{$channel.Dispose()}
|
||||
$enabled=Get-WelaCapi2ProbeChannel
|
||||
$expected=$original|ConvertTo-Json|ConvertFrom-Json;$expected.Enabled=$true
|
||||
Assert ((Key $enabled) -ceq (Key $expected)) 'Fixture changed only channel Enabled.'
|
||||
for($trial=1;$trial -le $ProbeRuns;$trial++){
|
||||
$out=Join-Path $root ('probe-'+$trial)
|
||||
$old=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$cli=&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" capi2-probe -Capi2ProbeAction Run -Capi2ProbeOutputPath $out -Capi2ProbeTimeoutSeconds 15 2>&1|Out-String;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old}
|
||||
if(-not(Test-Path "$out/manifest.json")){throw ('Public probe did not retain a manifest: '+$cli)}
|
||||
$manifest=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText("$out/manifest.json"))
|
||||
Write-Host ($manifest|ConvertTo-Json -Depth 24)
|
||||
foreach($file in @(Get-ChildItem -LiteralPath $out -Filter '*.xml')){Write-Host ([IO.File]::ReadAllText($file.FullName))}
|
||||
Assert ($code -eq 0 -and $manifest.Status -eq 'LocalChainEventObserved' -and $manifest.ExitCode -eq 0) ('Actual CAPI2 probe failed: '+$manifest.Diagnostic+' '+$cli)
|
||||
Assert ($manifest.Matches -eq 1 -and $manifest.ChannelChanges -eq 0 -and $manifest.StoreChanges -eq 0 -and $manifest.TrustPolicyChanges -eq 0 -and $manifest.ReadyRuleCredit -eq 0) 'Bounded event evidence grants no configuration or Sigma claim.'
|
||||
Assert ($manifest.Operation.Nonce -notin $nonces -and $manifest.Operation.Thumbprint -notin $thumbprints) 'Independent public invocation generated a fresh nonce and certificate.'
|
||||
$nonces+=$manifest.Operation.Nonce;$thumbprints+=$manifest.Operation.Thumbprint
|
||||
$der=Assert-WelaCapi2ProbeCertificate $manifest.Operation $manifest.Operation.Nonce
|
||||
Assert ((Get-WelaArrivalHash $der) -ceq $manifest.Operation.CertificateSha256) 'Actual DER matches retained SHA256.'
|
||||
Assert (Test-WelaCapi2ProbeEvent ([IO.File]::ReadAllText("$out/event.xml")) $manifest.Operation $manifest.Before) 'Actual event11 matches certificate, nonce, PID, SID, UTC, offline flags and expected chain outcome.'
|
||||
foreach($artifact in $manifest.Artifacts){Assert ($artifact.Sha256 -ceq (Get-FileHash -LiteralPath (Join-Path $out $artifact.Name)).Hash.ToLowerInvariant()) 'Artifact hash verifies.'}
|
||||
Assert ((Key (Get-WelaCapi2ProbeChannel)) -ceq (Key $enabled)) 'Public probe preserved channel configuration.'
|
||||
Assert ((Key (Read-Stores)) -ceq (Key $originalStores)) 'CurrentUser and LocalMachine My/Root/CA certificate inventories preserved.'
|
||||
Assert ((Get-Acl -LiteralPath $out).AreAccessRulesProtected) 'Private evidence blocks inherited broad access.'
|
||||
}
|
||||
}catch{$failure=$_}
|
||||
finally{
|
||||
try{$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new($original.Name);try{if($channel.IsEnabled -ne $original.Enabled){$channel.IsEnabled=$original.Enabled;$channel.SaveChanges()}}finally{$channel.Dispose()};$restored=Get-WelaCapi2ProbeChannel;$null=Write-WelaArrivalArtifact $root 'channel-restored.json' ($restored|ConvertTo-Json);if((Key $restored) -cne (Key $original)){throw 'Original channel configuration was not restored.'};$channelRestored=$true}catch{$cleanupErrors+='Channel restoration: '+$_.Exception.Message}
|
||||
try{$storesAfter=Read-Stores;$null=Write-WelaArrivalArtifact $root 'stores-after.json' ($storesAfter|ConvertTo-Json -Depth 8);if((Key $storesAfter) -cne (Key $originalStores)){throw 'Certificate store inventory changed.'};$storesPreserved=$true}catch{$cleanupErrors+='Store observation: '+$_.Exception.Message}
|
||||
$null=Write-WelaArrivalArtifact $root 'cleanup.json' ([pscustomobject]@{ChangedEnabled=$changed;Failure=$(if($failure){$failure.Exception.Message}else{$null});CleanupErrors=$cleanupErrors;ChannelRestored=$channelRestored;SelectedStoresPreserved=$storesPreserved;Complete=($null -eq $failure -and $cleanupErrors.Count -eq 0);Evidence=$root}|ConvertTo-Json)
|
||||
}
|
||||
if($failure){throw $failure};if($cleanupErrors.Count){throw ($cleanupErrors -join '; ')}
|
||||
Write-Host "PASS: $script:count actual CAPI2 assertions across $ProbeRuns independent public runs; original channel restored and selected certificate inventories preserved."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
**改善:**
|
||||
|
||||
- 固定のオフライン一時証明書チェーン構築とローカル CAPI2 イベント11を確認する `capi2-probe` Plan/Run を追加。公開証明書・nonce・PID・トークン・高精度UTCによる照合、ワーカーと収集の時間制限、証拠保存に対応。既存のチャネル、証明書ストア、信頼設定を維持し、TLS、失効確認、リモート転送、Sigma の検証実績は付与しません。
|
||||
|
||||
- `transcription-recovery` を追加し、完了した Windows PowerShell 文字起こし設定を、再構築したハッシュ付き計画から型を保持して復元できるようにしました。ローカル保存先、ユーザー・ヘッダー・他のポリシーを確認し、一時停止は明示的な同意を求め、変更順序と途中結果を永続記録します。ヘルプと復旧手順には、一時停止中のエラーや中断でマシンの文字起こしが無効のまま残り、自動ロールバックや再有効化を行わないことを明記しました。使い捨て環境の実 CLI テストで復元とドリフト拒否を検証し、本番セッション・集中管理先の権限と収集・Sigma 対応は未検証とします。 (#376) (@Shirofune-Security)
|
||||
|
||||
- 完了したログ容量・保持モード設定を1件ずつ戻す `eventlog-recovery` を追加しました。元の記録と変更直前の記録、現在のチャネル・実行環境・コードを照合し、縮小と保持モード変更には個別の明示指定を必要とします。永続記録とネイティブ読戻しで無関係な設定を保持し、状態変化や再適用を拒否します。失われたイベント、長期保持、Sigma 利用可能性の証明は加算しません。 (@Shirofune-Security)
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
**Improvements:**
|
||||
|
||||
- Added explicit `capi2-probe` Plan/Run for a fixed offline ephemeral certificate-chain build and exact local CAPI2 event 11 evidence, with public certificate/nonce/PID/token/precise-UTC binding, bounded worker/collection and retained artifacts. Existing channel, certificate-store and trust configuration are preserved; no TLS, revocation, remote delivery or Sigma credit is claimed.
|
||||
|
||||
- Added explicit `transcription-recovery` for one completed Windows PowerShell transcription configuration, with independently rebuilt hashed plans, typed local-directory restoration, preserved user/header/other policy, explicit temporary-suspension consent and durable ordered receipts. Help and recovery guidance warn that interrupted suspension can leave machine transcription disabled without automatic rollback or re-enable. Disposable native public-CLI tests verify restoration and drift refusal; production sessions, central authorization/collection and Sigma readiness remain unverified. (#376) (@Shirofune-Security)
|
||||
|
||||
- Added reviewed `eventlog-recovery` for one completed profile size/retention write. Matched original and immediate-prewrite evidence, current channel/context/source guards, separate shrink/retention consent, durable pending receipts and native readback preserve unrelated channel settings and refuse drift or replay. Windows fixtures restore original settings; lost events, sustained retention and Sigma readiness are not inferred. (@Shirofune-Security)
|
||||
|
||||
Reference in new issue
Block a user