Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions Sources/EventViewerX.Tests/TestEvtxQueryExecutor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using System;
using EventViewerX.Reports.Evtx;
using EventViewerX.Reports.Security;
using Xunit;

namespace EventViewerX.Tests;

public class TestEvtxQueryExecutor {
[Fact]
public void TryRead_ShouldFailForMissingFilePath() {
var request = new EvtxQueryRequest {
FilePath = string.Empty
};

var ok = EvtxQueryExecutor.TryRead(request, out _, out var failure);

Assert.False(ok);
Assert.NotNull(failure);
Assert.Equal(EvtxQueryFailureKind.InvalidArgument, failure!.Kind);
}

[Fact]
public void TryRead_ShouldFailForInvalidTimeRange() {
var request = new EvtxQueryRequest {
FilePath = "dummy.evtx",
StartTimeUtc = new DateTime(2026, 2, 10, 11, 0, 0, DateTimeKind.Utc),
EndTimeUtc = new DateTime(2026, 2, 10, 10, 0, 0, DateTimeKind.Utc)
};

var ok = EvtxQueryExecutor.TryRead(request, out _, out var failure);

Assert.False(ok);
Assert.NotNull(failure);
Assert.Equal(EvtxQueryFailureKind.InvalidArgument, failure!.Kind);
}

[Fact]
public void TryRead_ShouldFailForInvalidEventIds() {
var request = new EvtxQueryRequest {
FilePath = "dummy.evtx",
EventIds = new[] { 4624, -1 }
};

var ok = EvtxQueryExecutor.TryRead(request, out _, out var failure);

Assert.False(ok);
Assert.NotNull(failure);
Assert.Equal(EvtxQueryFailureKind.InvalidArgument, failure!.Kind);
}

[Fact]
public void TryRead_ShouldReturnNotFoundForMissingFile() {
var request = new EvtxQueryRequest {
FilePath = "C:/this/file/does/not/exist.evtx"
};

var ok = EvtxQueryExecutor.TryRead(request, out _, out var failure);

Assert.False(ok);
Assert.NotNull(failure);
Assert.Equal(EvtxQueryFailureKind.NotFound, failure!.Kind);
}

[Fact]
public void SecurityBuilder_TryBuildFromFile_ShouldSurfaceQueryFailure() {
var request = new EvtxQueryRequest {
FilePath = "C:/this/file/does/not/exist.evtx",
EventIds = new[] { 4625 },
ProviderName = "Microsoft-Windows-Security-Auditing"
};

var ok = SecurityFailedLogonsReportBuilder.TryBuildFromFile(
request,
includeSamples: false,
sampleSize: 10,
report: out _,
failure: out var failure);

Assert.False(ok);
Assert.NotNull(failure);
Assert.Equal(EvtxQueryFailureKind.NotFound, failure!.Kind);
}
}
131 changes: 131 additions & 0 deletions Sources/EventViewerX/Reports/Evtx/EvtxQueryExecutor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;

namespace EventViewerX.Reports.Evtx;

/// <summary>
/// Executes EVTX queries using a stable request/response contract.
/// </summary>
public static class EvtxQueryExecutor {
/// <summary>
/// Queries an EVTX file and returns either events or a typed failure.
/// </summary>
/// <param name="request">EVTX query request.</param>
/// <param name="result">Result object with queried events.</param>
/// <param name="failure">Failure details when query fails.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns><see langword="true"/> when query succeeds; otherwise <see langword="false"/>.</returns>
public static bool TryRead(
EvtxQueryRequest request,
out EvtxQueryResult result,
out EvtxQueryFailure? failure,
CancellationToken cancellationToken = default) {

if (request is null) {
result = new EvtxQueryResult();
failure = new EvtxQueryFailure {
Kind = EvtxQueryFailureKind.InvalidArgument,
Message = "request is required."
};
return false;
}

if (string.IsNullOrWhiteSpace(request.FilePath)) {
result = new EvtxQueryResult();
failure = new EvtxQueryFailure {
Kind = EvtxQueryFailureKind.InvalidArgument,
Message = "filePath is required."
};
return false;
}

if (request.StartTimeUtc.HasValue && request.EndTimeUtc.HasValue && request.StartTimeUtc.Value > request.EndTimeUtc.Value) {
result = new EvtxQueryResult();
failure = new EvtxQueryFailure {
Kind = EvtxQueryFailureKind.InvalidArgument,
Message = "startTimeUtc must be less than or equal to endTimeUtc."
};
return false;
}

if (request.MaxEvents < 0) {
result = new EvtxQueryResult();
failure = new EvtxQueryFailure {
Kind = EvtxQueryFailureKind.InvalidArgument,
Message = "maxEvents must be greater than or equal to 0."
};
return false;
}

if (request.EventIds is not null && request.EventIds.Any(static id => id <= 0)) {
result = new EvtxQueryResult();
failure = new EvtxQueryFailure {
Kind = EvtxQueryFailureKind.InvalidArgument,
Message = "eventIds must contain only positive values."
};
return false;
}

try {
var eventIds = request.EventIds is null ? null : new List<int>(request.EventIds);

var list = new List<EventObject>();
foreach (var ev in SearchEvents.QueryLogFile(
filePath: request.FilePath,
eventIds: eventIds,
providerName: request.ProviderName,
startTime: request.StartTimeUtc,
endTime: request.EndTimeUtc,
maxEvents: request.MaxEvents,
oldest: request.OldestFirst,
cancellationToken: cancellationToken)) {
cancellationToken.ThrowIfCancellationRequested();
list.Add(ev);
}

result = new EvtxQueryResult {
Events = list
};
failure = null;
return true;
} catch (ArgumentException ex) {
result = new EvtxQueryResult();
failure = new EvtxQueryFailure {
Kind = EvtxQueryFailureKind.InvalidArgument,
Message = ex.Message
};
return false;
} catch (FileNotFoundException ex) {
result = new EvtxQueryResult();
failure = new EvtxQueryFailure {
Kind = EvtxQueryFailureKind.NotFound,
Message = ex.Message
};
return false;
} catch (UnauthorizedAccessException ex) {
result = new EvtxQueryResult();
failure = new EvtxQueryFailure {
Kind = EvtxQueryFailureKind.AccessDenied,
Message = ex.Message
};
return false;
} catch (IOException ex) {
result = new EvtxQueryResult();
failure = new EvtxQueryFailure {
Kind = EvtxQueryFailureKind.IoError,
Message = ex.Message
};
return false;
} catch (Exception ex) {
result = new EvtxQueryResult();
failure = new EvtxQueryFailure {
Kind = EvtxQueryFailureKind.Exception,
Message = ex.Message
};
return false;
}
}
}
32 changes: 32 additions & 0 deletions Sources/EventViewerX/Reports/Evtx/EvtxQueryFailure.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace EventViewerX.Reports.Evtx;

/// <summary>
/// Canonical error kind produced by EVTX query execution.
/// </summary>
public enum EvtxQueryFailureKind {
/// <summary>Invalid request arguments.</summary>
InvalidArgument,
/// <summary>Target EVTX file was not found.</summary>
NotFound,
/// <summary>Access to target EVTX file was denied.</summary>
AccessDenied,
/// <summary>I/O error occurred while reading EVTX file.</summary>
IoError,
/// <summary>Unexpected failure.</summary>
Exception
}

/// <summary>
/// Failure payload produced by EVTX query execution.
/// </summary>
public sealed class EvtxQueryFailure {
/// <summary>
/// Gets or sets failure kind.
/// </summary>
public EvtxQueryFailureKind Kind { get; set; }

/// <summary>
/// Gets or sets failure message.
/// </summary>
public string Message { get; set; } = string.Empty;
}
44 changes: 44 additions & 0 deletions Sources/EventViewerX/Reports/Evtx/EvtxQueryRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;

namespace EventViewerX.Reports.Evtx;

/// <summary>
/// Query contract for reading events from an EVTX file.
/// </summary>
public sealed class EvtxQueryRequest {
/// <summary>
/// Gets or sets the EVTX file path (absolute or relative).
/// </summary>
public string FilePath { get; set; } = string.Empty;

/// <summary>
/// Gets or sets optional event IDs to include.
/// </summary>
public IReadOnlyList<int>? EventIds { get; set; }

/// <summary>
/// Gets or sets an optional provider name filter.
/// </summary>
public string? ProviderName { get; set; }

/// <summary>
/// Gets or sets the optional UTC lower bound.
/// </summary>
public DateTime? StartTimeUtc { get; set; }

/// <summary>
/// Gets or sets the optional UTC upper bound.
/// </summary>
public DateTime? EndTimeUtc { get; set; }

/// <summary>
/// Gets or sets the maximum events to return (0 means unlimited).
/// </summary>
public int MaxEvents { get; set; }

/// <summary>
/// Gets or sets a value indicating whether events should be read oldest-first.
/// </summary>
public bool OldestFirst { get; set; }
}
13 changes: 13 additions & 0 deletions Sources/EventViewerX/Reports/Evtx/EvtxQueryResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System.Collections.Generic;

namespace EventViewerX.Reports.Evtx;

/// <summary>
/// Query result for EVTX reads.
/// </summary>
public sealed class EvtxQueryResult {
/// <summary>
/// Gets or sets queried events.
/// </summary>
public IReadOnlyList<EventObject> Events { get; set; } = new List<EventObject>();
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Threading;
using EventViewerX.Reports.Evtx;
using EventViewerX.Rules.ActiveDirectory;

namespace EventViewerX.Reports.Security;
Expand Down Expand Up @@ -120,6 +121,35 @@ public static SecurityAccountLockoutsReport BuildFromEvents(
return b.Build();
}

/// <summary>
/// Builds a report directly from an EVTX query request.
/// </summary>
/// <param name="request">EVTX query request.</param>
/// <param name="includeSamples">When true, captures up to <paramref name="sampleSize"/> sample events.</param>
/// <param name="sampleSize">Maximum sample events to capture.</param>
/// <param name="report">Built report when successful.</param>
/// <param name="failure">Failure details when query fails.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns><see langword="true"/> when query succeeds; otherwise <see langword="false"/>.</returns>
public static bool TryBuildFromFile(
EvtxQueryRequest request,
bool includeSamples,
int sampleSize,
out SecurityAccountLockoutsReport report,
out EvtxQueryFailure? failure,
CancellationToken cancellationToken = default) {

if (!EvtxQueryExecutor.TryRead(request, out var queried, out failure, cancellationToken)) {
report = new SecurityAccountLockoutsReport();
return false;
}

var b = new SecurityAccountLockoutsReportBuilder(includeSamples, sampleSize);
b.AddRange(queried.Events, cancellationToken);
report = b.Build();
return true;
}

/// <summary>
/// Builds a report snapshot.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Threading;
using EventViewerX.Reports.Evtx;
using EventViewerX.Rules.ActiveDirectory;

namespace EventViewerX.Reports.Security;
Expand Down Expand Up @@ -141,6 +142,35 @@ public static SecurityFailedLogonsReport BuildFromEvents(
return b.Build();
}

/// <summary>
/// Builds a report directly from an EVTX query request.
/// </summary>
/// <param name="request">EVTX query request.</param>
/// <param name="includeSamples">When true, captures up to <paramref name="sampleSize"/> sample events.</param>
/// <param name="sampleSize">Maximum sample events to capture.</param>
/// <param name="report">Built report when successful.</param>
/// <param name="failure">Failure details when query fails.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns><see langword="true"/> when query succeeds; otherwise <see langword="false"/>.</returns>
public static bool TryBuildFromFile(
EvtxQueryRequest request,
bool includeSamples,
int sampleSize,
out SecurityFailedLogonsReport report,
out EvtxQueryFailure? failure,
CancellationToken cancellationToken = default) {

if (!EvtxQueryExecutor.TryRead(request, out var queried, out failure, cancellationToken)) {
report = new SecurityFailedLogonsReport();
return false;
}

var b = new SecurityFailedLogonsReportBuilder(includeSamples, sampleSize);
b.AddRange(queried.Events, cancellationToken);
report = b.Build();
return true;
}

/// <summary>
/// Builds a report snapshot.
/// </summary>
Expand Down
Loading
Loading