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

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Dependencies
node_modules/
# Built dashboard output (generated by npm run build)
PatchProbe.Server/public/
# SQLite database files
*.db
*.db-shm
*.db-wal
# Environment / secrets
.env
*.env.local
# .NET build output
bin/
obj/
# Logs
logs/
*.log
# OS
.DS_Store
Thumbs.db
.vs/
.claude/

615
CLAUDE.md Normal file
View File

@@ -0,0 +1,615 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build
```bash
# Debug build
dotnet build PatchProbe.Cli/PatchProbe.Cli.csproj
# Self-contained single-file win-x64 release
dotnet publish PatchProbe.Cli/PatchProbe.Cli.csproj -c Release
# Output: PatchProbe.Cli/bin/Release/net10.0-windows/win-x64/publish/PatchProbe.exe
```
## Run
```bash
# Collect evidence, write JSON to output/ directory
PatchProbe.exe scan
# Write payload to a specific path
PatchProbe.exe scan --output C:\temp\scan.json
# Collect but print JSON to stdout instead of uploading
PatchProbe.exe scan --no-upload
```
Note: must be run as administrator for full data access (WUA, event logs, WMI).
---
# Project Overview
I am building a Windows patch-management evidence collection and orchestration platform similar in concept to the patch-management component of major RMM tools.
The goal is NOT to replace Windows Update Agent (WUA), CBS, DISM, or the Windows driver-selection stack.
Instead, the platform should act as a centralized control plane that:
* collects patch-related evidence from endpoints
* evaluates compliance
* manages policy/rings/approvals
* eventually orchestrates installations
* reports status and failures
Windows itself remains authoritative for:
* update applicability
* prerequisites
* supersedence
* CBS/component servicing logic
* driver matching/ranking
* installation success/failure
The collector should be:
* portable
* standalone
* self-contained
* executable as administrator
* non-persistent
* non-installed
* safe/read-only initially
The first milestone is NOT patch installation.
The first milestone is proving that a one-shot executable can collect equivalent patch-management evidence to what an RMM platform sees.
---
# Current State
I have already:
* created a new C# Console App
* selected .NET 9
* chosen a self-contained single-file EXE architecture
The collector executable should eventually be called:
```text
PatchProbe.exe
```
---
# Architecture Requirements
The application should use:
* C#
* .NET 9
* Generic Host
* Dependency Injection
* HttpClientFactory
* Structured logging
* Clean collector/service architecture
The project should remain a console application.
Do NOT use:
* WinForms
* WPF
* ASP.NET
* Worker Service
* MAUI
The collector flow is:
```text
PatchProbe.exe
→ collect evidence
→ serialize to JSON
→ upload to backend
→ exit
```
The collector should not:
* install updates
* download updates
* modify policy
* create scheduled tasks
* register services
* persist locally beyond optional logs/cache
---
# Desired Solution Structure
The solution should be scaffolded approximately like this:
```text
PatchProbe.sln
├── PatchProbe.Cli/
│ ├── Program.cs
│ ├── Collectors/
│ ├── Models/
│ ├── Services/
│ └── Utils/
├── PatchProbe.Shared/
│ ├── Models/
│ ├── Contracts/
│ └── Serialization/
└── PatchProbe.Engine.Contracts/
├── ApiModels/
└── Versioning/
```
Even if some projects begin mostly empty.
---
# NuGet Packages To Use
Immediately add:
```bash
dotnet add package System.Management
dotnet add package Serilog
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
dotnet add package System.CommandLine
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Http
```
---
# Program.cs Architecture
Use Generic Host architecture.
Example direction:
```csharp
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
var builder = Host.CreateApplicationBuilder(args);
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.WriteTo.File("logs/patchprobe.log")
.CreateLogger();
builder.Services.AddSingleton<DeviceCollector>();
builder.Services.AddSingleton<WindowsUpdateCollector>();
builder.Services.AddSingleton<PayloadUploader>();
var host = builder.Build();
var deviceCollector = host.Services.GetRequiredService<DeviceCollector>();
var wuCollector = host.Services.GetRequiredService<WindowsUpdateCollector>();
var device = await deviceCollector.CollectAsync();
var updates = await wuCollector.CollectAsync();
Console.WriteLine("Collection complete.");
```
Maintain proper layering and separation of concerns.
---
# Core Philosophy
Treat collected data as immutable evidence.
Meaning:
```text
PatchProbe scan = factual snapshot
```
NOT:
* interpreted state
* compliance judgment
* install decisions
Those belong in the backend engine later.
The backend evaluates:
* policy
* compliance
* approval
* orchestration
* ring logic
The collector only gathers evidence.
---
# Windows Patch-Management Philosophy
The engine does NOT replace:
* Windows Update Agent
* CBS
* TrustedInstaller
* DISM
* Windows driver ranking
Instead:
```text
The platform provides:
- policy
- scheduling
- approvals
- maintenance windows
- telemetry
- compliance reporting
- failure classification
- orchestration
Windows provides:
- applicability
- prerequisites
- supersedence
- servicing logic
- driver selection
- install success/failure
```
---
# Required Initial Collectors
Implement collectors in this approximate order.
## 1. DeviceCollector
Collect:
* hostname
* manufacturer
* model
* serial number
* BIOS version
* BIOS date
* TPM presence/version
* RAM
* domain/workgroup
* system type
Use:
* WMI/CIM
* registry
* environment info
---
## 2. OsCollector
Collect:
* ProductName
* EditionID
* DisplayVersion
* ReleaseId
* Build number
* UBR
* architecture
* install date
* last boot
Registry path:
```text
HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion
```
---
## 3. WindowsUpdateCollector
This is the core component.
Use WUA COM APIs.
Use:
* Microsoft.Update.Session
* CreateUpdateSearcher()
Collect:
* applicable updates
* update titles
* KB IDs
* categories
* severity
* reboot requirements
* WUA result codes
* update history
* WU source/policy state
Search criteria:
```text
IsInstalled=0 and IsHidden=0
```
Use COM interop or dynamic COM access.
Example direction:
```csharp
Type sessionType = Type.GetTypeFromProgID("Microsoft.Update.Session");
dynamic session = Activator.CreateInstance(sessionType);
dynamic searcher = session.CreateUpdateSearcher();
dynamic result = searcher.Search("IsInstalled=0 and IsHidden=0");
```
The collector should ask Windows what is applicable rather than attempting to independently determine applicability.
---
# Important Patch-Management Concepts
Windows decides:
* whether an update is applicable
* whether prerequisites are met
* supersedence
* CBS/component servicing logic
* driver ranking/matching
* installation success
The platform should observe and orchestrate, not replace these systems.
---
# 4. PendingRebootCollector
Collect pending reboot indicators from:
* CBS
* Windows Update
* Session Manager
* Computer rename state
Registry locations include:
```text
HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending
HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired
HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager
```
---
# 5. DriverCollector
Collect:
* device name
* driver version
* manufacturer
* INF name
* hardware IDs
* device class
* driver date
Use:
* Win32_PnPSignedDriver
* pnputil where useful
Important:
Do NOT attempt to replace Windows driver ranking logic.
---
# 6. CBS / DISM Collector
Collect:
* package state
* servicing state
* DISM package output
Use:
```text
dism /online /get-packages /format:table
```
Treat CBS as the servicing authority.
---
# 7. Windows Update Policy Collector
Collect policy/configuration from:
* WSUS
* WUfB
* AU settings
* deferrals
Registry paths include:
```text
HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate
HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU
HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings
```
---
# 8. Event Collector
Collect recent relevant events from:
* System
* Microsoft-Windows-WindowsUpdateClient/Operational
* Microsoft-Windows-UpdateOrchestrator/Operational
Focus on:
* failures
* installs
* servicing issues
* reboot events
---
# Payload Design
The collector should produce a JSON payload approximately shaped like:
```json
{
"schemaVersion": "0.1",
"collector": {},
"device": {},
"os": {},
"pendingReboot": {},
"windowsUpdate": {
"applicableUpdates": [],
"history": [],
"policy": {}
},
"installedHotfixes": [],
"cbsPackages": [],
"drivers": [],
"recentUpdateEvents": []
}
```
This payload should be treated as immutable evidence.
---
# CLI Shape
The executable should eventually support:
```text
PatchProbe.exe scan
PatchProbe.exe upload
PatchProbe.exe export
PatchProbe.exe debug
PatchProbe.exe validate
```
Initially only implement:
```text
PatchProbe.exe scan
```
---
# Publishing Requirements
The application should be published as:
* self-contained
* single-file
* win-x64 executable
Example publish command:
```bash
dotnet publish ^
-c Release ^
-r win-x64 ^
--self-contained true ^
-p:PublishSingleFile=true ^
-p:EnableCompressionInSingleFile=true
```
Result:
* one EXE
* no installed runtime dependency
---
# Important Design Constraints
Do NOT:
* tightly couple collection and analysis
* tightly couple collection and orchestration
* make compliance decisions in the collector
Maintain:
```text
Collector
→ evidence payload
Backend
→ analysis/policy/compliance
Frontend
→ visualization/reporting
```
---
# Future Evolution Path
The architecture should naturally evolve into:
```text
Phase 1:
One-shot evidence collector
Phase 2:
Scheduled execution
Phase 3:
Patch orchestration
Phase 4:
Remediation engine
Phase 5:
Approval rings/policies
Phase 6:
Full RMM-style patch-management platform
```
---
# Current Request
Based on all of the above:
* scaffold the initial project architecture
* implement the first collectors
* implement payload models
* implement logging
* implement WUA scanning
* implement JSON serialization
* implement upload abstraction
* implement clean DI architecture
* implement a production-quality starting structure suitable for future expansion

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

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PatchProbe</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2543
PatchProbe.Dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
{
"name": "patchprobe-dashboard",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@simplewebauthn/browser": "^13.0.0",
"@tanstack/react-query": "^5.56.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-react": "^4.3.1",
"tailwindcss": "^4.0.0",
"vite": "^6.0.0"
}
}

View File

@@ -0,0 +1,44 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './hooks/useAuth.jsx';
import Layout from './components/Layout.jsx';
import Login from './pages/Login.jsx';
import Dashboard from './pages/Dashboard.jsx';
import Devices from './pages/Devices.jsx';
import DeviceDetail from './pages/DeviceDetail.jsx';
import Scans from './pages/Scans.jsx';
import ScanDetail from './pages/ScanDetail.jsx';
import Admin from './pages/Admin.jsx';
function PrivateRoute({ children }) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) return <div className="flex h-screen items-center justify-center text-zinc-400">Loading</div>;
if (!isAuthenticated) return <Navigate to="/login" replace />;
return children;
}
function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}>
<Route index element={<Dashboard />} />
<Route path="devices" element={<Devices />} />
<Route path="devices/:id" element={<DeviceDetail />} />
<Route path="scans" element={<Scans />} />
<Route path="scans/:id" element={<ScanDetail />} />
<Route path="admin" element={<Admin />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,62 @@
import { useState } from 'react';
function JsonNode({ data, depth = 0 }) {
const [collapsed, setCollapsed] = useState(depth > 2);
if (data === null) return <span className="text-red-400">null</span>;
if (data === true) return <span className="text-blue-400">true</span>;
if (data === false) return <span className="text-blue-400">false</span>;
if (typeof data === 'number') return <span className="text-amber-400">{data}</span>;
if (typeof data === 'string') return <span className="text-green-400">"{data}"</span>;
const isArray = Array.isArray(data);
const entries = isArray ? data.map((v, i) => [i, v]) : Object.entries(data);
const open = isArray ? '[' : '{';
const close = isArray ? ']' : '}';
if (entries.length === 0) {
return <span className="text-zinc-400">{open}{close}</span>;
}
return (
<span>
<button
onClick={() => setCollapsed(c => !c)}
className="text-zinc-500 hover:text-zinc-300 mr-1 font-mono text-xs"
>
{collapsed ? '▶' : '▼'}
</button>
<span className="text-zinc-400">{open}</span>
{collapsed ? (
<button
onClick={() => setCollapsed(false)}
className="text-zinc-500 hover:text-zinc-300 text-xs mx-1"
>
{entries.length} {isArray ? 'items' : 'keys'}
</button>
) : (
<div style={{ paddingLeft: '1.25rem' }}>
{entries.map(([key, val], idx) => (
<div key={key}>
{!isArray && (
<span className="text-zinc-300">"{key}"</span>
)}
{!isArray && <span className="text-zinc-500">: </span>}
<JsonNode data={val} depth={depth + 1} />
{idx < entries.length - 1 && <span className="text-zinc-600">,</span>}
</div>
))}
</div>
)}
{!collapsed && <span className="text-zinc-400">{close}</span>}
</span>
);
}
export default function JsonViewer({ data }) {
return (
<pre className="font-mono text-sm leading-relaxed whitespace-pre-wrap break-all">
<JsonNode data={data} depth={0} />
</pre>
);
}

View File

@@ -0,0 +1,64 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth.jsx';
const navItems = [
{ to: '/', label: 'Dashboard', icon: '▦' },
{ to: '/devices', label: 'Devices', icon: '⬡' },
{ to: '/scans', label: 'Scans', icon: '≡' },
{ to: '/admin', label: 'Admin', icon: '⚙' },
];
export default function Layout() {
const { user, logout } = useAuth();
return (
<div className="flex h-screen bg-zinc-950 overflow-hidden">
{/* Sidebar */}
<aside className="w-52 flex flex-col flex-shrink-0 bg-zinc-900 border-r border-zinc-800">
{/* Logo */}
<div className="px-5 py-5 border-b border-zinc-800">
<span className="text-lg font-semibold tracking-tight text-zinc-100">PatchProbe</span>
</div>
{/* Nav */}
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
{navItems.map(({ to, label, icon }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
isActive
? 'bg-indigo-600 text-white'
: 'text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800'
}`
}
>
<span className="text-base leading-none">{icon}</span>
{label}
</NavLink>
))}
</nav>
{/* User + logout */}
<div className="px-4 py-4 border-t border-zinc-800">
<p className="text-xs text-zinc-500 truncate mb-2">{user?.displayName ?? user?.username}</p>
<button
onClick={() => logout()}
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
>
Sign out
</button>
</div>
</aside>
{/* Main */}
<main className="flex-1 overflow-y-auto">
<div className="max-w-6xl mx-auto px-8 py-8">
<Outlet />
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,14 @@
export default function StatusBadge({ variant, children }) {
const styles = {
green: 'bg-green-950 text-green-400 border-green-800',
red: 'bg-red-950 text-red-400 border-red-800',
amber: 'bg-amber-950 text-amber-400 border-amber-800',
indigo: 'bg-indigo-950 text-indigo-400 border-indigo-800',
zinc: 'bg-zinc-800 text-zinc-400 border-zinc-700',
};
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${styles[variant] ?? styles.zinc}`}>
{children}
</span>
);
}

View File

@@ -0,0 +1,36 @@
import { createContext, useContext } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { authApi } from '../lib/api.js';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const queryClient = useQueryClient();
const { data: user, isLoading, error } = useQuery({
queryKey: ['me'],
queryFn: authApi.me,
retry: false,
staleTime: 5 * 60_000,
});
const logoutMutation = useMutation({
mutationFn: authApi.logout,
onSuccess: () => {
queryClient.clear();
window.location.href = '/login';
},
});
const isAuthenticated = !!user && !error;
return (
<AuthContext.Provider value={{ user, isLoading, isAuthenticated, logout: logoutMutation.mutate }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}

View File

@@ -0,0 +1,8 @@
@import "tailwindcss";
@layer base {
body {
background-color: theme(--color-zinc-950);
color: theme(--color-zinc-100);
}
}

View File

@@ -0,0 +1,56 @@
class ApiError extends Error {
constructor(message, status) {
super(message);
this.status = status;
}
}
async function request(method, path, body) {
const opts = { method, credentials: 'include', headers: {} };
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(path, opts);
if (res.status === 204) return null;
const data = await res.json().catch(() => ({ error: res.statusText }));
if (!res.ok) throw new ApiError(data.error ?? 'Request failed', res.status);
return data;
}
export const api = {
get: (path) => request('GET', path),
post: (path, body) => request('POST', path, body),
delete: (path) => request('DELETE', path),
};
// Typed helpers for each resource
export const authApi = {
status: () => api.get('/api/auth/status'),
me: () => api.get('/api/auth/me'),
logout: () => api.post('/api/auth/logout'),
registerBegin: (body) => api.post('/api/auth/register/begin', body),
registerFinish: (body) => api.post('/api/auth/register/finish', body),
loginBegin: () => api.post('/api/auth/login/begin'),
loginFinish: (body) => api.post('/api/auth/login/finish', body),
passkeys: () => api.get('/api/auth/passkeys'),
deletePasskey: (id) => api.delete(`/api/auth/passkeys/${encodeURIComponent(id)}`),
};
export const devicesApi = {
list: () => api.get('/api/admin/devices'),
revoke: (id) => api.delete(`/api/admin/devices/${id}`),
scans: (id) => api.get(`/api/admin/devices/${id}/scans`),
};
export const scansApi = {
list: () => api.get('/api/scans'),
get: (id) => api.get(`/api/scans/${id}`),
remove: (id) => api.delete(`/api/scans/${id}`),
};
export const tokensApi = {
list: () => api.get('/api/admin/tokens'),
create: (body) => api.post('/api/admin/tokens', body),
revoke: (tok) => api.delete(`/api/admin/tokens/${tok}`),
};

View File

@@ -0,0 +1,22 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import './index.css';
import App from './App.jsx';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
},
},
});
createRoot(document.getElementById('root')).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,281 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { tokensApi, authApi } from '../lib/api.js';
import StatusBadge from '../components/StatusBadge.jsx';
import { startRegistration } from '@simplewebauthn/browser';
function fmt(iso) {
if (!iso) return '—';
return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'short' }).format(new Date(iso));
}
// ---------------------------------------------------------------------------
// Enrollment tokens
// ---------------------------------------------------------------------------
function TokensSection() {
const queryClient = useQueryClient();
const [form, setForm] = useState({ label: '', expiresInDays: '', maxUses: '' });
const [created, setCreated] = useState(null);
const { data: tokens = [] } = useQuery({ queryKey: ['tokens'], queryFn: tokensApi.list });
const createMutation = useMutation({
mutationFn: (body) => tokensApi.create(body),
onSuccess: (data) => {
setCreated(data.token);
setForm({ label: '', expiresInDays: '', maxUses: '' });
queryClient.invalidateQueries({ queryKey: ['tokens'] });
},
});
const revokeMutation = useMutation({
mutationFn: tokensApi.revoke,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tokens'] }),
});
function submit(e) {
e.preventDefault();
const body = { label: form.label };
if (form.expiresInDays) body.expiresInDays = parseInt(form.expiresInDays, 10);
if (form.maxUses) body.maxUses = parseInt(form.maxUses, 10);
createMutation.mutate(body);
}
return (
<div className="space-y-4">
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wider">Enrollment Tokens</h2>
{/* Created token — show once */}
{created && (
<div className="bg-indigo-950 border border-indigo-800 rounded-xl p-4">
<p className="text-xs text-indigo-400 mb-1 font-medium">Token created copy now, it won't be shown again:</p>
<code className="text-sm text-indigo-200 break-all">{created}</code>
<button onClick={() => setCreated(null)} className="mt-2 block text-xs text-indigo-500 hover:text-indigo-400">
Dismiss
</button>
</div>
)}
{/* Create form */}
<form onSubmit={submit} className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<p className="text-sm font-medium text-zinc-300 mb-4">Create token</p>
<div className="grid grid-cols-3 gap-3 mb-3">
<div className="col-span-3 md:col-span-1">
<label className="block text-xs text-zinc-500 mb-1">Label *</label>
<input
required value={form.label} onChange={e => setForm(f => ({ ...f, label: e.target.value }))}
placeholder="lab-devices"
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Expires in (days)</label>
<input
type="number" min="1" value={form.expiresInDays} onChange={e => setForm(f => ({ ...f, expiresInDays: e.target.value }))}
placeholder="never"
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Max uses</label>
<input
type="number" min="1" value={form.maxUses} onChange={e => setForm(f => ({ ...f, maxUses: e.target.value }))}
placeholder="unlimited"
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
<button
type="submit" disabled={createMutation.isPending}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-4 py-2 rounded-md transition-colors"
>
{createMutation.isPending ? 'Creating' : 'Create token'}
</button>
</form>
{/* Token list */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-xs text-zinc-500">
<th className="text-left px-4 py-3 font-medium">Token</th>
<th className="text-left px-4 py-3 font-medium">Label</th>
<th className="text-left px-4 py-3 font-medium">Expires</th>
<th className="text-right px-4 py-3 font-medium">Uses</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th className="text-right px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{tokens.length === 0 && (
<tr><td colSpan={6} className="px-4 py-6 text-center text-zinc-500">No tokens</td></tr>
)}
{tokens.map(t => (
<tr key={t.tokenMasked + t.label} className="hover:bg-zinc-800/40">
<td className="px-4 py-3 font-mono text-xs text-zinc-400">{t.tokenMasked}</td>
<td className="px-4 py-3 text-zinc-300">{t.label}</td>
<td className="px-4 py-3 text-zinc-400 text-xs">{fmt(t.expiresAt)}</td>
<td className="px-4 py-3 text-right text-zinc-400 text-xs">
{t.usedCount}{t.maxUses ? ` / ${t.maxUses}` : ''}
</td>
<td className="px-4 py-3">
{t.revokedAt
? <StatusBadge variant="red">Revoked</StatusBadge>
: t.active
? <StatusBadge variant="green">Active</StatusBadge>
: <StatusBadge variant="zinc">Exhausted</StatusBadge>}
</td>
<td className="px-4 py-3 text-right">
{!t.revokedAt && (
<button
onClick={() => { if (confirm('Revoke this token?')) revokeMutation.mutate(t.id); }}
className="text-xs text-red-500 hover:text-red-400"
>
Revoke
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Passkeys
// ---------------------------------------------------------------------------
function PasskeysSection() {
const queryClient = useQueryClient();
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [regForm, setRegForm] = useState({ username: '', displayName: '' });
const [showRegForm, setShowRegForm] = useState(false);
const { data: passkeys = [] } = useQuery({ queryKey: ['passkeys'], queryFn: authApi.passkeys });
const deleteMutation = useMutation({
mutationFn: authApi.deletePasskey,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['passkeys'] }),
});
async function addPasskey(e) {
e.preventDefault();
setError('');
setBusy(true);
try {
const options = await authApi.registerBegin(regForm);
const credential = await startRegistration({ optionsJSON: options });
await authApi.registerFinish(credential);
setShowRegForm(false);
setRegForm({ username: '', displayName: '' });
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
} catch (err) {
setError(err.message ?? 'Registration failed');
} finally {
setBusy(false);
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wider">Passkeys</h2>
<button
onClick={() => setShowRegForm(s => !s)}
className="text-xs text-indigo-400 hover:text-indigo-300"
>
{showRegForm ? 'Cancel' : '+ Add passkey'}
</button>
</div>
{error && (
<div className="bg-red-950 border border-red-800 text-red-400 text-sm px-3 py-2 rounded-md">{error}</div>
)}
{showRegForm && (
<form onSubmit={addPasskey} className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 space-y-3">
<p className="text-sm font-medium text-zinc-300">Register new passkey</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-zinc-500 mb-1">Username</label>
<input required value={regForm.username} onChange={e => setRegForm(f => ({ ...f, username: e.target.value }))}
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Display name</label>
<input required value={regForm.displayName} onChange={e => setRegForm(f => ({ ...f, displayName: e.target.value }))}
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
<button type="submit" disabled={busy}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-4 py-2 rounded-md transition-colors"
>
{busy ? 'Waiting for passkey' : 'Register passkey'}
</button>
</form>
)}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-xs text-zinc-500">
<th className="text-left px-4 py-3 font-medium">ID</th>
<th className="text-left px-4 py-3 font-medium">Type</th>
<th className="text-left px-4 py-3 font-medium">Registered</th>
<th className="text-left px-4 py-3 font-medium">Last used</th>
<th className="text-right px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{passkeys.length === 0 && (
<tr><td colSpan={5} className="px-4 py-6 text-center text-zinc-500">No passkeys</td></tr>
)}
{passkeys.map(p => (
<tr key={p.id} className="hover:bg-zinc-800/40">
<td className="px-4 py-3 font-mono text-xs text-zinc-400">{p.idMasked}</td>
<td className="px-4 py-3">
<StatusBadge variant={p.backedUp ? 'indigo' : 'zinc'}>
{p.deviceType ?? 'unknown'}
</StatusBadge>
</td>
<td className="px-4 py-3 text-zinc-400 text-xs">{fmt(p.createdAt)}</td>
<td className="px-4 py-3 text-zinc-400 text-xs">{fmt(p.lastUsedAt)}</td>
<td className="px-4 py-3 text-right">
{passkeys.length > 1 && (
<button
onClick={() => { if (confirm('Remove this passkey?')) deleteMutation.mutate(p.id); }}
className="text-xs text-red-500 hover:text-red-400"
>
Remove
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function Admin() {
return (
<div className="space-y-10">
<h1 className="text-xl font-semibold text-zinc-100">Admin</h1>
<TokensSection />
<PasskeysSection />
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { devicesApi, scansApi } from '../lib/api.js';
function StatCard({ label, value, sub, to }) {
const inner = (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{label}</p>
<p className="text-3xl font-semibold text-zinc-100">{value ?? '—'}</p>
{sub && <p className="text-xs text-zinc-500 mt-1">{sub}</p>}
</div>
);
return to ? <Link to={to} className="hover:border-indigo-700 transition-colors rounded-xl block">{inner}</Link> : inner;
}
function fmt(iso) {
if (!iso) return '—';
return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'short' }).format(new Date(iso));
}
export default function Dashboard() {
const { data: devices } = useQuery({ queryKey: ['devices'], queryFn: devicesApi.list });
const { data: scans } = useQuery({ queryKey: ['scans'], queryFn: scansApi.list });
const activeDevices = devices?.filter(d => !d.revoked) ?? [];
const revokedDevices = devices?.filter(d => d.revoked) ?? [];
const pendingReboots = scans?.filter(s => s.pendingReboot) ?? [];
const recent = scans?.slice(0, 10) ?? [];
return (
<div className="space-y-8">
<h1 className="text-xl font-semibold text-zinc-100">Dashboard</h1>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard label="Active Devices" value={activeDevices.length} sub={`${revokedDevices.length} revoked`} to="/devices" />
<StatCard label="Total Scans" value={scans?.length} sub="all time" to="/scans" />
<StatCard label="Pending Reboots" value={pendingReboots.length} sub="across all scans" />
<StatCard label="Updates Detected" value={scans?.reduce((n, s) => n + (s.applicableUpdateCount ?? 0), 0)} sub="total across all scans" />
</div>
{/* Recent scans */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wider">Recent Scans</h2>
<Link to="/scans" className="text-xs text-indigo-400 hover:text-indigo-300">View all </Link>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-xs text-zinc-500">
<th className="text-left px-4 py-3 font-medium">Machine</th>
<th className="text-left px-4 py-3 font-medium">Collected</th>
<th className="text-right px-4 py-3 font-medium">Updates</th>
<th className="text-right px-4 py-3 font-medium">Reboot</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{recent.length === 0 && (
<tr><td colSpan={4} className="px-4 py-6 text-center text-zinc-500">No scans yet</td></tr>
)}
{recent.map(s => (
<tr key={s.id} className="hover:bg-zinc-800/50">
<td className="px-4 py-3">
<Link to={`/scans/${s.id}`} className="text-zinc-100 hover:text-indigo-400">
{s.machineName}
</Link>
</td>
<td className="px-4 py-3 text-zinc-400">{fmt(s.collectedAt)}</td>
<td className="px-4 py-3 text-right text-zinc-300">{s.applicableUpdateCount}</td>
<td className="px-4 py-3 text-right">
{s.pendingReboot
? <span className="text-amber-400">Yes</span>
: <span className="text-zinc-500">No</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { devicesApi } from '../lib/api.js';
import StatusBadge from '../components/StatusBadge.jsx';
function fmt(iso) {
if (!iso) return '—';
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
}
export default function DeviceDetail() {
const { id } = useParams();
const { data: devices = [] } = useQuery({ queryKey: ['devices'], queryFn: devicesApi.list });
const { data: scans = [], isLoading } = useQuery({
queryKey: ['device-scans', id],
queryFn: () => devicesApi.scans(id),
});
const device = devices.find(d => d.id === id);
return (
<div className="space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-zinc-500">
<Link to="/devices" className="hover:text-zinc-300">Devices</Link>
<span>/</span>
<span className="text-zinc-300">{device?.machineName ?? id}</span>
</div>
{/* Device info */}
{device && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-xs text-zinc-500 mb-1">Machine name</p>
<p className="text-sm text-zinc-100 font-medium">{device.machineName}</p>
</div>
<div>
<p className="text-xs text-zinc-500 mb-1">Status</p>
{device.revoked
? <StatusBadge variant="red">Revoked</StatusBadge>
: <StatusBadge variant="green">Active</StatusBadge>}
</div>
<div>
<p className="text-xs text-zinc-500 mb-1">Enrolled</p>
<p className="text-xs text-zinc-300">{fmt(device.enrolledAt)}</p>
</div>
<div>
<p className="text-xs text-zinc-500 mb-1">Last seen</p>
<p className="text-xs text-zinc-300">{fmt(device.lastSeenAt)}</p>
</div>
<div className="col-span-2 md:col-span-4">
<p className="text-xs text-zinc-500 mb-1">Device ID</p>
<p className="text-xs text-zinc-500 font-mono">{device.id}</p>
</div>
{device.deviceFingerprint && (
<div className="col-span-2 md:col-span-4">
<p className="text-xs text-zinc-500 mb-1">Fingerprint</p>
<p className="text-xs text-zinc-500 font-mono">{device.deviceFingerprint}</p>
</div>
)}
</div>
)}
{/* Scans */}
<div>
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wider mb-3">Scan History</h2>
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-xs text-zinc-500">
<th className="text-left px-4 py-3 font-medium">Collected</th>
<th className="text-right px-4 py-3 font-medium">Updates</th>
<th className="text-left px-4 py-3 font-medium">Reboot</th>
<th className="text-left px-4 py-3 font-medium">Admin</th>
<th className="text-right px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{isLoading && (
<tr><td colSpan={5} className="px-4 py-6 text-center text-zinc-500">Loading</td></tr>
)}
{!isLoading && scans.length === 0 && (
<tr><td colSpan={5} className="px-4 py-6 text-center text-zinc-500">No scans yet</td></tr>
)}
{scans.map(s => (
<tr key={s.id} className="hover:bg-zinc-800/40">
<td className="px-4 py-3 text-zinc-300">{fmt(s.collectedAt)}</td>
<td className="px-4 py-3 text-right text-zinc-300">{s.applicableUpdateCount}</td>
<td className="px-4 py-3">
{s.pendingReboot
? <StatusBadge variant="amber">Yes</StatusBadge>
: <span className="text-zinc-500 text-xs">No</span>}
</td>
<td className="px-4 py-3">
{s.ranAsAdministrator
? <StatusBadge variant="indigo">Admin</StatusBadge>
: <span className="text-zinc-500 text-xs">No</span>}
</td>
<td className="px-4 py-3 text-right">
<Link to={`/scans/${s.id}`} className="text-xs text-indigo-400 hover:text-indigo-300">View </Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { devicesApi } from '../lib/api.js';
import StatusBadge from '../components/StatusBadge.jsx';
function fmt(iso) {
if (!iso) return '—';
return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'short' }).format(new Date(iso));
}
export default function Devices() {
const queryClient = useQueryClient();
const [filter, setFilter] = useState('active'); // 'active' | 'all'
const { data: devices = [], isLoading } = useQuery({ queryKey: ['devices'], queryFn: devicesApi.list });
const revokeMutation = useMutation({
mutationFn: devicesApi.revoke,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['devices'] }),
});
const visible = filter === 'active' ? devices.filter(d => !d.revoked) : devices;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-zinc-100">Devices</h1>
<div className="flex gap-1 bg-zinc-800 rounded-lg p-0.5">
{['active', 'all'].map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1.5 text-xs rounded-md capitalize transition-colors ${
filter === f ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-500 hover:text-zinc-300'
}`}
>
{f}
</button>
))}
</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-xs text-zinc-500">
<th className="text-left px-4 py-3 font-medium">Machine</th>
<th className="text-left px-4 py-3 font-medium">Enrolled</th>
<th className="text-left px-4 py-3 font-medium">Last Seen</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th className="text-right px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{isLoading && (
<tr><td colSpan={5} className="px-4 py-8 text-center text-zinc-500">Loading</td></tr>
)}
{!isLoading && visible.length === 0 && (
<tr><td colSpan={5} className="px-4 py-8 text-center text-zinc-500">No devices found</td></tr>
)}
{visible.map(d => (
<tr key={d.id} className="hover:bg-zinc-800/40">
<td className="px-4 py-3">
<Link to={`/devices/${d.id}`} className="text-zinc-100 hover:text-indigo-400 font-medium">
{d.machineName}
</Link>
<p className="text-xs text-zinc-600 mt-0.5 font-mono">{d.id.slice(0, 8)}</p>
</td>
<td className="px-4 py-3 text-zinc-400 text-xs">{fmt(d.enrolledAt)}</td>
<td className="px-4 py-3 text-zinc-400 text-xs">{fmt(d.lastSeenAt)}</td>
<td className="px-4 py-3">
{d.revoked
? <StatusBadge variant="red">Revoked</StatusBadge>
: <StatusBadge variant="green">Active</StatusBadge>}
</td>
<td className="px-4 py-3 text-right">
{!d.revoked && (
<button
onClick={() => {
if (confirm(`Revoke ${d.machineName}?`)) revokeMutation.mutate(d.id);
}}
className="text-xs text-red-500 hover:text-red-400 transition-colors"
>
Revoke
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
import { authApi } from '../lib/api.js';
export default function Login() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [status, setStatus] = useState(null); // { hasUsers }
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [regForm, setRegForm] = useState({ username: '', displayName: '' });
useEffect(() => {
authApi.status().then(setStatus).catch(() => setStatus({ hasUsers: true }));
}, []);
async function onSignIn() {
setError('');
setBusy(true);
try {
const options = await authApi.loginBegin();
const credential = await startAuthentication({ optionsJSON: options });
await authApi.loginFinish(credential);
queryClient.invalidateQueries({ queryKey: ['me'] });
navigate('/');
} catch (err) {
setError(err.message ?? 'Sign-in failed');
} finally {
setBusy(false);
}
}
async function onRegister(e) {
e.preventDefault();
setError('');
setBusy(true);
try {
const options = await authApi.registerBegin(regForm);
const credential = await startRegistration({ optionsJSON: options });
await authApi.registerFinish(credential);
queryClient.invalidateQueries({ queryKey: ['me'] });
navigate('/');
} catch (err) {
setError(err.message ?? 'Registration failed');
} finally {
setBusy(false);
}
}
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center px-4">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="text-center mb-8">
<h1 className="text-2xl font-semibold text-zinc-100">PatchProbe</h1>
<p className="text-sm text-zinc-500 mt-1">Patch management dashboard</p>
</div>
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
{/* Error */}
{error && (
<div className="bg-red-950 border border-red-800 text-red-400 text-sm px-3 py-2 rounded-md">
{error}
</div>
)}
{/* First-time setup */}
{status && !status.hasUsers && (
<div>
<p className="text-sm text-zinc-400 mb-4">
No admin account exists yet. Register your first passkey to get started.
</p>
<form onSubmit={onRegister} className="space-y-3">
<div>
<label className="block text-xs text-zinc-500 mb-1">Username</label>
<input
type="text"
required
value={regForm.username}
onChange={e => setRegForm(f => ({ ...f, username: e.target.value }))}
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
placeholder="admin"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Display name</label>
<input
type="text"
required
value={regForm.displayName}
onChange={e => setRegForm(f => ({ ...f, displayName: e.target.value }))}
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
placeholder="Admin"
/>
</div>
<button
type="submit"
disabled={busy}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-medium py-2.5 rounded-md transition-colors"
>
{busy ? 'Registering…' : 'Register passkey'}
</button>
</form>
</div>
)}
{/* Sign in */}
{status && status.hasUsers && (
<div>
<p className="text-sm text-zinc-400 mb-4">
Sign in using a passkey registered on this device.
</p>
<button
onClick={onSignIn}
disabled={busy}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-medium py-2.5 rounded-md transition-colors"
>
{busy ? 'Waiting for passkey…' : 'Sign in with passkey'}
</button>
</div>
)}
{!status && (
<p className="text-sm text-zinc-500 text-center">Connecting</p>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { scansApi } from '../lib/api.js';
import StatusBadge from '../components/StatusBadge.jsx';
import JsonViewer from '../components/JsonViewer.jsx';
function fmt(iso) {
if (!iso) return '—';
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'long' }).format(new Date(iso));
}
function Section({ title, data, defaultOpen = false }) {
const [open, setOpen] = useState(defaultOpen);
const isEmpty = data == null || (Array.isArray(data) && data.length === 0) || (typeof data === 'object' && Object.keys(data).length === 0);
return (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-5 py-3.5 hover:bg-zinc-800/50 transition-colors"
>
<span className="text-sm font-medium text-zinc-200">{title}</span>
<div className="flex items-center gap-3">
{isEmpty && <span className="text-xs text-zinc-600">empty</span>}
<span className="text-zinc-500">{open ? '▲' : '▼'}</span>
</div>
</button>
{open && (
<div className="px-5 py-4 border-t border-zinc-800 overflow-x-auto">
{isEmpty
? <p className="text-zinc-500 text-sm">No data</p>
: <JsonViewer data={data} />}
</div>
)}
</div>
);
}
export default function ScanDetail() {
const { id } = useParams();
const { data: scan, isLoading, error } = useQuery({
queryKey: ['scan', id],
queryFn: () => scansApi.get(id),
});
if (isLoading) return <div className="text-zinc-500">Loading</div>;
if (error) return <div className="text-red-400">Failed to load scan: {error.message}</div>;
const c = scan?.collector;
const wu = scan?.windowsUpdate;
return (
<div className="space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-zinc-500">
<Link to="/scans" className="hover:text-zinc-300">Scans</Link>
<span>/</span>
<span className="text-zinc-300">{c?.machineName}</span>
</div>
{/* Summary */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-xs text-zinc-500 mb-1">Machine</p>
<p className="text-sm font-medium text-zinc-100">{c?.machineName}</p>
</div>
<div>
<p className="text-xs text-zinc-500 mb-1">Collected</p>
<p className="text-xs text-zinc-300">{fmt(c?.collectedAt)}</p>
</div>
<div>
<p className="text-xs text-zinc-500 mb-1">Applicable Updates</p>
<p className="text-2xl font-semibold text-zinc-100">{wu?.applicableUpdates?.length ?? 0}</p>
</div>
<div>
<p className="text-xs text-zinc-500 mb-1">Pending Reboot</p>
{scan?.pendingReboot?.anyPending
? <StatusBadge variant="amber">Yes</StatusBadge>
: <StatusBadge variant="green">No</StatusBadge>}
</div>
<div>
<p className="text-xs text-zinc-500 mb-1">Ran as Admin</p>
{c?.ranAsAdministrator
? <StatusBadge variant="indigo">Yes</StatusBadge>
: <StatusBadge variant="red">No</StatusBadge>}
</div>
<div>
<p className="text-xs text-zinc-500 mb-1">Schema</p>
<p className="text-xs text-zinc-500">{scan?.schemaVersion}</p>
</div>
</div>
{/* Sections */}
<div className="space-y-3">
<Section title="OS" data={scan?.os} defaultOpen />
<Section title="Device" data={scan?.device} />
<Section title="Applicable Updates" data={wu?.applicableUpdates} />
<Section title="Update History" data={wu?.history} />
<Section title="Windows Update Policy" data={wu?.policy} />
<Section title="Pending Reboot" data={scan?.pendingReboot} />
<Section title="Installed Hotfixes" data={scan?.installedHotfixes} />
<Section title="CBS / DISM Packages" data={scan?.cbsPackages} />
<Section title="Drivers" data={scan?.drivers} />
<Section title="Recent Update Events" data={scan?.recentUpdateEvents} />
<Section title="Collector Metadata" data={scan?.collector} />
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { scansApi } from '../lib/api.js';
import StatusBadge from '../components/StatusBadge.jsx';
function fmt(iso) {
if (!iso) return '—';
return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'short' }).format(new Date(iso));
}
export default function Scans() {
const queryClient = useQueryClient();
const [search, setSearch] = useState('');
const { data: scans = [], isLoading } = useQuery({ queryKey: ['scans'], queryFn: scansApi.list });
const deleteMutation = useMutation({
mutationFn: scansApi.remove,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scans'] }),
});
const visible = search
? scans.filter(s => s.machineName?.toLowerCase().includes(search.toLowerCase()))
: scans;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-zinc-100">Scans</h1>
<input
type="search"
placeholder="Filter by machine…"
value={search}
onChange={e => setSearch(e.target.value)}
className="bg-zinc-800 border border-zinc-700 rounded-md px-3 py-1.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-indigo-500 w-52"
/>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-xs text-zinc-500">
<th className="text-left px-4 py-3 font-medium">Machine</th>
<th className="text-left px-4 py-3 font-medium">Collected</th>
<th className="text-right px-4 py-3 font-medium">Updates</th>
<th className="text-left px-4 py-3 font-medium">Reboot</th>
<th className="text-left px-4 py-3 font-medium">Admin</th>
<th className="text-right px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{isLoading && (
<tr><td colSpan={6} className="px-4 py-8 text-center text-zinc-500">Loading</td></tr>
)}
{!isLoading && visible.length === 0 && (
<tr><td colSpan={6} className="px-4 py-8 text-center text-zinc-500">No scans found</td></tr>
)}
{visible.map(s => (
<tr key={s.id} className="hover:bg-zinc-800/40">
<td className="px-4 py-3">
<Link to={`/scans/${s.id}`} className="text-zinc-100 hover:text-indigo-400 font-medium">
{s.machineName}
</Link>
</td>
<td className="px-4 py-3 text-zinc-400 text-xs">{fmt(s.collectedAt)}</td>
<td className="px-4 py-3 text-right text-zinc-300">{s.applicableUpdateCount}</td>
<td className="px-4 py-3">
{s.pendingReboot
? <StatusBadge variant="amber">Yes</StatusBadge>
: <span className="text-zinc-500 text-xs">No</span>}
</td>
<td className="px-4 py-3">
{s.ranAsAdministrator
? <StatusBadge variant="indigo">Admin</StatusBadge>
: <span className="text-zinc-500 text-xs">No</span>}
</td>
<td className="px-4 py-3 text-right space-x-3">
<Link to={`/scans/${s.id}`} className="text-xs text-indigo-400 hover:text-indigo-300">View</Link>
<button
onClick={() => { if (confirm('Delete this scan?')) deleteMutation.mutate(s.id); }}
className="text-xs text-red-500 hover:text-red-400"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
build: {
outDir: '../PatchProbe.Server/public',
emptyOutDir: true,
},
server: {
proxy: {
'/api': 'http://localhost:3000',
},
},
});

View File

@@ -0,0 +1,7 @@
namespace PatchProbe.Engine.Contracts.ApiModels;
public sealed record EnrollmentRequest(
string EnrollmentKey,
string MachineName,
string DeviceFingerprint,
string PublicKeySpki);

View File

@@ -0,0 +1,5 @@
namespace PatchProbe.Engine.Contracts.ApiModels;
public sealed record EnrollmentResponse(
string DeviceId,
string Message);

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,6 @@
namespace PatchProbe.Shared.Contracts;
public interface ICollector<T>
{
Task<T> CollectAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,8 @@
using PatchProbe.Shared.Models;
namespace PatchProbe.Shared.Contracts;
public interface IPayloadUploader
{
Task UploadAsync(PatchProbePayload payload, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,14 @@
namespace PatchProbe.Shared.Models;
public sealed class ApplicableUpdate
{
public string? UpdateId { get; init; }
public string? Title { get; init; }
public string? KbArticleId { get; init; }
public string? Category { get; init; }
public string? Severity { get; init; }
public bool RebootRequired { get; init; }
public bool IsDownloaded { get; init; }
public string? Description { get; init; }
public string? SupportUrl { get; init; }
}

View File

@@ -0,0 +1,9 @@
namespace PatchProbe.Shared.Models;
public sealed class CbsPackage
{
public string? PackageIdentity { get; init; }
public string? State { get; init; }
public string? ReleaseType { get; init; }
public string? InstallTime { get; init; }
}

View File

@@ -0,0 +1,10 @@
namespace PatchProbe.Shared.Models;
public sealed class CollectorMeta
{
public string SchemaVersion { get; init; } = "0.1";
public string CollectorVersion { get; init; } = "1.0.0";
public DateTimeOffset CollectedAt { get; init; } = DateTimeOffset.UtcNow;
public string? MachineName { get; init; }
public bool RanAsAdministrator { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace PatchProbe.Shared.Models;
public sealed class DeviceInfo
{
public string? Hostname { get; init; }
public string? Manufacturer { get; init; }
public string? Model { get; init; }
public string? SerialNumber { get; init; }
public string? BiosVersion { get; init; }
public string? BiosDate { get; init; }
public bool TpmPresent { get; init; }
public string? TpmVersion { get; init; }
public ulong RamBytes { get; init; }
public string? Domain { get; init; }
public string? Workgroup { get; init; }
public string? SystemType { get; init; }
}

View File

@@ -0,0 +1,13 @@
namespace PatchProbe.Shared.Models;
public sealed class DriverInfo
{
public string? DeviceName { get; init; }
public string? DriverVersion { get; init; }
public string? Manufacturer { get; init; }
public string? InfName { get; init; }
public string? HardwareId { get; init; }
public string? DeviceClass { get; init; }
public DateTimeOffset? DriverDate { get; init; }
public bool IsSigned { get; init; }
}

View File

@@ -0,0 +1,9 @@
namespace PatchProbe.Shared.Models;
public sealed class InstalledHotfix
{
public string? HotFixId { get; init; }
public string? Description { get; init; }
public string? InstalledBy { get; init; }
public DateTimeOffset? InstalledOn { get; init; }
}

View File

@@ -0,0 +1,14 @@
namespace PatchProbe.Shared.Models;
public sealed class OsInfo
{
public string? ProductName { get; init; }
public string? EditionId { get; init; }
public string? DisplayVersion { get; init; }
public string? ReleaseId { get; init; }
public string? BuildNumber { get; init; }
public int Ubr { get; init; }
public string? Architecture { get; init; }
public DateTimeOffset? InstallDate { get; init; }
public DateTimeOffset? LastBoot { get; init; }
}

View File

@@ -0,0 +1,14 @@
namespace PatchProbe.Shared.Models;
public sealed class PatchProbePayload
{
public CollectorMeta Collector { get; init; } = new();
public DeviceInfo? Device { get; init; }
public OsInfo? Os { get; init; }
public PendingRebootInfo? PendingReboot { get; init; }
public WindowsUpdateInfo? WindowsUpdate { get; init; }
public List<InstalledHotfix> InstalledHotfixes { get; init; } = [];
public List<CbsPackage> CbsPackages { get; init; } = [];
public List<DriverInfo> Drivers { get; init; } = [];
public List<UpdateEvent> RecentUpdateEvents { get; init; } = [];
}

View File

@@ -0,0 +1,10 @@
namespace PatchProbe.Shared.Models;
public sealed class PendingRebootInfo
{
public bool CbsRebootPending { get; init; }
public bool WindowsUpdateRebootRequired { get; init; }
public bool SessionManagerRebootRequired { get; init; }
public bool ComputerRenameRequired { get; init; }
public bool AnyPending => CbsRebootPending || WindowsUpdateRebootRequired || SessionManagerRebootRequired || ComputerRenameRequired;
}

View File

@@ -0,0 +1,11 @@
namespace PatchProbe.Shared.Models;
public sealed class UpdateEvent
{
public long EventId { get; init; }
public string? Source { get; init; }
public string? LogName { get; init; }
public string? Level { get; init; }
public DateTimeOffset? TimeCreated { get; init; }
public string? Message { get; init; }
}

View File

@@ -0,0 +1,13 @@
namespace PatchProbe.Shared.Models;
public sealed class UpdateHistoryEntry
{
public string? UpdateId { get; init; }
public string? Title { get; init; }
public string? KbArticleId { get; init; }
public int ResultCode { get; init; }
public int HResult { get; init; }
public DateTimeOffset? Date { get; init; }
public string? Operation { get; init; }
public string? ServerSelection { get; init; }
}

View File

@@ -0,0 +1,9 @@
namespace PatchProbe.Shared.Models;
public sealed class WindowsUpdateInfo
{
public List<ApplicableUpdate> ApplicableUpdates { get; init; } = [];
public List<UpdateHistoryEntry> History { get; init; } = [];
public WindowsUpdatePolicy? Policy { get; init; }
public string? SearchError { get; init; }
}

View File

@@ -0,0 +1,16 @@
namespace PatchProbe.Shared.Models;
public sealed class WindowsUpdatePolicy
{
public bool WsusConfigured { get; init; }
public string? WsusServer { get; init; }
public string? WsusStatusServer { get; init; }
public bool WufbConfigured { get; init; }
public int? DeferFeatureUpdatesDays { get; init; }
public int? DeferQualityUpdatesDays { get; init; }
public bool AutoUpdateEnabled { get; init; }
public int? AuOptions { get; init; }
public bool TargetGroupEnabled { get; init; }
public string? TargetGroup { get; init; }
public string? BranchReadinessLevel { get; init; }
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,25 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using PatchProbe.Shared.Models;
namespace PatchProbe.Shared.Serialization;
public static class PayloadSerializer
{
public static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
public static string Serialize(PatchProbePayload payload) =>
JsonSerializer.Serialize(payload, Options);
public static async Task SerializeToFileAsync(PatchProbePayload payload, string path, CancellationToken cancellationToken = default)
{
await using var stream = File.Create(path);
await JsonSerializer.SerializeAsync(stream, payload, Options, cancellationToken);
}
}

5
PatchProbe.slnx Normal file
View File

@@ -0,0 +1,5 @@
<Solution>
<Project Path="PatchProbe.Cli/PatchProbe.Cli.csproj" />
<Project Path="PatchProbe.Engine.Contracts/PatchProbe.Engine.Contracts.csproj" />
<Project Path="PatchProbe.Shared/PatchProbe.Shared.csproj" />
</Solution>

6
PatchProbe/App.config Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
</configuration>

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{FC689B9E-18B0-4DD8-964E-3A4D9D093BFD}</ProjectGuid>
<OutputType>Exe</OutputType>
<RootNamespace>PatchProbe</RootNamespace>
<AssemblyName>PatchProbe</AssemblyName>
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Xml" />
</ItemGroup>
<ItemGroup>
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

15
PatchProbe/Program.cs Normal file
View File

@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace PatchProbe
{
internal class Program
{
static void Main(string[] args)
{
}
}
}

View File

@@ -0,0 +1,33 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("PatchProbe")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("PatchProbe")]
[assembly: AssemblyCopyright("Copyright © 2026")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Setting ComVisible to false makes the types in this assembly not visible
// to COM components. If you need to access a type in this assembly from
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]
// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("fc689b9e-18b0-4dd8-964e-3a4d9d093bfd")]
// Version information for an assembly consists of the following four values:
//
// Major Version
// Minor Version
// Build Number
// Revision
//
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]