Fix JSON marshal for software entries (#35011)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com>
This commit is contained in:
Zach Wasserman 2025-11-03 09:47:21 -08:00 committed by GitHub
parent 6c9cd40513
commit fdfd050d87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 214 additions and 0 deletions

View file

@ -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"`

View file

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

View file

@ -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()