Initial Commit

This commit is contained in:
2026-05-25 10:29:38 +08:00
commit c42c9aea2a
64 changed files with 5919 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
namespace PatchProbe.Cli.Auth;
internal sealed record DeviceCredentials(
string DeviceId,
string PrivateKeyPkcs8,
string ServerUrl);

View File

@@ -0,0 +1,55 @@
using System.Security.AccessControl;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace PatchProbe.Cli.Auth;
internal sealed class DpapiCredentialStore : IDeviceCredentialStore
{
// %ProgramData%\PatchProbe\device.cred — machine-scoped, survives user changes
internal static readonly string StorePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"PatchProbe", "device.cred");
public bool IsEnrolled => File.Exists(StorePath);
public DeviceCredentials Load()
{
var cipher = File.ReadAllBytes(StorePath);
var plain = ProtectedData.Unprotect(cipher, null, DataProtectionScope.LocalMachine);
return JsonSerializer.Deserialize<DeviceCredentials>(Encoding.UTF8.GetString(plain))
?? throw new InvalidOperationException("Credential file is corrupt or empty.");
}
public void Save(DeviceCredentials credentials)
{
var dir = Path.GetDirectoryName(StorePath)!;
Directory.CreateDirectory(dir);
var plain = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(credentials));
var cipher = ProtectedData.Protect(plain, null, DataProtectionScope.LocalMachine);
File.WriteAllBytes(StorePath, cipher);
RestrictToAdmins(StorePath);
}
public void Delete()
{
if (File.Exists(StorePath))
File.Delete(StorePath);
}
private static void RestrictToAdmins(string path)
{
var fi = new FileInfo(path);
var acl = fi.GetAccessControl();
// Break inheritance; grant only SYSTEM and Administrators full control
acl.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
acl.AddAccessRule(new FileSystemAccessRule(
"SYSTEM", FileSystemRights.FullControl, AccessControlType.Allow));
acl.AddAccessRule(new FileSystemAccessRule(
"Administrators", FileSystemRights.FullControl, AccessControlType.Allow));
fi.SetAccessControl(acl);
}
}

View File

@@ -0,0 +1,31 @@
using System.Security.Cryptography;
using System.Text;
namespace PatchProbe.Cli.Auth;
internal sealed class EcdsaRequestAuthenticator(IDeviceCredentialStore store) : IRequestAuthenticator
{
public Task AuthenticateAsync(HttpRequestMessage request, string requestBody, CancellationToken ct = default)
{
if (!store.IsEnrolled)
return Task.CompletedTask;
var creds = store.Load();
using var ecdsa = ECDsa.Create();
ecdsa.ImportPkcs8PrivateKey(Convert.FromBase64String(creds.PrivateKeyPkcs8), out _);
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
var bodyHash = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(requestBody)));
// Signed message: deviceId LF timestamp LF sha256(body)
var message = Encoding.UTF8.GetBytes($"{creds.DeviceId}\n{timestamp}\n{bodyHash}");
var signature = Convert.ToBase64String(ecdsa.SignData(message, HashAlgorithmName.SHA256));
request.Headers.Add("X-Device-Id", creds.DeviceId);
request.Headers.Add("X-Timestamp", timestamp);
request.Headers.Add("X-Signature", signature);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,9 @@
namespace PatchProbe.Cli.Auth;
internal interface IDeviceCredentialStore
{
bool IsEnrolled { get; }
DeviceCredentials Load();
void Save(DeviceCredentials credentials);
void Delete();
}

View File

@@ -0,0 +1,7 @@
namespace PatchProbe.Cli.Auth;
internal interface IRequestAuthenticator
{
/// <summary>Adds authentication headers to an outgoing HTTP request.</summary>
Task AuthenticateAsync(HttpRequestMessage request, string requestBody, CancellationToken ct = default);
}

View File

@@ -0,0 +1,84 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using PatchProbe.Shared.Contracts;
using PatchProbe.Shared.Models;
namespace PatchProbe.Cli.Collectors;
public sealed class CbsDismCollector(ILogger<CbsDismCollector> logger) : ICollector<List<CbsPackage>>
{
public async Task<List<CbsPackage>> CollectAsync(CancellationToken cancellationToken = default)
{
logger.LogInformation("Collecting CBS/DISM package state");
var packages = new List<CbsPackage>();
try
{
var output = await RunDismAsync(cancellationToken);
packages = ParseDismOutput(output);
logger.LogInformation("Collected {Count} CBS packages", packages.Count);
}
catch (Exception ex)
{
logger.LogWarning(ex, "CBS/DISM collection failed");
}
return packages;
}
private static async Task<string> RunDismAsync(CancellationToken cancellationToken)
{
var psi = new ProcessStartInfo
{
FileName = "dism.exe",
Arguments = "/Online /Get-Packages /Format:Table",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start dism.exe");
var output = await process.StandardOutput.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
return output;
}
private static List<CbsPackage> ParseDismOutput(string output)
{
var packages = new List<CbsPackage>();
var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
// Skip header lines; data rows contain "Package Identity" columns separated by spaces/pipes
bool inDataSection = false;
foreach (var line in lines)
{
var trimmed = line.Trim();
if (trimmed.StartsWith("Package Identity", StringComparison.OrdinalIgnoreCase))
{
inDataSection = true;
continue;
}
if (!inDataSection || string.IsNullOrWhiteSpace(trimmed) || trimmed.StartsWith('-'))
continue;
// DISM table output uses multiple spaces as column separator
var parts = System.Text.RegularExpressions.Regex.Split(trimmed, @"\s{2,}");
if (parts.Length >= 2)
{
packages.Add(new CbsPackage
{
PackageIdentity = parts[0].Trim(),
State = parts.Length > 1 ? parts[1].Trim() : null,
ReleaseType = parts.Length > 2 ? parts[2].Trim() : null,
InstallTime = parts.Length > 3 ? parts[3].Trim() : null,
});
}
}
return packages;
}
}

View File

@@ -0,0 +1,51 @@
using System.Management;
using Microsoft.Extensions.Logging;
using PatchProbe.Shared.Contracts;
using PatchProbe.Shared.Models;
namespace PatchProbe.Cli.Collectors;
public sealed class DeviceCollector(ILogger<DeviceCollector> logger) : ICollector<DeviceInfo>
{
public Task<DeviceInfo> CollectAsync(CancellationToken cancellationToken = default)
{
logger.LogInformation("Collecting device information");
var cs = QueryFirst("SELECT * FROM Win32_ComputerSystem");
var bios = QueryFirst("SELECT * FROM Win32_BIOS");
var tpm = QueryFirst("SELECT * FROM Win32_Tpm", @"root\CIMv2\Security\MicrosoftTpm");
var info = new DeviceInfo
{
Hostname = Environment.MachineName,
Manufacturer = cs?["Manufacturer"]?.ToString()?.Trim(),
Model = cs?["Model"]?.ToString()?.Trim(),
SerialNumber = bios?["SerialNumber"]?.ToString()?.Trim(),
BiosVersion = bios?["SMBIOSBIOSVersion"]?.ToString()?.Trim(),
BiosDate = bios?["ReleaseDate"]?.ToString()?.Trim(),
TpmPresent = tpm != null,
TpmVersion = tpm?["SpecVersion"]?.ToString()?.Trim(),
RamBytes = cs != null ? Convert.ToUInt64(cs["TotalPhysicalMemory"]) : 0,
Domain = cs?["Domain"]?.ToString()?.Trim(),
Workgroup = string.Equals(cs?["DomainRole"]?.ToString(), "0", StringComparison.Ordinal) ||
string.Equals(cs?["DomainRole"]?.ToString(), "1", StringComparison.Ordinal)
? cs?["Workgroup"]?.ToString()?.Trim() : null,
SystemType = cs?["SystemType"]?.ToString()?.Trim(),
};
return Task.FromResult(info);
}
private static ManagementBaseObject? QueryFirst(string query, string scope = @"root\CIMv2")
{
try
{
using var searcher = new ManagementObjectSearcher(new ManagementScope(scope), new ObjectQuery(query));
using var results = searcher.Get();
foreach (ManagementBaseObject obj in results)
return obj;
}
catch { /* best-effort */ }
return null;
}
}

View File

@@ -0,0 +1,60 @@
using System.Management;
using Microsoft.Extensions.Logging;
using PatchProbe.Shared.Contracts;
using PatchProbe.Shared.Models;
namespace PatchProbe.Cli.Collectors;
public sealed class DriverCollector(ILogger<DriverCollector> logger) : ICollector<List<DriverInfo>>
{
public Task<List<DriverInfo>> CollectAsync(CancellationToken cancellationToken = default)
{
logger.LogInformation("Collecting driver information via Win32_PnPSignedDriver");
var drivers = new List<DriverInfo>();
try
{
using var searcher = new ManagementObjectSearcher(
new ManagementScope(@"root\CIMv2"),
new ObjectQuery("SELECT * FROM Win32_PnPSignedDriver WHERE DeviceName IS NOT NULL"));
using var results = searcher.Get();
foreach (ManagementBaseObject obj in results)
{
DateTimeOffset? driverDate = null;
var dateRaw = obj["DriverDate"]?.ToString();
if (!string.IsNullOrEmpty(dateRaw))
{
try
{
// WMI datetime format: yyyymmddHHmmss.ffffff+UTC — convert via UTC to avoid local-offset mismatch
var dt = ManagementDateTimeConverter.ToDateTime(dateRaw);
driverDate = new DateTimeOffset(dt.ToUniversalTime(), TimeSpan.Zero);
}
catch { /* best-effort */ }
}
drivers.Add(new DriverInfo
{
DeviceName = obj["DeviceName"]?.ToString()?.Trim(),
DriverVersion = obj["DriverVersion"]?.ToString()?.Trim(),
Manufacturer = obj["Manufacturer"]?.ToString()?.Trim(),
InfName = obj["InfName"]?.ToString()?.Trim(),
HardwareId = obj["HardWareID"]?.ToString()?.Trim(),
DeviceClass = obj["DeviceClass"]?.ToString()?.Trim(),
DriverDate = driverDate,
IsSigned = string.Equals(obj["IsSigned"]?.ToString(), "True", StringComparison.OrdinalIgnoreCase),
});
}
logger.LogInformation("Collected {Count} drivers", drivers.Count);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Driver collection failed");
}
return Task.FromResult(drivers);
}
}

View File

@@ -0,0 +1,78 @@
using System.Diagnostics.Eventing.Reader;
using Microsoft.Extensions.Logging;
using PatchProbe.Shared.Contracts;
using PatchProbe.Shared.Models;
namespace PatchProbe.Cli.Collectors;
public sealed class EventCollector(ILogger<EventCollector> logger) : ICollector<List<UpdateEvent>>
{
private static readonly (string LogName, string? Provider)[] Sources =
[
("System", null),
("Microsoft-Windows-WindowsUpdateClient/Operational", "Microsoft-Windows-WindowsUpdateClient"),
("Microsoft-Windows-UpdateOrchestrator/Operational", "Microsoft-Windows-UpdateOrchestrator"),
];
private const int LookbackHours = 72;
private const int MaxEventsPerSource = 50;
public Task<List<UpdateEvent>> CollectAsync(CancellationToken cancellationToken = default)
{
logger.LogInformation("Collecting recent Windows Update events (last {Hours}h)", LookbackHours);
var events = new List<UpdateEvent>();
var since = DateTime.UtcNow.AddHours(-LookbackHours);
foreach (var (logName, provider) in Sources)
{
try
{
events.AddRange(ReadEvents(logName, provider, since));
}
catch (Exception ex)
{
logger.LogDebug(ex, "Could not read event log: {LogName}", logName);
}
}
logger.LogInformation("Collected {Count} update events", events.Count);
return Task.FromResult(events);
}
private static IEnumerable<UpdateEvent> ReadEvents(string logName, string? provider, DateTime since)
{
var providerFilter = provider != null ? $" and Provider[@Name='{provider}']" : string.Empty;
var query = new EventLogQuery(
logName,
PathType.LogName,
$"*[System[(Level<=3){providerFilter} and TimeCreated[@SystemTime>='{since:yyyy-MM-ddTHH:mm:ss.000Z}']]]");
using var reader = new EventLogReader(query);
int count = 0;
while (reader.ReadEvent() is EventRecord record && count < MaxEventsPerSource)
{
using (record)
{
yield return new UpdateEvent
{
EventId = record.Id,
Source = record.ProviderName,
LogName = record.LogName,
Level = record.LevelDisplayName,
TimeCreated = record.TimeCreated.HasValue
? new DateTimeOffset(record.TimeCreated.Value, TimeSpan.Zero)
: null,
Message = TryFormatMessage(record),
};
count++;
}
}
}
private static string? TryFormatMessage(EventRecord record)
{
try { return record.FormatDescription(); }
catch { return null; }
}
}

View File

@@ -0,0 +1,48 @@
using System.Management;
using Microsoft.Extensions.Logging;
using PatchProbe.Shared.Contracts;
using PatchProbe.Shared.Models;
namespace PatchProbe.Cli.Collectors;
public sealed class HotfixCollector(ILogger<HotfixCollector> logger) : ICollector<List<InstalledHotfix>>
{
public Task<List<InstalledHotfix>> CollectAsync(CancellationToken cancellationToken = default)
{
logger.LogInformation("Collecting installed hotfixes via Win32_QuickFixEngineering");
var hotfixes = new List<InstalledHotfix>();
try
{
using var searcher = new ManagementObjectSearcher(
new ManagementScope(@"root\CIMv2"),
new ObjectQuery("SELECT * FROM Win32_QuickFixEngineering"));
using var results = searcher.Get();
foreach (ManagementBaseObject obj in results)
{
DateTimeOffset? installedOn = null;
var dateRaw = obj["InstalledOn"]?.ToString();
if (!string.IsNullOrEmpty(dateRaw) && DateTime.TryParse(dateRaw, out var dt))
installedOn = new DateTimeOffset(dt, TimeSpan.Zero);
hotfixes.Add(new InstalledHotfix
{
HotFixId = obj["HotFixID"]?.ToString()?.Trim(),
Description = obj["Description"]?.ToString()?.Trim(),
InstalledBy = obj["InstalledBy"]?.ToString()?.Trim(),
InstalledOn = installedOn,
});
}
logger.LogInformation("Collected {Count} installed hotfixes", hotfixes.Count);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Hotfix collection failed");
}
return Task.FromResult(hotfixes);
}
}

View File

@@ -0,0 +1,48 @@
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using PatchProbe.Shared.Contracts;
using PatchProbe.Shared.Models;
namespace PatchProbe.Cli.Collectors;
public sealed class OsCollector(ILogger<OsCollector> logger) : ICollector<OsInfo>
{
private const string CurrentVersionKey = @"SOFTWARE\Microsoft\Windows NT\CurrentVersion";
public Task<OsInfo> CollectAsync(CancellationToken cancellationToken = default)
{
logger.LogInformation("Collecting OS information");
using var key = Registry.LocalMachine.OpenSubKey(CurrentVersionKey);
var installDateRaw = key?.GetValue("InstallDate");
DateTimeOffset? installDate = installDateRaw is int installDateInt
? DateTimeOffset.FromUnixTimeSeconds(installDateInt)
: null;
var info = new OsInfo
{
ProductName = key?.GetValue("ProductName")?.ToString(),
EditionId = key?.GetValue("EditionID")?.ToString(),
DisplayVersion = key?.GetValue("DisplayVersion")?.ToString(),
ReleaseId = key?.GetValue("ReleaseId")?.ToString(),
BuildNumber = key?.GetValue("CurrentBuildNumber")?.ToString(),
Ubr = key?.GetValue("UBR") is int ubr ? ubr : 0,
Architecture = Environment.Is64BitOperatingSystem ? "x64" : "x86",
InstallDate = installDate,
LastBoot = GetLastBoot(),
};
return Task.FromResult(info);
}
private static DateTimeOffset? GetLastBoot()
{
try
{
var uptime = TimeSpan.FromMilliseconds(Environment.TickCount64);
return DateTimeOffset.UtcNow - uptime;
}
catch { return null; }
}
}

View File

@@ -0,0 +1,64 @@
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using PatchProbe.Shared.Contracts;
using PatchProbe.Shared.Models;
namespace PatchProbe.Cli.Collectors;
public sealed class PendingRebootCollector(ILogger<PendingRebootCollector> logger) : ICollector<PendingRebootInfo>
{
public Task<PendingRebootInfo> CollectAsync(CancellationToken cancellationToken = default)
{
logger.LogInformation("Collecting pending reboot indicators");
var info = new PendingRebootInfo
{
CbsRebootPending = KeyExists(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending")
|| KeyExists(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootInProgress"),
WindowsUpdateRebootRequired = KeyExists(@"SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired"),
SessionManagerRebootRequired = HasPendingFileRenameOperations(),
ComputerRenameRequired = HasPendingComputerRename(),
};
logger.LogInformation("Pending reboot: {AnyPending} (CBS={Cbs}, WU={Wu}, SessionMgr={Sm}, Rename={Rename})",
info.AnyPending, info.CbsRebootPending, info.WindowsUpdateRebootRequired,
info.SessionManagerRebootRequired, info.ComputerRenameRequired);
return Task.FromResult(info);
}
private static bool KeyExists(string subKeyPath)
{
try
{
using var key = Registry.LocalMachine.OpenSubKey(subKeyPath);
return key != null;
}
catch { return false; }
}
private static bool HasPendingFileRenameOperations()
{
try
{
using var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\Session Manager");
var value = key?.GetValue("PendingFileRenameOperations");
if (value is string[] arr) return arr.Length > 0;
}
catch { }
return false;
}
private static bool HasPendingComputerRename()
{
try
{
using var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\ComputerName\ActiveComputerName");
using var pendingKey = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName");
var active = key?.GetValue("ComputerName")?.ToString();
var pending = pendingKey?.GetValue("ComputerName")?.ToString();
return !string.Equals(active, pending, StringComparison.OrdinalIgnoreCase);
}
catch { return false; }
}
}

View File

@@ -0,0 +1,145 @@
using Microsoft.Extensions.Logging;
using PatchProbe.Shared.Contracts;
using PatchProbe.Shared.Models;
namespace PatchProbe.Cli.Collectors;
public sealed class WindowsUpdateCollector(ILogger<WindowsUpdateCollector> logger) : ICollector<WindowsUpdateInfo>
{
public Task<WindowsUpdateInfo> CollectAsync(CancellationToken cancellationToken = default)
{
logger.LogInformation("Collecting Windows Update information via WUA COM");
var applicable = new List<ApplicableUpdate>();
var history = new List<UpdateHistoryEntry>();
string? searchError = null;
try
{
var sessionType = Type.GetTypeFromProgID("Microsoft.Update.Session")
?? throw new InvalidOperationException("Microsoft.Update.Session ProgID not found");
dynamic session = Activator.CreateInstance(sessionType)!;
dynamic searcher = session.CreateUpdateSearcher();
logger.LogInformation("Searching for applicable updates (IsInstalled=0 and IsHidden=0)");
dynamic result = searcher.Search("IsInstalled=0 and IsHidden=0");
for (int i = 0; i < result.Updates.Count; i++)
{
dynamic u = result.Updates.Item(i);
string kbId = ExtractKbId(u);
string category = ExtractFirstCategory(u);
applicable.Add(new ApplicableUpdate
{
UpdateId = TryGet<string>(() => u.Identity.UpdateID),
Title = TryGet<string>(() => u.Title),
KbArticleId = kbId,
Category = category,
Severity = TryGet<string>(() => u.MsrcSeverity),
RebootRequired = TryGet<bool>(() => u.RebootRequired),
IsDownloaded = TryGet<bool>(() => u.IsDownloaded),
Description = TryGet<string>(() => u.Description),
SupportUrl = TryGet<string>(() => u.SupportUrl),
});
}
logger.LogInformation("Found {Count} applicable updates", applicable.Count);
int totalHistoryCount = searcher.GetTotalHistoryCount();
int historyLimit = Math.Min(totalHistoryCount, 100);
if (historyLimit > 0)
{
dynamic historyEntries = searcher.QueryHistory(0, historyLimit);
for (int i = 0; i < historyEntries.Count; i++)
{
dynamic e = historyEntries.Item(i);
history.Add(new UpdateHistoryEntry
{
UpdateId = TryGet<string>(() => e.UpdateIdentity.UpdateID),
Title = TryGet<string>(() => e.Title),
KbArticleId = ExtractKbIdFromTitle(TryGet<string>(() => e.Title)),
ResultCode = TryGet<int>(() => (int)e.ResultCode),
HResult = TryGet<int>(() => e.HResult),
Date = TryGetDate(() => e.Date),
Operation = TryGet<int>(() => (int)e.Operation) switch
{
1 => "Installation",
2 => "Uninstallation",
_ => "Unknown"
},
ServerSelection = TryGet<int>(() => (int)e.ServerSelection) switch
{
0 => "Default",
1 => "ManagedServer",
2 => "WindowsUpdate",
3 => "Others",
_ => "Unknown"
},
});
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "WUA search failed");
searchError = ex.Message;
}
return Task.FromResult(new WindowsUpdateInfo
{
ApplicableUpdates = applicable,
History = history,
SearchError = searchError,
});
}
private static string ExtractKbId(dynamic update)
{
try
{
if (update.KBArticleIDs.Count > 0)
return "KB" + update.KBArticleIDs.Item(0);
}
catch { /* fall through to title parse */ }
return ExtractKbIdFromTitle(TryGet<string>(() => update.Title));
}
private static string ExtractFirstCategory(dynamic update)
{
try
{
if (update.Categories.Count > 0)
return update.Categories.Item(0).Name;
}
catch { }
return string.Empty;
}
private static string ExtractKbIdFromTitle(string? title)
{
if (string.IsNullOrEmpty(title)) return string.Empty;
var match = System.Text.RegularExpressions.Regex.Match(title, @"KB\d+", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
return match.Success ? match.Value.ToUpperInvariant() : string.Empty;
}
private static T TryGet<T>(Func<T> getter)
{
try { return getter(); }
catch { return default!; }
}
private static DateTimeOffset? TryGetDate(Func<object> getter)
{
try
{
var raw = getter();
if (raw is DateTime dt) return new DateTimeOffset(dt, TimeSpan.Zero);
if (raw is DateTimeOffset dto) return dto;
if (DateTime.TryParse(raw?.ToString(), out var parsed)) return new DateTimeOffset(parsed, TimeSpan.Zero);
}
catch { }
return null;
}
}

View File

@@ -0,0 +1,49 @@
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using PatchProbe.Shared.Contracts;
using PatchProbe.Shared.Models;
namespace PatchProbe.Cli.Collectors;
public sealed class WindowsUpdatePolicyCollector(ILogger<WindowsUpdatePolicyCollector> logger) : ICollector<WindowsUpdatePolicy>
{
private const string WuPolicyKey = @"SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate";
private const string AuPolicyKey = @"SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU";
private const string WuUxKey = @"SOFTWARE\Microsoft\WindowsUpdate\UX\Settings";
public Task<WindowsUpdatePolicy> CollectAsync(CancellationToken cancellationToken = default)
{
logger.LogInformation("Collecting Windows Update policy");
using var wuKey = Registry.LocalMachine.OpenSubKey(WuPolicyKey);
using var auKey = Registry.LocalMachine.OpenSubKey(AuPolicyKey);
using var uxKey = Registry.LocalMachine.OpenSubKey(WuUxKey);
var wsusServer = wuKey?.GetValue("WUServer")?.ToString();
var policy = new WindowsUpdatePolicy
{
WsusConfigured = !string.IsNullOrEmpty(wsusServer),
WsusServer = wsusServer,
WsusStatusServer = wuKey?.GetValue("WUStatusServer")?.ToString(),
WufbConfigured = wuKey?.GetValue("ManagePreviewBuilds") != null
|| uxKey?.GetValue("BranchReadinessLevel") != null,
DeferFeatureUpdatesDays = GetInt(wuKey, "DeferFeatureUpdatesPeriodInDays")
?? GetInt(uxKey, "DeferFeatureUpdatesPeriodInDays"),
DeferQualityUpdatesDays = GetInt(wuKey, "DeferQualityUpdatesPeriodInDays")
?? GetInt(uxKey, "DeferQualityUpdatesPeriodInDays"),
AutoUpdateEnabled = GetInt(auKey, "NoAutoUpdate") != 1,
AuOptions = GetInt(auKey, "AUOptions"),
TargetGroupEnabled = GetInt(wuKey, "TargetGroupEnabled") == 1,
TargetGroup = wuKey?.GetValue("TargetGroup")?.ToString(),
BranchReadinessLevel = uxKey?.GetValue("BranchReadinessLevel")?.ToString(),
};
return Task.FromResult(policy);
}
private static int? GetInt(RegistryKey? key, string name)
{
if (key?.GetValue(name) is int v) return v;
return null;
}
}

View File

@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\PatchProbe.Shared\PatchProbe.Shared.csproj" />
<ProjectReference Include="..\PatchProbe.Engine.Contracts\PatchProbe.Engine.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.8" />
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="System.CommandLine" Version="2.0.8" />
<PackageReference Include="System.Management" Version="10.0.8" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.8" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>PatchProbe</AssemblyName>
<RootNamespace>PatchProbe.Cli</RootNamespace>
<!-- Publish defaults for win-x64 self-contained single-file EXE -->
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>
</Project>

244
PatchProbe.Cli/Program.cs Normal file
View File

@@ -0,0 +1,244 @@
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);
}

View File

@@ -0,0 +1,54 @@
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using PatchProbe.Cli.Auth;
using PatchProbe.Engine.Contracts.ApiModels;
namespace PatchProbe.Cli.Services;
internal sealed class EnrollmentService(
IHttpClientFactory httpClientFactory,
IDeviceCredentialStore credentialStore,
ILogger<EnrollmentService> logger)
{
public async Task EnrollAsync(string serverUrl, string enrollmentKey, CancellationToken ct = default)
{
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var publicKeySpki = Convert.ToBase64String(ecdsa.ExportSubjectPublicKeyInfo());
var privateKeyPkcs8 = Convert.ToBase64String(ecdsa.ExportPkcs8PrivateKey());
var requestBody = new EnrollmentRequest(
EnrollmentKey: enrollmentKey,
MachineName: Environment.MachineName,
DeviceFingerprint: GetMachineFingerprint(),
PublicKeySpki: publicKeySpki);
var http = httpClientFactory.CreateClient();
var url = $"{serverUrl.TrimEnd('/')}/api/enrollments";
logger.LogInformation("Enrolling device with server at {Url}", url);
var response = await http.PostAsJsonAsync(url, requestBody, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<EnrollmentResponse>(cancellationToken: ct)
?? throw new InvalidOperationException("Server returned an empty enrollment response.");
credentialStore.Save(new DeviceCredentials(
DeviceId: result.DeviceId,
PrivateKeyPkcs8: privateKeyPkcs8,
ServerUrl: serverUrl));
logger.LogInformation("Enrollment complete — Device ID: {DeviceId}", result.DeviceId);
}
private static string GetMachineFingerprint()
{
// MachineGuid is a stable, per-install GUID set by Windows Setup.
using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Cryptography");
var guid = key?.GetValue("MachineGuid")?.ToString() ?? Environment.MachineName;
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(guid))).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,60 @@
using System.Text;
using Microsoft.Extensions.Logging;
using PatchProbe.Cli.Auth;
using PatchProbe.Shared.Contracts;
using PatchProbe.Shared.Models;
using PatchProbe.Shared.Serialization;
namespace PatchProbe.Cli.Services;
internal sealed class PayloadUploader(
ILogger<PayloadUploader> logger,
IHttpClientFactory httpClientFactory,
IRequestAuthenticator authenticator) : IPayloadUploader
{
public async Task UploadAsync(PatchProbePayload payload, string? serverUrl = null, CancellationToken cancellationToken = default)
{
if (!string.IsNullOrEmpty(serverUrl))
{
await PostToServerAsync(payload, serverUrl, cancellationToken);
}
else
{
await WriteLocalAsync(payload, cancellationToken);
}
}
Task IPayloadUploader.UploadAsync(PatchProbePayload payload, CancellationToken cancellationToken) =>
UploadAsync(payload, null, cancellationToken);
private async Task PostToServerAsync(PatchProbePayload payload, string serverUrl, CancellationToken cancellationToken)
{
var url = serverUrl.TrimEnd('/') + "/api/scans";
logger.LogInformation("Uploading scan to {Url}", url);
var json = PayloadSerializer.Serialize(payload);
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
await authenticator.AuthenticateAsync(request, json, cancellationToken);
var client = httpClientFactory.CreateClient();
var response = await client.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
logger.LogInformation("Scan uploaded successfully (HTTP {Status})", (int)response.StatusCode);
}
private async Task WriteLocalAsync(PatchProbePayload payload, CancellationToken cancellationToken)
{
var outputDir = Path.Combine(AppContext.BaseDirectory, "output");
Directory.CreateDirectory(outputDir);
var fileName = $"patchprobe_{payload.Collector.CollectedAt:yyyyMMdd_HHmmss}_{payload.Collector.MachineName}.json";
var filePath = Path.Combine(outputDir, fileName);
await PayloadSerializer.SerializeToFileAsync(payload, filePath, cancellationToken);
logger.LogInformation("Payload written to {FilePath}", filePath);
}
}