diff --git a/Docs/Endpoint-Investigations.md b/Docs/Endpoint-Investigations.md new file mode 100644 index 00000000..0674441c --- /dev/null +++ b/Docs/Endpoint-Investigations.md @@ -0,0 +1,153 @@ +# Captured endpoint investigations + +EventViewerX reads captured CMTrace and plain-text logs, reconstructs attributable +Intune application attempts, and interprets captured `dsregcmd /status` output. +The workflow runs offline. It copies and hashes original files before parsing, +retains diagnostic records and collection context in an investigation session, +and produces findings with evidence coordinates and next checks. + +The endpoint extension uses the existing investigation manifest and integrity +checks. Windows event observations and their detection plan can be supplied to +`EventInvestigationSession.CreateEndpoint` alongside endpoint artifacts. Text +records keep their source format and never acquire synthetic event IDs or UTC +timestamps. + +## Create and replay through the CLI + +```powershell +evx investigation create --directory .\Case-001 --log .\AppWorkload.log,.\AppActionProcessor.log --dsreg .\dsreg-status.txt --device Endpoint-001 --context User --captured-at 2026-10-09T10:00:00Z --expected-join Hybrid --html .\Case-001.html +evx investigation inspect --directory .\Case-001 +evx investigation replay --directory .\Case-001 --html .\Case-001-replay.html +``` + +Use a new session directory and new report files. Presentation exports belong +outside the session. `inspect` returns the retained analysis; `replay` evaluates +the verified canonical diagnostic records using the same collection context. +A different engine build requires `--allow-different-engine` and marks the +comparative result incomplete. Neither command executes a live collection query. + +`--facts` accepts a JSON object. `--registry` retains a registry snapshot without +importing it. `--attachment` retains other original evidence without executing it. +These artifacts are hashed and listed in the manifest; unsupported policy facts +are preserved for inspection rather than used to invent conclusions. + +Declare `--device` only when the inputs belong to that endpoint. Cross-file +application correlation also requires known instants and a specific execution +identity in the records. A source generation containing any unknown instant stays +entirely in byte order. Unknown context and generic `User` context, regardless of +casing, keep attempts source-local. CLI capture options apply to all supplied +files; use the typed API or PowerShell capture descriptors when files have +different contexts, offsets, or capture times. + +## Create and replay through PowerShell + +```powershell +Import-Module PSEventViewer + +$capture = [EventViewerX.EventEndpointCapture]@{ + ExpectedJoin = 'Hybrid' + Inputs = @( + [EventViewerX.EventEndpointInput]@{ + Path = '.\AppWorkload.log' + Kind = 'Log' + Device = 'Endpoint-001' + UtcOffset = [TimeSpan]::FromHours(2) + } + [EventViewerX.EventEndpointInput]@{ + Path = '.\dsreg-status.txt' + Kind = 'DsRegCmd' + ExecutionContext = 'User' + CapturedAt = [DateTimeOffset]::Parse('2026-10-09T10:00:00Z') + } + ) +} + +$session = Export-EVXInvestigation -Path .\Case-001 -EndpointCapture $capture +$opened = Open-EVXInvestigation -Path .\Case-001 +$analysis = $opened | Invoke-EVXInvestigation -Endpoint -HtmlPath .\Case-001.html +$analysis.Applications | Select-Object ApplicationId, Context, AttemptId, LastPhase, Outcome +$analysis.Findings | Select-Object Title, Status, Explanation, NextChecks +``` + +Add native event observations through the pipeline and supply `-Manifest` and +`-Plan` to retain their declared event window and detection rules. Without native +events, the endpoint capture creates an empty native plan. The default +`Invoke-EVXInvestigation` continues to replay native Windows-event detection; +`-Endpoint` selects diagnostic analysis. + +## Read a bounded log batch + +```powershell +$options = [EventViewerX.EventDiagnosticReadOptions]@{ + FinalFile = $false + MaximumRecords = 1000 + UtcOffset = [TimeSpan]::FromHours(2) +} +$batch = Get-EVXDiagnostic -Path .\IntuneManagementExtension.log -Options $options +$batch.Records | Select-Object Source, LineStart, LineEnd, Timestamp, TimeQuality, Message +# After durably accepting the batch, retain its checkpoint for the next read. +$next = Get-EVXDiagnostic -Path .\IntuneManagementExtension.log -Options $options -Checkpoint $batch.Checkpoint +``` + +The reader supports UTF-8 and BOM-marked UTF-16. It bounds bytes per batch, +records per batch, and retained bytes per frame. Incomplete trailing frames stay +pending. Oversized completed frames carry an explicit diagnostic and cannot +produce application conclusions. A frame larger than the batch bound cannot +advance until the bound is increased; session capture reports that gap instead +of repeatedly retrying it. Concurrent truncation discards an unfinished frame and +reports the changed input so the next read can validate its generation. + +The checkpoint fingerprints physical file identity, the prefix, and the preceding +4 KiB. Rotation, empty truncation, and changes in those checked bytes start a new +generation. In-place historical changes elsewhere are outside the bounded check; +use an immutable copied investigation for full-file integrity. The collector must +durably accept records before committing a checkpoint. The CLI `diagnostics read` +can retain the position with `--checkpoint`, but cannot make stdout and an external +consumer transaction atomic. + +CMTrace offsets are signed minutes. An embedded offset takes precedence; use +`--utc-offset-minutes` or `UtcOffset` when the writer's offset is known. Missing +offsets remain unknown. The reader never applies the collector's local timezone. + +## Interpret a number in context + +```powershell +Get-EVXDiagnostic -Code '-2147024891' -Kind HResult +Get-EVXDiagnostic -Code '3010' -Kind WindowsInstaller +evx diagnostics code --value 0x80070005 --kind HResult +``` + +The result retains the original number, its unsigned and hexadecimal forms, +HRESULT facility and severity where applicable, and a next check. A process exit +code needs the application's configured return-code mapping. Windows Installer +0, 1641, and 3010 have success semantics; a required restart still needs reboot +policy and later detection evidence. [Windows Installer return-code contract](https://learn.microsoft.com/en-us/windows/win32/msi/error-codes). + +## Read conclusions and preserve evidence + +Application analysis recognizes applicability, requirements, dependencies, +detection, download, enforcement, restart, and reporting messages. Records need +one explicit application ID. Unattributed lines remain in evidence and never +inherit an app from a nearby line or thread. Attempt boundaries without explicit +IDs are marked inferred. An observed client detection result does not prove +service-side reporting or later deployment state. A negative detection during an +unfinished installation remains uncertain; post-enforcement failure needs a +completion or exit result, or an explicit terminal failure. [Microsoft's documented application lifecycle](https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/develop-deliver-working-win32-app-via-intune). + +DSRegCmd analysis retains section-local values, unknowns, and conflicting fields. +Join-state mismatch needs an explicit intended join mode. User PRT and Hello +results need the affected user's context and policy. MDM URLs alone do not prove +device enrollment. Capture time must be supplied by the collector; a file's +modification time is not a collection receipt. [Microsoft's DSRegCmd field contracts](https://learn.microsoft.com/en-us/entra/identity/devices/troubleshoot-device-dsregcmd). + +Captured DSRegCmd text and JSON facts support UTF-8 and BOM-marked UTF-16, including +Windows PowerShell 5.1 redirection. Canonical session JSON remains strict UTF-8. +Replay checks the hash of the diagnostic bytes actually consumed as well as the +session's preflight integrity checks. + +Original artifacts and canonical diagnostic records remain sensitive. Reports +omit raw log messages and DSRegCmd fields by default; application identifiers and +conclusions can still be sensitive. Use `--include-sensitive` or +`-IncludeSensitiveEvidence` only when the report recipient needs raw evidence. +This report option is data minimization, not anonymization. Keep an original +session separate from a presentation export. diff --git a/Docs/Export-EVXInvestigation.md b/Docs/Export-EVXInvestigation.md index 24fb17cd..71f2960f 100644 --- a/Docs/Export-EVXInvestigation.md +++ b/Docs/Export-EVXInvestigation.md @@ -9,11 +9,16 @@ schema: 2.0.0 Captures a reproducible investigation with copied input evidence, canonical observations, effective rules, and verified outputs. ## SYNTAX -### __AllParameterSets +### Events (Default) ```powershell Export-EVXInvestigation [-Path] -InputObject -Manifest -Plan [-InputPath ] [-Coverage ] [-WhatIf] [-Confirm] [] ``` +### Endpoint +```powershell +Export-EVXInvestigation [-Path] -EndpointCapture [-InputObject ] [-Manifest ] [-Plan ] [-Coverage ] [-WhatIf] [-Confirm] [] +``` + ## DESCRIPTION Use a new directory. The manifest declares the full input window, including correlation warm-up. Existing evidence is never overwritten. @@ -21,7 +26,13 @@ Use a new directory. The manifest declares the full input window, including corr ### EXAMPLE 1 ```powershell -Export-EVXInvestigation -InputObject 'Value' -Manifest 'Value' -Plan 'Value' +Export-EVXInvestigation -Path 'C:\Path' -InputObject 'Value' -Manifest 'Value' -Plan 'Value' +``` + + +### EXAMPLE 2 +```powershell +Export-EVXInvestigation -Path 'C:\Path' -EndpointCapture 'Value' ``` @@ -32,7 +43,7 @@ Expected and observed collection coverage. ```yaml Type: EventDetectionCoverage -Parameter Sets: __AllParameterSets +Parameter Sets: Events, Endpoint Aliases: None Possible values: @@ -43,16 +54,32 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -EndpointCapture +Endpoint capture descriptors, intended join state, and finite evidence bounds. Originals are copied before parsing; commands are never executed. + +```yaml +Type: EventEndpointCapture +Parameter Sets: Endpoint +Aliases: None +Possible values: + +Required: True +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -InputObject Canonical observation or detached event to retain. ```yaml Type: Object -Parameter Sets: __AllParameterSets +Parameter Sets: Events, Endpoint Aliases: None Possible values: -Required: True +Required: False Position: named Default value: None Accept pipeline input: True (ByValue) @@ -64,7 +91,7 @@ Original input files to copy and hash inside the session. ```yaml Type: String[] -Parameter Sets: __AllParameterSets +Parameter Sets: Events Aliases: None Possible values: @@ -80,11 +107,11 @@ Source receipts, query identity, time range, parser versions, and execution limi ```yaml Type: EventInvestigationManifest -Parameter Sets: __AllParameterSets +Parameter Sets: Events, Endpoint Aliases: None Possible values: -Required: True +Required: False Position: named Default value: None Accept pipeline input: False @@ -96,7 +123,7 @@ New investigation directory. ```yaml Type: String -Parameter Sets: __AllParameterSets +Parameter Sets: Events, Endpoint Aliases: None Possible values: @@ -112,11 +139,11 @@ Effective detection plan to retain and evaluate. ```yaml Type: EventDetectionPlan -Parameter Sets: __AllParameterSets +Parameter Sets: Events, Endpoint Aliases: None Possible values: -Required: True +Required: False Position: named Default value: None Accept pipeline input: False diff --git a/Docs/Get-EVXDiagnostic.md b/Docs/Get-EVXDiagnostic.md new file mode 100644 index 00000000..b8f22980 --- /dev/null +++ b/Docs/Get-EVXDiagnostic.md @@ -0,0 +1,135 @@ +--- +external help file: PSEventViewer-help.xml +Module Name: PSEventViewer +online version: https://github.com/EvotecIT/EventViewerX +schema: 2.0.0 +--- +# Get-EVXDiagnostic +## SYNOPSIS +Reads one bounded captured-log batch or interprets a diagnostic code in its declared context. + +## SYNTAX +### Log (Default) +```powershell +Get-EVXDiagnostic [-Path] [-Checkpoint ] [-Options ] [] +``` + +### Code +```powershell +Get-EVXDiagnostic -Code [-Kind ] [] +``` + +## DESCRIPTION +Checkpoints preserve complete-frame boundaries. The command never executes a collection command or changes the endpoint. + +## EXAMPLES + +### EXAMPLE 1 +```powershell +Get-EVXDiagnostic -Path 'C:\Path' +``` + + +### EXAMPLE 2 +```powershell +Get-EVXDiagnostic -Code 'Value' +``` + + +## PARAMETERS + +### -Checkpoint +Previous batch restart position. + +```yaml +Type: EventDiagnosticCheckpoint +Parameter Sets: Log +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Code +Signed decimal, unsigned decimal, or 0x-prefixed hexadecimal number. + +```yaml +Type: String +Parameter Sets: Code +Aliases: None +Possible values: + +Required: True +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Kind +Emitting API family. Unknown preserves the number without guessing an outcome. + +```yaml +Type: EventDiagnosticCodeKind +Parameter Sets: Code +Aliases: None +Possible values: Unknown, Win32, HResult, WindowsInstaller, ProcessExit + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Options +Finite bounds, declared writer offset, and final-file behavior. + +```yaml +Type: EventDiagnosticReadOptions +Parameter Sets: Log +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Path +Log file to read, using UTF-8 or BOM-marked UTF-16. + +```yaml +Type: String +Parameter Sets: Log +Aliases: None +Possible values: + +Required: True +Position: 0 +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). + +## INPUTS + +- `None` + +## OUTPUTS + +- `EventViewerX.EventDiagnosticReadResult` +- `EventViewerX.EventDiagnosticCode` + +## RELATED LINKS + +- None diff --git a/Docs/Invoke-EVXInvestigation.md b/Docs/Invoke-EVXInvestigation.md index a23b47c1..9f26a2ff 100644 --- a/Docs/Invoke-EVXInvestigation.md +++ b/Docs/Invoke-EVXInvestigation.md @@ -9,11 +9,16 @@ schema: 2.0.0 Replays a verified investigation without rewriting its original evidence or generated outputs. ## SYNTAX -### __AllParameterSets +### Events (Default) ```powershell Invoke-EVXInvestigation [-Session] [-AllowDifferentEngine] [] ``` +### Endpoint +```powershell +Invoke-EVXInvestigation [-Session] -Endpoint [-AllowDifferentEngine] [-HtmlPath ] [-IncludeSensitiveEvidence] [] +``` + ## DESCRIPTION Replays a verified investigation without rewriting its original evidence or generated outputs. @@ -21,7 +26,13 @@ Replays a verified investigation without rewriting its original evidence or gene ### EXAMPLE 1 ```powershell -Invoke-EVXInvestigation -AllowDifferentEngine +Invoke-EVXInvestigation -Session 'Value' +``` + + +### EXAMPLE 2 +```powershell +Invoke-EVXInvestigation -Session 'Value' -Endpoint ``` @@ -32,7 +43,55 @@ Allows comparative replay with a different engine build and marks resulting cove ```yaml Type: SwitchParameter -Parameter Sets: __AllParameterSets +Parameter Sets: Events, Endpoint +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Endpoint +Replays captured endpoint evidence. The default replays native Windows-event detection. + +```yaml +Type: SwitchParameter +Parameter Sets: Endpoint +Aliases: None +Possible values: + +Required: True +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -HtmlPath +New HTML presentation export outside the immutable session directory. Existing files are rejected. + +```yaml +Type: String +Parameter Sets: Endpoint +Aliases: None +Possible values: + +Required: False +Position: named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -IncludeSensitiveEvidence +Includes raw messages and DSRegCmd fields in the presentation export. The report remains sensitive even when raw evidence is omitted. + +```yaml +Type: SwitchParameter +Parameter Sets: Endpoint Aliases: None Possible values: @@ -48,7 +107,7 @@ Session returned by Open-EVXInvestigation or Export-EVXInvestigation. ```yaml Type: EventInvestigationSession -Parameter Sets: __AllParameterSets +Parameter Sets: Events, Endpoint Aliases: None Possible values: @@ -69,6 +128,7 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## OUTPUTS - `EventViewerX.EventDetectionExecutionResult` +- `EventViewerX.EventEndpointAnalysis` ## RELATED LINKS diff --git a/Docs/README.md b/Docs/README.md index 2bfa6dc6..6728e360 100644 --- a/Docs/README.md +++ b/Docs/README.md @@ -51,6 +51,9 @@ Gets the built-in versioned EventViewerX detection packs. Returns signed-content-ready pack manifests with rule provenance, versions, hashes, licenses, and ATT&CK tags. +### [Get-EVXDiagnostic](Get-EVXDiagnostic.md) +Reads one bounded captured-log batch or interprets a diagnostic code in its declared context. + ### [Get-EVXEvent](Get-EVXEvent.md) Enhanced event querying cmdlet that replaces and extends Get-WinEvent functionality. diff --git a/PSEventViewer.psd1 b/PSEventViewer.psd1 index 5f801d65..076e6647 100644 --- a/PSEventViewer.psd1 +++ b/PSEventViewer.psd1 @@ -1,7 +1,7 @@ -@{ +@{ AliasesToExport = @('Find-WinEvent', 'Get-EVXFilter', 'Write-EVXEntry') Author = 'Przemyslaw Klys' - CmdletsToExport = @('Backup-EVXStore', 'Clear-EVXLog', 'Compare-EVXDetectionPack', 'Export-EVXEvent', 'Export-EVXInvestigation', 'Get-EVXAnalysisContract', 'Get-EVXCollectorSubscription', 'Get-EVXDetectionCoverage', 'Get-EVXDetectionPack', 'Get-EVXEvent', 'Get-EVXKerberosImpact', 'Get-EVXLog', 'Get-EVXPowerShellScript', 'Get-EVXProvider', 'Get-EVXRequirement', 'Get-EVXStoredEvent', 'Get-EVXTarget', 'Get-EVXWatcher', 'Import-EVXSigmaRule', 'Install-EVXProviderPackage', 'Invoke-EVXDetection', 'Invoke-EVXInvestigation', 'Invoke-EVXStoreRetention', 'Measure-EVXEvent', 'New-EVXCollectorSubscription', 'New-EVXFilter', 'New-EVXLog', 'New-EVXProviderPackage', 'New-EVXSource', 'Open-EVXInvestigation', 'Remove-EVXLog', 'Remove-EVXSource', 'Reset-EVXEventCheckpoint', 'Restore-EVXStore', 'Set-EVXCollectorSubscription', 'Set-EVXLog', 'Show-EVXEvent', 'Start-EVXWatcher', 'Stop-EVXWatcher', 'Test-EVXDetectionPack', 'Test-EVXLog', 'Test-EVXProviderDefinition', 'Test-EVXReadiness', 'Test-EVXSigmaRule', 'Test-EVXStore', 'Uninstall-EVXProviderPackage', 'Update-EVXLogArchive', 'Write-EVXEvent') + CmdletsToExport = @('Backup-EVXStore', 'Clear-EVXLog', 'Compare-EVXDetectionPack', 'Export-EVXEvent', 'Export-EVXInvestigation', 'Get-EVXAnalysisContract', 'Get-EVXCollectorSubscription', 'Get-EVXDetectionCoverage', 'Get-EVXDetectionPack', 'Get-EVXDiagnostic', 'Get-EVXEvent', 'Get-EVXKerberosImpact', 'Get-EVXLog', 'Get-EVXPowerShellScript', 'Get-EVXProvider', 'Get-EVXRequirement', 'Get-EVXStoredEvent', 'Get-EVXTarget', 'Get-EVXWatcher', 'Import-EVXSigmaRule', 'Install-EVXProviderPackage', 'Invoke-EVXDetection', 'Invoke-EVXInvestigation', 'Invoke-EVXStoreRetention', 'Measure-EVXEvent', 'New-EVXCollectorSubscription', 'New-EVXFilter', 'New-EVXLog', 'New-EVXProviderPackage', 'New-EVXSource', 'Open-EVXInvestigation', 'Remove-EVXLog', 'Remove-EVXSource', 'Reset-EVXEventCheckpoint', 'Restore-EVXStore', 'Set-EVXCollectorSubscription', 'Set-EVXLog', 'Show-EVXEvent', 'Start-EVXWatcher', 'Stop-EVXWatcher', 'Test-EVXDetectionPack', 'Test-EVXLog', 'Test-EVXProviderDefinition', 'Test-EVXReadiness', 'Test-EVXSigmaRule', 'Test-EVXStore', 'Uninstall-EVXProviderPackage', 'Update-EVXLogArchive', 'Write-EVXEvent') CompanyName = 'Evotec' CompatiblePSEditions = @('Desktop', 'Core') Copyright = '(c) 2011 - 2026 Przemyslaw Klys @ Evotec. All rights reserved.' diff --git a/README.md b/README.md index fc001dfe..4cc8402c 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,11 @@ For portable evidence sessions, bounded detection checkpoints, coverage-aware ab watcher draining, rule-change previews, and clock uncertainty, see [Reproducible investigations and bounded replay](INVESTIGATIONS.md). +[Captured endpoint investigations](Docs/Endpoint-Investigations.md) combine bounded +CMTrace/IME log reading, contextual error-code interpretation, application-attempt +analysis, captured DSRegCmd triage, and evidence-backed next checks in the same +verified session format. + - Stream local channels, remote channels, offline EVTX files, or structured QueryList XML without accumulating the complete result. - Push event ID, provider, time, record ID, level, keyword, user, and event-data diff --git a/Sources/EventViewerX.Cli/Program.Endpoint.cs b/Sources/EventViewerX.Cli/Program.Endpoint.cs new file mode 100644 index 00000000..440b8f9a --- /dev/null +++ b/Sources/EventViewerX.Cli/Program.Endpoint.cs @@ -0,0 +1,97 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using EventViewerX.Reporting; + +namespace EventViewerX.Cli; + +internal static partial class Program { + private static int Diagnostic(CliArguments options) { + using var cancellation = new ConsoleQueryCancellation(); + if (options.Subcommand == "code") { + return WriteJson(EventDiagnosticCode.Resolve(options.Require("value"), ParseEnum(options.Get("kind"), EventDiagnosticCodeKind.Unknown, "--kind"))); + } + if (options.Subcommand != "read") { throw new ArgumentException("Use diagnostics read or diagnostics code."); } + string inputPath = System.IO.Path.GetFullPath(options.Require("path")); + EventDiagnosticCheckpoint? checkpoint = null; + string? checkpointPath = options.Get("checkpoint"); + if (checkpointPath != null) { + checkpointPath = System.IO.Path.GetFullPath(checkpointPath); + StringComparison comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (string.Equals(ResolveDiagnosticPath(inputPath), ResolveDiagnosticPath(checkpointPath), comparison)) { + throw new ArgumentException("The checkpoint path must be different from the diagnostic input path."); + } + } + if (checkpointPath != null && File.Exists(checkpointPath)) { + if (new FileInfo(checkpointPath).Length > 64 * 1024) { throw new InvalidDataException("Checkpoint exceeds 64 KiB."); } + checkpoint = JsonSerializer.Deserialize(File.ReadAllText(checkpointPath), JsonOptions) ?? throw new InvalidDataException("Missing checkpoint."); + } + var result = EventDiagnosticLogReader.Read(inputPath, new EventDiagnosticReadOptions { + UtcOffset = Offset(options), FinalFile = !options.Has("live"), MaximumRecords = options.GetInt("max-records", 10_000), + MaximumBatchBytes = options.GetInt("max-batch-bytes", 8 * 1024 * 1024), MaximumRecordBytes = options.GetInt("max-record-bytes", 256 * 1024) + }, checkpoint, cancellation.Token); + int exit = WriteJson(result); + // The caller must durably retain stdout before accepting this position. A reader cannot make an external sink atomic. + if (checkpointPath != null) { + string temporary = System.IO.Path.GetFullPath(checkpointPath) + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try { + File.WriteAllText(temporary, JsonSerializer.Serialize(result.Checkpoint, JsonOptions), new UTF8Encoding(false)); + File.Move(temporary, checkpointPath, true); + } finally { if (File.Exists(temporary)) { File.Delete(temporary); } } + } + return exit; + } + + private static string ResolveDiagnosticPath(string fullPath) { + string root = System.IO.Path.GetPathRoot(fullPath)!; + string[] parts = fullPath.Substring(root.Length).Split(System.IO.Path.DirectorySeparatorChar); + string directory = root; + for (int index = 0; index < parts.Length - 1; index++) { + directory = System.IO.Path.Combine(directory, parts[index]); + if (Directory.Exists(directory)) { + directory = new DirectoryInfo(directory).ResolveLinkTarget(true)?.FullName ?? directory; + } + } + string path = System.IO.Path.Combine(directory, parts[parts.Length - 1]); + return File.Exists(path) ? new FileInfo(path).ResolveLinkTarget(true)?.FullName ?? path : path; + } + + private static TimeSpan? Offset(CliArguments options) => options.Get("utc-offset-minutes") is string minutes + ? TimeSpan.FromMinutes(int.Parse(minutes, CultureInfo.InvariantCulture)) : null; + + private static int EndpointInvestigation(CliArguments options) { + using var cancellation = new ConsoleQueryCancellation(); + EventInvestigationSession session; + if (options.Subcommand == "create") { + var inputs = new List(); + foreach ((string flag, string kind) in new[] { ("log", "Log"), ("dsreg", "DsRegCmd"), ("facts", "Facts"), ("registry", "Registry"), ("attachment", "Attachment") }) { + foreach (string path in options.GetMany(flag)) { + inputs.Add(new EventEndpointInput { Path = path, Kind = kind, Device = options.Get("device") ?? string.Empty, UtcOffset = Offset(options), ExecutionContext = options.Get("context") ?? "Unknown", + CapturedAt = options.Get("captured-at") is string at ? ParseCaptureTime(at) : null }); + } + } + session = EventInvestigationSession.CreateEndpoint(options.Require("directory"), new EventEndpointCapture { + Inputs = inputs.ToArray(), ExpectedJoin = options.Get("expected-join"), MaximumRecords = options.GetInt("max-records", 25_000), + MaximumInputBytes = options.GetLong("max-input-bytes", 256L * 1024 * 1024), MaximumRecordBytes = options.GetLong("max-record-bytes", 64L * 1024 * 1024) + }, cancellationToken: cancellation.Token); + } else if (options.Subcommand is "replay" or "inspect") { + session = EventInvestigationSession.Open(options.Require("directory"), cancellation.Token); + } else { throw new ArgumentException("Use investigation create, inspect, or replay."); } + EventEndpointAnalysis analysis = options.Subcommand == "replay" ? session.ReplayEndpoint(options.Has("allow-different-engine"), cancellation.Token) : session.ReadEndpointAnalysis(cancellation.Token); + if (options.Get("html") is string html) { + string destination = System.IO.Path.GetFullPath(html); + if (destination.StartsWith(session.DirectoryPath.TrimEnd(System.IO.Path.DirectorySeparatorChar) + System.IO.Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) { + throw new ArgumentException("Write presentation exports outside the immutable investigation directory."); + } + using var output = new FileStream(destination, FileMode.CreateNew, FileAccess.Write, FileShare.None); + using var writer = new StreamWriter(output, new UTF8Encoding(false)); + writer.Write(EventEndpointHtmlRenderer.Render(analysis, options.Has("include-sensitive"))); + } + return WriteJson(new { Directory = session.DirectoryPath, analysis.InputComplete, analysis.Applications, analysis.Findings, analysis.CoverageDiagnostics }); + } + + private static DateTimeOffset ParseCaptureTime(string value) { + if (!value.EndsWith("Z", StringComparison.OrdinalIgnoreCase) && !System.Text.RegularExpressions.Regex.IsMatch(value, "[+-]\\d{2}:\\d{2}$")) { throw new ArgumentException("--captured-at requires an explicit UTC offset or Z."); } + return DateTimeOffset.Parse(value, CultureInfo.InvariantCulture); + } +} \ No newline at end of file diff --git a/Sources/EventViewerX.Cli/Program.cs b/Sources/EventViewerX.Cli/Program.cs index 873c7e98..d961fd32 100644 --- a/Sources/EventViewerX.Cli/Program.cs +++ b/Sources/EventViewerX.Cli/Program.cs @@ -32,6 +32,8 @@ private static async Task Main(string[] args) { "query" => await QueryAsync(options).ConfigureAwait(false), "report" => await ReportAsync(options).ConfigureAwait(false), "bundle" => VerifyEvidenceBundle(options), + "investigation" => EndpointInvestigation(options), + "diagnostics" => Diagnostic(options), "measure" => await MeasureAsync(options).ConfigureAwait(false), "detect" => await DetectAsync(options).ConfigureAwait(false), "kerberos-impact" => await KerberosImpactAsync(options).ConfigureAwait(false), @@ -554,12 +556,24 @@ private static void WriteSavedEventDiagnostic(SavedEventReadDiagnostic diagnosti } private static void ValidateOptions(CliArguments options) { - if (options.Subcommand.Length > 0 && options.Command is not ("collector" or "provider" or "store" or "bundle")) { + if (options.Subcommand.Length > 0 && options.Command is not ("collector" or "provider" or "store" or "bundle" or "investigation" or "diagnostics")) { throw new ArgumentException( $"Unexpected argument '{options.Subcommand}'. The {options.Command} command does not accept a subcommand."); } switch (options.Command) { + case "investigation" when options.Subcommand == "create": + options.ValidateAllowed("directory", "log", "dsreg", "facts", "registry", "attachment", "device", "expected-join", "context", "captured-at", "utc-offset-minutes", "max-records", "max-input-bytes", "max-record-bytes", "html", "include-sensitive"); + break; + case "investigation" when options.Subcommand is "replay" or "inspect": + options.ValidateAllowed("directory", "allow-different-engine", "html", "include-sensitive"); + break; + case "diagnostics" when options.Subcommand == "read": + options.ValidateAllowed("path", "checkpoint", "utc-offset-minutes", "max-records", "max-batch-bytes", "max-record-bytes", "live"); + break; + case "diagnostics" when options.Subcommand == "code": + options.ValidateAllowed("value", "kind"); + break; case "query": options.ValidateAllowed( "preset", "type", "definition", "definition-name", "log", "path", "event-id", "record-id", @@ -679,6 +693,10 @@ private static int Help() { " evx --version\n" + " evx types [--type TYPE[,TYPE] | --definition FILE]\n" + " evx schemas\n" + + " evx diagnostics code --value NUMBER [--kind Unknown|Win32|HResult|WindowsInstaller|ProcessExit]\n" + + " evx diagnostics read --path FILE [--checkpoint FILE.json] [--utc-offset-minutes N] [--live] [--max-records N]\n" + + " evx investigation create --directory NEW_DIR [--log FILE[,FILE]] [--dsreg FILE] [--facts FILE] [--registry FILE] [--attachment FILE] [--context User|SYSTEM|Unknown] [--captured-at OFFSET_TIMESTAMP] [--expected-join Entra|Hybrid|Domain|Unjoined] [--html FILE [--include-sensitive]]\n" + + " evx investigation inspect|replay --directory DIR [--allow-different-engine with replay] [--html FILE [--include-sensitive]]\n" + " 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]] [--stream for JSONL without retaining rows] [--read-mode Metadata|Message|StructuredData|StructuredDataAndMessage|Full] [--summary-file FILE.json] [--require-complete (exit 2 on incomplete input)] [--explain] [--since 01:00:00] [--max N]\n" + " evx report (--type TYPE[,TYPE] | --definition FILE | --log LOG | --path FILE[,FILE] | --store FILE.db) [--summary Hour|Day|Week|Month] (--html FILE | --excel FILE | --csv FILE.csv|BUNDLE.zip | --email-html FILE | --bundle FILE.zip | --mail-profile FILE) [--privacy omit|pseudonymize [--privacy-key-file FILE (32 binary bytes)] [--retain-fields FIELD[,FIELD]] [--pseudonymize-fields FIELD[,FIELD]]] [--summary-file FILE.json] [--require-complete] [--drawer-placement Auto|Top|Right]\n" + " evx bundle verify --path FILE.zip [--max-bytes N]\n" + diff --git a/Sources/EventViewerX.Reporting/EventEndpointHtmlRenderer.cs b/Sources/EventViewerX.Reporting/EventEndpointHtmlRenderer.cs new file mode 100644 index 00000000..92ccf740 --- /dev/null +++ b/Sources/EventViewerX.Reporting/EventEndpointHtmlRenderer.cs @@ -0,0 +1,79 @@ +using HtmlForgeX; + +namespace EventViewerX.Reporting; + +/// Presentation-only endpoint investigation report using shared HtmlForgeX diagnostic components. +public static class EventEndpointHtmlRenderer { + /// Renders searchable conclusions, next checks, and evidence coordinates. Raw messages and identity fields require explicit inclusion. + /// Even without raw evidence, the report can contain application identifiers. Treat captured sessions and diagnostic exports as sensitive. + public static string Render(EventEndpointAnalysis analysis, bool includeSensitiveEvidence = false) { + if (analysis == null) { throw new ArgumentNullException(nameof(analysis)); } + using var document = new Document { LibraryMode = LibraryMode.Offline, ThemeMode = ThemeMode.System, DarkThemeVariant = HfxThemeVariant.DarkCarbon }; + document.Head.Title = "Endpoint investigation"; + var dashboard = new MonitoringDashboard().Brand("EventViewerX").FooterInfo("Captured evidence · no live collection or endpoint changes") + .Settings(settings => settings.State(state => state.StateId("eventviewerx-endpoint").HashMode(MonitoringDashboardHashMode.Namespaced).End()) + .Theme(theme => theme.Selector().End()).End()); + dashboard.AddPage("findings", "Findings and next checks", "Conclusions bounded by supplied evidence", TablerIconType.ReportAnalytics, page => { + page.AddMetric(metric => metric.Title("Input parsing").Value(analysis.InputComplete ? "Complete" : "Incomplete") + .State(analysis.InputComplete ? MonitoringHealthState.Healthy : MonitoringHealthState.Warning) + .Description("Describes supplied bytes, not endpoint or lifecycle coverage")); + page.AddMetric(metric => metric.Title("Application attempts").Value(analysis.Applications.Length.ToString()).Description("Explicitly attributable records")); + var explorer = Explorer("Diagnostic findings", "Rule", "Title", "Status"); + var evidence = analysis.Records.ToDictionary(record => record.Identity, StringComparer.Ordinal); + for (int index = 0; index < analysis.Findings.Length; index++) { + EventDiagnosticFinding finding = analysis.Findings[index]; + explorer.AddRecord("finding-" + index, finding.Title, record => { + record.Cell("Rule", finding.RuleId).Cell("Title", finding.Title).Cell("Status", finding.Status).Detail("Explanation", finding.Explanation) + .Detail("Next checks", string.Join("\n", finding.NextChecks.Select(check => check.Artifact + ": " + check.Action + " Reason: " + check.Reason))) + .Detail("Evidence", string.Join("\n", finding.EvidenceIdentities.Select(id => evidence.TryGetValue(id, out EventDiagnosticRecord? item) + ? item.Source + ": lines " + item.LineStart + "-" + item.LineEnd + ", bytes " + item.ByteStart + "-" + item.ByteEnd + " [" + id + "]" : "Artifact SHA-256: " + id))) + .Detail("Rule version", finding.RuleVersion).Detail("References", string.Join("\n", finding.References)).Tag(finding.Status); + }); + } + page.Panel("Findings", panel => panel.Subtitle("Search by application or status; select a record for full guidance and provenance").Content(explorer)); + var coverage = Explorer("Coverage limits", "Diagnostic"); + for (int index = 0; index < analysis.CoverageDiagnostics.Length; index++) { + string diagnostic = analysis.CoverageDiagnostics[index]; + coverage.AddRecord("coverage-" + index, "Evidence limit", record => record.Cell("Diagnostic", diagnostic)); + } + page.Panel("Coverage and uncertainty", panel => panel.Content(coverage)); + }, active: true); + dashboard.AddPage("evidence", "Evidence", includeSensitiveEvidence ? "Sensitive captured records included" : "Coordinates only; raw evidence stays in the original session", TablerIconType.ListDetails, page => { + var explorer = Explorer("Captured log records", "Source", "Lines", "Bytes", "Time", "Time quality", "Format", "Component", "Severity"); + if (includeSensitiveEvidence) { explorer.AddColumn("Message", "Message"); } + foreach (EventDiagnosticRecord item in analysis.Records) { + explorer.AddRecord(item.Identity, item.Source + ":" + item.LineStart, record => { + record.Cell("Source", item.Source).Cell("Lines", item.LineStart + "-" + item.LineEnd).Cell("Bytes", item.ByteStart + "-" + item.ByteEnd) + .Cell("Time", item.Timestamp?.ToString("O") ?? "Unknown instant").Cell("Time quality", item.TimeQuality).Cell("Format", item.Format) + .Cell("Component", item.Component).Cell("Severity", item.Severity?.ToString() ?? "Unknown").Detail("Identity", item.Identity) + .Detail("Diagnostic", item.Diagnostic ?? "None"); + if (includeSensitiveEvidence) { record.Cell("Message", item.Message).Detail("Raw frame", item.RawText).Detail("Writer context", item.Context); } + }); + } + page.Panel("Records", panel => panel.Subtitle("Known instants use the writer's offset; unknown local times remain unknown").Content(explorer)); + if (includeSensitiveEvidence) { + var identity = Explorer("Captured DSRegCmd fields", "Artifact", "Section", "Name", "Value", "Line", "Context", "Captured at"); + int index = 0; + foreach (EventDsRegSnapshot snapshot in analysis.IdentitySnapshots) { + foreach (EventDsRegField field in snapshot.Fields) { + identity.AddRecord("identity-" + index++, field.Name, record => record.Cell("Artifact", snapshot.EvidenceIdentity).Cell("Section", field.Section) + .Cell("Name", field.Name).Cell("Value", field.Value).Cell("Line", field.Line.ToString()).Cell("Context", snapshot.ExecutionContext) + .Cell("Captured at", snapshot.CapturedAt?.ToString("O") ?? "Unknown")); + } + } + page.Panel("Identity snapshot", panel => panel.Content(identity)); + } + }); + document.Body.Add(dashboard); + return document.ToString(); + } + + private static MonitoringRecordExplorer Explorer(string label, params string[] columns) { + var explorer = new MonitoringRecordExplorer().Settings(settings => settings.AccessibleLabel(label).PageSize(25) + .InlineDetails(false).DrawerInitiallyCollapsed(true).OverviewDrawerLabel("Details and guidance").RawDrawerPanel(false).ExportDrawerPanel(false).End()); + for (int index = 0; index < columns.Length; index++) { explorer.AddColumn(columns[index], columns[index], visible: index < 3); } + explorer.AddColumnGroup("Summary", columns.Take(3)).ActiveGroup("Summary"); + if (columns.Length > 3) { explorer.AddColumnGroup("All evidence", columns); } + return explorer; + } +} \ No newline at end of file diff --git a/Sources/EventViewerX.Tests/TestEndpointDiagnostics.cs b/Sources/EventViewerX.Tests/TestEndpointDiagnostics.cs new file mode 100644 index 00000000..edc4849e --- /dev/null +++ b/Sources/EventViewerX.Tests/TestEndpointDiagnostics.cs @@ -0,0 +1,326 @@ +using System.Text; +using System.Text.Json; +using EventViewerX.Reporting; +using Xunit; + +namespace EventViewerX.Tests; + +public sealed class TestEndpointDiagnostics : IDisposable { + private readonly string _root = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "evx-endpoint-" + Guid.NewGuid().ToString("N")); + public TestEndpointDiagnostics() { Directory.CreateDirectory(_root); } + private string PathOf(string name) => System.IO.Path.Combine(_root, name); + private static string Frame(string message, string time = "10:00:00.123+120", string context = "") => + "\r\n"; + + [Theory] + [InlineData("-2147024891", EventDiagnosticCodeKind.HResult, "Failure", "0x80070005")] + [InlineData("0x80070005", EventDiagnosticCodeKind.HResult, "Failure", "0x80070005")] + [InlineData("3010", EventDiagnosticCodeKind.WindowsInstaller, "SuccessRestartRequired", "0x00000BC2")] + [InlineData("1641", EventDiagnosticCodeKind.WindowsInstaller, "SuccessRestartInitiated", "0x00000669")] + [InlineData("3010", EventDiagnosticCodeKind.ProcessExit, "Unknown", "0x00000BC2")] + [InlineData("1603", EventDiagnosticCodeKind.Unknown, "Unknown", "0x00000643")] + public void CodeOutcomeRequiresDeclaredContext(string text, EventDiagnosticCodeKind kind, string outcome, string hex) { + EventDiagnosticCode result = EventDiagnosticCode.Resolve(text, kind); + Assert.Equal(text, result.Original); Assert.Equal(outcome, result.Outcome); Assert.Equal(hex, result.Hex); + if (kind == EventDiagnosticCodeKind.HResult) { Assert.Equal((uint)5, result.Win32Value); } + } + + [Fact] + public void UnknownWriterOffsetDoesNotUseReaderZone() { + string path = PathOf("time.log"); File.WriteAllText(path, Frame("record", "10:00:00.123"), new UTF8Encoding(false)); + EventDiagnosticRecord unknown = Assert.Single(EventDiagnosticLogReader.Read(path).Records); + Assert.Null(unknown.Timestamp); Assert.Equal("UnknownOffset", unknown.TimeQuality); + EventDiagnosticRecord supplied = Assert.Single(EventDiagnosticLogReader.Read(path, new EventDiagnosticReadOptions { UtcOffset = TimeSpan.FromHours(-4) }).Records); + Assert.Equal(TimeSpan.FromHours(-4), supplied.Timestamp!.Value.Offset); Assert.Equal("SuppliedOffset", supplied.TimeQuality); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void IncrementalFramesRetainMultilineAndSplitEncodedWrites(bool utf16) { + string path = PathOf("partial.log"); Encoding encoding = utf16 ? new UnicodeEncoding(false, true) : new UTF8Encoding(false); + string complete = Frame("first\nsecond \u20ac"); byte[] bytes = encoding.GetPreamble().Concat(encoding.GetBytes(complete)).ToArray(); + int split = Array.IndexOf(bytes, (byte)0xac); + if (utf16) { split = bytes.Length - 3; } + Assert.True(split > 0); + File.WriteAllBytes(path, bytes.Take(split).ToArray()); + var options = new EventDiagnosticReadOptions { FinalFile = false }; + EventDiagnosticReadResult pending = EventDiagnosticLogReader.Read(path, options); + Assert.Empty(pending.Records); Assert.True(pending.HasMore); + using (var append = new FileStream(path, FileMode.Append)) { append.Write(bytes, split, bytes.Length - split); } + EventDiagnosticReadResult result = EventDiagnosticLogReader.Read(path, options, pending.Checkpoint); + EventDiagnosticRecord record = Assert.Single(result.Records); + Assert.Equal("first\nsecond \u20ac", record.Message); Assert.Equal(2, record.LineEnd); Assert.True(result.IsComplete); + Assert.Equal(TimeSpan.FromHours(2), record.Timestamp!.Value.Offset); + Assert.Empty(EventDiagnosticLogReader.Read(path, options, result.Checkpoint).Records); + } + + [Fact] + public void ResumeDetectsEmptyTruncationAndSameLengthOverwrite() { + string path = PathOf("rotating.log"); File.WriteAllText(path, "alpha\n", new UTF8Encoding(false)); + EventDiagnosticReadResult first = EventDiagnosticLogReader.Read(path); + File.WriteAllText(path, "bravo\n", new UTF8Encoding(false)); + EventDiagnosticReadResult rewritten = EventDiagnosticLogReader.Read(path, checkpoint: first.Checkpoint); + Assert.True(rewritten.GenerationChanged); Assert.NotEqual(first.Records[0].Identity, rewritten.Records[0].Identity); + File.WriteAllText(path, string.Empty); + EventDiagnosticReadResult empty = EventDiagnosticLogReader.Read(path, checkpoint: rewritten.Checkpoint); + Assert.True(empty.GenerationChanged); Assert.Equal(0, empty.Checkpoint.Offset); + File.WriteAllText(path, "after\n", new UTF8Encoding(false)); + EventDiagnosticReadResult after = EventDiagnosticLogReader.Read(path, checkpoint: empty.Checkpoint); + Assert.Equal("after", Assert.Single(after.Records).Message); + Assert.Equal(empty.Checkpoint.Generation, after.Checkpoint.Generation); + } + + [Fact] + public void ResumePreservesGenerationWhenWindowsPathCasingChanges() { + if (!System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) { return; } + string path = PathOf("CaseSensitive.log"); + File.WriteAllText(path, "one\ntwo\n", new UTF8Encoding(false)); + var options = new EventDiagnosticReadOptions { MaximumRecords = 1 }; + EventDiagnosticReadResult first = EventDiagnosticLogReader.Read(path, options); + EventDiagnosticReadResult second = EventDiagnosticLogReader.Read(path.ToUpperInvariant(), options, first.Checkpoint); + Assert.False(second.GenerationChanged); + Assert.Equal(first.Checkpoint.Generation, second.Checkpoint.Generation); + Assert.Equal("two", Assert.Single(second.Records).Message); + Assert.Equal(2, second.Records[0].LineStart); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void UnsupportedUtf32DoesNotProduceDiagnosticRecords(bool bigEndian) { + string path = PathOf("utf32.log"); + var encoding = new UTF32Encoding(bigEndian, true, true); + File.WriteAllBytes(path, encoding.GetPreamble().Concat(encoding.GetBytes("one\n")).ToArray()); + byte[] original = File.ReadAllBytes(path); + InvalidDataException error = Assert.Throws(() => EventDiagnosticLogReader.Read(path)); + Assert.Contains("UTF-32", error.Message); + Assert.Equal(original, File.ReadAllBytes(path)); + } + + [Fact] + public void BoundsMarkOversizedEvidenceAndLeaveTrailingFramesPending() { + string path = PathOf("bounded.log"); File.WriteAllText(path, Frame(new string('x', 800)) + "(() => EventDiagnosticLogReader.Read(path, cancellationToken: new CancellationToken(true))); + } + + [Fact] + public void SerializedCheckpointResumesOnceAcrossBatchesAndPhysicalReplacement() { + string path = PathOf("batch.log"); File.WriteAllText(path, "one\ntwo\nthree\n", new UTF8Encoding(false)); + var options = new EventDiagnosticReadOptions { MaximumRecords = 1 }; + EventDiagnosticReadResult first = EventDiagnosticLogReader.Read(path, options); + EventDiagnosticCheckpoint checkpoint = JsonSerializer.Deserialize(JsonSerializer.Serialize(first.Checkpoint))!; + EventDiagnosticReadResult second = EventDiagnosticLogReader.Read(path, options, checkpoint); + Assert.Equal("two", Assert.Single(second.Records).Message); Assert.Equal(2, second.Records[0].LineStart); + Assert.NotEqual(first.Records[0].Identity, second.Records[0].Identity); + File.Move(path, PathOf("old.log")); File.WriteAllText(path, "one\ntwo\nthree\n", new UTF8Encoding(false)); + EventDiagnosticReadResult replaced = EventDiagnosticLogReader.Read(path, options, second.Checkpoint); + Assert.True(replaced.GenerationChanged); Assert.Equal("one", Assert.Single(replaced.Records).Message); + } + + [Theory] + [InlineData("installation command configured", "Unknown")] + [InlineData("installation has not started", "Unknown")] + [InlineData("installation starts", "Unknown")] + [InlineData("installation completed", "Failure")] + public void NegativeDetectionRequiresObservedExecution(string phase, string expected) { + string path = PathOf("execution.log"); + const string app = "AppId: 11111111-1111-1111-1111-111111111111 context=SYSTEM attempt=one "; + File.WriteAllText(path, Frame(app + phase) + Frame(app + "applicationDetected: False", "10:01:00+120"), new UTF8Encoding(false)); + EventIntuneApplicationAttempt attempt = Assert.Single(EventIntuneApplicationAnalyzer.Analyze(EventDiagnosticLogReader.Read(path).Records).Applications); + Assert.Equal(expected, attempt.Outcome); + } + + [Theory] + [InlineData("session", "0")] + [InlineData("session", "3010")] + [InlineData("attempt", "0")] + [InlineData("attempt", "3010")] + public void RestartedEnforcementDoesNotInheritEarlierCompletion(string marker, string code) { + string path = PathOf("retry.log"); + string app = "AppId: 11111111-1111-1111-1111-111111111111 context=SYSTEM " + marker + "=one "; + File.WriteAllText(path, Frame(app + "MSI exit code " + code) + Frame(app + "installation starts", "10:01:00+120") + + Frame(app + "applicationDetected: False", "10:02:00+120"), new UTF8Encoding(false)); + EventEndpointAnalysis analysis = EventIntuneApplicationAnalyzer.Analyze(EventDiagnosticLogReader.Read(path).Records); + EventIntuneApplicationAttempt attempt = Assert.Single(analysis.Applications); + Assert.Equal("Unknown", attempt.Outcome); Assert.Null(attempt.ExitCode); Assert.Equal(3, attempt.EvidenceIdentities.Length); + Assert.Equal("InsufficientEvidence", Assert.Single(analysis.Findings).Status); + } + + [Fact] + public async Task ConcurrentTruncationStopsWithoutWaitingForCancellation() { + string path = PathOf("truncate.log"); + File.WriteAllText(path, string.Empty); _ = EventDiagnosticLogReader.Read(path); + using (var file = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete)) { file.SetLength(32 * 1024 * 1024); } + using var started = new ManualResetEventSlim(); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + Task reading = Task.Factory.StartNew(() => { + started.Set(); + return EventDiagnosticLogReader.Read(path, new EventDiagnosticReadOptions { MaximumBatchBytes = 64 * 1024 * 1024, MaximumRecordBytes = 128 }, cancellationToken: cancellation.Token); + }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + try { + Assert.True(started.Wait(TimeSpan.FromSeconds(2))); + await Task.Delay(100); + using (var truncate = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete)) { truncate.SetLength(0); } + EventDiagnosticReadResult result = await reading; + Assert.False(result.IsComplete); Assert.Empty(result.Records); + Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Contains("changed during")); + } finally { + cancellation.Cancel(); + try { await reading; } catch (OperationCanceledException) { } + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void MixedTimestampQualityKeepsEntireSourceInByteOrder(bool declaredDevice) { + string path = PathOf("mixed-time.log"); + const string app = "AppId: 11111111-1111-1111-1111-111111111111 context=SYSTEM attempt=one "; + File.WriteAllText(path, Frame(app + "applicationDetected: False", "10:00:00") + Frame(app + "MSI exit code 0", "10:01:00+120"), new UTF8Encoding(false)); + var records = EventDiagnosticLogReader.Read(path, new EventDiagnosticReadOptions { Device = declaredDevice ? "captured-device" : string.Empty }).Records; + EventIntuneApplicationAttempt attempt = Assert.Single(EventIntuneApplicationAnalyzer.Analyze(records).Applications); + Assert.Equal("InstallerSucceededDetectionUnknown", attempt.Outcome); + Assert.Equal(records.Select(record => record.Identity), attempt.EvidenceIdentities); + } + + [Theory] + [InlineData("User")] + [InlineData("user")] + [InlineData("USER")] + [InlineData("Unknown")] + [InlineData("unknown")] + [InlineData("UNKNOWN")] + public void GenericContextNeverCorrelatesAcrossFiles(string context) { + string first = PathOf("generic-first.log"), second = PathOf("generic-second.log"); + const string app = "AppId: 11111111-1111-1111-1111-111111111111 attempt=one context="; + File.WriteAllText(first, Frame(app + context + " MSI exit code 0")); + File.WriteAllText(second, Frame(app + context + " applicationDetected: False", "10:01:00+120")); + var options = new EventDiagnosticReadOptions { Device = "captured-device" }; + var records = EventDiagnosticLogReader.Read(first, options).Records.Concat(EventDiagnosticLogReader.Read(second, options).Records); + EventEndpointAnalysis result = EventIntuneApplicationAnalyzer.Analyze(records); + Assert.Equal(2, result.Applications.Length); Assert.DoesNotContain(result.Applications, attempt => attempt.Outcome == "Failure"); + } + + [Theory] + [InlineData("utf-8")] + [InlineData("utf-16le")] + [InlineData("utf-16be")] + public void CapturedTextSupportsBomEncodingsWhileRetainingOriginalBytes(string format) { + Encoding encoding = format switch { "utf-16le" => new UnicodeEncoding(false, true), "utf-16be" => new UnicodeEncoding(true, true), _ => new UTF8Encoding(true) }; + string dsreg = PathOf("status.txt"), facts = PathOf("facts.json"); + File.WriteAllText(dsreg, "| Device State |\r\nAzureAdJoined : YES\r\nDomainJoined : NO\r\nEnterpriseJoined : NO\r\n", encoding); + File.WriteAllText(facts, "{\"collector\":\"synthetic\"}", encoding); + byte[] original = File.ReadAllBytes(dsreg); + EventInvestigationSession session = EventInvestigationSession.CreateEndpoint(PathOf("encoded-case"), new EventEndpointCapture { ExpectedJoin = "Hybrid", Inputs = new[] { + new EventEndpointInput { Path = dsreg, Kind = "DsRegCmd" }, new EventEndpointInput { Path = facts, Kind = "Facts" } } }); + EventEndpointAnalysis replay = session.ReplayEndpoint(); + Assert.Equal("YES", Assert.Single(replay.IdentitySnapshots).Fields.Single(field => field.Name == "AzureAdJoined").Value); + Assert.Contains(replay.Findings, finding => finding.RuleId == "endpoint.identity.join" && finding.Status == "Failure"); + Assert.Equal(original, File.ReadAllBytes(System.IO.Path.Combine(session.DirectoryPath, "input-000001.evidence"))); + } + + [Fact] + public void EndpointRecordsVerifyBytesConsumedAfterPreflight() { + string path = PathOf("consumed.log"); File.WriteAllText(path, Frame("AppId: 11111111-1111-1111-1111-111111111111 MSI exit code 1603")); + EventInvestigationSession session = EventInvestigationSession.CreateEndpoint(PathOf("consumed-case"), new EventEndpointCapture { Inputs = new[] { new EventEndpointInput { Path = path } } }); + session.Verify(); + // Enter the existing record-consumption boundary after a successful preflight, without adding a production test hook. + var reader = typeof(EventInvestigationSession).GetMethod("ReadDiagnosticRecords", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + Type receiptType = reader.GetParameters()[0].ParameterType; + object receipt = JsonSerializer.Deserialize(File.ReadAllText(System.IO.Path.Combine(session.DirectoryPath, "endpoint-capture.json")), receiptType, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + string records = System.IO.Path.Combine(session.DirectoryPath, "endpoint-records.jsonl"); + File.WriteAllText(records, File.ReadAllText(records).Replace("1603", "3010"), new UTF8Encoding(false)); + var consumed = (IEnumerable)reader.Invoke(session, new[] { receipt, (object)CancellationToken.None })!; + Assert.Throws(() => consumed.ToArray()); + } + + [Fact] + public void CrossFileCorrelationNeedsDeclaredDeviceAndKnownInstants() { + string a = PathOf("workload.log"), b = PathOf("actions.log"); + File.WriteAllText(a, Frame("AppId: 11111111-1111-1111-1111-111111111111 context=SYSTEM attempt=one MSI exit code 3010"), new UTF8Encoding(false)); + File.WriteAllText(b, Frame("AppId: 11111111-1111-1111-1111-111111111111 context=SYSTEM attempt=one applicationDetected: True", "10:01:00+120"), new UTF8Encoding(false)); + EventDiagnosticRecord[] records = EventDiagnosticLogReader.Read(a).Records.Concat(EventDiagnosticLogReader.Read(b).Records).ToArray(); + Assert.Equal(2, EventIntuneApplicationAnalyzer.Analyze(records).Applications.Length); + foreach (EventDiagnosticRecord record in records) { record.Device = "captured-device"; } + Assert.Equal("RestartRequired", Assert.Single(EventIntuneApplicationAnalyzer.Analyze(records).Applications).Outcome); + records[1].Timestamp = null; + Assert.Equal(2, EventIntuneApplicationAnalyzer.Analyze(records).Applications.Length); + } + + [Fact] + public void ApplicationEvidenceDoesNotBleedBetweenAppsOrUnattributedLines() { + const string a = "11111111-1111-1111-1111-111111111111", b = "22222222-2222-2222-2222-222222222222"; + string path = PathOf("apps.log"); + File.WriteAllText(path, Frame("AppId: " + a + " context=SYSTEM attempt=one installation starts") + + Frame("AppId: " + b + " context=SYSTEM attempt=two installation starts") + + Frame("MSI exit code 1603") + Frame("AppId: " + a + " context=SYSTEM attempt=one MSI exit code 3010") + + Frame("AppId: " + b + " context=SYSTEM attempt=two applicationDetected: True"), new UTF8Encoding(false)); + EventEndpointAnalysis analysis = EventIntuneApplicationAnalyzer.Analyze(EventDiagnosticLogReader.Read(path).Records); + Assert.Equal("RestartRequired", analysis.Applications.Single(app => app.ApplicationId == a).Outcome); + Assert.Equal("Installed", analysis.Applications.Single(app => app.ApplicationId == b).Outcome); + Assert.Contains(analysis.CoverageDiagnostics, diagnostic => diagnostic.Contains("not correlated")); + Assert.All(analysis.Findings, finding => Assert.NotEmpty(finding.NextChecks)); + } + + [Fact] + public void DsRegUnknownIntentAndSystemContextCannotDiagnoseUserFailure() { + string text = "| Device State |\nAzureAdJoined : YES\nDomainJoined : NO\n| Tenant Details |\nMdmUrl : https://example.invalid/mdm\n| SSO State |\nAzureAdPrt : NO\n| User State |\nNgcSet : NO\n"; + EventDsRegSnapshot snapshot = EventDsRegAnalyzer.Parse(text, "evidence", "SYSTEM"); + EventDiagnosticFinding[] findings = EventDsRegAnalyzer.Analyze(snapshot); + Assert.DoesNotContain(findings, finding => finding.Status == "Failure"); + Assert.Contains(findings, finding => finding.RuleId == "endpoint.identity.prt" && finding.Explanation.Contains("SYSTEM")); + Assert.Equal("Failure", EventDsRegAnalyzer.Analyze(snapshot, "Hybrid").Single(finding => finding.RuleId == "endpoint.identity.join").Status); + snapshot.Fields = snapshot.Fields.Concat(new[] { new EventDsRegField { Section = "Device State", Name = "AzureAdJoined", Value = "NO" } }).ToArray(); + Assert.Equal("InsufficientEvidence", EventDsRegAnalyzer.Analyze(snapshot, "Hybrid").Single(finding => finding.RuleId == "endpoint.identity.join").Status); + } + + [Fact] + public void CapturedSessionReplayUsesHashedOriginalBytesAndRetainsOtherArtifactKinds() { + string path = PathOf("app.log"), facts = PathOf("facts.json"), registry = PathOf("registry.reg"); + File.WriteAllText(path, Frame("AppId: 11111111-1111-1111-1111-111111111111 context=SYSTEM applicationDetected: True"), new UTF8Encoding(false)); + File.WriteAllText(facts, "{\"intendedContext\":\"SYSTEM\"}"); File.WriteAllText(registry, "Windows Registry Editor Version 5.00\n"); + EventInvestigationSession session = EventInvestigationSession.CreateEndpoint(PathOf("session"), new EventEndpointCapture { Inputs = new[] { + new EventEndpointInput { Path = path }, new EventEndpointInput { Path = facts, Kind = "Facts" }, new EventEndpointInput { Path = registry, Kind = "Registry" } } }); + EventEndpointAnalysis original = session.ReadEndpointAnalysis(); + File.WriteAllText(path, "source changed after capture"); + EventInvestigationSession opened = EventInvestigationSession.Open(session.DirectoryPath); + Assert.Equal(JsonSerializer.Serialize(original), JsonSerializer.Serialize(opened.ReplayEndpoint())); + Assert.Contains(opened.Manifest.Artifacts, artifact => artifact.Kind == "Registry" && artifact.Sensitivity == "Sensitive"); + string minimal = EventEndpointHtmlRenderer.Render(original); + Assert.DoesNotContain("applicationDetected: True", minimal); + Assert.Contains("applicationDetected: True", EventEndpointHtmlRenderer.Render(original, true)); + Assert.Empty(opened.Replay().Findings); + EventInvestigationArtifact input = opened.Manifest.Artifacts.First(artifact => artifact.Role == "input"); + File.AppendAllText(System.IO.Path.Combine(session.DirectoryPath, input.Path), "tampered"); + Assert.Throws(() => EventInvestigationSession.Open(session.DirectoryPath)); + } + + [Fact] + public void MixedEvidenceReplaysNativeDetectionAndEndpointAnalysisWithoutSyntheticEvents() { + string path = PathOf("mixed.log"); File.WriteAllText(path, Frame("AppId: 11111111-1111-1111-1111-111111111111 MSI exit code 1603"), new UTF8Encoding(false)); + DateTime instant = new(2026, 10, 9, 8, 0, 0, DateTimeKind.Utc); + EventObservation observation = EventObservation.Create(new SavedEventRecord { EventId = 42, ProviderName = "SyntheticProvider", Channel = "SyntheticChannel", Computer = "SyntheticHost", TimeCreatedUtc = instant }.ToEventObject("synthetic.evtx", EventReadMode.Full)); + EventDetectionPlan plan = EventDetectionPlan.Compile(new[] { new EventDetectionRule(new EventDetectionRuleDefinition { RuleId = "synthetic.event", Title = "Synthetic event", EventIds = new[] { 42 } }) }); + EventInvestigationSession session = EventInvestigationSession.CreateEndpoint(PathOf("mixed-session"), new EventEndpointCapture { Inputs = new[] { new EventEndpointInput { Path = path } } }, + new EventInvestigationManifest { StartUtc = instant.AddMinutes(-1), EndUtc = instant.AddMinutes(1), QueryIdentity = "synthetic-mixed" }, new[] { observation }, plan); + Assert.Equal(42, Assert.Single(Assert.Single(session.Replay().Findings).Evidence).EventId); + Assert.Equal("Failure", Assert.Single(session.ReplayEndpoint().Applications).Outcome); + Assert.Equal(1, session.Manifest.ObservationCount); + } + + [Fact] + public void CaptureBoundsCannotCommitACompleteSession() { + string path = PathOf("large.log"); File.WriteAllText(path, new string('a', 100)); + string directory = PathOf("rejected-session"); + Assert.Throws(() => EventInvestigationSession.CreateEndpoint(directory, new EventEndpointCapture { Inputs = new[] { new EventEndpointInput { Path = path } }, MaximumInputBytes = 50 })); + Assert.False(File.Exists(System.IO.Path.Combine(directory, "manifest.json"))); + } + + public void Dispose() { if (Directory.Exists(_root)) { Directory.Delete(_root, true); } } +} \ No newline at end of file diff --git a/Sources/EventViewerX/Diagnostics/EventDiagnosticCode.cs b/Sources/EventViewerX/Diagnostics/EventDiagnosticCode.cs new file mode 100644 index 00000000..93f2ed78 --- /dev/null +++ b/Sources/EventViewerX/Diagnostics/EventDiagnosticCode.cs @@ -0,0 +1,88 @@ +using System.Globalization; + +namespace EventViewerX; + +/// Context of a diagnostic number; the same bits can have unrelated meanings in different APIs. +public enum EventDiagnosticCodeKind { + /// No authoritative family is known. + Unknown, + /// Win32 status code. + Win32, + /// HRESULT bit layout. + HResult, + /// Windows Installer return code. + WindowsInstaller, + /// Application-defined process exit code. Requires the application's configured return-code map. + ProcessExit +} + +/// Independently authored interpretation, retaining the original representation and unknown outcomes. +public sealed class EventDiagnosticCode { + /// Unmodified input. + public string Original { get; private set; } = string.Empty; + /// Declared context. + public EventDiagnosticCodeKind Kind { get; private set; } + /// Unsigned 32-bit representation, or null for invalid input. + public uint? Value { get; private set; } + /// Canonical hexadecimal representation. + public string? Hex { get; private set; } + /// HRESULT facility, only for a declared HRESULT. + public int? Facility { get; private set; } + /// HRESULT severity bit, only for a declared HRESULT. + public bool? HResultFailure { get; private set; } + /// Embedded Win32 value for a Win32-facility HRESULT. + public uint? Win32Value { get; private set; } + /// Success, SuccessRestartRequired, SuccessRestartInitiated, Failure, or Unknown. + public string Outcome { get; private set; } = "Unknown"; + /// Meaning within the declared context; never an inferred root cause. + public string Explanation { get; private set; } = string.Empty; + /// Next artifact or configuration to inspect. + public string NextCheck { get; private set; } = string.Empty; + /// Primary documentation for the number family. + public string Reference { get; private set; } = string.Empty; + + /// Interprets signed decimal, unsigned decimal, or 0x-prefixed 32-bit hexadecimal. No context is guessed from magnitude. + public static EventDiagnosticCode Resolve(string text, EventDiagnosticCodeKind kind = EventDiagnosticCodeKind.Unknown) { + if (text == null) { throw new ArgumentNullException(nameof(text)); } + if (!Enum.IsDefined(typeof(EventDiagnosticCodeKind), kind)) { throw new ArgumentOutOfRangeException(nameof(kind)); } + var result = new EventDiagnosticCode { Original = text, Kind = kind }; + string input = text.Trim(); + uint value; + bool valid = input.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? uint.TryParse(input.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out value) + : uint.TryParse(input, NumberStyles.None, CultureInfo.InvariantCulture, out value); + if (!valid && int.TryParse(input, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out int signed)) { value = unchecked((uint)signed); valid = true; } + if (!valid) { result.Explanation = "This value is not a 32-bit diagnostic number."; result.NextCheck = "Retain the original value and determine its emitting API."; return result; } + result.Value = value; result.Hex = "0x" + value.ToString("X8", CultureInfo.InvariantCulture); + result.Explanation = "The numeric representation is known; its outcome needs the emitting API or configured return-code map."; + result.NextCheck = "Inspect the application's return-code mapping and the surrounding operation."; + if (kind == EventDiagnosticCodeKind.HResult) { + result.Facility = (int)((value >> 16) & 0x1fff); + result.HResultFailure = (value & 0x80000000) != 0; + result.Outcome = result.HResultFailure.Value ? "Failure" : "Success"; + result.Explanation = "The HRESULT severity bit indicates " + result.Outcome.ToLowerInvariant() + ". This is not a root-cause diagnosis."; + if ((value & 0xffff0000) == 0x80070000) { result.Win32Value = value & 0xffff; } + result.Reference = "https://learn.microsoft.com/en-us/windows/win32/com/structure-of-com-error-codes"; + result.NextCheck = "Inspect the failing API and its preceding diagnostic records."; + } else if (kind is EventDiagnosticCodeKind.Win32 or EventDiagnosticCodeKind.WindowsInstaller) { + result.Outcome = value == 0 ? "Success" : "Failure"; + result.Reference = "https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes"; + result.Explanation = value == 0 ? "The operation returned success." : "The API returned a nonzero status; inspect the operation that produced it."; + result.NextCheck = "Inspect the operation's diagnostic context and platform documentation."; + if (kind == EventDiagnosticCodeKind.WindowsInstaller) { + result.Reference = "https://learn.microsoft.com/en-us/windows/win32/msi/error-codes"; + switch (value) { + case 1641: result.Outcome = "SuccessRestartInitiated"; result.Explanation = "Windows Installer completed the operation and initiated a restart."; break; + case 3010: result.Outcome = "SuccessRestartRequired"; result.Explanation = "Windows Installer completed the operation and requires a restart."; break; + case 1603: result.Explanation = "Windows Installer reported installation failure. The number does not identify the failing custom action."; break; + case 1618: result.Explanation = "Another Windows Installer transaction is running."; break; + case 1619: result.Explanation = "Windows Installer could not open the supplied package."; break; + } + result.NextCheck = result.Outcome.StartsWith("SuccessRestart", StringComparison.Ordinal) + ? "Inspect reboot policy and post-restart detection before calling the application deployment complete." + : "Inspect the installer log and configured Intune return-code mapping."; + } + } + return result; + } +} \ No newline at end of file diff --git a/Sources/EventViewerX/Diagnostics/EventDiagnosticLogReader.Metadata.cs b/Sources/EventViewerX/Diagnostics/EventDiagnosticLogReader.Metadata.cs new file mode 100644 index 00000000..e6f12084 --- /dev/null +++ b/Sources/EventViewerX/Diagnostics/EventDiagnosticLogReader.Metadata.cs @@ -0,0 +1,75 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; + +namespace EventViewerX; + +public static partial class EventDiagnosticLogReader { + private static readonly Regex Attributes = new("(?[a-zA-Z]+)=\"(?[^\"]*)\"", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1)); + private static readonly Regex TimeOffset = new("^(?