diff --git a/agent/ShopDbEndpointAgent.Core/Contracts.cs b/agent/ShopDbEndpointAgent.Core/Contracts.cs index ad0e8592..e35af45b 100644 --- a/agent/ShopDbEndpointAgent.Core/Contracts.cs +++ b/agent/ShopDbEndpointAgent.Core/Contracts.cs @@ -39,6 +39,10 @@ public sealed class EndpointReport [JsonPropertyName("processor")] public object? Processor { get; set; } [JsonPropertyName("osinfo")] public object? OsInfo { get; set; } [JsonPropertyName("domain")] public string? Domain { get; set; } + // Installed-software SBOM, mirroring the Assess-Orchestrator applications + // collection: both Uninstall registry hives, {displayname, displayversion, + // publisher}. Server logs it as an unpersisted key until the SBOM store lands. + [JsonPropertyName("applications")] public object[]? Applications { get; set; } } public sealed class EndpointEventRecord diff --git a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs index 3f86c785..c230d11b 100644 --- a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs +++ b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs @@ -56,6 +56,7 @@ public sealed class EndpointCollector : ICollector Processor = GetProcessor(), OsInfo = GetOsInfo(), Domain = GetDomain(), + Applications = GetApplications(), }; if (report.LastBootTime is DateTimeOffset boot) @@ -466,6 +467,76 @@ public sealed class EndpointCollector : ICollector return records.ToArray(); } + // Installed-software SBOM. Mirrors the Assess-Orchestrator applications + // collection exactly: enumerate both Uninstall registry hives (64-bit and + // WOW6432Node), keep only entries with a DisplayName, and emit + // {displayname, displayversion, publisher}. This is the same registry + // surface the machine-number/eDNC probes already read. Wire keys stay + // lowercase/no-underscore; the server logs this as an unpersisted key until + // the SBOM store is built. Never throws out to the caller. + private object[] GetApplications() + { + const string uninstall64 = + @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"; + const string uninstall32 = + @"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"; + + var result = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + CollectUninstallHive(uninstall64, result, seen); + CollectUninstallHive(uninstall32, result, seen); + return result.ToArray(); + } + + private void CollectUninstallHive(string subKey, List result, + HashSet seen) + { + try + { + using var machine = RegistryKey.OpenBaseKey( + RegistryHive.LocalMachine, RegistryView.Default); + using var uninstall = machine.OpenSubKey(subKey); + if (uninstall is null) + return; + + foreach (var name in uninstall.GetSubKeyNames()) + { + try + { + using var entry = uninstall.OpenSubKey(name); + var displayName = entry?.GetValue("DisplayName")?.ToString()?.Trim(); + if (string.IsNullOrWhiteSpace(displayName)) + continue; + + var displayVersion = + entry?.GetValue("DisplayVersion")?.ToString()?.Trim() ?? ""; + var publisher = + entry?.GetValue("Publisher")?.ToString()?.Trim() ?? ""; + + // De-dupe on name+version so the two hives do not double-list + // the same package. + if (!seen.Add(displayName + "\u0000" + displayVersion)) + continue; + + result.Add(new + { + displayname = displayName, + displayversion = displayVersion, + publisher, + }); + } + catch (Exception error) + { + logger.LogDebug(error, "Uninstall entry read blocked: {Entry}", name); + } + } + } + catch (Exception error) + { + logger.LogDebug(error, "Uninstall hive read blocked: {SubKey}", subKey); + } + } + // Fixed local disks with capacity/free so the server can flag a PC that is // running out of space. Win32_LogicalDisk DriveType 3 = local fixed disk; // network/removable drives are skipped. Bytes stay as longs; the server can diff --git a/plugins/endpoints/tests/test_plugin.py b/plugins/endpoints/tests/test_plugin.py index 0082ba76..72fb9add 100644 --- a/plugins/endpoints/tests/test_plugin.py +++ b/plugins/endpoints/tests/test_plugin.py @@ -182,7 +182,8 @@ def test_new_hardware_telemetry_keys_are_logged_not_silently_known(): rather than dropping them silently.""" from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS - for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain'): + for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain', + 'applications'): assert key not in KNOWN_PAYLOAD_KEYS