using System.CommandLine; using System.CommandLine.Invocation; using System.Security.Principal; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using PatchProbe.Cli.Auth; using PatchProbe.Cli.Collectors; using PatchProbe.Cli.Services; using PatchProbe.Shared.Contracts; using PatchProbe.Shared.Models; using PatchProbe.Shared.Serialization; using Serilog; Log.Logger = new LoggerConfiguration() .MinimumLevel.Information() .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") .WriteTo.File("logs/patchprobe.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 7) .CreateLogger(); try { var builder = Host.CreateApplicationBuilder(args); builder.Services.AddSerilog(); builder.Services.AddHttpClient(); // Collectors builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Auth builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Services builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); var host = builder.Build(); var rootCommand = new RootCommand("PatchProbe — Windows patch-management evidence collector"); // ── scan ────────────────────────────────────────────────────────────── var scanCommand = new Command("scan", "Collect patch-management evidence from this endpoint"); var outputOption = new Option("--output") { Description = "Path to write the JSON payload to disk" }; var noUploadOption = new Option("--no-upload") { Description = "Collect evidence and print JSON to stdout instead of uploading" }; var serverUrlOption = new Option("--server-url") { Description = "PatchProbe server URL to upload to (overrides enrolled server URL)" }; scanCommand.Add(outputOption); scanCommand.Add(noUploadOption); scanCommand.Add(serverUrlOption); scanCommand.SetAction(async (ParseResult parseResult, CancellationToken ct) => { var output = parseResult.GetValue(outputOption); var noUpload = parseResult.GetValue(noUploadOption); var serverUrlArg = parseResult.GetValue(serverUrlOption); if (!IsAdministrator()) Log.Warning("Not running as administrator — some data sources may be unavailable"); var sp = host.Services; var deviceCollector = sp.GetRequiredService(); var osCollector = sp.GetRequiredService(); var wuCollector = sp.GetRequiredService(); var rebootCollector = sp.GetRequiredService(); var driverCollector = sp.GetRequiredService(); var cbsCollector = sp.GetRequiredService(); var policyCollector = sp.GetRequiredService(); var hotfixCollector = sp.GetRequiredService(); var eventCollector = sp.GetRequiredService(); var uploader = sp.GetRequiredService(); var credStore = sp.GetRequiredService(); Log.Information("Starting PatchProbe scan on {Machine}", Environment.MachineName); var (device, os, reboot, drivers, cbs, hotfixes, events, policy) = await CollectAllAsync( deviceCollector, osCollector, rebootCollector, driverCollector, cbsCollector, hotfixCollector, eventCollector, policyCollector, ct); var wuRaw = await wuCollector.CollectAsync(ct); var wuInfo = new WindowsUpdateInfo { ApplicableUpdates = wuRaw.ApplicableUpdates, History = wuRaw.History, SearchError = wuRaw.SearchError, Policy = policy, }; var payload = new PatchProbePayload { Collector = new CollectorMeta { CollectedAt = DateTimeOffset.UtcNow, MachineName = Environment.MachineName, RanAsAdministrator = IsAdministrator(), }, Device = device, Os = os, PendingReboot = reboot, WindowsUpdate = wuInfo, InstalledHotfixes = hotfixes, CbsPackages = cbs, Drivers = drivers, RecentUpdateEvents = events, }; if (!string.IsNullOrEmpty(output)) { await PayloadSerializer.SerializeToFileAsync(payload, output, ct); Log.Information("Payload written to {Path}", output); } else if (!noUpload) { // Resolve server URL: CLI arg → enrolled server URL → local write var effectiveServerUrl = serverUrlArg ?? (credStore.IsEnrolled ? credStore.Load().ServerUrl : null); if (effectiveServerUrl is null) Log.Warning("No server URL configured — writing locally. Run 'PatchProbe.exe enroll' to register this device."); await uploader.UploadAsync(payload, effectiveServerUrl, ct); } else { var json = PayloadSerializer.Serialize(payload); Console.WriteLine(json); } Log.Information("Scan complete. Applicable updates: {Count}, Pending reboot: {Reboot}", wuInfo.ApplicableUpdates.Count, reboot?.AnyPending); }); // ── enroll ──────────────────────────────────────────────────────────── var enrollCommand = new Command("enroll", "Register this device with the PatchProbe server"); var enrollServerUrlOption = new Option("--server-url") { Description = "PatchProbe server URL (required)" }; var enrollmentKeyOption = new Option("--enrollment-key") { Description = "Enrollment key provided by your administrator (required)" }; var forceOption = new Option("--force") { Description = "Re-enroll even if this device is already enrolled" }; enrollCommand.Add(enrollServerUrlOption); enrollCommand.Add(enrollmentKeyOption); enrollCommand.Add(forceOption); enrollCommand.SetAction(async (ParseResult parseResult, CancellationToken ct) => { var serverUrl = parseResult.GetValue(enrollServerUrlOption); var enrollmentKey = parseResult.GetValue(enrollmentKeyOption); var force = parseResult.GetValue(forceOption); if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(enrollmentKey)) { Log.Error("--server-url and --enrollment-key are required"); return; } if (!IsAdministrator()) Log.Warning("Not running as administrator — credential storage to %ProgramData% may fail"); var sp = host.Services; var credStore = sp.GetRequiredService(); if (credStore.IsEnrolled && !force) { Log.Warning("Device is already enrolled. Use --force to re-enroll and generate a new keypair."); return; } var enrollmentService = sp.GetRequiredService(); await enrollmentService.EnrollAsync(serverUrl, enrollmentKey, ct); }); // ── unenroll ────────────────────────────────────────────────────────── var unenrollCommand = new Command("unenroll", "Remove stored device credentials from this endpoint"); unenrollCommand.SetAction((ParseResult _, CancellationToken _) => { if (!IsAdministrator()) Log.Warning("Not running as administrator — credential removal may fail"); var credStore = host.Services.GetRequiredService(); if (!credStore.IsEnrolled) { Log.Information("Device is not enrolled — nothing to remove"); return Task.CompletedTask; } credStore.Delete(); Log.Information("Device credentials removed from {Path}", DpapiCredentialStore.StorePath); return Task.CompletedTask; }); rootCommand.Add(scanCommand); rootCommand.Add(enrollCommand); rootCommand.Add(unenrollCommand); var parseResult = rootCommand.Parse(args, new ParserConfiguration()); return await parseResult.InvokeAsync(new InvocationConfiguration()); } catch (Exception ex) { Log.Fatal(ex, "Unhandled exception"); return 1; } finally { await Log.CloseAndFlushAsync(); } static async Task<(DeviceInfo, OsInfo, PendingRebootInfo, List, List, List, List, WindowsUpdatePolicy)> CollectAllAsync( DeviceCollector deviceCollector, OsCollector osCollector, PendingRebootCollector rebootCollector, DriverCollector driverCollector, CbsDismCollector cbsCollector, HotfixCollector hotfixCollector, EventCollector eventCollector, WindowsUpdatePolicyCollector policyCollector, CancellationToken ct) { var deviceTask = deviceCollector.CollectAsync(ct); var osTask = osCollector.CollectAsync(ct); var rebootTask = rebootCollector.CollectAsync(ct); var driverTask = driverCollector.CollectAsync(ct); var cbsTask = cbsCollector.CollectAsync(ct); var hotfixTask = hotfixCollector.CollectAsync(ct); var eventTask = eventCollector.CollectAsync(ct); var policyTask = policyCollector.CollectAsync(ct); await Task.WhenAll(deviceTask, osTask, rebootTask, driverTask, cbsTask, hotfixTask, eventTask, policyTask); return (deviceTask.Result, osTask.Result, rebootTask.Result, driverTask.Result, cbsTask.Result, hotfixTask.Result, eventTask.Result, policyTask.Result); } static bool IsAdministrator() { using var identity = WindowsIdentity.GetCurrent(); var principal = new WindowsPrincipal(identity); return principal.IsInRole(WindowsBuiltInRole.Administrator); }