Initial Commit
This commit is contained in:
6
PatchProbe.Cli/Auth/DeviceCredentials.cs
Normal file
6
PatchProbe.Cli/Auth/DeviceCredentials.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace PatchProbe.Cli.Auth;
|
||||
|
||||
internal sealed record DeviceCredentials(
|
||||
string DeviceId,
|
||||
string PrivateKeyPkcs8,
|
||||
string ServerUrl);
|
||||
55
PatchProbe.Cli/Auth/DpapiCredentialStore.cs
Normal file
55
PatchProbe.Cli/Auth/DpapiCredentialStore.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
31
PatchProbe.Cli/Auth/EcdsaRequestAuthenticator.cs
Normal file
31
PatchProbe.Cli/Auth/EcdsaRequestAuthenticator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
9
PatchProbe.Cli/Auth/IDeviceCredentialStore.cs
Normal file
9
PatchProbe.Cli/Auth/IDeviceCredentialStore.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace PatchProbe.Cli.Auth;
|
||||
|
||||
internal interface IDeviceCredentialStore
|
||||
{
|
||||
bool IsEnrolled { get; }
|
||||
DeviceCredentials Load();
|
||||
void Save(DeviceCredentials credentials);
|
||||
void Delete();
|
||||
}
|
||||
7
PatchProbe.Cli/Auth/IRequestAuthenticator.cs
Normal file
7
PatchProbe.Cli/Auth/IRequestAuthenticator.cs
Normal 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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
36
PatchProbe.Cli/PatchProbe.Cli.csproj
Normal file
36
PatchProbe.Cli/PatchProbe.Cli.csproj
Normal 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
244
PatchProbe.Cli/Program.cs
Normal 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);
|
||||
}
|
||||
54
PatchProbe.Cli/Services/EnrollmentService.cs
Normal file
54
PatchProbe.Cli/Services/EnrollmentService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
60
PatchProbe.Cli/Services/PayloadUploader.cs
Normal file
60
PatchProbe.Cli/Services/PayloadUploader.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user