mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-06 14:34:43 +02:00
Measure bounded native event delivery and verify exact EVTX samples (#430)
* Add bounded local delivery measurement and exact EVTX samples * Link delivery measurement changelog to PR 430 * Reject evidence aliases before Windows path normalization * Use PowerShell 5.1-compatible record IDs and bound fixture cleanup * Revalidate the native EVTX artifact before recording final evidence * Require exact observed local computer identities for sampled events * Clarify provider scope within shared built-in event channels * Preserve mixed XML payload ordering in EVTX sample verification * Bound ordered event XML comparisons for nested UserData * Dispose observer wait handle when bookmark creation fails
This commit is contained in:
1 parent
bcd4e9717e
commit
2fd37d0318
13 files changed
+751
No files matched your search
@@ -39,6 +39,10 @@ modules/WecSubscriptionXml.cs text eol=lf
|
||||
scripts/AppLockerProbe.ps1 text eol=lf
|
||||
tests/AppLockerProbe*.ps1 text eol=lf
|
||||
|
||||
# Local delivery catalog and native observer retain reproducible source bytes.
|
||||
config/event_measurement.json text eol=lf
|
||||
scripts/EventMeasurement* text eol=lf
|
||||
tests/EventMeasurement* text eol=lf
|
||||
tests/SelectedSaclFixtureProtection.cs text eol=lf
|
||||
# Fixed local WMI probe source/worker fingerprints.
|
||||
/scripts/WmiProbe*.ps1 text eol=lf
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Local event delivery measurement
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
paths:
|
||||
- 'WELA.ps1'
|
||||
- 'scripts/EventMeasurement*'
|
||||
- 'scripts/EvtxRecovery.ps1'
|
||||
- 'scripts/ControlApplicability.ps1'
|
||||
- 'scripts/NativeValidation.ps1'
|
||||
- 'config/event_measurement.json'
|
||||
- 'tests/EventMeasurement*'
|
||||
- '.github/workflows/event-measurement.yml'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
native-delivery:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-2022, windows-2025]
|
||||
engine: [powershell, pwsh]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 12
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Fixtures and native callback/export proof (Windows PowerShell 5.1)
|
||||
if: matrix.engine == 'powershell'
|
||||
shell: powershell
|
||||
run: |
|
||||
./tests/EventMeasurement.Tests.ps1
|
||||
./tests/EventMeasurement.Windows.Tests.ps1 -AllowDisposablePolicyWrite
|
||||
- name: Fixtures and native callback/export proof (PowerShell 7)
|
||||
if: matrix.engine == 'pwsh'
|
||||
shell: pwsh
|
||||
run: |
|
||||
./tests/EventMeasurement.Tests.ps1
|
||||
./tests/EventMeasurement.Windows.Tests.ps1 -AllowDisposablePolicyWrite
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
**改善:**
|
||||
|
||||
- `event-measurement`を追加。明示した組み込みの管理・運用チャネル1つを一定時間だけ観測し、単調時計によるコールバック到着時間、元のXML・ブックマーク、非公開の証拠、ネイティブEVTXサンプルの厳密な再読み取りを記録します。上限超過、欠落・古い記録、状態変化、不完全なエクスポートは未検証とし、サンプルのバイト数からログ増加量・保持容量・バックエンド到達・Sigma検知可能性を推定しません。 (#430) (@Shirofune-Security)
|
||||
- `targeted-sacl`で子孫への継承を明示的に許可した場合、件数・深さを制限した子孫一覧と記述子を計画・変更直前に照合し、変更前の記録、保護された子孫の保持、各オブジェクトの継承結果を確認するようにしました。上限超過、読み取り拒否、リンク、子孫の追加・消失・変更は処理を停止または失敗として記録し、親のみの既存動作は保持します。使い捨てファイル/レジストリ階層で継承と保護を検証し、子孫ACEの所有権、一括復旧、Sigma利用可能性は主張しません。 (#429) (@Shirofune-Security)
|
||||
- 固定のローカル名前空間読み取りを行う任意実行の `wmi-probe` を追加しました。実トークン・監査ポリシー・完全な SACL を観測し、WMI Security4662 を厳密に照合して、容量制限付きの非公開 XML とコードの指紋を記録します。本番の名前空間やポリシーは変更せず、Sigma の評価には加算しません。WMI 接続は明示的に管理するセキュリティ特権だけを使用し、意図しないスレッド特権の有効化を防ぎます。両 PowerShell エンジンの使い捨て Server 2022/2025 テストで実際のローカル 4662 と監査設定・名前空間の復元を確認しました。リモートアクセス、プロバイダー処理の成否、個々のクエリへの排他的な帰属は未検証です。 (#428) (@Shirofune-Security)
|
||||
- 正規バックアップと現在の WELA 監査コンポーネントを照合し、新規・無効・未リンクの GPO のみを作成する `gpo-create` の Review / Plan / Create を追加しました。実ファイルとネイティブレポートの厳密な検証、明示的なドメイン/書き込み可能 DC、変更しない保護付きバックアップコピー、永続 GUID 記録、内容・無効状態・権限・リンク・バージョンの直前/最終確認で既存ポリシーを保護します。Windows テストは Microsoft の固定バックアップの読み取りと対象外ポリシー/ワークグループの拒否を確認し、実 AD/SYSVOL への正常インポートとクライアント/イベントの受け入れ検証は別途必要です。適用や Sigma の有効性は主張しません。(#427) (@Shirofune-Security)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
**Improvements:**
|
||||
|
||||
- Added opt-in `event-measurement` for bounded local callback-delivery windows on one explicit built-in Administrative/Operational channel, with monotonic timing, original XML/bookmarks, private evidence and exact native EVTX sample reopening. Caps, missing/stale records, source drift and incomplete exports remain unverified; sample-file bytes do not imply channel growth, retention capacity, backend ingestion or Sigma readiness. (#430) (@Shirofune-Security)
|
||||
- Extended explicit `targeted-sacl` child consent with bounded reviewed descendant inventories, fresh preflight/pre-write checks, durable child snapshots, protected-subtree preservation and per-child native inheritance outcomes. Caps, denials, links, new/disappeared children and drift block or fail the run; parent-only behavior stays unchanged. Disposable populated file/registry tests verify inheritance and protection without child-ACE ownership, bulk rollback or Sigma credit. (#429) (@Shirofune-Security)
|
||||
|
||||
- Added opt-in `wmi-probe` for a fixed local namespace read with observed token, audit-policy and full SACL context, exact WMI Security4662 correlation, bounded private raw XML and source fingerprints. Production makes no namespace/policy changes and grants no Sigma credit. WMI connections now use only the explicitly scoped security privilege, avoiding unintended thread privilege expansion. Disposable Server 2022/2025 tests under both PowerShell engines verify real local 4662 events and exact policy/namespace cleanup. Remote access, provider-operation success and exclusive query attribution remain unverified. (#428) (@Shirofune-Security)
|
||||
|
||||
@@ -122,6 +122,12 @@
|
||||
[ValidateSet('Plan','Run')][string]$AppLockerProbeAction = 'Plan',
|
||||
[string]$AppLockerProbeOutputPath,
|
||||
[ValidateRange(1,30)][int]$AppLockerProbeTimeoutSeconds = 15,
|
||||
[ValidateSet('Plan','Run')][string]$MeasurementAction = 'Plan',
|
||||
[string]$MeasurementChannel,
|
||||
[ValidateRange(1,60)][int]$MeasurementSeconds = 10,
|
||||
[ValidateRange(1,1024)][int]$MeasurementMaximumEvents = 256,
|
||||
[string]$MeasurementOutputPath,
|
||||
[switch]$MeasurementExportEvtx,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
@@ -172,6 +178,7 @@ Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorActi
|
||||
. (Join-Path $ScriptRoot "scripts/GpoAuditPackages.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/IntuneAuditExport.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/EvtxRecovery.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/EventMeasurement.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/GpoCreation.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/AuditRecovery.ps1")
|
||||
|
||||
@@ -1905,6 +1912,8 @@ Usage:
|
||||
./WELA.ps1 smb-auditing -SmbAction Audit -ResultsPath smb-audit.json
|
||||
./WELA.ps1 smb-auditing -SmbAction Plan
|
||||
./WELA.ps1 rule-eligibility -ResultsPath eligibility.json -HtmlPath eligibility.html
|
||||
./WELA.ps1 event-measurement -MeasurementChannel Security
|
||||
./WELA.ps1 event-measurement -MeasurementChannel Security -MeasurementAction Run -MeasurementOutputPath C:\Evidence\new-sample -MeasurementExportEvtx
|
||||
./WELA.ps1 rule-eligibility -RuleEvidencePath reviewed-lab-evidence.json -ResultsPath evidence-review.json
|
||||
./WELA.ps1 smb-auditing -SmbAction Configure -DryRun
|
||||
./WELA.ps1 powershell-transcription -TranscriptionAction Plan -TranscriptDirectory C:\Transcripts -ResultsPath transcription-plan.json
|
||||
@@ -1955,6 +1964,8 @@ Write-Host ""
|
||||
Write-Host "WELA v$WELAVersion - $WELAReleaseName"
|
||||
Write-Host ""
|
||||
|
||||
if ($Cmd -ne 'event-measurement' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Measurement*'}).Count) {throw 'Measurement options require event-measurement. No command was run.'}
|
||||
if ($Cmd -eq 'event-measurement' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','MeasurementAction','MeasurementChannel','MeasurementSeconds','MeasurementMaximumEvents','MeasurementOutputPath','MeasurementExportEvtx','Help')}).Count) {throw 'event-measurement accepts only its dedicated options. No command was run.'}
|
||||
if ($Cmd -ne 'dns-analytical' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'Dns*' -or $_ -eq 'AllowDnsTraceReset' }).Count) {
|
||||
throw 'DNS analytical options require dns-analytical. No command was run.'
|
||||
}
|
||||
@@ -2125,6 +2136,14 @@ if ($Profile -and $Cmd.ToLower() -in @('plan', 'audit', 'audit-settings', 'confi
|
||||
}
|
||||
|
||||
switch ($Cmd.ToLower()) {
|
||||
'event-measurement' {
|
||||
if ($Help) {Write-Host 'Usage: ./WELA.ps1 event-measurement -MeasurementChannel EXACT_NAME [-MeasurementAction Plan|Run] [-MeasurementSeconds 1..60] [-MeasurementMaximumEvents 1..1024] [-MeasurementOutputPath NEW_DIRECTORY] [-MeasurementExportEvtx]. Plan reads actual source/reader state; Run records bounded local deliveries without changing logging. See docs/event-measurement.md.';return}
|
||||
if ([string]::IsNullOrWhiteSpace($MeasurementChannel)) {throw 'event-measurement requires an exact -MeasurementChannel.'}
|
||||
$measurement=Invoke-WelaEventMeasurement -Action $MeasurementAction -Channel $MeasurementChannel -Seconds $MeasurementSeconds -MaximumEvents $MeasurementMaximumEvents -OutputPath $MeasurementOutputPath -ExportEvtx:$MeasurementExportEvtx
|
||||
if ($measurement.Before) {$measurement.Before.Configuration | Format-List | Out-Host; $measurement.Before.Reader | Select-Object Computer,HostKey,Reader | Format-List | Out-Host}
|
||||
$measurement | Select-Object Action,Status,Channel,ObservedDeliveries,ObservedDeliveriesPerSecond,Evtx,Diagnostic,OutputPath | Format-List | Out-Host
|
||||
exit $measurement.ExitCode
|
||||
}
|
||||
'dns-analytical' {
|
||||
if ($Help) { Write-Host 'Usage: ./WELA.ps1 dns-analytical [-DnsAction Audit|Plan|Configure] [-DnsState Enabled|Disabled] [-DnsRetention Preserve|Circular|Retain] [-DnsMinimumBytes bytes] [-DnsArchiveMaximumBytes bytes] [-AllowDnsTraceReset] [-Auto] [-DryRun] [-BackupPath new-private-directory] [-ResultsPath new.json]. Configure requires explicit DnsState and reset consent for changes; archives stopped traces before reset. See docs/dns-analytical.md.'; return }
|
||||
$report=Invoke-WelaDnsAnalytical -Action $DnsAction -State $DnsState -Retention $DnsRetention -MinimumBytes $DnsMinimumBytes -ArchiveMaximumBytes $DnsArchiveMaximumBytes -AllowTraceReset:$AllowDnsTraceReset -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"kind": "WelaLocalDeliveryMeasurement",
|
||||
"channels": [
|
||||
"Security",
|
||||
"System",
|
||||
"Application",
|
||||
"Microsoft-Windows-DNS-Client/Operational",
|
||||
"Microsoft-Windows-CAPI2/Operational",
|
||||
"Microsoft-Windows-WinRM/Operational",
|
||||
"Microsoft-Windows-PowerShell/Operational"
|
||||
],
|
||||
"source": "https://learn.microsoft.com/en-us/windows/win32/api/winevt/nf-winevt-evtsubscribe",
|
||||
"scope": "Exact registered local Administrative/Operational channels only. ForwardedEvents, Analytic/Debug and third-party channels are excluded. Shared built-in channels may contain records from non-Microsoft providers; actual provider identity is retained. Registration, availability and reader access are verified on the actual host."
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# Bounded local event-delivery measurement
|
||||
|
||||
`event-measurement` observes callbacks from one explicitly selected local Windows event channel for 1–60 seconds. `Plan` is the read-only default. `Run` creates a new private evidence directory; it changes no channel, policy, service, provider, subscription definition or retention setting, and generates no test events.
|
||||
|
||||
```powershell
|
||||
# Read actual channel registration, configuration, log identity and reader context.
|
||||
./WELA.ps1 event-measurement -MeasurementChannel Security
|
||||
|
||||
# Observe ten seconds, with at most 256 retained events, and verify a native EVTX sample.
|
||||
./WELA.ps1 event-measurement -MeasurementAction Run -MeasurementChannel Security `
|
||||
-MeasurementSeconds 10 -MeasurementMaximumEvents 256 `
|
||||
-MeasurementOutputPath C:\Evidence\new-security-sample -MeasurementExportEvtx
|
||||
```
|
||||
|
||||
The exact selectable channels are `Security`, `System`, `Application`, `Microsoft-Windows-DNS-Client/Operational`, `Microsoft-Windows-CAPI2/Operational`, `Microsoft-Windows-WinRM/Operational` and `Microsoft-Windows-PowerShell/Operational`. The actual channel must be registered, enabled and Administrative or Operational, and the current native 64-bit Windows reader must have access. Each event computer name must exactly match `MachineName` or local hostname/domain names obtained from native `IPGlobalProperties`; these observed names are recorded in the reader snapshot. A different domain sharing the same short hostname is not accepted. No DNS query is performed. A missing feature, disabled channel, denied read or unavailable identity is **Unverified**, with a nonzero exit; this command does not enable it. Administrative rights alone do not guarantee channel access. Select only a channel whose event contents you are authorized to retain.
|
||||
|
||||
`ForwardedEvents` is excluded because collector record IDs and original source XML require a separate validated mapping. Remote sources, arbitrary/wildcard channels, Sysmon and other third-party channels, and Analytic/Debug traces are excluded. System and Application are shared built-in channels: their measurement includes all locally sourced records delivered there, including records from non-Microsoft providers. The original provider identity is retained; selecting a built-in channel does not assert a Microsoft-only producer set. `-DryRun`, hypothetical `-Role`/`-Build`, configuration options and unrelated command options are rejected. Use `Plan` for read-only preflight.
|
||||
|
||||
## What is measured
|
||||
|
||||
The native observer uses local `EvtSubscribe` with `EvtSubscribeToFutureEvents | EvtSubscribeStrict` and a C# callback. No PowerShell script executes on the native callback thread. Windows serializes delivery behind this callback, so its rendering/bookmark overhead is part of this observer's workload; the result is not an independent benchmark of the producer. Once registration returns, a `Stopwatch` starts the observation window. Callbacks arriving before that window are excluded and counted separately. Each retained event records its monotonic offset at serialized callback processing, original rendered XML and a native bookmark. The interval ends at the requested monotonic deadline; registration and shutdown/serialization are outside the rate denominator. UTC start/completion values aid correlation but do not replace the monotonic clock.
|
||||
|
||||
`ObservedDeliveriesPerSecond` is the retained callback count divided by that completed interval. It is **not** a producer-generation rate, event `TimeCreated` density, causal latency, sustained throughput capacity, collector/backend ingestion rate or evidence of losslessness. Events generated before the window can be delivered during it; callbacks queued until after its deadline are outside it. Native subscription diagnostics, consecutive local record IDs, source snapshots and EVTX readback detect some inconsistencies. None proves that every upstream event was generated or delivered: `LossAssessment` remains **Unknown** even for a successful window.
|
||||
|
||||
The cap is 1–1024 events, one MiB of UTF-8 XML per event and sixteen MiB per batch. Semantic comparison permits at most 64 nested elements in each System or payload tree; ordered node hashes keep comparison data bounded for deeply nested payloads. Merely reaching the selected event count is allowed; observing an additional callback within the window produces `EventCapExceeded`. Native errors (including stale/missing-record notifications), XML caps, duplicated/reordered/discontinuous IDs, invalid bookmarks, source/reader drift and observed clear/reset indicators retain available partial evidence and suppress a valid rate. A busy source can exceed these bounds; choose a shorter interval, rather than treating a capped sample as an exact rate.
|
||||
|
||||
A zero-delivery completed window is `NoDeliveriesObserved`, exit zero, with a **null rate** and no fabricated EVTX. It establishes only that this observer retained no deliveries in its interval. Other failures are `Unverified`, exit one. A failure before an output directory can be created returns its diagnostic without claiming a durable receipt. A later failure retains the private partial bundle and diagnostic. The manifest lists hashes of saved artifacts; original XML and bookmarks remain separate files.
|
||||
|
||||
## EVTX bytes and preservation
|
||||
|
||||
`-MeasurementExportEvtx` creates `sample.evtx` using native `EventLogSession.ExportLog`, selecting only the sampled numeric record IDs through a structured query. It reopens the file with the native event reader and requires exactly every original identity and payload, with no missing, extra or duplicate records. Namespace-aware semantic comparison permits localized `RenderingInfo` differences, while preserving System plus EventData/UserData/BinaryEventData, including the order of mixed text and element content. Equivalent namespace prefixes, attribute ordering, adjacent text/CDATA and element-only indentation do not change payload identity; mixed-content and explicitly preserved whitespace remain data. The original XML is never rewritten. Reused IDs after a clear or overwritten/wrapped source records cannot substitute for an observed sample that differs in content.
|
||||
|
||||
Verification holds a file read handle denying writes/deletion, streams its SHA-256, checks every recovered record, and observes source/reader state again. The sample and other saved artifacts are rehashed before the final manifest; a changed artifact revokes verified bytes and is recorded as unverified. Only a successful readback exposes `Evtx.Bytes`. These are **logical bytes of this particular native export artifact**, including EVTX format overhead. They are not the XML byte count, physical allocation, live-channel growth, compression efficiency, backend storage or a forecast of 18 months of retention. Export is limited to a 64 MiB artifact; an oversized native output is rejected and retained as unverified, without truncation. Native export and reopen are synchronous APIs; the delivery window is bounded, but subsequent native I/O may take longer.
|
||||
|
||||
Output must be a new directory under an existing parent on an ordinary local fixed drive. UNC/device paths, ADS, wildcards, control characters, reparse paths, reserved DOS names and trailing-dot/space aliases are refused. Before evidence files are created, inherited directory access is removed and access is limited to the current reader, SYSTEM and local Administrators. These parties can still change their own evidence; file hashes and fresh checks are integrity observations, not signatures or protection against a concurrent administrator. There is no atomic transaction combining native subscription, export and channel configuration. No source EVTX is cleared, moved, resized or overwritten, and no product rollback is needed. Operators can remove a reviewed output bundle using their normal evidence-retention policy.
|
||||
|
||||
This feature is separate from `retention-health`'s retrospective `TimeCreated`/XML sampling and from `evtx-recovery`'s exact single-probe archive verification. It awards no Sigma readiness credit and supplies no translated query or backend proof.
|
||||
|
||||
## Validation and remaining labs
|
||||
|
||||
Safe fixtures exercise selection and CLI guards, UserData/namespace handling, protected artifacts, exact-record query groups, zero/capped/error windows, bookmark/source/context drift, duplicated or discontinuous IDs and missing/extra/changed EVTX contents. The gated native workflow runs on disposable Server 2022 and 2025 under Windows PowerShell 5.1 and PowerShell 7. It temporarily enables process-creation success plus precedence/command-line capture, starts the public command, generates three fixed uniquely owned `cmd.exe /d /c echo WELA_PROBE_…` processes, verifies actual 4688 deliveries and native EVTX reopening, then verifies restoration of all 59 audit masks and the exact prior registry values/types/absence. It neither clears system logs nor registers arbitrary providers. The fixture is refused outside explicit opt-in on a GitHub-hosted ephemeral runner.
|
||||
|
||||
Native CI validates the Security-channel sample on those disposable hosts. Windows 11, DC/ADCS roles, other selectable channels, long intervals, high load, real queue loss, concurrent clears and downstream backends need separate environment-specific validation. Passing CI does not validate an organization's retention duration or every rule that consumes the channel.
|
||||
|
||||
## Microsoft API references
|
||||
|
||||
- [IPGlobalProperties.HostName](https://learn.microsoft.com/en-us/dotnet/api/system.net.networkinformation.ipglobalproperties.hostname): native local computer-name metadata.
|
||||
- [EvtSubscribe](https://learn.microsoft.com/en-us/windows/win32/api/winevt/nf-winevt-evtsubscribe): local future-event subscriptions and supported Admin/Operational channels.
|
||||
- [Subscription flags](https://learn.microsoft.com/en-us/windows/win32/api/winevt/ne-winevt-evt_subscribe_flags): strict missing-record notifications and future-only origin.
|
||||
- [Subscription callback](https://learn.microsoft.com/en-us/windows/win32/api/winevt/nc-winevt-evt_subscribe_callback): service-owned event handles, serialized callback delivery and strict stale notifications.
|
||||
- [EvtRender](https://learn.microsoft.com/en-us/windows/win32/api/winevt/nf-winevt-evtrender): original event and bookmark XML.
|
||||
- [EvtExportLog](https://learn.microsoft.com/en-us/windows/win32/api/winevt/nf-winevt-evtexportlog): exact filtered exports, structured queries, new target files and header-only empty exports.
|
||||
@@ -0,0 +1,293 @@
|
||||
# Bounded local callback-delivery measurement. Product code never generates events or changes logging.
|
||||
function Get-WelaMeasurementCatalog {
|
||||
$path=Join-Path (Split-Path $PSScriptRoot -Parent) 'config/event_measurement.json'
|
||||
$data=Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
|
||||
$expected=@('Security','System','Application','Microsoft-Windows-DNS-Client/Operational','Microsoft-Windows-CAPI2/Operational','Microsoft-Windows-WinRM/Operational','Microsoft-Windows-PowerShell/Operational')
|
||||
if ($data.schemaVersion -ne 1 -or $data.kind -cne 'WelaLocalDeliveryMeasurement' -or ($data.channels -join '|') -cne ($expected -join '|')) {throw 'Unsupported measurement catalog; arbitrary channels cannot be enabled through this command.'}
|
||||
[pscustomobject]@{Channels=$expected;Sha256=(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant();Source=$data.source;Scope=$data.scope}
|
||||
}
|
||||
function Resolve-WelaMeasurementPath {
|
||||
param([Parameter(Mandatory)][string]$Path)
|
||||
# Validate lexical aliases before Windows/provider canonicalization can trim them.
|
||||
foreach ($part in $Path.Split([char[]]@('\','/'))) {
|
||||
if ($part -notin @('.','..') -and ($part -match '[. ]$' -or $part -match '^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)')) {throw 'Measurement evidence rejects ambiguous path aliases and reserved names.'}
|
||||
}
|
||||
$full=Resolve-WelaEvtxPath $Path
|
||||
foreach ($part in $full.Substring([IO.Path]::GetPathRoot($full).Length).Split([char[]]@('\','/'))) {
|
||||
if ($part -match '[. ]$' -or $part -match '^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)' -or $part -match '[<>"|]') {throw 'Measurement evidence rejects ambiguous path aliases and reserved names.'}
|
||||
}
|
||||
# Unlike a missing future output component, denied ancestor metadata is not absence.
|
||||
$ancestor=$full
|
||||
while ($ancestor) {
|
||||
try {
|
||||
$item=Get-Item -LiteralPath $ancestor -Force -ErrorAction Stop
|
||||
if ([int]$item.Attributes -band [int][IO.FileAttributes]::ReparsePoint) {throw 'Measurement evidence cannot traverse reparse points.'}
|
||||
} catch [System.Management.Automation.ItemNotFoundException] { }
|
||||
$parent=[IO.Directory]::GetParent($ancestor);if (-not $parent) {break};$ancestor=$parent.FullName
|
||||
}
|
||||
$full
|
||||
}
|
||||
function New-WelaMeasurementOutput {
|
||||
param([string]$Path)
|
||||
$full=Resolve-WelaMeasurementPath $Path
|
||||
# No source input is consumed; use the existing new-directory ACL adapter with an unrelated sentinel.
|
||||
$root=New-WelaEvtxOutput -Path $full -SourcePath (Join-Path ([IO.Path]::GetPathRoot($full)) ('wela-unused-'+[guid]::NewGuid().ToString('N')))
|
||||
if ($env:OS -eq 'Windows_NT') {
|
||||
$acl=Get-Acl -LiteralPath $root -ErrorAction Stop
|
||||
if (-not $acl.AreAccessRulesProtected) {throw 'Private evidence directory ACL protection was not applied.'}
|
||||
$allowed=@([Security.Principal.WindowsIdentity]::GetCurrent().User.Value,'S-1-5-18','S-1-5-32-544')
|
||||
foreach ($rule in $acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) {
|
||||
if ($rule.IsInherited -or $rule.IdentityReference.Value -notin $allowed -or $rule.AccessControlType -ne 'Allow') {throw 'Unexpected private evidence directory access rule.'}
|
||||
}
|
||||
}
|
||||
$root
|
||||
}
|
||||
function Write-WelaMeasurementArtifact {
|
||||
param([string]$Root,[string]$Name,[string]$Text)
|
||||
$null=Resolve-WelaMeasurementPath $Root
|
||||
if ($Name -cnotmatch '^(?:[a-z][a-z0-9-]*\.json|event-[0-9]{4}\.xml|bookmark-[0-9]{4}\.xml)$') {throw 'Unexpected measurement artifact name.'}
|
||||
$bytes=[Text.UTF8Encoding]::new($false).GetBytes($Text)
|
||||
$stream=[IO.File]::Open((Join-Path $Root $Name),[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
|
||||
try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)} finally {$stream.Dispose()}
|
||||
$hash=Get-WelaEvtxHash $bytes
|
||||
if ((Get-FileHash -LiteralPath (Join-Path $Root $Name) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $hash) {throw 'Measurement artifact readback differs.'}
|
||||
[pscustomobject]@{Name=$Name;Sha256=$hash;Bytes=$bytes.Length}
|
||||
}
|
||||
function Get-WelaMeasurementState {
|
||||
param([string]$Channel)
|
||||
$reader=Get-WelaEvtxReader
|
||||
# Local IP-helper metadata performs no DNS query. A shared short-name prefix is not identity.
|
||||
$network=[Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties()
|
||||
$names=@($reader.Computer,$network.HostName)
|
||||
if (-not [string]::IsNullOrWhiteSpace($network.DomainName)) {$names+=($network.HostName+'.'+$network.DomainName)}
|
||||
if (@($names|Where-Object {$_ -notmatch '^[\p{L}\p{N}][\p{L}\p{N}_.-]{0,254}$'}).Count) {throw 'Exact native local computer names are unavailable.'}
|
||||
$reader|Add-Member NoteProperty SourceComputerNames @($names|Sort-Object -Unique)
|
||||
$configuration=New-Object System.Diagnostics.Eventing.Reader.EventLogConfiguration($Channel)
|
||||
$session=New-Object System.Diagnostics.Eventing.Reader.EventLogSession
|
||||
try {
|
||||
$information=$session.GetLogInformation($Channel,[System.Diagnostics.Eventing.Reader.PathType]::LogName)
|
||||
if ($configuration.LogName -cne $Channel -or [string]$configuration.LogType -notin @('Administrative','Operational') -or -not $configuration.IsEnabled) {throw 'The exact channel must be registered, enabled and Administrative/Operational.'}
|
||||
if ([string]::IsNullOrWhiteSpace($configuration.SecurityDescriptor) -or [string]::IsNullOrWhiteSpace($configuration.LogFilePath) -or $configuration.MaximumSizeInBytes -le 0 -or $null -eq $information.CreationTime -or $null -eq $information.RecordCount -or $null -eq $information.OldestRecordNumber) {throw 'Required channel metadata or log identity is unavailable.'}
|
||||
[pscustomobject]@{
|
||||
CapturedUtc=[datetime]::UtcNow.ToString('o');Reader=$reader
|
||||
Configuration=[pscustomobject]@{Name=$configuration.LogName;Type=[string]$configuration.LogType;Enabled=[bool]$configuration.IsEnabled;Mode=[string]$configuration.LogMode;MaximumBytes=[long]$configuration.MaximumSizeInBytes;RegisteredPath=$configuration.LogFilePath;SecurityDescriptor=$configuration.SecurityDescriptor;Providers=@($configuration.ProviderNames|Sort-Object)}
|
||||
Log=[pscustomobject]@{CreatedUtc=$information.CreationTime.ToUniversalTime().ToString('o');OldestRecord=[long]$information.OldestRecordNumber;RecordCount=[long]$information.RecordCount;FileBytes=$information.FileSize;Full=$information.IsLogFull}
|
||||
}
|
||||
} finally {$configuration.Dispose();$session.Dispose()}
|
||||
}
|
||||
function Assert-WelaMeasurementState {
|
||||
param($Before,$After)
|
||||
foreach ($name in @('Reader','Configuration')) {
|
||||
if ((ConvertTo-Json -InputObject $Before.$name -Depth 20 -Compress) -cne (ConvertTo-Json -InputObject $After.$name -Depth 20 -Compress)) {throw "Measurement $name changed during collection/export."}
|
||||
}
|
||||
if ($Before.Log.CreatedUtc -cne $After.Log.CreatedUtc -or $After.Log.OldestRecord -lt $Before.Log.OldestRecord -or ($Before.Log.RecordCount -gt 0 -and $After.Log.RecordCount -eq 0)) {throw 'Log clear/reset or inconsistent identity was observed.'}
|
||||
}
|
||||
function Read-WelaMeasurementXmlDocument {
|
||||
param([string]$Text,[int]$Maximum=1048576)
|
||||
if ([Text.Encoding]::UTF8.GetByteCount($Text) -gt $Maximum) {throw 'XML exceeds its evidence limit.'}
|
||||
$settings=New-Object Xml.XmlReaderSettings
|
||||
$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=$Maximum
|
||||
$reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Text),$settings)
|
||||
try {$document=New-Object Xml.XmlDocument;$document.XmlResolver=$null;$document.PreserveWhitespace=$true;$document.Load($reader)} finally {$reader.Dispose()}
|
||||
return ,$document
|
||||
}
|
||||
function Get-WelaMeasurementXmlKey {
|
||||
param($Node,[int]$Depth=0)
|
||||
if ($Depth -ge 64) {throw 'Event XML semantic comparison exceeds the 64-element nesting cap.'}
|
||||
# Keep text in its original position relative to element children. UserData can
|
||||
# contain mixed content; collecting all text separately would erase payload order.
|
||||
$attributes=@($Node.Attributes | Where-Object {$_.NamespaceURI -ne 'http://www.w3.org/2000/xmlns/'} | Sort-Object NamespaceURI,LocalName -CaseSensitive | ForEach-Object {ConvertTo-Json -InputObject @($_.NamespaceURI,$_.LocalName,$_.Value) -Compress})
|
||||
$content=New-Object 'System.Collections.Generic.List[string]'
|
||||
$text=New-Object Text.StringBuilder
|
||||
$hasElements=@($Node.ChildNodes | Where-Object NodeType -eq Element).Count -gt 0
|
||||
$mixed=@($Node.ChildNodes | Where-Object {$_.NodeType -in @('Text','CDATA','SignificantWhitespace')}).Count -gt 0
|
||||
foreach ($child in $Node.ChildNodes) {
|
||||
if ($child.NodeType -eq 'Element') {
|
||||
if ($text.Length) {$content.Add((ConvertTo-Json -InputObject @('Text',$text.ToString()) -Compress));$null=$text.Clear()}
|
||||
$content.Add((ConvertTo-Json -InputObject @('Element',(Get-WelaMeasurementXmlKey -Node $child -Depth ($Depth+1))) -Compress))
|
||||
} elseif ($child.NodeType -in @('Text','CDATA','SignificantWhitespace')) {$null=$text.Append($child.Value)}
|
||||
elseif ($child.NodeType -eq 'Whitespace') {
|
||||
# Ignore indentation only for element-only content; mixed/leaf text is data.
|
||||
if (-not $hasElements -or $mixed) {$null=$text.Append($child.Value)}
|
||||
} else {throw 'Unsupported event XML node.'}
|
||||
}
|
||||
if ($text.Length) {$content.Add((ConvertTo-Json -InputObject @('Text',$text.ToString()) -Compress))}
|
||||
# Child digests keep memory proportional to the bounded XML, rather than
|
||||
# repeatedly JSON-escaping each descendant's serialized representation.
|
||||
$key=ConvertTo-Json -InputObject @($Node.NamespaceURI,$Node.LocalName,$attributes,@($content.ToArray())) -Depth 30 -Compress
|
||||
Get-WelaEvtxHash ([Text.Encoding]::UTF8.GetBytes($key))
|
||||
}
|
||||
function Read-WelaMeasurementEvent {
|
||||
param([string]$Xml,[string]$Channel,[string[]]$Computer)
|
||||
$doc=Read-WelaMeasurementXmlDocument $Xml
|
||||
$ns='http://schemas.microsoft.com/win/2004/08/events/event';$root=$doc.DocumentElement
|
||||
if ($root.LocalName -cne 'Event' -or $root.NamespaceURI -cne $ns -or @($root.Attributes|Where-Object NamespaceURI -ne 'http://www.w3.org/2000/xmlns/').Count) {throw 'Unexpected event XML root.'}
|
||||
$parts=@{}
|
||||
foreach ($node in $root.ChildNodes) {
|
||||
if ($node.NodeType -eq 'Whitespace') {continue}
|
||||
if ($node.NodeType -ne 'Element' -or $node.NamespaceURI -cne $ns -or $node.LocalName -cnotin @('System','EventData','UserData','BinaryEventData','RenderingInfo') -or $parts.ContainsKey($node.LocalName)) {throw 'Unknown or duplicate event XML section.'}
|
||||
$parts[$node.LocalName]=$node
|
||||
}
|
||||
if (-not $parts.System -or @('EventData','UserData','BinaryEventData'|Where-Object {$parts.ContainsKey($_)}).Count -gt 1) {throw 'Ambiguous event payload.'}
|
||||
$system=@{}
|
||||
foreach ($node in $parts.System.ChildNodes) {
|
||||
if ($node.NodeType -eq 'Whitespace') {continue}
|
||||
if ($node.NodeType -ne 'Element' -or $node.NamespaceURI -cne $ns -or $system.ContainsKey($node.LocalName)) {throw 'Ambiguous System identity.'}
|
||||
$system[$node.LocalName]=$node
|
||||
}
|
||||
foreach ($name in @('Provider','EventID','Version','TimeCreated','EventRecordID','Channel','Computer')) {if (-not $system.ContainsKey($name)) {throw "Missing native event identity: $name"}}
|
||||
[uint64]$record=0;[uint32]$eventId=0;[byte]$version=0
|
||||
if (-not [uint64]::TryParse($system.EventRecordID.InnerText,[ref]$record) -or $record -eq 0 -or -not [uint32]::TryParse($system.EventID.InnerText,[ref]$eventId) -or -not [byte]::TryParse($system.Version.InnerText,[ref]$version)) {throw 'Invalid native numeric event identity.'}
|
||||
$source=$system.Computer.InnerText
|
||||
if ($system.Channel.InnerText -cne $Channel -or $source -notmatch '^[\p{L}\p{N}][\p{L}\p{N}_.-]{0,254}$' -or $source -notin $Computer) {throw 'Event source/channel does not match the actual local reader.'}
|
||||
$provider=$system.Provider.GetAttribute('Name');if ([string]::IsNullOrWhiteSpace($provider)) {throw 'Provider name is unavailable.'}
|
||||
$keys=@((Get-WelaMeasurementXmlKey $parts.System))
|
||||
foreach ($name in @('EventData','UserData','BinaryEventData')) {if ($parts.ContainsKey($name)) {$keys+=$name+'='+(Get-WelaMeasurementXmlKey $parts[$name])}}
|
||||
[pscustomobject]@{RecordId=$record.ToString([Globalization.CultureInfo]::InvariantCulture);Channel=$Channel;Computer=$source;Provider=$provider;ProviderGuid=$system.Provider.GetAttribute('Guid');EventId=$eventId;Version=$version;EventUtc=(ConvertTo-WelaEvtxUtc $system.TimeCreated.GetAttribute('SystemTime')).ToString('o');Key=($keys -join '|')}
|
||||
}
|
||||
function Assert-WelaMeasurementBookmark {
|
||||
param([string]$Xml,$Event)
|
||||
$doc=Read-WelaMeasurementXmlDocument -Text $Xml -Maximum 65536
|
||||
if ($doc.DocumentElement.LocalName -cne 'BookmarkList') {throw 'Unexpected native bookmark root.'}
|
||||
$entries=@($doc.DocumentElement.ChildNodes|Where-Object NodeType -eq Element)
|
||||
if ($entries.Count -ne 1 -or $entries[0].LocalName -cne 'Bookmark' -or $entries[0].GetAttribute('Channel') -cne $Event.Channel -or $entries[0].GetAttribute('RecordId') -cne $Event.RecordId) {throw 'Native bookmark does not identify the delivered event.'}
|
||||
}
|
||||
function New-WelaMeasurementObserver {
|
||||
param([string]$Channel,[int]$Seconds,[int]$MaximumEvents)
|
||||
if (-not ('Wela.EventMeasurementV1.Observer' -as [type])) {Add-Type -Path (Join-Path $PSScriptRoot 'EventMeasurementNative.cs') -ErrorAction Stop}
|
||||
[Wela.EventMeasurementV1.Observer]::new($Channel,$Seconds,$MaximumEvents)
|
||||
}
|
||||
function Get-WelaMeasurementQuery {
|
||||
param([string]$Channel,[array]$Events)
|
||||
if ($Events.Count -lt 1 -or $Events.Count -gt 1024) {throw 'An EVTX sample requires 1 through 1024 exact record IDs.'}
|
||||
$ids=@{};$selects=@()
|
||||
foreach ($event in $Events) {if ($event.RecordId -cnotmatch '^[1-9][0-9]{0,19}$' -or $ids.ContainsKey($event.RecordId)) {throw 'Invalid or duplicate sampled record ID.'};$ids[$event.RecordId]=$true}
|
||||
$escaped=[Security.SecurityElement]::Escape($Channel)
|
||||
for ($offset=0;$offset -lt $Events.Count;$offset+=20) {
|
||||
$last=[Math]::Min($offset+19,$Events.Count-1)
|
||||
$predicates=@($Events[$offset..$last]|ForEach-Object {'EventRecordID='+$_.RecordId}) -join ' or '
|
||||
$selects+='<Select Path="'+$escaped+'">*[System['+$predicates+']]</Select>'
|
||||
}
|
||||
'<QueryList><Query Id="0" Path="'+$escaped+'">'+($selects -join '')+'</Query></QueryList>'
|
||||
}
|
||||
function Export-WelaMeasurementEvtx {
|
||||
param([string]$Channel,[string]$Query,[string]$Path)
|
||||
$null=Resolve-WelaMeasurementPath $Path
|
||||
if (Test-Path -LiteralPath $Path) {throw 'EVTX sample path already exists.'}
|
||||
$session=New-Object System.Diagnostics.Eventing.Reader.EventLogSession
|
||||
try {$session.ExportLog($Channel,[System.Diagnostics.Eventing.Reader.PathType]::LogName,$Query,$Path,$false)} finally {$session.Dispose()}
|
||||
}
|
||||
function Read-WelaMeasurementEvtx {
|
||||
param([string]$Path,[int]$MaximumEvents)
|
||||
$query=New-Object System.Diagnostics.Eventing.Reader.EventLogQuery($Path,[System.Diagnostics.Eventing.Reader.PathType]::FilePath,'*')
|
||||
$query.TolerateQueryErrors=$false
|
||||
$reader=New-Object System.Diagnostics.Eventing.Reader.EventLogReader($query)
|
||||
$result=New-Object 'System.Collections.Generic.List[string]'
|
||||
try {
|
||||
for ($i=0;$i -le $MaximumEvents;$i++) {
|
||||
$event=$reader.ReadEvent([timespan]::FromSeconds(5));if ($null -eq $event) {break}
|
||||
try {$xml=$event.ToXml();if ([Text.Encoding]::UTF8.GetByteCount($xml) -gt 1048576) {throw 'Exported event exceeds the XML cap.'};$result.Add($xml)} finally {$event.Dispose()}
|
||||
}
|
||||
} finally {$reader.Dispose()}
|
||||
return ,$result.ToArray()
|
||||
}
|
||||
function Confirm-WelaMeasurementEvtx {
|
||||
param([string]$Path,[array]$Events,[string]$Channel,[string[]]$Computer)
|
||||
$null=Resolve-WelaMeasurementPath $Path
|
||||
# Hold a read handle denying writes/deletion throughout native reopen verification.
|
||||
$file=[IO.File]::Open($Path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
|
||||
try {
|
||||
if ($file.Length -lt 1 -or $file.Length -gt 67108864) {throw 'EVTX export is empty or exceeds the 64 MiB artifact cap.'}
|
||||
$bytes=$file.Length;$sha=[Security.Cryptography.SHA256]::Create()
|
||||
try {$hash=([BitConverter]::ToString($sha.ComputeHash($file))).Replace('-','').ToLowerInvariant()} finally {$sha.Dispose()}
|
||||
$actual=Read-WelaMeasurementEvtx -Path $Path -MaximumEvents $Events.Count
|
||||
if (@($actual).Count -ne $Events.Count) {throw 'EVTX reopen contains missing or extra records.'}
|
||||
$expected=@{};foreach ($event in $Events) {$expected[$event.RecordId]=$event.Key}
|
||||
$seen=@{}
|
||||
foreach ($xml in $actual) {
|
||||
$event=Read-WelaMeasurementEvent -Xml $xml -Channel $Channel -Computer $Computer
|
||||
if ($seen.ContainsKey($event.RecordId) -or -not $expected.ContainsKey($event.RecordId) -or $expected[$event.RecordId] -cne $event.Key) {throw 'EVTX reopen differs from the original delivered event identity or payload.'}
|
||||
$seen[$event.RecordId]=$true
|
||||
}
|
||||
if ($file.Length -ne $bytes) {throw 'EVTX sample changed during verification.'}
|
||||
[pscustomobject]@{Status='ExactSampleReopened';Name='sample.evtx';Bytes=$bytes;Sha256=$hash;Records=$seen.Count;ByteMeaning='Logical bytes of this specific native EVTX export, including format overhead; not channel growth, allocation, backend storage or retention capacity.'}
|
||||
} finally {$file.Dispose()}
|
||||
}
|
||||
function Invoke-WelaEventMeasurement {
|
||||
[CmdletBinding()]
|
||||
param([ValidateSet('Plan','Run')][string]$Action='Plan',[Parameter(Mandatory)][string]$Channel,[ValidateRange(1,60)][int]$Seconds=10,[ValidateRange(1,1024)][int]$MaximumEvents=256,[string]$OutputPath,[switch]$ExportEvtx)
|
||||
$catalog=Get-WelaMeasurementCatalog
|
||||
if ($Channel -cnotin $catalog.Channels) {throw 'Select one exact reviewed channel; remote, forwarded, wildcard, Analytic and Debug channels are unsupported.'}
|
||||
if ($Action -eq 'Plan' -and ($OutputPath -or $ExportEvtx)) {throw 'OutputPath and ExportEvtx require Run; Plan is read-only.'}
|
||||
if ($Action -eq 'Run' -and [string]::IsNullOrWhiteSpace($OutputPath)) {throw 'Run requires a new private output directory.'}
|
||||
if ($OutputPath) {$OutputPath=Resolve-WelaMeasurementPath $OutputPath;if(Test-Path -LiteralPath $OutputPath){throw 'Measurement output already exists.'}}
|
||||
$result=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaLocalDeliveryMeasurement';Action=$Action;Status='Planned';ExitCode=0;GeneratedUtc=[datetime]::UtcNow.ToString('o');Channel=$Channel;RequestedSeconds=$Seconds;MaximumEvents=$MaximumEvents;MaximumXmlBytes=16777216;MaximumEventXmlBytes=1048576;MaximumEvtxBytes=67108864;CatalogSha256=$catalog.Sha256;OutputPath=$OutputPath;Before=$null;After=$null;Window=$null;ObservedDeliveries=0;ObservedDeliveriesPerSecond=$null;LossAssessment='Unknown: Windows subscription diagnostics and consistency checks cannot prove that all upstream events were generated or delivered.';Evtx=[pscustomobject]@{Status='NotRequested';Bytes=$null};Events=@();Artifacts=@();Diagnostic='';PolicyChanges=0;ReadyRuleCredit=0;Scope='Local callback deliveries during one bounded monotonic window. No producer throughput, backend ingestion, retention-duration, storage-growth or Sigma readiness claim.'}
|
||||
$root=$null;$observer=$null
|
||||
try {
|
||||
$result.Before=Get-WelaMeasurementState $Channel
|
||||
if ($Action -eq 'Plan') {return $result}
|
||||
$root=New-WelaMeasurementOutput $OutputPath;$result.OutputPath=$root
|
||||
$result.Artifacts+=Write-WelaMeasurementArtifact $root 'before-state.json' (ConvertTo-Json -InputObject $result.Before -Depth 20)
|
||||
$observer=New-WelaMeasurementObserver -Channel $Channel -Seconds $Seconds -MaximumEvents $MaximumEvents
|
||||
$result.Artifacts+=Write-WelaMeasurementArtifact $root 'window-open.json' (ConvertTo-Json -InputObject ([pscustomobject]@{StartedUtc=$observer.StartedUtc;RegistrationSeconds=$observer.RegistrationSeconds;Channel=$Channel;RequestedSeconds=$Seconds;Origin='Future events only; callbacks before the measurement window are excluded.'}))
|
||||
$capture=$observer.Complete();$observer.Dispose();$observer=$null
|
||||
$result.Window=[pscustomobject]@{StartedUtc=$capture.StartedUtc;CompletedUtc=$capture.CompletedUtc;RegistrationSeconds=$capture.RegistrationSeconds;ElapsedSeconds=$capture.ElapsedSeconds;NativeStatus=$capture.Status;NativeError=$capture.NativeError;BeforeWindowCallbacks=$capture.BeforeWindowCallbacks;OutsideWindowCallbacks=$capture.OutsideWindowCallbacks;XmlUtf8Bytes=$capture.XmlUtf8Bytes;Clock='Stopwatch monotonic; serialized callback processing time, not event TimeCreated';LastBookmark=$null}
|
||||
$result.ObservedDeliveries=@($capture.Events).Count
|
||||
[uint64]$previous=0;$index=0
|
||||
foreach ($delivery in $capture.Events) {
|
||||
$index++;$eventName='event-{0:d4}.xml' -f $index;$bookmarkName='bookmark-{0:d4}.xml' -f $index
|
||||
$result.Artifacts+=Write-WelaMeasurementArtifact $root $eventName $delivery.Xml
|
||||
$result.Artifacts+=Write-WelaMeasurementArtifact $root $bookmarkName $delivery.BookmarkXml
|
||||
$event=Read-WelaMeasurementEvent -Xml $delivery.Xml -Channel $Channel -Computer $result.Before.Reader.SourceComputerNames
|
||||
Assert-WelaMeasurementBookmark $delivery.BookmarkXml $event
|
||||
if ($previous -ne 0 -and [uint64]$event.RecordId -ne $previous+1) {throw 'Delivered record IDs are duplicated, reordered or discontinuous; completeness is unverified.'}
|
||||
if ($delivery.ElapsedSeconds -lt 0 -or $delivery.ElapsedSeconds -ge $Seconds) {throw 'Delivery timestamp is outside the monotonic observation window.'}
|
||||
$previous=[uint64]$event.RecordId
|
||||
$event|Add-Member NoteProperty ObservedElapsedSeconds $delivery.ElapsedSeconds
|
||||
$event|Add-Member NoteProperty XmlArtifact $eventName
|
||||
$event|Add-Member NoteProperty BookmarkArtifact $bookmarkName
|
||||
$result.Events+= $event;$result.Window.LastBookmark=$bookmarkName
|
||||
}
|
||||
$result.After=Get-WelaMeasurementState $Channel
|
||||
$result.Artifacts+=Write-WelaMeasurementArtifact $root 'after-state.json' (ConvertTo-Json -InputObject $result.After -Depth 20)
|
||||
Assert-WelaMeasurementState $result.Before $result.After
|
||||
if ((Get-WelaMeasurementCatalog).Sha256 -cne $catalog.Sha256) {throw 'Measurement catalog changed during collection.'}
|
||||
if ($capture.Status -cne 'WindowComplete' -or $capture.NativeError -ne 0) {throw ($capture.Status+': '+$capture.Diagnostic)}
|
||||
if ($capture.ElapsedSeconds -ne $Seconds) {throw 'Native observation window did not complete.'}
|
||||
if ($result.Events.Count -eq 0) {
|
||||
$result.Status='NoDeliveriesObserved';$result.Evtx.Status=if($ExportEvtx){'NotCreatedNoEvents'}else{'NotRequested'}
|
||||
$result.Diagnostic='No deliveries were observed in this window. This does not establish zero producer traffic, capacity or absence of loss.'
|
||||
} else {
|
||||
if ($ExportEvtx) {
|
||||
$result.Evtx.Status='Unverified'
|
||||
$query=Get-WelaMeasurementQuery $Channel $result.Events
|
||||
$result.Artifacts+=Write-WelaMeasurementArtifact $root 'sample-query.json' (ConvertTo-Json -InputObject ([pscustomobject]@{Channel=$Channel;Query=$query;RecordIds=@($result.Events.RecordId)}))
|
||||
Export-WelaMeasurementEvtx -Channel $Channel -Query $query -Path (Join-Path $root 'sample.evtx')
|
||||
$verified=Confirm-WelaMeasurementEvtx -Path (Join-Path $root 'sample.evtx') -Events $result.Events -Channel $Channel -Computer $result.Before.Reader.SourceComputerNames
|
||||
$final=Get-WelaMeasurementState $Channel;Assert-WelaMeasurementState $result.Before $final
|
||||
$result.Artifacts+=Write-WelaMeasurementArtifact $root 'export-after-state.json' (ConvertTo-Json -InputObject $final -Depth 20)
|
||||
$result.Evtx=$verified
|
||||
$result.Artifacts+=[pscustomobject]@{Name=$verified.Name;Sha256=$verified.Sha256;Bytes=$verified.Bytes}
|
||||
}
|
||||
$result.Status='DeliveryWindowObserved';$result.ObservedDeliveriesPerSecond=$result.Events.Count/[double]$capture.ElapsedSeconds
|
||||
}
|
||||
} catch {$result.Status='Unverified';$result.ExitCode=1;$result.ObservedDeliveriesPerSecond=$null;$result.Diagnostic=$_.Exception.Message}
|
||||
finally {if ($observer) {$observer.Dispose()}}
|
||||
if ($root) {
|
||||
# Keys are private in-memory comparators, not evidence content; original XML carries all fields.
|
||||
foreach ($event in $result.Events) {$event.PSObject.Properties.Remove('Key')}
|
||||
try {
|
||||
foreach ($artifact in $result.Artifacts) {
|
||||
$path=Resolve-WelaMeasurementPath (Join-Path $root $artifact.Name)
|
||||
if ((Get-Item -LiteralPath $path -Force -ErrorAction Stop).Length -ne $artifact.Bytes -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact.Sha256) {throw 'Saved event evidence changed before the manifest was written.'}
|
||||
}
|
||||
if ((Get-WelaMeasurementCatalog).Sha256 -cne $catalog.Sha256) {throw 'Measurement catalog changed before the manifest was written.'}
|
||||
} catch {
|
||||
$result.Status='Unverified';$result.ExitCode=1;$result.ObservedDeliveriesPerSecond=$null;$result.Diagnostic+=' Final evidence check failed: '+$_.Exception.Message
|
||||
if ($result.Evtx.Status -eq 'ExactSampleReopened') {$result.Evtx.Status='Unverified';$result.Evtx.Bytes=$null}
|
||||
}
|
||||
try {$null=Write-WelaMeasurementArtifact $root 'manifest.json' (ConvertTo-Json -InputObject $result -Depth 30)}
|
||||
catch {$result.Status='Unverified';$result.ExitCode=1;$result.ObservedDeliveriesPerSecond=$null;$result.Diagnostic+=' Manifest write failed: '+$_.Exception.Message}
|
||||
}
|
||||
return $result
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Native local future-event delivery observer. No log, policy or service writes.
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
|
||||
namespace Wela.EventMeasurementV1 {
|
||||
public sealed class Delivery {
|
||||
public string Xml;
|
||||
public string BookmarkXml;
|
||||
public double ElapsedSeconds;
|
||||
}
|
||||
public sealed class Capture {
|
||||
public string Status;
|
||||
public int NativeError;
|
||||
public string Diagnostic;
|
||||
public string StartedUtc;
|
||||
public string CompletedUtc;
|
||||
public double RegistrationSeconds;
|
||||
public double ElapsedSeconds;
|
||||
public int OutsideWindowCallbacks;
|
||||
public int BeforeWindowCallbacks;
|
||||
public long XmlUtf8Bytes;
|
||||
public Delivery[] Events;
|
||||
}
|
||||
public sealed class Observer : IDisposable {
|
||||
[UnmanagedFunctionPointer(CallingConvention.Winapi)]
|
||||
private delegate uint Callback(uint action, IntPtr context, IntPtr evt);
|
||||
[DllImport("wevtapi.dll", CharSet=CharSet.Unicode, SetLastError=true)]
|
||||
private static extern IntPtr EvtSubscribe(IntPtr session, IntPtr signal, string channel, string query, IntPtr bookmark, IntPtr context, Callback callback, uint flags);
|
||||
[DllImport("wevtapi.dll", CharSet=CharSet.Unicode, SetLastError=true)]
|
||||
private static extern IntPtr EvtCreateBookmark(string xml);
|
||||
[DllImport("wevtapi.dll", SetLastError=true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool EvtUpdateBookmark(IntPtr bookmark, IntPtr evt);
|
||||
[DllImport("wevtapi.dll", SetLastError=true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool EvtRender(IntPtr context, IntPtr evt, uint flags, int size, IntPtr buffer, out int used, out int count);
|
||||
[DllImport("wevtapi.dll", SetLastError=true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool EvtClose(IntPtr handle);
|
||||
private readonly object gate = new object();
|
||||
private readonly ManualResetEvent finished = new ManualResetEvent(false);
|
||||
private readonly Stopwatch timer = new Stopwatch();
|
||||
private readonly List<Delivery> events = new List<Delivery>();
|
||||
private readonly Callback callback;
|
||||
private readonly int seconds, maximum;
|
||||
private IntPtr subscription, bookmark;
|
||||
private bool armed, disposed, completed;
|
||||
private string status = "WindowComplete", diagnostic = "", started;
|
||||
private int nativeError, outsideWindow, beforeWindow;
|
||||
private long xmlBytes;
|
||||
private double registration, elapsed;
|
||||
public string StartedUtc { get { return started; } }
|
||||
public double RegistrationSeconds { get { return registration; } }
|
||||
public Observer(string channel, int seconds, int maximum) {
|
||||
if (seconds < 1 || seconds > 60 || maximum < 1 || maximum > 1024) throw new ArgumentOutOfRangeException();
|
||||
this.seconds=seconds; this.maximum=maximum; callback=OnEvent;
|
||||
bookmark=EvtCreateBookmark(null);
|
||||
if (bookmark==IntPtr.Zero) {
|
||||
int bookmarkError=Marshal.GetLastWin32Error();
|
||||
finished.Dispose();
|
||||
throw new System.ComponentModel.Win32Exception(bookmarkError, "EvtCreateBookmark failed");
|
||||
}
|
||||
Stopwatch opening=Stopwatch.StartNew();
|
||||
subscription=EvtSubscribe(IntPtr.Zero, IntPtr.Zero, channel, "*", IntPtr.Zero, IntPtr.Zero, callback, 0x10001);
|
||||
int error=Marshal.GetLastWin32Error();
|
||||
registration=opening.Elapsed.TotalSeconds;
|
||||
if(subscription==IntPtr.Zero) { EvtClose(bookmark); bookmark=IntPtr.Zero; finished.Dispose(); throw new System.ComponentModel.Win32Exception(error,"EvtSubscribe failed"); }
|
||||
lock(gate) { started=DateTime.UtcNow.ToString("o"); timer.Start(); armed=true; }
|
||||
}
|
||||
private static string Render(IntPtr handle, uint flags, int maximumBytes) {
|
||||
int used, count;
|
||||
bool ok=EvtRender(IntPtr.Zero,handle,flags,0,IntPtr.Zero,out used,out count);
|
||||
int error=Marshal.GetLastWin32Error();
|
||||
if(ok || error!=122 || used<2 || used>maximumBytes) throw new InvalidOperationException("EvtRender size/error outside bounds: "+error+"/"+used);
|
||||
IntPtr buffer=Marshal.AllocHGlobal(used);
|
||||
try {
|
||||
if(!EvtRender(IntPtr.Zero,handle,flags,used,buffer,out used,out count)) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(),"EvtRender failed");
|
||||
return Marshal.PtrToStringUni(buffer);
|
||||
} finally { Marshal.FreeHGlobal(buffer); }
|
||||
}
|
||||
private uint OnEvent(uint action, IntPtr context, IntPtr evt) {
|
||||
// The service owns callback event handles. Never close them here.
|
||||
lock(gate) {
|
||||
if(disposed || completed) return 0;
|
||||
if(action==0) { nativeError=unchecked((int)evt.ToInt64()); status="NativeError"; diagnostic="Native subscription error (including stale/missing records when reported): "+nativeError; finished.Set(); return 0; }
|
||||
if(action!=1) { status="NativeError"; diagnostic="Unknown subscription callback action"; finished.Set(); return 0; }
|
||||
if(!armed) { beforeWindow++; return 0; }
|
||||
double observed=timer.Elapsed.TotalSeconds;
|
||||
if(observed>=seconds) { outsideWindow++; finished.Set(); return 0; }
|
||||
if(status!="WindowComplete") return 0;
|
||||
if(events.Count>=maximum) { status="EventCapExceeded"; diagnostic="At least one additional delivery exceeded the selected event cap."; elapsed=observed; finished.Set(); return 0; }
|
||||
try {
|
||||
string xml=Render(evt,1,2097152);
|
||||
int bytes=System.Text.Encoding.UTF8.GetByteCount(xml);
|
||||
if(bytes>1048576 || xmlBytes+bytes>16777216) { status="XmlCapExceeded"; diagnostic="Rendered XML exceeds the per-event 1 MiB or batch 16 MiB cap."; elapsed=observed; finished.Set(); return 0; }
|
||||
if(!EvtUpdateBookmark(bookmark,evt)) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(),"EvtUpdateBookmark failed");
|
||||
events.Add(new Delivery { Xml=xml, BookmarkXml=Render(bookmark,2,65536), ElapsedSeconds=observed });
|
||||
xmlBytes+=bytes;
|
||||
} catch(Exception ex) { status="NativeError"; diagnostic=ex.Message; elapsed=observed; finished.Set(); }
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
public Capture Complete() {
|
||||
if(disposed || completed) throw new InvalidOperationException("Observer is already closed");
|
||||
double remaining=seconds-timer.Elapsed.TotalSeconds;
|
||||
if(remaining>0) finished.WaitOne((int)Math.Ceiling(remaining*1000));
|
||||
lock(gate) { completed=true; if(status=="WindowComplete") elapsed=seconds; else if(elapsed==0) elapsed=Math.Min(timer.Elapsed.TotalSeconds,seconds); }
|
||||
// EvtClose outside the callback lock avoids a shutdown/callback deadlock.
|
||||
if(subscription!=IntPtr.Zero) { EvtClose(subscription); subscription=IntPtr.Zero; }
|
||||
return new Capture { Status=status, NativeError=nativeError, Diagnostic=diagnostic, StartedUtc=started, CompletedUtc=DateTime.UtcNow.ToString("o"), RegistrationSeconds=registration, ElapsedSeconds=elapsed, OutsideWindowCallbacks=outsideWindow, BeforeWindowCallbacks=beforeWindow, XmlUtf8Bytes=xmlBytes, Events=events.ToArray() };
|
||||
}
|
||||
public void Dispose() {
|
||||
lock(gate) { if(disposed) return; disposed=true; }
|
||||
if(subscription!=IntPtr.Zero) { EvtClose(subscription); subscription=IntPtr.Zero; }
|
||||
if(bookmark!=IntPtr.Zero) { EvtClose(bookmark); bookmark=IntPtr.Zero; }
|
||||
finished.Dispose();
|
||||
GC.KeepAlive(callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
. (Join-Path $repo 'scripts/EvtxRecovery.ps1')
|
||||
. (Join-Path $repo 'scripts/EventMeasurement.ps1')
|
||||
$script:checks=0
|
||||
function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++}
|
||||
function Reject([scriptblock]$Code,[string]$Pattern){$message='';try{& $Code|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern; got $message"}
|
||||
function Event([int]$Id=10,[string]$Payload='<EventData><Data Name="Value">owned & exact</Data></EventData>',[string]$Channel='Security',[string]$Computer='HOST.lab.test') {
|
||||
'<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"><System><Provider Name="Microsoft-Windows-Security-Auditing" Guid="{54849625-5478-4994-a5ba-3e3b0328c30d}"/><EventID>4688</EventID><Version>2</Version><TimeCreated SystemTime="2025-01-02T03:04:05.1234500Z"/><EventRecordID>'+ $Id +'</EventRecordID><Channel>'+ $Channel +'</Channel><Computer>'+ $Computer +'</Computer></System>'+ $Payload +'</Event>'
|
||||
}
|
||||
function Delivery([int]$Id=10) {[pscustomobject]@{Xml=(Event $Id);BookmarkXml=('<BookmarkList><Bookmark Channel="Security" RecordId="'+$Id+'" IsCurrent="true"/></BookmarkList>');ElapsedSeconds=0.25}}
|
||||
function Capture([array]$Events=@()) {[pscustomobject]@{Status='WindowComplete';NativeError=0;Diagnostic='';StartedUtc='2025-01-02T03:04:05Z';CompletedUtc='2025-01-02T03:04:06Z';RegistrationSeconds=0.01;ElapsedSeconds=1;OutsideWindowCallbacks=0;BeforeWindowCallbacks=0;XmlUtf8Bytes=0;Events=$Events}}
|
||||
function State {
|
||||
[pscustomobject]@{CapturedUtc='2025-01-02T03:04:05Z';Reader=[pscustomobject]@{Computer='HOST';SourceComputerNames=@('HOST','HOST.lab.test');HostKey='specific host context';Reader=[pscustomobject]@{Sid='S-1-5-18'}};Configuration=[pscustomobject]@{Name='Security';Type='Administrative';Enabled=$true;Mode='Circular';MaximumBytes=20971520;RegisteredPath='C:\Windows\System32\winevt\Logs\Security.evtx';SecurityDescriptor='specific SDDL';Providers=@('Microsoft-Windows-Security-Auditing')};Log=[pscustomobject]@{CreatedUtc='2025-01-01T00:00:00Z';OldestRecord=1;RecordCount=9;FileBytes=1048576;Full=$false}}
|
||||
}
|
||||
$script:stateReads=0;$script:drift='';$script:exportCalls=0;$script:reopen=@();$script:capture=Capture;$script:tamperPath=$null
|
||||
function Get-WelaMeasurementState {
|
||||
$script:stateReads++;$s=State
|
||||
if ($script:stateReads -eq 3 -and $script:tamperPath) {[IO.File]::WriteAllBytes($script:tamperPath,[byte[]]@(1,2,3,4))}
|
||||
if($script:stateReads -gt 1){switch($script:drift){'reader'{$s.Reader.Reader.Sid='different'};'mode'{$s.Configuration.Mode='Retain'};'created'{$s.Log.CreatedUtc='different'};'clear'{$s.Log.RecordCount=0};'denied'{throw 'Access denied to channel'}}};$s
|
||||
}
|
||||
function New-WelaMeasurementObserver {
|
||||
$o=[pscustomobject]@{StartedUtc='2025-01-02T03:04:05Z';RegistrationSeconds=0.01}
|
||||
$o|Add-Member ScriptMethod Complete {return $script:capture};$o|Add-Member ScriptMethod Dispose {};return $o
|
||||
}
|
||||
function Export-WelaMeasurementEvtx {param($Channel,$Query,$Path);$script:exportCalls++;[IO.File]::WriteAllBytes($Path,[byte[]]@(69,86,84,88,1,2,3,4))}
|
||||
function Read-WelaMeasurementEvtx {param($Path,$MaximumEvents);return ,$script:reopen}
|
||||
function Invoke-WelaNative {throw 'Forbidden native configuration write'}
|
||||
function Set-ItemProperty {throw 'Forbidden registry write'}
|
||||
function Start-WelaProbeProcess {throw 'Forbidden product probe generation'}
|
||||
$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-measurement-tests-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp
|
||||
function Run([string]$Name,[switch]$Export) {$script:stateReads=0;Invoke-WelaEventMeasurement -Action Run -Channel Security -Seconds 1 -OutputPath (Join-Path $temp $Name) -ExportEvtx:$Export}
|
||||
try {
|
||||
Add-Type -Path (Join-Path $repo 'scripts/EventMeasurementNative.cs') -ErrorAction Stop
|
||||
Assert ([bool]('Wela.EventMeasurementV1.Observer' -as [type])) 'Native callback implementation compiles without calling Windows APIs'
|
||||
Assert ((Get-WelaMeasurementCatalog).Channels.Count -eq 7) 'Seven exact reviewed local channels'
|
||||
foreach($bad in @('ForwardedEvents','Security*','Microsoft-Windows-DNSServer/Analytical','\\server\Security','Microsoft-Windows-Sysmon/Operational','security')) {Reject {Invoke-WelaEventMeasurement -Channel $bad} 'exact reviewed'}
|
||||
Assert ($script:stateReads -eq 0) 'Unsupported selection rejected before host reads'
|
||||
Reject {Invoke-WelaEventMeasurement -Channel Security -OutputPath $temp} 'require Run'
|
||||
Reject {Invoke-WelaEventMeasurement -Action Run -Channel Security} 'new private'
|
||||
Reject {Invoke-WelaEventMeasurement -Action Run -Channel Security -OutputPath $temp} 'already exists'
|
||||
foreach($alias in @('data.','data ','CON.txt','sample:stream','wild*')) {$raw=$temp+[IO.Path]::DirectorySeparatorChar+$alias;Assert ($raw.EndsWith($alias)) 'Alias fixture retains literal spelling before provider normalization';Reject {Resolve-WelaMeasurementPath $raw} 'path|stream|wildcard|alias|reserved'}
|
||||
$plan=Invoke-WelaEventMeasurement -Channel Security
|
||||
Assert ($plan.Status -eq 'Planned' -and $plan.Artifacts.Count -eq 0 -and $plan.ObservedDeliveriesPerSecond -eq $null) 'Plan reads state but creates no evidence or measurement'
|
||||
$event=Read-WelaMeasurementEvent -Xml (Event) -Channel Security -Computer @('HOST','HOST.lab.test')
|
||||
Assert ($event.RecordId -ceq '10' -and $event.EventId -eq 4688 -and $event.Computer -ceq 'HOST.lab.test') 'Original numeric and qualified computer identities retained'
|
||||
$user=Read-WelaMeasurementEvent -Xml (Event -Payload '<UserData><Audit xmlns="urn:provider"><Value>kept</Value></Audit></UserData>') -Channel Security -Computer @('HOST','HOST.lab.test')
|
||||
$otherNamespace=Read-WelaMeasurementEvent -Xml (Event -Payload '<UserData><Audit xmlns="urn:other"><Value>kept</Value></Audit></UserData>') -Channel Security -Computer @('HOST','HOST.lab.test')
|
||||
$otherValue=Read-WelaMeasurementEvent -Xml (Event -Payload '<UserData><Audit xmlns="urn:provider"><Value>changed</Value></Audit></UserData>') -Channel Security -Computer @('HOST','HOST.lab.test')
|
||||
Assert ($user.Key -cne $otherNamespace.Key -and $user.Key -cne $otherValue.Key) 'Provider-specific UserData namespace and fields participate in semantic equality'
|
||||
$mixedPayload='<UserData><Payload xmlns="urn:provider" First="1" Second="2">before<Child>value</Child>after</Payload></UserData>'
|
||||
$movedPayload='<UserData><Payload xmlns="urn:provider" First="1" Second="2">beforeafter<Child>value</Child></Payload></UserData>'
|
||||
$equivalentPayload='<UserData><p:Payload xmlns:p="urn:provider" Second="2" First="1">be<![CDATA[fore]]><p:Child>value</p:Child>after</p:Payload></UserData>'
|
||||
$mixed=Read-WelaMeasurementEvent -Xml (Event -Payload $mixedPayload) -Channel Security -Computer @('HOST','HOST.lab.test')
|
||||
$moved=Read-WelaMeasurementEvent -Xml (Event -Payload $movedPayload) -Channel Security -Computer @('HOST','HOST.lab.test')
|
||||
$equivalent=Read-WelaMeasurementEvent -Xml (Event -Payload $equivalentPayload) -Channel Security -Computer @('HOST','HOST.lab.test')
|
||||
Assert ($mixed.Key -cne $moved.Key) 'Moving mixed-content text across an element changes the payload identity'
|
||||
Assert ($mixed.Key -ceq $equivalent.Key) 'Equivalent prefixes, attribute order and adjacent text/CDATA preserve payload identity'
|
||||
$spaced=Read-WelaMeasurementEvent -Xml (Event -Payload $mixedPayload.Replace('</Child>after','</Child> after')) -Channel Security -Computer @('HOST','HOST.lab.test')
|
||||
Assert ($mixed.Key -cne $spaced.Key) 'Mixed-content whitespace remains payload data'
|
||||
$indented=Read-WelaMeasurementEvent -Xml (Event -Payload "<UserData>`n <Audit xmlns=`"urn:provider`">`n <Value>kept</Value>`n </Audit>`n</UserData>") -Channel Security -Computer @('HOST','HOST.lab.test')
|
||||
Assert ($user.Key -ceq $indented.Key) 'Element-only indentation does not change semantic identity'
|
||||
$preserved=Read-WelaMeasurementEvent -Xml (Event -Payload '<UserData><Payload xmlns="urn:provider" xml:space="preserve"> <Child>value</Child> </Payload></UserData>') -Channel Security -Computer @('HOST','HOST.lab.test')
|
||||
$preservedChanged=Read-WelaMeasurementEvent -Xml (Event -Payload '<UserData><Payload xmlns="urn:provider" xml:space="preserve"> <Child>value</Child> </Payload></UserData>') -Channel Security -Computer @('HOST','HOST.lab.test')
|
||||
Assert ($preserved.Key -cne $preservedChanged.Key) 'Explicit xml:space preservation keeps significant whitespace'
|
||||
$nestedPayload='<UserData><Payload xmlns="urn:provider">'+('<Child>'*30)+'kept'+('</Child>'*30)+'</Payload></UserData>'
|
||||
$nested=Read-WelaMeasurementEvent -Xml (Event -Payload $nestedPayload) -Channel Security -Computer @('HOST','HOST.lab.test')
|
||||
$nestedChanged=Read-WelaMeasurementEvent -Xml (Event -Payload $nestedPayload.Replace('kept','changed')) -Channel Security -Computer @('HOST','HOST.lab.test')
|
||||
Assert ($nested.Key.Length -lt 200 -and $nested.Key -cne $nestedChanged.Key) 'Deep valid payload comparison remains bounded and retains the deepest value'
|
||||
$tooDeep='<UserData><Payload xmlns="urn:provider">'+('<Child>'*64)+'value'+('</Child>'*64)+'</Payload></UserData>'
|
||||
Reject {Read-WelaMeasurementEvent -Xml (Event -Payload $tooDeep) -Channel Security -Computer @('HOST','HOST.lab.test')} '64-element nesting cap'
|
||||
$rendered=Read-WelaMeasurementEvent -Xml ((Event).Replace('</Event>','<RenderingInfo Culture="en-US"><Message>display text</Message></RenderingInfo></Event>')) -Channel Security -Computer @('HOST','HOST.lab.test')
|
||||
Assert ($rendered.Key -ceq $event.Key) 'Localized RenderingInfo does not change original event semantics'
|
||||
foreach($bad in @((Event -Channel System),(Event -Computer OTHER),(Event -Computer 'HOST.other-domain.test'),(Event -Id 0),((Event).Replace('<Version>2</Version>','')),((Event).Replace('<EventData>','<EventData/><EventData>')),('<!DOCTYPE Event [<!ENTITY x SYSTEM "file:///etc/passwd">]>'+(Event)))) {Reject {Read-WelaMeasurementEvent -Xml $bad -Channel Security -Computer @('HOST','HOST.lab.test')} 'identity|match|payload|section|DTD|system|duplicate'}
|
||||
Reject {Assert-WelaMeasurementBookmark '<BookmarkList><Bookmark Channel="Security" RecordId="11"/></BookmarkList>' $event} 'does not identify'
|
||||
Assert-WelaMeasurementBookmark (Delivery).BookmarkXml $event;$script:checks++
|
||||
$many=@(1..41|ForEach-Object {[pscustomobject]@{RecordId=[string]$_}});$query=Get-WelaMeasurementQuery Security $many
|
||||
Assert (([xml]$query).QueryList.Query.Select.Count -eq 3 -and $query -match 'EventRecordID=41') 'Structured exact-record query splits more than twenty expressions'
|
||||
Reject {Get-WelaMeasurementQuery Security @($event,$event)} 'duplicate'
|
||||
$script:capture=Capture @((Delivery 10),(Delivery 11));$script:reopen=@((Event 11),(Event 10))
|
||||
$success=Run success -Export
|
||||
Assert ($success.Status -eq 'DeliveryWindowObserved' -and $success.ExitCode -eq 0 -and $success.ObservedDeliveriesPerSecond -eq 2) ('Complete measured window: '+$success.Diagnostic)
|
||||
Assert ($success.Evtx.Status -eq 'ExactSampleReopened' -and $success.Evtx.Bytes -eq 8 -and $success.Evtx.Records -eq 2) 'Actual export bytes require semantic reopen with every sampled record'
|
||||
Assert ($success.ReadyRuleCredit -eq 0 -and $success.PolicyChanges -eq 0 -and $success.LossAssessment -match '^Unknown') 'Measurement gives no rule credit or upstream losslessness claim'
|
||||
Assert ($success.Events[0].PSObject.Properties.Name -notcontains 'Key' -and $success.Events[0].XmlArtifact -eq 'event-0001.xml') 'Manifest refers to exact original evidence artifacts'
|
||||
foreach($artifact in $success.Artifacts) {Assert ((Get-FileHash -LiteralPath (Join-Path $success.OutputPath $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Protected artifact hash matches written evidence'}
|
||||
$script:capture=Capture @((Delivery));$script:capture.Events[0].Xml=Event -Payload $mixedPayload
|
||||
$script:reopen=@((Event -Payload $movedPayload));$changedMixed=Run 'changed-mixed-content' -Export
|
||||
Assert ($changedMixed.ExitCode -eq 1 -and $changedMixed.Evtx.Status -eq 'Unverified' -and $null -eq $changedMixed.Evtx.Bytes -and $changedMixed.Diagnostic -match 'identity or payload') 'EVTX reopen rejects moved mixed-content text before exposing verified bytes'
|
||||
$script:reopen=@((Event -Payload $equivalentPayload));$equivalentMixed=Run 'equivalent-mixed-content' -Export
|
||||
Assert ($equivalentMixed.ExitCode -eq 0 -and $equivalentMixed.Evtx.Status -eq 'ExactSampleReopened') 'EVTX reopen accepts equivalent mixed content without rewriting original XML'
|
||||
$script:capture=Capture
|
||||
$zero=Run zero -Export
|
||||
Assert ($zero.Status -eq 'NoDeliveriesObserved' -and $zero.Evtx.Status -eq 'NotCreatedNoEvents' -and $null -eq $zero.ObservedDeliveriesPerSecond -and -not(Test-Path (Join-Path $zero.OutputPath 'sample.evtx'))) 'Empty window neither invents an EVTX archive nor asserts zero producer rate'
|
||||
foreach($status in @('EventCapExceeded','XmlCapExceeded','NativeError')) {
|
||||
$script:capture=Capture @((Delivery));$script:capture.Status=$status;$script:capture.Diagnostic='explicit native diagnostic';$script:capture.NativeError=if($status -eq 'NativeError'){15011}else{0}
|
||||
$r=Run $status -Export
|
||||
Assert ($r.Status -eq 'Unverified' -and $r.ExitCode -eq 1 -and $null -eq $r.ObservedDeliveriesPerSecond -and $r.Diagnostic -match $status -and (Test-Path (Join-Path $r.OutputPath 'event-0001.xml'))) "$status retains partial evidence and suppresses a valid rate"
|
||||
}
|
||||
foreach($drift in @('reader','mode','created','clear','denied')) {$script:drift=$drift;$script:capture=Capture @((Delivery));$r=Run $drift -Export;Assert ($r.ExitCode -eq 1 -and $null -eq $r.ObservedDeliveriesPerSecond) "$drift invalidates measurement"};$script:drift=''
|
||||
foreach($ids in @(@(10,10),@(11,10),@(10,12))) {$script:capture=Capture @($ids|ForEach-Object {Delivery $_});$r=Run ('ids'+($ids -join '-'));Assert ($r.ExitCode -eq 1 -and $r.Diagnostic -match 'discontinuous') 'Duplicate/reordered/gapped delivery IDs are not credited'}
|
||||
$script:capture=Capture @((Delivery));$script:capture.Events[0].ElapsedSeconds=1;$r=Run timestamp;Assert ($r.ExitCode -eq 1 -and $r.Diagnostic -match 'outside') 'Out-of-window callback refused'
|
||||
$script:capture=Capture @((Delivery))
|
||||
foreach($variant in @('empty','extra','changed','wrong-id','wrong-channel')) {
|
||||
$script:reopen=switch($variant){'empty'{@()};'extra'{@((Event),(Event 11))};'changed'{@((Event -Payload '<EventData><Data Name="Value">changed</Data></EventData>'))};'wrong-id'{@((Event 11))};'wrong-channel'{@((Event -Channel System))}}
|
||||
$r=Run ('export-'+$variant) -Export
|
||||
Assert ($r.ExitCode -eq 1 -and $r.Evtx.Status -eq 'Unverified' -and $null -eq $r.Evtx.Bytes -and $null -eq $r.ObservedDeliveriesPerSecond) "EVTX $variant readback cannot produce verified bytes or a valid rate"
|
||||
}
|
||||
$script:capture=Capture @((Delivery));$script:reopen=@((Event));$script:tamperPath=Join-Path (Join-Path $temp 'post-reopen-tamper') 'sample.evtx'
|
||||
$r=Run 'post-reopen-tamper' -Export;$script:tamperPath=$null
|
||||
Assert ($r.ExitCode -eq 1 -and $r.Evtx.Status -eq 'Unverified' -and $null -eq $r.Evtx.Bytes -and $r.Diagnostic -match 'Saved event evidence changed') 'Post-reopen sample mutation revokes verified bytes at final manifest freshness check'
|
||||
$failure=Get-Content -LiteralPath (Join-Path $r.OutputPath 'manifest.json') -Raw | ConvertFrom-Json
|
||||
Assert ($failure.Status -eq 'Unverified' -and $failure.Diagnostic -match 'Final evidence check failed') 'Final artifact mismatch retains a durable unverified manifest'
|
||||
# Exercise public dispatch boundaries in a child; expected errors must not terminate PS5.1 before exit capture.
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
foreach($args in @(@('configure','-MeasurementAction','Plan'),@('event-measurement','-MeasurementChannel','Security','-Auto'),@('event-measurement','-MeasurementChannel','ForwardedEvents'))) {
|
||||
$saved=$ErrorActionPreference;$ErrorActionPreference='Continue'
|
||||
try {$output=& $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') @args 2>&1;$code=$LASTEXITCODE} finally {$ErrorActionPreference=$saved}
|
||||
Assert ($code -ne 0 -and ($output|Out-String) -match 'Measurement options|dedicated options|exact reviewed') 'Public guard rejects unrelated options or unsupported source before native access'
|
||||
}
|
||||
Write-Host "$script:checks event measurement assertions passed. Mocks do not establish native event or EVTX success."
|
||||
} finally {Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue}
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,78 @@
|
||||
param([switch]$AllowDisposablePolicyWrite)
|
||||
$ErrorActionPreference='Stop'
|
||||
if ($env:OS -ne 'Windows_NT') {Write-Host 'Skipped: native Windows required.';exit 0}
|
||||
if (-not $AllowDisposablePolicyWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted') {throw 'Native fixture requires explicit policy-write opt-in on an ephemeral GitHub-hosted runner.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
. (Join-Path $repo 'scripts/Configuration.ps1')
|
||||
. (Join-Path $repo 'scripts/ControlApplicability.ps1')
|
||||
. (Join-Path $repo 'scripts/NativeValidation.ps1')
|
||||
. (Join-Path $repo 'scripts/EvtxRecovery.ps1')
|
||||
. (Join-Path $repo 'scripts/EventMeasurement.ps1')
|
||||
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
|
||||
$guid='0cce922b-69ae-11d9-bed3-505054503030'
|
||||
$controls=@([pscustomobject]@{Path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';Name='SCENoApplyLegacyAuditPolicy'},[pscustomobject]@{Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit';Name='ProcessCreationIncludeCmdLine_Enabled'})
|
||||
$beforeMasks=Get-WelaEffectiveAuditPolicy
|
||||
if($beforeMasks.Count -ne 59){throw 'Complete initial policy snapshot unavailable.'}
|
||||
foreach($control in $controls){$control|Add-Member NoteProperty Before (Get-WelaRegistryState -Path $control.Path -Name $control.Name)}
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-delivery-native-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
$receipt=Join-Path $root 'fixture-before.json'
|
||||
[pscustomobject]@{AuditMasks=$beforeMasks;Controls=$controls}|ConvertTo-Json -Depth 15|Set-Content -LiteralPath $receipt -Encoding UTF8
|
||||
$touched=$false;$restored=$false;$child=$null;$checks=0
|
||||
function Check($Value,[string]$Message){if(-not $Value){throw $Message};$script:checks++}
|
||||
try {
|
||||
$touched=$true
|
||||
foreach($control in $controls){if(-not(Test-Path -LiteralPath $control.Path)){$null=New-WelaRegistryKey $control.Path};$null=New-ItemProperty -LiteralPath $control.Path -Name $control.Name -Value 1 -PropertyType DWord -Force}
|
||||
Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 1 -Mode minimum
|
||||
$nativeState=Get-WelaProbeState;Assert-WelaProbePrerequisites $nativeState
|
||||
$channelBefore=Get-WelaMeasurementState Security
|
||||
$engine=(Get-Process -Id $PID).Path;$bundle=Join-Path $root 'measurement'
|
||||
$start=New-Object Diagnostics.ProcessStartInfo
|
||||
$start.FileName=$engine;$start.Arguments='-NoProfile -File "'+(Join-Path $repo 'WELA.ps1')+'" event-measurement -MeasurementAction Run -MeasurementChannel Security -MeasurementSeconds 10 -MeasurementMaximumEvents 1024 -MeasurementOutputPath "'+$bundle+'" -MeasurementExportEvtx'
|
||||
$start.UseShellExecute=$false;$start.CreateNoWindow=$true;$start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true
|
||||
$child=[Diagnostics.Process]::Start($start)
|
||||
$stdout=$child.StandardOutput.ReadToEndAsync();$stderr=$child.StandardError.ReadToEndAsync()
|
||||
$opening=[Diagnostics.Stopwatch]::StartNew();$opened=Join-Path $bundle 'window-open.json'
|
||||
while(-not(Test-Path -LiteralPath $opened)){
|
||||
if($child.HasExited){throw ('Measurement did not open: '+$stdout.GetAwaiter().GetResult()+' '+$stderr.GetAwaiter().GetResult())}
|
||||
if($opening.Elapsed.TotalSeconds -gt 60){throw 'Measurement did not open within 60 seconds; the owned child will be stopped during cleanup.'}
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
$processes=@(1..3|ForEach-Object {Start-WelaProbeProcess})
|
||||
if(-not $child.WaitForExit(90000)){throw 'Native measurement child exceeded its bounded fixture timeout.'}
|
||||
$out=$stdout.GetAwaiter().GetResult();$err=$stderr.GetAwaiter().GetResult();Write-Host $out
|
||||
Check ($child.ExitCode -eq 0) ('Public measurement failed: '+$err)
|
||||
$manifest=ConvertFrom-WelaEvtxJson (Get-Content -LiteralPath (Join-Path $bundle 'manifest.json') -Raw)
|
||||
Check ($manifest.Status -eq 'DeliveryWindowObserved' -and $manifest.Window.ElapsedSeconds -eq 10 -and $manifest.Window.NativeStatus -eq 'WindowComplete') 'Native callback observation did not complete its monotonic window.'
|
||||
Check ($manifest.PolicyChanges -eq 0 -and $manifest.ReadyRuleCredit -eq 0 -and $manifest.LossAssessment -match '^Unknown') 'Native report overclaimed configuration, readiness or upstream completeness.'
|
||||
Check ($manifest.ObservedDeliveries -ge 3 -and $manifest.ObservedDeliveriesPerSecond -eq $manifest.ObservedDeliveries/10.0) 'Native observed count/rate is inconsistent.'
|
||||
Check ($manifest.Evtx.Status -eq 'ExactSampleReopened' -and $manifest.Evtx.Records -eq $manifest.ObservedDeliveries) 'Exact native export/reopen failed.'
|
||||
Check ($manifest.Evtx.Bytes -eq (Get-Item -LiteralPath (Join-Path $bundle 'sample.evtx')).Length -and $manifest.Evtx.Sha256 -ceq (Get-FileHash -LiteralPath (Join-Path $bundle 'sample.evtx')).Hash.ToLowerInvariant()) 'Measured native EVTX artifact bytes/hash differ.'
|
||||
foreach($artifact in $manifest.Artifacts){Check ((Get-FileHash -LiteralPath (Join-Path $bundle $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Native artifact hash mismatch.'}
|
||||
$delivered=@($manifest.Events|ForEach-Object {[IO.File]::ReadAllText((Join-Path $bundle $_.XmlArtifact))})
|
||||
foreach($process in $processes){$matches=@($delivered|Where-Object {Test-WelaProbeEvent -Xml $_ -Process $process -State $nativeState -EndUtc ([datetime]::UtcNow)});Check ($matches.Count -eq 1) ('No exact sampled native 4688 for owned process '+$process.Marker)}
|
||||
$parsed=@($delivered|ForEach-Object {Read-WelaMeasurementEvent -Xml $_ -Channel Security -Computer $manifest.Before.Reader.SourceComputerNames})
|
||||
$independent=Confirm-WelaMeasurementEvtx -Path (Join-Path $bundle 'sample.evtx') -Events $parsed -Channel Security -Computer $manifest.Before.Reader.SourceComputerNames
|
||||
Check ($independent.Sha256 -ceq $manifest.Evtx.Sha256) 'Independent native reopen differs.'
|
||||
$channelAfter=Get-WelaMeasurementState Security;Assert-WelaMeasurementState $channelBefore $channelAfter;$checks++
|
||||
$acl=Get-Acl -LiteralPath $bundle;Check $acl.AreAccessRulesProtected 'Evidence ACL is not protected.'
|
||||
# No policy/channel clear, service start, arbitrary provider or source registration occurs in product or fixture.
|
||||
Write-Host "Native delivery/export proved exact owned 4688 samples in a ten-second callback window on $($nativeState.context.patch), PowerShell $($PSVersionTable.PSVersion). Other channels/roles and backend storage remain unproven."
|
||||
} finally {
|
||||
if($child){if(-not $child.HasExited){$child.Kill();$child.WaitForExit(10000)|Out-Null};$child.Dispose()}
|
||||
if($touched){
|
||||
$errors=@()
|
||||
try{Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $beforeMasks[$guid] -Mode exact}catch{$errors+=$_.Exception.Message}
|
||||
foreach($control in $controls){try{
|
||||
if($control.Before.ValueExists){$null=New-ItemProperty -LiteralPath $control.Path -Name $control.Name -Value $control.Before.Value -PropertyType $control.Before.Type -Force}
|
||||
else{Remove-ItemProperty -LiteralPath $control.Path -Name $control.Name -ErrorAction SilentlyContinue}
|
||||
if(-not $control.Before.KeyExists -and(Test-Path -LiteralPath $control.Path)){$key=Get-Item -LiteralPath $control.Path;if($key.ValueCount -eq 0 -and $key.SubKeyCount -eq 0){Remove-Item -LiteralPath $control.Path -ErrorAction Stop}}
|
||||
if((Get-WelaRegistryState -Path $control.Path -Name $control.Name|ConvertTo-Json -Compress) -cne ($control.Before|ConvertTo-Json -Compress)){throw ('Registry restoration differs: '+$control.Name)}
|
||||
}catch{$errors+=$_.Exception.Message}}
|
||||
try{$afterMasks=Get-WelaEffectiveAuditPolicy;foreach($id in $beforeMasks.Keys){if($afterMasks[$id] -ne $beforeMasks[$id]){throw ('Restoration differs for audit GUID '+$id)}}}catch{$errors+=$_.Exception.Message}
|
||||
$restored=$errors.Count -eq 0
|
||||
if(-not $restored){throw ('Fixture restoration failed; evidence retained at '+$root+': '+($errors -join '; '))}
|
||||
}
|
||||
if($restored){Remove-Item -LiteralPath $root -Recurse -Force}
|
||||
}
|
||||
Write-Host "$checks native delivery/export checks and complete policy/typed-registry restoration passed."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
**改善:**
|
||||
|
||||
- `event-measurement`を追加。明示した組み込みの管理・運用チャネル1つを一定時間だけ観測し、単調時計によるコールバック到着時間、元のXML・ブックマーク、非公開の証拠、ネイティブEVTXサンプルの厳密な再読み取りを記録します。上限超過、欠落・古い記録、状態変化、不完全なエクスポートは未検証とし、サンプルのバイト数からログ増加量・保持容量・バックエンド到達・Sigma検知可能性を推定しません。 (#430) (@Shirofune-Security)
|
||||
- `targeted-sacl`で子孫への継承を明示的に許可した場合、件数・深さを制限した子孫一覧と記述子を計画・変更直前に照合し、変更前の記録、保護された子孫の保持、各オブジェクトの継承結果を確認するようにしました。上限超過、読み取り拒否、リンク、子孫の追加・消失・変更は処理を停止または失敗として記録し、親のみの既存動作は保持します。使い捨てファイル/レジストリ階層で継承と保護を検証し、子孫ACEの所有権、一括復旧、Sigma利用可能性は主張しません。 (#429) (@Shirofune-Security)
|
||||
- 固定のローカル名前空間読み取りを行う任意実行の `wmi-probe` を追加しました。実トークン・監査ポリシー・完全な SACL を観測し、WMI Security4662 を厳密に照合して、容量制限付きの非公開 XML とコードの指紋を記録します。本番の名前空間やポリシーは変更せず、Sigma の評価には加算しません。WMI 接続は明示的に管理するセキュリティ特権だけを使用し、意図しないスレッド特権の有効化を防ぎます。両 PowerShell エンジンの使い捨て Server 2022/2025 テストで実際のローカル 4662 と監査設定・名前空間の復元を確認しました。リモートアクセス、プロバイダー処理の成否、個々のクエリへの排他的な帰属は未検証です。 (#428) (@Shirofune-Security)
|
||||
- 正規バックアップと現在の WELA 監査コンポーネントを照合し、新規・無効・未リンクの GPO のみを作成する `gpo-create` の Review / Plan / Create を追加しました。実ファイルとネイティブレポートの厳密な検証、明示的なドメイン/書き込み可能 DC、変更しない保護付きバックアップコピー、永続 GUID 記録、内容・無効状態・権限・リンク・バージョンの直前/最終確認で既存ポリシーを保護します。Windows テストは Microsoft の固定バックアップの読み取りと対象外ポリシー/ワークグループの拒否を確認し、実 AD/SYSVOL への正常インポートとクライアント/イベントの受け入れ検証は別途必要です。適用や Sigma の有効性は主張しません。(#427) (@Shirofune-Security)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
**Improvements:**
|
||||
|
||||
- Added opt-in `event-measurement` for bounded local callback-delivery windows on one explicit built-in Administrative/Operational channel, with monotonic timing, original XML/bookmarks, private evidence and exact native EVTX sample reopening. Caps, missing/stale records, source drift and incomplete exports remain unverified; sample-file bytes do not imply channel growth, retention capacity, backend ingestion or Sigma readiness. (#430) (@Shirofune-Security)
|
||||
- Extended explicit `targeted-sacl` child consent with bounded reviewed descendant inventories, fresh preflight/pre-write checks, durable child snapshots, protected-subtree preservation and per-child native inheritance outcomes. Caps, denials, links, new/disappeared children and drift block or fail the run; parent-only behavior stays unchanged. Disposable populated file/registry tests verify inheritance and protection without child-ACE ownership, bulk rollback or Sigma credit. (#429) (@Shirofune-Security)
|
||||
|
||||
- Added opt-in `wmi-probe` for a fixed local namespace read with observed token, audit-policy and full SACL context, exact WMI Security4662 correlation, bounded private raw XML and source fingerprints. Production makes no namespace/policy changes and grants no Sigma credit. WMI connections now use only the explicitly scoped security privilege, avoiding unintended thread privilege expansion. Disposable Server 2022/2025 tests under both PowerShell engines verify real local 4662 events and exact policy/namespace cleanup. Remote access, provider-operation success and exclusive query attribution remain unverified. (#428) (@Shirofune-Security)
|
||||
|
||||
Reference in new issue
Block a user