diff --git a/Benchmarks/EventDetection/Test-DetectionBenchmarkBudget.ps1 b/Benchmarks/EventDetection/Test-DetectionBenchmarkBudget.ps1 new file mode 100644 index 00000000..d384bbae --- /dev/null +++ b/Benchmarks/EventDetection/Test-DetectionBenchmarkBudget.ps1 @@ -0,0 +1,219 @@ +<# +.SYNOPSIS +Validates full BenchmarkDotNet detection results against checked-in budgets. + +.DESCRIPTION +Normalizes the permanent candidate-index and streaming-throughput BenchmarkDotNet +JSON reports into PowerForge summary rows. The gate verifies the complete 4-case +candidate matrix and 12-case throughput matrix before comparing timing and +allocation metrics. Allocation is expressed per event for throughput so scale +changes cannot hide a regression. + +.EXAMPLE +.\Test-DetectionBenchmarkBudget.ps1 ` + -CandidateResultPath .\BenchmarkDotNet.Artifacts\results\EventViewerX.DetectionBenchmarks.DetectionCandidateIndexBenchmarks-report-full-compressed.json ` + -ThroughputResultPath .\BenchmarkDotNet.Artifacts\results\EventViewerX.DetectionBenchmarks.DetectionThroughputBenchmarks-report-full-compressed.json +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $CandidateResultPath, + + [Parameter(Mandatory)] + [string] $ThroughputResultPath, + + [string] $BaselineRoot = $PSScriptRoot, + + [string] $OutputRoot, + + [switch] $UpdateBaseline +) + +$ErrorActionPreference = 'Stop' +$repositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..')).Path +if ([string]::IsNullOrWhiteSpace($OutputRoot)) { + $OutputRoot = Join-Path $repositoryRoot 'Ignore\Benchmarks\EventDetection\Gate' +} +$outputRoot = [IO.Path]::GetFullPath($OutputRoot) +$baselineRoot = [IO.Path]::GetFullPath($BaselineRoot) +$candidateResultPath = (Resolve-Path -LiteralPath $CandidateResultPath).Path +$throughputResultPath = (Resolve-Path -LiteralPath $ThroughputResultPath).Path + +function ConvertFrom-BenchmarkParameters { + param([string] $Value) + + $result = [ordered] @{} + foreach ($part in @($Value -split '&')) { + if ([string]::IsNullOrWhiteSpace($part)) { + continue + } + $pair = $part.Split('=', 2) + if ($pair.Count -ne 2) { + throw "Benchmark parameter '$part' is not a name=value pair." + } + $result[[Uri]::UnescapeDataString($pair[0])] = [Uri]::UnescapeDataString($pair[1]) + } + $result +} + +function ConvertTo-DetectionSummaryRow { + param( + [Parameter(Mandatory)] $Benchmark, + [Parameter(Mandatory)] [string] $Scenario, + [Parameter(Mandatory)] [string[]] $RequiredParameters + ) + + $variables = ConvertFrom-BenchmarkParameters -Value ([string] $Benchmark.Parameters) + foreach ($name in $RequiredParameters) { + if (-not $variables.Contains($name)) { + throw "Benchmark '$($Benchmark.FullName)' is missing required parameter '$name'." + } + } + if ($null -eq $Benchmark.Statistics -or $null -eq $Benchmark.Memory) { + throw "Benchmark '$($Benchmark.FullName)' is missing statistics or memory diagnostics." + } + $eventCount = if ($variables.Contains('EventCount')) { + [long] $variables.EventCount + } else { + 1L + } + if ($eventCount -le 0) { + throw "Benchmark '$($Benchmark.FullName)' has an invalid EventCount." + } + $sampleCountProperty = $Benchmark.Statistics.PSObject.Properties['N'] + $medianProperty = $Benchmark.Statistics.PSObject.Properties['Median'] + $allocationProperty = $Benchmark.Memory.PSObject.Properties['BytesAllocatedPerOperation'] + if ($null -eq $sampleCountProperty -or $null -eq $sampleCountProperty.Value -or + $null -eq $medianProperty -or $null -eq $medianProperty.Value) { + throw "Benchmark '$($Benchmark.FullName)' is missing required timing statistics." + } + $sampleCount = [int] $sampleCountProperty.Value + $medianNanoseconds = [double] $medianProperty.Value + if ($sampleCount -le 0 -or + [double]::IsNaN($medianNanoseconds) -or + [double]::IsInfinity($medianNanoseconds) -or + $medianNanoseconds -lt 0) { + throw "Benchmark '$($Benchmark.FullName)' has invalid timing statistics." + } + if ($null -eq $allocationProperty -or $null -eq $allocationProperty.Value) { + throw "Benchmark '$($Benchmark.FullName)' is missing BytesAllocatedPerOperation." + } + $allocated = [double] $allocationProperty.Value + if ([double]::IsNaN($allocated) -or [double]::IsInfinity($allocated) -or $allocated -lt 0) { + throw "Benchmark '$($Benchmark.FullName)' has invalid BytesAllocatedPerOperation." + } + [ordered] @{ + suite = 'event-detection' + scenario = $Scenario + operation = [string] $Benchmark.Method + engine = 'EventViewerXDetection' + variables = $variables + sampleCount = $sampleCount + failureCount = 0 + status = 'Succeeded' + medianMs = [double] $Benchmark.Statistics.Median / 1000000.0 + metrics = [ordered] @{ + AllocatedBytes = $allocated + AllocatedBytesPerEvent = $allocated / $eventCount + } + } +} + +function Assert-ExactMatrix { + param( + [Parameter(Mandatory)] [object[]] $Rows, + [Parameter(Mandatory)] [string[]] $ExpectedKeys, + [Parameter(Mandatory)] [scriptblock] $Key + ) + + [string[]] $allKeys = @($Rows | ForEach-Object $Key) + [array] $duplicates = @($allKeys | Group-Object | Where-Object Count -gt 1) + if ($duplicates.Count -gt 0) { + throw "Benchmark matrix contains duplicate parameter tuples: $($duplicates.Name -join ', ')." + } + [string[]] $actual = @($allKeys | Sort-Object) + [string[]] $expected = @($ExpectedKeys | Sort-Object -Unique) + if (($actual -join "`n") -ne ($expected -join "`n")) { + throw "Benchmark matrix mismatch. Expected [$($expected -join ', ')]; actual [$($actual -join ', ')]." + } +} + +$candidateDocument = Get-Content -LiteralPath $candidateResultPath -Raw | ConvertFrom-Json +$throughputDocument = Get-Content -LiteralPath $throughputResultPath -Raw | ConvertFrom-Json +[object[]] $candidateRows = @( + $candidateDocument.Benchmarks | + ForEach-Object { + ConvertTo-DetectionSummaryRow -Benchmark $_ -Scenario 'CandidateIndex' -RequiredParameters RuleCount + } +) +[object[]] $throughputRows = @( + $throughputDocument.Benchmarks | + ForEach-Object { + ConvertTo-DetectionSummaryRow -Benchmark $_ -Scenario 'Throughput' -RequiredParameters EventCount,Lane + } +) + +Assert-ExactMatrix -Rows $candidateRows -ExpectedKeys @('1', '10', '100', '1000') -Key { + [string] $_.variables.RuleCount +} +$expectedThroughput = foreach ($eventCount in 1000,10000,100000,1000000) { + foreach ($lane in 'StatelessPredicate','ThresholdWindow','OrderedTemporal') { + "$eventCount|$lane" + } +} +Assert-ExactMatrix -Rows $throughputRows -ExpectedKeys $expectedThroughput -Key { + "$($_.variables.EventCount)|$($_.variables.Lane)" +} +if ($UpdateBaseline.IsPresent -and + @($candidateRows + $throughputRows | Where-Object sampleCount -lt 3).Count -gt 0) { + throw 'Updating detection baselines requires at least three measured samples for every benchmark tuple.' +} + +[IO.Directory]::CreateDirectory($outputRoot) | Out-Null +$candidateSummaryPath = Join-Path $outputRoot 'candidate-summary.json' +$throughputSummaryPath = Join-Path $outputRoot 'throughput-summary.json' +$candidateRows | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $candidateSummaryPath -Encoding utf8 +$throughputRows | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $throughputSummaryPath -Encoding utf8 + +Import-Module PSPublishModule -MinimumVersion 3.0.134 -Force -ErrorAction Stop +$groupBy = @('Suite', 'Scenario', 'Operation', 'Engine', 'Variables') +$gates = @( + @{ + SummaryPath = $candidateSummaryPath + BaselinePath = Join-Path $baselineRoot 'detection-candidate-timing-baseline.json' + Metric = 'MedianMs' + RelativeTolerance = 0.5 + AbsoluteToleranceMs = 0.25 + } + @{ + SummaryPath = $candidateSummaryPath + BaselinePath = Join-Path $baselineRoot 'detection-candidate-allocation-baseline.json' + Metric = 'AllocatedBytes' + RelativeTolerance = 0.1 + AbsoluteToleranceMs = 65536 + } + @{ + SummaryPath = $throughputSummaryPath + BaselinePath = Join-Path $baselineRoot 'detection-throughput-timing-baseline.json' + Metric = 'MedianMs' + RelativeTolerance = 0.5 + AbsoluteToleranceMs = 0.25 + } + @{ + SummaryPath = $throughputSummaryPath + BaselinePath = Join-Path $baselineRoot 'detection-throughput-allocation-baseline.json' + Metric = 'AllocatedBytesPerEvent' + RelativeTolerance = 0.1 + AbsoluteToleranceMs = 16 + } +) + +$results = foreach ($gate in $gates) { + $gate.GroupBy = $groupBy + $gate.Confirm = $false + if ($UpdateBaseline.IsPresent) { + $gate.Update = $true + } + Test-BenchmarkGate @gate +} +$results diff --git a/Benchmarks/EventDetection/detection-candidate-allocation-baseline.json b/Benchmarks/EventDetection/detection-candidate-allocation-baseline.json new file mode 100644 index 00000000..f788e441 --- /dev/null +++ b/Benchmarks/EventDetection/detection-candidate-allocation-baseline.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "generatedUtc": "2026-09-20T15:00:19.4630477+00:00", + "metrics": { + "event-detection|CandidateIndex|EvaluateIndexedCandidates|EventViewerXDetection|RuleCount=1000|AllocatedBytes": 2030360, + "event-detection|CandidateIndex|EvaluateIndexedCandidates|EventViewerXDetection|RuleCount=100|AllocatedBytes": 2030360, + "event-detection|CandidateIndex|EvaluateIndexedCandidates|EventViewerXDetection|RuleCount=10|AllocatedBytes": 2030368, + "event-detection|CandidateIndex|EvaluateIndexedCandidates|EventViewerXDetection|RuleCount=1|AllocatedBytes": 2030360 + } +} diff --git a/Benchmarks/EventDetection/detection-candidate-timing-baseline.json b/Benchmarks/EventDetection/detection-candidate-timing-baseline.json new file mode 100644 index 00000000..5b069d08 --- /dev/null +++ b/Benchmarks/EventDetection/detection-candidate-timing-baseline.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "generatedUtc": "2026-09-20T15:00:19.4557095+00:00", + "metrics": { + "event-detection|CandidateIndex|EvaluateIndexedCandidates|EventViewerXDetection|RuleCount=1000|MedianMs": 0.670431640625, + "event-detection|CandidateIndex|EvaluateIndexedCandidates|EventViewerXDetection|RuleCount=100|MedianMs": 0.64148505859375, + "event-detection|CandidateIndex|EvaluateIndexedCandidates|EventViewerXDetection|RuleCount=10|MedianMs": 0.757770751953125, + "event-detection|CandidateIndex|EvaluateIndexedCandidates|EventViewerXDetection|RuleCount=1|MedianMs": 0.5335905029296875 + } +} diff --git a/Benchmarks/EventDetection/detection-throughput-allocation-baseline.json b/Benchmarks/EventDetection/detection-throughput-allocation-baseline.json new file mode 100644 index 00000000..96ce4852 --- /dev/null +++ b/Benchmarks/EventDetection/detection-throughput-allocation-baseline.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "generatedUtc": "2026-09-20T15:00:19.4647027+00:00", + "metrics": { + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000000;Lane=OrderedTemporal|AllocatedBytesPerEvent": 160.003656, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000000;Lane=StatelessPredicate|AllocatedBytesPerEvent": 104.0032, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000000;Lane=ThresholdWindow|AllocatedBytesPerEvent": 176.78412, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=100000;Lane=OrderedTemporal|AllocatedBytesPerEvent": 160.03656, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=100000;Lane=StatelessPredicate|AllocatedBytesPerEvent": 104.032, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=100000;Lane=ThresholdWindow|AllocatedBytesPerEvent": 181.01386, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=10000;Lane=OrderedTemporal|AllocatedBytesPerEvent": 160.3656, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=10000;Lane=StatelessPredicate|AllocatedBytesPerEvent": 104.32, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=10000;Lane=ThresholdWindow|AllocatedBytesPerEvent": 186.5887, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000;Lane=OrderedTemporal|AllocatedBytesPerEvent": 163.656, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000;Lane=StatelessPredicate|AllocatedBytesPerEvent": 107.2, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000;Lane=ThresholdWindow|AllocatedBytesPerEvent": 179.992 + } +} diff --git a/Benchmarks/EventDetection/detection-throughput-timing-baseline.json b/Benchmarks/EventDetection/detection-throughput-timing-baseline.json new file mode 100644 index 00000000..c2c0968a --- /dev/null +++ b/Benchmarks/EventDetection/detection-throughput-timing-baseline.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "generatedUtc": "2026-09-20T15:00:19.4639571+00:00", + "metrics": { + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000000;Lane=OrderedTemporal|MedianMs": 107.52748333333332, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000000;Lane=StatelessPredicate|MedianMs": 91.15102222222222, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000000;Lane=ThresholdWindow|MedianMs": 482.3738, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=100000;Lane=OrderedTemporal|MedianMs": 10.629079296875, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=100000;Lane=StatelessPredicate|MedianMs": 8.4470671875, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=100000;Lane=ThresholdWindow|MedianMs": 47.57053636363636, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=10000;Lane=OrderedTemporal|MedianMs": 1.0334109375, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=10000;Lane=StatelessPredicate|MedianMs": 0.8798130859375, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=10000;Lane=ThresholdWindow|MedianMs": 4.65337578125, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000;Lane=OrderedTemporal|MedianMs": 0.11401185913085937, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000;Lane=StatelessPredicate|MedianMs": 0.0965123291015625, + "event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000;Lane=ThresholdWindow|MedianMs": 0.459223828125 + } +} diff --git a/Benchmarks/EventLogParsing/Invoke-EventLogParsingBenchmark.ps1 b/Benchmarks/EventLogParsing/Invoke-EventLogParsingBenchmark.ps1 index 0413ddd3..9dd0c08f 100644 --- a/Benchmarks/EventLogParsing/Invoke-EventLogParsingBenchmark.ps1 +++ b/Benchmarks/EventLogParsing/Invoke-EventLogParsingBenchmark.ps1 @@ -65,6 +65,16 @@ param( [string] $OutputRoot, + [string] $BaselinePath, + + [switch] $UpdateBaseline, + + [ValidateRange(0, 10)] + [double] $RelativeTolerance = 0.5, + + [ValidateRange(0, [double]::MaxValue)] + [double] $AbsoluteToleranceMs = 500, + [ValidateRange(0, [int]::MaxValue)] [int] $WarmupCount = 0, @@ -84,6 +94,10 @@ $specPath = Join-Path $PSScriptRoot 'event-log-parsing.benchmark.ps1' Import-Module PSPublishModule -MinimumVersion 3.0.134 -ErrorAction Stop +if ($UpdateBaseline.IsPresent -and $IterationCount -lt 3) { + throw 'Updating a performance baseline requires at least three measured iterations.' +} + if ([bool] $EvtxECmdPath -ne [bool] $EvtxMapsPath) { throw 'EvtxECmdPath and EvtxMapsPath must be supplied together.' } @@ -149,6 +163,15 @@ if ($ReadmeTable -eq 'Reporting') { $Case = 'Typed-Report-Html', 'Typed-Report-Excel', 'Typed-Report-Email', 'Typed-Report-All' $Engine = 'EventViewerXReport' } +$typedFixtureFullPath = $null +$typedFixtureSha256 = $null +if ($TypedFixturePath) { + $typedFixtureFullPath = [IO.Path]::GetFullPath($TypedFixturePath) + if (-not (Test-Path -LiteralPath $typedFixtureFullPath -PathType Leaf)) { + throw "The typed fixture '$typedFixtureFullPath' does not exist." + } + $typedFixtureSha256 = (Get-FileHash -LiteralPath $typedFixtureFullPath -Algorithm SHA256).Hash +} if ($ReadmeTable -eq 'ColdStart') { if ($Case -or $Engine) { throw 'ReadmeTable ColdStart owns its curated Case and Engine matrix. Do not combine it with Case or Engine.' @@ -192,7 +215,8 @@ if ($TypedFixturePath) { if ($ExpectedTypedCount -le 0) { throw 'TypedFixturePath requires a positive ExpectedTypedCount.' } - $variables.TypedFixturePath = [IO.Path]::GetFullPath($TypedFixturePath) + $variables.TypedFixturePath = $typedFixtureFullPath + $variables.TypedFixtureSha256 = $typedFixtureSha256 $variables.ExpectedTypedCount = $ExpectedTypedCount $variables.TypedEventTypes = $TypedEventTypes } @@ -261,6 +285,24 @@ if (-not $Plan) { throw "The benchmark completed with $($failedSamples.Count) failed sample(s):`n$($failureSummary -join "`n")" } + if ($BaselinePath) { + $gate = @{ + SummaryPath = [string] $benchmarkResult.Artifacts['summary.json'] + BaselinePath = [IO.Path]::GetFullPath($BaselinePath) + Metric = 'MedianMs' + GroupBy = @('Suite', 'Scenario', 'Operation', 'Engine', 'Variables') + RelativeTolerance = $RelativeTolerance + AbsoluteToleranceMs = $AbsoluteToleranceMs + Confirm = $false + } + if ($UpdateBaseline.IsPresent) { + $gate.Update = $true + } + Test-BenchmarkGate @gate | Out-Null + } elseif ($UpdateBaseline.IsPresent) { + throw 'UpdateBaseline requires BaselinePath.' + } + $readmePath = Join-Path $PSScriptRoot 'README.md' if ($ReadmeTable -in 'Scale', 'ColdStart', 'Reporting') { $readmePath = Join-Path $repositoryRoot 'README.md' @@ -308,6 +350,8 @@ if (-not $Plan) { -Renderer ComparisonTable ` -Confirm:$false | Out-Null } +} elseif ($UpdateBaseline.IsPresent) { + throw 'A benchmark plan cannot update a performance baseline.' } $benchmarkResult diff --git a/Benchmarks/EventLogParsing/README.md b/Benchmarks/EventLogParsing/README.md index 237f11da..615abd6b 100644 --- a/Benchmarks/EventLogParsing/README.md +++ b/Benchmarks/EventLogParsing/README.md @@ -92,6 +92,11 @@ operation in a retained `output-validation.json` sidecar. After validation succe deleted so repeated large-log samples do not consume unbounded disk space. A failed lane keeps its output for diagnosis. +Typed benchmark baseline keys include the wrapper-calculated fixture SHA-256, +the expected typed count, and the effective report sample count for each case. +This prevents a valid budget from being reused for different EVTX bytes or a +smaller reporting window. + PowerForge records: - end-to-end duration and engine-reported duration; diff --git a/Benchmarks/EventLogParsing/event-log-parsing.benchmark.ps1 b/Benchmarks/EventLogParsing/event-log-parsing.benchmark.ps1 index 95ff27b0..ddeac749 100644 --- a/Benchmarks/EventLogParsing/event-log-parsing.benchmark.ps1 +++ b/Benchmarks/EventLogParsing/event-log-parsing.benchmark.ps1 @@ -13,6 +13,7 @@ $expensiveSampleCount = Get-BenchmarkInput -Name ExpensiveSampleCount -Int -Defa $scaleSampleCountsText = Get-BenchmarkInput -Name ScaleSampleCounts -Default '1000,10000,100000,1000000' $reportSampleCount = Get-BenchmarkInput -Name ReportSampleCount -Int -Default 1000 $typedFixturePath = Get-BenchmarkInput -Name TypedFixturePath +$typedFixtureSha256 = Get-BenchmarkInput -Name TypedFixtureSha256 $expectedTypedCount = Get-BenchmarkInput -Name ExpectedTypedCount -Int -Default 0 $typedEventTypes = Get-BenchmarkInput -Name TypedEventTypes -Default 'ADUserLogon,ADUserLogonFailed,ADUserLockouts' $readmeTable = Get-BenchmarkInput -Name ReadmeTable -Default None @@ -261,7 +262,23 @@ $definitions += [pscustomobject] @{ $caseDefinitions = @{} [array] $cases = foreach ($definition in $definitions) { $caseDefinitions[$definition.Name] = $definition - [pscustomobject] @{ Name = $definition.Name } + if ($definition.Fixture -eq 'Typed') { + if ([string]::IsNullOrWhiteSpace($typedFixtureSha256)) { + throw 'TypedFixtureSha256 is required so typed benchmark baselines identify the exact workload fixture.' + } + [pscustomobject] @{ + Name = $definition.Name + TypedFixtureSha256 = $typedFixtureSha256 + ExpectedTypedCount = $expectedTypedCount + ReportSampleCount = if ($definition.Workload -like 'TypedReport*') { + $definition.MaxEvents + } else { + 0 + } + } + } else { + [pscustomobject] @{ Name = $definition.Name } + } } $commonIdentitySignatures = @{} $exactOutputHashes = @{} diff --git a/Benchmarks/EventLogParsing/reporting-5000-baseline.json b/Benchmarks/EventLogParsing/reporting-5000-baseline.json new file mode 100644 index 00000000..6b75d195 --- /dev/null +++ b/Benchmarks/EventLogParsing/reporting-5000-baseline.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "generatedUtc": "2026-09-20T16:35:13.9685567+00:00", + "metrics": { + "event-log-parsing|Typed-Report-All|Scan|EventViewerXReport|ExpectedTypedCount=5000;ReportSampleCount=1000;TypedFixtureSha256=2B7C5527C6E85DD09A286CB6203EAF266DAE64259D835059A5DB4D953B8C2A5A|MedianMs": 17799.3101, + "event-log-parsing|Typed-Report-Email|Scan|EventViewerXReport|ExpectedTypedCount=5000;ReportSampleCount=1000;TypedFixtureSha256=2B7C5527C6E85DD09A286CB6203EAF266DAE64259D835059A5DB4D953B8C2A5A|MedianMs": 1070.828, + "event-log-parsing|Typed-Report-Excel|Scan|EventViewerXReport|ExpectedTypedCount=5000;ReportSampleCount=1000;TypedFixtureSha256=2B7C5527C6E85DD09A286CB6203EAF266DAE64259D835059A5DB4D953B8C2A5A|MedianMs": 15649.7688, + "event-log-parsing|Typed-Report-Html|Scan|EventViewerXReport|ExpectedTypedCount=5000;ReportSampleCount=1000;TypedFixtureSha256=2B7C5527C6E85DD09A286CB6203EAF266DAE64259D835059A5DB4D953B8C2A5A|MedianMs": 1884.8237 + } +} diff --git a/Benchmarks/EventSources/Invoke-EventSourceFanOutBenchmark.ps1 b/Benchmarks/EventSources/Invoke-EventSourceFanOutBenchmark.ps1 new file mode 100644 index 00000000..df1a776e --- /dev/null +++ b/Benchmarks/EventSources/Invoke-EventSourceFanOutBenchmark.ps1 @@ -0,0 +1,115 @@ +<# +.SYNOPSIS +Measures bounded EventViewerX fan-out across several remote Windows Event Log targets. + +.DESCRIPTION +Captures a fixed record boundary per target, then compares sequential and bounded-parallel +priming over the same exact per-machine event windows. Every sample validates that all +targets contributed the requested count and that both modes returned the same identities. + +.EXAMPLE +.\Invoke-EventSourceFanOutBenchmark.ps1 -MachineName AD0,AD1,AD2 -LogName Security -SampleCount 100,1000 -IterationCount 3 +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateCount(2, 64)] + [string[]] $MachineName, + + [ValidateNotNullOrEmpty()] + [string] $LogName = 'Security', + + [ValidateRange(1, [int]::MaxValue)] + [int[]] $SampleCount = @(100, 1000), + + [ValidateRange(0, 100)] + [int] $WarmupCount = 0, + + [ValidateRange(1, 100)] + [int] $IterationCount = 3, + + [ValidateRange(2, 64)] + [int] $MaxConcurrency = 8, + + [ValidateRange(1, [int]::MaxValue)] + [int] $RemoteConnectionTimeoutMilliseconds = 5000, + + [ValidateRange(1, [int]::MaxValue)] + [int] $RemoteReadTimeoutMilliseconds = 30000, + + [string] $OutputRoot, + + [switch] $Plan, + + [switch] $SkipBuild +) + +$ErrorActionPreference = 'Stop' +$repositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..')).Path +$projectPath = Join-Path $repositoryRoot 'Sources\PSEventViewer\PSEventViewer.csproj' +$modulePath = Join-Path $repositoryRoot 'Sources\PSEventViewer\bin\Release\net10.0-windows\PSEventViewer.dll' +$corePath = Join-Path $repositoryRoot 'Sources\EventViewerX\bin\Release\net10.0-windows\EventViewerX.dll' +$specPath = Join-Path $PSScriptRoot 'event-source-fanout.benchmark.ps1' +if ([string]::IsNullOrWhiteSpace($OutputRoot)) { + $OutputRoot = Join-Path $repositoryRoot 'Ignore\Benchmarks\EventSources\FanOut' +} + +[string[]] $targets = @($MachineName | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Sort-Object -Unique) +if ($targets.Count -lt 2) { + throw 'MachineName must contain at least two distinct non-empty targets.' +} +if (-not $SkipBuild.IsPresent) { + dotnet build $projectPath --configuration Release --framework net10.0-windows + if ($LASTEXITCODE -ne 0) { + throw 'The PSEventViewer Release build failed before the event-source fan-out benchmark.' + } +} + +Import-Module $modulePath -Force -ErrorAction Stop +Import-Module PSPublishModule -MinimumVersion 3.0.134 -Force -ErrorAction Stop +[array] $boundaries = foreach ($target in $targets) { + $boundaryQuery = [EventViewerX.EventLogChannelQuery]::new($LogName) + $boundaryQuery.MachineName = $target + $boundaryQuery.ReadMode = [EventViewerX.EventReadMode]::Metadata + $boundaryQuery.MaxEvents = 1 + $boundaryQuery.RemoteConnectionTimeoutMilliseconds = $RemoteConnectionTimeoutMilliseconds + $boundaryQuery.RemoteReadTimeoutMilliseconds = $RemoteReadTimeoutMilliseconds + $boundaryEvent = [EventViewerX.EventLogEngine]::ReadChannel( + $boundaryQuery, + [Threading.CancellationToken]::None) | Select-Object -First 1 + if ($null -eq $boundaryEvent -or $null -eq $boundaryEvent.RecordId) { + throw "Unable to capture a stable record boundary for '$LogName' on '$target'." + } + [pscustomobject]@{ + MachineName = $target + MaximumRecordId = [long] $boundaryEvent.RecordId + } +} + +$invoke = @{ + Path = $specPath + OutputRoot = [IO.Path]::GetFullPath($OutputRoot) + WarmupCount = $WarmupCount + IterationCount = $IterationCount + RunMode = 'remote' + Variable = @{ + EventViewerXPath = $corePath + TargetsJson = $boundaries | ConvertTo-Json -Compress + LogName = $LogName + SampleCounts = [string] (($SampleCount | Sort-Object -Unique) -join ',') + MaxConcurrency = $MaxConcurrency + RemoteConnectionTimeoutMilliseconds = $RemoteConnectionTimeoutMilliseconds + RemoteReadTimeoutMilliseconds = $RemoteReadTimeoutMilliseconds + } +} +if ($Plan.IsPresent) { + $invoke.Plan = $true +} +$result = Invoke-BenchmarkSuite @invoke +if (-not $Plan.IsPresent) { + $failed = @($result.Summary | Where-Object { $_.FailureCount -gt 0 -or $_.Status -eq 'Failed' }) + if ($failed.Count -gt 0) { + throw "Event-source fan-out benchmark run $($result.RunId) contained failed samples." + } +} +$result diff --git a/Benchmarks/EventSources/event-source-fanout.benchmark.ps1 b/Benchmarks/EventSources/event-source-fanout.benchmark.ps1 new file mode 100644 index 00000000..aa1f2361 --- /dev/null +++ b/Benchmarks/EventSources/event-source-fanout.benchmark.ps1 @@ -0,0 +1,136 @@ +$repositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..')).Path +$eventViewerXPath = Get-BenchmarkInput -Name EventViewerXPath -Default (Join-Path $repositoryRoot 'Sources\EventViewerX\bin\Release\net10.0-windows\EventViewerX.dll') +$targetsJson = Get-BenchmarkInput -Name TargetsJson +$logName = Get-BenchmarkInput -Name LogName -Default Security +$sampleCountsText = Get-BenchmarkInput -Name SampleCounts -Default '100,1000' +$maxConcurrency = Get-BenchmarkInput -Name MaxConcurrency -Int -Default 8 +$remoteConnectionTimeoutMilliseconds = Get-BenchmarkInput -Name RemoteConnectionTimeoutMilliseconds -Int -Default 5000 +$remoteReadTimeoutMilliseconds = Get-BenchmarkInput -Name RemoteReadTimeoutMilliseconds -Int -Default 30000 + +[array] $targets = @($targetsJson | ConvertFrom-Json) +if ($targets.Count -lt 2) { + throw 'Remote fan-out requires at least two target machines.' +} +[int[]] $sampleCounts = @($sampleCountsText.Split(',') | ForEach-Object { + [int] $value = 0 + if (-not [int]::TryParse($_.Trim(), [ref] $value) -or $value -le 0) { + throw "SampleCounts must contain positive 32-bit values. Received '$($_)'." + } + $value + } | Sort-Object -Unique) + +$coreHash = (Get-FileHash -LiteralPath $eventViewerXPath -Algorithm SHA256).Hash +$identitySignatures = @{} + +function Invoke-EventSourceFanOut { + param( + [Parameter(Mandatory)] $Case, + [Parameter(Mandatory)] $Run, + [Parameter(Mandatory)] [int] $Concurrency + ) + + [array] $queries = foreach ($target in $targets) { + $query = [EventViewerX.EventLogChannelQuery]::new($Case.LogName) + $query.MachineName = [string] $target.MachineName + $query.XPath = "*[System[EventRecordID <= $([long] $target.MaximumRecordId)]]" + $query.ReadMode = [EventViewerX.EventReadMode]::Metadata + $query.MaxEvents = $Case.EventsPerMachine + $query.RemoteConnectionTimeoutMilliseconds = $remoteConnectionTimeoutMilliseconds + $query.RemoteReadTimeoutMilliseconds = $remoteReadTimeoutMilliseconds + $query + } + $batch = [EventViewerX.EventLogBatchQuery]::ForChannels( + [EventViewerX.EventLogChannelQuery[]] $queries) + $batch.MaxConcurrency = $Concurrency + + $counts = @{} + $recordIdSums = @{} + [long] $totalCount = 0 + [long] $orderSignature = 0 + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + foreach ($eventRecord in [EventViewerX.EventLogBatchEngine]::Read($batch)) { + $machine = [string] $eventRecord.QueriedMachine + if (-not $counts.ContainsKey($machine)) { + $counts[$machine] = [long] 0 + $recordIdSums[$machine] = [long] 0 + } + [long] $recordId = if ($null -ne $eventRecord.RecordId) { $eventRecord.RecordId } else { 0 } + $counts[$machine] = [long] $counts[$machine] + 1 + $recordIdSums[$machine] = [long] $recordIdSums[$machine] + $recordId + $totalCount++ + $orderSignature = (($orderSignature * 16777619) + $recordId) % 1000000007 + $null = $eventRecord.Id + $null = $eventRecord.ProviderName + $null = $eventRecord.MachineName + $null = $eventRecord.LogName + } + $stopwatch.Stop() + + [array] $sourceSignatures = foreach ($machine in $counts.Keys | Sort-Object) { + '{0}:{1}:{2}' -f $machine.ToUpperInvariant(), $counts[$machine], $recordIdSums[$machine] + } + $Run.Result = [pscustomobject]@{ + TotalCount = $totalCount + SourceCount = $counts.Count + SourceSignatures = $sourceSignatures -join '|' + OrderSignature = $orderSignature + ElapsedMilliseconds = $stopwatch.Elapsed.TotalMilliseconds + } +} + +New-BenchmarkSuite 'event-source-fanout' -OutputRoot (Join-Path $repositoryRoot 'Ignore\Benchmarks\EventSources\FanOut') { + Add-BenchmarkCaseSource @($sampleCounts | ForEach-Object { + [pscustomobject]@{ + Name = "FanOut-$($targets.Count)x$_-$logName" + LogName = $logName + EventsPerMachine = $_ + } + }) + Set-BenchmarkPolicy -Warmup 0 -Iterations 3 -Order Rotated -OutlierMode None + Set-BenchmarkProfile Current -Cleanup Always + Add-BenchmarkMetadata EventViewerXSha256 $coreHash + Add-BenchmarkMetadata Contract 'Same fixed per-machine record boundaries, exact per-machine counts, and deterministic merged identity set' + Add-BenchmarkMetadata TargetMachines (($targets.MachineName | Sort-Object) -join ',') + Add-BenchmarkMetadata TargetLog $logName + + Add-BenchmarkEngine Sequential { + Add-BenchmarkOperation Query { + param($case, $run) + Invoke-EventSourceFanOut -Case $case -Run $run -Concurrency 1 + } + } + Add-BenchmarkEngine Parallel { + Add-BenchmarkOperation Query { + param($case, $run) + Invoke-EventSourceFanOut -Case $case -Run $run -Concurrency ([Math]::Min($maxConcurrency, $targets.Count)) + } + } + + Add-BenchmarkValidation { + param($case, $run) + + [long] $expectedCount = [long] $case.EventsPerMachine * $targets.Count + Assert-BenchmarkValue -Actual ([long] $run.Result.TotalCount) -Expected $expectedCount -Message 'Fan-out must return the requested count from every target.' + Assert-BenchmarkValue -Actual ([int] $run.Result.SourceCount) -Expected $targets.Count -Message 'Fan-out must return records from every target machine.' + $signature = '{0}|{1}|{2}|{3}' -f + $run.Result.TotalCount, + $run.Result.SourceCount, + $run.Result.SourceSignatures, + $run.Result.OrderSignature + if ($identitySignatures.ContainsKey($case.Scenario)) { + Assert-BenchmarkValue -Actual $signature -Expected $identitySignatures[$case.Scenario] -Message 'Sequential and parallel fan-out must return the same merged identity set.' + } else { + $identitySignatures[$case.Scenario] = $signature + } + } + + Add-BenchmarkMetric EventsPerSecond { + param($case, $run) + [Math]::Round($run.Result.TotalCount / ($run.Result.ElapsedMilliseconds / 1000), 2) + } + Add-BenchmarkMetric Events { param($case, $run) [long] $run.Result.TotalCount } + Add-BenchmarkMetric Sources { param($case, $run) [int] $run.Result.SourceCount } + Add-BenchmarkMetric OrderSignature { param($case, $run) [long] $run.Result.OrderSignature } + Add-BenchmarkComparison Engine -Baseline Sequential -Metric MedianMs -TieTolerance 0.05 + Set-BenchmarkArtifacts Json, Csv, Markdown +} diff --git a/Benchmarks/EventStore/Invoke-EventStoreBenchmark.ps1 b/Benchmarks/EventStore/Invoke-EventStoreBenchmark.ps1 index 8b4cf6f7..0a204d49 100644 --- a/Benchmarks/EventStore/Invoke-EventStoreBenchmark.ps1 +++ b/Benchmarks/EventStore/Invoke-EventStoreBenchmark.ps1 @@ -21,6 +21,16 @@ param( [string] $OutputRoot, + [string] $BaselinePath, + + [switch] $UpdateBaseline, + + [ValidateRange(0, 10)] + [double] $RelativeTolerance = 0.5, + + [ValidateRange(0, [double]::MaxValue)] + [double] $AbsoluteToleranceMs = 250, + [switch] $SkipBuild, [switch] $Plan, @@ -31,13 +41,16 @@ param( $ErrorActionPreference = 'Stop' $repositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..')).Path $projectPath = Join-Path $repositoryRoot 'Sources\PSEventViewer\PSEventViewer.csproj' -$modulePath = Join-Path $repositoryRoot 'PSEventViewer.psd1' +$runtimeRoot = Join-Path $repositoryRoot 'Sources\PSEventViewer\bin\Release\net8.0-windows' $specPath = Join-Path $PSScriptRoot 'event-store.benchmark.ps1' $fixtureProjectPath = Join-Path $PSScriptRoot 'EventStore.BenchmarkFixture.csproj' $fixtureAssemblyPath = Join-Path $PSScriptRoot 'bin\Release\net8.0-windows\EventStore.BenchmarkFixture.dll' if ([string]::IsNullOrWhiteSpace($OutputRoot)) { $OutputRoot = Join-Path $repositoryRoot 'Ignore\Benchmarks\EventStore' } +if ($UpdateBaseline.IsPresent -and $IterationCount -lt 3) { + throw 'Updating a performance baseline requires at least three measured iterations.' +} $resolvedRowCounts = @($RowCount | ForEach-Object { foreach ($token in $_.Split(',')) { [int] $value = 0 @@ -53,7 +66,7 @@ $resolvedRowCounts = @($RowCount | ForEach-Object { } | Sort-Object -Unique) if (-not $SkipBuild.IsPresent) { - dotnet build $projectPath --configuration Release --framework net10.0-windows + dotnet build $projectPath --configuration Release --framework net8.0-windows if ($LASTEXITCODE -ne 0) { throw 'The PSEventViewer Release build failed before the event-store benchmark.' } @@ -63,7 +76,29 @@ if (-not $SkipBuild.IsPresent) { } } -Import-Module $modulePath -Force -ErrorAction Stop +$nativeArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant() +$nativeSQLitePath = Join-Path $runtimeRoot "runtimes\win-$nativeArchitecture\native\e_sqlite3.dll" +if (-not (Test-Path -LiteralPath $nativeSQLitePath -PathType Leaf)) { + throw "The event-store benchmark SQLite runtime '$nativeSQLitePath' does not exist." +} +[System.Runtime.InteropServices.NativeLibrary]::Load($nativeSQLitePath) | Out-Null +foreach ($assemblyName in @( + 'EventViewerX.dll' + 'EventViewerX.Reporting.dll' + 'SQLitePCLRaw.core.dll' + 'SQLitePCLRaw.provider.e_sqlite3.dll' + 'SQLitePCLRaw.batteries_v2.dll' + 'Microsoft.Data.Sqlite.dll' + 'DbaClientX.Core.dll' + 'DbaClientX.SQLite.dll' + 'EventViewerX.Storage.dll' + )) { + $assemblyPath = Join-Path $runtimeRoot $assemblyName + if (-not (Test-Path -LiteralPath $assemblyPath -PathType Leaf)) { + throw "The event-store benchmark assembly '$assemblyPath' does not exist." + } + [System.Reflection.Assembly]::LoadFrom($assemblyPath) | Out-Null +} Add-Type -Path $fixtureAssemblyPath -ErrorAction Stop Import-Module PSPublishModule -MinimumVersion 3.0.134 -Force -ErrorAction Stop $results = foreach ($currentRowCount in $resolvedRowCounts) { @@ -86,6 +121,28 @@ if (-not $Plan.IsPresent) { if ($failed.Count -gt 0) { throw "One or more event-store benchmark runs contained failed samples." } + if ($BaselinePath) { + if ($results.Count -ne 1) { + throw 'BaselinePath requires exactly one RowCount so a run cannot silently overwrite or compare the wrong scale baseline.' + } + $gate = @{ + SummaryPath = [string] $results[0].Artifacts['summary.json'] + BaselinePath = [IO.Path]::GetFullPath($BaselinePath) + Metric = 'MedianMs' + GroupBy = @('Suite', 'Scenario', 'Operation', 'Engine', 'Variables') + RelativeTolerance = $RelativeTolerance + AbsoluteToleranceMs = $AbsoluteToleranceMs + Confirm = $false + } + if ($UpdateBaseline.IsPresent) { + $gate.Update = $true + } + Test-BenchmarkGate @gate | Out-Null + } elseif ($UpdateBaseline.IsPresent) { + throw 'UpdateBaseline requires BaselinePath.' + } +} elseif ($UpdateBaseline.IsPresent) { + throw 'A benchmark plan cannot update a performance baseline.' } if ($UpdateReadme.IsPresent) { if ($Plan.IsPresent) { diff --git a/Benchmarks/EventStore/event-store-100000-baseline.json b/Benchmarks/EventStore/event-store-100000-baseline.json new file mode 100644 index 00000000..de09497f --- /dev/null +++ b/Benchmarks/EventStore/event-store-100000-baseline.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "generatedUtc": "2026-09-20T14:58:56.4137178+00:00", + "metrics": { + "event-store|DailySummary-100000|Execute|EventViewerXStorage|RowCount=100000;Workload=DailySummary|MedianMs": 166.097, + "event-store|ManagedQuery-100000|Execute|EventViewerXStorage|RowCount=100000;Workload=ManagedQuery|MedianMs": 1707.0471, + "event-store|SqlQuery-100000|Execute|EventViewerXStorage|RowCount=100000;Workload=SqlQuery|MedianMs": 329.5802, + "event-store|TypedCsv-100000|Execute|EventViewerXStorage|RowCount=100000;Workload=TypedCsv|MedianMs": 83.3207, + "event-store|Write-100000|Execute|EventViewerXStorage|RowCount=100000;Workload=Write|MedianMs": 7906.9641 + } +} diff --git a/Benchmarks/EventWatcher/Invoke-EventWatcherBurstBenchmark.ps1 b/Benchmarks/EventWatcher/Invoke-EventWatcherBurstBenchmark.ps1 index 0065cb01..51f2cb60 100644 --- a/Benchmarks/EventWatcher/Invoke-EventWatcherBurstBenchmark.ps1 +++ b/Benchmarks/EventWatcher/Invoke-EventWatcherBurstBenchmark.ps1 @@ -23,6 +23,16 @@ param( [string] $OutputRoot, + [string] $BaselinePath, + + [switch] $UpdateBaseline, + + [ValidateRange(0, 10)] + [double] $RelativeTolerance = 0.5, + + [ValidateRange(0, [double]::MaxValue)] + [double] $AbsoluteToleranceMs = 250, + [switch] $Plan, [switch] $SkipBuild @@ -37,6 +47,9 @@ $specPath = Join-Path $PSScriptRoot 'event-watcher-burst.benchmark.ps1' if ([string]::IsNullOrWhiteSpace($OutputRoot)) { $OutputRoot = Join-Path $repositoryRoot 'Ignore\Benchmarks\EventWatcher' } +if ($UpdateBaseline.IsPresent -and $IterationCount -lt 3) { + throw 'Updating a performance baseline requires at least three measured iterations.' +} if (-not $SkipBuild.IsPresent) { dotnet build $projectPath --configuration Release --framework net10.0 @@ -74,5 +87,24 @@ if (-not $Plan.IsPresent) { if ($failed.Count -gt 0) { throw "Watcher burst benchmark run $($result.RunId) contained failed samples." } + if ($BaselinePath) { + $gate = @{ + SummaryPath = [string] $result.Artifacts['summary.json'] + BaselinePath = [IO.Path]::GetFullPath($BaselinePath) + Metric = 'MedianMs' + GroupBy = @('Suite', 'Scenario', 'Operation', 'Engine', 'Variables') + RelativeTolerance = $RelativeTolerance + AbsoluteToleranceMs = $AbsoluteToleranceMs + Confirm = $false + } + if ($UpdateBaseline.IsPresent) { + $gate.Update = $true + } + Test-BenchmarkGate @gate | Out-Null + } elseif ($UpdateBaseline.IsPresent) { + throw 'UpdateBaseline requires BaselinePath.' + } +} elseif ($UpdateBaseline.IsPresent) { + throw 'A benchmark plan cannot update a performance baseline.' } $result diff --git a/Benchmarks/EventWatcher/watcher-baseline.json b/Benchmarks/EventWatcher/watcher-baseline.json new file mode 100644 index 00000000..c97650da --- /dev/null +++ b/Benchmarks/EventWatcher/watcher-baseline.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": 1, + "generatedUtc": "2026-09-20T14:58:45.7896331+00:00", + "metrics": { + "event-watcher-burst|Burst-10000|Deliver|EventViewerXCli|EventCount=10000|MedianMs": 1398.6531, + "event-watcher-burst|Burst-1000|Deliver|EventViewerXCli|EventCount=1000|MedianMs": 181.7987, + "event-watcher-burst|Burst-100|Deliver|EventViewerXCli|EventCount=100|MedianMs": 92.8143 + } +} diff --git a/Docs/Get-EVXCollectorSubscription.md b/Docs/Get-EVXCollectorSubscription.md index bb7045bb..94be73fd 100644 --- a/Docs/Get-EVXCollectorSubscription.md +++ b/Docs/Get-EVXCollectorSubscription.md @@ -13,7 +13,7 @@ Reads local or remote WEC subscription inventory and returns detached snapshots ## SYNTAX ### Subscriptions (Default) ```powershell -Get-EVXCollectorSubscription [[-Name] ] [-MachineName ] [-EnabledOnly] [-IncludeRuntimeStatus] [] +Get-EVXCollectorSubscription [[-Name] ] [-MachineName ] [-EnabledOnly] [-IncludeRuntimeStatus] [-IncludeSourceAuthorization] [] ``` ### Readiness @@ -56,6 +56,13 @@ Get-EVXCollectorSubscription -Name 'Domain controller authentication' -IncludeRu Adds processed-event counters, source heartbeat timestamps, and native Windows errors to the local snapshot. +### EXAMPLE 5 +```powershell +Get-EVXCollectorSubscription -Name 'Domain controller authentication' -IncludeSourceAuthorization +``` + +Reads the local collector's authoritative subscription XML and adds the domain-computer DACL and raw certificate subject policy. This does not calculate effective authorization. + ## PARAMETERS ### -EnabledOnly @@ -90,6 +97,22 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -IncludeSourceAuthorization +Reads domain-computer and non-domain certificate source authorization from the local collector's authoritative subscription configuration. + +```yaml +Type: SwitchParameter +Parameter Sets: Subscriptions +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -MachineName Collector computers. Omit for the local computer. diff --git a/Docs/Get-EVXEvent.md b/Docs/Get-EVXEvent.md index cf67a2d9..7ba4defc 100644 --- a/Docs/Get-EVXEvent.md +++ b/Docs/Get-EVXEvent.md @@ -989,7 +989,7 @@ One or more built-in typed event definitions to query. Each type owns its source Type: EventType[] Parameter Sets: Type Aliases: NamedEvent, NamedEvents -Possible values: ADComputerCreateChange, ADComputerDeleted, ADComputerChangeDetailed, ADGroupMembershipChange, ADGroupEnumeration, ADGroupChange, ADGroupCreateDelete, ADGroupChangeDetailed, ADGroupPolicyChanges, ADGroupPolicyEdits, ADGroupPolicyLinks, ADGroupPolicyChangesDetailed, GpoCreated, GpoDeleted, GpoModified, ADLdapBindingSummary, ADLdapBindingDetails, ADUserCreateChange, ADUserStatus, ADUserChangeDetailed, ADUserLockouts, ADUserLogon, ADUserLogonNTLMv1, ADUserLogonFailed, ADUserUnlocked, ADUserPrivilegeUse, ADUserRightsAssignment, KerberosTGTRequest, KerberosServiceTicket, KerberosTicketFailure, KerberosPolicyChange, KerberosKdcRc4Audit, ADOrganizationalUnitChangeDetailed, ADOtherChangeDetailed, ADSMBServerAuditV1, LogsClearedSecurity, LogsClearedOther, LogsFullSecurity, NetworkAccessAuthenticationPolicy, CertificateIssued, AuditPolicyChange, FirewallRuleChange, DhcpLeaseCreated, BitLockerKeyChange, BitLockerSuspended, DeviceRecognized, DeviceDisabled, ObjectDeletion, ScheduledTaskDeleted, ScheduledTaskCreated, OSCrash, OSBugCheck, OSStartup, OSShutdown, OSUncleanShutdown, OSStartupSecurity, OSCrashOnAuditFailRecovery, OSTimeChange, WindowsUpdateFailure, ClientGroupPoliciesApplication, ClientGroupPoliciesSystem, HyperVVirtualMachineShutdown, HyperVVirtualMachineStarted, IISSiteBindingFailure, HyperVCheckpointCreated, IISSiteStopped, ExchangeDatabaseMounted, DfsReplicationError, SqlDatabaseCreated, SyncCompleted, AADConnectStagingEnabled, AADConnectStagingDisabled, AADConnectPasswordSyncFailed, AADConnectRunProfile, AADSyncCycleStage, AADSyncProvisionCredentialsPing, AADSyncPasswordHashSyncStatus, AADSyncImportStatus, AADSyncFilterStatus, NetworkMonitorDriverLoaded, NetworkPromiscuousMode, ActiveDirectoryAuthentication, ActiveDirectoryAccountLifecycle, ActiveDirectoryChanges, GroupPolicyActivity, KerberosActivity, OperatingSystemLifecycle, WindowsSecurityChanges, EntraConnectHealth, NetworkSecurity, InfrastructureHealth, ScheduledTaskEnabled, ScheduledTaskDisabled, ScheduledTaskUpdated, FirewallRuleAdded, FirewallRuleDeleted, DefenderThreatDetected, DefenderThreatAction, DefenderConfigurationChanged, ScheduledTaskActivity, FirewallRuleActivity, DefenderSecurity, AuthenticationHealth, GroupPolicyDirectoryAudit +Possible values: ADComputerCreateChange, ADComputerDeleted, ADComputerChangeDetailed, ADGroupMembershipChange, ADGroupEnumeration, ADGroupChange, ADGroupCreateDelete, ADGroupChangeDetailed, ADGroupPolicyChanges, ADGroupPolicyEdits, ADGroupPolicyLinks, ADGroupPolicyChangesDetailed, GpoCreated, GpoDeleted, GpoModified, ADLdapBindingSummary, ADLdapBindingDetails, ADUserCreateChange, ADUserStatus, ADUserChangeDetailed, ADUserLockouts, ADUserLogon, ADUserLogonNTLMv1, ADUserLogonFailed, ADUserUnlocked, ADUserPrivilegeUse, ADUserRightsAssignment, KerberosTGTRequest, KerberosServiceTicket, KerberosTicketFailure, KerberosPolicyChange, ADOrganizationalUnitChangeDetailed, ADOtherChangeDetailed, ADSMBServerAuditV1, LogsClearedSecurity, LogsClearedOther, LogsFullSecurity, NetworkAccessAuthenticationPolicy, CertificateIssued, AuditPolicyChange, FirewallRuleChange, DhcpLeaseCreated, BitLockerKeyChange, BitLockerSuspended, DeviceRecognized, DeviceDisabled, ObjectDeletion, ScheduledTaskDeleted, ScheduledTaskCreated, OSCrash, OSBugCheck, OSStartup, OSShutdown, OSUncleanShutdown, OSStartupSecurity, OSCrashOnAuditFailRecovery, OSTimeChange, WindowsUpdateFailure, ClientGroupPoliciesApplication, ClientGroupPoliciesSystem, HyperVVirtualMachineShutdown, HyperVVirtualMachineStarted, IISSiteBindingFailure, HyperVCheckpointCreated, IISSiteStopped, ExchangeDatabaseMounted, DfsReplicationError, SqlDatabaseCreated, SyncCompleted, AADConnectStagingEnabled, AADConnectStagingDisabled, AADConnectPasswordSyncFailed, AADConnectRunProfile, AADSyncCycleStage, AADSyncProvisionCredentialsPing, AADSyncPasswordHashSyncStatus, AADSyncImportStatus, AADSyncFilterStatus, NetworkMonitorDriverLoaded, NetworkPromiscuousMode, ActiveDirectoryAuthentication, ActiveDirectoryAccountLifecycle, ActiveDirectoryChanges, GroupPolicyActivity, KerberosActivity, OperatingSystemLifecycle, WindowsSecurityChanges, EntraConnectHealth, NetworkSecurity, InfrastructureHealth, ScheduledTaskEnabled, ScheduledTaskDisabled, ScheduledTaskUpdated, FirewallRuleAdded, FirewallRuleDeleted, DefenderThreatDetected, DefenderThreatAction, DefenderConfigurationChanged, ScheduledTaskActivity, FirewallRuleActivity, DefenderSecurity, AuthenticationHealth, GroupPolicyDirectoryAudit, KerberosKdcRc4Audit Required: True Position: 0 diff --git a/Docs/Get-EVXRequirement.md b/Docs/Get-EVXRequirement.md index dbfae777..1ef7232f 100644 --- a/Docs/Get-EVXRequirement.md +++ b/Docs/Get-EVXRequirement.md @@ -46,7 +46,7 @@ Built-in event types to inspect. Omit to return every type. Type: EventType[] Parameter Sets: __AllParameterSets Aliases: None -Possible values: ADComputerCreateChange, ADComputerDeleted, ADComputerChangeDetailed, ADGroupMembershipChange, ADGroupEnumeration, ADGroupChange, ADGroupCreateDelete, ADGroupChangeDetailed, ADGroupPolicyChanges, ADGroupPolicyEdits, ADGroupPolicyLinks, ADGroupPolicyChangesDetailed, GpoCreated, GpoDeleted, GpoModified, ADLdapBindingSummary, ADLdapBindingDetails, ADUserCreateChange, ADUserStatus, ADUserChangeDetailed, ADUserLockouts, ADUserLogon, ADUserLogonNTLMv1, ADUserLogonFailed, ADUserUnlocked, ADUserPrivilegeUse, ADUserRightsAssignment, KerberosTGTRequest, KerberosServiceTicket, KerberosTicketFailure, KerberosPolicyChange, KerberosKdcRc4Audit, ADOrganizationalUnitChangeDetailed, ADOtherChangeDetailed, ADSMBServerAuditV1, LogsClearedSecurity, LogsClearedOther, LogsFullSecurity, NetworkAccessAuthenticationPolicy, CertificateIssued, AuditPolicyChange, FirewallRuleChange, DhcpLeaseCreated, BitLockerKeyChange, BitLockerSuspended, DeviceRecognized, DeviceDisabled, ObjectDeletion, ScheduledTaskDeleted, ScheduledTaskCreated, OSCrash, OSBugCheck, OSStartup, OSShutdown, OSUncleanShutdown, OSStartupSecurity, OSCrashOnAuditFailRecovery, OSTimeChange, WindowsUpdateFailure, ClientGroupPoliciesApplication, ClientGroupPoliciesSystem, HyperVVirtualMachineShutdown, HyperVVirtualMachineStarted, IISSiteBindingFailure, HyperVCheckpointCreated, IISSiteStopped, ExchangeDatabaseMounted, DfsReplicationError, SqlDatabaseCreated, SyncCompleted, AADConnectStagingEnabled, AADConnectStagingDisabled, AADConnectPasswordSyncFailed, AADConnectRunProfile, AADSyncCycleStage, AADSyncProvisionCredentialsPing, AADSyncPasswordHashSyncStatus, AADSyncImportStatus, AADSyncFilterStatus, NetworkMonitorDriverLoaded, NetworkPromiscuousMode, ActiveDirectoryAuthentication, ActiveDirectoryAccountLifecycle, ActiveDirectoryChanges, GroupPolicyActivity, KerberosActivity, OperatingSystemLifecycle, WindowsSecurityChanges, EntraConnectHealth, NetworkSecurity, InfrastructureHealth, ScheduledTaskEnabled, ScheduledTaskDisabled, ScheduledTaskUpdated, FirewallRuleAdded, FirewallRuleDeleted, DefenderThreatDetected, DefenderThreatAction, DefenderConfigurationChanged, ScheduledTaskActivity, FirewallRuleActivity, DefenderSecurity, AuthenticationHealth, GroupPolicyDirectoryAudit +Possible values: ADComputerCreateChange, ADComputerDeleted, ADComputerChangeDetailed, ADGroupMembershipChange, ADGroupEnumeration, ADGroupChange, ADGroupCreateDelete, ADGroupChangeDetailed, ADGroupPolicyChanges, ADGroupPolicyEdits, ADGroupPolicyLinks, ADGroupPolicyChangesDetailed, GpoCreated, GpoDeleted, GpoModified, ADLdapBindingSummary, ADLdapBindingDetails, ADUserCreateChange, ADUserStatus, ADUserChangeDetailed, ADUserLockouts, ADUserLogon, ADUserLogonNTLMv1, ADUserLogonFailed, ADUserUnlocked, ADUserPrivilegeUse, ADUserRightsAssignment, KerberosTGTRequest, KerberosServiceTicket, KerberosTicketFailure, KerberosPolicyChange, ADOrganizationalUnitChangeDetailed, ADOtherChangeDetailed, ADSMBServerAuditV1, LogsClearedSecurity, LogsClearedOther, LogsFullSecurity, NetworkAccessAuthenticationPolicy, CertificateIssued, AuditPolicyChange, FirewallRuleChange, DhcpLeaseCreated, BitLockerKeyChange, BitLockerSuspended, DeviceRecognized, DeviceDisabled, ObjectDeletion, ScheduledTaskDeleted, ScheduledTaskCreated, OSCrash, OSBugCheck, OSStartup, OSShutdown, OSUncleanShutdown, OSStartupSecurity, OSCrashOnAuditFailRecovery, OSTimeChange, WindowsUpdateFailure, ClientGroupPoliciesApplication, ClientGroupPoliciesSystem, HyperVVirtualMachineShutdown, HyperVVirtualMachineStarted, IISSiteBindingFailure, HyperVCheckpointCreated, IISSiteStopped, ExchangeDatabaseMounted, DfsReplicationError, SqlDatabaseCreated, SyncCompleted, AADConnectStagingEnabled, AADConnectStagingDisabled, AADConnectPasswordSyncFailed, AADConnectRunProfile, AADSyncCycleStage, AADSyncProvisionCredentialsPing, AADSyncPasswordHashSyncStatus, AADSyncImportStatus, AADSyncFilterStatus, NetworkMonitorDriverLoaded, NetworkPromiscuousMode, ActiveDirectoryAuthentication, ActiveDirectoryAccountLifecycle, ActiveDirectoryChanges, GroupPolicyActivity, KerberosActivity, OperatingSystemLifecycle, WindowsSecurityChanges, EntraConnectHealth, NetworkSecurity, InfrastructureHealth, ScheduledTaskEnabled, ScheduledTaskDisabled, ScheduledTaskUpdated, FirewallRuleAdded, FirewallRuleDeleted, DefenderThreatDetected, DefenderThreatAction, DefenderConfigurationChanged, ScheduledTaskActivity, FirewallRuleActivity, DefenderSecurity, AuthenticationHealth, GroupPolicyDirectoryAudit, KerberosKdcRc4Audit Required: False Position: 0 diff --git a/Docs/Import-EVXSigmaRule.md b/Docs/Import-EVXSigmaRule.md index 5593227a..099b929a 100644 --- a/Docs/Import-EVXSigmaRule.md +++ b/Docs/Import-EVXSigmaRule.md @@ -13,12 +13,12 @@ The YAML adapter is separate from the detection engine because it adds a YAML de ## SYNTAX ### Rule (Default) ```powershell -Import-EVXSigmaRule [-Path] [] +Import-EVXSigmaRule [-Path] [-TelemetryProfile ] [] ``` ### Pack ```powershell -Import-EVXSigmaRule [-Path] -AsPack -PackId -Version [] +Import-EVXSigmaRule [-Path] -AsPack -PackId -Version [-TelemetryProfile ] [] ``` ## DESCRIPTION @@ -92,6 +92,23 @@ Accept pipeline input: True (ByValue, ByPropertyName) Accept wildcard characters: True ``` +### -TelemetryProfile +Explicit telemetry assumptions used for category-only Sigma log sources. +Strict is lossless and rejects categories without exact native selectors. + +```yaml +Type: String +Parameter Sets: Rule, Pack +Aliases: None +Possible values: Strict, WindowsSysmonAndPowerShell + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Version Semantic pack version used with AsPack. diff --git a/Docs/Measure-EVXEvent.md b/Docs/Measure-EVXEvent.md index 0e3a56c0..3f515603 100644 --- a/Docs/Measure-EVXEvent.md +++ b/Docs/Measure-EVXEvent.md @@ -350,7 +350,7 @@ Stored built-in event types to include. Type: EventType[] Parameter Sets: Store Aliases: None -Possible values: ADComputerCreateChange, ADComputerDeleted, ADComputerChangeDetailed, ADGroupMembershipChange, ADGroupEnumeration, ADGroupChange, ADGroupCreateDelete, ADGroupChangeDetailed, ADGroupPolicyChanges, ADGroupPolicyEdits, ADGroupPolicyLinks, ADGroupPolicyChangesDetailed, GpoCreated, GpoDeleted, GpoModified, ADLdapBindingSummary, ADLdapBindingDetails, ADUserCreateChange, ADUserStatus, ADUserChangeDetailed, ADUserLockouts, ADUserLogon, ADUserLogonNTLMv1, ADUserLogonFailed, ADUserUnlocked, ADUserPrivilegeUse, ADUserRightsAssignment, KerberosTGTRequest, KerberosServiceTicket, KerberosTicketFailure, KerberosPolicyChange, KerberosKdcRc4Audit, ADOrganizationalUnitChangeDetailed, ADOtherChangeDetailed, ADSMBServerAuditV1, LogsClearedSecurity, LogsClearedOther, LogsFullSecurity, NetworkAccessAuthenticationPolicy, CertificateIssued, AuditPolicyChange, FirewallRuleChange, DhcpLeaseCreated, BitLockerKeyChange, BitLockerSuspended, DeviceRecognized, DeviceDisabled, ObjectDeletion, ScheduledTaskDeleted, ScheduledTaskCreated, OSCrash, OSBugCheck, OSStartup, OSShutdown, OSUncleanShutdown, OSStartupSecurity, OSCrashOnAuditFailRecovery, OSTimeChange, WindowsUpdateFailure, ClientGroupPoliciesApplication, ClientGroupPoliciesSystem, HyperVVirtualMachineShutdown, HyperVVirtualMachineStarted, IISSiteBindingFailure, HyperVCheckpointCreated, IISSiteStopped, ExchangeDatabaseMounted, DfsReplicationError, SqlDatabaseCreated, SyncCompleted, AADConnectStagingEnabled, AADConnectStagingDisabled, AADConnectPasswordSyncFailed, AADConnectRunProfile, AADSyncCycleStage, AADSyncProvisionCredentialsPing, AADSyncPasswordHashSyncStatus, AADSyncImportStatus, AADSyncFilterStatus, NetworkMonitorDriverLoaded, NetworkPromiscuousMode, ActiveDirectoryAuthentication, ActiveDirectoryAccountLifecycle, ActiveDirectoryChanges, GroupPolicyActivity, KerberosActivity, OperatingSystemLifecycle, WindowsSecurityChanges, EntraConnectHealth, NetworkSecurity, InfrastructureHealth, ScheduledTaskEnabled, ScheduledTaskDisabled, ScheduledTaskUpdated, FirewallRuleAdded, FirewallRuleDeleted, DefenderThreatDetected, DefenderThreatAction, DefenderConfigurationChanged, ScheduledTaskActivity, FirewallRuleActivity, DefenderSecurity, AuthenticationHealth, GroupPolicyDirectoryAudit +Possible values: ADComputerCreateChange, ADComputerDeleted, ADComputerChangeDetailed, ADGroupMembershipChange, ADGroupEnumeration, ADGroupChange, ADGroupCreateDelete, ADGroupChangeDetailed, ADGroupPolicyChanges, ADGroupPolicyEdits, ADGroupPolicyLinks, ADGroupPolicyChangesDetailed, GpoCreated, GpoDeleted, GpoModified, ADLdapBindingSummary, ADLdapBindingDetails, ADUserCreateChange, ADUserStatus, ADUserChangeDetailed, ADUserLockouts, ADUserLogon, ADUserLogonNTLMv1, ADUserLogonFailed, ADUserUnlocked, ADUserPrivilegeUse, ADUserRightsAssignment, KerberosTGTRequest, KerberosServiceTicket, KerberosTicketFailure, KerberosPolicyChange, ADOrganizationalUnitChangeDetailed, ADOtherChangeDetailed, ADSMBServerAuditV1, LogsClearedSecurity, LogsClearedOther, LogsFullSecurity, NetworkAccessAuthenticationPolicy, CertificateIssued, AuditPolicyChange, FirewallRuleChange, DhcpLeaseCreated, BitLockerKeyChange, BitLockerSuspended, DeviceRecognized, DeviceDisabled, ObjectDeletion, ScheduledTaskDeleted, ScheduledTaskCreated, OSCrash, OSBugCheck, OSStartup, OSShutdown, OSUncleanShutdown, OSStartupSecurity, OSCrashOnAuditFailRecovery, OSTimeChange, WindowsUpdateFailure, ClientGroupPoliciesApplication, ClientGroupPoliciesSystem, HyperVVirtualMachineShutdown, HyperVVirtualMachineStarted, IISSiteBindingFailure, HyperVCheckpointCreated, IISSiteStopped, ExchangeDatabaseMounted, DfsReplicationError, SqlDatabaseCreated, SyncCompleted, AADConnectStagingEnabled, AADConnectStagingDisabled, AADConnectPasswordSyncFailed, AADConnectRunProfile, AADSyncCycleStage, AADSyncProvisionCredentialsPing, AADSyncPasswordHashSyncStatus, AADSyncImportStatus, AADSyncFilterStatus, NetworkMonitorDriverLoaded, NetworkPromiscuousMode, ActiveDirectoryAuthentication, ActiveDirectoryAccountLifecycle, ActiveDirectoryChanges, GroupPolicyActivity, KerberosActivity, OperatingSystemLifecycle, WindowsSecurityChanges, EntraConnectHealth, NetworkSecurity, InfrastructureHealth, ScheduledTaskEnabled, ScheduledTaskDisabled, ScheduledTaskUpdated, FirewallRuleAdded, FirewallRuleDeleted, DefenderThreatDetected, DefenderThreatAction, DefenderConfigurationChanged, ScheduledTaskActivity, FirewallRuleActivity, DefenderSecurity, AuthenticationHealth, GroupPolicyDirectoryAudit, KerberosKdcRc4Audit Required: False Position: named diff --git a/Docs/New-EVXCollectorSubscription.md b/Docs/New-EVXCollectorSubscription.md index 394caf69..4ad250f5 100644 --- a/Docs/New-EVXCollectorSubscription.md +++ b/Docs/New-EVXCollectorSubscription.md @@ -601,7 +601,7 @@ Built-in leaf or composite event types. Their definitions own source channels an Type: EventType[] Parameter Sets: Type Aliases: None -Possible values: ADComputerCreateChange, ADComputerDeleted, ADComputerChangeDetailed, ADGroupMembershipChange, ADGroupEnumeration, ADGroupChange, ADGroupCreateDelete, ADGroupChangeDetailed, ADGroupPolicyChanges, ADGroupPolicyEdits, ADGroupPolicyLinks, ADGroupPolicyChangesDetailed, GpoCreated, GpoDeleted, GpoModified, ADLdapBindingSummary, ADLdapBindingDetails, ADUserCreateChange, ADUserStatus, ADUserChangeDetailed, ADUserLockouts, ADUserLogon, ADUserLogonNTLMv1, ADUserLogonFailed, ADUserUnlocked, ADUserPrivilegeUse, ADUserRightsAssignment, KerberosTGTRequest, KerberosServiceTicket, KerberosTicketFailure, KerberosPolicyChange, KerberosKdcRc4Audit, ADOrganizationalUnitChangeDetailed, ADOtherChangeDetailed, ADSMBServerAuditV1, LogsClearedSecurity, LogsClearedOther, LogsFullSecurity, NetworkAccessAuthenticationPolicy, CertificateIssued, AuditPolicyChange, FirewallRuleChange, DhcpLeaseCreated, BitLockerKeyChange, BitLockerSuspended, DeviceRecognized, DeviceDisabled, ObjectDeletion, ScheduledTaskDeleted, ScheduledTaskCreated, OSCrash, OSBugCheck, OSStartup, OSShutdown, OSUncleanShutdown, OSStartupSecurity, OSCrashOnAuditFailRecovery, OSTimeChange, WindowsUpdateFailure, ClientGroupPoliciesApplication, ClientGroupPoliciesSystem, HyperVVirtualMachineShutdown, HyperVVirtualMachineStarted, IISSiteBindingFailure, HyperVCheckpointCreated, IISSiteStopped, ExchangeDatabaseMounted, DfsReplicationError, SqlDatabaseCreated, SyncCompleted, AADConnectStagingEnabled, AADConnectStagingDisabled, AADConnectPasswordSyncFailed, AADConnectRunProfile, AADSyncCycleStage, AADSyncProvisionCredentialsPing, AADSyncPasswordHashSyncStatus, AADSyncImportStatus, AADSyncFilterStatus, NetworkMonitorDriverLoaded, NetworkPromiscuousMode, ActiveDirectoryAuthentication, ActiveDirectoryAccountLifecycle, ActiveDirectoryChanges, GroupPolicyActivity, KerberosActivity, OperatingSystemLifecycle, WindowsSecurityChanges, EntraConnectHealth, NetworkSecurity, InfrastructureHealth, ScheduledTaskEnabled, ScheduledTaskDisabled, ScheduledTaskUpdated, FirewallRuleAdded, FirewallRuleDeleted, DefenderThreatDetected, DefenderThreatAction, DefenderConfigurationChanged, ScheduledTaskActivity, FirewallRuleActivity, DefenderSecurity, AuthenticationHealth, GroupPolicyDirectoryAudit +Possible values: ADComputerCreateChange, ADComputerDeleted, ADComputerChangeDetailed, ADGroupMembershipChange, ADGroupEnumeration, ADGroupChange, ADGroupCreateDelete, ADGroupChangeDetailed, ADGroupPolicyChanges, ADGroupPolicyEdits, ADGroupPolicyLinks, ADGroupPolicyChangesDetailed, GpoCreated, GpoDeleted, GpoModified, ADLdapBindingSummary, ADLdapBindingDetails, ADUserCreateChange, ADUserStatus, ADUserChangeDetailed, ADUserLockouts, ADUserLogon, ADUserLogonNTLMv1, ADUserLogonFailed, ADUserUnlocked, ADUserPrivilegeUse, ADUserRightsAssignment, KerberosTGTRequest, KerberosServiceTicket, KerberosTicketFailure, KerberosPolicyChange, ADOrganizationalUnitChangeDetailed, ADOtherChangeDetailed, ADSMBServerAuditV1, LogsClearedSecurity, LogsClearedOther, LogsFullSecurity, NetworkAccessAuthenticationPolicy, CertificateIssued, AuditPolicyChange, FirewallRuleChange, DhcpLeaseCreated, BitLockerKeyChange, BitLockerSuspended, DeviceRecognized, DeviceDisabled, ObjectDeletion, ScheduledTaskDeleted, ScheduledTaskCreated, OSCrash, OSBugCheck, OSStartup, OSShutdown, OSUncleanShutdown, OSStartupSecurity, OSCrashOnAuditFailRecovery, OSTimeChange, WindowsUpdateFailure, ClientGroupPoliciesApplication, ClientGroupPoliciesSystem, HyperVVirtualMachineShutdown, HyperVVirtualMachineStarted, IISSiteBindingFailure, HyperVCheckpointCreated, IISSiteStopped, ExchangeDatabaseMounted, DfsReplicationError, SqlDatabaseCreated, SyncCompleted, AADConnectStagingEnabled, AADConnectStagingDisabled, AADConnectPasswordSyncFailed, AADConnectRunProfile, AADSyncCycleStage, AADSyncProvisionCredentialsPing, AADSyncPasswordHashSyncStatus, AADSyncImportStatus, AADSyncFilterStatus, NetworkMonitorDriverLoaded, NetworkPromiscuousMode, ActiveDirectoryAuthentication, ActiveDirectoryAccountLifecycle, ActiveDirectoryChanges, GroupPolicyActivity, KerberosActivity, OperatingSystemLifecycle, WindowsSecurityChanges, EntraConnectHealth, NetworkSecurity, InfrastructureHealth, ScheduledTaskEnabled, ScheduledTaskDisabled, ScheduledTaskUpdated, FirewallRuleAdded, FirewallRuleDeleted, DefenderThreatDetected, DefenderThreatAction, DefenderConfigurationChanged, ScheduledTaskActivity, FirewallRuleActivity, DefenderSecurity, AuthenticationHealth, GroupPolicyDirectoryAudit, KerberosKdcRc4Audit Required: True Position: 2 diff --git a/Docs/New-EVXFilter.md b/Docs/New-EVXFilter.md index 66b622ea..19b70d72 100644 --- a/Docs/New-EVXFilter.md +++ b/Docs/New-EVXFilter.md @@ -364,7 +364,7 @@ Built-in event type whose typed fields should be exposed for predicate construct Type: EventType Parameter Sets: Type Aliases: None -Possible values: ADComputerCreateChange, ADComputerDeleted, ADComputerChangeDetailed, ADGroupMembershipChange, ADGroupEnumeration, ADGroupChange, ADGroupCreateDelete, ADGroupChangeDetailed, ADGroupPolicyChanges, ADGroupPolicyEdits, ADGroupPolicyLinks, ADGroupPolicyChangesDetailed, GpoCreated, GpoDeleted, GpoModified, ADLdapBindingSummary, ADLdapBindingDetails, ADUserCreateChange, ADUserStatus, ADUserChangeDetailed, ADUserLockouts, ADUserLogon, ADUserLogonNTLMv1, ADUserLogonFailed, ADUserUnlocked, ADUserPrivilegeUse, ADUserRightsAssignment, KerberosTGTRequest, KerberosServiceTicket, KerberosTicketFailure, KerberosPolicyChange, KerberosKdcRc4Audit, ADOrganizationalUnitChangeDetailed, ADOtherChangeDetailed, ADSMBServerAuditV1, LogsClearedSecurity, LogsClearedOther, LogsFullSecurity, NetworkAccessAuthenticationPolicy, CertificateIssued, AuditPolicyChange, FirewallRuleChange, DhcpLeaseCreated, BitLockerKeyChange, BitLockerSuspended, DeviceRecognized, DeviceDisabled, ObjectDeletion, ScheduledTaskDeleted, ScheduledTaskCreated, OSCrash, OSBugCheck, OSStartup, OSShutdown, OSUncleanShutdown, OSStartupSecurity, OSCrashOnAuditFailRecovery, OSTimeChange, WindowsUpdateFailure, ClientGroupPoliciesApplication, ClientGroupPoliciesSystem, HyperVVirtualMachineShutdown, HyperVVirtualMachineStarted, IISSiteBindingFailure, HyperVCheckpointCreated, IISSiteStopped, ExchangeDatabaseMounted, DfsReplicationError, SqlDatabaseCreated, SyncCompleted, AADConnectStagingEnabled, AADConnectStagingDisabled, AADConnectPasswordSyncFailed, AADConnectRunProfile, AADSyncCycleStage, AADSyncProvisionCredentialsPing, AADSyncPasswordHashSyncStatus, AADSyncImportStatus, AADSyncFilterStatus, NetworkMonitorDriverLoaded, NetworkPromiscuousMode, ActiveDirectoryAuthentication, ActiveDirectoryAccountLifecycle, ActiveDirectoryChanges, GroupPolicyActivity, KerberosActivity, OperatingSystemLifecycle, WindowsSecurityChanges, EntraConnectHealth, NetworkSecurity, InfrastructureHealth, ScheduledTaskEnabled, ScheduledTaskDisabled, ScheduledTaskUpdated, FirewallRuleAdded, FirewallRuleDeleted, DefenderThreatDetected, DefenderThreatAction, DefenderConfigurationChanged, ScheduledTaskActivity, FirewallRuleActivity, DefenderSecurity, AuthenticationHealth, GroupPolicyDirectoryAudit +Possible values: ADComputerCreateChange, ADComputerDeleted, ADComputerChangeDetailed, ADGroupMembershipChange, ADGroupEnumeration, ADGroupChange, ADGroupCreateDelete, ADGroupChangeDetailed, ADGroupPolicyChanges, ADGroupPolicyEdits, ADGroupPolicyLinks, ADGroupPolicyChangesDetailed, GpoCreated, GpoDeleted, GpoModified, ADLdapBindingSummary, ADLdapBindingDetails, ADUserCreateChange, ADUserStatus, ADUserChangeDetailed, ADUserLockouts, ADUserLogon, ADUserLogonNTLMv1, ADUserLogonFailed, ADUserUnlocked, ADUserPrivilegeUse, ADUserRightsAssignment, KerberosTGTRequest, KerberosServiceTicket, KerberosTicketFailure, KerberosPolicyChange, ADOrganizationalUnitChangeDetailed, ADOtherChangeDetailed, ADSMBServerAuditV1, LogsClearedSecurity, LogsClearedOther, LogsFullSecurity, NetworkAccessAuthenticationPolicy, CertificateIssued, AuditPolicyChange, FirewallRuleChange, DhcpLeaseCreated, BitLockerKeyChange, BitLockerSuspended, DeviceRecognized, DeviceDisabled, ObjectDeletion, ScheduledTaskDeleted, ScheduledTaskCreated, OSCrash, OSBugCheck, OSStartup, OSShutdown, OSUncleanShutdown, OSStartupSecurity, OSCrashOnAuditFailRecovery, OSTimeChange, WindowsUpdateFailure, ClientGroupPoliciesApplication, ClientGroupPoliciesSystem, HyperVVirtualMachineShutdown, HyperVVirtualMachineStarted, IISSiteBindingFailure, HyperVCheckpointCreated, IISSiteStopped, ExchangeDatabaseMounted, DfsReplicationError, SqlDatabaseCreated, SyncCompleted, AADConnectStagingEnabled, AADConnectStagingDisabled, AADConnectPasswordSyncFailed, AADConnectRunProfile, AADSyncCycleStage, AADSyncProvisionCredentialsPing, AADSyncPasswordHashSyncStatus, AADSyncImportStatus, AADSyncFilterStatus, NetworkMonitorDriverLoaded, NetworkPromiscuousMode, ActiveDirectoryAuthentication, ActiveDirectoryAccountLifecycle, ActiveDirectoryChanges, GroupPolicyActivity, KerberosActivity, OperatingSystemLifecycle, WindowsSecurityChanges, EntraConnectHealth, NetworkSecurity, InfrastructureHealth, ScheduledTaskEnabled, ScheduledTaskDisabled, ScheduledTaskUpdated, FirewallRuleAdded, FirewallRuleDeleted, DefenderThreatDetected, DefenderThreatAction, DefenderConfigurationChanged, ScheduledTaskActivity, FirewallRuleActivity, DefenderSecurity, AuthenticationHealth, GroupPolicyDirectoryAudit, KerberosKdcRc4Audit Required: True Position: named diff --git a/Docs/Operational-Packs.md b/Docs/Operational-Packs.md new file mode 100644 index 00000000..55c9981c --- /dev/null +++ b/Docs/Operational-Packs.md @@ -0,0 +1,54 @@ +# Operational detection packs + +This document describes the current 4.0 source tree. Version 4.0 is not yet +published or released. + +EventViewerX groups reusable detections into versioned packs instead of adding +a PowerShell command for every administrative scenario. A pack owns rule IDs, +versions, hashes, provenance, source coverage, executable fixtures, and tuning. +The PowerShell module and CLI remain thin surfaces over the same core plan. + +The source currently includes five `1.0.0` packs: + +| Pack ID | Operational focus | +| --- | --- | +| `eventviewerx.eventing-integrity` | Log clear/full, audit-policy change, crash-on-audit-fail recovery, and time changes. | +| `eventviewerx.identity-privilege` | Privileged group membership, SID history, account lifecycle, user rights, privilege use, deletion, and lockout bursts. | +| `eventviewerx.authentication-modernization` | NTLMv1, failed-logon correlation, weak Kerberos, LDAP signing, SMB1, Kerberos policy, and RC4 enforcement risk. | +| `eventviewerx.governance` | Group Policy, Certificate Services, BitLocker suspension, and recovery-material changes. | +| `eventviewerx.endpoint-protection` | Defender, scheduled tasks, firewall rules, network drivers/promiscuous mode, and removable devices. | + +## Admin workflow + +```powershell +# Inventory immutable content and source requirements. +$packs = Get-EVXDetectionPack +$packs | Select-Object PackId, Version, Hash +Get-EVXDetectionCoverage + +# Validate positive, negative, boundary, and known-benign fixtures. +Test-EVXDetectionPack + +# Prove that the target fleet can supply the required channels and policies. +Test-EVXReadiness -Scenario AuthenticationMonitoring -ActiveDirectory CurrentForest + +# Run all built-in packs, or pass selected versioned packs explicitly. +Get-EVXEvent -Type ActiveDirectoryAuthentication -TimePeriod Last24Hours | + Invoke-EVXDetection + +$selected = Get-EVXDetectionPack -PackId ` + 'eventviewerx.authentication-modernization', 'eventviewerx.eventing-integrity' +Get-EVXEvent -Type ActiveDirectoryAuthentication -TimePeriod Last24Hours | + Invoke-EVXDetection -Pack $selected +``` + +Tuning suppresses or adjusts known behavior without rewriting pack identity. +Coverage remains part of the result, so an empty finding set is not reported as +complete when required telemetry is absent. External Sigma rules can be wrapped +in an integrity-protected pack with an explicit ID/version after compilation; +they still execute in the same bounded engine. + +Add a new pack only when a coherent operational owner, source contract, and +fixture set exist. Add a new rule to an existing pack when the owner and +coverage contract are unchanged. Do not create a new cmdlet merely to expose a +new rule or scenario. diff --git a/Docs/Performance-Gates.md b/Docs/Performance-Gates.md new file mode 100644 index 00000000..61b94a57 --- /dev/null +++ b/Docs/Performance-Gates.md @@ -0,0 +1,64 @@ +# Performance regression gates + +This document describes the current 4.0 source tree. Version 4.0 is not yet +published or released. + +Performance claims are tied to exact workloads, fixtures, SDK/runtime identity, +and correctness checks. PowerForge owns the PowerShell benchmark orchestration, +artifact provenance, normalized summaries, and baseline comparison. The +BenchmarkDotNet detection suite retains its native measurement engine and uses +a thin adapter to PowerForge gates. + +## Checked-in budgets + +| Area | Matrix | Gate | +| --- | --- | --- | +| Persistent watcher | 100, 1,000, and 10,000-event bursts | Exact delivery/loss/duplicate validation plus median wall-clock baseline. | +| Local history | 100,000 rows across write, managed query, SQL query, daily summary, and typed CSV | Workload correctness plus median wall-clock baseline. | +| Typed reporting | Exact 5,000-record Security EVTX, 1,000-report-row window, HTML/Excel/email/all | Exact fixture SHA-256, typed count, effective report sample count, renderer validation, and median wall-clock baseline. The sensitive lab fixture is not committed. | +| Detection candidate index | 1, 10, 100, and 1,000 enabled rules | Complete matrix, median time, and allocation per operation. | +| Detection streaming | Three lanes at 1K, 10K, 100K, and 1M observations | Complete matrix, median time, and allocation per event. | + +Timing tolerances are intentionally broad: 50% relative plus a small absolute +allowance. These gates catch large regressions without turning workstation +noise into a product failure. Correctness, complete matrices, zero failed +samples, bytes per event, and scale slope are stronger invariants. +Each expected detection parameter tuple must occur exactly once; missing rows +and duplicate rows both fail before a timing or allocation comparison runs. +Typed parsing and reporting keys include the automatically calculated fixture +SHA-256, expected typed count, and effective report sample count. Replacing the +fixture or changing the measured row window therefore requires an explicitly +reviewed baseline update rather than silently reusing an unrelated budget. + +## Run the gates + +```powershell +# Elevated Windows session; creates and removes disposable logs. +.\Benchmarks\EventWatcher\Invoke-EventWatcherBurstBenchmark.ps1 ` + -BurstCount 100,1000,10000 -IterationCount 3 ` + -BaselinePath .\Benchmarks\EventWatcher\watcher-baseline.json + +.\Benchmarks\EventStore\Invoke-EventStoreBenchmark.ps1 ` + -RowCount 100000 -WarmupCount 1 -IterationCount 3 ` + -BaselinePath .\Benchmarks\EventStore\event-store-100000-baseline.json + +.\Benchmarks\EventLogParsing\Invoke-EventLogParsingBenchmark.ps1 ` + -Case Typed-Report-Html,Typed-Report-Excel,Typed-Report-Email,Typed-Report-All ` + -Engine EventViewerXReport ` + -TypedFixturePath C:\Evidence\Security-5000.evtx ` + -ExpectedTypedCount 5000 -ReportSampleCount 1000 -IterationCount 3 ` + -BaselinePath .\Benchmarks\EventLogParsing\reporting-5000-baseline.json + +.\Benchmarks\EventDetection\Test-DetectionBenchmarkBudget.ps1 ` + -CandidateResultPath .\BenchmarkDotNet.Artifacts\results\EventViewerX.DetectionBenchmarks.DetectionCandidateIndexBenchmarks-report-full-compressed.json ` + -ThroughputResultPath .\BenchmarkDotNet.Artifacts\results\EventViewerX.DetectionBenchmarks.DetectionThroughputBenchmarks-report-full-compressed.json +``` + +## Updating a baseline + +Use `-UpdateBaseline` only after the workload, fixture identity, correctness +checks, and measured regression/improvement have been reviewed. A faster run is +not sufficient if it dropped events, weakened output, skipped a matrix row, or +changed the fixture. Keep superseded heavy artifacts out of the repository; +commit only the small baseline and the contract/documentation change that +explains why it moved. diff --git a/Docs/Show-EVXEvent.md b/Docs/Show-EVXEvent.md index e8073f50..890bcf1c 100644 --- a/Docs/Show-EVXEvent.md +++ b/Docs/Show-EVXEvent.md @@ -675,7 +675,7 @@ Built-in leaf or composite event definitions. Each definition owns its channels Type: EventType[] Parameter Sets: Type, Store Aliases: None -Possible values: ADComputerCreateChange, ADComputerDeleted, ADComputerChangeDetailed, ADGroupMembershipChange, ADGroupEnumeration, ADGroupChange, ADGroupCreateDelete, ADGroupChangeDetailed, ADGroupPolicyChanges, ADGroupPolicyEdits, ADGroupPolicyLinks, ADGroupPolicyChangesDetailed, GpoCreated, GpoDeleted, GpoModified, ADLdapBindingSummary, ADLdapBindingDetails, ADUserCreateChange, ADUserStatus, ADUserChangeDetailed, ADUserLockouts, ADUserLogon, ADUserLogonNTLMv1, ADUserLogonFailed, ADUserUnlocked, ADUserPrivilegeUse, ADUserRightsAssignment, KerberosTGTRequest, KerberosServiceTicket, KerberosTicketFailure, KerberosPolicyChange, KerberosKdcRc4Audit, ADOrganizationalUnitChangeDetailed, ADOtherChangeDetailed, ADSMBServerAuditV1, LogsClearedSecurity, LogsClearedOther, LogsFullSecurity, NetworkAccessAuthenticationPolicy, CertificateIssued, AuditPolicyChange, FirewallRuleChange, DhcpLeaseCreated, BitLockerKeyChange, BitLockerSuspended, DeviceRecognized, DeviceDisabled, ObjectDeletion, ScheduledTaskDeleted, ScheduledTaskCreated, OSCrash, OSBugCheck, OSStartup, OSShutdown, OSUncleanShutdown, OSStartupSecurity, OSCrashOnAuditFailRecovery, OSTimeChange, WindowsUpdateFailure, ClientGroupPoliciesApplication, ClientGroupPoliciesSystem, HyperVVirtualMachineShutdown, HyperVVirtualMachineStarted, IISSiteBindingFailure, HyperVCheckpointCreated, IISSiteStopped, ExchangeDatabaseMounted, DfsReplicationError, SqlDatabaseCreated, SyncCompleted, AADConnectStagingEnabled, AADConnectStagingDisabled, AADConnectPasswordSyncFailed, AADConnectRunProfile, AADSyncCycleStage, AADSyncProvisionCredentialsPing, AADSyncPasswordHashSyncStatus, AADSyncImportStatus, AADSyncFilterStatus, NetworkMonitorDriverLoaded, NetworkPromiscuousMode, ActiveDirectoryAuthentication, ActiveDirectoryAccountLifecycle, ActiveDirectoryChanges, GroupPolicyActivity, KerberosActivity, OperatingSystemLifecycle, WindowsSecurityChanges, EntraConnectHealth, NetworkSecurity, InfrastructureHealth, ScheduledTaskEnabled, ScheduledTaskDisabled, ScheduledTaskUpdated, FirewallRuleAdded, FirewallRuleDeleted, DefenderThreatDetected, DefenderThreatAction, DefenderConfigurationChanged, ScheduledTaskActivity, FirewallRuleActivity, DefenderSecurity, AuthenticationHealth, GroupPolicyDirectoryAudit +Possible values: ADComputerCreateChange, ADComputerDeleted, ADComputerChangeDetailed, ADGroupMembershipChange, ADGroupEnumeration, ADGroupChange, ADGroupCreateDelete, ADGroupChangeDetailed, ADGroupPolicyChanges, ADGroupPolicyEdits, ADGroupPolicyLinks, ADGroupPolicyChangesDetailed, GpoCreated, GpoDeleted, GpoModified, ADLdapBindingSummary, ADLdapBindingDetails, ADUserCreateChange, ADUserStatus, ADUserChangeDetailed, ADUserLockouts, ADUserLogon, ADUserLogonNTLMv1, ADUserLogonFailed, ADUserUnlocked, ADUserPrivilegeUse, ADUserRightsAssignment, KerberosTGTRequest, KerberosServiceTicket, KerberosTicketFailure, KerberosPolicyChange, ADOrganizationalUnitChangeDetailed, ADOtherChangeDetailed, ADSMBServerAuditV1, LogsClearedSecurity, LogsClearedOther, LogsFullSecurity, NetworkAccessAuthenticationPolicy, CertificateIssued, AuditPolicyChange, FirewallRuleChange, DhcpLeaseCreated, BitLockerKeyChange, BitLockerSuspended, DeviceRecognized, DeviceDisabled, ObjectDeletion, ScheduledTaskDeleted, ScheduledTaskCreated, OSCrash, OSBugCheck, OSStartup, OSShutdown, OSUncleanShutdown, OSStartupSecurity, OSCrashOnAuditFailRecovery, OSTimeChange, WindowsUpdateFailure, ClientGroupPoliciesApplication, ClientGroupPoliciesSystem, HyperVVirtualMachineShutdown, HyperVVirtualMachineStarted, IISSiteBindingFailure, HyperVCheckpointCreated, IISSiteStopped, ExchangeDatabaseMounted, DfsReplicationError, SqlDatabaseCreated, SyncCompleted, AADConnectStagingEnabled, AADConnectStagingDisabled, AADConnectPasswordSyncFailed, AADConnectRunProfile, AADSyncCycleStage, AADSyncProvisionCredentialsPing, AADSyncPasswordHashSyncStatus, AADSyncImportStatus, AADSyncFilterStatus, NetworkMonitorDriverLoaded, NetworkPromiscuousMode, ActiveDirectoryAuthentication, ActiveDirectoryAccountLifecycle, ActiveDirectoryChanges, GroupPolicyActivity, KerberosActivity, OperatingSystemLifecycle, WindowsSecurityChanges, EntraConnectHealth, NetworkSecurity, InfrastructureHealth, ScheduledTaskEnabled, ScheduledTaskDisabled, ScheduledTaskUpdated, FirewallRuleAdded, FirewallRuleDeleted, DefenderThreatDetected, DefenderThreatAction, DefenderConfigurationChanged, ScheduledTaskActivity, FirewallRuleActivity, DefenderSecurity, AuthenticationHealth, GroupPolicyDirectoryAudit, KerberosKdcRc4Audit Required: False Position: 0 diff --git a/Docs/Sigma-Compatibility.md b/Docs/Sigma-Compatibility.md new file mode 100644 index 00000000..af135b91 --- /dev/null +++ b/Docs/Sigma-Compatibility.md @@ -0,0 +1,98 @@ +# Sigma compatibility + +This document describes the current 4.0 source tree. Version 4.0 is not yet +published or released. + +EventViewerX compiles supported Sigma YAML into the same immutable native +detection plan used by built-in rules. It does not embed a second detection +engine. Unsupported syntax or unsafe source assumptions remain explicit +diagnostics and block import. + +## Two compilation modes + +`Strict` is the default. A rule must provide selectors EventViewerX can compile +without guessing, such as an explicit Windows channel, provider, service, or +event ID. This mode is the right default for mixed fleets and evidence that +must be portable between telemetry configurations. + +`WindowsSysmonAndPowerShell` is an explicit, versioned telemetry profile. It +maps well-known Sigma categories to exact Microsoft-Windows-Sysmon/Operational +or Microsoft-Windows-PowerShell/Operational selectors. Selecting it asserts +that those channels are enabled, retained, and collected. An explicit EventID +in a rule remains authoritative. A conflicting service or product is rejected. + +The built-in profile identity is: + +- profile ID: `windows-sysmon-powershell` +- contract version: `1.0.0` +- Sysmon categories: process, network, driver/image load, remote thread, raw + access, process access, file, registry, pipe, WMI, DNS, tampering, and Sysmon + status/error categories with exact documented event IDs +- PowerShell categories: module logging event 4103 and script block logging + event 4104 + +High-risk ambiguous categories such as generic file access/change/rename and +legacy PowerShell classic-log categories are not guessed by this profile. + +```powershell +# Lossless strict validation remains the default. +Test-EVXSigmaRule -Path .\Rules\*.yml + +# Opt in only when this telemetry contract matches the target fleet. +$result = Test-EVXSigmaRule -Path .\Rules\*.yml ` + -TelemetryProfile WindowsSysmonAndPowerShell + +$rules = Import-EVXSigmaRule -Path .\Rules\*.yml ` + -TelemetryProfile WindowsSysmonAndPowerShell +``` + +The CLI exposes the same choice: + +```powershell +evx detect --path .\Security.evtx --sigma .\Rules\*.yml ` + --sigma-profile windows-sysmon-powershell --explain +``` + +## Pinned SigmaHQ audit + +The reproducible audit uses the SigmaHQ `sigma` repository at exact commit +`2e8fd89f82d9104c1b30321a307254ddeea17de2`. The audit executable verifies the +40-character commit and rejects tracked modifications within the selected +scope. Its scan set comes from Git's tracked, materialized files in that scope, +so a real sparse checkout is supported while ignored, untracked, or +skip-worktree files cannot alter the result. All selected files form one +compilation unit so correlations can resolve rules defined in other files, +while diagnostics remain attributed to their source files. The audit emits +distinct JSON and Markdown report files and rejects aliased output paths and +unknown command-line options. + +For the 2,410 Windows rules at that commit: + +| Mode | Supported | Unsupported | Compatibility | +| --- | ---: | ---: | ---: | +| Strict | 253 | 2,157 | 10.50% | +| Windows Sysmon and PowerShell 1.0.0 | 2,213 | 197 | 91.83% | + +The remaining 197 rules are not silently approximated. The leading diagnostic +classes are 90 unsupported field/operator constructs, 74 source contracts that +still lack a safe exact mapping, 19 intentionally unmapped categories, 6 +unsupported conditions, 5 unsupported correlation constructs, and 3 other +unsupported selection constructs. + +Run the audit from a pinned, clean sparse checkout whose corpus root contains +the materialized `windows` directory: + +```powershell +dotnet run --project .\Sources\EventViewerX.SigmaAudit\EventViewerX.SigmaAudit.csproj ` + -c Release -- ` + --corpus C:\Temp\sigma\rules ` + --commit 2e8fd89f82d9104c1b30321a307254ddeea17de2 ` + --scope windows ` + --profile windows-sysmon-powershell ` + --output-json .\Artifacts\sigma-windows.json ` + --output-markdown .\Artifacts\sigma-windows.md +``` + +Compatibility is a corpus-at-commit measurement, not a promise that every +future SigmaHQ rule will compile. Pin the corpus, keep the generated report, +and review diagnostics whenever the corpus or profile version changes. diff --git a/Docs/Start-EVXWatcher.md b/Docs/Start-EVXWatcher.md index bc064b85..fbc375bc 100644 --- a/Docs/Start-EVXWatcher.md +++ b/Docs/Start-EVXWatcher.md @@ -503,7 +503,7 @@ One or more built-in typed event definitions to monitor. Type: EventType[] Parameter Sets: Type Aliases: NamedEvent, NamedEvents -Possible values: ADComputerCreateChange, ADComputerDeleted, ADComputerChangeDetailed, ADGroupMembershipChange, ADGroupEnumeration, ADGroupChange, ADGroupCreateDelete, ADGroupChangeDetailed, ADGroupPolicyChanges, ADGroupPolicyEdits, ADGroupPolicyLinks, ADGroupPolicyChangesDetailed, GpoCreated, GpoDeleted, GpoModified, ADLdapBindingSummary, ADLdapBindingDetails, ADUserCreateChange, ADUserStatus, ADUserChangeDetailed, ADUserLockouts, ADUserLogon, ADUserLogonNTLMv1, ADUserLogonFailed, ADUserUnlocked, ADUserPrivilegeUse, ADUserRightsAssignment, KerberosTGTRequest, KerberosServiceTicket, KerberosTicketFailure, KerberosPolicyChange, KerberosKdcRc4Audit, ADOrganizationalUnitChangeDetailed, ADOtherChangeDetailed, ADSMBServerAuditV1, LogsClearedSecurity, LogsClearedOther, LogsFullSecurity, NetworkAccessAuthenticationPolicy, CertificateIssued, AuditPolicyChange, FirewallRuleChange, DhcpLeaseCreated, BitLockerKeyChange, BitLockerSuspended, DeviceRecognized, DeviceDisabled, ObjectDeletion, ScheduledTaskDeleted, ScheduledTaskCreated, OSCrash, OSBugCheck, OSStartup, OSShutdown, OSUncleanShutdown, OSStartupSecurity, OSCrashOnAuditFailRecovery, OSTimeChange, WindowsUpdateFailure, ClientGroupPoliciesApplication, ClientGroupPoliciesSystem, HyperVVirtualMachineShutdown, HyperVVirtualMachineStarted, IISSiteBindingFailure, HyperVCheckpointCreated, IISSiteStopped, ExchangeDatabaseMounted, DfsReplicationError, SqlDatabaseCreated, SyncCompleted, AADConnectStagingEnabled, AADConnectStagingDisabled, AADConnectPasswordSyncFailed, AADConnectRunProfile, AADSyncCycleStage, AADSyncProvisionCredentialsPing, AADSyncPasswordHashSyncStatus, AADSyncImportStatus, AADSyncFilterStatus, NetworkMonitorDriverLoaded, NetworkPromiscuousMode, ActiveDirectoryAuthentication, ActiveDirectoryAccountLifecycle, ActiveDirectoryChanges, GroupPolicyActivity, KerberosActivity, OperatingSystemLifecycle, WindowsSecurityChanges, EntraConnectHealth, NetworkSecurity, InfrastructureHealth, ScheduledTaskEnabled, ScheduledTaskDisabled, ScheduledTaskUpdated, FirewallRuleAdded, FirewallRuleDeleted, DefenderThreatDetected, DefenderThreatAction, DefenderConfigurationChanged, ScheduledTaskActivity, FirewallRuleActivity, DefenderSecurity, AuthenticationHealth, GroupPolicyDirectoryAudit +Possible values: ADComputerCreateChange, ADComputerDeleted, ADComputerChangeDetailed, ADGroupMembershipChange, ADGroupEnumeration, ADGroupChange, ADGroupCreateDelete, ADGroupChangeDetailed, ADGroupPolicyChanges, ADGroupPolicyEdits, ADGroupPolicyLinks, ADGroupPolicyChangesDetailed, GpoCreated, GpoDeleted, GpoModified, ADLdapBindingSummary, ADLdapBindingDetails, ADUserCreateChange, ADUserStatus, ADUserChangeDetailed, ADUserLockouts, ADUserLogon, ADUserLogonNTLMv1, ADUserLogonFailed, ADUserUnlocked, ADUserPrivilegeUse, ADUserRightsAssignment, KerberosTGTRequest, KerberosServiceTicket, KerberosTicketFailure, KerberosPolicyChange, ADOrganizationalUnitChangeDetailed, ADOtherChangeDetailed, ADSMBServerAuditV1, LogsClearedSecurity, LogsClearedOther, LogsFullSecurity, NetworkAccessAuthenticationPolicy, CertificateIssued, AuditPolicyChange, FirewallRuleChange, DhcpLeaseCreated, BitLockerKeyChange, BitLockerSuspended, DeviceRecognized, DeviceDisabled, ObjectDeletion, ScheduledTaskDeleted, ScheduledTaskCreated, OSCrash, OSBugCheck, OSStartup, OSShutdown, OSUncleanShutdown, OSStartupSecurity, OSCrashOnAuditFailRecovery, OSTimeChange, WindowsUpdateFailure, ClientGroupPoliciesApplication, ClientGroupPoliciesSystem, HyperVVirtualMachineShutdown, HyperVVirtualMachineStarted, IISSiteBindingFailure, HyperVCheckpointCreated, IISSiteStopped, ExchangeDatabaseMounted, DfsReplicationError, SqlDatabaseCreated, SyncCompleted, AADConnectStagingEnabled, AADConnectStagingDisabled, AADConnectPasswordSyncFailed, AADConnectRunProfile, AADSyncCycleStage, AADSyncProvisionCredentialsPing, AADSyncPasswordHashSyncStatus, AADSyncImportStatus, AADSyncFilterStatus, NetworkMonitorDriverLoaded, NetworkPromiscuousMode, ActiveDirectoryAuthentication, ActiveDirectoryAccountLifecycle, ActiveDirectoryChanges, GroupPolicyActivity, KerberosActivity, OperatingSystemLifecycle, WindowsSecurityChanges, EntraConnectHealth, NetworkSecurity, InfrastructureHealth, ScheduledTaskEnabled, ScheduledTaskDisabled, ScheduledTaskUpdated, FirewallRuleAdded, FirewallRuleDeleted, DefenderThreatDetected, DefenderThreatAction, DefenderConfigurationChanged, ScheduledTaskActivity, FirewallRuleActivity, DefenderSecurity, AuthenticationHealth, GroupPolicyDirectoryAudit, KerberosKdcRc4Audit Required: True Position: 1 diff --git a/Docs/Test-EVXReadiness.md b/Docs/Test-EVXReadiness.md index 7cc024ac..ffa0e620 100644 --- a/Docs/Test-EVXReadiness.md +++ b/Docs/Test-EVXReadiness.md @@ -13,12 +13,12 @@ Composes explicit target discovery, native Event Log probes, effective local aud ## SYNTAX ### Type (Default) ```powershell -Test-EVXReadiness [-Type] [-ActiveDirectory ] [-Name ] [-IncludeTrustedForests] [-Collector ] [-SubscriptionName ] [-ExpectedSource ] [-DirectoryCredential ] [-EventLogCredential ] [-Authentication ] [-DiscoveryTimeoutMs ] [-MaximumDomainCount ] [-MaximumTargetCount ] [-ProbeTimeoutMs ] [-MaxEventsToScan ] [] +Test-EVXReadiness [-Type] [-ActiveDirectory ] [-Name ] [-IncludeTrustedForests] [-Collector ] [-SubscriptionName ] [-ExpectedSource ] [-DirectoryCredential ] [-EventLogCredential ] [-Authentication ] [-DiscoveryTimeoutMs ] [-MaximumDomainCount ] [-MaximumTargetCount ] [-ProbeTimeoutMs ] [-MaxEventsToScan ] [-MaximumHeartbeatAgeMinutes ] [] ``` ### Scenario ```powershell -Test-EVXReadiness [-Scenario] [-ActiveDirectory ] [-Name ] [-IncludeTrustedForests] [-Collector ] [-SubscriptionName ] [-ExpectedSource ] [-DirectoryCredential ] [-EventLogCredential ] [-Authentication ] [-DiscoveryTimeoutMs ] [-MaximumDomainCount ] [-MaximumTargetCount ] [-ProbeTimeoutMs ] [-MaxEventsToScan ] [] +Test-EVXReadiness [-Scenario] [-ActiveDirectory ] [-Name ] [-IncludeTrustedForests] [-Collector ] [-SubscriptionName ] [-ExpectedSource ] [-DirectoryCredential ] [-EventLogCredential ] [-Authentication ] [-DiscoveryTimeoutMs ] [-MaximumDomainCount ] [-MaximumTargetCount ] [-ProbeTimeoutMs ] [-MaxEventsToScan ] [-MaximumHeartbeatAgeMinutes ] [] ``` ## DESCRIPTION @@ -211,6 +211,24 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -MaximumHeartbeatAgeMinutes +Optional maximum accepted age, in minutes, for each WEC source heartbeat. +Requires Collector and SubscriptionName. Omit this parameter when the organization +has not selected a heartbeat-lag policy. + +```yaml +Type: Int32 +Parameter Sets: Type, Scenario +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -MaximumTargetCount Maximum distinct event-log targets retained by one explicit discovery. @@ -298,7 +316,7 @@ Explicit event types to assess. Type: EventType[] Parameter Sets: Type Aliases: None -Possible values: ADComputerCreateChange, ADComputerDeleted, ADComputerChangeDetailed, ADGroupMembershipChange, ADGroupEnumeration, ADGroupChange, ADGroupCreateDelete, ADGroupChangeDetailed, ADGroupPolicyChanges, ADGroupPolicyEdits, ADGroupPolicyLinks, ADGroupPolicyChangesDetailed, GpoCreated, GpoDeleted, GpoModified, ADLdapBindingSummary, ADLdapBindingDetails, ADUserCreateChange, ADUserStatus, ADUserChangeDetailed, ADUserLockouts, ADUserLogon, ADUserLogonNTLMv1, ADUserLogonFailed, ADUserUnlocked, ADUserPrivilegeUse, ADUserRightsAssignment, KerberosTGTRequest, KerberosServiceTicket, KerberosTicketFailure, KerberosPolicyChange, KerberosKdcRc4Audit, ADOrganizationalUnitChangeDetailed, ADOtherChangeDetailed, ADSMBServerAuditV1, LogsClearedSecurity, LogsClearedOther, LogsFullSecurity, NetworkAccessAuthenticationPolicy, CertificateIssued, AuditPolicyChange, FirewallRuleChange, DhcpLeaseCreated, BitLockerKeyChange, BitLockerSuspended, DeviceRecognized, DeviceDisabled, ObjectDeletion, ScheduledTaskDeleted, ScheduledTaskCreated, OSCrash, OSBugCheck, OSStartup, OSShutdown, OSUncleanShutdown, OSStartupSecurity, OSCrashOnAuditFailRecovery, OSTimeChange, WindowsUpdateFailure, ClientGroupPoliciesApplication, ClientGroupPoliciesSystem, HyperVVirtualMachineShutdown, HyperVVirtualMachineStarted, IISSiteBindingFailure, HyperVCheckpointCreated, IISSiteStopped, ExchangeDatabaseMounted, DfsReplicationError, SqlDatabaseCreated, SyncCompleted, AADConnectStagingEnabled, AADConnectStagingDisabled, AADConnectPasswordSyncFailed, AADConnectRunProfile, AADSyncCycleStage, AADSyncProvisionCredentialsPing, AADSyncPasswordHashSyncStatus, AADSyncImportStatus, AADSyncFilterStatus, NetworkMonitorDriverLoaded, NetworkPromiscuousMode, ActiveDirectoryAuthentication, ActiveDirectoryAccountLifecycle, ActiveDirectoryChanges, GroupPolicyActivity, KerberosActivity, OperatingSystemLifecycle, WindowsSecurityChanges, EntraConnectHealth, NetworkSecurity, InfrastructureHealth, ScheduledTaskEnabled, ScheduledTaskDisabled, ScheduledTaskUpdated, FirewallRuleAdded, FirewallRuleDeleted, DefenderThreatDetected, DefenderThreatAction, DefenderConfigurationChanged, ScheduledTaskActivity, FirewallRuleActivity, DefenderSecurity, AuthenticationHealth, GroupPolicyDirectoryAudit +Possible values: ADComputerCreateChange, ADComputerDeleted, ADComputerChangeDetailed, ADGroupMembershipChange, ADGroupEnumeration, ADGroupChange, ADGroupCreateDelete, ADGroupChangeDetailed, ADGroupPolicyChanges, ADGroupPolicyEdits, ADGroupPolicyLinks, ADGroupPolicyChangesDetailed, GpoCreated, GpoDeleted, GpoModified, ADLdapBindingSummary, ADLdapBindingDetails, ADUserCreateChange, ADUserStatus, ADUserChangeDetailed, ADUserLockouts, ADUserLogon, ADUserLogonNTLMv1, ADUserLogonFailed, ADUserUnlocked, ADUserPrivilegeUse, ADUserRightsAssignment, KerberosTGTRequest, KerberosServiceTicket, KerberosTicketFailure, KerberosPolicyChange, ADOrganizationalUnitChangeDetailed, ADOtherChangeDetailed, ADSMBServerAuditV1, LogsClearedSecurity, LogsClearedOther, LogsFullSecurity, NetworkAccessAuthenticationPolicy, CertificateIssued, AuditPolicyChange, FirewallRuleChange, DhcpLeaseCreated, BitLockerKeyChange, BitLockerSuspended, DeviceRecognized, DeviceDisabled, ObjectDeletion, ScheduledTaskDeleted, ScheduledTaskCreated, OSCrash, OSBugCheck, OSStartup, OSShutdown, OSUncleanShutdown, OSStartupSecurity, OSCrashOnAuditFailRecovery, OSTimeChange, WindowsUpdateFailure, ClientGroupPoliciesApplication, ClientGroupPoliciesSystem, HyperVVirtualMachineShutdown, HyperVVirtualMachineStarted, IISSiteBindingFailure, HyperVCheckpointCreated, IISSiteStopped, ExchangeDatabaseMounted, DfsReplicationError, SqlDatabaseCreated, SyncCompleted, AADConnectStagingEnabled, AADConnectStagingDisabled, AADConnectPasswordSyncFailed, AADConnectRunProfile, AADSyncCycleStage, AADSyncProvisionCredentialsPing, AADSyncPasswordHashSyncStatus, AADSyncImportStatus, AADSyncFilterStatus, NetworkMonitorDriverLoaded, NetworkPromiscuousMode, ActiveDirectoryAuthentication, ActiveDirectoryAccountLifecycle, ActiveDirectoryChanges, GroupPolicyActivity, KerberosActivity, OperatingSystemLifecycle, WindowsSecurityChanges, EntraConnectHealth, NetworkSecurity, InfrastructureHealth, ScheduledTaskEnabled, ScheduledTaskDisabled, ScheduledTaskUpdated, FirewallRuleAdded, FirewallRuleDeleted, DefenderThreatDetected, DefenderThreatAction, DefenderConfigurationChanged, ScheduledTaskActivity, FirewallRuleActivity, DefenderSecurity, AuthenticationHealth, GroupPolicyDirectoryAudit, KerberosKdcRc4Audit Required: True Position: 0 diff --git a/Docs/Test-EVXSigmaRule.md b/Docs/Test-EVXSigmaRule.md index c804b5e6..9e3a9380 100644 --- a/Docs/Test-EVXSigmaRule.md +++ b/Docs/Test-EVXSigmaRule.md @@ -13,7 +13,7 @@ Returns structured diagnostics and native rules without executing them. Unsuppor ## SYNTAX ### __AllParameterSets ```powershell -Test-EVXSigmaRule [-Path] [] +Test-EVXSigmaRule [-Path] [-TelemetryProfile ] [] ``` ## DESCRIPTION @@ -48,6 +48,23 @@ Accept pipeline input: True (ByValue, ByPropertyName) Accept wildcard characters: True ``` +### -TelemetryProfile +Explicit telemetry assumptions used for category-only Sigma log sources. +Strict is lossless and rejects categories without exact native selectors. + +```yaml +Type: String +Parameter Sets: __AllParameterSets +Aliases: None +Possible values: Strict, WindowsSysmonAndPowerShell + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/Docs/WEC-Fleet-Operations.md b/Docs/WEC-Fleet-Operations.md new file mode 100644 index 00000000..9fbe52ef --- /dev/null +++ b/Docs/WEC-Fleet-Operations.md @@ -0,0 +1,109 @@ +# Windows Event Collector fleet operations + +This document describes the current 4.0 source tree. Version 4.0 is not yet +published or released. + +EventViewerX treats Windows Event Collector as an evidence transport, not as a +single healthy/unhealthy switch. Fleet readiness needs five separate proofs: +desired subscription configuration, source authorization, runtime enrollment, +heartbeat freshness, and actual event delivery. + +## 1. Plan the fleet from event requirements + +Start with a built-in type or scenario so the same source contract drives the +query, WEC XML, readiness checks, reports, and detections. + +```powershell +Get-EVXRequirement -Type ActiveDirectoryAuthentication + +$definition = New-EVXCollectorSubscription ` + -Name EventViewerX-Authentication ` + -Type ActiveDirectoryAuthentication ` + -SubscriptionType SourceInitiated ` + -CollectorHostName WEC01.contoso.com ` + -AllowedSourceSid $domainControllersSid ` + -DeliveryMode Push + +$definition | Set-EVXCollectorSubscription -InitializeCollector -Confirm:$false +``` + +Source-initiated fleets should use an explicit source SID/SDDL policy and +Active Directory discovery only when the operator intentionally selects that +scope. Collector-initiated subscriptions should list exact sources. + +## 2. Detect configuration drift + +`Get-EVXCollectorSubscription` returns normalized XML, query definitions, +enabled state, destination log, and source authorization. Store the generated +definition in source control and compare normalized snapshots during change +review. Applying a typed definition verifies the persisted XML. A failed apply +restores the prior definition; if both apply and rollback fail, EventViewerX +reports that persisted state is unknown instead of claiming success. + +Remote inventory is read-only. WEC mutation and native runtime status are +local-only Windows contracts. Use an explicitly authorized remote PowerShell +session to run the same command on a collector; EventViewerX does not pretend +that Event Log RPC can mutate or fully diagnose a remote WEC service. + +## 3. Assess enrollment, coverage, and heartbeat lag + +```powershell +Test-EVXReadiness ` + -Scenario AuthenticationMonitoring ` + -Collector . ` + -SubscriptionName EventViewerX-Authentication ` + -ActiveDirectory CurrentForest ` + -MaximumHeartbeatAgeMinutes 15 +``` + +The readiness report checks Wecsvc, WinRM, a listener, ForwardedEvents, +subscription enablement, typed query coverage, expected-source enrollment, +runtime errors, and the optional operator-owned heartbeat-age policy. Omitting +`MaximumHeartbeatAgeMinutes` leaves heartbeat timestamps as evidence and does +not invent an organizational threshold. A missing timestamp is `Unknown`, not +fabricated success or failure. A heartbeat-age policy requires both `Collector` +and `SubscriptionName`; the request is rejected if no subscription exists to +which the policy can be applied. Once a policy is requested, EventViewerX keeps +an explicit required `ExpectedSourceHeartbeat` check even when the subscription, +expected source set, local runtime access, or an expected runtime source is +unavailable. Those checks remain `Unknown` with remediation instead of +disappearing from the report. + +For direct inspection: + +```powershell +Get-EVXCollectorSubscription -Readiness +Get-EVXCollectorSubscription -Name EventViewerX-Authentication ` + -IncludeRuntimeStatus -IncludeSourceAuthorization +``` + +## 4. Prove completeness end to end + +Active runtime state and a fresh heartbeat prove connectivity, not delivery +completeness. Use a disposable correlation marker: + +1. Record the source channel and record ID before the test. +2. Emit a uniquely identifiable event on each selected source through an + approved test provider or an existing safe test event. +3. Read the exact source record IDs directly. +4. Wait within the configured delivery/heartbeat budget. +5. Read ForwardedEvents through EventViewerX and require the same source + computer, source channel, event ID, record ID, and correlation marker. +6. Remove only the disposable subscription and test artifacts. + +Do not infer completeness from `EventsProcessed`, `Active`, or a successful +subscription create/delete cycle. Those are useful health signals but do not +prove that the intended record arrived without loss or duplication. + +## 5. Operate and roll back safely + +- Generate subscription XML before mutation and review the exact source ACL, + query, destination, delivery mode, heartbeat, latency, and retention needs. +- Use `Set-EVXCollectorSubscription` for verified local apply. It retains the + previous XML, verifies the new state, and attempts bounded rollback on error. +- Keep ForwardedEvents retention large enough for the worst expected outage and + ingestion lag. EventViewerX does not guess a fleet-wide retention policy. +- Persist downstream checkpoints only after the corresponding report, store, + or outbox artifact is durable. +- Treat `Unknown` readiness as missing evidence that needs investigation, not + as a pass. diff --git a/README.md b/README.md index e3fbd24a..f242b3ce 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ High-performance Windows Event Log tooling for .NET and PowerShell. +> **4.0 development status:** the expanded API, CLI, detection, reporting, +> storage, WEC, and portable-EVTX documentation below describes the current +> source tree. Version 4.0 has not been published or released. The public +> PowerShell Gallery and NuGet packages remain on their 3.x lines until the +> separate 4.0 release decision is made. + PSEventViewer is the thin PowerShell surface. EventViewerX is the reusable C# engine underneath it. Live channels, remote sessions, WEC, provider messages, and Windows configuration use the Windows Event Log APIs. Saved EVTX files can @@ -107,13 +113,13 @@ The `master` branch is the active home of PSEventViewer and EventViewerX. ```powershell Install-Module -Name PSEventViewer -Scope CurrentUser Import-Module PSEventViewer - -# Optional CLI for interactive use and automation on hosts with .NET 10. -dotnet tool install --global EventViewerX.Cli --version 4.0.0 -evx --version ``` -The module supports Windows PowerShell 5.1 and PowerShell 7+. EventViewerX +That command installs the current public 3.x module, not the unreleased 4.0 +source described by this branch. The 4.0 CLI and package set are source-only +until a later release; do not use a speculative `4.0.0` package command. + +The 4.0 source supports Windows PowerShell 5.1 and PowerShell 7+. EventViewerX targets .NET Framework 4.7.2, .NET 8 for Windows, and .NET 10 for Windows. The CLI is also available as RID-specific release ZIPs. Use a framework-dependent ZIP when .NET 10 is installed, or `PortableCompat` when @@ -136,6 +142,17 @@ the target host needs the runtime bundled with the executable. rollback, and removal. - [Custom event definitions](Docs/Event-Definitions.md): one portable typed schema shared by query, reports, watchers, WEC, C#, and `evx.exe`. +- [Operational detection packs](Docs/Operational-Packs.md): the five built-in, + versioned admin workflows, their coverage contracts, fixture gates, and + tuning path without adding cmdlets per scenario. +- [Sigma compatibility](Docs/Sigma-Compatibility.md): strict compilation, + the opt-in Sysmon/PowerShell telemetry profile, pinned SigmaHQ audit results, + and the remaining unsupported semantics. +- [WEC fleet operations](Docs/WEC-Fleet-Operations.md): planning, readiness, + drift, heartbeat lag, end-to-end completeness proof, rollback, and truthful + local/remote boundaries. +- [Performance regression gates](Docs/Performance-Gates.md): reproducible + watcher, storage, reporting, and detection budgets and their update policy. - [Troubleshooting](Docs/Troubleshooting.md): performance, permissions, remoting, message resources, EVTX, checkpoints, and provider deployment. - [Security and ownership boundaries](Docs/Security.md): remote credentials, diff --git a/Sources/EventViewerX.Cli/Program.Detect.cs b/Sources/EventViewerX.Cli/Program.Detect.cs index 7b481e12..8612c170 100644 --- a/Sources/EventViewerX.Cli/Program.Detect.cs +++ b/Sources/EventViewerX.Cli/Program.Detect.cs @@ -24,6 +24,9 @@ await File.ReadAllTextAsync(Path.GetFullPath(tuningPath)).ConfigureAwait(false), var packs = new List(); string[] packPaths = options.GetMany("pack"); string[] sigmaPaths = options.GetMany("sigma"); + SigmaCompilationOptions? sigmaOptions = ResolveSigmaCompilationOptions( + options.Get("sigma-profile"), + sigmaPaths.Length > 0); bool explicitContent = packPaths.Length > 0 || sigmaPaths.Length > 0; if (!explicitContent || options.Has("include-built-in")) { EventDetectionPack[] builtIn = EventDetectionCatalog.GetBuiltInPacks().ToArray(); @@ -41,7 +44,9 @@ await File.ReadAllTextAsync(Path.GetFullPath(tuningPath)).ConfigureAwait(false), rules.AddRange(pack.GetRules()); } if (sigmaPaths.Length > 0) { - SigmaCompilationResult result = SigmaRuleCompiler.Load(sigmaPaths); + SigmaCompilationResult result = SigmaRuleCompiler.Load( + sigmaPaths, + sigmaOptions); foreach (SigmaDiagnostic diagnostic in result.Diagnostics) { Console.Error.WriteLine($"{diagnostic.Severity} {diagnostic.Code}: {diagnostic.Message}"); } @@ -371,6 +376,26 @@ await File.ReadAllTextAsync(Path.GetFullPath(coveragePath)).ConfigureAwait(false return execution.IsComplete ? 0 : 2; } + private static SigmaCompilationOptions? ResolveSigmaCompilationOptions( + string? profile, + bool hasSigmaInput) { + + if (profile == null) { + return null; + } + if (!hasSigmaInput) { + throw new ArgumentException("--sigma-profile requires at least one --sigma input."); + } + return profile.Trim().ToLowerInvariant() switch { + "strict" => null, + "windows-sysmon-powershell" => new SigmaCompilationOptions { + LogSourceProfile = SigmaLogSourceProfile.WindowsSysmonAndPowerShell + }, + _ => throw new ArgumentException( + "--sigma-profile must be 'strict' or 'windows-sysmon-powershell'.") + }; + } + private static long Remaining(long maximum, int current) => maximum == 0 ? 0 : Math.Max(0, maximum - current); diff --git a/Sources/EventViewerX.Cli/Program.cs b/Sources/EventViewerX.Cli/Program.cs index dcb22a87..914089ad 100644 --- a/Sources/EventViewerX.Cli/Program.cs +++ b/Sources/EventViewerX.Cli/Program.cs @@ -703,7 +703,7 @@ private static void ValidateOptions(CliArguments options) { case "detect": options.ValidateAllowed( "type", "log", "path", "machine", "collector", "start", "end", "since", "max", - "event-id", "provider", "portable-evtx", "portable-evtx-executable", "sigma", "pack", "include-built-in", "tuning", "explain", "dry-run", + "event-id", "provider", "portable-evtx", "portable-evtx-executable", "sigma", "sigma-profile", "pack", "include-built-in", "tuning", "explain", "dry-run", "test-fixtures", "pack-coverage", "maximum-observations", "maximum-groups", "maximum-state-observations", "maximum-state-bytes", "write-findings-store", "jsonl", "report-html", "report-csv", "report-excel", "report-kind", "title", @@ -777,7 +777,7 @@ private static int Help() { " evx query (--type TYPE[,TYPE] | --definition FILE | --log LOG | --path FILE[,FILE] | --store FILE.db [--type TYPE[,TYPE] | --definition FILE | --definition-name NAME]) [--portable-evtx | --portable-evtx-executable FILE with --path] [--context-store CONTEXT.db with --type GroupPolicyDirectoryAudit] [--where JSON_OR_FILE (typed/store)] [--write-store FILE.db [--checkpoint NAME]] [--explain] [--since 01:00:00] [--max N]\n" + " evx report (--type TYPE[,TYPE] | --definition FILE | --log LOG | --path FILE[,FILE] | --store FILE.db [--type TYPE[,TYPE] | --definition FILE | --definition-name NAME]) [--portable-evtx | --portable-evtx-executable FILE with --path] [--summary Hour|Day|Week|Month] [--where JSON_OR_FILE (typed/store)] [--write-store FILE.db] (--html FILE | --excel FILE | --csv FILE.csv|BUNDLE.zip | --email-html FILE | --mail-profile FILE) [--drawer-placement Auto|Top|Right]\n" + " evx measure (--preset PRESET | --type TYPE[,TYPE] | --definition FILE | --log LOG | --path FILE[,FILE] | --store FILE.db) [--portable-evtx | --portable-evtx-executable FILE with --path] [--group-by FIELD[,FIELD]] [--bucket Hour|Day|Week|Month] [--measure OPERATION:FIELD:NAME:RATE_UNIT] [--top N] [--html FILE | --excel FILE | --csv FILE] [--explain]\n" + - " evx detect (--store FILE.db | --type TYPE[,TYPE] | --log LOG | --path FILE[,FILE]) [--coverage FILE with --store] [--portable-evtx | --portable-evtx-executable FILE with --path] [--sigma FILE[,FILE] | --pack FILE[,FILE]] [--include-built-in] [--tuning FILE] [--write-findings-store FILE.db] [--jsonl FILE] [--trace-jsonl FILE] [--report-kind KIND] [--report-html FILE | --report-csv FILE | --report-excel FILE] [--explain | --dry-run]\n" + + " evx detect (--store FILE.db | --type TYPE[,TYPE] | --log LOG | --path FILE[,FILE]) [--coverage FILE with --store] [--portable-evtx | --portable-evtx-executable FILE with --path] [--sigma FILE[,FILE] [--sigma-profile strict|windows-sysmon-powershell] | --pack FILE[,FILE]] [--include-built-in] [--tuning FILE] [--write-findings-store FILE.db] [--jsonl FILE] [--trace-jsonl FILE] [--report-kind KIND] [--report-html FILE | --report-csv FILE | --report-excel FILE] [--explain | --dry-run]\n" + " evx detect --test-fixtures\n" + " evx detect --pack-coverage [--pack FILE[,FILE]] [--include-built-in]\n" + " evx watch (--type TYPE[,TYPE] | --definition FILE) [--machine HOST | --collector WEC] [--checkpoint-store FILE.db] [--checkpoint-consumer NAME] [--ignore-stale-bookmark] [--jsonl FILE] [--outbox DIR | --mail-profile FILE] [--interval 00:05:00] [--delivery-queue-capacity N] [--notification-buffer-capacity N] [--outbox-maximum-batch-bytes N] [--outbox-maximum-bytes N] [--outbox-maximum-pending-batches N] [--dead-letter-after N] [--retry-delay 00:01:00] [--maximum-retry-delay 01:00:00] [--stop-after N] [--timeout 01:00:00] [--ready-file FILE] [--summary-file FILE]\n" + diff --git a/Sources/EventViewerX.Detection/SigmaCompilationOptions.cs b/Sources/EventViewerX.Detection/SigmaCompilationOptions.cs new file mode 100644 index 00000000..de4fc32a --- /dev/null +++ b/Sources/EventViewerX.Detection/SigmaCompilationOptions.cs @@ -0,0 +1,11 @@ +namespace EventViewerX.Sigma; + +/// Controls optional, explicitly selected assumptions used while compiling Sigma rules. +public sealed class SigmaCompilationOptions { + /// + /// Gets or sets the telemetry profile used to resolve Sigma categories that do not carry an + /// explicit event ID on every matching branch. The default is , which + /// preserves strict lossless compilation and rejects such categories. + /// + public SigmaLogSourceProfile? LogSourceProfile { get; set; } +} diff --git a/Sources/EventViewerX.Detection/SigmaLogSourceMapping.cs b/Sources/EventViewerX.Detection/SigmaLogSourceMapping.cs new file mode 100644 index 00000000..c98f4514 --- /dev/null +++ b/Sources/EventViewerX.Detection/SigmaLogSourceMapping.cs @@ -0,0 +1,48 @@ +namespace EventViewerX.Sigma; + +/// Maps one Sigma category to one explicitly chosen Windows telemetry contract. +public sealed class SigmaLogSourceMapping { + /// Creates a validated category mapping. + public SigmaLogSourceMapping( + string category, + IEnumerable channels, + IEnumerable providers, + IEnumerable eventIds) { + + if (string.IsNullOrWhiteSpace(category)) { + throw new ArgumentException("Sigma category cannot be empty.", nameof(category)); + } + Category = category.Trim(); + Channels = Array.AsReadOnly(Normalize(channels, nameof(channels))); + Providers = Array.AsReadOnly(Normalize(providers, nameof(providers))); + EventIds = Array.AsReadOnly((eventIds ?? throw new ArgumentNullException(nameof(eventIds))) + .Distinct() + .OrderBy(static value => value) + .ToArray()); + if (Channels.Count == 0 || EventIds.Count == 0 || EventIds.Any(static value => value < 0)) { + throw new ArgumentException( + "A Sigma log-source mapping requires at least one channel and one non-negative event ID."); + } + } + + /// Gets the Sigma logsource category. + public string Category { get; } + + /// Gets the exact Windows event channels selected by the profile. + public IReadOnlyList Channels { get; } + + /// Gets the exact Windows event providers selected by the profile. + public IReadOnlyList Providers { get; } + + /// Gets the event IDs selected by the profile. + public IReadOnlyList EventIds { get; } + + private static string[] Normalize( + IEnumerable values, + string parameterName) => (values ?? throw new ArgumentNullException(parameterName)) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(static value => value, StringComparer.OrdinalIgnoreCase) + .ToArray(); +} diff --git a/Sources/EventViewerX.Detection/SigmaLogSourceProfile.cs b/Sources/EventViewerX.Detection/SigmaLogSourceProfile.cs new file mode 100644 index 00000000..7b6deca6 --- /dev/null +++ b/Sources/EventViewerX.Detection/SigmaLogSourceProfile.cs @@ -0,0 +1,99 @@ +namespace EventViewerX.Sigma; + +/// +/// Describes a versioned, opt-in mapping from source-neutral Sigma categories to concrete Windows telemetry. +/// +public sealed class SigmaLogSourceProfile { + private const string SysmonChannel = "Microsoft-Windows-Sysmon/Operational"; + private const string SysmonProvider = "Microsoft-Windows-Sysmon"; + private const string PowerShellChannel = "Microsoft-Windows-PowerShell/Operational"; + private const string PowerShellProvider = "Microsoft-Windows-PowerShell"; + private readonly IReadOnlyDictionary mappings; + + /// Creates a validated, immutable telemetry profile. + public SigmaLogSourceProfile( + string profileId, + string version, + IEnumerable mappings) { + + if (string.IsNullOrWhiteSpace(profileId)) { + throw new ArgumentException("Sigma profile ID cannot be empty.", nameof(profileId)); + } + if (string.IsNullOrWhiteSpace(version) || !System.Version.TryParse(version, out _)) { + throw new ArgumentException("Sigma profile version must be a numeric version.", nameof(version)); + } + ProfileId = profileId.Trim(); + Version = version.Trim(); + SigmaLogSourceMapping[] materialized = (mappings ?? throw new ArgumentNullException(nameof(mappings))) + .ToArray(); + if (materialized.Length == 0) { + throw new ArgumentException("A Sigma telemetry profile requires at least one mapping.", nameof(mappings)); + } + if (materialized.GroupBy(static mapping => mapping.Category, StringComparer.OrdinalIgnoreCase) + .Any(static group => group.Count() != 1)) { + throw new ArgumentException("Sigma telemetry profile categories must be unique.", nameof(mappings)); + } + this.mappings = materialized.ToDictionary( + static mapping => mapping.Category, + StringComparer.OrdinalIgnoreCase); + Mappings = Array.AsReadOnly(materialized + .OrderBy(static mapping => mapping.Category, StringComparer.OrdinalIgnoreCase) + .ToArray()); + } + + /// Gets the stable profile identifier. + public string ProfileId { get; } + + /// Gets the mapping contract version. + public string Version { get; } + + /// Gets the immutable category mappings. + public IReadOnlyList Mappings { get; } + + /// + /// Gets the built-in profile that binds supported endpoint categories to Sysmon and PowerShell telemetry. + /// Selecting this profile asserts that the corresponding telemetry is enabled and collected. + /// + public static SigmaLogSourceProfile WindowsSysmonAndPowerShell { get; } = CreateWindowsProfile(); + + internal bool TryGetMapping(string category, out SigmaLogSourceMapping mapping) => + mappings.TryGetValue(category, out mapping!); + + private static SigmaLogSourceProfile CreateWindowsProfile() { + var mappings = new[] { + Sysmon("process_creation", 1), + Sysmon("network_connection", 3), + Sysmon("sysmon_status", 4, 16), + Sysmon("driver_load", 6), + Sysmon("image_load", 7), + Sysmon("create_remote_thread", 8), + Sysmon("raw_access_thread", 9), + Sysmon("process_access", 10), + Sysmon("file_event", 11), + Sysmon("registry_add", 12), + Sysmon("registry_delete", 12), + Sysmon("registry_event", 12, 13, 14), + Sysmon("registry_set", 13), + Sysmon("create_stream_hash", 15), + Sysmon("pipe_created", 17), + Sysmon("wmi_event", 19, 20, 21), + Sysmon("dns_query", 22), + Sysmon("file_delete", 23, 26), + Sysmon("process_tampering", 25), + Sysmon("file_executable_detected", 29), + Sysmon("sysmon_error", 255), + PowerShell("ps_module", 4103), + PowerShell("ps_script", 4104) + }; + return new SigmaLogSourceProfile( + "windows-sysmon-powershell", + "1.0.0", + mappings); + } + + private static SigmaLogSourceMapping Sysmon(string category, params int[] eventIds) => + new(category, new[] { SysmonChannel }, new[] { SysmonProvider }, eventIds); + + private static SigmaLogSourceMapping PowerShell(string category, params int[] eventIds) => + new(category, new[] { PowerShellChannel }, new[] { PowerShellProvider }, eventIds); +} diff --git a/Sources/EventViewerX.Detection/SigmaRuleCompiler.cs b/Sources/EventViewerX.Detection/SigmaRuleCompiler.cs index 5e166941..582bd2aa 100644 --- a/Sources/EventViewerX.Detection/SigmaRuleCompiler.cs +++ b/Sources/EventViewerX.Detection/SigmaRuleCompiler.cs @@ -9,7 +9,10 @@ namespace EventViewerX.Sigma; /// Parses, validates, and compiles supported Sigma 2.x YAML into native EventViewerX detections. public static class SigmaRuleCompiler { /// Compiles one or more YAML documents separated by ---. - public static SigmaCompilationResult CompileYaml(string yaml) { + public static SigmaCompilationResult CompileYaml(string yaml) => CompileYaml(yaml, null); + + /// Compiles one or more YAML documents using explicitly selected compilation options. + public static SigmaCompilationResult CompileYaml(string yaml, SigmaCompilationOptions? options) { if (string.IsNullOrWhiteSpace(yaml)) { throw new ArgumentException("Sigma YAML cannot be empty.", nameof(yaml)); } @@ -56,7 +59,7 @@ public static SigmaCompilationResult CompileYaml(string yaml) { continue; } if (TryGet(root, "detection", out _)) { - TryCompileBaseRule(root, index, diagnostics, baseRules); + TryCompileBaseRule(root, index, options, diagnostics, baseRules); } else if (!correlation) { diagnostics.Add(Error( "EVXSIGMA003", @@ -95,8 +98,21 @@ public static SigmaCompilationResult Load(string path) { return CompileYaml(File.ReadAllText(Path.GetFullPath(path))); } + /// Loads and compiles Sigma YAML from disk using explicitly selected compilation options. + public static SigmaCompilationResult Load(string path, SigmaCompilationOptions? options) { + if (string.IsNullOrWhiteSpace(path)) { + throw new ArgumentException("Sigma path cannot be empty.", nameof(path)); + } + return CompileYaml(File.ReadAllText(Path.GetFullPath(path)), options); + } + /// Loads several Sigma YAML files as one compilation unit so correlations can resolve across files. public static SigmaCompilationResult Load(IEnumerable paths) { + return Load(paths, null); + } + + /// Loads several Sigma YAML files as one compilation unit using explicitly selected compilation options. + public static SigmaCompilationResult Load(IEnumerable paths, SigmaCompilationOptions? options) { if (paths == null) { throw new ArgumentNullException(nameof(paths)); } @@ -109,7 +125,7 @@ public static SigmaCompilationResult Load(IEnumerable paths) { } return CompileYaml(string.Join( Environment.NewLine + "---" + Environment.NewLine, - resolved.Select(File.ReadAllText))); + resolved.Select(File.ReadAllText)), options); } /// Creates a native integrity-protected pack from fully supported Sigma input. @@ -120,7 +136,19 @@ public static EventDetectionPack CompilePack( IEnumerable? authors = null, string? license = null) { - SigmaCompilationResult result = CompileYaml(yaml); + return CompilePack(yaml, packId, version, authors, license, null); + } + + /// Creates a native integrity-protected pack from fully supported Sigma input using explicit compilation options. + public static EventDetectionPack CompilePack( + string yaml, + string packId, + string version, + IEnumerable? authors, + string? license, + SigmaCompilationOptions? options) { + + SigmaCompilationResult result = CompileYaml(yaml, options); if (!result.IsSupported) { _ = result.CompilePlan(); } @@ -135,6 +163,7 @@ public static EventDetectionPack CompilePack( private static void TryCompileBaseRule( YamlMappingNode root, int documentIndex, + SigmaCompilationOptions? options, ICollection diagnostics, ICollection rules) { @@ -163,7 +192,7 @@ private static void TryCompileBaseRule( } string condition = RequiredText(detection, "condition"); EventPredicate predicate = SigmaConditionCompiler.Compile(condition, selections); - LogSourceSelectors selectors = CompileLogSource(root, predicate, documentIndex, diagnostics); + LogSourceSelectors selectors = CompileLogSource(root, predicate, options, documentIndex, diagnostics); string status = OptionalText(root, "status"); var definition = new EventDetectionRuleDefinition { RuleId = "SIGMA-" + sourceId, @@ -180,7 +209,7 @@ private static void TryCompileBaseRule( Kind = EventDetectionRuleKind.Stateless, Channels = selectors.Channels, Providers = selectors.Providers, - EventIds = SigmaSelectionCompiler.GetGuaranteedEventIds(predicate), + EventIds = selectors.EventIds, Predicate = predicate, Tags = TextList(root, "tags"), FalsePositives = TextList(root, "falsepositives"), @@ -385,6 +414,7 @@ private static (int Threshold, string? Field) ParseCorrelationCondition( private static LogSourceSelectors CompileLogSource( YamlMappingNode root, EventPredicate predicate, + SigmaCompilationOptions? options, int documentIndex, ICollection diagnostics) { @@ -394,7 +424,10 @@ private static LogSourceSelectors CompileLogSource( SigmaDiagnosticSeverity.Warning, "Sigma rule has no logsource; EVX will rely on exact managed predicate verification.", documentIndex)); - return new LogSourceSelectors(Array.Empty(), Array.Empty()); + return new LogSourceSelectors( + Array.Empty(), + Array.Empty(), + SigmaSelectionCompiler.GetGuaranteedEventIds(predicate)); } string product = OptionalText(logSource, "product"); if (product.Length != 0 && !string.Equals(product, "windows", StringComparison.OrdinalIgnoreCase)) { @@ -417,18 +450,28 @@ private static LogSourceSelectors CompileLogSource( "EVXSIGMA022", $"Sigma Windows logsource service '{service}' has no lossless EventViewerX channel mapping.") }; - if (category.Length != 0 && !HasGuaranteedEventIdConstraint(predicate)) { - throw new SigmaConditionException( - "EVXSIGMA023", - $"Sigma category '{category}' cannot be preserved losslessly without an explicit EventID constraint on every matching branch."); + int[] eventIds = SigmaSelectionCompiler.GetGuaranteedEventIds(predicate); + string[] channels = channel == null ? Array.Empty() : new[] { channel }; + string[] providers = Array.Empty(); + if (category.Length != 0 && eventIds.Length == 0) { + SigmaLogSourceProfile? profile = options?.LogSourceProfile; + if (profile == null || !profile.TryGetMapping(category, out SigmaLogSourceMapping mapping)) { + throw new SigmaConditionException( + "EVXSIGMA023", + $"Sigma category '{category}' cannot be preserved losslessly without an explicit EventID constraint on every matching branch or an explicitly selected telemetry profile mapping."); + } + if (channel != null && !mapping.Channels.Contains(channel, StringComparer.OrdinalIgnoreCase)) { + throw new SigmaConditionException( + "EVXSIGMA024", + $"Sigma category '{category}' resolves to telemetry that conflicts with service '{service}' in profile '{profile.ProfileId}' version '{profile.Version}'."); + } + channels = channel == null + ? mapping.Channels.ToArray() + : new[] { channel }; + providers = mapping.Providers.ToArray(); + eventIds = mapping.EventIds.ToArray(); } - return new LogSourceSelectors( - channel == null ? Array.Empty() : new[] { channel }, - Array.Empty()); - } - - private static bool HasGuaranteedEventIdConstraint(EventPredicate predicate) { - return SigmaSelectionCompiler.GetGuaranteedEventIds(predicate).Length > 0; + return new LogSourceSelectors(channels, providers, eventIds); } private static CompiledBaseRule ResolveBaseRule( @@ -599,12 +642,14 @@ internal CompiledCorrelation( } private readonly struct LogSourceSelectors { - internal LogSourceSelectors(string[] channels, string[] providers) { + internal LogSourceSelectors(string[] channels, string[] providers, int[] eventIds) { Channels = channels; Providers = providers; + EventIds = eventIds; } internal string[] Channels { get; } internal string[] Providers { get; } + internal int[] EventIds { get; } } } diff --git a/Sources/EventViewerX.SigmaAudit/EventViewerX.SigmaAudit.csproj b/Sources/EventViewerX.SigmaAudit/EventViewerX.SigmaAudit.csproj new file mode 100644 index 00000000..f5a0a2cc --- /dev/null +++ b/Sources/EventViewerX.SigmaAudit/EventViewerX.SigmaAudit.csproj @@ -0,0 +1,14 @@ + + + Exe + net10.0-windows + enable + enable + true + Reproducible Sigma corpus compatibility audit for EventViewerX. + + + + + + diff --git a/Sources/EventViewerX.SigmaAudit/GitCorpusVerifier.cs b/Sources/EventViewerX.SigmaAudit/GitCorpusVerifier.cs new file mode 100644 index 00000000..a2589c96 --- /dev/null +++ b/Sources/EventViewerX.SigmaAudit/GitCorpusVerifier.cs @@ -0,0 +1,132 @@ +using System.Diagnostics; + +namespace EventViewerX.SigmaAudit; + +internal static class GitCorpusVerifier { + internal static GitCorpusSnapshot Verify( + string corpusPath, + string scanRoot, + string expectedCommit) { + + DirectoryInfo repository = FindRepository(corpusPath); + string actualCommit = Run(repository.FullName, "rev-parse", "HEAD").Trim(); + if (!string.Equals(actualCommit, expectedCommit, StringComparison.OrdinalIgnoreCase)) { + throw new InvalidDataException( + $"Sigma corpus HEAD is '{actualCommit}', not requested commit '{expectedCommit}'."); + } + + string relativeScanRoot = Path.GetRelativePath(repository.FullName, scanRoot); + string status = Run( + repository.FullName, + "status", + "--porcelain", + "--untracked-files=no", + "--", + relativeScanRoot).Trim(); + if (status.Length != 0) { + throw new InvalidDataException( + "Sigma corpus contains tracked changes and cannot produce a reproducible compatibility report."); + } + + string trackedOutput = Run( + repository.FullName, + "ls-files", + "-v", + "-z", + "--", + relativeScanRoot); + string scanRootFullPath = Path.GetFullPath(scanRoot) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string[] files = trackedOutput + .Split('\0', StringSplitOptions.RemoveEmptyEntries) + .Select(record => { + if (record.Length < 3 || record[1] != ' ' || record[0] != 'H') { + throw new InvalidDataException( + $"Tracked Sigma corpus entry '{record}' has a hidden or unsupported Git index state. " + + "Clear assume-unchanged/skip-worktree flags and materialize the pinned file before auditing it."); + } + return record[2..]; + }) + .Select(path => Path.GetFullPath(Path.Combine( + repository.FullName, + path.Replace('/', Path.DirectorySeparatorChar)))) + .Where(path => + path.StartsWith(scanRootFullPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) && + (path.EndsWith(".yml", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase))) + .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (files.Length == 0) { + throw new InvalidDataException( + $"Sigma scope '{scanRootFullPath}' contains no tracked YAML files."); + } + string? missing = files.FirstOrDefault(static path => !File.Exists(path)); + if (missing != null) { + throw new InvalidDataException( + $"Tracked Sigma corpus file '{missing}' is not materialized in the worktree."); + } + return new GitCorpusSnapshot(files); + } + + private static DirectoryInfo FindRepository(string path) { + var directory = new DirectoryInfo(Path.GetFullPath(path)); + for (DirectoryInfo? candidate = directory; candidate != null; candidate = candidate.Parent) { + if (Directory.Exists(Path.Combine(candidate.FullName, ".git")) || + File.Exists(Path.Combine(candidate.FullName, ".git"))) { + return candidate; + } + } + throw new InvalidDataException( + $"Sigma corpus '{directory.FullName}' is not inside a Git worktree."); + } + + private static string Run(string workingDirectory, params string[] arguments) { + var startInfo = new ProcessStartInfo { + FileName = "git", + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + foreach (string argument in arguments) { + startInfo.ArgumentList.Add(argument); + } + using Process process = Process.Start(startInfo) ?? + throw new InvalidOperationException("Git could not be started."); + Task outputTask = process.StandardOutput.ReadToEndAsync(); + Task errorTask = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(10000)) { + try { + process.Kill(entireProcessTree: true); + _ = process.WaitForExit(5000); + } catch (InvalidOperationException) { + // The process exited between the timeout and termination request. + } + try { + _ = Task.WhenAll(outputTask, errorTask).Wait(TimeSpan.FromSeconds(5)); + } catch (AggregateException) { + // Preserve the primary bounded-runtime failure below. + } + throw new TimeoutException("Git did not complete within 10 seconds while verifying the Sigma corpus."); + } + if (!Task.WhenAll(outputTask, errorTask).Wait(TimeSpan.FromSeconds(5))) { + throw new TimeoutException("Git exited but its redirected output did not close within 5 seconds."); + } + string standardOutput = outputTask.GetAwaiter().GetResult(); + string standardError = errorTask.GetAwaiter().GetResult(); + if (process.ExitCode != 0) { + throw new InvalidDataException( + $"Git failed while verifying the Sigma corpus: {standardError.Trim()}"); + } + return standardOutput; + } +} + +internal sealed class GitCorpusSnapshot { + internal GitCorpusSnapshot(IReadOnlyList files) { + Files = files; + } + + internal IReadOnlyList Files { get; } +} diff --git a/Sources/EventViewerX.SigmaAudit/MarkdownReportWriter.cs b/Sources/EventViewerX.SigmaAudit/MarkdownReportWriter.cs new file mode 100644 index 00000000..0a0938a0 --- /dev/null +++ b/Sources/EventViewerX.SigmaAudit/MarkdownReportWriter.cs @@ -0,0 +1,54 @@ +using System.Globalization; +using System.Text; + +namespace EventViewerX.SigmaAudit; + +internal static class MarkdownReportWriter { + internal static string Render(SigmaAuditReport report) { + var builder = new StringBuilder(); + builder.AppendLine("# Sigma compatibility audit"); + builder.AppendLine(); + builder.AppendLine($"- Repository: `{report.Repository}`"); + builder.AppendLine($"- Commit: `{report.Commit}`"); + builder.AppendLine($"- Scope: `{report.Scope}`"); + builder.AppendLine($"- Telemetry profile: `{report.ProfileId}` version `{report.ProfileVersion}`"); + builder.AppendLine($"- Generated: `{report.GeneratedAtUtc:O}`"); + builder.AppendLine(); + builder.AppendLine("## Summary"); + builder.AppendLine(); + builder.AppendLine("| Files | Supported | Supported with warnings | Unsupported | Compiled rules | Supported % |"); + builder.AppendLine("| ---: | ---: | ---: | ---: | ---: | ---: |"); + builder.AppendLine( + $"| {report.Summary.TotalFiles} | {report.Summary.SupportedFiles} | " + + $"{report.Summary.SupportedWithWarningsFiles} | {report.Summary.UnsupportedFiles} | " + + $"{report.Summary.CompiledRules} | {Number(report.Summary.SupportedPercent)} |" ); + builder.AppendLine(); + builder.AppendLine("## Categories"); + builder.AppendLine(); + builder.AppendLine("| Category | Files | Supported | Warnings | Unsupported | Supported % |"); + builder.AppendLine("| --- | ---: | ---: | ---: | ---: | ---: |"); + foreach (SigmaCategorySummary category in report.Categories) { + builder.AppendLine( + $"| {Escape(category.Category)} | {category.TotalFiles} | " + + $"{category.SupportedFiles} | {category.SupportedWithWarningsFiles} | " + + $"{category.UnsupportedFiles} | {Number(category.SupportedPercent)} |" ); + } + builder.AppendLine(); + builder.AppendLine("## Diagnostic coverage"); + builder.AppendLine(); + builder.AppendLine("| Code | Severity | Files | Occurrences |"); + builder.AppendLine("| --- | --- | ---: | ---: |"); + foreach (SigmaDiagnosticSummary diagnostic in report.Diagnostics) { + builder.AppendLine( + $"| `{Escape(diagnostic.Code)}` | {Escape(diagnostic.Severity)} | " + + $"{diagnostic.FileCount} | {diagnostic.Occurrences} |" ); + } + return builder.ToString(); + } + + private static string Number(double value) => + value.ToString("0.00", CultureInfo.InvariantCulture); + + private static string Escape(string value) => + value.Replace("|", "\\|", StringComparison.Ordinal); +} diff --git a/Sources/EventViewerX.SigmaAudit/Program.cs b/Sources/EventViewerX.SigmaAudit/Program.cs new file mode 100644 index 00000000..0542ac21 --- /dev/null +++ b/Sources/EventViewerX.SigmaAudit/Program.cs @@ -0,0 +1,153 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EventViewerX.SigmaAudit; + +internal static class Program { + public static int Main(string[] args) { + try { + AuditOptions options = AuditOptions.Parse(args); + SigmaAuditReport report = SigmaAuditRunner.Run(options); + var jsonOptions = new JsonSerializerOptions { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + jsonOptions.Converters.Add(new JsonStringEnumConverter()); + + WriteOutput(options.OutputJson, JsonSerializer.Serialize(report, jsonOptions)); + WriteOutput(options.OutputMarkdown, MarkdownReportWriter.Render(report)); + + Console.WriteLine( + $"Audited {report.Summary.TotalFiles} Sigma files: " + + $"{report.Summary.SupportedFiles} supported, " + + $"{report.Summary.SupportedWithWarningsFiles} supported with warnings, " + + $"{report.Summary.UnsupportedFiles} unsupported."); + return 0; + } catch (ArgumentException exception) { + Console.Error.WriteLine(exception.Message); + Console.Error.WriteLine(AuditOptions.Usage); + return 64; + } catch (Exception exception) { + Console.Error.WriteLine(exception); + return 1; + } + } + + private static void WriteOutput(string path, string content) { + string fullPath = Path.GetFullPath(path); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, content); + } +} + +internal sealed class AuditOptions { + internal const string Usage = + "Usage: EventViewerX.SigmaAudit --corpus --commit " + + "--output-json --output-markdown [--scope windows|all] " + + "[--profile strict|windows-sysmon-powershell]"; + + private AuditOptions( + string corpusPath, + string commit, + string outputJson, + string outputMarkdown, + AuditScope scope, + AuditProfile profile) { + + CorpusPath = corpusPath; + Commit = commit; + OutputJson = outputJson; + OutputMarkdown = outputMarkdown; + Scope = scope; + Profile = profile; + } + + internal string CorpusPath { get; } + internal string Commit { get; } + internal string OutputJson { get; } + internal string OutputMarkdown { get; } + internal AuditScope Scope { get; } + internal AuditProfile Profile { get; } + + internal static AuditOptions Parse(IReadOnlyList args) { + var allowed = new HashSet( + new[] { + "corpus", + "commit", + "output-json", + "output-markdown", + "scope", + "profile" + }, + StringComparer.OrdinalIgnoreCase); + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var index = 0; index < args.Count; index += 2) { + if (index + 1 >= args.Count || !args[index].StartsWith("--", StringComparison.Ordinal)) { + throw new ArgumentException("Every Sigma audit option must use --name value syntax."); + } + string name = args[index][2..]; + if (!allowed.Contains(name)) { + throw new ArgumentException($"Option '--{name}' is not supported."); + } + if (!values.TryAdd(name, args[index + 1])) { + throw new ArgumentException($"Option '--{name}' was supplied more than once."); + } + } + + string corpus = Required(values, "corpus"); + string commit = Required(values, "commit"); + string outputJson = Required(values, "output-json"); + string outputMarkdown = Required(values, "output-markdown"); + string scopeText = values.TryGetValue("scope", out string? suppliedScope) + ? suppliedScope + : "windows"; + AuditScope scope = scopeText.ToLowerInvariant() switch { + "windows" => AuditScope.Windows, + "all" => AuditScope.All, + _ => throw new ArgumentException("Scope must be 'windows' or 'all'.") + }; + string profileText = values.TryGetValue("profile", out string? suppliedProfile) + ? suppliedProfile + : "strict"; + AuditProfile profile = profileText.ToLowerInvariant() switch { + "strict" => AuditProfile.Strict, + "windows-sysmon-powershell" => AuditProfile.WindowsSysmonAndPowerShell, + _ => throw new ArgumentException( + "Profile must be 'strict' or 'windows-sysmon-powershell'.") + }; + string fullCorpus = Path.GetFullPath(corpus); + if (!Directory.Exists(fullCorpus)) { + throw new ArgumentException($"Sigma corpus path '{fullCorpus}' does not exist."); + } + if (commit.Length != 40 || commit.Any(static value => !Uri.IsHexDigit(value))) { + throw new ArgumentException("Commit must be a full 40-character hexadecimal Git object ID."); + } + string fullOutputJson = Path.GetFullPath(outputJson); + string fullOutputMarkdown = Path.GetFullPath(outputMarkdown); + if (string.Equals( + fullOutputJson, + fullOutputMarkdown, + StringComparison.OrdinalIgnoreCase)) { + + throw new ArgumentException( + "Output JSON and Markdown paths must resolve to distinct files."); + } + return new AuditOptions( + fullCorpus, + commit.ToLowerInvariant(), + fullOutputJson, + fullOutputMarkdown, + scope, + profile); + } + + private static string Required( + IReadOnlyDictionary values, + string name) { + + if (!values.TryGetValue(name, out string? value) || string.IsNullOrWhiteSpace(value)) { + throw new ArgumentException($"Option '--{name}' is required."); + } + return value.Trim(); + } +} diff --git a/Sources/EventViewerX.SigmaAudit/SigmaAuditReport.cs b/Sources/EventViewerX.SigmaAudit/SigmaAuditReport.cs new file mode 100644 index 00000000..2a76b349 --- /dev/null +++ b/Sources/EventViewerX.SigmaAudit/SigmaAuditReport.cs @@ -0,0 +1,70 @@ +namespace EventViewerX.SigmaAudit; + +internal enum AuditScope { + Windows, + All +} + +internal enum AuditProfile { + Strict, + WindowsSysmonAndPowerShell +} + +internal enum SigmaFileStatus { + Supported, + SupportedWithWarnings, + Unsupported +} + +internal sealed class SigmaAuditReport { + public required string Repository { get; init; } + public required string Commit { get; init; } + public required AuditScope Scope { get; init; } + public required string ProfileId { get; init; } + public required string ProfileVersion { get; init; } + public required DateTime GeneratedAtUtc { get; init; } + public required SigmaAuditSummary Summary { get; init; } + public required IReadOnlyList Categories { get; init; } + public required IReadOnlyList Diagnostics { get; init; } + public required IReadOnlyList Files { get; init; } +} + +internal sealed class SigmaAuditSummary { + public required int TotalFiles { get; init; } + public required int SupportedFiles { get; init; } + public required int SupportedWithWarningsFiles { get; init; } + public required int UnsupportedFiles { get; init; } + public required int CompiledRules { get; init; } + public required double SupportedPercent { get; init; } +} + +internal sealed class SigmaCategorySummary { + public required string Category { get; init; } + public required int TotalFiles { get; init; } + public required int SupportedFiles { get; init; } + public required int SupportedWithWarningsFiles { get; init; } + public required int UnsupportedFiles { get; init; } + public required double SupportedPercent { get; init; } +} + +internal sealed class SigmaDiagnosticSummary { + public required string Code { get; init; } + public required string Severity { get; init; } + public required int FileCount { get; init; } + public required int Occurrences { get; init; } +} + +internal sealed class SigmaFileResult { + public required string Path { get; init; } + public required string Category { get; init; } + public required SigmaFileStatus Status { get; init; } + public required int CompiledRules { get; init; } + public required IReadOnlyList Diagnostics { get; init; } +} + +internal sealed class SigmaFileDiagnostic { + public required string Code { get; init; } + public required string Severity { get; init; } + public required string Message { get; init; } + public required int DocumentIndex { get; init; } +} diff --git a/Sources/EventViewerX.SigmaAudit/SigmaAuditRunner.cs b/Sources/EventViewerX.SigmaAudit/SigmaAuditRunner.cs new file mode 100644 index 00000000..867d47f9 --- /dev/null +++ b/Sources/EventViewerX.SigmaAudit/SigmaAuditRunner.cs @@ -0,0 +1,126 @@ +using EventViewerX.Sigma; + +namespace EventViewerX.SigmaAudit; + +internal static class SigmaAuditRunner { + private const string Repository = "https://github.com/SigmaHQ/sigma"; + + internal static SigmaAuditReport Run(AuditOptions options) { + string scanRoot = options.Scope == AuditScope.Windows + ? Path.Combine(options.CorpusPath, "windows") + : options.CorpusPath; + if (!Directory.Exists(scanRoot)) { + throw new ArgumentException( + $"Sigma scope root '{scanRoot}' does not exist."); + } + GitCorpusSnapshot corpus = GitCorpusVerifier.Verify( + options.CorpusPath, + scanRoot, + options.Commit); + SigmaLogSourceProfile? profile = options.Profile switch { + AuditProfile.Strict => null, + AuditProfile.WindowsSysmonAndPowerShell => + SigmaLogSourceProfile.WindowsSysmonAndPowerShell, + _ => throw new ArgumentOutOfRangeException(nameof(options.Profile)) + }; + var compilationOptions = new SigmaCompilationOptions { + LogSourceProfile = profile + }; + string scanPrefix = Path.GetFullPath(scanRoot) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + string[] files = corpus.Files + .Where(path => path.StartsWith( + scanPrefix, + StringComparison.OrdinalIgnoreCase)) + .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (files.Length == 0) { + throw new InvalidDataException( + $"Sigma scope root '{scanRoot}' contains no YAML files."); + } + + SigmaFileResult[] results = SigmaCorpusCompiler.Audit( + options.CorpusPath, + scanRoot, + files, + compilationOptions); + int supported = results.Count(static item => + item.Status == SigmaFileStatus.Supported); + int supportedWithWarnings = results.Count(static item => + item.Status == SigmaFileStatus.SupportedWithWarnings); + int unsupported = results.Count(static item => + item.Status == SigmaFileStatus.Unsupported); + int supportedTotal = supported + supportedWithWarnings; + return new SigmaAuditReport { + Repository = Repository, + Commit = options.Commit, + Scope = options.Scope, + ProfileId = profile?.ProfileId ?? "strict", + ProfileVersion = profile?.Version ?? "1.0.0", + GeneratedAtUtc = DateTime.UtcNow, + Summary = new SigmaAuditSummary { + TotalFiles = results.Length, + SupportedFiles = supported, + SupportedWithWarningsFiles = supportedWithWarnings, + UnsupportedFiles = unsupported, + CompiledRules = results.Sum(static item => item.CompiledRules), + SupportedPercent = Percent(supportedTotal, results.Length) + }, + Categories = BuildCategories(results), + Diagnostics = BuildDiagnostics(results), + Files = results + }; + } + + private static IReadOnlyList BuildCategories( + IEnumerable results) => results + .GroupBy(static item => item.Category, StringComparer.OrdinalIgnoreCase) + .OrderBy(static group => group.Key, StringComparer.OrdinalIgnoreCase) + .Select(static group => { + int supported = group.Count(static item => + item.Status == SigmaFileStatus.Supported); + int warnings = group.Count(static item => + item.Status == SigmaFileStatus.SupportedWithWarnings); + int unsupported = group.Count(static item => + item.Status == SigmaFileStatus.Unsupported); + return new SigmaCategorySummary { + Category = group.Key, + TotalFiles = group.Count(), + SupportedFiles = supported, + SupportedWithWarningsFiles = warnings, + UnsupportedFiles = unsupported, + SupportedPercent = Percent( + supported + warnings, + group.Count()) + }; + }) + .ToArray(); + + private static IReadOnlyList BuildDiagnostics( + IEnumerable results) => results + .SelectMany(static file => file.Diagnostics.Select(diagnostic => + new { File = file.Path, Diagnostic = diagnostic })) + .GroupBy( + static item => (item.Diagnostic.Code, item.Diagnostic.Severity), + static item => item) + .Select(static group => new SigmaDiagnosticSummary { + Code = group.Key.Code, + Severity = group.Key.Severity, + FileCount = group.Select(static item => item.File) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(), + Occurrences = group.Count() + }) + .OrderByDescending(static item => item.FileCount) + .ThenBy(static item => item.Code, StringComparer.Ordinal) + .ToArray(); + + private static double Percent(int numerator, int denominator) => + denominator == 0 + ? 0 + : Math.Round( + numerator * 100d / denominator, + 2, + MidpointRounding.AwayFromZero); +} diff --git a/Sources/EventViewerX.SigmaAudit/SigmaCorpusCompiler.cs b/Sources/EventViewerX.SigmaAudit/SigmaCorpusCompiler.cs new file mode 100644 index 00000000..5fdeb8af --- /dev/null +++ b/Sources/EventViewerX.SigmaAudit/SigmaCorpusCompiler.cs @@ -0,0 +1,197 @@ +using System.Security.Cryptography; +using System.Text; +using EventViewerX.Sigma; +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; + +namespace EventViewerX.SigmaAudit; + +internal static class SigmaCorpusCompiler { + internal static SigmaFileResult[] Audit( + string corpusRoot, + string scopeRoot, + IReadOnlyList paths, + SigmaCompilationOptions options) { + + var files = new List(paths.Count); + var documents = new List(); + foreach (string path in paths) { + CorpusFile file = ReadFile(corpusRoot, scopeRoot, path, documents.Count); + files.Add(file); + documents.AddRange(file.Documents); + } + + if (documents.Count == 0) { + return files.Select(static file => ResultForUnreadableFile(file)).ToArray(); + } + + var combinedStream = new YamlStream( + documents.Select(static document => document.Document).ToArray()); + string yaml; + using (var writer = new StringWriter()) { + combinedStream.Save(writer, assignAnchors: false); + yaml = writer.ToString(); + } + SigmaCompilationResult compilation = SigmaRuleCompiler.CompileYaml(yaml, options); + var compiledSourceIds = new HashSet( + compilation.Rules.Select(static rule => rule.Definition.SourceId), + StringComparer.OrdinalIgnoreCase); + + return files.Select(file => ResultForFile( + file, + compilation, + compiledSourceIds)).ToArray(); + } + + private static CorpusFile ReadFile( + string corpusRoot, + string scopeRoot, + string path, + int firstDocumentIndex) { + + string relativePath = Path + .GetRelativePath(corpusRoot, path) + .Replace(Path.DirectorySeparatorChar, '/'); + string category = Path + .GetRelativePath(scopeRoot, path) + .Replace(Path.DirectorySeparatorChar, '/') + .Split('/')[0]; + try { + string yaml = File.ReadAllText(path); + var stream = new YamlStream(); + using var reader = new StringReader(yaml); + stream.Load(reader); + if (stream.Documents.Count == 0) { + throw new InvalidDataException("Sigma YAML contains no documents."); + } + + CorpusDocument[] documents = stream.Documents + .Select((document, index) => CreateDocument( + document, + firstDocumentIndex + index)) + .ToArray(); + return new CorpusFile(relativePath, category, documents, null); + } catch (Exception exception) when ( + exception is IOException or + UnauthorizedAccessException or + ArgumentException or + InvalidDataException or + YamlException) { + + return new CorpusFile( + relativePath, + category, + Array.Empty(), + exception.Message); + } + } + + private static CorpusDocument CreateDocument(YamlDocument document, int index) { + string yaml = document.RootNode.ToString(); + string? sourceId = null; + if (document.RootNode is YamlMappingNode root && + (HasKey(root, "detection") || HasKey(root, "correlation"))) { + + sourceId = ScalarValue(root, "id"); + if (string.IsNullOrWhiteSpace(sourceId)) { + sourceId = "generated-" + Hash(yaml)[..24].ToLowerInvariant(); + } + } + return new CorpusDocument(index, sourceId, document); + } + + private static SigmaFileResult ResultForFile( + CorpusFile file, + SigmaCompilationResult compilation, + ISet compiledSourceIds) { + + if (file.Error != null) { + return ResultForUnreadableFile(file); + } + + var documentIndexes = new HashSet( + file.Documents.Select(static document => document.Index)); + SigmaDiagnostic[] sourceDiagnostics = compilation.Diagnostics + .Where(item => documentIndexes.Contains(item.DocumentIndex)) + .ToArray(); + SigmaFileDiagnostic[] diagnostics = sourceDiagnostics + .Select(item => new SigmaFileDiagnostic { + Code = item.Code, + Severity = item.Severity.ToString(), + Message = item.Message, + DocumentIndex = item.DocumentIndex - file.Documents[0].Index + }) + .ToArray(); + var failedDocumentIndexes = new HashSet(sourceDiagnostics + .Where(static item => item.Severity == SigmaDiagnosticSeverity.Error) + .Select(static item => item.DocumentIndex)); + int compiledRules = file.Documents.Count(document => + document.SourceId != null && + compiledSourceIds.Contains(document.SourceId) && + !failedDocumentIndexes.Contains(document.Index)); + bool errors = sourceDiagnostics.Any(static item => + item.Severity == SigmaDiagnosticSeverity.Error); + bool warnings = sourceDiagnostics.Any(static item => + item.Severity == SigmaDiagnosticSeverity.Warning); + return new SigmaFileResult { + Path = file.RelativePath, + Category = file.Category, + Status = errors + ? SigmaFileStatus.Unsupported + : warnings + ? SigmaFileStatus.SupportedWithWarnings + : SigmaFileStatus.Supported, + CompiledRules = compiledRules, + Diagnostics = diagnostics + }; + } + + private static SigmaFileResult ResultForUnreadableFile(CorpusFile file) => + new() { + Path = file.RelativePath, + Category = file.Category, + Status = SigmaFileStatus.Unsupported, + CompiledRules = 0, + Diagnostics = new[] { + new SigmaFileDiagnostic { + Code = "EVXSIGMAAUDIT001", + Severity = "Error", + Message = file.Error ?? "Sigma YAML contains no compilable documents.", + DocumentIndex = 0 + } + } + }; + + private static bool HasKey(YamlMappingNode root, string key) => + root.Children.Keys + .OfType() + .Any(node => string.Equals(node.Value, key, StringComparison.OrdinalIgnoreCase)); + + private static string? ScalarValue(YamlMappingNode root, string key) { + foreach (KeyValuePair item in root.Children) { + if (item.Key is YamlScalarNode scalarKey && + string.Equals(scalarKey.Value, key, StringComparison.OrdinalIgnoreCase) && + item.Value is YamlScalarNode scalarValue) { + + return scalarValue.Value?.Trim(); + } + } + return null; + } + + private static string Hash(string value) { + using SHA256 sha = SHA256.Create(); + return Convert.ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes(value))); + } + + private sealed record CorpusFile( + string RelativePath, + string Category, + IReadOnlyList Documents, + string? Error); + + private sealed record CorpusDocument( + int Index, + string? SourceId, + YamlDocument Document); +} diff --git a/Sources/EventViewerX.SigmaAudit/packages.lock.json b/Sources/EventViewerX.SigmaAudit/packages.lock.json new file mode 100644 index 00000000..c051e602 --- /dev/null +++ b/Sources/EventViewerX.SigmaAudit/packages.lock.json @@ -0,0 +1,71 @@ +{ + "version": 1, + "dependencies": { + "net10.0-windows7.0": { + "YamlDotNet": { + "type": "Direct", + "requested": "[18.1.0, )", + "resolved": "18.1.0", + "contentHash": "5K+9KFg2TdTl7VXv88Qzi/0lqK6JFoNP3lRuImPYGRV7K/QYklDyTrj4+A+KAki1JsQi6qKY+hDyY7d6WRqjrw==" + }, + "DnsClientX": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "Pp57zl3zJHkB1MMiVApLZuksJitylTQ1baThYTScU8DR9uh8iPqskFj6HNsSEKlW/vJnTvnWwu5zuouAFxEBNw==" + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "3.0.10", + "contentHash": "yZIhtw8sYuvsONzQbZxWpR60tMWYHXoo0DL6nyOqSFiU5POjBTSEyWFpTQtJEZuy+oqiYTXKXY/Mjx7KnqIQFw==" + }, + "Json.More.Net": { + "type": "Transitive", + "resolved": "3.0.1", + "contentHash": "fRctF2J2SILYG6wqP21drmeEODmCVkVQ/b3MndDu2fT1swfySyUgq7ePCk+aENGlDcIm05fyfjh9vcuqDEfv3w==" + }, + "JsonPointer.Net": { + "type": "Transitive", + "resolved": "7.0.2", + "contentHash": "oClYHv2ooeRrtPZyC9sb/Za/ie5pGhjTbNHlAyFg4XOCyU+606FdoZKS7UdaWvtsPlrF+U2w0Ja3TRvn5+spyA==", + "dependencies": { + "Humanizer.Core": "3.0.10", + "Json.More.Net": "3.0.1" + } + }, + "JsonSchema.Net": { + "type": "Transitive", + "resolved": "9.4.0", + "contentHash": "muE4nPuzbD9x5XA1mkXJNqX4mhz47oF3EY8qFLY1pyUY7lhXFKq65t/dSKIHeNSLBV17zwZuq24MD1em90rMNg==", + "dependencies": { + "JsonPointer.Net": "7.0.2" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "QTXEoQBzz00SFWbo7nAg1Ogd4f99lwqcO9uAJ7MYSLEUR28f6As32QktrqG2Fr9cfAfd1GjLyGYspE7Ipj7P6w==" + }, + "System.DirectoryServices": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "kj7evAu1OcxBg2KOXWDgbLt12100t+1rlKk6M9/F/lK1NF68me6iQ/x9rJ8xEEt7AtVj4lMUFD/nr6N5zcQBKw==" + }, + "eventviewerx": { + "type": "Project", + "dependencies": { + "DnsClientX": "[2.1.0, )", + "System.Diagnostics.EventLog": "[10.0.11, )", + "System.DirectoryServices": "[10.0.11, )" + } + }, + "eventviewerx.detection": { + "type": "Project", + "dependencies": { + "EventViewerX": "[4.0.0, )", + "JsonSchema.Net": "[9.4.0, )", + "YamlDotNet": "[18.1.0, )" + } + } + } + } +} \ No newline at end of file diff --git a/Sources/EventViewerX.Tests/TestEventLogBatchEngine.cs b/Sources/EventViewerX.Tests/TestEventLogBatchEngine.cs index cabef56a..1e2b29a9 100644 --- a/Sources/EventViewerX.Tests/TestEventLogBatchEngine.cs +++ b/Sources/EventViewerX.Tests/TestEventLogBatchEngine.cs @@ -883,6 +883,26 @@ public void FatalSynchronousPrimerCancelsItsSibling() { TimeSpan.FromSeconds(5)); } + [Fact] + public void PrimedSourceLifetimeRemainsUsableAfterPrimingOwnerIsDisposed() { + using var requestCancellation = + new CancellationTokenSource(); + var primingCancellation = + new CancellationTokenSource(); + using CancellationTokenSource sourceLifetime = + EventLogBatchEngine.CreatePrimedSourceLifetime( + requestCancellation.Token, + primingCancellation.Token); + CancellationToken sourceToken = sourceLifetime.Token; + + primingCancellation.Dispose(); + + using CancellationTokenRegistration registration = + sourceToken.Register(static () => { }); + requestCancellation.Cancel(); + Assert.True(sourceToken.IsCancellationRequested); + } + [Fact] public async Task FatalAsynchronousPrimerCancelsItsSibling() { var siblingStarted = diff --git a/Sources/EventViewerX.Tests/TestEventReadinessEngine.cs b/Sources/EventViewerX.Tests/TestEventReadinessEngine.cs index 52b4665c..879bf6d0 100644 --- a/Sources/EventViewerX.Tests/TestEventReadinessEngine.cs +++ b/Sources/EventViewerX.Tests/TestEventReadinessEngine.cs @@ -800,6 +800,254 @@ public void PartialSourceRuntimeKeepsSourceStateUnknown() { Assert.Equal(EventReadinessDiagnosticKind.NoEvidence, source.DiagnosticKind); } + [Fact] + public void CollectorHeartbeatPolicyFailsAStaleSource() { + var evidence = CreateCollectorEvidence(); + evidence.CollectorRuntime.Sources = new[] { + new CollectorSubscriptionSourceRuntimeStatus { + Address = "dc01.example.com", + Status = "Active", + LastErrorCode = 0, + LastHeartbeatTime = DateTimeOffset.UtcNow.AddHours(-2) + } + }; + + EventReadinessReport report = EventReadinessEngine.Evaluate( + new EventReadinessRequest { + Types = new[] { EventType.ADUserLogonNTLMv1 }, + Collector = ".", + SubscriptionName = "EventViewerX-AD", + MaximumCollectorHeartbeatAge = TimeSpan.FromMinutes(15), + TargetDiscovery = new EventTargetDiscoveryRequest { + Scope = EventTargetDiscoveryScope.CurrentDomain + } + }, + evidence, + CancellationToken.None); + + EventReadinessCheckResult heartbeat = Assert.Single(report.Checks, static check => + check.Check == "ExpectedSourceHeartbeat"); + Assert.Equal(EventReadinessStatus.Fail, heartbeat.Status); + Assert.Equal(EventReadinessDiagnosticKind.InvalidConfiguration, heartbeat.DiagnosticKind); + } + + [Fact] + public void CollectorHeartbeatPolicyKeepsSmallClockSkewCurrent() { + var evidence = CreateCollectorEvidence(); + evidence.CollectorRuntime.Sources = new[] { + new CollectorSubscriptionSourceRuntimeStatus { + Address = "dc01.example.com", + Status = "Active", + LastErrorCode = 0, + LastHeartbeatTime = DateTimeOffset.UtcNow.AddMinutes(1) + } + }; + + EventReadinessReport report = EvaluateCollectorWithHeartbeatPolicy(evidence); + + EventReadinessCheckResult heartbeat = Assert.Single(report.Checks, static check => + check.Check == "ExpectedSourceHeartbeat"); + Assert.Equal(EventReadinessStatus.Pass, heartbeat.Status); + Assert.Equal(EventReadinessDiagnosticKind.None, heartbeat.DiagnosticKind); + } + + [Fact] + public void CollectorHeartbeatPolicyRejectsImplausibleFutureTimestamp() { + var evidence = CreateCollectorEvidence(); + evidence.CollectorRuntime.Sources = new[] { + new CollectorSubscriptionSourceRuntimeStatus { + Address = "dc01.example.com", + Status = "Active", + LastErrorCode = 0, + LastHeartbeatTime = DateTimeOffset.UtcNow.AddMinutes(10) + } + }; + + EventReadinessReport report = EvaluateCollectorWithHeartbeatPolicy(evidence); + + EventReadinessCheckResult heartbeat = Assert.Single(report.Checks, static check => + check.Check == "ExpectedSourceHeartbeat"); + Assert.Equal(EventReadinessStatus.Unknown, heartbeat.Status); + Assert.Equal(EventReadinessDiagnosticKind.InvalidConfiguration, heartbeat.DiagnosticKind); + Assert.Contains("future", heartbeat.Evidence, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CollectorHeartbeatPolicyKeepsMissingTimestampUnknown() { + var evidence = CreateCollectorEvidence(); + + EventReadinessReport report = EventReadinessEngine.Evaluate( + new EventReadinessRequest { + Types = new[] { EventType.ADUserLogonNTLMv1 }, + Collector = ".", + SubscriptionName = "EventViewerX-AD", + MaximumCollectorHeartbeatAge = TimeSpan.FromMinutes(15), + TargetDiscovery = new EventTargetDiscoveryRequest { + Scope = EventTargetDiscoveryScope.CurrentDomain + } + }, + evidence, + CancellationToken.None); + + EventReadinessCheckResult heartbeat = Assert.Single(report.Checks, static check => + check.Check == "ExpectedSourceHeartbeat"); + Assert.Equal(EventReadinessStatus.Unknown, heartbeat.Status); + Assert.Equal(EventReadinessDiagnosticKind.NoEvidence, heartbeat.DiagnosticKind); + } + + [Fact] + public void CollectorHeartbeatPolicyKeepsRemoteRuntimeEvidenceUnknown() { + var evidence = CreateCollectorEvidence(); + + EventReadinessReport report = EventReadinessEngine.Evaluate( + new EventReadinessRequest { + Types = new[] { EventType.ADUserLogonNTLMv1 }, + Collector = "wec01.example.com", + SubscriptionName = "EventViewerX-AD", + ExpectedSources = new[] { "dc01.example.com" }, + MaximumCollectorHeartbeatAge = TimeSpan.FromMinutes(15) + }, + evidence, + CancellationToken.None); + + EventReadinessCheckResult heartbeat = Assert.Single(report.Checks, static check => + check.Check == "ExpectedSourceHeartbeat"); + Assert.Equal("dc01.example.com", heartbeat.Target); + Assert.Equal(EventReadinessStatus.Unknown, heartbeat.Status); + Assert.Equal(EventReadinessDiagnosticKind.NoEvidence, heartbeat.DiagnosticKind); + } + + [Fact] + public void CollectorHeartbeatPolicyKeepsMissingExpectedSourceSetUnknown() { + var evidence = CreateCollectorEvidence(); + + EventReadinessReport report = EventReadinessEngine.Evaluate( + new EventReadinessRequest { + Types = new[] { EventType.ADUserLogonNTLMv1 }, + Collector = ".", + SubscriptionName = "EventViewerX-AD", + MaximumCollectorHeartbeatAge = TimeSpan.FromMinutes(15) + }, + evidence, + CancellationToken.None); + + EventReadinessCheckResult heartbeat = Assert.Single(report.Checks, static check => + check.Check == "ExpectedSourceHeartbeat"); + Assert.Equal(EventReadinessStatus.Unknown, heartbeat.Status); + Assert.Equal(EventReadinessDiagnosticKind.NoEvidence, heartbeat.DiagnosticKind); + Assert.Contains("no expected source set", heartbeat.Evidence, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CollectorHeartbeatPolicyKeepsMissingSubscriptionUnknown() { + var evidence = CreateCollectorEvidence(); + evidence.Subscription = null; + + EventReadinessReport report = EventReadinessEngine.Evaluate( + new EventReadinessRequest { + Types = new[] { EventType.ADUserLogonNTLMv1 }, + Collector = ".", + SubscriptionName = "EventViewerX-AD", + ExpectedSources = new[] { "dc01.example.com" }, + MaximumCollectorHeartbeatAge = TimeSpan.FromMinutes(15) + }, + evidence, + CancellationToken.None); + + EventReadinessCheckResult heartbeat = Assert.Single(report.Checks, static check => + check.Check == "ExpectedSourceHeartbeat"); + Assert.Equal("dc01.example.com", heartbeat.Target); + Assert.Equal(EventReadinessStatus.Unknown, heartbeat.Status); + Assert.Contains("subscription was not found", heartbeat.Evidence, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, evidence.RuntimeReadCount); + } + + [Fact] + public void CollectorHeartbeatPolicyKeepsMissingRuntimeSourceUnknown() { + var evidence = CreateCollectorEvidence(); + evidence.CollectorRuntime.Sources = Array.Empty(); + + EventReadinessReport report = EventReadinessEngine.Evaluate( + new EventReadinessRequest { + Types = new[] { EventType.ADUserLogonNTLMv1 }, + Collector = ".", + SubscriptionName = "EventViewerX-AD", + ExpectedSources = new[] { "dc01.example.com" }, + MaximumCollectorHeartbeatAge = TimeSpan.FromMinutes(15) + }, + evidence, + CancellationToken.None); + + EventReadinessCheckResult heartbeat = Assert.Single(report.Checks, static check => + check.Check == "ExpectedSourceHeartbeat"); + Assert.Equal("dc01.example.com", heartbeat.Target); + Assert.Equal(EventReadinessStatus.Unknown, heartbeat.Status); + Assert.Contains("absent from subscription runtime", heartbeat.Evidence, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CollectorHeartbeatPolicyKeepsRuntimeAccessFailureUnknown() { + var evidence = CreateCollectorEvidence(); + evidence.RuntimeException = new UnauthorizedAccessException("access is denied"); + + EventReadinessReport report = EventReadinessEngine.Evaluate( + new EventReadinessRequest { + Types = new[] { EventType.ADUserLogonNTLMv1 }, + Collector = ".", + SubscriptionName = "EventViewerX-AD", + ExpectedSources = new[] { "dc01.example.com" }, + MaximumCollectorHeartbeatAge = TimeSpan.FromMinutes(15) + }, + evidence, + CancellationToken.None); + + EventReadinessCheckResult heartbeat = Assert.Single(report.Checks, static check => + check.Check == "ExpectedSourceHeartbeat"); + Assert.Equal(EventReadinessStatus.Unknown, heartbeat.Status); + Assert.Contains("access was denied", heartbeat.Evidence, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CollectorHeartbeatPolicyKeepsRuntimeInspectionFailureUnknown() { + var evidence = CreateCollectorEvidence(); + evidence.RuntimeException = new InvalidOperationException("runtime failed"); + + EventReadinessReport report = EventReadinessEngine.Evaluate( + new EventReadinessRequest { + Types = new[] { EventType.ADUserLogonNTLMv1 }, + Collector = ".", + SubscriptionName = "EventViewerX-AD", + ExpectedSources = new[] { "dc01.example.com" }, + MaximumCollectorHeartbeatAge = TimeSpan.FromMinutes(15) + }, + evidence, + CancellationToken.None); + + EventReadinessCheckResult heartbeat = Assert.Single(report.Checks, static check => + check.Check == "ExpectedSourceHeartbeat"); + Assert.Equal(EventReadinessStatus.Unknown, heartbeat.Status); + Assert.Contains("inspection failed", heartbeat.Evidence, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(null)] + [InlineData("collector01.example.com")] + public void CollectorHeartbeatPolicyRequiresASubscription(string? collector) { + var request = new EventReadinessRequest { + Types = new[] { EventType.ADUserLogonNTLMv1 }, + Collector = collector, + MaximumCollectorHeartbeatAge = TimeSpan.FromMinutes(15) + }; + + ArgumentException exception = Assert.Throws(() => + EventReadinessEngine.Evaluate( + request, + CreateCollectorEvidence(), + CancellationToken.None)); + + Assert.Equal("MaximumCollectorHeartbeatAge", exception.ParamName); + } + [Fact] public void ActiveSubscriptionWithNoRuntimeSourcesFailsExpectedSourceEnrollment() { var evidence = CreateCollectorEvidence(); @@ -1647,6 +1895,21 @@ private static EventReadinessReport EvaluateCollector(FakeEvidenceProvider evide evidence, CancellationToken.None); + private static EventReadinessReport EvaluateCollectorWithHeartbeatPolicy( + FakeEvidenceProvider evidence) => + EventReadinessEngine.Evaluate( + new EventReadinessRequest { + Types = new[] { EventType.ADUserLogonNTLMv1 }, + Collector = ".", + SubscriptionName = "EventViewerX-AD", + MaximumCollectorHeartbeatAge = TimeSpan.FromMinutes(15), + TargetDiscovery = new EventTargetDiscoveryRequest { + Scope = EventTargetDiscoveryScope.CurrentDomain + } + }, + evidence, + CancellationToken.None); + private static EventTargetDiscoveryResult DomainControllerTargetResult() => new( EventTargetDiscoveryScope.CurrentDomain, null, @@ -1701,6 +1964,7 @@ private sealed class FakeEvidenceProvider : IEventReadinessEvidenceProvider { internal Action? ChannelPolicyReadAction { get; set; } internal int ChannelPolicyReadCount { get; private set; } internal Exception? SubscriptionException { get; set; } + internal Exception? RuntimeException { get; set; } internal int RuntimeReadCount { get; private set; } internal List<(string LogName, string XPath, string? MachineName, NetworkCredential? Credential)> ProbeCalls { get; } = new(); @@ -1858,6 +2122,9 @@ public CollectorSubscriptionRuntimeStatus ReadLocalCollectorRuntime( CancellationToken cancellationToken) { RuntimeReadCount++; + if (RuntimeException != null) { + throw RuntimeException; + } return CollectorRuntime; } } diff --git a/Sources/EventViewerX.Tests/TestSigmaTelemetryProfiles.cs b/Sources/EventViewerX.Tests/TestSigmaTelemetryProfiles.cs new file mode 100644 index 00000000..99061cbf --- /dev/null +++ b/Sources/EventViewerX.Tests/TestSigmaTelemetryProfiles.cs @@ -0,0 +1,155 @@ +using EventViewerX.Sigma; +using Xunit; + +namespace EventViewerX.Tests; + +public sealed class TestSigmaTelemetryProfiles { + private const string ProcessCreationRule = """ + title: Process creation profile test + id: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa + logsource: + product: windows + category: process_creation + detection: + selection: + Image|endswith: '\\powershell.exe' + condition: selection + """; + + [Fact] + public void CategoryWithoutEventIdRemainsUnsupportedByDefault() { + SigmaCompilationResult result = SigmaRuleCompiler.CompileYaml(ProcessCreationRule); + + Assert.False(result.IsSupported); + SigmaDiagnostic diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("EVXSIGMA023", diagnostic.Code); + Assert.Empty(result.Rules); + } + + [Fact] + public void ExplicitWindowsProfileMapsCategoryToConcreteTelemetry() { + var options = new SigmaCompilationOptions { + LogSourceProfile = SigmaLogSourceProfile.WindowsSysmonAndPowerShell + }; + + SigmaCompilationResult result = SigmaRuleCompiler.CompileYaml(ProcessCreationRule, options); + EventDetectionRuleDefinition definition = Assert.Single(result.Rules).Definition; + + Assert.True(result.IsSupported); + Assert.Empty(result.Diagnostics); + Assert.Equal(new[] { "Microsoft-Windows-Sysmon/Operational" }, definition.Channels); + Assert.Equal(new[] { "Microsoft-Windows-Sysmon" }, definition.Providers); + Assert.Equal(new[] { 1 }, definition.EventIds); + } + + [Fact] + public void ExplicitEventIdRemainsAuthoritativeWhenProfileIsSelected() { + const string yaml = """ + title: Explicit event ID category test + id: bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb + logsource: + product: windows + category: process_creation + detection: + selection: + EventID: 4688 + NewProcessName|endswith: '\\powershell.exe' + condition: selection + """; + var options = new SigmaCompilationOptions { + LogSourceProfile = SigmaLogSourceProfile.WindowsSysmonAndPowerShell + }; + + SigmaCompilationResult result = SigmaRuleCompiler.CompileYaml(yaml, options); + EventDetectionRuleDefinition definition = Assert.Single(result.Rules).Definition; + + Assert.True(result.IsSupported); + Assert.Empty(definition.Channels); + Assert.Empty(definition.Providers); + Assert.Equal(new[] { 4688 }, definition.EventIds); + } + + [Fact] + public void ConflictingServiceAndProfileCategoryAreRejected() { + const string yaml = """ + title: Conflicting telemetry test + id: cccccccc-cccc-4ccc-8ccc-cccccccccccc + logsource: + product: windows + service: security + category: process_creation + detection: + selection: + Image|endswith: '\\powershell.exe' + condition: selection + """; + var options = new SigmaCompilationOptions { + LogSourceProfile = SigmaLogSourceProfile.WindowsSysmonAndPowerShell + }; + + SigmaCompilationResult result = SigmaRuleCompiler.CompileYaml(yaml, options); + + Assert.False(result.IsSupported); + Assert.Contains(result.Diagnostics, static diagnostic => diagnostic.Code == "EVXSIGMA024"); + Assert.Empty(result.Rules); + } + + [Fact] + public void CompatibleServiceNarrowsAMultiChannelProfileMapping() { + const string yaml = """ + title: Explicit service narrowing test + id: dddddddd-dddd-4ddd-8ddd-dddddddddddd + logsource: + product: windows + service: sysmon + category: process_creation + detection: + selection: + Image|endswith: '\\powershell.exe' + condition: selection + """; + var profile = new SigmaLogSourceProfile( + "multi-channel-test", + "1.0.0", + new[] { + new SigmaLogSourceMapping( + "process_creation", + new[] { + "Microsoft-Windows-Sysmon/Operational", + "Security" + }, + new[] { "Microsoft-Windows-Sysmon" }, + new[] { 1 }) + }); + var options = new SigmaCompilationOptions { + LogSourceProfile = profile + }; + + SigmaCompilationResult result = SigmaRuleCompiler.CompileYaml(yaml, options); + EventDetectionRuleDefinition definition = Assert.Single(result.Rules).Definition; + + Assert.True(result.IsSupported); + Assert.Equal( + new[] { "Microsoft-Windows-Sysmon/Operational" }, + definition.Channels); + Assert.Equal(new[] { "Microsoft-Windows-Sysmon" }, definition.Providers); + Assert.Equal(new[] { 1 }, definition.EventIds); + } + + [Fact] + public void BuiltInProfileIsVersionedAndHasUniqueMappings() { + SigmaLogSourceProfile profile = SigmaLogSourceProfile.WindowsSysmonAndPowerShell; + + Assert.Equal("windows-sysmon-powershell", profile.ProfileId); + Assert.Equal("1.0.0", profile.Version); + Assert.Equal( + profile.Mappings.Count, + profile.Mappings.Select(static mapping => mapping.Category) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count()); + Assert.All(profile.Mappings, static mapping => { + Assert.NotEmpty(mapping.Channels); + Assert.NotEmpty(mapping.EventIds); + }); + } +} diff --git a/Sources/EventViewerX.sln b/Sources/EventViewerX.sln index 624da07c..684829e3 100644 --- a/Sources/EventViewerX.sln +++ b/Sources/EventViewerX.sln @@ -33,6 +33,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventViewerX.Evtx", "EventV EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventViewerX.AotSmoke", "EventViewerX.AotSmoke\EventViewerX.AotSmoke.csproj", "{641592F6-A646-4E1D-9538-95907C3393A8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventViewerX.SigmaAudit", "EventViewerX.SigmaAudit\EventViewerX.SigmaAudit.csproj", "{A5575529-A264-40B0-B22F-0DA68CDE868D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -175,6 +177,18 @@ Global {641592F6-A646-4E1D-9538-95907C3393A8}.Release|x64.Build.0 = Release|Any CPU {641592F6-A646-4E1D-9538-95907C3393A8}.Release|x86.ActiveCfg = Release|Any CPU {641592F6-A646-4E1D-9538-95907C3393A8}.Release|x86.Build.0 = Release|Any CPU + {A5575529-A264-40B0-B22F-0DA68CDE868D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5575529-A264-40B0-B22F-0DA68CDE868D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5575529-A264-40B0-B22F-0DA68CDE868D}.Debug|x64.ActiveCfg = Debug|Any CPU + {A5575529-A264-40B0-B22F-0DA68CDE868D}.Debug|x64.Build.0 = Debug|Any CPU + {A5575529-A264-40B0-B22F-0DA68CDE868D}.Debug|x86.ActiveCfg = Debug|Any CPU + {A5575529-A264-40B0-B22F-0DA68CDE868D}.Debug|x86.Build.0 = Debug|Any CPU + {A5575529-A264-40B0-B22F-0DA68CDE868D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5575529-A264-40B0-B22F-0DA68CDE868D}.Release|Any CPU.Build.0 = Release|Any CPU + {A5575529-A264-40B0-B22F-0DA68CDE868D}.Release|x64.ActiveCfg = Release|Any CPU + {A5575529-A264-40B0-B22F-0DA68CDE868D}.Release|x64.Build.0 = Release|Any CPU + {A5575529-A264-40B0-B22F-0DA68CDE868D}.Release|x86.ActiveCfg = Release|Any CPU + {A5575529-A264-40B0-B22F-0DA68CDE868D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Sources/EventViewerX/EventLogBatchEngine.Async.cs b/Sources/EventViewerX/EventLogBatchEngine.Async.cs index e9b1ecdd..271d4eb5 100644 --- a/Sources/EventViewerX/EventLogBatchEngine.Async.cs +++ b/Sources/EventViewerX/EventLogBatchEngine.Async.cs @@ -33,6 +33,7 @@ await PrimeConcurrentlyAsync( plan.Sources[index], plan.ContinueOnError, plan.FailureHandler, + cancellationToken, primingToken)) .ConfigureAwait(false); var cursors = primed @@ -94,16 +95,29 @@ await AwaitMoveNextAsync( EventSourceSnapshot source, bool continueOnError, Action? failureHandler, - CancellationToken cancellationToken) { + CancellationToken requestToken, + CancellationToken primingToken) { return await Task.Run(() => { - EventSourceCursor? cursor = TryOpenCursor( - index, - source, - continueOnError, - failureHandler, - cancellationToken); + CancellationTokenSource sourceLifetime = + CreatePrimedSourceLifetime( + requestToken, + primingToken); + EventSourceCursor? cursor; + try { + cursor = TryOpenCursor( + index, + source, + continueOnError, + failureHandler, + sourceLifetime.Token, + sourceLifetime); + } catch { + sourceLifetime.Dispose(); + throw; + } if (cursor == null) { + sourceLifetime.Dispose(); return null; } try { @@ -111,7 +125,7 @@ await AwaitMoveNextAsync( cursor, continueOnError, failureHandler, - cancellationToken)) { + primingToken)) { EventSourceCursor result = cursor; cursor = null; return result; diff --git a/Sources/EventViewerX/EventLogBatchEngine.cs b/Sources/EventViewerX/EventLogBatchEngine.cs index 779c159c..ce46e1aa 100644 --- a/Sources/EventViewerX/EventLogBatchEngine.cs +++ b/Sources/EventViewerX/EventLogBatchEngine.cs @@ -229,13 +229,25 @@ private static IEnumerable ReadSynchronously( maxConcurrency, cancellationToken, (index, primingToken) => { - EventSourceCursor? cursor = TryOpenCursor( - index, - sources[index], - continueOnError, - failureHandler, - primingToken); + CancellationTokenSource sourceLifetime = + CreatePrimedSourceLifetime( + cancellationToken, + primingToken); + EventSourceCursor? cursor; + try { + cursor = TryOpenCursor( + index, + sources[index], + continueOnError, + failureHandler, + sourceLifetime.Token, + sourceLifetime); + } catch { + sourceLifetime.Dispose(); + throw; + } if (cursor == null) { + sourceLifetime.Dispose(); return null; } try { @@ -256,19 +268,37 @@ private static IEnumerable ReadSynchronously( }); } + internal static CancellationTokenSource CreatePrimedSourceLifetime( + CancellationToken requestToken, + CancellationToken primingToken) { + + // The priming token belongs to the bounded startup phase and is + // disposed as soon as all source heads are available. A source may + // continue enumerating long after that point, so retain an owned + // linked source whose token remains registrable for the cursor's + // complete lifetime. Fatal priming cancellation still reaches every + // active source, while request cancellation remains effective after + // priming has completed. + return CancellationTokenSource.CreateLinkedTokenSource( + requestToken, + primingToken); + } + private static EventSourceCursor? TryOpenCursor( int index, EventSourceSnapshot source, bool continueOnError, Action? failureHandler, - CancellationToken cancellationToken) { + CancellationToken cancellationToken, + CancellationTokenSource? sourceLifetime = null) { try { return new EventSourceCursor( index, source, source.Open(cancellationToken) - .GetEnumerator()); + .GetEnumerator(), + sourceLifetime); } catch (Exception exception) { if (!continueOnError) { throw; @@ -407,16 +437,19 @@ internal EventLogBatchExecutionPlan( private sealed class EventSourceCursor : IDisposable { private readonly IEnumerator _enumerator; + private readonly CancellationTokenSource? _sourceLifetime; private bool _disposed; internal EventSourceCursor( int index, EventSourceSnapshot source, - IEnumerator enumerator) { + IEnumerator enumerator, + CancellationTokenSource? sourceLifetime = null) { Index = index; Source = source; _enumerator = enumerator; + _sourceLifetime = sourceLifetime; } internal int Index { get; } @@ -436,7 +469,11 @@ public void Dispose() { return; } _disposed = true; - _enumerator.Dispose(); + try { + _enumerator.Dispose(); + } finally { + _sourceLifetime?.Dispose(); + } } } diff --git a/Sources/EventViewerX/EventReadinessEngine.Collector.cs b/Sources/EventViewerX/EventReadinessEngine.Collector.cs index e94bb0d4..5cf3b501 100644 --- a/Sources/EventViewerX/EventReadinessEngine.Collector.cs +++ b/Sources/EventViewerX/EventReadinessEngine.Collector.cs @@ -2,6 +2,9 @@ namespace EventViewerX; /// Windows Event Collector readiness composition. public static partial class EventReadinessEngine { + private static readonly TimeSpan MaximumFutureCollectorHeartbeatSkew = + TimeSpan.FromMinutes(5); + private static void AddCollectorChecks( EventReadinessRequest request, EventTargetDiscoveryResult? discovery, @@ -120,6 +123,10 @@ private static void AddCollectorChecks( return; } + string[] expectedSources = BuildExpectedSourceSet( + request.ExpectedSources.Concat( + discovery?.Targets.Select(static target => target.ComputerName) ?? Array.Empty())); + CollectorSubscriptionSnapshot? subscription = null; bool subscriptionInspected = false; try { @@ -166,6 +173,13 @@ private static void AddCollectorChecks( "Create the subscription or correct the supplied subscription name.", required: true, diagnosticKind: EventReadinessDiagnosticKind.Missing)); + AddCollectorHeartbeatUnknownChecks( + checks, + collector + "/" + request.SubscriptionName, + expectedSources, + request.MaximumCollectorHeartbeatAge, + "Heartbeat age cannot be evaluated because the named subscription was not found.", + "Create or select the subscription, then rerun readiness after Windows reports source runtime heartbeat evidence."); return; } else if (subscription != null) { AddCollectorBooleanCheck( @@ -214,9 +228,6 @@ private static void AddCollectorChecks( diagnosticKind: coverage.DiagnosticKind)); } - string[] expectedSources = BuildExpectedSourceSet( - request.ExpectedSources.Concat( - discovery?.Targets.Select(static target => target.ComputerName) ?? Array.Empty())); if (expectedSources.Length == 0) { checks.Add(new EventReadinessCheckResult( EventReadinessLayer.WindowsEventCollector, @@ -240,6 +251,13 @@ private static void AddCollectorChecks( "Run the same readiness command locally on the collector to compare expected sources with runtime enrollment.", required: true, diagnosticKind: EventReadinessDiagnosticKind.NoEvidence)); + AddCollectorHeartbeatUnknownChecks( + checks, + collector + "/" + request.SubscriptionName, + expectedSources, + request.MaximumCollectorHeartbeatAge, + "Heartbeat age cannot be inspected remotely because wecutil runtime status is local-only.", + "Run the same readiness command locally on the collector and inspect each expected source heartbeat."); return; } @@ -259,6 +277,13 @@ private static void AddCollectorChecks( "Run the assessment with an identity permitted to read local WEC runtime status.", required: true, diagnosticKind: EventReadinessDiagnosticKind.AccessDenied)); + AddCollectorHeartbeatUnknownChecks( + checks, + collector + "/" + request.SubscriptionName, + expectedSources, + request.MaximumCollectorHeartbeatAge, + "Heartbeat age is unavailable because local subscription runtime access was denied.", + "Run the assessment with an identity permitted to read local WEC runtime status."); return; } catch (Exception exception) { checks.Add(new EventReadinessCheckResult( @@ -271,6 +296,13 @@ private static void AddCollectorChecks( "Run 'wecutil gr' or Get-EVXCollectorSubscription -IncludeRuntimeStatus locally and inspect the Windows error.", required: true, diagnosticKind: EventReadinessDiagnosticKind.Error)); + AddCollectorHeartbeatUnknownChecks( + checks, + collector + "/" + request.SubscriptionName, + expectedSources, + request.MaximumCollectorHeartbeatAge, + "Heartbeat age is unavailable because local subscription runtime inspection failed.", + "Run 'wecutil gr' locally and inspect the Windows error before applying the heartbeat policy."); return; } bool runtimeHasDefinitiveError = @@ -291,6 +323,17 @@ private static void AddCollectorChecks( : "Run 'wecutil gr' locally and confirm that Windows returned runtime evidence for the subscription.", EventReadinessDiagnosticKind.InvalidConfiguration); if ((!runtimeStateConclusive && runtime.Sources.Count == 0) || expectedSources.Length == 0) { + AddCollectorHeartbeatUnknownChecks( + checks, + collector + "/" + request.SubscriptionName, + expectedSources, + request.MaximumCollectorHeartbeatAge, + expectedSources.Length == 0 + ? "Heartbeat age cannot be evaluated because no expected source set was supplied or discovered." + : "Heartbeat age cannot be evaluated because Windows returned no subscription runtime source evidence.", + expectedSources.Length == 0 + ? "Supply -ExpectedSource or enable an explicit directory discovery scope, then rerun readiness locally on the collector." + : "Run 'wecutil gr' locally and confirm that Windows returns source runtime and heartbeat evidence."); return; } CollectorSubscriptionSourceRuntimeStatus[] runtimeSources = runtime.Sources @@ -316,6 +359,13 @@ private static void AddCollectorChecks( "Verify source policy, subscription ACL, WinRM reachability, and forwarding-client operational logs.", required: true, diagnosticKind: EventReadinessDiagnosticKind.Missing)); + AddCollectorHeartbeatUnknownChecks( + checks, + collector + "/" + request.SubscriptionName, + new[] { expectedSource }, + request.MaximumCollectorHeartbeatAge, + "Heartbeat age cannot be evaluated because the expected source is absent from subscription runtime.", + "Verify source enrollment and rerun readiness after Windows reports runtime heartbeat evidence."); continue; } bool sourceHasDefinitiveError = @@ -347,9 +397,104 @@ private static void AddCollectorChecks( : sourceIsHealthy ? EventReadinessDiagnosticKind.None : EventReadinessDiagnosticKind.InvalidConfiguration)); + AddCollectorHeartbeatCheck( + checks, + expectedSource, + source, + request.MaximumCollectorHeartbeatAge); } } + private static void AddCollectorHeartbeatUnknownChecks( + ICollection checks, + string policyTarget, + IReadOnlyList expectedSources, + TimeSpan? maximumHeartbeatAge, + string evidence, + string remediation) { + + if (!maximumHeartbeatAge.HasValue) { + return; + } + IEnumerable targets = expectedSources.Count == 0 + ? new[] { policyTarget } + : expectedSources; + foreach (string target in targets) { + checks.Add(new EventReadinessCheckResult( + EventReadinessLayer.WindowsEventCollector, + "ExpectedSourceHeartbeat", + target, + EventReadinessStatus.Unknown, + EventReadinessEvidenceLevel.Unknown, + $"{evidence} Required maximum age={maximumHeartbeatAge.Value}.", + remediation, + required: true, + diagnosticKind: EventReadinessDiagnosticKind.NoEvidence)); + } + } + + private static void AddCollectorHeartbeatCheck( + ICollection checks, + string expectedSource, + CollectorSubscriptionSourceRuntimeStatus source, + TimeSpan? maximumHeartbeatAge) { + + if (!maximumHeartbeatAge.HasValue) { + return; + } + if (!source.LastHeartbeatTime.HasValue) { + checks.Add(new EventReadinessCheckResult( + EventReadinessLayer.WindowsEventCollector, + "ExpectedSourceHeartbeat", + expectedSource, + EventReadinessStatus.Unknown, + EventReadinessEvidenceLevel.Unknown, + $"Windows did not report a heartbeat timestamp; required maximum age={maximumHeartbeatAge.Value}.", + "Inspect the source forwarding-client operational log and confirm that the subscription heartbeat interval is configured and observed.", + required: true, + diagnosticKind: EventReadinessDiagnosticKind.NoEvidence)); + return; + } + + DateTimeOffset observedUtc = DateTimeOffset.UtcNow; + DateTimeOffset heartbeatUtc = source.LastHeartbeatTime.Value.ToUniversalTime(); + TimeSpan age = observedUtc - heartbeatUtc; + if (age < -MaximumFutureCollectorHeartbeatSkew) { + checks.Add(new EventReadinessCheckResult( + EventReadinessLayer.WindowsEventCollector, + "ExpectedSourceHeartbeat", + expectedSource, + EventReadinessStatus.Unknown, + EventReadinessEvidenceLevel.Inspected, + $"Last heartbeat={source.LastHeartbeatTime.Value:O}; observed UTC={observedUtc:O}; " + + $"timestamp is more than {MaximumFutureCollectorHeartbeatSkew} in the future.", + "Correct the collector/source clocks or timestamp parsing before treating heartbeat freshness as evidence.", + required: true, + diagnosticKind: EventReadinessDiagnosticKind.InvalidConfiguration)); + return; + } + if (age < TimeSpan.Zero) { + age = TimeSpan.Zero; + } + bool current = age <= maximumHeartbeatAge.Value; + checks.Add(new EventReadinessCheckResult( + EventReadinessLayer.WindowsEventCollector, + "ExpectedSourceHeartbeat", + expectedSource, + current + ? EventReadinessStatus.Pass + : EventReadinessStatus.Fail, + EventReadinessEvidenceLevel.Inspected, + $"Last heartbeat={source.LastHeartbeatTime.Value:O}; age={age}; required maximum age={maximumHeartbeatAge.Value}.", + current + ? string.Empty + : "Inspect source policy, WinRM reachability, subscription authorization, and the forwarding-client operational log before treating the source as complete.", + required: true, + diagnosticKind: current + ? EventReadinessDiagnosticKind.None + : EventReadinessDiagnosticKind.InvalidConfiguration)); + } + private static string[] BuildExpectedSourceSet(IEnumerable sources) => sources .Where(static source => !string.IsNullOrWhiteSpace(source)) .Select(NormalizeSourceAddress) diff --git a/Sources/EventViewerX/EventReadinessRequest.cs b/Sources/EventViewerX/EventReadinessRequest.cs index 94823b40..62a90bfd 100644 --- a/Sources/EventViewerX/EventReadinessRequest.cs +++ b/Sources/EventViewerX/EventReadinessRequest.cs @@ -25,6 +25,12 @@ public sealed class EventReadinessRequest { public TimeSpan ProbeTimeout { get; set; } = TimeSpan.FromSeconds(15); /// Maximum records inspected by each probe. public int MaxEventsToScan { get; set; } = 4096; + /// + /// Optional maximum accepted age for a WEC source heartbeat. When omitted, heartbeat timestamps + /// remain diagnostic evidence and no product-owned staleness policy is invented. Requires a + /// collector subscription when set. + /// + public TimeSpan? MaximumCollectorHeartbeatAge { get; set; } internal EventReadinessRequest Snapshot() { EventType[] selected = Scenario == EventReadinessScenario.None @@ -59,6 +65,18 @@ internal EventReadinessRequest Snapshot() { if (MaxEventsToScan <= 0) { throw new ArgumentOutOfRangeException(nameof(MaxEventsToScan)); } + if (MaximumCollectorHeartbeatAge.HasValue && + (MaximumCollectorHeartbeatAge.Value <= TimeSpan.Zero || + MaximumCollectorHeartbeatAge.Value > TimeSpan.FromDays(365))) { + throw new ArgumentOutOfRangeException( + nameof(MaximumCollectorHeartbeatAge), + "Maximum collector heartbeat age must be greater than zero and no more than 365 days."); + } + if (MaximumCollectorHeartbeatAge.HasValue && subscriptionName == null) { + throw new ArgumentException( + "MaximumCollectorHeartbeatAge requires a collector subscription so heartbeat evidence cannot be silently ignored.", + nameof(MaximumCollectorHeartbeatAge)); + } return new EventReadinessRequest { Types = selected, Scenario = Scenario, @@ -71,7 +89,8 @@ internal EventReadinessRequest Snapshot() { : new NetworkCredential(EventLogCredential.UserName, EventLogCredential.Password, EventLogCredential.Domain), Authentication = Authentication, ProbeTimeout = ProbeTimeout, - MaxEventsToScan = MaxEventsToScan + MaxEventsToScan = MaxEventsToScan, + MaximumCollectorHeartbeatAge = MaximumCollectorHeartbeatAge }; } diff --git a/Sources/EventViewerX/Native/WindowsEventRemoteReader.cs b/Sources/EventViewerX/Native/WindowsEventRemoteReader.cs index 152368c4..3a384922 100644 --- a/Sources/EventViewerX/Native/WindowsEventRemoteReader.cs +++ b/Sources/EventViewerX/Native/WindowsEventRemoteReader.cs @@ -132,7 +132,7 @@ private static IEnumerable ReadIterator( var queryFailures = new ConcurrentQueue(); var sessionOpened = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); - using var workerCancellation = + var workerCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); Task producer; try { @@ -180,6 +180,7 @@ private static IEnumerable ReadIterator( } catch { operationSlot.Dispose(); results.Dispose(); + workerCancellation.Dispose(); throw; } @@ -232,11 +233,13 @@ private static IEnumerable ReadIterator( workerCancellation.Cancel(); if (producer.IsCompleted) { results.Dispose(); + workerCancellation.Dispose(); } else { _ = producer.ContinueWith( completed => { _ = completed.Exception; results.Dispose(); + workerCancellation.Dispose(); }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, diff --git a/Sources/PSEventViewer/CmdletImportEVXSigmaRule.cs b/Sources/PSEventViewer/CmdletImportEVXSigmaRule.cs index 6526d147..3391c75d 100644 --- a/Sources/PSEventViewer/CmdletImportEVXSigmaRule.cs +++ b/Sources/PSEventViewer/CmdletImportEVXSigmaRule.cs @@ -27,6 +27,14 @@ public sealed class CmdletImportEVXSigmaRule : PSCmdlet { [SupportsWildcards] public string[] Path { get; set; } = Array.Empty(); + /// + /// Explicit telemetry assumptions used for category-only Sigma log sources. + /// Strict is lossless and rejects categories without exact native selectors. + /// + [Parameter] + [ValidateSet("Strict", "WindowsSysmonAndPowerShell")] + public string TelemetryProfile { get; set; } = "Strict"; + /// Returns one versioned EventViewerX pack instead of individual rules. [Parameter(Mandatory = true, ParameterSetName = "Pack")] public SwitchParameter AsPack { get; set; } @@ -50,7 +58,9 @@ protected override void ProcessRecord() { /// protected override void EndProcessing() { - SigmaCompilationResult result = SigmaRuleCompiler.Load(_resolvedPaths); + SigmaCompilationResult result = SigmaRuleCompiler.Load( + _resolvedPaths, + ResolveCompilationOptions()); foreach (SigmaDiagnostic diagnostic in result.Diagnostics) { if (diagnostic.Severity == SigmaDiagnosticSeverity.Warning) { WriteWarning($"{diagnostic.Code}: {diagnostic.Message}"); @@ -76,4 +86,14 @@ protected override void EndProcessing() { result.Rules.Select(static rule => rule.Definition)); WriteObject(pack, enumerateCollection: false); } + + private SigmaCompilationOptions? ResolveCompilationOptions() => + string.Equals( + TelemetryProfile, + "WindowsSysmonAndPowerShell", + StringComparison.OrdinalIgnoreCase) + ? new SigmaCompilationOptions { + LogSourceProfile = SigmaLogSourceProfile.WindowsSysmonAndPowerShell + } + : null; } diff --git a/Sources/PSEventViewer/CmdletTestEVXReadiness.cs b/Sources/PSEventViewer/CmdletTestEVXReadiness.cs index c1f424c7..44cb7495 100644 --- a/Sources/PSEventViewer/CmdletTestEVXReadiness.cs +++ b/Sources/PSEventViewer/CmdletTestEVXReadiness.cs @@ -94,6 +94,15 @@ public sealed class CmdletTestEVXReadiness : AsyncPSCmdlet { [ValidateRange(1, int.MaxValue)] public int MaxEventsToScan { get; set; } = 4096; + /// + /// Optional maximum accepted age, in minutes, for each WEC source heartbeat. + /// Requires Collector and SubscriptionName. Omit this parameter when the organization + /// has not selected a heartbeat-lag policy. + /// + [Parameter] + [ValidateRange(1, 525600)] + public int MaximumHeartbeatAgeMinutes { get; set; } + /// protected override Task ProcessRecordAsync() { var request = new EventReadinessRequest { @@ -114,7 +123,10 @@ protected override Task ProcessRecordAsync() { EventLogCredential = EventLogCredential?.GetNetworkCredential(), Authentication = Authentication, ProbeTimeout = TimeSpan.FromMilliseconds(ProbeTimeoutMs), - MaxEventsToScan = MaxEventsToScan + MaxEventsToScan = MaxEventsToScan, + MaximumCollectorHeartbeatAge = MaximumHeartbeatAgeMinutes > 0 + ? TimeSpan.FromMinutes(MaximumHeartbeatAgeMinutes) + : null }; WriteObject(EventReadinessEngine.Evaluate(request, CancelToken)); return Task.CompletedTask; diff --git a/Sources/PSEventViewer/CmdletTestEVXSigmaRule.cs b/Sources/PSEventViewer/CmdletTestEVXSigmaRule.cs index 494aad70..8db8df43 100644 --- a/Sources/PSEventViewer/CmdletTestEVXSigmaRule.cs +++ b/Sources/PSEventViewer/CmdletTestEVXSigmaRule.cs @@ -21,6 +21,14 @@ public sealed class CmdletTestEVXSigmaRule : PSCmdlet { [SupportsWildcards] public string[] Path { get; set; } = Array.Empty(); + /// + /// Explicit telemetry assumptions used for category-only Sigma log sources. + /// Strict is lossless and rejects categories without exact native selectors. + /// + [Parameter] + [ValidateSet("Strict", "WindowsSysmonAndPowerShell")] + public string TelemetryProfile { get; set; } = "Strict"; + /// protected override void ProcessRecord() { foreach (string path in SigmaPathResolver.Resolve(SessionState, Path, nameof(Path))) { @@ -32,7 +40,19 @@ protected override void ProcessRecord() { /// protected override void EndProcessing() { - SigmaCompilationResult result = SigmaRuleCompiler.Load(_resolvedPaths); + SigmaCompilationResult result = SigmaRuleCompiler.Load( + _resolvedPaths, + ResolveCompilationOptions()); WriteObject(result, enumerateCollection: false); } + + private SigmaCompilationOptions? ResolveCompilationOptions() => + string.Equals( + TelemetryProfile, + "WindowsSysmonAndPowerShell", + StringComparison.OrdinalIgnoreCase) + ? new SigmaCompilationOptions { + LogSourceProfile = SigmaLogSourceProfile.WindowsSysmonAndPowerShell + } + : null; } diff --git a/en-US/PSEventViewer-help.xml b/en-US/PSEventViewer-help.xml index 655dc2dd..e2fe59c7 100644 --- a/en-US/PSEventViewer-help.xml +++ b/en-US/PSEventViewer-help.xml @@ -2144,6 +2144,18 @@ already provides integrity validation. None + + IncludeSourceAuthorization + + Reads domain-computer and non-domain certificate source authorization from the local collector's authoritative subscription configuration. + + SwitchParameter + + SwitchParameter + + + None + MachineName @@ -2210,6 +2222,18 @@ already provides integrity validation. None + + IncludeSourceAuthorization + + Reads domain-computer and non-domain certificate source authorization from the local collector's authoritative subscription configuration. + + SwitchParameter + + SwitchParameter + + + None + MachineName @@ -2300,6 +2324,13 @@ already provides integrity validation. Adds processed-event counters, source heartbeat timestamps, and native Windows errors to the local snapshot. + + Inspect who may forward to a source-initiated subscription + Get-EVXCollectorSubscription -Name 'Domain controller authentication' -IncludeSourceAuthorization + + Reads the local collector's authoritative subscription XML and adds the domain-computer DACL and raw certificate subject policy. This does not calculate effective authorization. + + @@ -4399,7 +4430,6 @@ Settings.QuerySessionTimeoutMs for reading. KerberosServiceTicket KerberosTicketFailure KerberosPolicyChange - KerberosKdcRc4Audit ADOrganizationalUnitChangeDetailed ADOtherChangeDetailed ADSMBServerAuditV1 @@ -4472,6 +4502,7 @@ Settings.QuerySessionTimeoutMs for reading. DefenderSecurity AuthenticationHealth GroupPolicyDirectoryAudit + KerberosKdcRc4Audit EventType[] @@ -7319,7 +7350,6 @@ Settings.QuerySessionTimeoutMs for reading. KerberosServiceTicket KerberosTicketFailure KerberosPolicyChange - KerberosKdcRc4Audit ADOrganizationalUnitChangeDetailed ADOtherChangeDetailed ADSMBServerAuditV1 @@ -7392,6 +7422,7 @@ Settings.QuerySessionTimeoutMs for reading. DefenderSecurity AuthenticationHealth GroupPolicyDirectoryAudit + KerberosKdcRc4Audit EventType[] @@ -8938,7 +8969,6 @@ This cannot be combined with EventLogPath. KerberosServiceTicket KerberosTicketFailure KerberosPolicyChange - KerberosKdcRc4Audit ADOrganizationalUnitChangeDetailed ADOtherChangeDetailed ADSMBServerAuditV1 @@ -9011,6 +9041,7 @@ This cannot be combined with EventLogPath. DefenderSecurity AuthenticationHealth GroupPolicyDirectoryAudit + KerberosKdcRc4Audit EventType[] @@ -9059,7 +9090,6 @@ This cannot be combined with EventLogPath. KerberosServiceTicket KerberosTicketFailure KerberosPolicyChange - KerberosKdcRc4Audit ADOrganizationalUnitChangeDetailed ADOtherChangeDetailed ADSMBServerAuditV1 @@ -9132,6 +9162,7 @@ This cannot be combined with EventLogPath. DefenderSecurity AuthenticationHealth GroupPolicyDirectoryAudit + KerberosKdcRc4Audit EventType[] @@ -9566,6 +9597,23 @@ This cannot be combined with EventLogPath. None + + TelemetryProfile + + Explicit telemetry assumptions used for category-only Sigma log sources. +Strict is lossless and rejects categories without exact native selectors. + + String + + Strict + WindowsSysmonAndPowerShell + + + String + + + None + Import-EVXSigmaRule @@ -9605,6 +9653,23 @@ This cannot be combined with EventLogPath. None + + TelemetryProfile + + Explicit telemetry assumptions used for category-only Sigma log sources. +Strict is lossless and rejects categories without exact native selectors. + + String + + Strict + WindowsSysmonAndPowerShell + + + String + + + None + Version @@ -9656,6 +9721,23 @@ This cannot be combined with EventLogPath. None + + TelemetryProfile + + Explicit telemetry assumptions used for category-only Sigma log sources. +Strict is lossless and rejects categories without exact native selectors. + + String + + Strict + WindowsSysmonAndPowerShell + + + String + + + None + Version @@ -11012,7 +11094,6 @@ This cannot be combined with EventLogPath. KerberosServiceTicket KerberosTicketFailure KerberosPolicyChange - KerberosKdcRc4Audit ADOrganizationalUnitChangeDetailed ADOtherChangeDetailed ADSMBServerAuditV1 @@ -11085,6 +11166,7 @@ This cannot be combined with EventLogPath. DefenderSecurity AuthenticationHealth GroupPolicyDirectoryAudit + KerberosKdcRc4Audit EventType[] @@ -11390,7 +11472,6 @@ This cannot be combined with EventLogPath. KerberosServiceTicket KerberosTicketFailure KerberosPolicyChange - KerberosKdcRc4Audit ADOrganizationalUnitChangeDetailed ADOtherChangeDetailed ADSMBServerAuditV1 @@ -11463,6 +11544,7 @@ This cannot be combined with EventLogPath. DefenderSecurity AuthenticationHealth GroupPolicyDirectoryAudit + KerberosKdcRc4Audit EventType[] @@ -12629,7 +12711,6 @@ This cannot be combined with EventLogPath. KerberosServiceTicket KerberosTicketFailure KerberosPolicyChange - KerberosKdcRc4Audit ADOrganizationalUnitChangeDetailed ADOtherChangeDetailed ADSMBServerAuditV1 @@ -12702,6 +12783,7 @@ This cannot be combined with EventLogPath. DefenderSecurity AuthenticationHealth GroupPolicyDirectoryAudit + KerberosKdcRc4Audit EventType[] @@ -13816,7 +13898,6 @@ This cannot be combined with EventLogPath. KerberosServiceTicket KerberosTicketFailure KerberosPolicyChange - KerberosKdcRc4Audit ADOrganizationalUnitChangeDetailed ADOtherChangeDetailed ADSMBServerAuditV1 @@ -13889,6 +13970,7 @@ This cannot be combined with EventLogPath. DefenderSecurity AuthenticationHealth GroupPolicyDirectoryAudit + KerberosKdcRc4Audit EventType[] @@ -14212,7 +14294,6 @@ This cannot be combined with EventLogPath. KerberosServiceTicket KerberosTicketFailure KerberosPolicyChange - KerberosKdcRc4Audit ADOrganizationalUnitChangeDetailed ADOtherChangeDetailed ADSMBServerAuditV1 @@ -14285,6 +14366,7 @@ This cannot be combined with EventLogPath. DefenderSecurity AuthenticationHealth GroupPolicyDirectoryAudit + KerberosKdcRc4Audit EventType @@ -15261,7 +15343,6 @@ This cannot be combined with EventLogPath. KerberosServiceTicket KerberosTicketFailure KerberosPolicyChange - KerberosKdcRc4Audit ADOrganizationalUnitChangeDetailed ADOtherChangeDetailed ADSMBServerAuditV1 @@ -15334,6 +15415,7 @@ This cannot be combined with EventLogPath. DefenderSecurity AuthenticationHealth GroupPolicyDirectoryAudit + KerberosKdcRc4Audit EventType @@ -17898,7 +17980,6 @@ This is available only with -Type GroupPolicyDirectoryAudit and never performs d KerberosServiceTicket KerberosTicketFailure KerberosPolicyChange - KerberosKdcRc4Audit ADOrganizationalUnitChangeDetailed ADOtherChangeDetailed ADSMBServerAuditV1 @@ -17971,6 +18052,7 @@ This is available only with -Type GroupPolicyDirectoryAudit and never performs d DefenderSecurity AuthenticationHealth GroupPolicyDirectoryAudit + KerberosKdcRc4Audit EventType[] @@ -18403,7 +18485,6 @@ This is available only with -Type GroupPolicyDirectoryAudit and never performs d KerberosServiceTicket KerberosTicketFailure KerberosPolicyChange - KerberosKdcRc4Audit ADOrganizationalUnitChangeDetailed ADOtherChangeDetailed ADSMBServerAuditV1 @@ -18476,6 +18557,7 @@ This is available only with -Type GroupPolicyDirectoryAudit and never performs d DefenderSecurity AuthenticationHealth GroupPolicyDirectoryAudit + KerberosKdcRc4Audit EventType[] @@ -20082,7 +20164,6 @@ This is available only with -Type GroupPolicyDirectoryAudit and never performs d KerberosServiceTicket KerberosTicketFailure KerberosPolicyChange - KerberosKdcRc4Audit ADOrganizationalUnitChangeDetailed ADOtherChangeDetailed ADSMBServerAuditV1 @@ -20155,6 +20236,7 @@ This is available only with -Type GroupPolicyDirectoryAudit and never performs d DefenderSecurity AuthenticationHealth GroupPolicyDirectoryAudit + KerberosKdcRc4Audit EventType[] @@ -21607,7 +21689,6 @@ Omit this parameter to reject reuse when the action delegate is not the same ins KerberosServiceTicket KerberosTicketFailure KerberosPolicyChange - KerberosKdcRc4Audit ADOrganizationalUnitChangeDetailed ADOtherChangeDetailed ADSMBServerAuditV1 @@ -21680,6 +21761,7 @@ Omit this parameter to reject reuse when the action delegate is not the same ins DefenderSecurity AuthenticationHealth GroupPolicyDirectoryAudit + KerberosKdcRc4Audit EventType[] @@ -22324,7 +22406,6 @@ LogName and Path are not included because this watcher targets one LogName.KerberosServiceTicket KerberosTicketFailure KerberosPolicyChange - KerberosKdcRc4Audit ADOrganizationalUnitChangeDetailed ADOtherChangeDetailed ADSMBServerAuditV1 @@ -22397,6 +22478,7 @@ LogName and Path are not included because this watcher targets one LogName.DefenderSecurity AuthenticationHealth GroupPolicyDirectoryAudit + KerberosKdcRc4Audit EventType[] @@ -23212,6 +23294,20 @@ LogName and Path are not included because this watcher targets one LogName. None + + MaximumHeartbeatAgeMinutes + + Optional maximum accepted age, in minutes, for each WEC source heartbeat. +Requires Collector and SubscriptionName. Omit this parameter when the organization +has not selected a heartbeat-lag policy. + + Int32 + + Int32 + + + None + MaximumTargetCount @@ -23298,7 +23394,6 @@ LogName and Path are not included because this watcher targets one LogName.KerberosServiceTicket KerberosTicketFailure KerberosPolicyChange - KerberosKdcRc4Audit ADOrganizationalUnitChangeDetailed ADOtherChangeDetailed ADSMBServerAuditV1 @@ -23371,6 +23466,7 @@ LogName and Path are not included because this watcher targets one LogName.DefenderSecurity AuthenticationHealth GroupPolicyDirectoryAudit + KerberosKdcRc4Audit EventType[] @@ -23514,6 +23610,20 @@ LogName and Path are not included because this watcher targets one LogName. None + + MaximumHeartbeatAgeMinutes + + Optional maximum accepted age, in minutes, for each WEC source heartbeat. +Requires Collector and SubscriptionName. Omit this parameter when the organization +has not selected a heartbeat-lag policy. + + Int32 + + Int32 + + + None + MaximumTargetCount @@ -23718,6 +23828,20 @@ LogName and Path are not included because this watcher targets one LogName. None + + MaximumHeartbeatAgeMinutes + + Optional maximum accepted age, in minutes, for each WEC source heartbeat. +Requires Collector and SubscriptionName. Omit this parameter when the organization +has not selected a heartbeat-lag policy. + + Int32 + + Int32 + + + None + MaximumTargetCount @@ -23824,7 +23948,6 @@ LogName and Path are not included because this watcher targets one LogName.KerberosServiceTicket KerberosTicketFailure KerberosPolicyChange - KerberosKdcRc4Audit ADOrganizationalUnitChangeDetailed ADOtherChangeDetailed ADSMBServerAuditV1 @@ -23897,6 +24020,7 @@ LogName and Path are not included because this watcher targets one LogName.DefenderSecurity AuthenticationHealth GroupPolicyDirectoryAudit + KerberosKdcRc4Audit EventType[] @@ -23978,6 +24102,23 @@ LogName and Path are not included because this watcher targets one LogName. None + + TelemetryProfile + + Explicit telemetry assumptions used for category-only Sigma log sources. +Strict is lossless and rejects categories without exact native selectors. + + String + + Strict + WindowsSysmonAndPowerShell + + + String + + + None + @@ -23993,6 +24134,23 @@ LogName and Path are not included because this watcher targets one LogName. None + + TelemetryProfile + + Explicit telemetry assumptions used for category-only Sigma log sources. +Strict is lossless and rejects categories without exact native selectors. + + String + + Strict + WindowsSysmonAndPowerShell + + + String + + + None +