Add bundle_identifier to top-level host software API response (#42188)

- @noahtalerman: For the following story:
  - https://github.com/fleetdm/fleet/issues/43557

## Summary

- Adds `bundle_identifier` as a top-level field on
`HostSoftwareWithInstaller` struct, which is the response type for all
host software API endpoints (`GET /api/v1/fleet/hosts/{id}/software`,
`GET /api/v1/fleet/device/{token}/software`, etc.)
- The value is sourced directly from `software_titles.bundle_identifier`
via the SQL query, so it is always present even when
`installed_versions` is empty (e.g., software that has never been
installed on a host)
- Falls back to `installed_versions[0].bundle_identifier` if the
title-level value is not available
- The field is retained inside `installed_versions` for full backwards
compatibility

## Changes

### `server/fleet/software_installer.go`
- Added `BundleIdentifier string` field with
`json:"bundle_identifier,omitempty" db:"-"` tag to
`HostSoftwareWithInstaller` struct

### `server/datastore/mysql/software.go`
- Added `TitleBundleIdentifier` field to internal `hostSoftware` struct
mapped to `title_bundle_identifier` DB column
- Added `software_titles.bundle_identifier AS title_bundle_identifier`
to all four SQL query branches:
  - Software installers SELECT
  - VPP apps SELECT
  - In-house apps SELECT
  - Available-for-install SELECT (`stmtAvailable`)
- Added `software_titles.bundle_identifier` to GROUP BY clauses for
software installers and VPP apps
- In the return path, populates `BundleIdentifier` from
`TitleBundleIdentifier` (with fallback to first installed version)

### `server/datastore/mysql/software_test.go`
- Added `BundleIdentifier` assertion to both `compareResults` helper
functions (macOS/Linux and iOS/iPadOS test suites)
- Added explicit top-level `BundleIdentifier` assertions in
`testListHostSoftwareWithVPPApps` where the installed version bundle
identifier is already verified

## Example response shape

```json
{
  "id": 121,
  "name": "Google Chrome.app",
  "bundle_identifier": "com.google.Chrome",
  "icon_url": null,
  "software_package": { ... },
  "app_store_app": null,
  "source": "apps",
  "status": "failed_install",
  "installed_versions": [
    {
      "version": "121.0",
      "bundle_identifier": "com.google.Chrome",
      ...
    }
  ]
}
```

Built for
[ntalerman](https://fleetdm.slack.com/archives/D0AEA6U4SM9/p1774036621198819?thread_ts=1774035719.384099&cid=D0AEA6U4SM9)
by [Kilo for Slack](https://kilo.ai/features/slack-integration)

---------

Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
Co-authored-by: Kilo Code <kilo@kilo.ai>
Co-authored-by: Noah Talerman <47070608+noahtalerman@users.noreply.github.com>
Co-authored-by: jkatz01 <yehonatankatz@gmail.com>
This commit is contained in:
kilo-code-bot[bot]
2026-05-19 12:51:32 -04:00
committed by GitHub
co-authored by kiloconnect[bot] Kilo Code Noah Talerman jkatz01
parent 3e10ad717c
commit 6becc62393
4 changed files with 219 additions and 45 deletions
+155 -12
View File
@@ -4965,6 +4965,7 @@ Currently, `hash_sha256`, `executable_sha256`, and `executable_path` are only su
{
"id": 121,
"name": "Google Chrome.app",
"bundle_identifier": "com.google.Chrome",
"icon_url": null,
"software_package": {
"name": "GoogleChrome.pkg",
@@ -4999,27 +5000,169 @@ Currently, `hash_sha256`, `executable_sha256`, and `executable_path` are only su
]
},
{
"id": 134,
"name": "Falcon.app",
"id": 147,
"name": "Logic Pro",
"bundle_identifier": "com.apple.logic10",
"icon_url": "/api/latest/fleet/software/titles/147/icon?fleet_id=2",
"software_package": null,
"app_store_app": {
"app_store_id": "1091189122",
"platform": "darwin",
"version": "2.04",
"self_service": false,
"last_install": {
"command_uuid": "0aa14ae5-58fe-491a-ac9a-e4ee2b3aac40",
"installed_at": "2024-05-15T15:23:57Z"
},
},
"source": "apps",
"status": "installed",
"installed_versions": [
{
"version": "118.0",
"bundle_identifier": "com.apple.logic10",
"last_opened_at": "2024-04-01T23:03:07Z",
"vulnerabilities": ["CVE-2023-1234"],
"installed_paths": ["/Applications/Logic Pro.app"],
"signature_information": [
{
"installed_path": "/Applications/Logic Pro.app",
"team_identifier": "",
"hash_sha256": null,
"executable_sha256": null,
"executable_path": null
}
]
}
]
},
{
"id": 150,
"name": "GitHub Copilot",
"software_package": null,
"app_store_app": null,
"source": "jetbrains_plugins",
"extension_for": "goland",
"installed_versions": [
{
"version": "1.2.3",
"vulnerabilities": [],
"installed_paths": ["/Users/username/Library/Application Support/JetBrains/GoLand2025.2/plugins/github-copilot-intellij"],
}
]
},
{
"id": 12,
"name": "MyCustomApp",
"software_package": {
"name": "MyCustomApp-1.12.ipa",
"platform": "ios",
"version": "1.12",
"self_service": false,
"automatic_install_policies": null,
"last_install": null,
"last_uninstall": null
},
"app_store_app": null,
"versions_count": 1,
"source": "ios_apps",
"hosts_count": 48,
"versions": [
{
"id": 123,
"version": "1.12",
"vulnerabilities": null
}
],
}
],
"meta": {
"has_next_results": false,
"has_previous_results": false
}
}
```
### Get host's software by Fleet Desktop token
`GET /api/v1/fleet/device/:token/software`
#### Parameters
| Name | Type | In | Description |
| ---- | ------- | ---- | ---------------------------- |
| token | string | path | **Required.** The host's [Fleet Desktop token](https://fleetdm.com/guides/fleet-desktop#secure-fleet-desktop). |
| query | string | query | Search query keywords. Searchable fields include `name`. |
| available_for_install | boolean | query | If `true` or `1`, only list software that is available for install (added by the user). Default is `false`. |
| self_service | boolean | query | If `true` or `1`, only lists self-service software. Default is `false`. |
| vulnerable | boolean | query | If `true` or `1`, only list software that have vulnerabilities. Default is `false`. |
| page | integer | query | Page number of the results to fetch.|
| per_page | integer | query | Results per page.|
| order_key | string | query | What to order results by. Options include `"name"`. Default is `"name"`. |
| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `"asc"` and `"desc"`. Default is `"asc"`. |
| min_cvss_score | integer | query | _Available in Fleet Premium_. Filters to include only software with vulnerabilities that have a CVSS version 3.x base score higher than the specified value. |
| max_cvss_score | integer | query | _Available in Fleet Premium_. Filters to only include software with vulnerabilities that have a CVSS version 3.x base score lower than what's specified. |
| exploit | boolean | query | _Available in Fleet Premium_. If `true`, filters to only include software with vulnerabilities that have been actively exploited in the wild (`cisa_known_exploit: true`). Default is `false`. |
On macOS hosts, `last_opened_at` is supported for software from the `apps` source and is the last open time of the most recently installed version of the software. After an update, it may be empty until the software is opened again.
On Windows hosts, `last_opened_at` is supported for software from the `programs` source. On Linux hosts, `last_opened_at` is supported for software from the `deb_packages` and `rpm_packages` sources. On Windows and Linux hosts, it represents the last open time of any version.
Currently, `hash_sha256`, `executable_sha256`, and `executable_path` are only supported for macOS software from the `apps` source. `hash_sha256` is the [`cdhash_sha256`](https://fleetdm.com/tables/codesign).
#### Example
`GET /api/v1/fleet/device/:token/software`
##### Default response
`Status: 200`
```json
{
"count": 3,
"software": [
{
"id": 121,
"name": "Google Chrome.app",
"bundle_identifier": "com.google.Chrome"
"icon_url": null,
"software_package": {
"name": "FalconSensor-6.44.pkg",
"name": "GoogleChrome.pkg",
"platform": "darwin",
"self_service": false,
"last_install": null,
"last_uninstall": {
"script_execution_id": "ed579e73-0f41-46c8-aaf4-3c1e5880ed27",
"uninstalled_at": "2024-05-15T15:23:57Z"
"version": "125.12.0.3",
"self_service": true,
"last_install": {
"install_uuid": "8bbb8ac2-b254-4387-8cba-4d8a0407368b",
"installed_at": "2024-05-15T15:23:57Z"
}
},
"app_store_app": null,
"source": "",
"status": "pending_uninstall",
"installed_versions": [],
"source": "apps",
"status": "failed_install",
"installed_versions": [
{
"version": "121.0",
"bundle_identifier": "com.google.Chrome",
"last_opened_at": "2024-04-01T23:03:07Z",
"vulnerabilities": ["CVE-2023-1234","CVE-2023-4321","CVE-2023-7654"],
"installed_paths": ["/Applications/Google Chrome.app"],
"signature_information": [
{
"installed_path": "/Applications/Google Chrome.app",
"team_identifier": "EQHXZ8M8AV",
"hash_sha256": "a45d00ac9bf21e108fa8e452fabe4d9e05e6765b",
"executable_sha256": "7afc9d01a62f03a2de9637936d4afe68090d2de18d03f29c88cfb0b1ba63587f",
"executable_path": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
}
]
}
]
},
{
"id": 147,
"name": "Logic Pro",
"bundle_identifier": "com.apple.logic10"
"icon_url": "/api/latest/fleet/software/titles/147/icon?fleet_id=2",
"software_package": null,
"app_store_app": {
@@ -5804,7 +5947,7 @@ Grant a blocked host access for a single login. Requires Okta conditional access
| Name | Type | In | Description |
| ----------- | ------ | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| token | string | path | **Required.** The host's [device authentication token](https://fleetdm.com/guides/fleet-desktop#secure-fleet-desktop). |
| token | string | path | **Required.** The host's [Fleet Desktop token](https://fleetdm.com/guides/fleet-desktop#secure-fleet-desktop). |
#### Example
+19 -2
View File
@@ -3430,6 +3430,7 @@ type hostSoftware struct {
ExitCode *int `db:"exit_code"`
LastOpenedAt *time.Time `db:"last_opened_at"`
BundleIdentifier *string `db:"bundle_identifier"`
TitleBundleIdentifier *string `db:"title_bundle_identifier"`
Version *string `db:"version"`
SoftwareID *uint `db:"software_id"`
SoftwareSource *string `db:"software_source"`
@@ -4911,6 +4912,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt
st.source,
st.extension_for,
st.upgrade_code,
st.bundle_identifier as title_bundle_identifier,
si.id as installer_id,
si.self_service as package_self_service,
si.filename as package_name,
@@ -5941,6 +5943,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt
software_titles.source AS source,
software_titles.extension_for AS extension_for,
software_titles.upgrade_code AS upgrade_code, -- should be empty or non-empty string for "programs" sourced software, null otherwise
software_titles.bundle_identifier AS title_bundle_identifier,
software_installers.id AS installer_id,
software_installers.self_service AS package_self_service,
software_installers.filename AS package_name,
@@ -5969,6 +5972,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt
software_titles.source,
software_titles.extension_for,
software_titles.upgrade_code,
software_titles.bundle_identifier,
software_installers.id,
software_installers.self_service,
software_installers.filename,
@@ -5986,6 +5990,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt
software_titles.source AS source,
software_titles.extension_for AS extension_for,
software_titles.upgrade_code AS upgrade_code, -- should always be null for vpp (mac) apps
software_titles.bundle_identifier AS title_bundle_identifier,
NULL AS installer_id,
NULL AS package_self_service,
NULL AS package_name,
@@ -6013,7 +6018,8 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt
software_titles.name,
software_titles.source,
software_titles.extension_for,
software_titles.upgrade_code
software_titles.upgrade_code,
software_titles.bundle_identifier
`)
}
@@ -6027,6 +6033,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt
software_titles.source AS source,
software_titles.extension_for AS extension_for,
software_titles.upgrade_code AS upgrade_code,
software_titles.bundle_identifier AS title_bundle_identifier,
NULL AS installer_id,
NULL AS package_self_service,
NULL AS package_name,
@@ -6054,7 +6061,8 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt
software_titles.name,
software_titles.source,
software_titles.extension_for,
software_titles.upgrade_code
software_titles.upgrade_code,
software_titles.bundle_identifier
`)
}
stmt = fmt.Sprintf(stmt, replacements...)
@@ -6454,6 +6462,15 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt
software := make([]*fleet.HostSoftwareWithInstaller, 0, len(hostSoftwareList))
for _, hs := range hostSoftwareList {
// Populate top-level bundle_identifier from the software_titles table
// (available even when installed_versions is empty), falling back to
// the first installed version for backwards compatibility.
switch {
case hs.TitleBundleIdentifier != nil && *hs.TitleBundleIdentifier != "":
hs.HostSoftwareWithInstaller.BundleIdentifier = *hs.TitleBundleIdentifier
case len(hs.InstalledVersions) > 0 && hs.InstalledVersions[0].BundleIdentifier != "":
hs.HostSoftwareWithInstaller.BundleIdentifier = hs.InstalledVersions[0].BundleIdentifier
}
software = append(software, &hs.HostSoftwareWithInstaller)
}
+44 -31
View File
@@ -4264,6 +4264,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
require.True(t, ok, "unexpected software %s%s", g.Name, g.Source)
require.Equal(t, e.Name, g.Name)
require.Equal(t, e.Source, g.Source)
require.Equal(t, e.BundleIdentifier, g.BundleIdentifier)
if e.SoftwarePackage != nil {
require.Equal(t, e.SoftwarePackage.SelfService, g.SoftwarePackage.SelfService)
require.Equal(t, e.SoftwarePackage.AppStoreID, g.SoftwarePackage.AppStoreID)
@@ -4964,22 +4965,25 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
compareResults(expected, sw, true, i1.Name+i1.Source, i3.Name+i3.Source, i2.Name+i2.Source, i4.Name+i4.Source, i5.Name+i5.Source, i6.Name+i6.Source) // i3 is for team, i2 is available (excluded)
expected["vpp1apps"] = fleet.HostSoftwareWithInstaller{
Name: "vpp1",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstalled),
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp1, Platform: "darwin", SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp1CmdUUID}},
Name: "vpp1",
Source: "apps",
BundleIdentifier: "com.app.vpp1",
Status: expectStatus(fleet.SoftwareInstalled),
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp1, Platform: "darwin", SelfService: new(false), LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp1CmdUUID}},
}
expected["vpp2apps"] = fleet.HostSoftwareWithInstaller{
Name: "vpp2",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallPending),
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp2, Platform: "darwin", SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp2bCmdUUID}},
Name: "vpp2",
Source: "apps",
BundleIdentifier: "com.app.vpp2",
Status: expectStatus(fleet.SoftwareInstallPending),
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp2, Platform: "darwin", SelfService: new(false), LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp2bCmdUUID}},
}
expected["vpp3apps"] = fleet.HostSoftwareWithInstaller{
Name: "vpp3",
Source: "apps",
Status: nil,
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp3, Platform: "darwin", SelfService: ptr.Bool(true)},
Name: "vpp3",
Source: "apps",
BundleIdentifier: "com.app.vpp3",
Status: nil,
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp3, Platform: "darwin", SelfService: new(true)},
}
expectedAvailableOnly["vpp1apps"] = expected["vpp1apps"]
@@ -5027,9 +5031,10 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
compareResults(map[string]fleet.HostSoftwareWithInstaller{
i3.Name + i3.Source: expected[i3.Name+i3.Source],
"vpp1apps": {
Name: "vpp1",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallPending),
Name: "vpp1",
Source: "apps",
BundleIdentifier: "com.app.vpp1",
Status: expectStatus(fleet.SoftwareInstallPending),
AppStoreApp: &fleet.SoftwarePackageOrApp{
AppStoreID: vpp1,
Platform: "darwin",
@@ -5631,6 +5636,7 @@ func testListIOSHostSoftware(t *testing.T, ds *Datastore) {
require.True(t, ok, "unexpected software name:%s source:%s", g.Name, g.Source)
require.Equal(t, e.Name, g.Name)
require.Equal(t, e.Source, g.Source)
require.Equal(t, e.BundleIdentifier, g.BundleIdentifier)
if e.SoftwarePackage != nil {
require.Equal(t, e.SoftwarePackage.SelfService, g.SoftwarePackage.SelfService)
require.Equal(t, e.SoftwarePackage.AppStoreID, g.SoftwarePackage.AppStoreID)
@@ -5773,28 +5779,32 @@ func testListIOSHostSoftware(t *testing.T, ds *Datastore) {
compareResults(expected, sw, true) // i3 is for team, i2 is available (excluded)
expected["vpp1ios_apps"] = fleet.HostSoftwareWithInstaller{
Name: "vpp1",
Source: "ios_apps",
Status: expectStatus(fleet.SoftwareInstalled),
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp1, Platform: "ios", SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp1CmdUUID}},
Name: "vpp1",
Source: "ios_apps",
BundleIdentifier: "com.app.vpp1",
Status: expectStatus(fleet.SoftwareInstalled),
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp1, Platform: "ios", SelfService: new(false), LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp1CmdUUID}},
}
expected["vpp2ios_apps"] = fleet.HostSoftwareWithInstaller{
Name: "vpp2",
Source: "ios_apps",
Status: expectStatus(fleet.SoftwareInstallPending),
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp2, Platform: "ios", SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp2bCmdUUID}},
Name: "vpp2",
Source: "ios_apps",
BundleIdentifier: "com.app.vpp2",
Status: expectStatus(fleet.SoftwareInstallPending),
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp2, Platform: "ios", SelfService: new(false), LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp2bCmdUUID}},
}
expected["vpp3ios_apps"] = fleet.HostSoftwareWithInstaller{
Name: "vpp3",
Source: "ios_apps",
Status: nil,
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp3, Platform: "ios", SelfService: ptr.Bool(false)},
Name: "vpp3",
Source: "ios_apps",
BundleIdentifier: "com.app.vpp3",
Status: nil,
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp3, Platform: "ios", SelfService: new(false)},
}
expected["vpp4ios_apps"] = fleet.HostSoftwareWithInstaller{
Name: "vpp4",
Source: "ios_apps",
Status: nil,
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp4, Platform: "ios", SelfService: ptr.Bool(false)},
Name: "vpp4",
Source: "ios_apps",
BundleIdentifier: "com.app.vpp4",
Status: nil,
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp4, Platform: "ios", SelfService: new(false)},
}
expectedAvailableOnly := map[string]fleet.HostSoftwareWithInstaller{}
expectedAvailableOnly["vpp1ios_apps"] = expected["vpp1ios_apps"]
@@ -5949,6 +5959,8 @@ func testListHostSoftwareWithVPPApps(t *testing.T, ds *Datastore) {
assert.Equal(t, "1.2.3", sw[0].InstalledVersions[0].Version)
assert.Equal(t, "apps", sw[0].InstalledVersions[0].Source)
assert.Equal(t, vPPApp.BundleIdentifier, sw[0].InstalledVersions[0].BundleIdentifier)
// top-level bundle_identifier comes from software_titles.bundle_identifier
assert.Equal(t, vPPApp.BundleIdentifier, sw[0].BundleIdentifier)
// The vpp app is installed by fleet, and also has been inventoried by osquery
// Ensure we don't lose the version for the vpp app
@@ -5984,6 +5996,7 @@ func testListHostSoftwareWithVPPApps(t *testing.T, ds *Datastore) {
assert.Equal(t, vPPApp.Name, sw[0].Name)
assert.Equal(t, vPPApp.AdamID, sw[0].AppStoreApp.AppStoreID)
assert.Equal(t, "0.1.0", sw[0].InstalledVersions[0].Version)
assert.Equal(t, vPPApp.BundleIdentifier, sw[0].BundleIdentifier)
assert.Nil(t, sw[0].Status)
// insert an icon
+1
View File
@@ -735,6 +735,7 @@ func IsScriptPackage(ext string) bool {
type HostSoftwareWithInstaller struct {
ID uint `json:"id" db:"id"`
Name string `json:"name" db:"name"`
BundleIdentifier string `json:"bundle_identifier,omitempty" db:"-"`
IconUrl *string `json:"icon_url" db:"-"`
Source string `json:"source" db:"source"`
ExtensionFor string `json:"extension_for" db:"extension_for"`