Initial Commit
This commit is contained in:
84
PatchProbe.Cli/Collectors/CbsDismCollector.cs
Normal file
84
PatchProbe.Cli/Collectors/CbsDismCollector.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
51
PatchProbe.Cli/Collectors/DeviceCollector.cs
Normal file
51
PatchProbe.Cli/Collectors/DeviceCollector.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
60
PatchProbe.Cli/Collectors/DriverCollector.cs
Normal file
60
PatchProbe.Cli/Collectors/DriverCollector.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
78
PatchProbe.Cli/Collectors/EventCollector.cs
Normal file
78
PatchProbe.Cli/Collectors/EventCollector.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
48
PatchProbe.Cli/Collectors/HotfixCollector.cs
Normal file
48
PatchProbe.Cli/Collectors/HotfixCollector.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
48
PatchProbe.Cli/Collectors/OsCollector.cs
Normal file
48
PatchProbe.Cli/Collectors/OsCollector.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
64
PatchProbe.Cli/Collectors/PendingRebootCollector.cs
Normal file
64
PatchProbe.Cli/Collectors/PendingRebootCollector.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
145
PatchProbe.Cli/Collectors/WindowsUpdateCollector.cs
Normal file
145
PatchProbe.Cli/Collectors/WindowsUpdateCollector.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
49
PatchProbe.Cli/Collectors/WindowsUpdatePolicyCollector.cs
Normal file
49
PatchProbe.Cli/Collectors/WindowsUpdatePolicyCollector.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user