From fdfd050d87ac0231c516776ce62d4570590be5f7 Mon Sep 17 00:00:00 2001 From: Zach Wasserman Date: Mon, 3 Nov 2025 09:47:21 -0800 Subject: [PATCH] Fix JSON marshal for software entries (#35011) **Related issue:** Resolves #35005 # Checklist for submitter ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually For unreleased bug fixes in a release candidate, one of: - [x] Confirmed that the fix is not expected to adversely impact load test results ## Summary by CodeRabbit * **New Features** * Software API responses now properly include installed paths and path signature information when populated. * **Tests** * Added test coverage for software data serialization and installed paths functionality in API responses. --------- Co-authored-by: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> --- server/fleet/software.go | 16 +++ server/fleet/software_test.go | 67 ++++++++++++ server/service/integration_core_test.go | 131 ++++++++++++++++++++++++ 3 files changed, 214 insertions(+) diff --git a/server/fleet/software.go b/server/fleet/software.go index 53ff6a5a7b..9e39bf6a1f 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -397,6 +397,22 @@ type HostSoftwareEntry struct { PathSignatureInformation []PathSignatureInformation `json:"signature_information"` } +// MarshalJSON implements custom JSON marshaling for HostSoftwareEntry to ensure +// all fields (both from embedded Software and the additional fields) are marshaled +func (hse *HostSoftwareEntry) MarshalJSON() ([]byte, error) { + hse.populateBrowserField() + type Alias Software + return json.Marshal(&struct { + *Alias + InstalledPaths []string `json:"installed_paths"` + PathSignatureInformation []PathSignatureInformation `json:"signature_information"` + }{ + Alias: (*Alias)(&hse.Software), + InstalledPaths: hse.InstalledPaths, + PathSignatureInformation: hse.PathSignatureInformation, + }) +} + type PathSignatureInformation struct { InstalledPath string `json:"installed_path"` TeamIdentifier string `json:"team_identifier"` diff --git a/server/fleet/software_test.go b/server/fleet/software_test.go index 34b583f66f..4bc95038d2 100644 --- a/server/fleet/software_test.go +++ b/server/fleet/software_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -194,3 +195,69 @@ func TestEnhanceOutputDetails(t *testing.T) { }) } } + +func TestHostSoftwareEntryMarshalJSON(t *testing.T) { + // Test that HostSoftwareEntry properly marshals all fields including + // InstalledPaths and PathSignatureInformation from the embedded Software struct + hashValue := "abc123" + entry := HostSoftwareEntry{ + Software: Software{ + ID: 1, + Name: "Test Software", + Version: "1.0.0", + Source: "chrome_extensions", + BundleIdentifier: "com.test.software", + ExtensionID: "test-extension-id", + ExtensionFor: "chrome", + Browser: "", + Release: "1", + Vendor: "Test Vendor", + Arch: "x86_64", + GenerateCPE: "cpe:2.3:a:test:software:1.0.0:*:*:*:*:*:*:*", + Vulnerabilities: Vulnerabilities{}, + HostsCount: 5, + ApplicationID: ptr.String("com.test.app"), + }, + InstalledPaths: []string{"/usr/local/bin/test", "/opt/test"}, + PathSignatureInformation: []PathSignatureInformation{ + { + InstalledPath: "/usr/local/bin/test", + TeamIdentifier: "ABCDE12345", + HashSha256: &hashValue, + }, + }, + } + + // Marshal to JSON + data, err := entry.MarshalJSON() + require.NoError(t, err) + + // Expected JSON with all fields including browser and extension_for + expectedJSON := `{ + "id": 1, + "name": "Test Software", + "version": "1.0.0", + "bundle_identifier": "com.test.software", + "source": "chrome_extensions", + "extension_id": "test-extension-id", + "extension_for": "chrome", + "browser": "chrome", + "release": "1", + "vendor": "Test Vendor", + "arch": "x86_64", + "generated_cpe": "cpe:2.3:a:test:software:1.0.0:*:*:*:*:*:*:*", + "vulnerabilities": [], + "hosts_count": 5, + "application_id": "com.test.app", + "installed_paths": ["/usr/local/bin/test", "/opt/test"], + "signature_information": [ + { + "installed_path": "/usr/local/bin/test", + "team_identifier": "ABCDE12345", + "hash_sha256": "abc123" + } + ] + }` + + assert.JSONEq(t, expectedJSON, string(data)) +} diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 74669c9845..85d6b9c52a 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -2031,6 +2031,137 @@ func (s *integrationTestSuite) TestListHosts() { require.Equal(t, label2.Name, resp.Hosts[0].Labels[1].Name) } +func (s *integrationTestSuite) TestListHostsPopulateSoftwareWithInstalledPaths() { + t := s.T() + ctx := context.Background() + + // Create a host for this test + host, err := s.ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String(t.Name() + "1"), + OsqueryHostID: ptr.String(t.Name() + "1"), + UUID: t.Name() + "1", + Hostname: t.Name() + "foo.local", + PrimaryIP: "192.168.1.10", + PrimaryMac: "30-65-EC-6F-C4-58", + Platform: "darwin", + }) + require.NoError(t, err) + require.NotNil(t, host) + + // Create software with installed paths and signature information + software := []fleet.Software{ + { + Name: "Google Chrome.app", + Version: "121.0.6167.160", + Source: "chrome_extensions", + ExtensionID: "test-extension-id", + ExtensionFor: "chrome", + BundleIdentifier: "com.google.Chrome", + }, + } + hostSoftware, err := s.ds.UpdateHostSoftware(ctx, host.ID, software) + require.NoError(t, err) + require.Len(t, hostSoftware.CurrInstalled(), 1) + + // Add installed paths and signature information + swPaths := map[string]struct{}{} + for _, s := range software { + pathItems := [][3]string{ + {"/Applications/Google Chrome.app", "EQHXZ8M8AV", "abc123hash"}, + {"/Users/test/Applications/Google Chrome.app", "", ""}, + } + for _, pathItem := range pathItems { + path := pathItem[0] + teamIdentifier := pathItem[1] + cdHash := pathItem[2] + key := fmt.Sprintf( + "%s%s%s%s%s%s%s", + path, fleet.SoftwareFieldSeparator, teamIdentifier, fleet.SoftwareFieldSeparator, cdHash, fleet.SoftwareFieldSeparator, s.ToUniqueStr(), + ) + swPaths[key] = struct{}{} + } + } + err = s.ds.UpdateHostSoftwareInstalledPaths(ctx, host.ID, swPaths, hostSoftware) + require.NoError(t, err) + + // Sync to ensure counts are updated + err = s.ds.SyncHostsSoftware(ctx, time.Now().UTC()) + require.NoError(t, err) + + // Test: GET /api/latest/fleet/hosts with populate_software=true + var listResp listHostsResponse + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp, "populate_software", "true") + + // Find our test host in the response + var testHost *fleet.HostResponse + for i, h := range listResp.Hosts { + if h.ID == host.ID { + testHost = &listResp.Hosts[i] + break + } + } + require.NotNil(t, testHost, "test host not found in response") + + // Verify software is populated + require.NotEmpty(t, testHost.Software, "software should be populated") + require.Len(t, testHost.Software, 1, "expected 1 software entry") + + // Verify the software entry has the expected fields + sw := testHost.Software[0] + assert.Equal(t, "Google Chrome.app", sw.Name) + assert.Equal(t, "121.0.6167.160", sw.Version) + assert.Equal(t, "chrome_extensions", sw.Source) + assert.Equal(t, "test-extension-id", sw.ExtensionID) + assert.Equal(t, "chrome", sw.ExtensionFor) + assert.Equal(t, "chrome", sw.Browser) // backward compatibility field + assert.Equal(t, "com.google.Chrome", sw.BundleIdentifier) + + // Verify installed_paths is populated and not empty + assert.NotEmpty(t, sw.InstalledPaths, "installed_paths should be populated") + assert.Len(t, sw.InstalledPaths, 2, "expected 2 installed paths") + assert.Contains(t, sw.InstalledPaths, "/Applications/Google Chrome.app") + assert.Contains(t, sw.InstalledPaths, "/Users/test/Applications/Google Chrome.app") + + // Verify signature_information is populated + assert.NotEmpty(t, sw.PathSignatureInformation, "signature_information should be populated") + assert.Len(t, sw.PathSignatureInformation, 2, "expected 2 signature information entries") + + // Sort by installed path for consistent ordering + sort.Slice(sw.PathSignatureInformation, func(i, j int) bool { + return sw.PathSignatureInformation[i].InstalledPath < sw.PathSignatureInformation[j].InstalledPath + }) + + // Verify first signature information (system-level with team identifier) + sigInfo0 := sw.PathSignatureInformation[0] + assert.Equal(t, "/Applications/Google Chrome.app", sigInfo0.InstalledPath) + assert.Equal(t, "EQHXZ8M8AV", sigInfo0.TeamIdentifier) + assert.NotNil(t, sigInfo0.HashSha256) + assert.Equal(t, "abc123hash", *sigInfo0.HashSha256) + + // Verify second signature information (user-level without team identifier) + sigInfo1 := sw.PathSignatureInformation[1] + assert.Equal(t, "/Users/test/Applications/Google Chrome.app", sigInfo1.InstalledPath) + assert.Equal(t, "", sigInfo1.TeamIdentifier) + assert.Nil(t, sigInfo1.HashSha256) + + // Also verify the JSON marshaling by checking the raw JSON response + rawResp := s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, "populate_software", "true") + defer rawResp.Body.Close() + body, err := io.ReadAll(rawResp.Body) + require.NoError(t, err) + + // Verify the JSON contains our expected fields + assert.Contains(t, string(body), "installed_paths", "JSON should contain installed_paths field") + assert.Contains(t, string(body), "signature_information", "JSON should contain signature_information field") + assert.Contains(t, string(body), "/Applications/Google Chrome.app", "JSON should contain the installed path") + assert.Contains(t, string(body), "EQHXZ8M8AV", "JSON should contain the team identifier") + assert.Contains(t, string(body), "abc123hash", "JSON should contain the hash") +} + func (s *integrationTestSuite) TestInvites() { t := s.T()