diff --git a/src/Api/ShieldChecker.BackendApi/Program.cs b/src/Api/ShieldChecker.BackendApi/Program.cs index f00a5b6..ae43a9e 100644 --- a/src/Api/ShieldChecker.BackendApi/Program.cs +++ b/src/Api/ShieldChecker.BackendApi/Program.cs @@ -78,6 +78,11 @@ // ── Audit Service ───────────────────────────────────────────────────────────── builder.Services.AddScoped(); +// ── Background Services ────────────────────────────────────────────────────── +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); + var app = builder.Build(); // Initialise / validate the database on startup diff --git a/src/Api/ShieldChecker.BackendApi/Services/AutoSchedulerService.cs b/src/Api/ShieldChecker.BackendApi/Services/AutoSchedulerService.cs new file mode 100644 index 0000000..48babbc --- /dev/null +++ b/src/Api/ShieldChecker.BackendApi/Services/AutoSchedulerService.cs @@ -0,0 +1,279 @@ +using Microsoft.EntityFrameworkCore; +using ShieldChecker.DataAccess; +using ShieldChecker.DataAccess.Models; + +namespace ShieldChecker.BackendApi.Services +{ + /// + /// Background service that evaluates AutoSchedule entries every 60 seconds and + /// creates queued TestJob records for schedules whose NextExecution has passed. + /// Uses a SchedulerMutex row to prevent concurrent schedule evaluation across replicas. + /// + public sealed class AutoSchedulerService : BackgroundService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + /// Maximum age of a mutex before it is considered stale and can be reclaimed. + private static readonly TimeSpan MutexTimeout = TimeSpan.FromMinutes(5); + + public AutoSchedulerService(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("AutoSchedulerService started."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await EvaluateSchedulesAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "AutoSchedulerService encountered an error during schedule evaluation."); + } + + await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken); + } + + _logger.LogInformation("AutoSchedulerService stopped."); + } + + internal async Task EvaluateSchedulesAsync(CancellationToken ct) + { + using var scope = _scopeFactory.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + // Try to acquire the scheduler mutex + if (!await TryAcquireMutexAsync(context, ct)) + { + _logger.LogTrace("AutoScheduler mutex is held by another instance. Skipping this cycle."); + return; + } + + try + { + var now = DateTime.UtcNow; + var dueSchedules = await context.AutoSchedule + .Include(s => s.TestDefinitions) + .Where(s => s.Enabled && s.NextExecution <= now) + .ToListAsync(ct); + + if (dueSchedules.Count == 0) + { + _logger.LogTrace("No due schedules found."); + return; + } + + _logger.LogInformation("Found {Count} due schedule(s) to process.", dueSchedules.Count); + + foreach (var schedule in dueSchedules) + { + await ProcessScheduleAsync(context, schedule, now, ct); + } + } + finally + { + await ReleaseMutexAsync(context, ct); + } + } + + private async Task ProcessScheduleAsync(ShieldCheckerContext context, AutoSchedule schedule, DateTime now, CancellationToken ct) + { + _logger.LogInformation("Processing schedule '{Name}' (ID: {Id}).", schedule.Name, schedule.ID); + + // Determine eligible test definitions + var candidates = schedule.TestDefinitions + .Where(t => t.Enabled == true) + .ToList(); + + // If no specific tests are selected, select all enabled tests matching filters + if (candidates.Count == 0) + { + var query = context.UseCaseTests.Where(t => t.Enabled == true); + + if (schedule.FilterOperatingSystem.HasValue) + { + query = query.Where(t => t.OperatingSystem == schedule.FilterOperatingSystem.Value); + } + + candidates = await query.ToListAsync(ct); + } + else if (schedule.FilterOperatingSystem.HasValue) + { + candidates = candidates + .Where(t => t.OperatingSystem == schedule.FilterOperatingSystem.Value) + .ToList(); + } + + // Apply execution filter + if (schedule.FilterExecution.HasValue && schedule.FilterExecution.Value != FilterExecution.None) + { + candidates = await ApplyExecutionFilterAsync(context, candidates, schedule.FilterExecution.Value, now, ct); + } + + // Apply random count filter + if (schedule.FilterRandomCount.HasValue && schedule.FilterRandomCount.Value > 0 && candidates.Count > schedule.FilterRandomCount.Value) + { + candidates = candidates + .OrderBy(_ => Guid.NewGuid()) + .Take(schedule.FilterRandomCount.Value) + .ToList(); + } + + // Create queued jobs + int jobsCreated = 0; + foreach (var test in candidates) + { + var job = new TestJob + { + UseCaseID = test.ID, + Created = now, + Modified = now, + Status = JobStatus.Queued, + Result = JobResult.Undetermined, + SchedulerLog = $"Auto-scheduled by '{schedule.Name}' (ID: {schedule.ID})" + }; + context.TestJobs.Add(job); + jobsCreated++; + } + + // Advance NextExecution + schedule.NextExecution = CalculateNextExecution(schedule.NextExecution, schedule.Type); + + await context.SaveChangesAsync(ct); + + _logger.LogInformation( + "Schedule '{Name}' (ID: {Id}): created {Count} job(s). Next execution: {Next}.", + schedule.Name, schedule.ID, jobsCreated, schedule.NextExecution); + } + + private static async Task> ApplyExecutionFilterAsync( + ShieldCheckerContext context, + List candidates, + FilterExecution filter, + DateTime now, + CancellationToken ct) + { + var cutoff = filter switch + { + FilterExecution.OnlyWhenNoSuccessJobInPastWeek => now.AddDays(-7), + FilterExecution.OnlyWhenNoSuccessJobInPastMonth => now.AddDays(-30), + FilterExecution.OnlyWhenNoJobInPastWeek => now.AddDays(-7), + FilterExecution.OnlyWhenNoJobInPastMonth => now.AddDays(-30), + _ => now + }; + + bool successOnly = filter == FilterExecution.OnlyWhenNoSuccessJobInPastWeek + || filter == FilterExecution.OnlyWhenNoSuccessJobInPastMonth; + + var candidateIds = candidates.Select(c => c.ID).ToList(); + + // Find test IDs that already have matching jobs in the time window + IQueryable jobQuery = context.TestJobs + .Where(j => candidateIds.Contains(j.UseCaseID) && j.Created >= cutoff); + + if (successOnly) + { + jobQuery = jobQuery.Where(j => j.Result == JobResult.Success); + } + + var testIdsWithJobs = await jobQuery + .Select(j => j.UseCaseID) + .Distinct() + .ToListAsync(ct); + + return candidates.Where(c => !testIdsWithJobs.Contains(c.ID)).ToList(); + } + + internal static DateTime CalculateNextExecution(DateTime current, AutoScheduleType type) + { + return type switch + { + AutoScheduleType.Daily => current.AddDays(1), + AutoScheduleType.Weekly => current.AddDays(7), + AutoScheduleType.Monthly => current.AddMonths(1), + AutoScheduleType.Quarterly => current.AddMonths(3), + _ => current.AddDays(1) + }; + } + + private async Task TryAcquireMutexAsync(ShieldCheckerContext context, CancellationToken ct) + { + var mutex = await context.SchedulerMutex + .FirstOrDefaultAsync(m => m.SchedulerType == SchedulerType.Worker, ct); + + var now = DateTime.UtcNow; + string owner = Environment.MachineName; + + if (mutex == null) + { + context.SchedulerMutex.Add(new SchedulerMutex + { + Owner = owner, + SchedulerType = SchedulerType.Worker, + Start = now + }); + try + { + await context.SaveChangesAsync(ct); + return true; + } + catch (DbUpdateException) + { + // Another instance beat us to it + return false; + } + } + + // If the mutex is stale (owner crashed), reclaim it + if (now - mutex.Start > MutexTimeout) + { + _logger.LogWarning("Reclaiming stale AutoScheduler mutex from '{Owner}' (started {Start}).", + mutex.Owner, mutex.Start); + mutex.Owner = owner; + mutex.Start = now; + try + { + await context.SaveChangesAsync(ct); + return true; + } + catch (DbUpdateConcurrencyException) + { + return false; + } + } + + // Mutex is held by another instance and not stale + return false; + } + + private async Task ReleaseMutexAsync(ShieldCheckerContext context, CancellationToken ct) + { + var mutex = await context.SchedulerMutex + .FirstOrDefaultAsync(m => m.SchedulerType == SchedulerType.Worker, ct); + + if (mutex != null) + { + context.SchedulerMutex.Remove(mutex); + try + { + await context.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) + { + _logger.LogWarning(ex, "Failed to release AutoScheduler mutex."); + } + } + } + } +} diff --git a/src/Api/ShieldChecker.BackendApi/Services/JobTimeoutService.cs b/src/Api/ShieldChecker.BackendApi/Services/JobTimeoutService.cs new file mode 100644 index 0000000..7ea9dd0 --- /dev/null +++ b/src/Api/ShieldChecker.BackendApi/Services/JobTimeoutService.cs @@ -0,0 +1,97 @@ +using Microsoft.EntityFrameworkCore; +using ShieldChecker.DataAccess; +using ShieldChecker.DataAccess.Models; + +namespace ShieldChecker.BackendApi.Services +{ + /// + /// Background service that periodically scans for jobs exceeding the configured + /// and marks them as Error with an Undetermined result. + /// + public sealed class JobTimeoutService : BackgroundService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public JobTimeoutService(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("JobTimeoutService started."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await EnforceTimeoutsAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "JobTimeoutService encountered an error."); + } + + await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken); + } + + _logger.LogInformation("JobTimeoutService stopped."); + } + + internal async Task EnforceTimeoutsAsync(CancellationToken ct) + { + using var scope = _scopeFactory.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var settings = await context.Settings.FirstOrDefaultAsync(ct); + if (settings == null || settings.JobTimeout <= 0) + { + _logger.LogTrace("Job timeout not configured or is zero. Skipping."); + return; + } + + var now = DateTime.UtcNow; + var cutoff = now.AddMinutes(-settings.JobTimeout); + + // Find jobs that are in active processing states and have exceeded the timeout + var timedOutJobs = await context.TestJobs + .Where(j => + (j.Status == JobStatus.WaitingForMDE + || j.Status == JobStatus.WaitingForDetection + || j.Status == JobStatus.Queued) + && j.Created < cutoff) + .ToListAsync(ct); + + if (timedOutJobs.Count == 0) + { + _logger.LogTrace("No timed-out jobs found."); + return; + } + + _logger.LogInformation("Found {Count} timed-out job(s) (timeout: {Timeout} min).", + timedOutJobs.Count, settings.JobTimeout); + + foreach (var job in timedOutJobs) + { + job.Status = JobStatus.Error; + job.Result = JobResult.Undetermined; + job.Modified = now; + + string timestamp = now.ToString("yyyy-MM-dd HH:mm:ss"); + string logEntry = $"[{timestamp}] Job timed out after {settings.JobTimeout} minutes.{Environment.NewLine}"; + job.SchedulerLog = string.IsNullOrEmpty(job.SchedulerLog) ? logEntry : job.SchedulerLog + logEntry; + + _logger.LogWarning("Job {JobId} timed out (created: {Created}, timeout: {Timeout} min).", + job.ID, job.Created, settings.JobTimeout); + } + + await context.SaveChangesAsync(ct); + } + } +} diff --git a/src/Api/ShieldChecker.BackendApi/Services/MdeAlertDetectionService.cs b/src/Api/ShieldChecker.BackendApi/Services/MdeAlertDetectionService.cs new file mode 100644 index 0000000..793ea0e --- /dev/null +++ b/src/Api/ShieldChecker.BackendApi/Services/MdeAlertDetectionService.cs @@ -0,0 +1,312 @@ +using Microsoft.EntityFrameworkCore; +using ShieldChecker.DataAccess; +using ShieldChecker.DataAccess.Models; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace ShieldChecker.BackendApi.Services +{ + /// + /// Background service that monitors jobs in WaitingForDetection status and queries + /// the Microsoft Graph Security API for matching alerts from Microsoft Defender for Endpoint. + /// Transitions jobs through WaitingForDetection → Completed/ReviewPending based on detection results. + /// + public sealed class MdeAlertDetectionService : BackgroundService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + /// + /// Minimum time to wait after a job enters WaitingForDetection before checking for alerts. + /// MDE typically needs time to process and generate alerts. + /// + private static readonly TimeSpan DetectionDelay = TimeSpan.FromMinutes(15); + + /// + /// Maximum time window to search for alerts after the test was executed. + /// + private static readonly TimeSpan AlertSearchWindow = TimeSpan.FromHours(4); + + public MdeAlertDetectionService( + IServiceScopeFactory scopeFactory, + ILogger logger, + IConfiguration configuration) + { + _scopeFactory = scopeFactory; + _logger = logger; + _configuration = configuration; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("MdeAlertDetectionService started."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await CheckForAlertsAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "MdeAlertDetectionService encountered an error."); + } + + await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken); + } + + _logger.LogInformation("MdeAlertDetectionService stopped."); + } + + internal async Task CheckForAlertsAsync(CancellationToken ct) + { + using var scope = _scopeFactory.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var now = DateTime.UtcNow; + var readyThreshold = now - DetectionDelay; + + // Find jobs waiting for detection that have waited long enough + var jobs = await context.TestJobs + .Include(j => j.UseCase) + .Where(j => j.Status == JobStatus.WaitingForDetection && j.WorkerEnd != null && j.WorkerEnd <= readyThreshold) + .ToListAsync(ct); + + if (jobs.Count == 0) + { + _logger.LogTrace("No jobs ready for alert detection check."); + return; + } + + _logger.LogInformation("Checking MDE alerts for {Count} job(s).", jobs.Count); + + string? accessToken = await AcquireGraphTokenAsync(ct); + if (string.IsNullOrEmpty(accessToken)) + { + _logger.LogWarning("Could not acquire Microsoft Graph token. Skipping alert detection cycle."); + return; + } + + var settings = await context.Settings.FirstOrDefaultAsync(ct); + bool reviewMode = settings?.JobReview ?? false; + + foreach (var job in jobs) + { + try + { + await EvaluateJobAlertsAsync(context, job, accessToken, reviewMode, now, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to evaluate alerts for job {JobId}.", job.ID); + AppendSchedulerLog(job, $"Error checking MDE alerts: {ex.Message}"); + } + } + + await context.SaveChangesAsync(ct); + } + + private async Task EvaluateJobAlertsAsync( + ShieldCheckerContext context, + TestJob job, + string accessToken, + bool reviewMode, + DateTime now, + CancellationToken ct) + { + if (job.UseCase == null) + { + _logger.LogWarning("Job {JobId} has no associated test definition.", job.ID); + return; + } + + var expectedTitles = job.UseCase.ExpectedAlertTitle + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + + var searchStart = job.WorkerStart ?? job.Created; + var searchEnd = searchStart + AlertSearchWindow; + + // If we've exceeded the search window, finalize the job + bool windowExpired = now > searchEnd; + + var alerts = await QueryMdeAlertsAsync( + accessToken, + job.DefenderMachineId, + searchStart, + searchEnd > now ? now : searchEnd, + ct); + + // Check for matching alerts + var matchedAlerts = new List(); + var otherAlerts = new List(); + + foreach (var alert in alerts) + { + bool isExpected = expectedTitles.Any(expected => + alert.Title.Contains(expected, StringComparison.OrdinalIgnoreCase)); + + if (isExpected) + matchedAlerts.Add(alert.Title); + else + otherAlerts.Add(alert.Title); + } + + if (matchedAlerts.Count > 0) + { + // Expected alert was detected + job.DetectedAlerts = JsonSerializer.Serialize(new + { + matched = matchedAlerts, + other = otherAlerts + }); + + if (otherAlerts.Count > 0) + job.Result = JobResult.SuccessWithOtherDetection; + else + job.Result = JobResult.Success; + + job.Status = reviewMode ? JobStatus.ReviewPending : JobStatus.Completed; + AppendSchedulerLog(job, $"MDE alert detected: {string.Join(", ", matchedAlerts)}"); + _logger.LogInformation("Job {JobId}: Expected alert(s) detected. Result: {Result}.", job.ID, job.Result); + } + else if (windowExpired) + { + // Search window expired without finding the expected alert + job.Result = otherAlerts.Count > 0 ? JobResult.SuccessWithOtherDetection : JobResult.Failed; + job.DetectedAlerts = otherAlerts.Count > 0 + ? JsonSerializer.Serialize(new { matched = Array.Empty(), other = otherAlerts }) + : null; + job.Status = reviewMode ? JobStatus.ReviewPending : JobStatus.Completed; + AppendSchedulerLog(job, "MDE detection window expired. Expected alert not found."); + _logger.LogInformation("Job {JobId}: Detection window expired. Result: {Result}.", job.ID, job.Result); + } + else + { + // Still within the search window, keep waiting + _logger.LogTrace("Job {JobId}: Still within detection window. Will check again.", job.ID); + } + + job.Modified = now; + } + + internal async Task> QueryMdeAlertsAsync( + string accessToken, + string? machineId, + DateTime from, + DateTime to, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(machineId)) + return new List(); + + using var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + // Use Microsoft Graph Security API v2 alerts endpoint with OData filter + string fromStr = from.ToString("yyyy-MM-ddTHH:mm:ssZ"); + string toStr = to.ToString("yyyy-MM-ddTHH:mm:ssZ"); + + string filter = $"evidence/any(e: e/microsoft.graph.security.deviceEvidence/mdeDeviceId eq '{machineId}') " + + $"and createdDateTime ge {fromStr} and createdDateTime le {toStr}"; + + string url = $"https://graph.microsoft.com/v1.0/security/alerts_v2?$filter={Uri.EscapeDataString(filter)}&$select=title,createdDateTime,severity,status"; + + try + { + var response = await httpClient.GetAsync(url, ct); + if (!response.IsSuccessStatusCode) + { + string body = await response.Content.ReadAsStringAsync(ct); + _logger.LogWarning("Graph API returned {Status} when querying alerts: {Body}", + response.StatusCode, body); + return new List(); + } + + var json = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(json); + var results = new List(); + + if (doc.RootElement.TryGetProperty("value", out var alertsArray)) + { + foreach (var alertElement in alertsArray.EnumerateArray()) + { + results.Add(new AlertInfo + { + Title = alertElement.GetProperty("title").GetString() ?? string.Empty, + CreatedDateTime = alertElement.GetProperty("createdDateTime").GetString() ?? string.Empty, + Severity = alertElement.TryGetProperty("severity", out var sev) ? sev.GetString() ?? string.Empty : string.Empty, + }); + } + } + + return results; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to query MDE alerts from Graph API."); + return new List(); + } + } + + private async Task AcquireGraphTokenAsync(CancellationToken ct) + { + string? tenantId = _configuration["AzureAd:TenantId"]; + string? clientId = _configuration["MdeDetection:ClientId"] ?? _configuration["AzureAd:ClientId"]; + string? clientSecret = _configuration["MdeDetection:ClientSecret"] ?? _configuration["AzureAd:ClientSecret"]; + + if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret)) + { + _logger.LogWarning("MDE detection credentials not configured. Set AzureAd:TenantId and MdeDetection:ClientId/ClientSecret."); + return null; + } + + try + { + using var httpClient = new HttpClient(); + var tokenRequest = new Dictionary + { + ["grant_type"] = "client_credentials", + ["client_id"] = clientId, + ["client_secret"] = clientSecret, + ["scope"] = "https://graph.microsoft.com/.default" + }; + + var response = await httpClient.PostAsync( + $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token", + new FormUrlEncodedContent(tokenRequest), + ct); + + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.GetProperty("access_token").GetString(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to acquire Microsoft Graph access token."); + return null; + } + } + + private static void AppendSchedulerLog(TestJob job, string message) + { + string timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss"); + string entry = $"[{timestamp}] {message}{Environment.NewLine}"; + job.SchedulerLog = string.IsNullOrEmpty(job.SchedulerLog) ? entry : job.SchedulerLog + entry; + } + } + + /// Lightweight DTO for MDE alert data from Graph API. + internal sealed class AlertInfo + { + public string Title { get; set; } = string.Empty; + public string CreatedDateTime { get; set; } = string.Empty; + public string Severity { get; set; } = string.Empty; + } +} diff --git a/src/Api/ShieldChecker.BackendApi/ShieldChecker.BackendApi.csproj b/src/Api/ShieldChecker.BackendApi/ShieldChecker.BackendApi.csproj index 1265f91..d66e9b4 100644 --- a/src/Api/ShieldChecker.BackendApi/ShieldChecker.BackendApi.csproj +++ b/src/Api/ShieldChecker.BackendApi/ShieldChecker.BackendApi.csproj @@ -22,4 +22,8 @@ + + + + diff --git a/src/Api/ShieldChecker.HostServiceApi/Controllers/JobController.cs b/src/Api/ShieldChecker.HostServiceApi/Controllers/JobController.cs index bda1e1d..4d10856 100644 --- a/src/Api/ShieldChecker.HostServiceApi/Controllers/JobController.cs +++ b/src/Api/ShieldChecker.HostServiceApi/Controllers/JobController.cs @@ -70,6 +70,33 @@ public async Task GetJob([FromQuery] string workername, Cancellat job.Modified = DateTime.UtcNow; await _context.SaveChangesAsync(ct); + // Determine credentials based on executor user type + string? username = null; + string? password = null; + string? domain = null; + + switch (job.UseCase.ExecutorUserType) + { + case ExecutorUserType.local_admin: + // Local admin credentials are provided by the HostService configuration. + // The HostService uses its own HyperV:AdminUsername/AdminPassword for local admin execution. + break; + case ExecutorUserType.domain_admin: + username = $"Administrator@{settings.DomainFQDN}"; + domain = settings.DomainFQDN; + // Password is managed by the DC provisioning process + break; + case ExecutorUserType.domain_user: + username = $"testuser@{settings.DomainFQDN}"; + domain = settings.DomainFQDN; + // Password is managed by the DC provisioning process + break; + case ExecutorUserType.System: + default: + // Run as SYSTEM – no credentials needed + break; + } + var response = new { job.UseCase.ID, @@ -81,9 +108,9 @@ public async Task GetJob([FromQuery] string workername, Cancellat OperatingSystem = (int)job.UseCase.OperatingSystem, ExecutorSystemType = (int)job.UseCase.ExecutorSystemType, ExecutorUserType = (int)job.UseCase.ExecutorUserType, - Username = (string?)null, - Password = (string?)null, - Domain = (string?)null + Username = username, + Password = password, + Domain = domain }; _logger.LogInformation("Assigned job {JobId} to worker '{Worker}'.", job.ID, safeWorkerName); diff --git a/src/Api/ShieldChecker.HostServiceApi/Controllers/JobUpdaterController.cs b/src/Api/ShieldChecker.HostServiceApi/Controllers/JobUpdaterController.cs index ce50503..71953da 100644 --- a/src/Api/ShieldChecker.HostServiceApi/Controllers/JobUpdaterController.cs +++ b/src/Api/ShieldChecker.HostServiceApi/Controllers/JobUpdaterController.cs @@ -52,7 +52,7 @@ public async Task UpdateJob([FromQuery] string workername, [FromB return NotFound(); } - job.Status = update.Status.HasValue ? (JobStatus)update.Status.Value : JobStatus.Completed; + job.Status = update.Status.HasValue ? (JobStatus)update.Status.Value : JobStatus.WaitingForDetection; job.TestOutput = update.TestOutput; job.SchedulerLog = update.ExecutorOutput; job.WorkerEnd = DateTime.UtcNow; diff --git a/src/Api/ShieldChecker.HostServiceApi/Controllers/SettingsController.cs b/src/Api/ShieldChecker.HostServiceApi/Controllers/SettingsController.cs index 5b3371f..50c8d43 100644 --- a/src/Api/ShieldChecker.HostServiceApi/Controllers/SettingsController.cs +++ b/src/Api/ShieldChecker.HostServiceApi/Controllers/SettingsController.cs @@ -43,7 +43,9 @@ public async Task Get(CancellationToken ct) settings.DcVMCpuCount, settings.DcVMMemoryMB, settings.DcVMImage, - settings.VMStoragePath + settings.VMStoragePath, + MdeWindowsOnboardingScript = settings.MDEWindowsOnboardingScript, + MdeLinuxOnboardingScript = settings.MDELinuxOnboardingScript }; return Ok(result); diff --git a/src/HostService/ShieldChecker.HostService.Core/HostEngine.cs b/src/HostService/ShieldChecker.HostService.Core/HostEngine.cs index 8078e0f..67ecac5 100644 --- a/src/HostService/ShieldChecker.HostService.Core/HostEngine.cs +++ b/src/HostService/ShieldChecker.HostService.Core/HostEngine.cs @@ -133,6 +133,21 @@ public void ExecuteJobInVm( hyperV.CreateVm(vmName, imagePath, settings.WorkerVMCpuCount, settings.WorkerVMMemoryMB, settings.VMStoragePath); hyperV.StartVmAndWait(vmName); + // Run MDE onboarding script before test execution so Defender can detect attacks + string onboardingScript = settings.GetMdeOnboardingScriptForOs(jobDefinition.OperatingSystem); + if (!string.IsNullOrWhiteSpace(onboardingScript)) + { + _logger.LogInformation("Executing MDE onboarding script in VM '{VmName}'.", vmName); + hyperV.RunScriptInVm(vmName, onboardingScript, adminUsername, adminPassword); + _logger.LogInformation("MDE onboarding completed in VM '{VmName}'. Waiting for agent registration.", vmName); + // Allow time for the MDE agent to register with the service + Thread.Sleep(TimeSpan.FromSeconds(60)); + } + else + { + _logger.LogWarning("No MDE onboarding script configured for OS {Os}. MDE will not be active on the VM.", jobDefinition.OperatingSystem); + } + _logger.LogInformation("Executing prerequisites script in VM '{VmName}'.", vmName); hyperV.RunScriptInVm(vmName, jobDefinition.ScriptPrerequisites, adminUsername, adminPassword); diff --git a/src/HostService/ShieldChecker.HostService.Core/VmSettings.cs b/src/HostService/ShieldChecker.HostService.Core/VmSettings.cs index 1c450b4..bac9cd5 100644 --- a/src/HostService/ShieldChecker.HostService.Core/VmSettings.cs +++ b/src/HostService/ShieldChecker.HostService.Core/VmSettings.cs @@ -31,6 +31,12 @@ public class VmSettings [JsonPropertyName("vmStoragePath")] public string VMStoragePath { get; set; } = string.Empty; + [JsonPropertyName("mdeWindowsOnboardingScript")] + public string MdeWindowsOnboardingScript { get; set; } = string.Empty; + + [JsonPropertyName("mdeLinuxOnboardingScript")] + public string MdeLinuxOnboardingScript { get; set; } = string.Empty; + /// /// Returns the VHDX image path for the given operating system. /// @@ -40,5 +46,15 @@ public class VmSettings OperatingSystem.Linux => WorkerVMLinuxImage, _ => throw new NotSupportedException($"No image configured for OS {os}.") }; + + /// + /// Returns the MDE onboarding script content for the given operating system. + /// + public string GetMdeOnboardingScriptForOs(OperatingSystem os) => os switch + { + OperatingSystem.Windows => MdeWindowsOnboardingScript, + OperatingSystem.Linux => MdeLinuxOnboardingScript, + _ => string.Empty + }; } } diff --git a/src/HostService/ShieldChecker.HostService/Worker.cs b/src/HostService/ShieldChecker.HostService/Worker.cs index b8b7493..fc9b586 100644 --- a/src/HostService/ShieldChecker.HostService/Worker.cs +++ b/src/HostService/ShieldChecker.HostService/Worker.cs @@ -107,7 +107,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { ExecutorOutput = executorOutput, TestOutput = testOutput, - Status = 2 + Status = 2 // WaitingForDetection – the MDE alert detection service will handle final completion }; await engine.UpdateJobAsync(update, stoppingToken); } diff --git a/tests/ShieldChecker.BackendApi.Tests/AutoSchedulerServiceTests.cs b/tests/ShieldChecker.BackendApi.Tests/AutoSchedulerServiceTests.cs new file mode 100644 index 0000000..36d6990 --- /dev/null +++ b/tests/ShieldChecker.BackendApi.Tests/AutoSchedulerServiceTests.cs @@ -0,0 +1,289 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using ShieldChecker.BackendApi.Services; +using ShieldChecker.DataAccess; +using ShieldChecker.DataAccess.Models; + +namespace ShieldChecker.BackendApi.Tests +{ + public class AutoSchedulerServiceTests : IDisposable + { + private readonly ShieldCheckerContext _context; + private readonly ServiceProvider _serviceProvider; + private readonly AutoSchedulerService _service; + + public AutoSchedulerServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var services = new ServiceCollection(); + services.AddDbContext(o => + o.UseInMemoryDatabase(Guid.NewGuid().ToString())); + _serviceProvider = services.BuildServiceProvider(); + + _context = new ShieldCheckerContext(options); + SeedData(); + + // Create a scope factory that returns our known context + var scopeFactory = new TestScopeFactory(_context); + _service = new AutoSchedulerService(scopeFactory, NullLogger.Instance); + } + + private void SeedData() + { + var user = new UserInfo("System", "system@test.local", Guid.Empty); + _context.UserInfo.Add(user); + _context.SaveChanges(); + + _context.UseCaseTests.AddRange( + new TestDefinition + { + ID = 1, Name = "Test Win 1", Enabled = true, + OperatingSystem = DataAccess.Models.OperatingSystem.Windows, + ExpectedAlertTitle = "Alert 1", ScriptTest = "echo test", + CreatedBy = user, ModifiedBy = user, + Created = DateTime.UtcNow, Modified = DateTime.UtcNow + }, + new TestDefinition + { + ID = 2, Name = "Test Linux 1", Enabled = true, + OperatingSystem = DataAccess.Models.OperatingSystem.Linux, + ExpectedAlertTitle = "Alert 2", ScriptTest = "echo test", + CreatedBy = user, ModifiedBy = user, + Created = DateTime.UtcNow, Modified = DateTime.UtcNow + }, + new TestDefinition + { + ID = 3, Name = "Disabled Test", Enabled = false, + OperatingSystem = DataAccess.Models.OperatingSystem.Windows, + ExpectedAlertTitle = "Alert 3", ScriptTest = "echo test", + CreatedBy = user, ModifiedBy = user, + Created = DateTime.UtcNow, Modified = DateTime.UtcNow + } + ); + _context.SaveChanges(); + } + + [Fact] + public async Task EvaluateSchedules_CreatesJobs_ForDueSchedule() + { + var schedule = new AutoSchedule + { + Name = "Daily Schedule", + Enabled = true, + NextExecution = DateTime.UtcNow.AddMinutes(-5), + Type = AutoScheduleType.Daily, + TestDefinitions = new List + { + _context.UseCaseTests.Find(1)!, + _context.UseCaseTests.Find(2)! + } + }; + _context.AutoSchedule.Add(schedule); + _context.SaveChanges(); + + await _service.EvaluateSchedulesAsync(CancellationToken.None); + + var jobs = _context.TestJobs.ToList(); + Assert.Equal(2, jobs.Count); + Assert.All(jobs, j => Assert.Equal(JobStatus.Queued, j.Status)); + Assert.All(jobs, j => Assert.Equal(JobResult.Undetermined, j.Result)); + } + + [Fact] + public async Task EvaluateSchedules_SkipsDisabledSchedule() + { + _context.AutoSchedule.Add(new AutoSchedule + { + Name = "Disabled Schedule", + Enabled = false, + NextExecution = DateTime.UtcNow.AddMinutes(-5), + Type = AutoScheduleType.Daily, + TestDefinitions = new List { _context.UseCaseTests.Find(1)! } + }); + _context.SaveChanges(); + + await _service.EvaluateSchedulesAsync(CancellationToken.None); + + Assert.Empty(_context.TestJobs.ToList()); + } + + [Fact] + public async Task EvaluateSchedules_SkipsFutureSchedule() + { + _context.AutoSchedule.Add(new AutoSchedule + { + Name = "Future Schedule", + Enabled = true, + NextExecution = DateTime.UtcNow.AddHours(1), + Type = AutoScheduleType.Daily, + TestDefinitions = new List { _context.UseCaseTests.Find(1)! } + }); + _context.SaveChanges(); + + await _service.EvaluateSchedulesAsync(CancellationToken.None); + + Assert.Empty(_context.TestJobs.ToList()); + } + + [Fact] + public async Task EvaluateSchedules_AdvancesNextExecution() + { + var originalNext = DateTime.UtcNow.AddMinutes(-5); + var schedule = new AutoSchedule + { + Name = "Weekly Schedule", + Enabled = true, + NextExecution = originalNext, + Type = AutoScheduleType.Weekly, + TestDefinitions = new List { _context.UseCaseTests.Find(1)! } + }; + _context.AutoSchedule.Add(schedule); + _context.SaveChanges(); + + await _service.EvaluateSchedulesAsync(CancellationToken.None); + + var updated = _context.AutoSchedule.Find(schedule.ID)!; + Assert.Equal(originalNext.AddDays(7), updated.NextExecution, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task EvaluateSchedules_FiltersOperatingSystem() + { + _context.AutoSchedule.Add(new AutoSchedule + { + Name = "Linux Only", + Enabled = true, + NextExecution = DateTime.UtcNow.AddMinutes(-5), + Type = AutoScheduleType.Daily, + FilterOperatingSystem = DataAccess.Models.OperatingSystem.Linux, + TestDefinitions = new List + { + _context.UseCaseTests.Find(1)!, // Windows + _context.UseCaseTests.Find(2)! // Linux + } + }); + _context.SaveChanges(); + + await _service.EvaluateSchedulesAsync(CancellationToken.None); + + var jobs = _context.TestJobs.ToList(); + Assert.Single(jobs); + Assert.Equal(2, jobs[0].UseCaseID); // Only Linux test + } + + [Fact] + public async Task EvaluateSchedules_AppliesRandomCountFilter() + { + _context.AutoSchedule.Add(new AutoSchedule + { + Name = "Random 1", + Enabled = true, + NextExecution = DateTime.UtcNow.AddMinutes(-5), + Type = AutoScheduleType.Daily, + FilterRandomCount = 1, + TestDefinitions = new List + { + _context.UseCaseTests.Find(1)!, + _context.UseCaseTests.Find(2)! + } + }); + _context.SaveChanges(); + + await _service.EvaluateSchedulesAsync(CancellationToken.None); + + var jobs = _context.TestJobs.ToList(); + Assert.Single(jobs); + } + + [Fact] + public async Task EvaluateSchedules_SkipsDisabledTests() + { + _context.AutoSchedule.Add(new AutoSchedule + { + Name = "With Disabled", + Enabled = true, + NextExecution = DateTime.UtcNow.AddMinutes(-5), + Type = AutoScheduleType.Daily, + TestDefinitions = new List + { + _context.UseCaseTests.Find(1)!, + _context.UseCaseTests.Find(3)! // Disabled test + } + }); + _context.SaveChanges(); + + await _service.EvaluateSchedulesAsync(CancellationToken.None); + + var jobs = _context.TestJobs.ToList(); + Assert.Single(jobs); + Assert.Equal(1, jobs[0].UseCaseID); + } + + [Theory] + [InlineData(AutoScheduleType.Daily, 1)] + [InlineData(AutoScheduleType.Weekly, 7)] + public void CalculateNextExecution_ReturnsCorrectDate(AutoScheduleType type, int expectedDays) + { + var current = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var next = AutoSchedulerService.CalculateNextExecution(current, type); + Assert.Equal(current.AddDays(expectedDays), next); + } + + [Fact] + public void CalculateNextExecution_Monthly_AddsOneMonth() + { + var current = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc); + var next = AutoSchedulerService.CalculateNextExecution(current, AutoScheduleType.Monthly); + Assert.Equal(new DateTime(2026, 2, 15, 0, 0, 0, DateTimeKind.Utc), next); + } + + [Fact] + public void CalculateNextExecution_Quarterly_AddsThreeMonths() + { + var current = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc); + var next = AutoSchedulerService.CalculateNextExecution(current, AutoScheduleType.Quarterly); + Assert.Equal(new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), next); + } + + public void Dispose() + { + _context.Dispose(); + _serviceProvider.Dispose(); + } + + /// + /// Test helper that creates scopes returning the same DbContext instance. + /// + private class TestScopeFactory : IServiceScopeFactory + { + private readonly ShieldCheckerContext _context; + public TestScopeFactory(ShieldCheckerContext context) => _context = context; + public IServiceScope CreateScope() => new TestScope(_context); + + private class TestScope : IServiceScope + { + private readonly IServiceProvider _provider; + public TestScope(ShieldCheckerContext context) => + _provider = new TestServiceProvider(context); + public IServiceProvider ServiceProvider => _provider; + public void Dispose() { } + } + + private class TestServiceProvider : IServiceProvider + { + private readonly ShieldCheckerContext _context; + public TestServiceProvider(ShieldCheckerContext context) => _context = context; + public object? GetService(Type serviceType) + { + if (serviceType == typeof(ShieldCheckerContext)) + return _context; + return null; + } + } + } + } +} diff --git a/tests/ShieldChecker.BackendApi.Tests/JobTimeoutServiceTests.cs b/tests/ShieldChecker.BackendApi.Tests/JobTimeoutServiceTests.cs new file mode 100644 index 0000000..78ac625 --- /dev/null +++ b/tests/ShieldChecker.BackendApi.Tests/JobTimeoutServiceTests.cs @@ -0,0 +1,202 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using ShieldChecker.BackendApi.Services; +using ShieldChecker.DataAccess; +using ShieldChecker.DataAccess.Models; + +namespace ShieldChecker.BackendApi.Tests +{ + public class JobTimeoutServiceTests : IDisposable + { + private readonly ShieldCheckerContext _context; + private readonly JobTimeoutService _service; + + public JobTimeoutServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + _context = new ShieldCheckerContext(options); + SeedData(); + + var scopeFactory = new TestScopeFactory(_context); + _service = new JobTimeoutService(scopeFactory, NullLogger.Instance); + } + + private void SeedData() + { + var user = new UserInfo("System", "system@test.local", Guid.Empty); + _context.UserInfo.Add(user); + _context.SaveChanges(); + + _context.Settings.Add(new Settings + { + ID = 1, + MaxWorkerCount = 2, + JobTimeout = 60, // 60 minutes + WorkerVMCpuCount = 2, + WorkerVMMemoryMB = 4096, + WorkerVMWindowsImage = "C:\\img\\win.vhdx", + WorkerVMLinuxImage = "C:\\img\\linux.vhdx", + DcVMCpuCount = 2, + DcVMMemoryMB = 4096, + DcVMImage = "C:\\img\\dc.vhdx", + VMStoragePath = "C:\\vms", + DomainFQDN = "test.local", + DomainControllerName = "dc01", + MDEWindowsOnboardingScript = "", + MDELinuxOnboardingScript = "" + }); + + _context.UseCaseTests.Add(new TestDefinition + { + ID = 1, Name = "Test 1", ExpectedAlertTitle = "Alert", + ScriptTest = "echo test", + CreatedBy = new UserInfo("System", "system@test.local", Guid.Empty), + ModifiedBy = new UserInfo("System", "system@test.local", Guid.Empty), + Created = DateTime.UtcNow, Modified = DateTime.UtcNow + }); + _context.SaveChanges(); + } + + [Fact] + public async Task EnforceTimeouts_MarksOldJobsAsError() + { + _context.TestJobs.Add(new TestJob + { + UseCaseID = 1, + Created = DateTime.UtcNow.AddHours(-2), // Way past the 60-min timeout + Modified = DateTime.UtcNow.AddHours(-2), + Status = JobStatus.WaitingForMDE, + Result = JobResult.Undetermined + }); + _context.SaveChanges(); + + await _service.EnforceTimeoutsAsync(CancellationToken.None); + + var job = _context.TestJobs.First(); + Assert.Equal(JobStatus.Error, job.Status); + Assert.Equal(JobResult.Undetermined, job.Result); + Assert.Contains("timed out", job.SchedulerLog); + } + + [Fact] + public async Task EnforceTimeouts_DoesNotAffectRecentJobs() + { + _context.TestJobs.Add(new TestJob + { + UseCaseID = 1, + Created = DateTime.UtcNow.AddMinutes(-5), // Within the 60-min timeout + Modified = DateTime.UtcNow.AddMinutes(-5), + Status = JobStatus.WaitingForMDE, + Result = JobResult.Undetermined + }); + _context.SaveChanges(); + + await _service.EnforceTimeoutsAsync(CancellationToken.None); + + var job = _context.TestJobs.First(); + Assert.Equal(JobStatus.WaitingForMDE, job.Status); + } + + [Fact] + public async Task EnforceTimeouts_DoesNotAffectCompletedJobs() + { + _context.TestJobs.Add(new TestJob + { + UseCaseID = 1, + Created = DateTime.UtcNow.AddHours(-2), + Modified = DateTime.UtcNow.AddHours(-2), + Status = JobStatus.Completed, // Already completed + Result = JobResult.Success + }); + _context.SaveChanges(); + + await _service.EnforceTimeoutsAsync(CancellationToken.None); + + var job = _context.TestJobs.First(); + Assert.Equal(JobStatus.Completed, job.Status); + } + + [Fact] + public async Task EnforceTimeouts_HandlesMultipleStatuses() + { + var now = DateTime.UtcNow; + _context.TestJobs.AddRange( + new TestJob + { + UseCaseID = 1, Created = now.AddHours(-2), Modified = now.AddHours(-2), + Status = JobStatus.Queued, Result = JobResult.Undetermined + }, + new TestJob + { + UseCaseID = 1, Created = now.AddHours(-2), Modified = now.AddHours(-2), + Status = JobStatus.WaitingForDetection, Result = JobResult.Undetermined + } + ); + _context.SaveChanges(); + + await _service.EnforceTimeoutsAsync(CancellationToken.None); + + var jobs = _context.TestJobs.ToList(); + Assert.All(jobs, j => Assert.Equal(JobStatus.Error, j.Status)); + } + + [Fact] + public async Task EnforceTimeouts_SkipsWhenTimeoutNotConfigured() + { + var settings = _context.Settings.First(); + settings.JobTimeout = 0; + _context.SaveChanges(); + + _context.TestJobs.Add(new TestJob + { + UseCaseID = 1, + Created = DateTime.UtcNow.AddHours(-2), + Modified = DateTime.UtcNow.AddHours(-2), + Status = JobStatus.WaitingForMDE, + Result = JobResult.Undetermined + }); + _context.SaveChanges(); + + await _service.EnforceTimeoutsAsync(CancellationToken.None); + + var job = _context.TestJobs.First(); + Assert.Equal(JobStatus.WaitingForMDE, job.Status); + } + + public void Dispose() + { + _context.Dispose(); + } + + private class TestScopeFactory : IServiceScopeFactory + { + private readonly ShieldCheckerContext _context; + public TestScopeFactory(ShieldCheckerContext context) => _context = context; + public IServiceScope CreateScope() => new TestScope(_context); + + private class TestScope : IServiceScope + { + private readonly IServiceProvider _provider; + public TestScope(ShieldCheckerContext context) => + _provider = new TestServiceProvider(context); + public IServiceProvider ServiceProvider => _provider; + public void Dispose() { } + } + + private class TestServiceProvider : IServiceProvider + { + private readonly ShieldCheckerContext _context; + public TestServiceProvider(ShieldCheckerContext context) => _context = context; + public object? GetService(Type serviceType) + { + if (serviceType == typeof(ShieldCheckerContext)) + return _context; + return null; + } + } + } + } +}