commit c42c9aea2a3d36b8d683138860d6012dfe5fde3f Author: Bailey Taylor Date: Mon May 25 10:29:38 2026 +0800 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2d109d --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..aa96935 --- /dev/null +++ b/CLAUDE.md @@ -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(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var host = builder.Build(); + +var deviceCollector = host.Services.GetRequiredService(); +var wuCollector = host.Services.GetRequiredService(); + +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 diff --git a/PatchProbe.Cli/Auth/DeviceCredentials.cs b/PatchProbe.Cli/Auth/DeviceCredentials.cs new file mode 100644 index 0000000..24de881 --- /dev/null +++ b/PatchProbe.Cli/Auth/DeviceCredentials.cs @@ -0,0 +1,6 @@ +namespace PatchProbe.Cli.Auth; + +internal sealed record DeviceCredentials( + string DeviceId, + string PrivateKeyPkcs8, + string ServerUrl); diff --git a/PatchProbe.Cli/Auth/DpapiCredentialStore.cs b/PatchProbe.Cli/Auth/DpapiCredentialStore.cs new file mode 100644 index 0000000..345e210 --- /dev/null +++ b/PatchProbe.Cli/Auth/DpapiCredentialStore.cs @@ -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(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); + } +} diff --git a/PatchProbe.Cli/Auth/EcdsaRequestAuthenticator.cs b/PatchProbe.Cli/Auth/EcdsaRequestAuthenticator.cs new file mode 100644 index 0000000..37e7281 --- /dev/null +++ b/PatchProbe.Cli/Auth/EcdsaRequestAuthenticator.cs @@ -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; + } +} diff --git a/PatchProbe.Cli/Auth/IDeviceCredentialStore.cs b/PatchProbe.Cli/Auth/IDeviceCredentialStore.cs new file mode 100644 index 0000000..f11af16 --- /dev/null +++ b/PatchProbe.Cli/Auth/IDeviceCredentialStore.cs @@ -0,0 +1,9 @@ +namespace PatchProbe.Cli.Auth; + +internal interface IDeviceCredentialStore +{ + bool IsEnrolled { get; } + DeviceCredentials Load(); + void Save(DeviceCredentials credentials); + void Delete(); +} diff --git a/PatchProbe.Cli/Auth/IRequestAuthenticator.cs b/PatchProbe.Cli/Auth/IRequestAuthenticator.cs new file mode 100644 index 0000000..16f5e11 --- /dev/null +++ b/PatchProbe.Cli/Auth/IRequestAuthenticator.cs @@ -0,0 +1,7 @@ +namespace PatchProbe.Cli.Auth; + +internal interface IRequestAuthenticator +{ + /// Adds authentication headers to an outgoing HTTP request. + Task AuthenticateAsync(HttpRequestMessage request, string requestBody, CancellationToken ct = default); +} diff --git a/PatchProbe.Cli/Collectors/CbsDismCollector.cs b/PatchProbe.Cli/Collectors/CbsDismCollector.cs new file mode 100644 index 0000000..a3be0a4 --- /dev/null +++ b/PatchProbe.Cli/Collectors/CbsDismCollector.cs @@ -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 logger) : ICollector> +{ + public async Task> CollectAsync(CancellationToken cancellationToken = default) + { + logger.LogInformation("Collecting CBS/DISM package state"); + + var packages = new List(); + + 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 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 ParseDismOutput(string output) + { + var packages = new List(); + 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; + } +} diff --git a/PatchProbe.Cli/Collectors/DeviceCollector.cs b/PatchProbe.Cli/Collectors/DeviceCollector.cs new file mode 100644 index 0000000..cfd1cd8 --- /dev/null +++ b/PatchProbe.Cli/Collectors/DeviceCollector.cs @@ -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 logger) : ICollector +{ + public Task 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; + } +} diff --git a/PatchProbe.Cli/Collectors/DriverCollector.cs b/PatchProbe.Cli/Collectors/DriverCollector.cs new file mode 100644 index 0000000..487604e --- /dev/null +++ b/PatchProbe.Cli/Collectors/DriverCollector.cs @@ -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 logger) : ICollector> +{ + public Task> CollectAsync(CancellationToken cancellationToken = default) + { + logger.LogInformation("Collecting driver information via Win32_PnPSignedDriver"); + + var drivers = new List(); + + 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); + } +} diff --git a/PatchProbe.Cli/Collectors/EventCollector.cs b/PatchProbe.Cli/Collectors/EventCollector.cs new file mode 100644 index 0000000..8f83d70 --- /dev/null +++ b/PatchProbe.Cli/Collectors/EventCollector.cs @@ -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 logger) : ICollector> +{ + 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> CollectAsync(CancellationToken cancellationToken = default) + { + logger.LogInformation("Collecting recent Windows Update events (last {Hours}h)", LookbackHours); + + var events = new List(); + 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 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; } + } +} diff --git a/PatchProbe.Cli/Collectors/HotfixCollector.cs b/PatchProbe.Cli/Collectors/HotfixCollector.cs new file mode 100644 index 0000000..fda64d2 --- /dev/null +++ b/PatchProbe.Cli/Collectors/HotfixCollector.cs @@ -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 logger) : ICollector> +{ + public Task> CollectAsync(CancellationToken cancellationToken = default) + { + logger.LogInformation("Collecting installed hotfixes via Win32_QuickFixEngineering"); + + var hotfixes = new List(); + + 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); + } +} diff --git a/PatchProbe.Cli/Collectors/OsCollector.cs b/PatchProbe.Cli/Collectors/OsCollector.cs new file mode 100644 index 0000000..bfba04c --- /dev/null +++ b/PatchProbe.Cli/Collectors/OsCollector.cs @@ -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 logger) : ICollector +{ + private const string CurrentVersionKey = @"SOFTWARE\Microsoft\Windows NT\CurrentVersion"; + + public Task 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; } + } +} diff --git a/PatchProbe.Cli/Collectors/PendingRebootCollector.cs b/PatchProbe.Cli/Collectors/PendingRebootCollector.cs new file mode 100644 index 0000000..3a54a82 --- /dev/null +++ b/PatchProbe.Cli/Collectors/PendingRebootCollector.cs @@ -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 logger) : ICollector +{ + public Task 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; } + } +} diff --git a/PatchProbe.Cli/Collectors/WindowsUpdateCollector.cs b/PatchProbe.Cli/Collectors/WindowsUpdateCollector.cs new file mode 100644 index 0000000..8284062 --- /dev/null +++ b/PatchProbe.Cli/Collectors/WindowsUpdateCollector.cs @@ -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 logger) : ICollector +{ + public Task CollectAsync(CancellationToken cancellationToken = default) + { + logger.LogInformation("Collecting Windows Update information via WUA COM"); + + var applicable = new List(); + var history = new List(); + 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(() => u.Identity.UpdateID), + Title = TryGet(() => u.Title), + KbArticleId = kbId, + Category = category, + Severity = TryGet(() => u.MsrcSeverity), + RebootRequired = TryGet(() => u.RebootRequired), + IsDownloaded = TryGet(() => u.IsDownloaded), + Description = TryGet(() => u.Description), + SupportUrl = TryGet(() => 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(() => e.UpdateIdentity.UpdateID), + Title = TryGet(() => e.Title), + KbArticleId = ExtractKbIdFromTitle(TryGet(() => e.Title)), + ResultCode = TryGet(() => (int)e.ResultCode), + HResult = TryGet(() => e.HResult), + Date = TryGetDate(() => e.Date), + Operation = TryGet(() => (int)e.Operation) switch + { + 1 => "Installation", + 2 => "Uninstallation", + _ => "Unknown" + }, + ServerSelection = TryGet(() => (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(() => 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(Func getter) + { + try { return getter(); } + catch { return default!; } + } + + private static DateTimeOffset? TryGetDate(Func 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; + } +} diff --git a/PatchProbe.Cli/Collectors/WindowsUpdatePolicyCollector.cs b/PatchProbe.Cli/Collectors/WindowsUpdatePolicyCollector.cs new file mode 100644 index 0000000..6925382 --- /dev/null +++ b/PatchProbe.Cli/Collectors/WindowsUpdatePolicyCollector.cs @@ -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 logger) : ICollector +{ + 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 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; + } +} diff --git a/PatchProbe.Cli/PatchProbe.Cli.csproj b/PatchProbe.Cli/PatchProbe.Cli.csproj new file mode 100644 index 0000000..81bfe88 --- /dev/null +++ b/PatchProbe.Cli/PatchProbe.Cli.csproj @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + Exe + net10.0-windows + enable + enable + PatchProbe + PatchProbe.Cli + + win-x64 + true + true + true + true + + + diff --git a/PatchProbe.Cli/Program.cs b/PatchProbe.Cli/Program.cs new file mode 100644 index 0000000..630240d --- /dev/null +++ b/PatchProbe.Cli/Program.cs @@ -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(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // Auth + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // Services + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + + 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("--output") { Description = "Path to write the JSON payload to disk" }; + var noUploadOption = new Option("--no-upload") { Description = "Collect evidence and print JSON to stdout instead of uploading" }; + var serverUrlOption = new Option("--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(); + var osCollector = sp.GetRequiredService(); + var wuCollector = sp.GetRequiredService(); + var rebootCollector = sp.GetRequiredService(); + var driverCollector = sp.GetRequiredService(); + var cbsCollector = sp.GetRequiredService(); + var policyCollector = sp.GetRequiredService(); + var hotfixCollector = sp.GetRequiredService(); + var eventCollector = sp.GetRequiredService(); + var uploader = sp.GetRequiredService(); + var credStore = sp.GetRequiredService(); + + 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("--server-url") { Description = "PatchProbe server URL (required)" }; + var enrollmentKeyOption = new Option("--enrollment-key") { Description = "Enrollment key provided by your administrator (required)" }; + var forceOption = new Option("--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(); + + 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(); + 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(); + 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, List, List, List, 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); +} diff --git a/PatchProbe.Cli/Services/EnrollmentService.cs b/PatchProbe.Cli/Services/EnrollmentService.cs new file mode 100644 index 0000000..22c8180 --- /dev/null +++ b/PatchProbe.Cli/Services/EnrollmentService.cs @@ -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 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(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(); + } +} diff --git a/PatchProbe.Cli/Services/PayloadUploader.cs b/PatchProbe.Cli/Services/PayloadUploader.cs new file mode 100644 index 0000000..c1d94fe --- /dev/null +++ b/PatchProbe.Cli/Services/PayloadUploader.cs @@ -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 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); + } +} diff --git a/PatchProbe.Dashboard/index.html b/PatchProbe.Dashboard/index.html new file mode 100644 index 0000000..9b61208 --- /dev/null +++ b/PatchProbe.Dashboard/index.html @@ -0,0 +1,12 @@ + + + + + + PatchProbe + + +
+ + + diff --git a/PatchProbe.Dashboard/package-lock.json b/PatchProbe.Dashboard/package-lock.json new file mode 100644 index 0000000..1d49595 --- /dev/null +++ b/PatchProbe.Dashboard/package-lock.json @@ -0,0 +1,2543 @@ +{ + "name": "patchprobe-dashboard", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "patchprobe-dashboard", + "version": "1.0.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.11", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.11.tgz", + "integrity": "sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.11", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.11.tgz", + "integrity": "sha512-J0f9s5x3LE1450nNNfYx+e/n0DMa0uOBdFJUy5r0RvmsXd4nB/n0rbHtHI1vYXhikNFan+wf51p6Tmp4c8ucrg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.11" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz", + "integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/PatchProbe.Dashboard/package.json b/PatchProbe.Dashboard/package.json new file mode 100644 index 0000000..400a4e7 --- /dev/null +++ b/PatchProbe.Dashboard/package.json @@ -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" + } +} diff --git a/PatchProbe.Dashboard/src/App.jsx b/PatchProbe.Dashboard/src/App.jsx new file mode 100644 index 0000000..d2c1d54 --- /dev/null +++ b/PatchProbe.Dashboard/src/App.jsx @@ -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
Loading…
; + if (!isAuthenticated) return ; + return children; +} + +function AppRoutes() { + return ( + + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + ); +} + +export default function App() { + return ( + + + + + + ); +} diff --git a/PatchProbe.Dashboard/src/components/JsonViewer.jsx b/PatchProbe.Dashboard/src/components/JsonViewer.jsx new file mode 100644 index 0000000..67db0e7 --- /dev/null +++ b/PatchProbe.Dashboard/src/components/JsonViewer.jsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; + +function JsonNode({ data, depth = 0 }) { + const [collapsed, setCollapsed] = useState(depth > 2); + + if (data === null) return null; + if (data === true) return true; + if (data === false) return false; + if (typeof data === 'number') return {data}; + if (typeof data === 'string') return "{data}"; + + 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 {open}{close}; + } + + return ( + + + {open} + {collapsed ? ( + + ) : ( +
+ {entries.map(([key, val], idx) => ( +
+ {!isArray && ( + "{key}" + )} + {!isArray && : } + + {idx < entries.length - 1 && ,} +
+ ))} +
+ )} + {!collapsed && {close}} +
+ ); +} + +export default function JsonViewer({ data }) { + return ( +
+      
+    
+ ); +} diff --git a/PatchProbe.Dashboard/src/components/Layout.jsx b/PatchProbe.Dashboard/src/components/Layout.jsx new file mode 100644 index 0000000..ecc3588 --- /dev/null +++ b/PatchProbe.Dashboard/src/components/Layout.jsx @@ -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 ( +
+ {/* Sidebar */} + + + {/* Main */} +
+
+ +
+
+
+ ); +} diff --git a/PatchProbe.Dashboard/src/components/StatusBadge.jsx b/PatchProbe.Dashboard/src/components/StatusBadge.jsx new file mode 100644 index 0000000..348ed8e --- /dev/null +++ b/PatchProbe.Dashboard/src/components/StatusBadge.jsx @@ -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 ( + + {children} + + ); +} diff --git a/PatchProbe.Dashboard/src/hooks/useAuth.jsx b/PatchProbe.Dashboard/src/hooks/useAuth.jsx new file mode 100644 index 0000000..e1ba7a4 --- /dev/null +++ b/PatchProbe.Dashboard/src/hooks/useAuth.jsx @@ -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 ( + + {children} + + ); +} + +export function useAuth() { + return useContext(AuthContext); +} diff --git a/PatchProbe.Dashboard/src/index.css b/PatchProbe.Dashboard/src/index.css new file mode 100644 index 0000000..9a8257e --- /dev/null +++ b/PatchProbe.Dashboard/src/index.css @@ -0,0 +1,8 @@ +@import "tailwindcss"; + +@layer base { + body { + background-color: theme(--color-zinc-950); + color: theme(--color-zinc-100); + } +} diff --git a/PatchProbe.Dashboard/src/lib/api.js b/PatchProbe.Dashboard/src/lib/api.js new file mode 100644 index 0000000..3ea0aee --- /dev/null +++ b/PatchProbe.Dashboard/src/lib/api.js @@ -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}`), +}; diff --git a/PatchProbe.Dashboard/src/main.jsx b/PatchProbe.Dashboard/src/main.jsx new file mode 100644 index 0000000..391f13c --- /dev/null +++ b/PatchProbe.Dashboard/src/main.jsx @@ -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( + + + + + , +); diff --git a/PatchProbe.Dashboard/src/pages/Admin.jsx b/PatchProbe.Dashboard/src/pages/Admin.jsx new file mode 100644 index 0000000..ce34fe4 --- /dev/null +++ b/PatchProbe.Dashboard/src/pages/Admin.jsx @@ -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 ( +
+

Enrollment Tokens

+ + {/* Created token — show once */} + {created && ( +
+

Token created — copy now, it won't be shown again:

+ {created} + +
+ )} + + {/* Create form */} +
+

Create token

+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ + {/* Token list */} +
+ + + + + + + + + + + + + {tokens.length === 0 && ( + + )} + {tokens.map(t => ( + + + + + + + + + ))} + +
TokenLabelExpiresUsesStatus
No tokens
{t.tokenMasked}{t.label}{fmt(t.expiresAt)} + {t.usedCount}{t.maxUses ? ` / ${t.maxUses}` : ''} + + {t.revokedAt + ? Revoked + : t.active + ? Active + : Exhausted} + + {!t.revokedAt && ( + + )} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( +
+
+

Passkeys

+ +
+ + {error && ( +
{error}
+ )} + + {showRegForm && ( +
+

Register new passkey

+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ )} + +
+ + + + + + + + + + + + {passkeys.length === 0 && ( + + )} + {passkeys.map(p => ( + + + + + + + + ))} + +
IDTypeRegisteredLast used
No passkeys
{p.idMasked} + + {p.deviceType ?? 'unknown'} + + {fmt(p.createdAt)}{fmt(p.lastUsedAt)} + {passkeys.length > 1 && ( + + )} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +export default function Admin() { + return ( +
+

Admin

+ + +
+ ); +} diff --git a/PatchProbe.Dashboard/src/pages/Dashboard.jsx b/PatchProbe.Dashboard/src/pages/Dashboard.jsx new file mode 100644 index 0000000..93337bf --- /dev/null +++ b/PatchProbe.Dashboard/src/pages/Dashboard.jsx @@ -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 = ( +
+

{label}

+

{value ?? '—'}

+ {sub &&

{sub}

} +
+ ); + return to ? {inner} : 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 ( +
+

Dashboard

+ + {/* Stats */} +
+ + + + n + (s.applicableUpdateCount ?? 0), 0)} sub="total across all scans" /> +
+ + {/* Recent scans */} +
+
+

Recent Scans

+ View all → +
+
+ + + + + + + + + + + {recent.length === 0 && ( + + )} + {recent.map(s => ( + + + + + + + ))} + +
MachineCollectedUpdatesReboot
No scans yet
+ + {s.machineName} + + {fmt(s.collectedAt)}{s.applicableUpdateCount} + {s.pendingReboot + ? Yes + : No} +
+
+
+
+ ); +} diff --git a/PatchProbe.Dashboard/src/pages/DeviceDetail.jsx b/PatchProbe.Dashboard/src/pages/DeviceDetail.jsx new file mode 100644 index 0000000..39018a5 --- /dev/null +++ b/PatchProbe.Dashboard/src/pages/DeviceDetail.jsx @@ -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 ( +
+ {/* Breadcrumb */} +
+ Devices + / + {device?.machineName ?? id} +
+ + {/* Device info */} + {device && ( +
+
+

Machine name

+

{device.machineName}

+
+
+

Status

+ {device.revoked + ? Revoked + : Active} +
+
+

Enrolled

+

{fmt(device.enrolledAt)}

+
+
+

Last seen

+

{fmt(device.lastSeenAt)}

+
+
+

Device ID

+

{device.id}

+
+ {device.deviceFingerprint && ( +
+

Fingerprint

+

{device.deviceFingerprint}

+
+ )} +
+ )} + + {/* Scans */} +
+

Scan History

+
+ + + + + + + + + + + + {isLoading && ( + + )} + {!isLoading && scans.length === 0 && ( + + )} + {scans.map(s => ( + + + + + + + + ))} + +
CollectedUpdatesRebootAdmin
Loading…
No scans yet
{fmt(s.collectedAt)}{s.applicableUpdateCount} + {s.pendingReboot + ? Yes + : No} + + {s.ranAsAdministrator + ? Admin + : No} + + View → +
+
+
+
+ ); +} diff --git a/PatchProbe.Dashboard/src/pages/Devices.jsx b/PatchProbe.Dashboard/src/pages/Devices.jsx new file mode 100644 index 0000000..5dfe51a --- /dev/null +++ b/PatchProbe.Dashboard/src/pages/Devices.jsx @@ -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 ( +
+
+

Devices

+
+ {['active', 'all'].map(f => ( + + ))} +
+
+ +
+ + + + + + + + + + + + {isLoading && ( + + )} + {!isLoading && visible.length === 0 && ( + + )} + {visible.map(d => ( + + + + + + + + ))} + +
MachineEnrolledLast SeenStatusActions
Loading…
No devices found
+ + {d.machineName} + +

{d.id.slice(0, 8)}…

+
{fmt(d.enrolledAt)}{fmt(d.lastSeenAt)} + {d.revoked + ? Revoked + : Active} + + {!d.revoked && ( + + )} +
+
+
+ ); +} diff --git a/PatchProbe.Dashboard/src/pages/Login.jsx b/PatchProbe.Dashboard/src/pages/Login.jsx new file mode 100644 index 0000000..24c29cf --- /dev/null +++ b/PatchProbe.Dashboard/src/pages/Login.jsx @@ -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 ( +
+
+ {/* Logo */} +
+

PatchProbe

+

Patch management dashboard

+
+ +
+ {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* First-time setup */} + {status && !status.hasUsers && ( +
+

+ No admin account exists yet. Register your first passkey to get started. +

+
+
+ + 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" + /> +
+
+ + 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" + /> +
+ +
+
+ )} + + {/* Sign in */} + {status && status.hasUsers && ( +
+

+ Sign in using a passkey registered on this device. +

+ +
+ )} + + {!status && ( +

Connecting…

+ )} +
+
+
+ ); +} diff --git a/PatchProbe.Dashboard/src/pages/ScanDetail.jsx b/PatchProbe.Dashboard/src/pages/ScanDetail.jsx new file mode 100644 index 0000000..9011e93 --- /dev/null +++ b/PatchProbe.Dashboard/src/pages/ScanDetail.jsx @@ -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 ( +
+ + {open && ( +
+ {isEmpty + ?

No data

+ : } +
+ )} +
+ ); +} + +export default function ScanDetail() { + const { id } = useParams(); + const { data: scan, isLoading, error } = useQuery({ + queryKey: ['scan', id], + queryFn: () => scansApi.get(id), + }); + + if (isLoading) return
Loading…
; + if (error) return
Failed to load scan: {error.message}
; + + const c = scan?.collector; + const wu = scan?.windowsUpdate; + + return ( +
+ {/* Breadcrumb */} +
+ Scans + / + {c?.machineName} +
+ + {/* Summary */} +
+
+

Machine

+

{c?.machineName}

+
+
+

Collected

+

{fmt(c?.collectedAt)}

+
+
+

Applicable Updates

+

{wu?.applicableUpdates?.length ?? 0}

+
+
+

Pending Reboot

+ {scan?.pendingReboot?.anyPending + ? Yes + : No} +
+
+

Ran as Admin

+ {c?.ranAsAdministrator + ? Yes + : No} +
+
+

Schema

+

{scan?.schemaVersion}

+
+
+ + {/* Sections */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/PatchProbe.Dashboard/src/pages/Scans.jsx b/PatchProbe.Dashboard/src/pages/Scans.jsx new file mode 100644 index 0000000..df2a09a --- /dev/null +++ b/PatchProbe.Dashboard/src/pages/Scans.jsx @@ -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 ( +
+
+

Scans

+ 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" + /> +
+ +
+ + + + + + + + + + + + + {isLoading && ( + + )} + {!isLoading && visible.length === 0 && ( + + )} + {visible.map(s => ( + + + + + + + + + ))} + +
MachineCollectedUpdatesRebootAdminActions
Loading…
No scans found
+ + {s.machineName} + + {fmt(s.collectedAt)}{s.applicableUpdateCount} + {s.pendingReboot + ? Yes + : No} + + {s.ranAsAdministrator + ? Admin + : No} + + View + +
+
+
+ ); +} diff --git a/PatchProbe.Dashboard/vite.config.js b/PatchProbe.Dashboard/vite.config.js new file mode 100644 index 0000000..3d5ef79 --- /dev/null +++ b/PatchProbe.Dashboard/vite.config.js @@ -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', + }, + }, +}); diff --git a/PatchProbe.Engine.Contracts/ApiModels/EnrollmentRequest.cs b/PatchProbe.Engine.Contracts/ApiModels/EnrollmentRequest.cs new file mode 100644 index 0000000..347bb59 --- /dev/null +++ b/PatchProbe.Engine.Contracts/ApiModels/EnrollmentRequest.cs @@ -0,0 +1,7 @@ +namespace PatchProbe.Engine.Contracts.ApiModels; + +public sealed record EnrollmentRequest( + string EnrollmentKey, + string MachineName, + string DeviceFingerprint, + string PublicKeySpki); diff --git a/PatchProbe.Engine.Contracts/ApiModels/EnrollmentResponse.cs b/PatchProbe.Engine.Contracts/ApiModels/EnrollmentResponse.cs new file mode 100644 index 0000000..7d89de7 --- /dev/null +++ b/PatchProbe.Engine.Contracts/ApiModels/EnrollmentResponse.cs @@ -0,0 +1,5 @@ +namespace PatchProbe.Engine.Contracts.ApiModels; + +public sealed record EnrollmentResponse( + string DeviceId, + string Message); diff --git a/PatchProbe.Engine.Contracts/PatchProbe.Engine.Contracts.csproj b/PatchProbe.Engine.Contracts/PatchProbe.Engine.Contracts.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/PatchProbe.Engine.Contracts/PatchProbe.Engine.Contracts.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/PatchProbe.Shared/Contracts/ICollector.cs b/PatchProbe.Shared/Contracts/ICollector.cs new file mode 100644 index 0000000..33684de --- /dev/null +++ b/PatchProbe.Shared/Contracts/ICollector.cs @@ -0,0 +1,6 @@ +namespace PatchProbe.Shared.Contracts; + +public interface ICollector +{ + Task CollectAsync(CancellationToken cancellationToken = default); +} diff --git a/PatchProbe.Shared/Contracts/IPayloadUploader.cs b/PatchProbe.Shared/Contracts/IPayloadUploader.cs new file mode 100644 index 0000000..2166a98 --- /dev/null +++ b/PatchProbe.Shared/Contracts/IPayloadUploader.cs @@ -0,0 +1,8 @@ +using PatchProbe.Shared.Models; + +namespace PatchProbe.Shared.Contracts; + +public interface IPayloadUploader +{ + Task UploadAsync(PatchProbePayload payload, CancellationToken cancellationToken = default); +} diff --git a/PatchProbe.Shared/Models/ApplicableUpdate.cs b/PatchProbe.Shared/Models/ApplicableUpdate.cs new file mode 100644 index 0000000..52679e4 --- /dev/null +++ b/PatchProbe.Shared/Models/ApplicableUpdate.cs @@ -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; } +} diff --git a/PatchProbe.Shared/Models/CbsPackage.cs b/PatchProbe.Shared/Models/CbsPackage.cs new file mode 100644 index 0000000..49ce095 --- /dev/null +++ b/PatchProbe.Shared/Models/CbsPackage.cs @@ -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; } +} diff --git a/PatchProbe.Shared/Models/CollectorMeta.cs b/PatchProbe.Shared/Models/CollectorMeta.cs new file mode 100644 index 0000000..fe54f84 --- /dev/null +++ b/PatchProbe.Shared/Models/CollectorMeta.cs @@ -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; } +} diff --git a/PatchProbe.Shared/Models/DeviceInfo.cs b/PatchProbe.Shared/Models/DeviceInfo.cs new file mode 100644 index 0000000..079e258 --- /dev/null +++ b/PatchProbe.Shared/Models/DeviceInfo.cs @@ -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; } +} diff --git a/PatchProbe.Shared/Models/DriverInfo.cs b/PatchProbe.Shared/Models/DriverInfo.cs new file mode 100644 index 0000000..3e617fc --- /dev/null +++ b/PatchProbe.Shared/Models/DriverInfo.cs @@ -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; } +} diff --git a/PatchProbe.Shared/Models/InstalledHotfix.cs b/PatchProbe.Shared/Models/InstalledHotfix.cs new file mode 100644 index 0000000..f8edf9f --- /dev/null +++ b/PatchProbe.Shared/Models/InstalledHotfix.cs @@ -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; } +} diff --git a/PatchProbe.Shared/Models/OsInfo.cs b/PatchProbe.Shared/Models/OsInfo.cs new file mode 100644 index 0000000..da91d08 --- /dev/null +++ b/PatchProbe.Shared/Models/OsInfo.cs @@ -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; } +} diff --git a/PatchProbe.Shared/Models/PatchProbePayload.cs b/PatchProbe.Shared/Models/PatchProbePayload.cs new file mode 100644 index 0000000..fc3599e --- /dev/null +++ b/PatchProbe.Shared/Models/PatchProbePayload.cs @@ -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 InstalledHotfixes { get; init; } = []; + public List CbsPackages { get; init; } = []; + public List Drivers { get; init; } = []; + public List RecentUpdateEvents { get; init; } = []; +} diff --git a/PatchProbe.Shared/Models/PendingRebootInfo.cs b/PatchProbe.Shared/Models/PendingRebootInfo.cs new file mode 100644 index 0000000..ab59421 --- /dev/null +++ b/PatchProbe.Shared/Models/PendingRebootInfo.cs @@ -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; +} diff --git a/PatchProbe.Shared/Models/UpdateEvent.cs b/PatchProbe.Shared/Models/UpdateEvent.cs new file mode 100644 index 0000000..36ce9b9 --- /dev/null +++ b/PatchProbe.Shared/Models/UpdateEvent.cs @@ -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; } +} diff --git a/PatchProbe.Shared/Models/UpdateHistoryEntry.cs b/PatchProbe.Shared/Models/UpdateHistoryEntry.cs new file mode 100644 index 0000000..c441bbf --- /dev/null +++ b/PatchProbe.Shared/Models/UpdateHistoryEntry.cs @@ -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; } +} diff --git a/PatchProbe.Shared/Models/WindowsUpdateInfo.cs b/PatchProbe.Shared/Models/WindowsUpdateInfo.cs new file mode 100644 index 0000000..f69167c --- /dev/null +++ b/PatchProbe.Shared/Models/WindowsUpdateInfo.cs @@ -0,0 +1,9 @@ +namespace PatchProbe.Shared.Models; + +public sealed class WindowsUpdateInfo +{ + public List ApplicableUpdates { get; init; } = []; + public List History { get; init; } = []; + public WindowsUpdatePolicy? Policy { get; init; } + public string? SearchError { get; init; } +} diff --git a/PatchProbe.Shared/Models/WindowsUpdatePolicy.cs b/PatchProbe.Shared/Models/WindowsUpdatePolicy.cs new file mode 100644 index 0000000..2ea0755 --- /dev/null +++ b/PatchProbe.Shared/Models/WindowsUpdatePolicy.cs @@ -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; } +} diff --git a/PatchProbe.Shared/PatchProbe.Shared.csproj b/PatchProbe.Shared/PatchProbe.Shared.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/PatchProbe.Shared/PatchProbe.Shared.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/PatchProbe.Shared/Serialization/PayloadSerializer.cs b/PatchProbe.Shared/Serialization/PayloadSerializer.cs new file mode 100644 index 0000000..90c61d1 --- /dev/null +++ b/PatchProbe.Shared/Serialization/PayloadSerializer.cs @@ -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); + } +} diff --git a/PatchProbe.slnx b/PatchProbe.slnx new file mode 100644 index 0000000..ba4685d --- /dev/null +++ b/PatchProbe.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/PatchProbe/App.config b/PatchProbe/App.config new file mode 100644 index 0000000..56efbc7 --- /dev/null +++ b/PatchProbe/App.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/PatchProbe/PatchProbe.csproj b/PatchProbe/PatchProbe.csproj new file mode 100644 index 0000000..70cba7b --- /dev/null +++ b/PatchProbe/PatchProbe.csproj @@ -0,0 +1,53 @@ + + + + + Debug + AnyCPU + {FC689B9E-18B0-4DD8-964E-3A4D9D093BFD} + Exe + PatchProbe + PatchProbe + v4.7.2 + 512 + true + true + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/PatchProbe/Program.cs b/PatchProbe/Program.cs new file mode 100644 index 0000000..7aee3b4 --- /dev/null +++ b/PatchProbe/Program.cs @@ -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) + { + } + } +} diff --git a/PatchProbe/Properties/AssemblyInfo.cs b/PatchProbe/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..b546e7f --- /dev/null +++ b/PatchProbe/Properties/AssemblyInfo.cs @@ -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")]