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,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;
}
}