245 lines
11 KiB
C#
245 lines
11 KiB
C#
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<DeviceCollector>();
|
|
builder.Services.AddSingleton<OsCollector>();
|
|
builder.Services.AddSingleton<WindowsUpdateCollector>();
|
|
builder.Services.AddSingleton<PendingRebootCollector>();
|
|
builder.Services.AddSingleton<DriverCollector>();
|
|
builder.Services.AddSingleton<CbsDismCollector>();
|
|
builder.Services.AddSingleton<WindowsUpdatePolicyCollector>();
|
|
builder.Services.AddSingleton<HotfixCollector>();
|
|
builder.Services.AddSingleton<EventCollector>();
|
|
|
|
// Auth
|
|
builder.Services.AddSingleton<IDeviceCredentialStore, DpapiCredentialStore>();
|
|
builder.Services.AddSingleton<IRequestAuthenticator, EcdsaRequestAuthenticator>();
|
|
|
|
// Services
|
|
builder.Services.AddSingleton<EnrollmentService>();
|
|
builder.Services.AddSingleton<PayloadUploader>();
|
|
builder.Services.AddSingleton<IPayloadUploader>(sp => sp.GetRequiredService<PayloadUploader>());
|
|
|
|
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<string?>("--output") { Description = "Path to write the JSON payload to disk" };
|
|
var noUploadOption = new Option<bool>("--no-upload") { Description = "Collect evidence and print JSON to stdout instead of uploading" };
|
|
var serverUrlOption = new Option<string?>("--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<DeviceCollector>();
|
|
var osCollector = sp.GetRequiredService<OsCollector>();
|
|
var wuCollector = sp.GetRequiredService<WindowsUpdateCollector>();
|
|
var rebootCollector = sp.GetRequiredService<PendingRebootCollector>();
|
|
var driverCollector = sp.GetRequiredService<DriverCollector>();
|
|
var cbsCollector = sp.GetRequiredService<CbsDismCollector>();
|
|
var policyCollector = sp.GetRequiredService<WindowsUpdatePolicyCollector>();
|
|
var hotfixCollector = sp.GetRequiredService<HotfixCollector>();
|
|
var eventCollector = sp.GetRequiredService<EventCollector>();
|
|
var uploader = sp.GetRequiredService<PayloadUploader>();
|
|
var credStore = sp.GetRequiredService<IDeviceCredentialStore>();
|
|
|
|
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<string?>("--server-url") { Description = "PatchProbe server URL (required)" };
|
|
var enrollmentKeyOption = new Option<string?>("--enrollment-key") { Description = "Enrollment key provided by your administrator (required)" };
|
|
var forceOption = new Option<bool>("--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<IDeviceCredentialStore>();
|
|
|
|
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<EnrollmentService>();
|
|
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<IDeviceCredentialStore>();
|
|
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<DriverInfo>, List<CbsPackage>, List<InstalledHotfix>, List<UpdateEvent>, 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);
|
|
}
|