fleet/server/datastore/mysql/software_test.go
jacobshandling c75e5d85c0
Return light software metadata when listing hosts filtered by software present only on a different team (#42519)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #39190

https://www.loom.com/share/3c1828f03c584756b7ed8f3ba75a1038

<img width="1840" height="1196" alt="Screenshot 2026-03-30 at 1 08
32 PM"
src="https://github.com/user-attachments/assets/592c9396-65b4-4723-99e7-63f9ee0264c1"
/>

- [x] Changes file added for user-visible changes in `changes/`
- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

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

## Summary by CodeRabbit

* **Bug Fixes**
* Resolved host filtering by software version when the version is not
available on the selected team; now returns software information instead
of an error.
  * Fixed a related UI issue caused by the original filtering behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-03-30 21:33:21 -07:00

12013 lines
450 KiB
Go

package mysql
import (
"bytes"
"context"
crand "crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"log/slog"
"maps"
"math/rand"
std_slices "slices"
"sort"
"strconv"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/platform/logging"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd"
"github.com/fleetdm/fleet/v4/server/vulnerabilities/oval"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/exp/slices"
"golang.org/x/sync/errgroup"
)
func TestSoftware(t *testing.T) {
ds := CreateMySQLDS(t)
cases := []struct {
name string
fn func(t *testing.T, ds *Datastore)
}{
{"SaveHost", testSoftwareSaveHost},
{"CPE", testSoftwareCPE},
{"CheckForDeletedInstalledSoftware", testCheckForDeletedInstalledSoftware},
{"LoadVulnerabilities", testSoftwareLoadVulnerabilities},
{"ListSoftwareCPEs", testListSoftwareCPEs},
{"NothingChanged", testSoftwareNothingChanged},
{"LoadSupportsTonsOfCVEs", testSoftwareLoadSupportsTonsOfCVEs},
{"List", testSoftwareList},
{"SyncHostsSoftware", testSoftwareSyncHostsSoftware},
{"LoadHostsPopulateSoftware", testLoadHostSoftwarePopulateSoftwareInstalledPath},
{"DeleteSoftwareVulnerabilities", testDeleteSoftwareVulnerabilities},
{"HostsByCVE", testHostsByCVE},
{"HostVulnSummariesBySoftwareIDs", testHostVulnSummariesBySoftwareIDs},
{"UpdateHostSoftware", testUpdateHostSoftware},
{"UpdateHostSoftwareDeadlock", testUpdateHostSoftwareDeadlock},
{"UpdateHostSoftwareUpdatesSoftware", testUpdateHostSoftwareUpdatesSoftware},
{"UpdateHostSoftwareSameBundleIDDifferentNames", testUpdateHostSoftwareSameBundleIDDifferentNames},
{"UpdateHostSoftwareSameNameDifferentBundleIDs", testUpdateHostSoftwareSameNameDifferentBundleIDs},
{"UpdateHostSoftwareMultipleSameBundleID", testUpdateHostSoftwareMultipleSameBundleID},
{"UpdateHostSoftwareMultipleChecksumsPerBundleID", testUpdateHostSoftwareMultipleChecksumsPerBundleID},
{"UpdateHostSoftwareLongNameTruncation", testUpdateHostSoftwareLongNameTruncation},
{"UpdateHostBundleIDRenameOnlyNoNewSoftware", testUpdateHostBundleIDRenameOnlyNoNewSoftware},
{"UpdateHostBundleIDRenameWithNewSoftware", testUpdateHostBundleIDRenameWithNewSoftware},
{"UpdateHostBrowserExtensions", testUpdateHostBrowserExtensions},
{"ListSoftwareByHostIDShort", testListSoftwareByHostIDShort},
{"ListSoftwareVulnerabilitiesByHostIDsSource", testListSoftwareVulnerabilitiesByHostIDsSource},
{"InsertSoftwareVulnerability", testInsertSoftwareVulnerability},
{"InsertSoftwareVulnerabilities", testInsertSoftwareVulnerabilities},
{"ListCVEs", testListCVEs},
{"ListSoftwareForVulnDetection", testListSoftwareForVulnDetection},
{"AllSoftwareIterator", testAllSoftwareIterator},
{"AllSoftwareIteratorForCustomLinuxImages", testSoftwareIteratorForLinuxKernelCustomImages},
{"UpsertSoftwareCPEs", testUpsertSoftwareCPEs},
{"DeleteOutOfDateVulnerabilities", testDeleteOutOfDateVulnerabilities},
{"DeleteOrphanedSoftwareVulnerabilities", testDeleteOrphanedSoftwareVulnerabilities},
{"DeleteSoftwareCPEs", testDeleteSoftwareCPEs},
{"SoftwareByIDNoDuplicatedVulns", testSoftwareByIDNoDuplicatedVulns},
{"SoftwareByIDIncludesCVEPublishedDate", testSoftwareByIDIncludesCVEPublishedDate},
{"GetHostSoftwareInstalledPaths", testGetHostSoftwareInstalledPaths},
{"HostSoftwareInstalledPathsDelta", testHostSoftwareInstalledPathsDelta},
{"DeleteHostSoftwareInstalledPaths", testDeleteHostSoftwareInstalledPaths},
{"InsertHostSoftwareInstalledPaths", testInsertHostSoftwareInstalledPaths},
{"VerifySoftwareChecksum", testVerifySoftwareChecksum},
{"ListHostSoftware", testListHostSoftware},
{"ListHostSoftwarePaginationWithMultipleInstallers", testListHostSoftwarePaginationWithMultipleInstallers},
{"ListLinuxHostSoftware", testListLinuxHostSoftware},
{"ListIOSHostSoftware", testListIOSHostSoftware},
{"ListHostSoftwareWithVPPApps", testListHostSoftwareWithVPPApps},
{"ListHostSoftwareVPPSelfService", testListHostSoftwareVPPSelfService},
{"ListHostSoftwareVPPSelfServiceTeamFilter", testListHostSoftwareVPPSelfServiceTeamFilter},
{"SetHostSoftwareInstallResult", testSetHostSoftwareInstallResult},
{"CreateIntermediateInstallFailureRecord", testCreateIntermediateInstallFailureRecord},
{"ListHostSoftwareInstallThenTransferTeam", testListHostSoftwareInstallThenTransferTeam},
{"ListHostSoftwareFailInstallThenTransferTeam", testListHostSoftwareFailInstallThenTransferTeam},
{"ListHostSoftwareFailInstallThenLabelExclude", testListHostSoftwareFailInstallThenLabelExclude},
{"ListHostSoftwareFailUninstallThenLabelExclude", testListHostSoftwareFailUninstallThenLabelExclude},
{"ListHostSoftwareFailVPPInstallThenLabelExclude", testListHostSoftwareFailVPPInstallThenLabelExclude},
{"ListHostSoftwareFailInHouseInstallThenLabelExclude", testListHostSoftwareFailInHouseInstallThenLabelExclude},
{"ListHostSoftwareInstallThenDeleteInstallers", testListHostSoftwareInstallThenDeleteInstallers},
{"ListSoftwareVersionsVulnerabilityFilters", testListSoftwareVersionsVulnerabilityFilters},
{"TestListHostSoftwareWithLabelScoping", testListHostSoftwareWithLabelScoping},
{"TestListHostSoftwareVulnerableAndVPP", testListHostSoftwareVulnerableAndVPP},
{"TestListHostSoftwareQuerySearching", testListHostSoftwareQuerySearching},
{"TestListHostSoftwareWithLabelScopingVPP", testListHostSoftwareWithLabelScopingVPP},
{"TestListHostSoftwareSelfServiceWithLabelScopingHostInstalled", testListHostSoftwareSelfServiceWithLabelScopingHostInstalled},
{"TestListHostSoftwareLastOpenedAt", testListHostSoftwareLastOpenedAt},
{"DeletedInstalledSoftware", testDeletedInstalledSoftware},
{"SoftwareCategories", testSoftwareCategories},
{"LabelScopingTimestampLogic", testLabelScopingTimestampLogic},
{"InventoryPendingSoftware", testInventoryPendingSoftware},
{"PreInsertSoftwareInventory", testPreInsertSoftwareInventory},
{"ListHostSoftwareWithExtensionFor", testListHostSoftwareWithExtensionFor},
{"LongestCommonPrefix", testLongestCommonPrefix},
{"ListHostSoftwareInHouseApps", testListHostSoftwareInHouseApps},
{"ListHostSoftwareAndroidVPPAppMatching", testListHostSoftwareAndroidVPPAppMatching},
{"CountHostSoftwareInstallAttempts", testCountHostSoftwareInstallAttempts},
{"ResetNonPolicyInstallAttempts", testResetNonPolicyInstallAttempts},
{"ListSoftwareVersionsSearchByTitleName", testListSoftwareVersionsSearchByTitleName},
{"ListSoftwareInventoryDeletedHost", testListSoftwareInventoryDeletedHost},
{"ListHostSoftwareShPackageForDarwin", testListHostSoftwareShPackageForDarwin},
{"HostSWPaginationWithMultipleFMAVersions", testHostSWPaginationWithMultipleFMAVersions},
{"SoftwareLiteByID", testSoftwareLiteByID},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Helper()
defer TruncateTables(t, ds)
c.fn(t, ds)
})
}
}
func testSoftwareSaveHost(t *testing.T, ds *Datastore) {
host1 := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
host2 := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now())
software1 := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
}
software2 := []fleet.Software{
{Name: "foo", Version: "0.0.2", Source: "chrome_extensions"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "deb_packages", BundleIdentifier: "com.some.identifier"},
{Name: "zoo", Version: "0.0.5", Source: "deb_packages", BundleIdentifier: ""},
}
getHostSoftware := func(h *fleet.Host) []fleet.Software {
var software []fleet.Software
for _, s := range h.Software {
software = append(software, s.Software)
}
return software
}
_, err := ds.UpdateHostSoftware(context.Background(), host1.ID, software1)
require.NoError(t, err)
_, err = ds.UpdateHostSoftware(context.Background(), host2.ID, software2)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host1, false))
host1Software := getHostSoftware(host1)
test.ElementsMatchSkipIDAndHostCount(t, software1, host1Software)
soft1ByID, err := ds.SoftwareByID(context.Background(), host1.HostSoftware.Software[0].ID, nil, false, nil)
require.NoError(t, err)
require.NotNil(t, soft1ByID)
assert.Equal(t, host1Software[0], *soft1ByID)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host2, false))
host2Software := getHostSoftware(host2)
test.ElementsMatchSkipIDAndHostCount(t, software2, host2Software)
software1 = []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
{Name: "towel", Version: "42.0.0", Source: "apps"},
}
software2 = []fleet.Software{}
_, err = ds.UpdateHostSoftware(context.Background(), host1.ID, software1)
require.NoError(t, err)
_, err = ds.UpdateHostSoftware(context.Background(), host2.ID, software2)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host1, false))
host1Software = getHostSoftware(host1)
test.ElementsMatchSkipIDAndHostCount(t, software1, host1Software)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host2, false))
host2Software = getHostSoftware(host2)
test.ElementsMatchSkipIDAndHostCount(t, software2, host2Software)
software1 = []fleet.Software{
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
{Name: "towel", Version: "42.0.0", Source: "apps"},
}
_, err = ds.UpdateHostSoftware(context.Background(), host1.ID, software1)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host1, false))
host1Software = getHostSoftware(host1)
test.ElementsMatchSkipIDAndHostCount(t, software1, host1Software)
software2 = []fleet.Software{
{Name: "foo", Version: "0.0.2", Source: "chrome_extensions"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "deb_packages", BundleIdentifier: "com.some.identifier"},
{Name: "zoo", Version: "0.0.5", Source: "deb_packages", BundleIdentifier: "com.zoo"}, // "empty" -> "non-empty"
}
_, err = ds.UpdateHostSoftware(context.Background(), host2.ID, software2)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host2, false))
host2Software = getHostSoftware(host2)
test.ElementsMatchSkipIDAndHostCount(t, software2, host2Software)
software2 = []fleet.Software{
{Name: "foo", Version: "0.0.2", Source: "chrome_extensions"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "deb_packages", BundleIdentifier: "com.some.other"}, // "non-empty" -> "non-empty"
{Name: "zoo", Version: "0.0.5", Source: "deb_packages", BundleIdentifier: ""}, // non-empty -> empty
}
_, err = ds.UpdateHostSoftware(context.Background(), host2.ID, software2)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host2, false))
host2Software = getHostSoftware(host2)
test.ElementsMatchSkipIDAndHostCount(t, software2, host2Software)
}
func testSoftwareCPE(t *testing.T, ds *Datastore) {
host1 := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
software1 := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
}
software2 := []fleet.Software{
{Name: "bar", Version: "0.0.3", Source: "deb_packages", BundleIdentifier: "com.some.other"}, // "non-empty" -> "non-empty"
{Name: "zoo", Version: "0.0.5", Source: "rpm_packages", BundleIdentifier: ""}, // non-empty -> empty
}
_, err := ds.UpdateHostSoftware(context.Background(), host1.ID, append(software1, software2...))
require.NoError(t, err)
q := fleet.SoftwareIterQueryOptions{ExcludedSources: oval.SupportedSoftwareSources}
iterator, err := ds.AllSoftwareIterator(context.Background(), q)
require.NoError(t, err)
defer iterator.Close()
loops := 0
for iterator.Next() {
software, err := iterator.Value()
require.NoError(t, err)
require.NoError(t, iterator.Err())
require.NotEmpty(t, software.ID)
require.NotEmpty(t, software.Name)
require.NotEmpty(t, software.Version)
require.NotEmpty(t, software.Source)
require.NotEqual(t, software.Name, "bar")
require.NotEqual(t, software.Name, "zoo")
if loops > 2 {
t.Error("Looping through more software than we have")
}
loops++
}
assert.Equal(t, len(software1), loops)
require.NoError(t, iterator.Close())
}
func testSoftwareLoadVulnerabilities(t *testing.T, ds *Datastore) {
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "apps"},
{Name: "blah", Version: "1.0", Source: "apps"},
}
_, err := ds.UpdateHostSoftware(context.Background(), host.ID, software)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host, false))
cpes := []fleet.SoftwareCPE{
{SoftwareID: host.Software[0].ID, CPE: "somecpe"},
{SoftwareID: host.Software[1].ID, CPE: "someothercpewithoutvulns"},
}
_, err = ds.UpsertSoftwareCPEs(context.Background(), cpes)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host, false))
vulns := []fleet.SoftwareVulnerability{
{SoftwareID: host.Software[0].ID, CVE: "CVE-2022-0001"},
{SoftwareID: host.Software[0].ID, CVE: "CVE-2022-0002"},
}
for _, v := range vulns {
_, err = ds.InsertSoftwareVulnerability(context.Background(), v, fleet.NVDSource)
require.NoError(t, err)
}
require.NoError(t, ds.LoadHostSoftware(context.Background(), host, false))
softByID, err := ds.SoftwareByID(context.Background(), host.HostSoftware.Software[0].ID, nil, false, nil)
require.NoError(t, err)
require.NotNil(t, softByID)
require.Len(t, softByID.Vulnerabilities, 2)
assert.GreaterOrEqual(t, softByID.Vulnerabilities[0].CreatedAt, time.Now().Add(-time.Minute))
assert.Equal(t, "somecpe", host.Software[0].GenerateCPE)
require.Len(t, host.Software[0].Vulnerabilities, 2)
sort.Slice(host.Software[0].Vulnerabilities, func(i, j int) bool {
return host.Software[0].Vulnerabilities[i].CVE < host.Software[0].Vulnerabilities[j].CVE
})
assert.Equal(t, "CVE-2022-0001", host.Software[0].Vulnerabilities[0].CVE)
assert.Equal(t,
"https://nvd.nist.gov/vuln/detail/CVE-2022-0001", host.Software[0].Vulnerabilities[0].DetailsLink)
assert.Equal(t, "CVE-2022-0002", host.Software[0].Vulnerabilities[1].CVE)
assert.Equal(t,
"https://nvd.nist.gov/vuln/detail/CVE-2022-0002", host.Software[0].Vulnerabilities[1].DetailsLink)
assert.Equal(t, "someothercpewithoutvulns", host.Software[1].GenerateCPE)
require.Len(t, host.Software[1].Vulnerabilities, 0)
}
func testListSoftwareCPEs(t *testing.T, ds *Datastore) {
ctx := context.Background()
debian := test.NewHost(t, ds, "host3", "", "host3key", "host3uuid", time.Now())
debian.Platform = "debian"
require.NoError(t, ds.UpdateHost(ctx, debian))
ubuntu := test.NewHost(t, ds, "host4", "", "host4key", "host4uuid", time.Now())
ubuntu.Platform = "ubuntu"
require.NoError(t, ds.UpdateHost(ctx, ubuntu))
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "apps"},
{Name: "biz", Version: "0.0.1", Source: "deb_packages"},
{Name: "baz", Version: "0.0.3", Source: "deb_packages"},
}
_, err := ds.UpdateHostSoftware(ctx, debian.ID, software[:2])
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(ctx, debian, false))
_, err = ds.UpdateHostSoftware(ctx, ubuntu.ID, software[2:])
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(ctx, ubuntu, false))
cpes := []fleet.SoftwareCPE{
{SoftwareID: debian.Software[0].ID, CPE: "cpe1"},
{SoftwareID: debian.Software[1].ID, CPE: "cpe2"},
{SoftwareID: ubuntu.Software[0].ID, CPE: "cpe3"},
{SoftwareID: ubuntu.Software[1].ID, CPE: "cpe4"},
}
_, err = ds.UpsertSoftwareCPEs(ctx, cpes)
require.NoError(t, err)
cpes, err = ds.ListSoftwareCPEs(ctx)
expected := []string{
"cpe1", "cpe2", "cpe3", "cpe4",
}
var actual []string
for _, v := range cpes {
actual = append(actual, v.CPE)
}
require.NoError(t, err)
assert.ElementsMatch(t, actual, expected)
}
func testSoftwareNothingChanged(t *testing.T, ds *Datastore) {
cases := []struct {
desc string
current []fleet.Software
incoming []fleet.Software
want bool
}{
{"both nil", nil, nil, true},
{"different len", nil, []fleet.Software{{}}, false},
{
"identical",
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD"}},
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD"}},
true,
},
{
"different version",
[]fleet.Software{{Name: "A", Version: "1.1", Source: "ASD"}},
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD"}},
false,
},
{
"new software",
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD"}},
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD"}, {Name: "B", Version: "1.0", Source: "ASD"}},
false,
},
{
"removed software",
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD"}, {Name: "B", Version: "1.0", Source: "ASD"}},
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD"}},
false,
},
{
"identical with similar last open",
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD", LastOpenedAt: ptr.Time(time.Now())}},
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD", LastOpenedAt: ptr.Time(time.Now())}},
true,
},
{
"identical with no new last open",
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD", LastOpenedAt: ptr.Time(time.Now())}},
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD"}},
true,
},
{
"identical but added last open",
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD"}},
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD", LastOpenedAt: ptr.Time(time.Now())}},
false,
},
{
"identical but significantly changed last open",
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD", LastOpenedAt: ptr.Time(time.Now().Add(-365 * 24 * time.Hour))}},
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD", LastOpenedAt: ptr.Time(time.Now())}},
false,
},
{
"identical but insignificantly changed last open",
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD", LastOpenedAt: ptr.Time(time.Now().Add(-time.Second))}},
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD", LastOpenedAt: ptr.Time(time.Now())}},
true,
},
{
"identical with duplicates incoming",
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD"}},
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD"}, {Name: "A", Version: "1.0", Source: "ASD"}},
true,
},
{
"identical with duplicates incoming and insignificantly changed last open",
[]fleet.Software{{Name: "A", Version: "1.0", Source: "ASD", LastOpenedAt: ptr.Time(time.Now().Add(-time.Second))}},
[]fleet.Software{
{Name: "A", Version: "1.0", Source: "ASD"},
{Name: "A", Version: "1.0", Source: "ASD", LastOpenedAt: ptr.Time(time.Now().Add(-time.Hour))},
{Name: "A", Version: "1.0", Source: "ASD", LastOpenedAt: ptr.Time(time.Now())},
},
true,
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
current, incoming, got := nothingChanged(c.current, c.incoming, defaultMinLastOpenedAtDiff)
if c.want {
assert.True(t, got)
assert.Equal(t, len(current), len(incoming))
} else {
assert.False(t, got)
}
assert.Equal(t, len(c.current), len(current))
})
}
}
func generateCVEMeta(n int) fleet.CVEMeta {
CVEID := fmt.Sprintf("CVE-2022-%05d", n)
cvssScore := ptr.Float64(rand.Float64() * 10)
epssProbability := ptr.Float64(rand.Float64())
cisaKnownExploit := ptr.Bool(rand.Intn(2) == 1)
return fleet.CVEMeta{
CVE: CVEID,
CVSSScore: cvssScore,
EPSSProbability: epssProbability,
CISAKnownExploit: cisaKnownExploit,
}
}
func testSoftwareLoadSupportsTonsOfCVEs(t *testing.T, ds *Datastore) {
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "apps"},
{Name: "blah", Version: "1.0", Source: "apps"},
}
_, err := ds.UpdateHostSoftware(context.Background(), host.ID, software)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host, false))
sort.Slice(host.Software, func(i, j int) bool { return host.Software[i].Name < host.Software[j].Name })
cpes := []fleet.SoftwareCPE{
{SoftwareID: host.Software[1].ID, CPE: "someothercpewithoutvulns"},
{SoftwareID: host.Software[0].ID, CPE: "somecpe"},
}
_, err = ds.UpsertSoftwareCPEs(context.Background(), cpes)
require.NoError(t, err)
var cveMeta []fleet.CVEMeta
for i := 0; i < 1000; i++ {
cveMeta = append(cveMeta, generateCVEMeta(i))
}
err = ds.InsertCVEMeta(context.Background(), cveMeta)
require.NoError(t, err)
values := strings.TrimSuffix(strings.Repeat("(?, ?), ", len(cveMeta)), ", ")
query := `INSERT INTO software_cve (software_id, cve) VALUES ` + values
var args []interface{}
for _, cve := range cveMeta {
args = append(args, host.Software[0].ID, cve.CVE)
}
_, err = ds.writer(context.Background()).ExecContext(context.Background(), query, args...)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host, false))
for _, software := range host.Software {
switch software.Name {
case "bar":
assert.Equal(t, "somecpe", software.GenerateCPE)
require.Len(t, software.Vulnerabilities, 1000)
assert.True(t, strings.HasPrefix(software.Vulnerabilities[0].CVE, "CVE-"))
assert.Equal(t,
"https://nvd.nist.gov/vuln/detail/"+software.Vulnerabilities[0].CVE,
software.Vulnerabilities[0].DetailsLink,
)
case "blah":
assert.Len(t, software.Vulnerabilities, 0)
assert.Equal(t, "someothercpewithoutvulns", software.GenerateCPE)
case "foo":
assert.Len(t, software.Vulnerabilities, 0)
}
}
}
func testSoftwareList(t *testing.T, ds *Datastore) {
host1 := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
host2 := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now())
host3 := test.NewHost(t, ds, "host3", "", "host3key", "host3uuid", time.Now())
software1 := []fleet.Software{
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
}
software2 := []fleet.Software{
{Name: "foo", Version: "v0.0.2", Source: "chrome_extensions"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "deb_packages"},
}
software3 := []fleet.Software{
{Name: "baz", Version: "0.0.1", Source: "deb_packages"},
}
_, err := ds.UpdateHostSoftware(context.Background(), host1.ID, software1)
require.NoError(t, err)
_, err = ds.UpdateHostSoftware(context.Background(), host2.ID, software2)
require.NoError(t, err)
_, err = ds.UpdateHostSoftware(context.Background(), host3.ID, software3)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host1, false))
require.NoError(t, ds.LoadHostSoftware(context.Background(), host2, false))
require.NoError(t, ds.LoadHostSoftware(context.Background(), host3, false))
sort.Slice(host1.Software, func(i, j int) bool {
return host1.Software[i].Name+host1.Software[i].Version < host1.Software[j].Name+host1.Software[j].Version
})
cpes := []fleet.SoftwareCPE{
{SoftwareID: host1.Software[0].ID, CPE: "somecpe"},
{SoftwareID: host1.Software[1].ID, CPE: "someothercpewithoutvulns"},
{SoftwareID: host3.Software[0].ID, CPE: "somecpe2"},
}
_, err = ds.UpsertSoftwareCPEs(context.Background(), cpes)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host1, false))
require.NoError(t, ds.LoadHostSoftware(context.Background(), host2, false))
require.NoError(t, ds.LoadHostSoftware(context.Background(), host3, false))
sort.Slice(host1.Software, func(i, j int) bool {
return host1.Software[i].Name+host1.Software[i].Version < host1.Software[j].Name+host1.Software[j].Version
})
vulns := []fleet.SoftwareVulnerability{
{SoftwareID: host1.Software[0].ID, CVE: "CVE-2022-0001", ResolvedInVersion: ptr.String("2.0.0")},
{SoftwareID: host1.Software[0].ID, CVE: "CVE-2022-0002", ResolvedInVersion: ptr.String("2.0.0")},
{SoftwareID: host3.Software[0].ID, CVE: "CVE-2022-0003", ResolvedInVersion: ptr.String("2.0.0")},
}
for _, v := range vulns {
_, err = ds.InsertSoftwareVulnerability(context.Background(), v, fleet.NVDSource)
require.NoError(t, err)
}
now := time.Now().UTC().Truncate(time.Second)
cveMeta := []fleet.CVEMeta{
{
CVE: "CVE-2022-0001",
CVSSScore: ptr.Float64(2.0),
EPSSProbability: ptr.Float64(0.01),
CISAKnownExploit: ptr.Bool(false),
Published: ptr.Time(now.Add(-2 * time.Hour)),
Description: "this is a description for CVE-2022-0001",
},
{
CVE: "CVE-2022-0002",
CVSSScore: ptr.Float64(1.0),
EPSSProbability: ptr.Float64(0.99),
CISAKnownExploit: ptr.Bool(false),
Published: ptr.Time(now),
Description: "this is a description for CVE-2022-0002",
},
{
CVE: "CVE-2022-0003",
CVSSScore: ptr.Float64(3.0),
EPSSProbability: ptr.Float64(0.98),
CISAKnownExploit: ptr.Bool(true),
Published: ptr.Time(now.Add(-1 * time.Hour)),
Description: "this is a description for CVE-2022-0003",
},
}
err = ds.InsertCVEMeta(context.Background(), cveMeta)
require.NoError(t, err)
foo001 := fleet.Software{
Name: "foo",
Version: "0.0.1",
Source: "chrome_extensions",
GenerateCPE: "somecpe",
Vulnerabilities: fleet.Vulnerabilities{
{
CVE: "CVE-2022-0001",
DetailsLink: "https://nvd.nist.gov/vuln/detail/CVE-2022-0001",
CVSSScore: ptr.Float64Ptr(2.0),
EPSSProbability: ptr.Float64Ptr(0.01),
CISAKnownExploit: ptr.BoolPtr(false),
CVEPublished: ptr.TimePtr(now.Add(-2 * time.Hour)),
Description: ptr.StringPtr("this is a description for CVE-2022-0001"),
ResolvedInVersion: ptr.StringPtr("2.0.0"),
},
{
CVE: "CVE-2022-0002",
DetailsLink: "https://nvd.nist.gov/vuln/detail/CVE-2022-0002",
CVSSScore: ptr.Float64Ptr(1.0),
EPSSProbability: ptr.Float64Ptr(0.99),
CISAKnownExploit: ptr.BoolPtr(false),
CVEPublished: ptr.TimePtr(now),
Description: ptr.StringPtr("this is a description for CVE-2022-0002"),
ResolvedInVersion: ptr.StringPtr("2.0.0"),
},
},
}
foo002 := fleet.Software{Name: "foo", Version: "v0.0.2", Source: "chrome_extensions"}
foo003 := fleet.Software{Name: "foo", Version: "0.0.3", Source: "chrome_extensions", GenerateCPE: "someothercpewithoutvulns"}
bar003 := fleet.Software{Name: "bar", Version: "0.0.3", Source: "deb_packages"}
baz001 := fleet.Software{
Name: "baz",
Version: "0.0.1",
Source: "deb_packages",
GenerateCPE: "somecpe2",
Vulnerabilities: fleet.Vulnerabilities{
{
CVE: "CVE-2022-0003",
DetailsLink: "https://nvd.nist.gov/vuln/detail/CVE-2022-0003",
CVSSScore: ptr.Float64Ptr(3.0),
EPSSProbability: ptr.Float64Ptr(0.98),
CISAKnownExploit: ptr.BoolPtr(true),
CVEPublished: ptr.TimePtr(now.Add(-1 * time.Hour)),
Description: ptr.StringPtr("this is a description for CVE-2022-0003"),
ResolvedInVersion: ptr.StringPtr("2.0.0"),
},
},
}
require.NoError(t, ds.SyncHostsSoftware(context.Background(), time.Now()))
t.Run("lists everything", func(t *testing.T) {
opts := fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name,version",
},
IncludeCVEScores: true,
}
software := listSoftwareCheckCount(t, ds, 5, 5, opts, false)
expected := []fleet.Software{bar003, baz001, foo001, foo002, foo003}
test.ElementsMatchSkipID(t, software, expected)
})
t.Run("paginates", func(t *testing.T) {
opts := fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{
Page: 1,
PerPage: 1,
OrderKey: "version",
IncludeMetadata: true,
},
IncludeCVEScores: true,
}
software := listSoftwareCheckCount(t, ds, 1, 5, opts, true)
require.Len(t, software, 1)
var expected []fleet.Software
// Both foo001 and baz001 have the same version, thus we check which one the database picked
// for the second page.
if software[0].Name == "foo" {
expected = []fleet.Software{foo001}
} else {
expected = []fleet.Software{baz001}
}
test.ElementsMatchSkipID(t, software, expected)
})
t.Run("filters by team", func(t *testing.T) {
team1, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team1"})
require.NoError(t, err)
require.NoError(t, ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(&team1.ID, []uint{host1.ID})))
require.NoError(t, ds.SyncHostsSoftware(context.Background(), time.Now()))
opts := fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "version",
},
TeamID: &team1.ID,
IncludeCVEScores: true,
}
software := listSoftwareCheckCount(t, ds, 2, 2, opts, true)
expected := []fleet.Software{foo001, foo003}
test.ElementsMatchSkipID(t, software, expected)
// Now that we have the software, we can test pagination.
// Figure out which software has the highest ID.
targetSoftware := software[0]
if targetSoftware.ID < software[1].ID {
targetSoftware = software[1]
}
expected = []fleet.Software{foo001}
if targetSoftware.Name == "foo" && targetSoftware.Version == "0.0.3" {
expected = []fleet.Software{foo003}
}
opts = fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{
PerPage: 1,
Page: 1, // 2nd item, since 1st item is on page 0
OrderKey: "id",
IncludeMetadata: true,
},
TeamID: &team1.ID,
IncludeCVEScores: true,
}
software = listSoftwareCheckCount(t, ds, 1, 2, opts, true)
test.ElementsMatchSkipID(t, software, expected)
})
t.Run("filters by no team (team 0)", func(t *testing.T) {
opts := fleet.SoftwareListOptions{
TeamID: ptr.Uint(0),
IncludeCVEScores: true,
}
software := listSoftwareCheckCount(t, ds, 4, 4, opts, true)
expected := []fleet.Software{bar003, baz001, foo002, foo003}
test.ElementsMatchSkipID(t, software, expected)
})
t.Run("filters vulnerable software", func(t *testing.T) {
opts := fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name",
},
VulnerableOnly: true,
IncludeCVEScores: true,
}
software := listSoftwareCheckCount(t, ds, 2, 2, opts, true)
expected := []fleet.Software{foo001, baz001}
test.ElementsMatchSkipID(t, software, expected)
})
t.Run("filters by CVE", func(t *testing.T) {
opts := fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{
MatchQuery: "CVE-2022-0001",
},
IncludeCVEScores: true,
}
software := listSoftwareCheckCount(t, ds, 1, 1, opts, true)
expected := []fleet.Software{foo001}
test.ElementsMatchSkipID(t, software, expected)
opts.ListOptions.MatchQuery = "CVE-2022-0002"
software = listSoftwareCheckCount(t, ds, 1, 1, opts, true)
expected = []fleet.Software{foo001}
test.ElementsMatchSkipID(t, software, expected)
// partial CVE
opts.ListOptions.MatchQuery = "0002"
software = listSoftwareCheckCount(t, ds, 1, 1, opts, true)
expected = []fleet.Software{foo001}
test.ElementsMatchSkipID(t, software, expected)
// unknown CVE
opts.ListOptions.MatchQuery = "CVE-2022-0000"
listSoftwareCheckCount(t, ds, 0, 0, opts, true)
})
t.Run("filters by CVE with WithoutVulnerabilityDetails", func(t *testing.T) {
// Regression test for https://github.com/fleetdm/fleet/issues/34713
// When WithoutVulnerabilityDetails=true and IncludeCVEScores=false,
// the software_cve table is not joined in the subquery, but the WHERE clause
// still tries to reference scv.cve, causing "Unknown column 'scv.cve'" error.
opts := fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{
MatchQuery: "CVE-2022-0001",
},
WithoutVulnerabilityDetails: true,
IncludeCVEScores: false,
}
software := listSoftwareCheckCount(t, ds, 1, 1, opts, true)
expectedFoo001 := fleet.Software{
Name: "foo",
Version: "0.0.1",
Source: "chrome_extensions",
}
require.Len(t, software, 1)
require.Equal(t, expectedFoo001.Name, software[0].Name)
require.Equal(t, expectedFoo001.Version, software[0].Version)
require.Equal(t, expectedFoo001.Source, software[0].Source)
// Test with partial CVE
opts.ListOptions.MatchQuery = "0002"
software = listSoftwareCheckCount(t, ds, 1, 1, opts, true)
require.Len(t, software, 1)
require.Equal(t, expectedFoo001.Name, software[0].Name)
// Test with unknown CVE
opts.ListOptions.MatchQuery = "CVE-2022-0000"
listSoftwareCheckCount(t, ds, 0, 0, opts, true)
})
t.Run("filters by query", func(t *testing.T) {
// query by name (case insensitive)
opts := fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{
MatchQuery: "baR",
},
}
software := listSoftwareCheckCount(t, ds, 1, 1, opts, true)
expected := []fleet.Software{bar003}
test.ElementsMatchSkipID(t, software, expected)
// query by version
opts.ListOptions.MatchQuery = "0.0.3"
software = listSoftwareCheckCount(t, ds, 2, 2, opts, true)
expected = []fleet.Software{foo003, bar003}
test.ElementsMatchSkipID(t, software, expected)
// query by version (case insensitive)
opts.ListOptions.MatchQuery = "V0.0.2"
software = listSoftwareCheckCount(t, ds, 1, 1, opts, true)
expected = []fleet.Software{foo002}
test.ElementsMatchSkipID(t, software, expected)
})
t.Run("order by name and id", func(t *testing.T) {
opts := fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name,id",
OrderDirection: fleet.OrderAscending,
},
}
software := listSoftwareCheckCount(t, ds, 5, 5, opts, false)
assert.Equal(t, bar003.Name, software[0].Name)
assert.Equal(t, bar003.Version, software[0].Version)
assert.Equal(t, baz001.Name, software[1].Name)
assert.Equal(t, baz001.Version, software[1].Version)
// foo's ordered by id, descending
assert.Greater(t, software[3].ID, software[2].ID)
assert.Greater(t, software[4].ID, software[3].ID)
})
t.Run("order by hosts_count", func(t *testing.T) {
softwareDESC := listSoftwareCheckCount(t, ds, 5, 5, fleet.SoftwareListOptions{ListOptions: fleet.ListOptions{OrderKey: "hosts_count", OrderDirection: fleet.OrderDescending}, WithHostCounts: true}, false)
// ordered by counts descending, so foo003 is first
assert.Equal(t, foo003.Name, softwareDESC[0].Name)
assert.Equal(t, 2, softwareDESC[0].HostsCount)
// Test that ASC exactly reverses DESC ordering
softwareASC := listSoftwareCheckCount(t, ds, 5, 5, fleet.SoftwareListOptions{ListOptions: fleet.ListOptions{OrderKey: "hosts_count", OrderDirection: fleet.OrderAscending}, WithHostCounts: true}, false)
require.Len(t, softwareASC, 5)
require.Len(t, softwareDESC, 5)
// Verify at least 2 software items have the same host count (to test secondary sort)
countMap := make(map[int]int)
for _, s := range softwareDESC {
countMap[s.HostsCount]++
}
hasMultipleWithSameCount := false
for _, count := range countMap {
if count >= 2 {
hasMultipleWithSameCount = true
break
}
}
require.True(t, hasMultipleWithSameCount, "test requires at least 2 software items with the same host count")
// ASC should be exact reverse of DESC
for i := range softwareASC {
expectedIdx := len(softwareDESC) - 1 - i
assert.Equal(t, softwareDESC[expectedIdx].ID, softwareASC[i].ID,
"ASC[%d] should equal DESC[%d], ASC=%s %s (id=%d), DESC=%s %s (id=%d)",
i, expectedIdx,
softwareASC[i].Name, softwareASC[i].Version, softwareASC[i].ID,
softwareDESC[expectedIdx].Name, softwareDESC[expectedIdx].Version, softwareDESC[expectedIdx].ID)
}
})
t.Run("order by epss_probability", func(t *testing.T) {
opts := fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "epss_probability",
OrderDirection: fleet.OrderDescending,
},
IncludeCVEScores: true,
}
software := listSoftwareCheckCount(t, ds, 5, 5, opts, false)
assert.Equal(t, foo001.Name, software[0].Name)
assert.Equal(t, foo001.Version, software[0].Version)
})
t.Run("order by cvss_score", func(t *testing.T) {
opts := fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "cvss_score",
OrderDirection: fleet.OrderDescending,
},
IncludeCVEScores: true,
}
software := listSoftwareCheckCount(t, ds, 5, 5, opts, false)
assert.Equal(t, baz001.Name, software[0].Name)
assert.Equal(t, baz001.Version, software[0].Version)
})
t.Run("order by cisa_known_exploit", func(t *testing.T) {
opts := fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "cisa_known_exploit",
OrderDirection: fleet.OrderDescending,
},
IncludeCVEScores: true,
}
software := listSoftwareCheckCount(t, ds, 5, 5, opts, false)
assert.Equal(t, baz001.Name, software[0].Name)
assert.Equal(t, baz001.Version, software[0].Version)
})
t.Run("order by cve_published", func(t *testing.T) {
opts := fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "cve_published",
OrderDirection: fleet.OrderDescending,
},
IncludeCVEScores: true,
}
software := listSoftwareCheckCount(t, ds, 5, 5, opts, false)
assert.Equal(t, foo001.Name, software[0].Name)
assert.Equal(t, foo001.Version, software[0].Version)
})
t.Run("nil cve scores if IncludeCVEScores is false", func(t *testing.T) {
opts := fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name,version",
OrderDirection: fleet.OrderDescending,
},
IncludeCVEScores: false,
}
software := listSoftwareCheckCount(t, ds, 5, 5, opts, false)
for _, s := range software {
for _, vuln := range s.Vulnerabilities {
assert.Nil(t, vuln.CVSSScore)
assert.Nil(t, vuln.EPSSProbability)
assert.Nil(t, vuln.CISAKnownExploit)
}
}
})
t.Run("SQL injection prevention in OrderKey", func(t *testing.T) {
maliciousPayloads := []string{
"name;DROP TABLE software--",
"name+SLEEP(2)+name",
"name` UNION SELECT * FROM users--",
"name`+SLEEP(2)+`name",
"`name` UNION SELECT password FROM users WHERE `name",
"name' OR '1'='1",
}
for _, payload := range maliciousPayloads {
opts := fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{
OrderKey: payload,
},
IncludeCVEScores: true,
}
_, _, err := ds.ListSoftware(context.Background(), opts)
require.Error(t, err, "SQL injection payload should result in column error: %s", payload)
require.Contains(t, err.Error(), "Unknown column", "Expected column error for payload: %s", payload)
}
})
t.Run("validate ORDER BY", func(t *testing.T) {
opts := fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name",
OrderDirection: fleet.OrderAscending,
},
IncludeCVEScores: true,
}
software, _, err := ds.ListSoftware(context.Background(), opts)
require.NoError(t, err)
require.Len(t, software, 5)
// ordered by name ascending
require.Equal(t, "bar", software[0].Name)
require.Equal(t, "baz", software[1].Name)
require.Equal(t, "foo", software[2].Name)
require.Equal(t, "foo", software[3].Name)
require.Equal(t, "foo", software[4].Name)
opts.ListOptions.OrderDirection = fleet.OrderDescending
software, _, err = ds.ListSoftware(context.Background(), opts)
require.NoError(t, err)
require.Len(t, software, 5)
// ordered by name descending
require.Equal(t, "foo", software[0].Name)
require.Equal(t, "foo", software[1].Name)
require.Equal(t, "foo", software[2].Name)
require.Equal(t, "baz", software[3].Name)
require.Equal(t, "bar", software[4].Name)
opts = fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name,version",
OrderDirection: fleet.OrderAscending,
},
IncludeCVEScores: true,
}
software, _, err = ds.ListSoftware(context.Background(), opts)
require.NoError(t, err)
require.Len(t, software, 5)
require.Equal(t, "bar", software[0].Name)
require.Equal(t, "0.0.3", software[0].Version)
require.Equal(t, "baz", software[1].Name)
require.Equal(t, "0.0.1", software[1].Version)
for i := 2; i < 5; i++ {
require.Equal(t, "foo", software[i].Name)
}
require.Equal(t, "0.0.1", software[2].Version)
require.Equal(t, "0.0.3", software[3].Version)
require.Equal(t, "v0.0.2", software[4].Version)
})
}
func listSoftwareCheckCount(t *testing.T, ds *Datastore, expectedListCount int, expectedFullCount int, opts fleet.SoftwareListOptions, returnSorted bool) []fleet.Software {
t.Helper()
software, meta, err := ds.ListSoftware(context.Background(), opts)
require.NoError(t, err)
require.Len(t, software, expectedListCount)
count, err := ds.CountSoftware(context.Background(), opts)
require.NoError(t, err)
require.Equal(t, expectedFullCount, count)
if opts.ListOptions.IncludeMetadata {
require.NotNil(t, meta)
if expectedListCount == expectedFullCount {
require.False(t, meta.HasPreviousResults)
require.True(t, meta.HasNextResults)
}
if expectedFullCount > expectedListCount {
shouldHavePrevious := opts.ListOptions.Page > 0
require.Equal(t, shouldHavePrevious, meta.HasPreviousResults)
shouldHaveNext := uint(expectedFullCount) > (opts.ListOptions.Page+1)*opts.ListOptions.PerPage // page is 0-indexed
require.Equal(t, shouldHaveNext, meta.HasNextResults)
}
} else {
require.Nil(t, meta)
}
for i, s := range software {
sort.Slice(s.Vulnerabilities, func(i, j int) bool {
return s.Vulnerabilities[i].CVE < s.Vulnerabilities[j].CVE
})
for i2, v := range s.Vulnerabilities {
require.Greater(t, v.CreatedAt, time.Now().Add(-time.Hour)) // assert non-zero
software[i].Vulnerabilities[i2].CreatedAt = time.Time{} // zero out for comparison
}
}
if returnSorted {
sort.Slice(software, func(i, j int) bool {
return software[i].Name+software[i].Version < software[j].Name+software[j].Version
})
}
return software
}
func testSoftwareSyncHostsSoftware(t *testing.T, ds *Datastore) {
countHostSoftwareBatchSizeOrig := countHostSoftwareBatchSize
softwareInsertBatchSizeOrig := softwareInsertBatchSize
t.Cleanup(
func() {
countHostSoftwareBatchSize = countHostSoftwareBatchSizeOrig
softwareInsertBatchSize = softwareInsertBatchSizeOrig
},
)
countHostSoftwareBatchSize = 2
softwareInsertBatchSize = 2
ctx := context.Background()
cmpNameVersionCount := func(want, got []fleet.Software) {
cmp := make([]fleet.Software, len(got))
for i, sw := range got {
cmp[i] = fleet.Software{Name: sw.Name, Version: sw.Version, HostsCount: sw.HostsCount}
}
require.ElementsMatch(t, want, cmp)
}
// this check ensures that the total number of rows in software_host_counts
// matches the expected value. we can't rely on ds.CountSoftware alone, as
// that method (rightfully) ignores orphaned software counts.
checkTableTotalCount := func(want int) {
var tableCount int
err := ds.writer(context.Background()).Get(&tableCount, "SELECT COUNT(*) FROM software_host_counts")
require.NoError(t, err)
require.Equal(t, want, tableCount)
}
host0 := test.NewHost(t, ds, "host0", "", "host0key", "host0uuid", time.Now())
host1 := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
host2 := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now())
hostTemp := test.NewHost(t, ds, "hostTemp", "", "hostTempKey", "hostTempUuid", time.Now())
// Get counts without any software.
globalOpts := fleet.SoftwareListOptions{
WithHostCounts: true, ListOptions: fleet.ListOptions{OrderKey: "hosts_count", OrderDirection: fleet.OrderDescending},
}
_ = listSoftwareCheckCount(t, ds, 0, 0, globalOpts, false)
software0 := []fleet.Software{
{Name: "abc", Version: "0.0.1", Source: "apps", BundleIdentifier: "com.example.abc"},
{Name: "def", Version: "0.0.1", Source: "apps", BundleIdentifier: "com.example.def"},
}
software1 := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
}
software2 := []fleet.Software{
{Name: "foo", Version: "v0.0.2", Source: "chrome_extensions"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "deb_packages"},
}
softwareTemp := make([]fleet.Software, 0, 10)
for i := 0; i < 10; i++ {
softwareTemp = append(
softwareTemp, fleet.Software{Name: fmt.Sprintf("foo%d", i), Version: fmt.Sprintf("%d.0.1", i), Source: "deb_packages"},
)
}
_, err := ds.UpdateHostSoftware(ctx, host0.ID, software0)
require.NoError(t, err)
_, err = ds.UpdateHostSoftware(ctx, host1.ID, software1)
require.NoError(t, err)
_, err = ds.UpdateHostSoftware(ctx, hostTemp.ID, softwareTemp)
require.NoError(t, err)
_, err = ds.UpdateHostSoftware(ctx, host2.ID, software2)
require.NoError(t, err)
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
_ = listSoftwareCheckCount(t, ds, 16, 16, globalOpts, false)
checkTableTotalCount(32)
// Now, delete 2 hosts. Software with the lowest ID is removed, and there should be a chunk with missing software IDs from the deleted hostTemp software.
require.NoError(t, ds.DeleteHost(ctx, host0.ID))
require.NoError(t, ds.DeleteHost(ctx, hostTemp.ID))
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
globalCounts := listSoftwareCheckCount(t, ds, 4, 4, globalOpts, false)
want := []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 2},
{Name: "foo", Version: "0.0.1", HostsCount: 1},
{Name: "foo", Version: "v0.0.2", HostsCount: 1},
{Name: "bar", Version: "0.0.3", HostsCount: 1},
}
cmpNameVersionCount(want, globalCounts)
checkTableTotalCount(8)
// update host2, remove "bar" software
software2 = []fleet.Software{
{Name: "foo", Version: "v0.0.2", Source: "chrome_extensions"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
}
_, err = ds.UpdateHostSoftware(ctx, host2.ID, software2)
require.NoError(t, err)
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
globalCounts = listSoftwareCheckCount(t, ds, 3, 3, globalOpts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 2},
{Name: "foo", Version: "0.0.1", HostsCount: 1},
{Name: "foo", Version: "v0.0.2", HostsCount: 1},
}
cmpNameVersionCount(want, globalCounts)
checkTableTotalCount(6)
// create a software entry without any host and any counts
_, err = ds.writer(ctx).ExecContext(ctx, fmt.Sprintf(`INSERT INTO software (name, version, source, checksum) VALUES ('baz', '0.0.1', 'testing', %s)`, softwareChecksumComputedColumn("", "testing")))
require.NoError(t, err)
// listing does not return the new software entry
allSw := listSoftwareCheckCount(t, ds, 3, 3, fleet.SoftwareListOptions{}, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 0},
{Name: "foo", Version: "0.0.1", HostsCount: 0},
{Name: "foo", Version: "v0.0.2", HostsCount: 0},
}
cmpNameVersionCount(want, allSw)
// create 2 teams and assign a new host to each
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
require.NoError(t, err)
host3 := test.NewHost(t, ds, "host3", "", "host3key", "host3uuid", time.Now())
require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team1.ID, []uint{host3.ID})))
host4 := test.NewHost(t, ds, "host4", "", "host4key", "host4uuid", time.Now())
require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team2.ID, []uint{host4.ID})))
// assign existing host1 to team1 too, so we have a team with multiple hosts
require.NoError(t, ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(&team1.ID, []uint{host1.ID})))
// use some software for host3 and host4
software3 := []fleet.Software{
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
}
software4 := []fleet.Software{
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "deb_packages"},
}
_, err = ds.UpdateHostSoftware(ctx, host3.ID, software3)
require.NoError(t, err)
_, err = ds.UpdateHostSoftware(ctx, host4.ID, software4)
require.NoError(t, err)
// at this point, there's no counts per team, only global counts
globalCounts = listSoftwareCheckCount(t, ds, 3, 3, globalOpts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 2},
{Name: "foo", Version: "0.0.1", HostsCount: 1},
{Name: "foo", Version: "v0.0.2", HostsCount: 1},
}
cmpNameVersionCount(want, globalCounts)
checkTableTotalCount(6)
team1Opts := fleet.SoftwareListOptions{WithHostCounts: true, TeamID: ptr.Uint(team1.ID), ListOptions: fleet.ListOptions{OrderKey: "hosts_count", OrderDirection: fleet.OrderDescending}}
team1Counts := listSoftwareCheckCount(t, ds, 0, 0, team1Opts, false)
want = []fleet.Software{}
cmpNameVersionCount(want, team1Counts)
checkTableTotalCount(6)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host1, false))
nilSoftware, err := ds.SoftwareByID(context.Background(), host1.HostSoftware.Software[0].ID, &team1.ID, false, nil)
assert.Nil(t, nilSoftware)
assert.ErrorIs(t, err, sql.ErrNoRows)
// after a call to Calculate, the global counts are updated and the team counts appear
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
globalCounts = listSoftwareCheckCount(t, ds, 4, 4, globalOpts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 4},
{Name: "foo", Version: "0.0.1", HostsCount: 1},
{Name: "foo", Version: "v0.0.2", HostsCount: 1},
{Name: "bar", Version: "0.0.3", HostsCount: 1},
}
cmpNameVersionCount(want, globalCounts)
team1Counts = listSoftwareCheckCount(t, ds, 2, 2, team1Opts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 2},
{Name: "foo", Version: "0.0.1", HostsCount: 1},
}
cmpNameVersionCount(want, team1Counts)
// composite pk (software_id, team_id, global_stats), so we expect more rows
checkTableTotalCount(10)
soft1ByID, err := ds.SoftwareByID(context.Background(), host1.HostSoftware.Software[0].ID, &team1.ID, false, nil)
require.NoError(t, err)
soft2ByID, err := ds.SoftwareByID(context.Background(), host1.HostSoftware.Software[1].ID, &team1.ID, false, nil)
require.NoError(t, err)
test.ElementsMatchSkipIDAndHostCount(t, software1, []fleet.Software{*soft1ByID, *soft2ByID})
team2Opts := fleet.SoftwareListOptions{WithHostCounts: true, TeamID: ptr.Uint(team2.ID), ListOptions: fleet.ListOptions{OrderKey: "hosts_count", OrderDirection: fleet.OrderDescending}}
team2Counts := listSoftwareCheckCount(t, ds, 2, 2, team2Opts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 1},
{Name: "bar", Version: "0.0.3", HostsCount: 1},
}
cmpNameVersionCount(want, team2Counts)
// update host4 (team2), remove "bar" software
software4 = []fleet.Software{
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
}
_, err = ds.UpdateHostSoftware(ctx, host4.ID, software4)
require.NoError(t, err)
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
globalCounts = listSoftwareCheckCount(t, ds, 3, 3, globalOpts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 4},
{Name: "foo", Version: "0.0.1", HostsCount: 1},
{Name: "foo", Version: "v0.0.2", HostsCount: 1},
}
cmpNameVersionCount(want, globalCounts)
team1Counts = listSoftwareCheckCount(t, ds, 2, 2, team1Opts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 2},
{Name: "foo", Version: "0.0.1", HostsCount: 1},
}
cmpNameVersionCount(want, team1Counts)
team2Counts = listSoftwareCheckCount(t, ds, 1, 1, team2Opts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 1},
}
cmpNameVersionCount(want, team2Counts)
checkTableTotalCount(8)
// update host4 (team2), remove all software and delete team
software4 = []fleet.Software{}
_, err = ds.UpdateHostSoftware(ctx, host4.ID, software4)
require.NoError(t, err)
require.NoError(t, ds.DeleteTeam(ctx, team2.ID))
// insert a host_software record with software_id 0 to ensure we still calculate results correctly then
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err = q.ExecContext(ctx, `INSERT INTO host_software (host_id, software_id) VALUES (?, 0)`, host1.ID)
require.NoError(t, err)
return nil
})
// this call will remove team2 from the software host counts table,
// and would normally log because we have a zero software_id
realLogger := ds.logger
ds.logger = slog.New(slog.DiscardHandler)
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
ds.logger = realLogger
globalCounts = listSoftwareCheckCount(t, ds, 3, 3, globalOpts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 3},
{Name: "foo", Version: "0.0.1", HostsCount: 1},
{Name: "foo", Version: "v0.0.2", HostsCount: 1},
}
cmpNameVersionCount(want, globalCounts)
team1Counts = listSoftwareCheckCount(t, ds, 2, 2, team1Opts, false)
want = []fleet.Software{
{Name: "foo", Version: "0.0.3", HostsCount: 2},
{Name: "foo", Version: "0.0.1", HostsCount: 1},
}
cmpNameVersionCount(want, team1Counts)
listSoftwareCheckCount(t, ds, 0, 0, team2Opts, false)
checkTableTotalCount(7)
}
// softwareChecksumComputedColumn computes the checksum for a software entry
// The calculation must match the one in computeRawChecksum
func softwareChecksumComputedColumn(tableAlias string, source string) string {
if tableAlias != "" && !strings.HasSuffix(tableAlias, ".") {
tableAlias += "."
}
var nameCol string
if source != "apps" {
nameCol = fmt.Sprintf("%sname,", tableAlias)
}
// concatenate with separator \x00
return fmt.Sprintf(
` UNHEX(
MD5(
CONCAT_WS(CHAR(0),
%s
%[2]sversion,
%[2]ssource,
COALESCE(%[2]sbundle_identifier, ''),
`+"%[2]s`release`"+`,
%[2]sarch,
%[2]svendor,
%[2]sextension_for,
%[2]sextension_id
)
)
) `, nameCol, tableAlias,
)
}
func testLoadHostSoftwarePopulateSoftwareInstalledPath(t *testing.T, ds *Datastore) {
ctx := context.Background()
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithComputerName("computer1"))
software := []fleet.Software{
{
Name: "banana",
Version: "0.0.1",
Source: "apps",
},
}
mutation, err := ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
cdHash, execHash, execPath := "frog", "toad", "/path/to/executable"
installpaths := []fleet.HostSoftwareInstalledPath{
{
HostID: host.ID,
SoftwareID: mutation.Inserted[0].ID,
InstalledPath: "/the/path",
CDHashSHA256: ptr.String(cdHash),
ExecutableSHA256: ptr.String(execHash),
ExecutablePath: ptr.String(execPath),
},
}
err = insertHostSoftwareInstalledPaths(ctx, ds.writer(ctx), installpaths)
require.NoError(t, err)
err = ds.LoadHostSoftware(ctx, host, false)
require.NoError(t, err)
require.Equal(t, "/the/path", host.Software[0].PathSignatureInformation[0].InstalledPath)
require.Equal(t, cdHash, *host.Software[0].PathSignatureInformation[0].CDHashSHA256)
require.Equal(t, execHash, *host.Software[0].PathSignatureInformation[0].ExecutableSHA256)
require.Equal(t, execPath, *host.Software[0].PathSignatureInformation[0].ExecutablePath)
}
func insertVulnSoftwareForTest(t *testing.T, ds *Datastore) {
host1 := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithComputerName("computer1"))
host2 := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now())
software1 := []fleet.Software{
{
Name: "foo.rpm",
Version: "0.0.1",
Source: "rpm_packages",
GenerateCPE: "cpe_foo_rpm",
},
{
Name: "foo.chrome",
Version: "0.0.3",
Source: "chrome_extensions",
GenerateCPE: "cpe_foo_chrome_3",
},
}
software2 := []fleet.Software{
{
Name: "foo.chrome",
Version: "0.0.2",
Source: "chrome_extensions",
GenerateCPE: "cpe_foo_chrome_2",
},
{
Name: "foo.chrome",
Version: "0.0.3",
Source: "chrome_extensions",
GenerateCPE: "cpe_foo_chrome_3",
Vulnerabilities: fleet.Vulnerabilities{
{
CVE: "CVE-2022-0001",
DetailsLink: "https://nvd.nist.gov/vuln/detail/CVE-2022-0001",
},
},
},
{
Name: "bar.rpm",
Version: "0.0.3",
Source: "rpm_packages",
GenerateCPE: "cpe_bar_rpm",
Vulnerabilities: fleet.Vulnerabilities{
{
CVE: "CVE-2022-0002",
DetailsLink: "https://nvd.nist.gov/vuln/detail/CVE-2022-0002",
},
{
CVE: "CVE-2022-0003",
DetailsLink: "https://nvd.nist.gov/vuln/detail/CVE-333-444-555",
},
},
},
}
mutationResults, err := ds.UpdateHostSoftware(context.Background(), host1.ID, software1)
require.NoError(t, err)
// Insert paths for software1
s1Paths := map[string]struct{}{}
for _, s := range software1 {
key := fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s", fmt.Sprintf("/some/path/%s", s.Name), fleet.SoftwareFieldSeparator, "", fleet.SoftwareFieldSeparator, "", fleet.SoftwareFieldSeparator, "", fleet.SoftwareFieldSeparator, "", fleet.SoftwareFieldSeparator, s.ToUniqueStr())
s1Paths[key] = struct{}{}
}
require.NoError(t, ds.UpdateHostSoftwareInstalledPaths(context.Background(), host1.ID, s1Paths, mutationResults))
mutationResults, err = ds.UpdateHostSoftware(context.Background(), host2.ID, software2)
require.NoError(t, err)
// Insert paths for software2
s2Paths := map[string]struct{}{}
for _, s := range software2 {
key := fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s", fmt.Sprintf("/some/path/%s", s.Name), fleet.SoftwareFieldSeparator, "", fleet.SoftwareFieldSeparator, "", fleet.SoftwareFieldSeparator, "", fleet.SoftwareFieldSeparator, "", fleet.SoftwareFieldSeparator, s.ToUniqueStr())
s2Paths[key] = struct{}{}
}
require.NoError(t, ds.UpdateHostSoftwareInstalledPaths(context.Background(), host2.ID, s2Paths, mutationResults))
require.NoError(t, ds.LoadHostSoftware(context.Background(), host1, false))
require.NoError(t, ds.LoadHostSoftware(context.Background(), host2, false))
sort.Slice(host1.Software, func(i, j int) bool {
return host1.Software[i].Name+host1.Software[i].Version < host1.Software[j].Name+host1.Software[j].Version
})
sort.Slice(host2.Software, func(i, j int) bool {
return host2.Software[i].Name+host2.Software[i].Version < host2.Software[j].Name+host2.Software[j].Version
})
cpes := []fleet.SoftwareCPE{
{SoftwareID: host1.Software[0].ID, CPE: "cpe_foo_chrome_3"},
{SoftwareID: host1.Software[1].ID, CPE: "cpe_foo_rpm"},
{SoftwareID: host2.Software[0].ID, CPE: "cpe_bar_rpm"},
{SoftwareID: host2.Software[1].ID, CPE: "cpe_foo_chrome_2"},
{SoftwareID: host2.Software[2].ID, CPE: "cpe_foo_chrome_3"},
}
_, err = ds.UpsertSoftwareCPEs(context.Background(), cpes)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host1, false))
require.NoError(t, ds.LoadHostSoftware(context.Background(), host2, false))
sort.Slice(host1.Software, func(i, j int) bool {
return host1.Software[i].Name+host1.Software[i].Version < host1.Software[j].Name+host1.Software[j].Version
})
sort.Slice(host2.Software, func(i, j int) bool {
return host2.Software[i].Name+host2.Software[i].Version < host2.Software[j].Name+host2.Software[j].Version
})
chrome3 := host2.Software[2]
inserted, err := ds.InsertSoftwareVulnerability(context.Background(), fleet.SoftwareVulnerability{
SoftwareID: chrome3.ID,
CVE: "CVE-2022-0001",
}, fleet.NVDSource)
require.NoError(t, err)
require.True(t, inserted)
barRpm := host2.Software[0]
vulns := []fleet.SoftwareVulnerability{
{
SoftwareID: barRpm.ID,
CVE: "CVE-2022-0002",
},
{
SoftwareID: barRpm.ID,
CVE: "CVE-2022-0003",
},
}
for _, v := range vulns {
inserted, err := ds.InsertSoftwareVulnerability(context.Background(), v, fleet.NVDSource)
require.NoError(t, err)
require.True(t, inserted)
}
require.NoError(t, ds.SyncHostsSoftware(context.Background(), time.Now()))
}
func testDeleteSoftwareVulnerabilities(t *testing.T, ds *Datastore) {
ctx := context.Background()
err := ds.DeleteSoftwareVulnerabilities(ctx, nil)
require.NoError(t, err)
insertVulnSoftwareForTest(t, ds)
err = ds.DeleteSoftwareVulnerabilities(ctx, []fleet.SoftwareVulnerability{
{
SoftwareID: 999, // unknown software
CVE: "CVE-2022-0003",
},
})
require.NoError(t, err)
host2, err := ds.HostByIdentifier(ctx, "host2")
require.NoError(t, err)
err = ds.LoadHostSoftware(ctx, host2, false)
require.NoError(t, err)
sort.Slice(host2.Software, func(i, j int) bool {
return host2.Software[i].Name+host2.Software[i].Version < host2.Software[j].Name+host2.Software[j].Version
})
barRPM := host2.Software[0]
require.Len(t, barRPM.Vulnerabilities, 2)
err = ds.DeleteSoftwareVulnerabilities(ctx, []fleet.SoftwareVulnerability{
{
SoftwareID: barRPM.ID,
CVE: "CVE-0000-0000", // unknown CVE
},
})
require.NoError(t, err)
err = ds.DeleteSoftwareVulnerabilities(ctx, []fleet.SoftwareVulnerability{
{
SoftwareID: barRPM.ID,
CVE: "CVE-2022-0003",
},
})
require.NoError(t, err)
err = ds.LoadHostSoftware(ctx, host2, false)
require.NoError(t, err)
sort.Slice(host2.Software, func(i, j int) bool {
return host2.Software[i].Name+host2.Software[i].Version < host2.Software[j].Name+host2.Software[j].Version
})
barRPM = host2.Software[0]
require.Len(t, barRPM.Vulnerabilities, 1)
err = ds.DeleteSoftwareVulnerabilities(ctx, []fleet.SoftwareVulnerability{
{
SoftwareID: barRPM.ID,
CVE: "CVE-2022-0002",
},
})
require.NoError(t, err)
err = ds.LoadHostSoftware(ctx, host2, false)
require.NoError(t, err)
sort.Slice(host2.Software, func(i, j int) bool {
return host2.Software[i].Name+host2.Software[i].Version < host2.Software[j].Name+host2.Software[j].Version
})
barRPM = host2.Software[0]
require.Empty(t, barRPM.Vulnerabilities)
}
func testHostsByCVE(t *testing.T, ds *Datastore) {
ctx := context.Background()
hosts, err := ds.HostsByCVE(ctx, "CVE-0000-0000")
require.NoError(t, err)
require.Len(t, hosts, 0)
insertVulnSoftwareForTest(t, ds)
// CVE of foo chrome 0.0.3, both hosts have it
hosts, err = ds.HostsByCVE(ctx, "CVE-2022-0001")
require.NoError(t, err)
require.Len(t, hosts, 2)
require.ElementsMatch(t, hosts, []fleet.HostVulnerabilitySummary{
{
ID: 1,
Hostname: "host1",
DisplayName: "computer1",
SoftwareInstalledPaths: []string{
"/some/path/foo.chrome",
},
}, {
ID: 2,
Hostname: "host2",
DisplayName: "host2",
SoftwareInstalledPaths: []string{
"/some/path/foo.chrome",
},
},
})
// CVE of bar.rpm 0.0.3, only host 2 has it
hosts, err = ds.HostsByCVE(ctx, "CVE-2022-0002")
require.NoError(t, err)
require.Len(t, hosts, 1)
require.Equal(t, hosts[0].Hostname, "host2")
}
func testHostVulnSummariesBySoftwareIDs(t *testing.T, ds *Datastore) {
ctx := context.Background()
// Invalid non-existing host id
hosts, err := ds.HostVulnSummariesBySoftwareIDs(ctx, []uint{0})
require.NoError(t, err)
require.Len(t, hosts, 0)
insertVulnSoftwareForTest(t, ds)
allSoftware, _, err := ds.ListSoftware(ctx, fleet.SoftwareListOptions{})
require.NoError(t, err)
var fooRpm fleet.Software
var chrome3 fleet.Software
var barRpm fleet.Software
for _, s := range allSoftware {
switch s.GenerateCPE {
case "cpe_foo_rpm":
fooRpm = s
case "cpe_foo_chrome_3":
chrome3 = s
case "cpe_bar_rpm":
barRpm = s
}
}
require.NotZero(t, chrome3.ID)
require.NotZero(t, barRpm.ID)
hosts, err = ds.HostVulnSummariesBySoftwareIDs(ctx, []uint{chrome3.ID})
require.NoError(t, err)
require.ElementsMatch(t, hosts, []fleet.HostVulnerabilitySummary{
{
ID: 1,
Hostname: "host1",
DisplayName: "computer1",
SoftwareInstalledPaths: []string{"/some/path/foo.chrome"},
}, {
ID: 2,
Hostname: "host2",
DisplayName: "host2",
SoftwareInstalledPaths: []string{"/some/path/foo.chrome"},
},
})
hosts, err = ds.HostVulnSummariesBySoftwareIDs(ctx, []uint{barRpm.ID})
require.NoError(t, err)
require.ElementsMatch(t, hosts, []fleet.HostVulnerabilitySummary{
{
ID: 2,
Hostname: "host2",
DisplayName: "host2",
SoftwareInstalledPaths: []string{"/some/path/bar.rpm"},
},
})
// Duplicates should not be returned if cpes are found on the same host ie host2 should only appear once
hosts, err = ds.HostVulnSummariesBySoftwareIDs(ctx, []uint{chrome3.ID, barRpm.ID, fooRpm.ID})
require.NoError(t, err)
require.Len(t, hosts, 2)
require.Equal(t, hosts[0].Hostname, "host1")
require.Equal(t, hosts[1].Hostname, "host2")
require.ElementsMatch(t, hosts[0].SoftwareInstalledPaths, []string{"/some/path/foo.rpm", "/some/path/foo.chrome"})
require.ElementsMatch(t, hosts[1].SoftwareInstalledPaths, []string{"/some/path/bar.rpm", "/some/path/foo.chrome"})
}
// testUpdateHostSoftwareUpdatesSoftware tests that uninstalling applications
// from hosts (ds.UpdateHostSoftware) will remove the corresponding entry in
// `software` if no more hosts have the application installed.
func testUpdateHostSoftwareUpdatesSoftware(t *testing.T, ds *Datastore) {
ctx := context.Background()
h1 := test.NewHost(t, ds, "host", "", "hostkey", "hostuuid", time.Now())
h2 := test.NewHost(t, ds, "host2", "", "hostkey2", "hostuuid2", time.Now())
// Set the initial software list.
sw1 := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "test", GenerateCPE: "cpe_foo"},
{Name: "bar", Version: "0.0.2", Source: "test", GenerateCPE: "cpe_bar"},
{Name: "baz", Version: "0.0.3", Source: "test", GenerateCPE: "cpe_baz"},
}
_, err := ds.UpdateHostSoftware(ctx, h1.ID, sw1)
require.NoError(t, err)
sw2 := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "test", GenerateCPE: "cpe_foo"},
{Name: "bar", Version: "0.0.2", Source: "test", GenerateCPE: "cpe_bar"},
{Name: "baz", Version: "0.0.3", Source: "test", GenerateCPE: "cpe_baz"},
{Name: "baz2", Version: "0.0.3", Source: "test", GenerateCPE: "cpe_baz"},
}
_, err = ds.UpdateHostSoftware(ctx, h2.ID, sw2)
require.NoError(t, err)
// ListSoftware uses host_software_counts table.
err = ds.SyncHostsSoftware(ctx, time.Now())
require.NoError(t, err)
// Check the returned software.
cmpNameVersionCount := func(expected, got []fleet.Software) {
cmp := make([]fleet.Software, len(got))
for i, sw := range got {
cmp[i] = fleet.Software{Name: sw.Name, Version: sw.Version, HostsCount: sw.HostsCount}
}
require.ElementsMatch(t, expected, cmp)
}
opts := fleet.SoftwareListOptions{WithHostCounts: true}
software := listSoftwareCheckCount(t, ds, 4, 4, opts, false)
expectedSoftware := []fleet.Software{
{Name: "foo", Version: "0.0.1", HostsCount: 2},
{Name: "bar", Version: "0.0.2", HostsCount: 2},
{Name: "baz", Version: "0.0.3", HostsCount: 2},
{Name: "baz2", Version: "0.0.3", HostsCount: 1},
}
cmpNameVersionCount(expectedSoftware, software)
// Update software for the two hosts.
//
// - foo is still present in both hosts
// - new is added to h1.
// - baz is removed from h2.
// - baz2 is removed from h2.
// - bar is removed from both hosts.
sw1Updated := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "test", GenerateCPE: "cpe_foo"},
{Name: "baz", Version: "0.0.3", Source: "test", GenerateCPE: "cpe_baz"},
{Name: "new", Version: "0.0.4", Source: "test", GenerateCPE: "cpe_new"},
}
_, err = ds.UpdateHostSoftware(ctx, h1.ID, sw1Updated)
require.NoError(t, err)
sw2Updated := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "test", GenerateCPE: "cpe_foo"},
}
_, err = ds.UpdateHostSoftware(ctx, h2.ID, sw2Updated)
require.NoError(t, err)
var (
bazSoftwareID uint
barSoftwareID uint
baz2SoftwareID uint
)
for _, s := range software {
if s.Name == "baz" {
bazSoftwareID = s.ID
}
if s.Name == "baz2" {
baz2SoftwareID = s.ID
}
if s.Name == "bar" {
barSoftwareID = s.ID
}
}
require.NotZero(t, bazSoftwareID)
require.NotZero(t, barSoftwareID)
require.NotZero(t, baz2SoftwareID)
// "baz2" is still present in the database, even though no hosts are using it, until ds.SyncHostsSoftware is executed.
soft, err := ds.SoftwareByID(ctx, baz2SoftwareID, nil, false, nil)
require.NoError(t, err)
assert.Equal(t, "baz2", soft.Name)
assert.Zero(t, soft.HostsCount)
// "new" is not returned until ds.SyncHostsSoftware is executed.
// "bar" and "baz2" are gone from host_software, but will not be deleted until ds.SyncHostsSoftware is executed.
// "baz" still has the wrong count because ds.SyncHostsSoftware hasn't run yet.
//
// So... counts are "off" until ds.SyncHostsSoftware is run.
software = listSoftwareCheckCount(t, ds, 4, 4, opts, false)
expectedSoftware = []fleet.Software{
{Name: "foo", Version: "0.0.1", HostsCount: 2},
{Name: "baz", Version: "0.0.3", HostsCount: 2},
{Name: "bar", Version: "0.0.2", HostsCount: 2},
{Name: "baz2", Version: "0.0.3", HostsCount: 1},
}
cmpNameVersionCount(expectedSoftware, software)
hosts, err := ds.HostVulnSummariesBySoftwareIDs(ctx, []uint{bazSoftwareID})
require.NoError(t, err)
require.Len(t, hosts, 1)
require.Equal(t, hosts[0].ID, h1.ID)
hosts, err = ds.HostVulnSummariesBySoftwareIDs(ctx, []uint{barSoftwareID})
require.NoError(t, err)
require.Empty(t, hosts)
hosts, err = ds.HostVulnSummariesBySoftwareIDs(ctx, []uint{baz2SoftwareID})
require.NoError(t, err)
require.Empty(t, hosts)
// ListSoftware uses host_software_counts table.
err = ds.SyncHostsSoftware(ctx, time.Now())
require.NoError(t, err)
software = listSoftwareCheckCount(t, ds, 3, 3, opts, false)
expectedSoftware = []fleet.Software{
{Name: "foo", Version: "0.0.1", HostsCount: 2},
{Name: "baz", Version: "0.0.3", HostsCount: 1},
{Name: "new", Version: "0.0.4", HostsCount: 1},
}
cmpNameVersionCount(expectedSoftware, software)
}
func testUpdateHostSoftware(t *testing.T, ds *Datastore) {
ctx := context.Background()
softwareInsertBatchSizeOrig := softwareInsertBatchSize
t.Cleanup(
func() {
softwareInsertBatchSize = softwareInsertBatchSizeOrig
},
)
softwareInsertBatchSize = 2
now := time.Now()
lastYear := now.Add(-365 * 24 * time.Hour)
// sort software slice by last opened at timestamp
genSortFn := func(sl []fleet.HostSoftwareEntry) func(l, r int) bool {
return func(l, r int) bool {
lsw, rsw := sl[l], sl[r]
lts, rts := lsw.LastOpenedAt, rsw.LastOpenedAt
switch {
case lts == nil && rts == nil:
return true
case lts == nil && rts != nil:
return true
case lts != nil && rts == nil:
return false
default:
return lts.Before(*rts) || (lts.Equal(*rts) && lsw.Name < rsw.Name)
}
}
}
host := test.NewHost(t, ds, "host", "", "hostkey", "hostuuid", time.Now())
type tup struct {
name string
ts time.Time
}
validateSoftware := func(expect ...tup) {
err := ds.LoadHostSoftware(ctx, host, false)
require.NoError(t, err)
require.Len(t, host.Software, len(expect))
sort.Slice(host.Software, genSortFn(host.Software))
for i, sw := range host.Software {
want := expect[i]
require.Equal(t, want.name, sw.Name)
var titleID *uint
err := ds.writer(ctx).GetContext(
ctx, &titleID,
`SELECT s.title_id FROM software s WHERE s.id = ?`,
sw.ID,
)
require.NoError(t, err)
require.NotNil(t, titleID, "Software should have a title_id")
assert.NotZero(t, *titleID, "Title ID should not be zero")
if want.ts.IsZero() {
require.Nil(t, sw.LastOpenedAt)
} else {
require.WithinDuration(t, want.ts, *sw.LastOpenedAt, time.Second)
}
}
}
// set the initial software list
sw := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "test", GenerateCPE: "cpe_foo", ExtensionFor: "chrome"},
{Name: "bar", Version: "0.0.2", Source: "test", GenerateCPE: "cpe_bar", LastOpenedAt: &lastYear},
{Name: "baz", Version: "0.0.3", Source: "test", GenerateCPE: "cpe_baz", LastOpenedAt: &now},
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, sw)
require.NoError(t, err)
validateSoftware(tup{name: "foo"}, tup{"bar", lastYear}, tup{"baz", now})
// make changes: remove foo, add qux, no new timestamp on bar, small ts change on baz
nowish := now.Add(3 * time.Second)
sw = []fleet.Software{
{Name: "bar", Version: "0.0.2", Source: "test", GenerateCPE: "cpe_bar"},
{Name: "baz", Version: "0.0.3", Source: "test", GenerateCPE: "cpe_baz", LastOpenedAt: &nowish},
{Name: "qux", Version: "0.0.4", Source: "test", GenerateCPE: "cpe_qux"},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, sw)
require.NoError(t, err)
validateSoftware(tup{name: "qux"}, tup{"bar", lastYear}, tup{"baz", now}) // baz hasn't been updated to nowish, too small diff
// more changes: bar receives a date further in the past, baz and qux to future
lastLastYear := lastYear.Add(-365 * 24 * time.Hour)
future := now.Add(3 * 24 * time.Hour)
sw = []fleet.Software{
{Name: "bar", Version: "0.0.2", Source: "test", GenerateCPE: "cpe_bar", LastOpenedAt: &lastLastYear},
{Name: "baz", Version: "0.0.3", Source: "test", GenerateCPE: "cpe_baz", LastOpenedAt: &future},
{Name: "qux", Version: "0.0.4", Source: "test", GenerateCPE: "cpe_qux", LastOpenedAt: &future},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, sw)
require.NoError(t, err)
validateSoftware(tup{"bar", lastYear}, tup{"baz", future}, tup{"qux", future})
// more changes: all software receives a date further in the future, so all should be updated
farFuture := now.Add(4 * 24 * time.Hour)
sw = []fleet.Software{
{Name: "bar", Version: "0.0.2", Source: "test", GenerateCPE: "cpe_bar", LastOpenedAt: &farFuture},
{Name: "baz", Version: "0.0.3", Source: "test", GenerateCPE: "cpe_baz", LastOpenedAt: &farFuture},
{Name: "qux", Version: "0.0.4", Source: "test", GenerateCPE: "cpe_qux", LastOpenedAt: &farFuture},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, sw)
require.NoError(t, err)
validateSoftware(tup{"bar", farFuture}, tup{"baz", farFuture}, tup{"qux", farFuture})
// Test logging criteria for LastOpenedAt == nil
oldLogger := ds.logger
buf := &bytes.Buffer{}
ds.logger = logging.NewSlogLogger(logging.Options{Output: buf, Debug: true})
sw = []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "test"},
{Name: "bar_app", Version: "0.0.2", Source: "apps", LastOpenedAt: &now},
{Name: "baz_program", Version: "0.0.3", Source: "programs", LastOpenedAt: &now},
{Name: "qux_package", Version: "0.0.3", Source: "deb_packages", LastOpenedAt: &now},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, sw)
require.NoError(t, err)
sw = []fleet.Software{
{Name: "bar_app", Version: "0.0.2", Source: "apps"},
{Name: "baz_program", Version: "0.0.3", Source: "programs"},
{Name: "qux_package", Version: "0.0.3", Source: "deb_packages"},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, sw)
require.NoError(t, err)
require.Contains(t, buf.String(), "baz_program")
require.Contains(t, buf.String(), "qux_package")
require.NotContains(t, buf.String(), "bar_app")
ds.logger = oldLogger
}
// Test edge case: Software with same bundle identifier but different names
// When software with the same bundle ID but different name is added, the system
// reuses the existing software entry (matched by bundle ID) and links it to the host
func testUpdateHostSoftwareSameBundleIDDifferentNames(t *testing.T, ds *Datastore) {
ctx := t.Context()
host := test.NewHost(t, ds, "bundle-host", "", "bundlekey", "bundleuuid", time.Now())
// First, add software with a bundle ID
sw := []fleet.Software{
{Name: "GoLand.app", Version: "2024.3", Source: "apps", BundleIdentifier: "com.jetbrains.goland"},
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, sw)
require.NoError(t, err)
// Verify the software was added
err = ds.LoadHostSoftware(ctx, host, false)
require.NoError(t, err)
require.Len(t, host.Software, 1)
require.Equal(t, "GoLand.app", host.Software[0].Name)
// Now update with the same bundle ID but different name
// Despite having the same bundle id, the software is added with the new name
sw = []fleet.Software{
{Name: "GoLand 2.app", Version: "2024.3", Source: "apps", BundleIdentifier: "com.jetbrains.goland"},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, sw)
require.NoError(t, err)
err = ds.LoadHostSoftware(ctx, host, false)
require.NoError(t, err)
require.Len(t, host.Software, 1)
require.Equal(t, "GoLand 2.app", host.Software[0].Name, "Name should be updated to reflect what's on the host")
// Verify only one software title exists (both software entries map to same title by bundle_identifier)
var titleCount int
err = ds.writer(ctx).GetContext(ctx, &titleCount,
`SELECT COUNT(DISTINCT id) FROM software_titles WHERE bundle_identifier = ?`,
"com.jetbrains.goland")
require.NoError(t, err)
require.Equal(t, 1, titleCount)
// Verify two software entries exist with this bundle ID (different names, same bundle_identifier)
var softwareNames []string
err = ds.writer(ctx).SelectContext(ctx, &softwareNames,
`SELECT name FROM software WHERE bundle_identifier = ? ORDER BY name`,
"com.jetbrains.goland")
require.NoError(t, err)
require.Len(t, softwareNames, 2)
require.Equal(t, []string{"GoLand 2.app", "GoLand.app"}, softwareNames)
// Helper app edge case:
// We have a main app with a name and bundle id
// We have two helper apps with the same bundle id but different name
sw = []fleet.Software{
{Name: "Postman", Version: "11.60.2", Source: "apps", BundleIdentifier: "com.postmanlabs.mac"},
{Name: "Postman Helper (GPU)", Version: "", Source: "apps", BundleIdentifier: "com.postmanlabs.mac.helper"},
{Name: "Postman Helper (Renderer)", Version: "", Source: "apps", BundleIdentifier: "com.postmanlabs.mac.helper"},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, sw)
require.NoError(t, err)
err = ds.LoadHostSoftware(ctx, host, false)
require.NoError(t, err)
require.Len(t, host.Software, 3)
var softwareRecords []struct {
Name string `db:"name"`
BundleIdentifier string `db:"bundle_identifier"`
}
err = ds.writer(ctx).SelectContext(ctx, &softwareRecords,
`SELECT name, bundle_identifier FROM software WHERE bundle_identifier = ? OR bundle_identifier = ?`,
"com.postmanlabs.mac", "com.postmanlabs.mac.helper")
require.NoError(t, err)
require.Len(t, softwareRecords, 3)
for _, softwareRecord := range softwareRecords {
switch softwareRecord.Name {
case "Postman":
require.Equal(t, "com.postmanlabs.mac", softwareRecord.BundleIdentifier)
case "Postman Helper (GPU)", "Postman Helper (Renderer)":
require.Equal(t, "com.postmanlabs.mac.helper", softwareRecord.BundleIdentifier)
default:
t.Fatalf("Unexpected software name: %s", softwareRecord.Name)
}
}
// Re-ingest helper apps with new names
sw = []fleet.Software{
{Name: "Postman 2", Version: "11.60.2", Source: "apps", BundleIdentifier: "com.postmanlabs.mac"},
{Name: "Postman Helper 2 (GPU)", Version: "", Source: "apps", BundleIdentifier: "com.postmanlabs.mac.helper"},
{Name: "Postman Helper 2 (Renderer)", Version: "", Source: "apps", BundleIdentifier: "com.postmanlabs.mac.helper"},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, sw)
require.NoError(t, err)
err = ds.writer(ctx).SelectContext(ctx, &softwareRecords,
`SELECT name, bundle_identifier FROM software WHERE bundle_identifier = ? OR bundle_identifier = ?`,
"com.postmanlabs.mac", "com.postmanlabs.mac.helper")
require.NoError(t, err)
require.Len(t, softwareRecords, 6)
for _, softwareRecord := range softwareRecords {
switch softwareRecord.Name {
case "Postman", "Postman 2":
require.Equal(t, "com.postmanlabs.mac", softwareRecord.BundleIdentifier)
case "Postman Helper (GPU)", "Postman Helper (Renderer)", "Postman Helper 2 (GPU)", "Postman Helper 2 (Renderer)":
require.Equal(t, "com.postmanlabs.mac.helper", softwareRecord.BundleIdentifier)
default:
t.Fatalf("Unexpected software name: %s", softwareRecord.Name)
}
}
}
// Test edge case: Software with same name but different bundle identifiers
// This validates that separate software titles are created
func testUpdateHostSoftwareSameNameDifferentBundleIDs(t *testing.T, ds *Datastore) {
ctx := t.Context()
host := test.NewHost(t, ds, "dupname-host", "", "dupnamekey", "dupnameuuid", time.Now())
// Add two software items with same name but different bundle IDs
sw := []fleet.Software{
{Name: "MyApp", Version: "1.0", Source: "chrome_extensions", BundleIdentifier: "bundle_id1"},
{Name: "MyApp", Version: "1.0", Source: "chrome_extensions", BundleIdentifier: "bundle_id2"},
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, sw)
require.NoError(t, err)
// Verify both software entries exist
err = ds.LoadHostSoftware(ctx, host, false)
require.NoError(t, err)
require.Len(t, host.Software, 2)
// Verify two separate software titles exist
var titles []struct {
Name string `db:"name"`
BundleIdentifier *string `db:"bundle_identifier"`
}
err = sqlx.SelectContext(ctx, ds.reader(ctx), &titles,
`SELECT name, bundle_identifier FROM software_titles WHERE name = ? ORDER BY bundle_identifier`,
"MyApp")
require.NoError(t, err)
require.Len(t, titles, 2)
require.NotNil(t, titles[0].BundleIdentifier)
require.NotNil(t, titles[1].BundleIdentifier)
require.Equal(t, "bundle_id1", *titles[0].BundleIdentifier)
require.Equal(t, "bundle_id2", *titles[1].BundleIdentifier)
}
// Test edge case: Multiple software entries with the same bundle ID
// This validates that when software with the same bundle ID but different names
// are added from different hosts, we add software entries for each name
func testUpdateHostSoftwareMultipleSameBundleID(t *testing.T, ds *Datastore) {
ctx := t.Context()
host1 := test.NewHost(t, ds, "multi-bundle-host1", "", "multikey1", "multiuuid1", time.Now())
host2 := test.NewHost(t, ds, "multi-bundle-host2", "", "multikey2", "multiuuid2", time.Now())
host3 := test.NewHost(t, ds, "multi-bundle-host3", "", "multikey3", "multiuuid3", time.Now())
// Step 1: Host1 reports multiple software with the same bundle ID but different versions
// This can happen with multiple installations or beta/stable versions
sw1 := []fleet.Software{
{Name: "GoLand.app", Version: "2024.2", Source: "apps", BundleIdentifier: "com.jetbrains.goland"},
{Name: "GoLand.app", Version: "2024.3-beta", Source: "apps", BundleIdentifier: "com.jetbrains.goland"},
}
_, err := ds.UpdateHostSoftware(ctx, host1.ID, sw1)
require.NoError(t, err)
// Verify both software entries were created
err = ds.LoadHostSoftware(ctx, host1, false)
require.NoError(t, err)
require.Len(t, host1.Software, 2)
// Step 2: Host2 also reports the same software
sw2 := []fleet.Software{
{Name: "GoLand.app", Version: "2024.2", Source: "apps", BundleIdentifier: "com.jetbrains.goland"},
}
_, err = ds.UpdateHostSoftware(ctx, host2.ID, sw2)
require.NoError(t, err)
// Step 3: Host3 reports the SAME software but with a different name
// This should not rename all software, it should create a new software entry
sw3 := []fleet.Software{
{Name: "GoLand 2024.app", Version: "2024.2", Source: "apps", BundleIdentifier: "com.jetbrains.goland"},
{Name: "GoLand 2024.app", Version: "2024.3-beta", Source: "apps", BundleIdentifier: "com.jetbrains.goland"},
}
_, err = ds.UpdateHostSoftware(ctx, host3.ID, sw3)
require.NoError(t, err)
// Step 4: Verify insertion into software behavior
var updatedSoftware []struct {
ID uint `db:"id"`
Name string `db:"name"`
Version string `db:"version"`
NameSource string `db:"name_source"`
}
err = ds.writer(ctx).SelectContext(ctx, &updatedSoftware,
`SELECT DISTINCT id, name, version, name_source FROM software WHERE bundle_identifier = ? ORDER BY version`,
"com.jetbrains.goland")
require.NoError(t, err)
// Should have exactly 4 software entries
require.Len(t, updatedSoftware, 4, "Should have exactly 4 software entries")
// Verify we have both versions for each name
golandAppVersions := make(map[string]bool)
goland2024AppVersions := make(map[string]bool)
for _, sw := range updatedSoftware {
switch sw.Name {
case "GoLand.app":
golandAppVersions[sw.Version] = true
case "GoLand 2024.app":
goland2024AppVersions[sw.Version] = true
default:
t.Fatalf("Unexpected software name: %s", sw.Name)
}
}
require.Len(t, golandAppVersions, 2, "Should have 2 versions of GoLand.app")
require.True(t, golandAppVersions["2024.2"], "Should have GoLand.app v2024.2")
require.True(t, golandAppVersions["2024.3-beta"], "Should have GoLand.app v2024.3-beta")
require.Len(t, goland2024AppVersions, 2, "Should have 2 versions of GoLand 2024.app")
require.True(t, goland2024AppVersions["2024.2"], "Should have GoLand 2024.app v2024.2")
require.True(t, goland2024AppVersions["2024.3-beta"], "Should have GoLand 2024.app v2024.3-beta")
// Verify that each host sees only their software (no renaming happens)
err = ds.LoadHostSoftware(ctx, host1, false)
require.NoError(t, err)
require.Len(t, host1.Software, 2, "Host1 should have 2 software entries")
for _, s := range host1.Software {
require.Equal(t, "GoLand.app", s.Name, "Host1 software should be GoLand.app")
}
err = ds.LoadHostSoftware(ctx, host2, false)
require.NoError(t, err)
require.Len(t, host2.Software, 1, "Host2 should have 1 software entry")
require.Equal(t, "GoLand.app", host2.Software[0].Name, "Host2 software should be GoLand.app")
err = ds.LoadHostSoftware(ctx, host3, false)
require.NoError(t, err)
require.Len(t, host3.Software, 2, "Host3 should have 2 software entries")
for _, s := range host3.Software {
require.Equal(t, "GoLand 2024.app", s.Name, "Host3 software should be GoLand 2024.app")
}
}
// Test for the bug where multiple software with the same bundle ID causes
// "software not found for checksum" errors
// This test specifically validates that ALL software entries with the same
// bundle ID are properly linked to hosts
func testUpdateHostSoftwareMultipleChecksumsPerBundleID(t *testing.T, ds *Datastore) {
ctx := t.Context()
// Note: Basic multiple versions scenario is already covered in testUpdateHostSoftwareMultipleSameBundleID
// This test focuses on the specific bug fix for apps with many versions
// First, establish the software with host1 - using 10 versions to stress test
host1 := test.NewHost(t, ds, "rename-test-host1", "", "rename-key1", "rename-uuid1", time.Now())
// Create 10 versions to stress test the checksum tracking
var initialSoftware []fleet.Software
for i := 0; i < 10; i++ {
initialSoftware = append(initialSoftware, fleet.Software{
Name: "TestApp.app",
Version: fmt.Sprintf("1.%d.0", i),
Source: "apps",
BundleIdentifier: "com.stresstest.app",
})
}
_, err := ds.UpdateHostSoftware(ctx, host1.ID, initialSoftware)
require.NoError(t, err, "Should handle 10 versions with same bundle ID")
// Verify all 10 were inserted
err = ds.LoadHostSoftware(ctx, host1, false)
require.NoError(t, err)
require.Len(t, host1.Software, 10, "Host1 should have all 10 versions")
// Host2 reports the same software but different names
host2 := test.NewHost(t, ds, "rename-test-host2", "", "rename-key2", "rename-uuid2", time.Now())
var renamedSoftware []fleet.Software
for i := 0; i < 10; i++ {
renamedSoftware = append(renamedSoftware, fleet.Software{
Name: "TestApp Renamed.app", // Different name
Version: fmt.Sprintf("1.%d.0", i),
Source: "apps",
BundleIdentifier: "com.stresstest.app",
})
}
// This is where the bug would occur - only one software would be linked instead of all 10
result, err := ds.UpdateHostSoftware(ctx, host2.ID, renamedSoftware)
require.NoError(t, err, "Should handle renamed apps with 10 versions without 'software not found for checksum' error")
assert.NotNil(t, result)
// Verify both names exist in the database (no renaming occurs)
var dbSoftware []struct {
Name string `db:"name"`
Version string `db:"version"`
NameSource string `db:"name_source"`
}
err = ds.writer(ctx).SelectContext(ctx, &dbSoftware,
`SELECT name, version, name_source FROM software
WHERE bundle_identifier = ? ORDER BY name, version`,
"com.stresstest.app")
require.NoError(t, err)
require.Len(t, dbSoftware, 20, "Should have 20 software entries: 10 for each name")
// Verify we have 10 of each name
testAppCount := 0
testAppRenamedCount := 0
for _, sw := range dbSoftware {
switch sw.Name {
case "TestApp.app":
testAppCount++
case "TestApp Renamed.app":
testAppRenamedCount++
}
}
assert.Equal(t, 10, testAppCount, "Should have 10 'TestApp.app' entries")
assert.Equal(t, 10, testAppRenamedCount, "Should have 10 'TestApp Renamed.app' entries")
// Verify that host1 still has its original software
err = ds.LoadHostSoftware(ctx, host1, false)
require.NoError(t, err)
assert.Len(t, host1.Software, 10, "Host1 should still have all 10 versions")
for _, sw := range host1.Software {
assert.Equal(t, "TestApp.app", sw.Name, "Host1 should see original name")
}
// Verify that host2 has ALL 10 versions linked
err = ds.LoadHostSoftware(ctx, host2, false)
require.NoError(t, err)
assert.Len(t, host2.Software, 10, "Host2 should have all 10 versions linked")
// Verify all versions are present for host2
versions := make(map[string]bool)
for _, sw := range host2.Software {
versions[sw.Version] = true
assert.Equal(t, "TestApp Renamed.app", sw.Name, "Host2 should see its own name")
}
for i := 0; i < 10; i++ {
version := fmt.Sprintf("1.%d.0", i)
assert.True(t, versions[version], "Host2 should have version %s", version)
}
}
// Test edge case: Software with names exceeding maximum length
// This validates truncation and handling of long names
func testUpdateHostSoftwareLongNameTruncation(t *testing.T, ds *Datastore) {
ctx := t.Context()
host := test.NewHost(t, ds, "longname-host", "", "longnamekey", "longnameuuid", time.Now())
// Create a truncated name (255 'a' characters)
// Both software entries will have the same name after truncation, but different versions
truncatedName := strings.Repeat("a", fleet.SoftwareNameMaxLength)
// Test Case 1: Non-macOS software (name IS included in checksum)
// Add software with the same truncated name but different versions
// Note: In production, fleet.SoftwareFromOsqueryRow() truncates names, but we're testing
// the database layer directly with pre-truncated names
sw := []fleet.Software{
{Name: truncatedName, Version: "1.0", Source: "chrome_extensions"},
{Name: truncatedName, Version: "2.0", Source: "chrome_extensions"},
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, sw)
require.NoError(t, err)
// Verify the software was added with truncated names
err = ds.LoadHostSoftware(ctx, host, false)
require.NoError(t, err)
// The implementation actually creates separate entries based on the full checksum,
// even though the truncated names are the same
require.Len(t, host.Software, 2)
require.Equal(t, truncatedName, host.Software[0].Name)
require.Equal(t, truncatedName, host.Software[1].Name)
// Verify both software entries have title_ids
var softwareWithTitles []struct {
ID uint `db:"id"`
Version string `db:"version"`
TitleID *uint `db:"title_id"`
}
err = ds.writer(ctx).SelectContext(ctx, &softwareWithTitles,
`SELECT s.id, s.version, s.title_id
FROM software s
JOIN host_software hs ON hs.software_id = s.id
WHERE hs.host_id = ? AND s.name = ?
ORDER BY s.version`,
host.ID, truncatedName)
require.NoError(t, err)
require.Len(t, softwareWithTitles, 2)
// Both should have the same title_id (not NULL)
require.NotNil(t, softwareWithTitles[0].TitleID, "First software should have title_id")
require.NotNil(t, softwareWithTitles[1].TitleID, "Second software should have title_id")
require.Equal(t, *softwareWithTitles[0].TitleID, *softwareWithTitles[1].TitleID,
"Both software entries should have the same title_id")
// Test Case 2: macOS apps (name is NOT included in checksum)
// For macOS apps, the checksum doesn't include the name, so even software with
// the same truncated name and different versions will have different checksums
host2 := test.NewHost(t, ds, "longname-host2", "", "longnamekey2", "longnameuuid2", time.Now())
// Add macOS apps with the same truncated name but different versions
macOSApps := []fleet.Software{
{Name: truncatedName + ".app", Version: "1.0", Source: "apps"},
{Name: truncatedName + ".app", Version: "2.0", Source: "apps"},
}
_, err = ds.UpdateHostSoftware(ctx, host2.ID, macOSApps)
require.NoError(t, err)
// Verify both macOS apps were added
err = ds.LoadHostSoftware(ctx, host2, false)
require.NoError(t, err)
require.Len(t, host2.Software, 2)
// Verify both macOS apps have the same title (despite different checksums)
var macOSSoftwareWithTitles []struct {
ID uint `db:"id"`
Name string `db:"name"`
Version string `db:"version"`
TitleID *uint `db:"title_id"`
Source string `db:"source"`
}
err = ds.writer(ctx).SelectContext(ctx, &macOSSoftwareWithTitles,
`SELECT s.id, s.name, s.version, s.title_id, s.source
FROM software s
JOIN host_software hs ON hs.software_id = s.id
WHERE hs.host_id = ? AND s.source = 'apps'
ORDER BY s.version`,
host2.ID)
require.NoError(t, err)
require.Len(t, macOSSoftwareWithTitles, 2)
// Both macOS apps have different checksums but map to the same title
// The test successfully demonstrates the "multiple checksums to same title" scenario
}
func testListSoftwareByHostIDShort(t *testing.T, ds *Datastore) {
host1 := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
host2 := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now())
software1 := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
}
software2 := []fleet.Software{
{Name: "foo", Version: "v0.0.2", Source: "chrome_extensions"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "deb_packages"},
}
_, err := ds.UpdateHostSoftware(context.Background(), host1.ID, software1)
require.NoError(t, err)
_, err = ds.UpdateHostSoftware(context.Background(), host2.ID, software2)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host1, false))
require.NoError(t, ds.LoadHostSoftware(context.Background(), host2, false))
software, err := ds.ListSoftwareByHostIDShort(context.Background(), host1.ID)
require.NoError(t, err)
test.ElementsMatchSkipID(t, software1, software)
software, err = ds.ListSoftwareByHostIDShort(context.Background(), host2.ID)
require.NoError(t, err)
test.ElementsMatchSkipID(t, software2, software)
// bad host id returns no software
badHostID := uint(3)
software, err = ds.ListSoftwareByHostIDShort(context.Background(), badHostID)
require.NoError(t, err)
require.Len(t, software, 0)
}
func testListSoftwareVulnerabilitiesByHostIDsSource(t *testing.T, ds *Datastore) {
ctx := context.Background()
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "apps"},
{Name: "blah", Version: "1.0", Source: "apps"},
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
cpes := []fleet.SoftwareCPE{
{SoftwareID: host.Software[0].ID, CPE: "foo_cpe"},
{SoftwareID: host.Software[1].ID, CPE: "bar_cpe"},
{SoftwareID: host.Software[2].ID, CPE: "blah_cpe"},
}
_, err = ds.UpsertSoftwareCPEs(ctx, cpes)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
cveMap := map[int]string{
0: "cve-123",
1: "cve-456",
}
for i, s := range host.Software {
cve, ok := cveMap[i]
if ok {
inserted, err := ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{
SoftwareID: s.ID,
CVE: cve,
}, fleet.NVDSource)
require.NoError(t, err)
require.True(t, inserted)
}
}
result, err := ds.ListSoftwareVulnerabilitiesByHostIDsSource(ctx, []uint{host.ID}, fleet.NVDSource)
require.NoError(t, err)
var actualCVEs []string
for _, r := range result[host.ID] {
actualCVEs = append(actualCVEs, r.CVE)
}
expectedCVEs := []string{"cve-123", "cve-456"}
require.ElementsMatch(t, expectedCVEs, actualCVEs)
for _, r := range result[host.ID] {
require.NotEqual(t, r.SoftwareID, 0)
}
}
func testInsertSoftwareVulnerability(t *testing.T, ds *Datastore) {
ctx := context.Background()
t.Run("no vulnerabilities to insert", func(t *testing.T) {
inserted, err := ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{}, fleet.UbuntuOVALSource)
require.NoError(t, err)
require.False(t, inserted)
})
t.Run("duplicated vulnerabilities", func(t *testing.T) {
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
software := fleet.Software{
Name: "foo", Version: "0.0.1", Source: "chrome_extensions",
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, []fleet.Software{software})
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
cpes := []fleet.SoftwareCPE{
{SoftwareID: host.Software[0].ID, CPE: "foo_cpe_1"},
}
_, err = ds.UpsertSoftwareCPEs(ctx, cpes)
require.NoError(t, err)
inserted, err := ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{
SoftwareID: host.Software[0].ID, CVE: "cve-1",
}, fleet.UbuntuOVALSource)
require.NoError(t, err)
require.True(t, inserted)
// Sleep so that the updated_at timestamp is guaranteed to be updated.
time.Sleep(1 * time.Second)
insertedOrUpdated, err := ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{
SoftwareID: host.Software[0].ID, CVE: "cve-1",
}, fleet.UbuntuOVALSource)
require.NoError(t, err)
// This will always return true because we always update the timestamp
assert.True(t, insertedOrUpdated)
storedVulns, err := ds.ListSoftwareVulnerabilitiesByHostIDsSource(ctx, []uint{host.ID}, fleet.UbuntuOVALSource)
require.NoError(t, err)
occurrence := make(map[string]int)
for _, v := range storedVulns[host.ID] {
occurrence[v.CVE]++
}
require.Equal(t, 1, occurrence["cve-1"])
})
t.Run("a vulnerability already exists", func(t *testing.T) {
host := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now())
software := fleet.Software{
Name: "foo", Version: "0.0.1", Source: "chrome_extensions",
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, []fleet.Software{software})
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
cpes := []fleet.SoftwareCPE{
{SoftwareID: host.Software[0].ID, CPE: "foo_cpe_2"},
}
_, err = ds.UpsertSoftwareCPEs(ctx, cpes)
require.NoError(t, err)
var vulns []fleet.SoftwareVulnerability
for _, s := range host.Software {
vulns = append(vulns, fleet.SoftwareVulnerability{
SoftwareID: s.ID,
CVE: "cve-2",
})
}
inserted, err := ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.UbuntuOVALSource)
require.NoError(t, err)
require.True(t, inserted)
// Sleep so that the updated_at timestamp is guaranteed to be updated.
time.Sleep(1 * time.Second)
insertedOrUpdated, err := ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.UbuntuOVALSource)
require.NoError(t, err)
// This will always return true because we always update the timestamp
assert.True(t, insertedOrUpdated)
storedVulns, err := ds.ListSoftwareVulnerabilitiesByHostIDsSource(ctx, []uint{host.ID}, fleet.UbuntuOVALSource)
require.NoError(t, err)
occurrence := make(map[string]int)
for _, v := range storedVulns[host.ID] {
occurrence[v.CVE]++
}
require.Equal(t, 1, occurrence["cve-1"])
require.Equal(t, 1, occurrence["cve-2"])
})
t.Run("vulnerability includes version range", func(t *testing.T) {
// new host
host := test.NewHost(t, ds, "host3", "", "host3key", "host3uuid", time.Now())
// new software
software := fleet.Software{
Name: "host3software", Version: "0.0.1", Source: "chrome_extensions",
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, []fleet.Software{software})
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
// new software cpe
cpes := []fleet.SoftwareCPE{
{SoftwareID: host.Software[0].ID, CPE: "cpe:2.3:a:foo:foo:0.0.1:*:*:*:*:*:*:*"},
}
_, err = ds.UpsertSoftwareCPEs(ctx, cpes)
require.NoError(t, err)
// new vulnerability
vuln := fleet.SoftwareVulnerability{
SoftwareID: host.Software[0].ID,
CVE: "cve-3",
ResolvedInVersion: ptr.String("1.2.3"),
}
inserted, err := ds.InsertSoftwareVulnerability(ctx, vuln, fleet.UbuntuOVALSource)
require.NoError(t, err)
require.True(t, inserted)
// vulnerability with no ResolvedInVersion
vuln = fleet.SoftwareVulnerability{
SoftwareID: host.Software[0].ID,
CVE: "cve-4",
}
inserted, err = ds.InsertSoftwareVulnerability(ctx, vuln, fleet.UbuntuOVALSource)
require.NoError(t, err)
require.True(t, inserted)
storedVulns, err := ds.ListSoftwareVulnerabilitiesByHostIDsSource(ctx, []uint{host.ID}, fleet.UbuntuOVALSource)
require.NoError(t, err)
require.Len(t, storedVulns[host.ID], 2)
require.Equal(t, "cve-3", storedVulns[host.ID][0].CVE)
require.Equal(t, "1.2.3", *storedVulns[host.ID][0].ResolvedInVersion)
require.Equal(t, "cve-4", storedVulns[host.ID][1].CVE)
require.Nil(t, storedVulns[host.ID][1].ResolvedInVersion)
})
}
func testInsertSoftwareVulnerabilities(t *testing.T, ds *Datastore) {
ctx := t.Context()
host := test.NewHost(t, ds, "hostBatch", "", "hostBatchkey", "hostBatchuuid", time.Now())
software := fleet.Software{Name: "batchApp", Version: "1.0.0", Source: "apps"}
_, err := ds.UpdateHostSoftware(ctx, host.ID, []fleet.Software{software})
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
swID := host.Software[0].ID
t.Run("empty and filtered input returns nil", func(t *testing.T) {
newVulns, err := ds.InsertSoftwareVulnerabilities(ctx, nil, fleet.NVDSource)
require.NoError(t, err)
require.Nil(t, newVulns)
newVulns, err = ds.InsertSoftwareVulnerabilities(ctx, []fleet.SoftwareVulnerability{
{SoftwareID: swID, CVE: ""},
}, fleet.NVDSource)
require.NoError(t, err)
require.Nil(t, newVulns)
})
t.Run("new vulns returned on insert, not on re-insert", func(t *testing.T) {
vulns := []fleet.SoftwareVulnerability{
{SoftwareID: swID, CVE: "CVE-2024-0001"},
{SoftwareID: swID, CVE: "CVE-2024-0002", ResolvedInVersion: ptr.String("1.1.0")},
}
// First insert: both are new.
newVulns, err := ds.InsertSoftwareVulnerabilities(ctx, vulns, fleet.NVDSource)
require.NoError(t, err)
require.Len(t, newVulns, 2)
// Second insert: none are new.
newVulns, err = ds.InsertSoftwareVulnerabilities(ctx, vulns, fleet.NVDSource)
require.NoError(t, err)
require.Empty(t, newVulns)
// Mixed: one existing, one new.
vulns = append(vulns, fleet.SoftwareVulnerability{SoftwareID: swID, CVE: "CVE-2024-0003"})
newVulns, err = ds.InsertSoftwareVulnerabilities(ctx, vulns, fleet.NVDSource)
require.NoError(t, err)
require.Len(t, newVulns, 1)
assert.Equal(t, "CVE-2024-0003", newVulns[0].CVE)
})
t.Run("upsert refreshes updated_at", func(t *testing.T) {
pastTime := time.Now().Add(-24 * time.Hour)
_, err := ds.writer(ctx).ExecContext(ctx,
`UPDATE software_cve SET updated_at = ? WHERE software_id = ? AND cve = ?`,
pastTime, swID, "CVE-2024-0001")
require.NoError(t, err)
_, err = ds.InsertSoftwareVulnerabilities(ctx, []fleet.SoftwareVulnerability{
{SoftwareID: swID, CVE: "CVE-2024-0001"},
}, fleet.NVDSource)
require.NoError(t, err)
var afterTS time.Time
err = sqlx.GetContext(ctx, ds.reader(ctx), &afterTS,
`SELECT updated_at FROM software_cve WHERE software_id = ? AND cve = ?`, swID, "CVE-2024-0001")
require.NoError(t, err)
assert.True(t, afterTS.After(pastTime), "updated_at should be refreshed on upsert")
})
}
func testListCVEs(t *testing.T, ds *Datastore) {
ctx := context.Background()
now := time.Now().UTC()
threeDaysAgo := now.Add(-3 * 24 * time.Hour)
twoWeeksAgo := now.Add(-14 * 24 * time.Hour)
twoMonthsAgo := now.Add(-60 * 24 * time.Hour)
testCases := []fleet.CVEMeta{
{CVE: "cve-1", Published: &threeDaysAgo, Description: "cve-1 description"},
{CVE: "cve-2", Published: &twoWeeksAgo, Description: "cve-2 description"},
{CVE: "cve-3", Published: &twoMonthsAgo}, // past maxAge
{CVE: "cve-4"}, // no published date
}
err := ds.InsertCVEMeta(ctx, testCases)
require.NoError(t, err)
result, err := ds.ListCVEs(ctx, 30*24*time.Hour)
require.NoError(t, err)
expected := []string{"cve-1", "cve-1 description", "cve-2", "cve-2 description"}
var actual []string
for _, r := range result {
actual = append(actual, r.CVE)
actual = append(actual, r.Description)
}
require.ElementsMatch(t, expected, actual)
}
func testListSoftwareForVulnDetection(t *testing.T, ds *Datastore) {
t.Run("returns software without CPE entries", func(t *testing.T) {
ctx := context.Background()
host := test.NewHost(t, ds, "host3", "", "host3key", "host3uuid", time.Now())
host.Platform = "debian"
require.NoError(t, ds.UpdateHost(ctx, host))
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "apps"},
{Name: "biz", Version: "0.0.1", Source: "deb_packages"},
{Name: "baz", Version: "0.0.3", Source: "deb_packages"},
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
_, err = ds.UpsertSoftwareCPEs(ctx, []fleet.SoftwareCPE{{SoftwareID: host.Software[0].ID, CPE: "cpe1"}})
require.NoError(t, err)
// Load software again so that CPE data is included.
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
filter := fleet.VulnSoftwareFilter{HostID: &host.ID}
result, err := ds.ListSoftwareForVulnDetection(ctx, filter)
require.NoError(t, err)
sort.Slice(host.Software, func(i, j int) bool { return host.Software[i].ID < host.Software[j].ID })
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
require.Equal(t, len(host.Software), len(result))
for i := range host.Software {
require.Equal(t, host.Software[i].ID, result[i].ID)
require.Equal(t, host.Software[i].Name, result[i].Name)
require.Equal(t, host.Software[i].Version, result[i].Version)
require.Equal(t, host.Software[i].Release, result[i].Release)
require.Equal(t, host.Software[i].Arch, result[i].Arch)
require.Equal(t, host.Software[i].GenerateCPE, result[i].GenerateCPE)
}
// test name filter
filter = fleet.VulnSoftwareFilter{Name: "fo"} // LIKE match
result, err = ds.ListSoftwareForVulnDetection(ctx, filter)
require.NoError(t, err)
require.Len(t, result, 1)
require.Equal(t, "foo", result[0].Name)
// test source filter
filter = fleet.VulnSoftwareFilter{Source: "deb_packages"}
result, err = ds.ListSoftwareForVulnDetection(ctx, filter)
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
require.NoError(t, err)
require.Len(t, result, 2)
require.Equal(t, "baz", result[0].Name)
require.Equal(t, "biz", result[1].Name)
})
t.Run("KernelsOnly filter returns only kernel software", func(t *testing.T) {
ctx := context.Background()
host := test.NewHost(t, ds, "host_kernel_test", "", "hostkernelkey", "hostkerneluuid", time.Now())
host.Platform = "rhel"
require.NoError(t, ds.UpdateHost(ctx, host))
software := []fleet.Software{
{Name: "kernel", Version: "5.14.0-503.38.1.el9_5", Release: "", Arch: "x86_64", Source: "rpm_packages", IsKernel: true},
{Name: "kernel-modules", Version: "5.14.0", Release: "503.38.1.el9_5", Arch: "x86_64", Source: "rpm_packages", IsKernel: false},
{Name: "bash", Version: "5.1.8", Release: "6.el9_1", Arch: "x86_64", Source: "rpm_packages", IsKernel: false},
{Name: "openssl", Version: "3.0.7", Release: "18.el9_2", Arch: "x86_64", Source: "rpm_packages", IsKernel: false},
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
// Test KernelsOnly filter
filter := fleet.VulnSoftwareFilter{HostID: &host.ID, KernelsOnly: true}
result, err := ds.ListSoftwareForVulnDetection(ctx, filter)
require.NoError(t, err)
require.Len(t, result, 1)
require.Equal(t, "kernel", result[0].Name)
// Verify non-kernel filter returns all software
filter = fleet.VulnSoftwareFilter{HostID: &host.ID, KernelsOnly: false}
result, err = ds.ListSoftwareForVulnDetection(ctx, filter)
require.NoError(t, err)
require.Len(t, result, 4)
})
}
func testSoftwareByIDNoDuplicatedVulns(t *testing.T, ds *Datastore) {
t.Run("software installed in multiple hosts does not have duplicated vulnerabilities", func(t *testing.T) {
ctx := context.Background()
hostA := test.NewHost(t, ds, "hostA", "", "hostAkey", "hostAuuid", time.Now())
hostA.Platform = "ubuntu"
require.NoError(t, ds.UpdateHost(ctx, hostA))
hostB := test.NewHost(t, ds, "hostB", "", "hostBkey", "hostBuuid", time.Now())
hostB.Platform = "ubuntu"
require.NoError(t, ds.UpdateHost(ctx, hostB))
software := []fleet.Software{
{Name: "foo_123", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "bar_123", Version: "0.0.3", Source: "apps"},
{Name: "biz_123", Version: "0.0.1", Source: "deb_packages"},
{Name: "baz_123", Version: "0.0.3", Source: "deb_packages"},
}
_, err := ds.UpdateHostSoftware(ctx, hostA.ID, software)
require.NoError(t, err)
_, err = ds.UpdateHostSoftware(ctx, hostB.ID, software)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(ctx, hostA, false))
require.NoError(t, ds.LoadHostSoftware(ctx, hostB, false))
// Add one vulnerability to each software
for i, s := range hostA.Software {
inserted, err := ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{
SoftwareID: s.ID,
CVE: fmt.Sprintf("cve-%d", i),
}, fleet.UbuntuOVALSource)
require.NoError(t, err)
require.True(t, inserted)
}
for _, s := range hostA.Software {
result, err := ds.SoftwareByID(ctx, s.ID, nil, true, nil)
require.NoError(t, err)
require.Len(t, result.Vulnerabilities, 1)
}
})
}
func testSoftwareByIDIncludesCVEPublishedDate(t *testing.T, ds *Datastore) {
t.Run("software.vulnerabilities includes the published date", func(t *testing.T) {
ctx := context.Background()
host := test.NewHost(t, ds, "hostA", "", "hostAkey", "hostAuuid", time.Now())
team1, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team1"})
require.NoError(t, err)
require.NoError(t, ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(&team1.ID, []uint{host.ID})))
now := time.Now().UTC().Truncate(time.Second)
testCases := []struct {
name string
hasVuln bool
hasMeta bool
hasPublishedDate bool
}{
{"foo_123", true, true, true},
{"bar_123", true, true, false},
{"foo_456", true, false, false},
{"bar_456", false, true, true},
{"foo_789", false, true, false},
{"bar_789", false, false, false},
}
// Add software
var software []fleet.Software
for _, t := range testCases {
software = append(software, fleet.Software{
Name: t.name,
Version: "0.0.1",
Source: "apps",
BundleIdentifier: fmt.Sprintf("com.example.%s", t.name),
})
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
// Add vulnerabilities and CVEMeta
var meta []fleet.CVEMeta
for _, tC := range testCases {
idx := -1
for i, s := range host.Software {
if s.Name == tC.name {
idx = i
break
}
}
require.NotEqual(t, -1, idx, "software not found")
if tC.hasVuln {
inserted, err := ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{
SoftwareID: host.Software[idx].ID,
CVE: fmt.Sprintf("cve-%s", tC.name),
}, fleet.UbuntuOVALSource)
require.NoError(t, err)
require.True(t, inserted)
}
if tC.hasMeta {
var published *time.Time
if tC.hasPublishedDate {
published = &now
}
meta = append(meta, fleet.CVEMeta{
CVE: fmt.Sprintf("cve-%s", tC.name),
CVSSScore: ptr.Float64(5.4),
EPSSProbability: ptr.Float64(0.5),
CISAKnownExploit: ptr.Bool(true),
Published: published,
})
}
}
require.NoError(t, ds.InsertCVEMeta(ctx, meta))
for _, tC := range testCases {
idx := -1
for i, s := range host.Software {
if s.Name == tC.name {
idx = i
break
}
}
require.NotEqual(t, -1, idx, "software not found")
for _, teamID := range []*uint{nil, &team1.ID} {
// Test that scores are not included if includeCVEScores = false
withoutScores, err := ds.SoftwareByID(ctx, host.Software[idx].ID, teamID, false, nil)
require.NoError(t, err)
if tC.hasVuln {
require.Len(t, withoutScores.Vulnerabilities, 1)
require.Equal(t, fmt.Sprintf("cve-%s", tC.name), withoutScores.Vulnerabilities[0].CVE)
require.Nil(t, withoutScores.Vulnerabilities[0].CVSSScore)
require.Nil(t, withoutScores.Vulnerabilities[0].EPSSProbability)
require.Nil(t, withoutScores.Vulnerabilities[0].CISAKnownExploit)
} else {
require.Empty(t, withoutScores.Vulnerabilities)
}
withScores, err := ds.SoftwareByID(ctx, host.Software[idx].ID, teamID, true, nil)
require.NoError(t, err)
if tC.hasVuln {
require.Len(t, withScores.Vulnerabilities, 1)
require.Equal(t, fmt.Sprintf("cve-%s", tC.name), withoutScores.Vulnerabilities[0].CVE)
if tC.hasMeta {
require.NotNil(t, withScores.Vulnerabilities[0].CVSSScore)
require.NotNil(t, *withScores.Vulnerabilities[0].CVSSScore)
require.Equal(t, **withScores.Vulnerabilities[0].CVSSScore, 5.4)
require.NotNil(t, withScores.Vulnerabilities[0].EPSSProbability)
require.NotNil(t, *withScores.Vulnerabilities[0].EPSSProbability)
require.Equal(t, **withScores.Vulnerabilities[0].EPSSProbability, 0.5)
require.NotNil(t, withScores.Vulnerabilities[0].CISAKnownExploit)
require.NotNil(t, *withScores.Vulnerabilities[0].CISAKnownExploit)
require.Equal(t, **withScores.Vulnerabilities[0].CISAKnownExploit, true)
if tC.hasPublishedDate {
require.NotNil(t, withScores.Vulnerabilities[0].CVEPublished)
require.NotNil(t, *withScores.Vulnerabilities[0].CVEPublished)
require.Equal(t, (**withScores.Vulnerabilities[0].CVEPublished), now)
}
}
} else {
require.Empty(t, withoutScores.Vulnerabilities)
}
}
}
})
}
func testAllSoftwareIterator(t *testing.T, ds *Datastore) {
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
{Name: "foobar", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "chrome_extensions"},
{Name: "foo", Version: "v0.0.2", Source: "apps"},
{Name: "foo", Version: "0.0.3", Source: "apps"},
{Name: "bar", Version: "0.0.3", Source: "deb_packages"},
{Name: "baz", Version: "0.0.3", Source: "deb_packages"},
}
_, err := ds.UpdateHostSoftware(context.Background(), host.ID, software)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host, false))
foo_ce_v1 := slices.IndexFunc(host.Software, func(c fleet.HostSoftwareEntry) bool {
return c.Name == "foo" && c.Version == "0.0.1" && c.Source == "chrome_extensions"
})
foo_app_v2 := slices.IndexFunc(host.Software, func(c fleet.HostSoftwareEntry) bool {
return c.Name == "foo" && c.Version == "v0.0.2" && c.Source == "apps"
})
bar_v3 := slices.IndexFunc(host.Software, func(c fleet.HostSoftwareEntry) bool {
return c.Name == "bar" && c.Version == "0.0.3" && c.Source == "deb_packages"
})
cpes := []fleet.SoftwareCPE{
{SoftwareID: host.Software[foo_ce_v1].ID, CPE: "cpe:foo_ce_v1"},
{SoftwareID: host.Software[foo_app_v2].ID, CPE: "cpe:foo_app_v2"},
{SoftwareID: host.Software[bar_v3].ID, CPE: "cpe:bar_v3"},
}
_, err = ds.UpsertSoftwareCPEs(context.Background(), cpes)
require.NoError(t, err)
testCases := []struct {
name string
q fleet.SoftwareIterQueryOptions
expected []fleet.Software
}{
{
name: "include apps source",
expected: []fleet.Software{
{Name: "foo", Version: "v0.0.2", Source: "apps", GenerateCPE: "cpe:foo_app_v2"},
{Name: "foo", Version: "0.0.3", Source: "apps"},
},
q: fleet.SoftwareIterQueryOptions{IncludedSources: []string{"apps"}},
},
{
name: "exclude apps source",
expected: []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions", GenerateCPE: "cpe:foo_ce_v1"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "chrome_extensions"},
{Name: "foobar", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "deb_packages", GenerateCPE: "cpe:bar_v3"},
{Name: "baz", Version: "0.0.3", Source: "deb_packages"},
},
q: fleet.SoftwareIterQueryOptions{ExcludedSources: []string{"apps"}},
},
{
name: "no filter",
expected: []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions", GenerateCPE: "cpe:foo_ce_v1"},
{Name: "foo", Version: "v0.0.2", Source: "apps", GenerateCPE: "cpe:foo_app_v2"},
{Name: "foo", Version: "0.0.3", Source: "apps"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "chrome_extensions"},
{Name: "foobar", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "baz", Version: "0.0.3", Source: "deb_packages"},
{Name: "bar", Version: "0.0.3", Source: "deb_packages", GenerateCPE: "cpe:bar_v3"},
},
q: fleet.SoftwareIterQueryOptions{},
},
{
name: "partial name filter includes deb_packages",
expected: []fleet.Software{
{Name: "bar", Version: "0.0.3", Source: "deb_packages", GenerateCPE: "cpe:bar_v3"},
},
q: fleet.SoftwareIterQueryOptions{NameMatch: `ba[r|f]`, IncludedSources: []string{"deb_packages"}},
},
{
name: "name filter includes chrome_extensions",
expected: []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions", GenerateCPE: "cpe:foo_ce_v1"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
{Name: "foobar", Version: "0.0.1", Source: "chrome_extensions"},
},
q: fleet.SoftwareIterQueryOptions{NameMatch: "foo\\.*", IncludedSources: []string{"chrome_extensions"}},
},
{
name: "name filter and not name filter",
expected: []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions", GenerateCPE: "cpe:foo_ce_v1"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
},
q: fleet.SoftwareIterQueryOptions{NameMatch: "foo\\.*", NameExclude: "bar$", IncludedSources: []string{"chrome_extensions"}},
},
}
for _, tC := range testCases {
t.Run(tC.name, func(t *testing.T) {
var actual []fleet.Software
iter, err := ds.AllSoftwareIterator(context.Background(), tC.q)
require.NoError(t, err)
for iter.Next() {
software, err := iter.Value()
require.NoError(t, err)
actual = append(actual, *software)
}
iter.Close()
test.ElementsMatchSkipID(t, tC.expected, actual)
})
}
}
func testSoftwareIteratorForLinuxKernelCustomImages(t *testing.T, ds *Datastore) {
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
software := []fleet.Software{
{Name: "linux-image-5.4.0-42-generic", Version: "5.4.0-42.46", Source: "deb_packages"},
{Name: "linux-image-6.5.0-42-generic", Version: "6.5.0-100.27", Source: "deb_packages"},
{Name: "linux-image-5.4.0-42-custom", Version: "5.4.0-42.46", Source: "deb_packages"},
{Name: "linux-image-6.5.0-42-1234-foo", Version: "6.5.0-100.27", Source: "deb_packages"},
{Name: "linux-image-generic", Version: "1.0.0", Source: "deb_packages"},
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "deb_packages"},
}
_, err := ds.UpdateHostSoftware(context.Background(), host.ID, software)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host, false))
expected := []fleet.Software{
{Name: "linux-image-5.4.0-42-custom", Version: "5.4.0-42.46", Source: "deb_packages"},
{Name: "linux-image-6.5.0-42-1234-foo", Version: "6.5.0-100.27", Source: "deb_packages"},
}
opts := fleet.SoftwareIterQueryOptions{
NameMatch: nvd.LinuxImageRegex,
NameExclude: nvd.BuildLinuxExclusionRegex(),
IncludedSources: []string{"deb_packages"},
}
iterator, err := ds.AllSoftwareIterator(context.Background(), opts)
require.NoError(t, err)
var actual []fleet.Software
for iterator.Next() {
software, err := iterator.Value()
require.NoError(t, err)
actual = append(actual, *software)
}
iterator.Close()
test.ElementsMatchSkipID(t, expected, actual)
}
func testUpsertSoftwareCPEs(t *testing.T, ds *Datastore) {
ctx := context.Background()
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
cpes := []fleet.SoftwareCPE{
{SoftwareID: host.Software[0].ID, CPE: "cpe:foo_ce_v1"},
{SoftwareID: host.Software[0].ID, CPE: "cpe:foo_ce_v2"},
}
_, err = ds.UpsertSoftwareCPEs(ctx, cpes)
require.NoError(t, err)
cpes, err = ds.ListSoftwareCPEs(ctx)
require.NoError(t, err)
require.Equal(t, len(cpes), 1)
require.Equal(t, cpes[0].CPE, "cpe:foo_ce_v2")
cpes = []fleet.SoftwareCPE{
{SoftwareID: host.Software[0].ID, CPE: "cpe:foo_ce_v3"},
}
_, err = ds.UpsertSoftwareCPEs(ctx, cpes)
require.NoError(t, err)
cpes = []fleet.SoftwareCPE{
{SoftwareID: host.Software[0].ID, CPE: "cpe:foo_ce_v4"},
}
_, err = ds.UpsertSoftwareCPEs(ctx, cpes)
require.NoError(t, err)
cpes, err = ds.ListSoftwareCPEs(ctx)
require.NoError(t, err)
require.Equal(t, len(cpes), 1)
require.Equal(t, cpes[0].CPE, "cpe:foo_ce_v4")
}
func testDeleteOutOfDateVulnerabilities(t *testing.T, ds *Datastore) {
ctx := context.Background()
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
vulns := []fleet.SoftwareVulnerability{
{
SoftwareID: host.Software[0].ID,
CVE: "CVE-2023-001",
},
{
SoftwareID: host.Software[0].ID,
CVE: "CVE-2023-002",
},
}
inserted, err := ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.NVDSource)
require.NoError(t, err)
require.True(t, inserted)
inserted, err = ds.InsertSoftwareVulnerability(ctx, vulns[1], fleet.NVDSource)
require.NoError(t, err)
require.True(t, inserted)
_, err = ds.writer(ctx).ExecContext(ctx, "UPDATE software_cve SET updated_at = '2020-10-10 12:00:00'")
require.NoError(t, err)
// This should update the 'updated_at' timestamp.
insertedOrUpdated, err := ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.NVDSource)
require.NoError(t, err)
assert.True(t, insertedOrUpdated)
err = ds.DeleteOutOfDateVulnerabilities(ctx, fleet.NVDSource, time.Now().UTC().Add(-time.Hour))
require.NoError(t, err)
storedSoftware, err := ds.SoftwareByID(ctx, host.Software[0].ID, nil, false, nil)
require.NoError(t, err)
require.Equal(t, 1, len(storedSoftware.Vulnerabilities))
require.Equal(t, "CVE-2023-001", storedSoftware.Vulnerabilities[0].CVE)
}
func testDeleteOrphanedSoftwareVulnerabilities(t *testing.T, ds *Datastore) {
ctx := context.Background()
hostWithSoftware := test.NewHost(t, ds, "host_with_sw", "", "hwskey", "hwsuuid", time.Now())
hostToRemove := test.NewHost(t, ds, "host_to_remove", "", "htrkey", "htruuid", time.Now())
sharedSoftware := []fleet.Software{
{Name: "shared_app", Version: "1.0", Source: "apps"},
}
orphanSoftware := []fleet.Software{
{Name: "orphan_app", Version: "2.0", Source: "apps"},
}
_, err := ds.UpdateHostSoftware(ctx, hostWithSoftware.ID, sharedSoftware)
require.NoError(t, err)
_, err = ds.UpdateHostSoftware(ctx, hostToRemove.ID, orphanSoftware)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(ctx, hostWithSoftware, false))
require.NoError(t, ds.LoadHostSoftware(ctx, hostToRemove, false))
sharedSoftwareID := hostWithSoftware.Software[0].ID
orphanSoftwareID := hostToRemove.Software[0].ID
// Insert vulnerabilities for both software items.
inserted, err := ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{
SoftwareID: sharedSoftwareID, CVE: "CVE-2024-001",
}, fleet.UbuntuOVALSource)
require.NoError(t, err)
require.True(t, inserted)
inserted, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{
SoftwareID: orphanSoftwareID, CVE: "CVE-2024-002",
}, fleet.UbuntuOVALSource)
require.NoError(t, err)
require.True(t, inserted)
// Remove the host, making orphanSoftware's host_software entry disappear.
err = ds.DeleteHost(ctx, hostToRemove.ID)
require.NoError(t, err)
// Verify both vulns still exist before cleanup.
storedShared, err := ds.SoftwareByID(ctx, sharedSoftwareID, nil, false, nil)
require.NoError(t, err)
require.Len(t, storedShared.Vulnerabilities, 1)
storedOrphan, err := ds.SoftwareByID(ctx, orphanSoftwareID, nil, false, nil)
require.NoError(t, err)
require.Len(t, storedOrphan.Vulnerabilities, 1)
// Run orphan cleanup.
err = ds.DeleteOrphanedSoftwareVulnerabilities(ctx)
require.NoError(t, err)
// The vulnerability for shared software (still has a host) should remain.
storedShared, err = ds.SoftwareByID(ctx, sharedSoftwareID, nil, false, nil)
require.NoError(t, err)
require.Len(t, storedShared.Vulnerabilities, 1)
require.Equal(t, "CVE-2024-001", storedShared.Vulnerabilities[0].CVE)
// The vulnerability for orphan software (no hosts) should be deleted.
storedOrphan, err = ds.SoftwareByID(ctx, orphanSoftwareID, nil, false, nil)
require.NoError(t, err)
require.Empty(t, storedOrphan.Vulnerabilities)
}
func testDeleteSoftwareCPEs(t *testing.T, ds *Datastore) {
ctx := context.Background()
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.1", Source: "chrome_extensions"},
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
cpes := []fleet.SoftwareCPE{
{
SoftwareID: host.Software[0].ID,
CPE: "CPE-001",
},
{
SoftwareID: host.Software[1].ID,
CPE: "CPE-002",
},
}
_, err = ds.UpsertSoftwareCPEs(ctx, cpes)
require.NoError(t, err)
t.Run("nothing to delete", func(t *testing.T) {
affected, err := ds.DeleteSoftwareCPEs(ctx, nil)
require.NoError(t, err)
require.Zero(t, affected)
})
t.Run("with invalid software id", func(t *testing.T) {
toDelete := []fleet.SoftwareCPE{cpes[0], {
SoftwareID: host.Software[1].ID + 1234,
CPE: "CPE-002",
}}
affected, err := ds.DeleteSoftwareCPEs(ctx, toDelete)
require.NoError(t, err)
require.Equal(t, int64(1), affected)
storedCPEs, err := ds.ListSoftwareCPEs(ctx)
require.NoError(t, err)
test.ElementsMatchSkipID(t, cpes[1:], storedCPEs)
storedSoftware, err := ds.SoftwareByID(ctx, cpes[0].SoftwareID, nil, false, nil)
require.NoError(t, err)
require.Empty(t, storedSoftware.GenerateCPE)
})
}
func testGetHostSoftwareInstalledPaths(t *testing.T, ds *Datastore) {
ctx := context.Background()
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.1", Source: "chrome_extensions"},
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
// No installed_path entries
actual, err := ds.getHostSoftwareInstalledPaths(ctx, host.ID)
require.NoError(t, err)
require.Empty(t, actual)
// Insert an installed_path for a single software entry
query := `INSERT INTO host_software_installed_paths (host_id, software_id, installed_path) VALUES (?, ?, ?)`
args := []interface{}{host.ID, host.Software[0].ID, "/some/path"}
_, err = ds.writer(ctx).ExecContext(ctx, query, args...)
require.NoError(t, err)
actual, err = ds.getHostSoftwareInstalledPaths(ctx, host.ID)
require.Len(t, actual, 1)
require.Equal(t, actual[0].SoftwareID, host.Software[0].ID)
require.Equal(t, actual[0].HostID, host.ID)
require.Equal(t, actual[0].InstalledPath, "/some/path")
require.NoError(t, err)
}
func testHostSoftwareInstalledPathsDelta(t *testing.T, ds *Datastore) {
host := fleet.Host{ID: 1}
software := []fleet.Software{
{
ID: 2,
Name: "foo",
Version: "0.0.1",
Source: "chrome_extensions",
},
{
ID: 3,
Name: "bar",
Version: "0.0.2",
Source: "chrome_extensions",
},
{
ID: 4,
Name: "zub",
Version: "0.0.3",
Source: "chrome_extensions",
},
{
ID: 5,
Name: "zib",
Version: "0.0.4",
Source: "chrome_extensions",
},
}
t.Run("empty args", func(t *testing.T) {
toI, toD, err := hostSoftwareInstalledPathsDelta(t.Context(), host.ID, nil, nil, nil, slog.New(slog.DiscardHandler))
require.Empty(t, toI)
require.Empty(t, toD)
require.NoError(t, err)
})
t.Run("nothing reported from osquery", func(t *testing.T) {
var stored []fleet.HostSoftwareInstalledPath
for i, s := range software {
var cdHashSHA256, execHashSHA256, execPath *string
if i%2 == 0 {
cdhash := fmt.Sprintf("%x", sha256.Sum256(fmt.Appendf(nil, "cdhash-%d", s.ID)))
cdHashSHA256 = &cdhash
execHash := fmt.Sprintf("%x", sha256.Sum256(fmt.Appendf(nil, "exechash-%d", s.ID)))
execHashSHA256 = &execHash
ePath := fmt.Sprintf("/some/path/%d", s.ID)
execPath = &ePath
}
stored = append(stored, fleet.HostSoftwareInstalledPath{
ID: uint(i),
HostID: host.ID,
SoftwareID: s.ID,
InstalledPath: fmt.Sprintf("/some/path/%d", s.ID),
CDHashSHA256: cdHashSHA256,
ExecutableSHA256: execHashSHA256,
ExecutablePath: execPath,
})
}
toI, toD, err := hostSoftwareInstalledPathsDelta(t.Context(), host.ID, nil, stored, software, slog.New(slog.DiscardHandler))
require.NoError(t, err)
require.Empty(t, toI)
// Kind of an edge case ... but if nothing is reported by osquery we want the state of the
// DB to reflect that.
require.Len(t, toD, len(stored))
var expected []uint
for _, s := range stored {
expected = append(expected, s.ID)
}
require.ElementsMatch(t, toD, expected)
})
t.Run("host has no software but some paths were reported", func(t *testing.T) {
reported := make(map[string]struct{})
reported[fmt.Sprintf("/some/path/%d%s%s%s%s", software[0].ID, fleet.SoftwareFieldSeparator, "", fleet.SoftwareFieldSeparator, software[0].ToUniqueStr())] = struct{}{}
reported[fmt.Sprintf("/some/path/%d%s%s%s%s", software[1].ID+1, fleet.SoftwareFieldSeparator, "", fleet.SoftwareFieldSeparator, software[1].ToUniqueStr())] = struct{}{}
reported[fmt.Sprintf("/some/path/%d%s%s%s%s", software[2].ID, fleet.SoftwareFieldSeparator, "", fleet.SoftwareFieldSeparator, software[2].ToUniqueStr())] = struct{}{}
var stored []fleet.HostSoftwareInstalledPath
_, _, err := hostSoftwareInstalledPathsDelta(t.Context(), host.ID, reported, stored, nil, slog.New(slog.DiscardHandler))
require.Error(t, err)
})
t.Run("we have some deltas", func(t *testing.T) {
cdHash1 := fmt.Sprintf("%x", sha256.Sum256([]byte("cdhash-1")))
cdHash2 := fmt.Sprintf("%x", sha256.Sum256([]byte("cdhash-2")))
eHash1 := fmt.Sprintf("%x", sha256.Sum256([]byte("binhash-1")))
eHash2 := fmt.Sprintf("%x", sha256.Sum256([]byte("binhash-2")))
ePath1 := fmt.Sprintf("/some/path/%d", software[1].ID)
ePath2 := fmt.Sprintf("/some/new/path/%d", software[1].ID)
getKey := func(s fleet.Software, change uint) string {
var key string
switch s.ID {
case 3:
key = fmt.Sprintf(
"%s%d%s%s%s%s%s%s%s%s%s%s",
"/some/path/", s.ID+change, fleet.SoftwareFieldSeparator, "corp1", fleet.SoftwareFieldSeparator, cdHash1, fleet.SoftwareFieldSeparator, eHash1, fleet.SoftwareFieldSeparator, ePath1, fleet.SoftwareFieldSeparator, s.ToUniqueStr(),
)
case 5:
key = fmt.Sprintf(
"%s%d%s%s%s%s%s%s%s%s%s%s",
"/some/path/", s.ID+change, fleet.SoftwareFieldSeparator, "corp1", fleet.SoftwareFieldSeparator, cdHash2, fleet.SoftwareFieldSeparator, eHash2, fleet.SoftwareFieldSeparator, ePath2, fleet.SoftwareFieldSeparator, s.ToUniqueStr(),
)
default:
key = fmt.Sprintf(
"%s%d%s%s%s%s%s%s%s%s%s%s",
"/some/path/", s.ID+change, fleet.SoftwareFieldSeparator, "corp1", fleet.SoftwareFieldSeparator, "", fleet.SoftwareFieldSeparator, "", fleet.SoftwareFieldSeparator, "", fleet.SoftwareFieldSeparator, s.ToUniqueStr(),
)
}
return key
}
reported := make(map[string]struct{})
reported[getKey(software[0], 0)] = struct{}{}
reported[getKey(software[1], 1)] = struct{}{}
reported[getKey(software[2], 0)] = struct{}{}
var stored []fleet.HostSoftwareInstalledPath
stored = append(stored, fleet.HostSoftwareInstalledPath{
ID: 1,
HostID: host.ID,
SoftwareID: software[0].ID,
TeamIdentifier: "corp1",
InstalledPath: fmt.Sprintf("/some/path/%d", software[0].ID),
})
stored = append(stored, fleet.HostSoftwareInstalledPath{
ID: 2,
HostID: host.ID,
SoftwareID: software[1].ID,
TeamIdentifier: "corp1",
InstalledPath: fmt.Sprintf("/some/path/%d", software[1].ID),
CDHashSHA256: &cdHash1,
ExecutableSHA256: &eHash1,
ExecutablePath: &ePath1,
})
stored = append(stored, fleet.HostSoftwareInstalledPath{
ID: 3,
HostID: host.ID,
SoftwareID: software[2].ID,
TeamIdentifier: "corp1",
InstalledPath: fmt.Sprintf("/some/path/%d", software[2].ID+1),
})
stored = append(stored, fleet.HostSoftwareInstalledPath{
ID: 4,
HostID: host.ID,
SoftwareID: software[3].ID,
TeamIdentifier: "corp1",
InstalledPath: fmt.Sprintf("/some/path/%d", software[3].ID),
CDHashSHA256: &cdHash2,
ExecutableSHA256: &eHash2,
ExecutablePath: &ePath2,
})
toI, toD, err := hostSoftwareInstalledPathsDelta(t.Context(), host.ID, reported, stored, software, slog.New(slog.DiscardHandler))
require.NoError(t, err)
require.Len(t, toD, 3)
require.ElementsMatch(t,
[]uint{toD[0], toD[1], toD[2]},
[]uint{stored[1].ID, stored[2].ID, stored[3].ID},
)
require.Len(t, toI, 2)
for i := range toI {
require.Equal(t, toI[i].HostID, host.ID)
}
require.ElementsMatch(t,
[]uint{toI[0].SoftwareID, toI[1].SoftwareID},
[]uint{software[1].ID, software[2].ID},
)
require.ElementsMatch(t,
[]string{toI[0].InstalledPath, toI[1].InstalledPath},
[]string{fmt.Sprintf("/some/path/%d", software[1].ID+1), fmt.Sprintf("/some/path/%d", software[2].ID)},
)
require.ElementsMatch(t,
[]*string{toI[0].CDHashSHA256, toI[1].CDHashSHA256},
[]*string{&cdHash1, nil},
)
require.ElementsMatch(t,
[]*string{toI[0].ExecutableSHA256, toI[1].ExecutableSHA256},
[]*string{&eHash1, nil},
)
require.ElementsMatch(t,
[]*string{toI[0].ExecutablePath, toI[1].ExecutablePath},
[]*string{&ePath1, nil},
)
})
}
func testDeleteHostSoftwareInstalledPaths(t *testing.T, ds *Datastore) {
ctx := context.Background()
host1 := fleet.Host{ID: 1}
host2 := fleet.Host{ID: 2}
software1 := []fleet.Software{
{ID: 1, Name: "foo", Version: "0.0.1", Source: "chrome_extensions"},
{ID: 2, Name: "bar", Version: "0.0.1", Source: "chrome_extensions"},
{ID: 3, Name: "zoo", Version: "0.0.1", Source: "chrome_extensions"},
}
software2 := []fleet.Software{
{ID: 4, Name: "zip", Version: "0.0.1", Source: "apps"},
{ID: 5, Name: "bur", Version: "0.0.1", Source: "apps"},
}
query := `INSERT INTO host_software_installed_paths (host_id, software_id, installed_path) VALUES (?, ?, ?)`
for _, s := range software1 {
args := []interface{}{host1.ID, s.ID, fmt.Sprintf("/some/path/%d", s.ID)}
_, err := ds.writer(ctx).ExecContext(ctx, query, args...)
require.NoError(t, err)
}
args := []interface{}{host2.ID, software2[0].ID, fmt.Sprintf("/some/path/%d", software2[0].ID)}
_, err := ds.writer(ctx).ExecContext(ctx, query, args...)
require.NoError(t, err)
storedOnHost1, err := ds.getHostSoftwareInstalledPaths(ctx, host1.ID)
require.NoError(t, err)
storedOnHost2, err := ds.getHostSoftwareInstalledPaths(ctx, host2.ID)
require.NoError(t, err)
var toDelete []uint
for _, r := range storedOnHost1 {
if r.SoftwareID == software1[0].ID || r.SoftwareID == software1[1].ID {
toDelete = append(toDelete, r.ID)
}
}
for _, r := range storedOnHost2 {
if r.SoftwareID == software2[0].ID {
toDelete = append(toDelete, r.ID)
}
}
require.NoError(t, deleteHostSoftwareInstalledPaths(ctx, ds.writer(ctx), toDelete))
var actual []fleet.HostSoftwareInstalledPath
require.NoError(t, sqlx.SelectContext(ctx, ds.reader(ctx), &actual, `SELECT host_id, software_id, installed_path FROM host_software_installed_paths`))
expected := []fleet.HostSoftwareInstalledPath{
{
HostID: host1.ID,
SoftwareID: software1[2].ID,
InstalledPath: fmt.Sprintf("/some/path/%d", software1[2].ID),
},
}
test.ElementsMatchSkipID(t, actual, expected)
}
func testInsertHostSoftwareInstalledPaths(t *testing.T, ds *Datastore) {
ctx := context.Background()
toInsert := []fleet.HostSoftwareInstalledPath{
{
HostID: 1,
SoftwareID: 1,
InstalledPath: "1",
TeamIdentifier: "corp1",
CDHashSHA256: nil,
ExecutableSHA256: nil,
ExecutablePath: nil,
},
{
HostID: 1,
SoftwareID: 2,
InstalledPath: "2",
TeamIdentifier: "corp2",
CDHashSHA256: ptr.String("c32f12fc330236fcef22a4c776b001da75e6a54d68aa4f1fd3f24ad66be76f63"),
ExecutableSHA256: ptr.String("d32f12fc330236fcef22a4c776b001da75e6a54d68aa4f1fd3f24ad66be76f63"),
ExecutablePath: ptr.String("/some/path/2"),
},
{
HostID: 1,
SoftwareID: 3,
InstalledPath: "3",
TeamIdentifier: "",
CDHashSHA256: nil,
ExecutableSHA256: nil,
ExecutablePath: nil,
},
}
require.NoError(t, insertHostSoftwareInstalledPaths(ctx, ds.writer(ctx), toInsert))
var actual []fleet.HostSoftwareInstalledPath
require.NoError(t, sqlx.SelectContext(ctx, ds.reader(ctx), &actual, `SELECT host_id, software_id, installed_path, team_identifier, cdhash_sha256, executable_sha256, executable_path FROM host_software_installed_paths`))
require.ElementsMatch(t, actual, toInsert)
}
func TestCleanupSoftwareTitles(t *testing.T) {
ds := CreateMySQLDS(t)
ctx := context.Background()
host1 := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
host2 := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now())
host3 := test.NewHost(t, ds, "host3", "", "host3key", "host3uuid", time.Now())
expectedSoftware := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "chrome_extensions", ExtensionFor: "chrome"},
{Name: "foo", Version: "v0.0.2", Source: "chrome_extensions"},
{Name: "foo", Version: "0.0.3", Source: "chrome_extensions"},
{Name: "bar", Version: "0.0.3", Source: "deb_packages"},
{Name: "baz", Version: "0.0.1", Source: "deb_packages"},
}
expectedTitlesByNSB := map[string]fleet.SoftwareTitle{}
for _, s := range expectedSoftware {
expectedTitlesByNSB[s.Name+s.Source+s.ExtensionFor] = fleet.SoftwareTitle{
Name: s.Name,
Source: s.Source,
ExtensionFor: s.ExtensionFor,
}
}
software1 := []fleet.Software{expectedSoftware[0], expectedSoftware[2]}
software2 := []fleet.Software{expectedSoftware[1], expectedSoftware[2], expectedSoftware[3]}
software3 := []fleet.Software{expectedSoftware[4]}
_, err := ds.UpdateHostSoftware(ctx, host1.ID, software1)
require.NoError(t, err)
_, err = ds.UpdateHostSoftware(ctx, host2.ID, software2)
require.NoError(t, err)
_, err = ds.UpdateHostSoftware(ctx, host3.ID, software3)
require.NoError(t, err)
getSoftware := func() ([]fleet.Software, error) {
var sw []fleet.Software
err := ds.writer(ctx).SelectContext(ctx, &sw, `SELECT
id, name, version, bundle_identifier, source, extension_id, extension_for, `+"`release`"+`, vendor, arch, title_id
FROM software ORDER BY name, source, extension_for, version`)
if err != nil {
return nil, err
}
return sw, nil
}
getTitles := func() ([]fleet.SoftwareTitle, error) {
var swt []fleet.SoftwareTitle
err := ds.writer(ctx).SelectContext(ctx, &swt, `SELECT id, name, source, extension_for FROM software_titles ORDER BY name, source, extension_for`)
if err != nil {
return nil, err
}
return swt, nil
}
assertSoftware := func(t *testing.T, wantSoftware []fleet.Software) {
gotSoftware, err := getSoftware()
require.NoError(t, err)
require.Len(t, gotSoftware, len(wantSoftware))
byNSBV := map[string]fleet.Software{}
for _, s := range wantSoftware {
byNSBV[s.Name+s.Source+s.ExtensionFor+s.Version] = s
}
for _, r := range gotSoftware {
_, ok := byNSBV[r.Name+r.Source+r.ExtensionFor+r.Version]
require.True(t, ok)
assert.NotNil(t, r.TitleID)
swt, ok := expectedTitlesByNSB[r.Name+r.Source+r.ExtensionFor]
require.True(t, ok)
assert.Equal(t, swt.ID, *r.TitleID)
assert.Equal(t, swt.Name, r.Name)
assert.Equal(t, swt.Source, r.Source)
assert.Equal(t, swt.ExtensionFor, r.ExtensionFor)
}
}
assertTitles := func(t *testing.T, gotTitles []fleet.SoftwareTitle, expectMissing []string) {
for _, r := range gotTitles {
if len(expectMissing) > 0 {
require.NotContains(t, expectMissing, r.Name)
}
e, ok := expectedTitlesByNSB[r.Name+r.Source+r.ExtensionFor]
require.True(t, ok)
require.Equal(t, e.ID, r.ID)
require.Equal(t, e.Name, r.Name)
require.Equal(t, e.Source, r.Source)
require.Equal(t, e.ExtensionFor, r.ExtensionFor)
}
}
swTitles, err := getTitles()
require.NoError(t, err)
for _, swt := range swTitles {
if _, ok := expectedTitlesByNSB[swt.Name+swt.Source+swt.ExtensionFor]; ok {
expectedTitlesByNSB[swt.Name+swt.Source+swt.ExtensionFor] = swt
}
}
assertSoftware(t, expectedSoftware)
swt, err := getTitles()
require.NoError(t, err)
require.Len(t, swt, 4)
require.Equal(t, swt[0].Name, "bar")
require.Equal(t, swt[0].Source, "deb_packages")
require.Equal(t, swt[0].ExtensionFor, "")
expectedTitlesByNSB[swt[0].Name+swt[0].Source+swt[0].ExtensionFor] = swt[0]
require.Equal(t, swt[1].Name, "baz")
require.Equal(t, swt[1].Source, "deb_packages")
require.Equal(t, swt[1].ExtensionFor, "")
expectedTitlesByNSB[swt[1].Name+swt[1].Source+swt[1].ExtensionFor] = swt[1]
require.Equal(t, swt[2].Name, "foo")
require.Equal(t, swt[2].Source, "chrome_extensions")
require.Equal(t, swt[2].ExtensionFor, "")
expectedTitlesByNSB[swt[2].Name+swt[2].Source+swt[2].ExtensionFor] = swt[2]
require.Equal(t, swt[3].Name, "foo")
require.Equal(t, swt[3].Source, "chrome_extensions")
require.Equal(t, swt[3].ExtensionFor, "chrome")
expectedTitlesByNSB[swt[3].Name+swt[3].Source+swt[3].ExtensionFor] = swt[3]
// Double check software and titles
assertSoftware(t, expectedSoftware)
// remove the bar software title from host 2
_, err = ds.UpdateHostSoftware(context.Background(), host2.ID, software2[:2])
require.NoError(t, err)
// SyncHostsSoftware will remove the above software item from the software table
require.NoError(t, ds.SyncHostsSoftware(context.Background(), time.Now()))
assertSoftware(t, []fleet.Software{expectedSoftware[0], expectedSoftware[1], expectedSoftware[2], expectedSoftware[4]})
// bar is no longer associated with any host so the title should be deleted
require.NoError(t, ds.CleanupSoftwareTitles(context.Background()))
gotTitles, err := getTitles()
require.NoError(t, err)
require.Len(t, gotTitles, 3)
assertTitles(t, gotTitles, []string{"bar"})
// add bar to host 3
_, err = ds.UpdateHostSoftware(context.Background(), host3.ID, []fleet.Software{expectedSoftware[3], expectedSoftware[4]})
require.NoError(t, err)
require.NoError(t, ds.SyncHostsSoftware(context.Background(), time.Now()))
gotTitles, err = getTitles()
require.NoError(t, err)
require.Len(t, gotTitles, 4)
// bar was added back to software titles with a new ID
require.Equal(t, "bar", gotTitles[0].Name)
require.Equal(t, "deb_packages", gotTitles[0].Source)
require.NotEqual(t, expectedTitlesByNSB[gotTitles[0].Name+gotTitles[0].Source], gotTitles[0].ID)
expectedTitlesByNSB[gotTitles[0].Name+gotTitles[0].Source] = gotTitles[0]
assertTitles(t, gotTitles, nil)
assertSoftware(t, expectedSoftware)
// add a new version of foo to host 3
expectedSoftware = append(expectedSoftware, fleet.Software{Name: "foo", Version: "0.0.4", Source: "chrome_extensions"})
_, err = ds.UpdateHostSoftware(ctx, host3.ID, expectedSoftware[3:])
require.NoError(t, err)
gotTitles, err = getTitles()
require.NoError(t, err)
require.Len(t, gotTitles, 4)
assertTitles(t, gotTitles, nil)
assertSoftware(t, expectedSoftware)
// add a new source of foo to host 3
expectedSoftware = append(expectedSoftware, fleet.Software{Name: "foo", Version: "0.0.4", Source: "rpm_packages"})
_, err = ds.UpdateHostSoftware(ctx, host3.ID, expectedSoftware[3:])
require.NoError(t, err)
// new source of foo results in a new software title entry
gotTitles, err = getTitles()
require.NoError(t, err)
require.Len(t, gotTitles, 5)
require.Equal(t, "foo", gotTitles[4].Name)
require.Equal(t, "rpm_packages", gotTitles[4].Source)
require.Equal(t, "", gotTitles[4].ExtensionFor)
expectedTitlesByNSB[gotTitles[4].Name+gotTitles[4].Source+gotTitles[4].ExtensionFor] = gotTitles[4]
assertTitles(t, gotTitles, nil)
assertSoftware(t, expectedSoftware)
}
func testUpdateHostSoftwareDeadlock(t *testing.T, ds *Datastore) {
// To increase chance of deadlock increase these numbers.
// We are keeping them low to not cause CI issues ("too many connections" errors
// due to concurrent tests).
const (
hostCount = 10
updateCount = 10
)
ctx := context.Background()
var hosts []*fleet.Host
for i := 1; i <= hostCount; i++ {
h, err := ds.NewHost(ctx, &fleet.Host{
ID: uint(i),
OsqueryHostID: ptr.String(fmt.Sprintf("id-%d", i)),
NodeKey: ptr.String(fmt.Sprintf("key-%d", i)),
Platform: "linux",
Hostname: fmt.Sprintf("host-%d", i),
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
})
require.NoError(t, err)
hosts = append(hosts, h)
}
var g errgroup.Group
for _, h := range hosts {
hostID := h.ID
g.Go(func() error {
for i := 0; i < updateCount; i++ {
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "test", GenerateCPE: "cpe_foo"},
{Name: "bar", Version: "0.0.2", Source: "test", GenerateCPE: "cpe_bar"},
{Name: "baz", Version: "0.0.3", Source: "test", GenerateCPE: "cpe_baz"},
}
removeIdx := rand.Intn(len(software))
software = append(software[:removeIdx], software[removeIdx+1:]...)
if _, err := ds.UpdateHostSoftware(ctx, hostID, software); err != nil {
return err
}
time.Sleep(10 * time.Millisecond)
}
return nil
})
}
err := g.Wait()
require.NoError(t, err)
}
func testVerifySoftwareChecksum(t *testing.T, ds *Datastore) {
ctx := context.Background()
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "test"},
{Name: "foo", Version: "0.0.1", Source: "test", ExtensionFor: "firefox"},
{Name: "foo", Version: "0.0.1", Source: "test", ExtensionID: "ext"},
{Name: "foo", Version: "0.0.2", Source: "test"},
{Name: "foo", Version: "0.0.2", Source: "test", ApplicationID: ptr.String("foo.bar.baz")},
{Name: "foo", Version: "0.0.2", Source: "programs", UpgradeCode: ptr.String("{55ac7218-24cb-4b99-9449-f28d9c59cc7e}")},
{Name: "foo", Version: "0.0.2", Source: "programs", UpgradeCode: ptr.String("")},
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
checksums := make([]string, len(software))
for i, sw := range software {
checksum, err := sw.ComputeRawChecksum()
require.NoError(t, err)
checksums[i] = hex.EncodeToString(checksum)
}
for i, cs := range checksums {
var got fleet.Software
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &got,
`SELECT name, version, source, bundle_identifier, `+"`release`"+`, arch, vendor, extension_for, extension_id, application_id, upgrade_code FROM software WHERE checksum = UNHEX(?)`, cs)
})
require.Equal(t, software[i], got)
}
}
func testListHostSoftware(t *testing.T, ds *Datastore) {
ctx := context.Background()
t.Cleanup(func() { ds.testActivateSpecificNextActivities = nil })
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, host, false)
otherHost := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now(), test.WithPlatform("ubuntu"))
opts := fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 11, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"}}
user, err := ds.NewUser(ctx, &fleet.User{
Password: []byte("p4ssw0rd.123"),
Name: "user1",
Email: "user1@example.com",
GlobalRole: ptr.String(fleet.RoleAdmin),
})
require.NoError(t, err)
test.CreateInsertGlobalVPPToken(t, ds)
expectStatus := func(s fleet.SoftwareInstallerStatus) *fleet.SoftwareInstallerStatus {
return &s
}
// no software yet
sw, meta, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Empty(t, sw)
require.Equal(t, &fleet.PaginationMetadata{}, meta)
// works with available software too
opts.IncludeAvailableForInstall = true
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Empty(t, sw)
require.Equal(t, &fleet.PaginationMetadata{}, meta)
// available for install only works too
opts.OnlyAvailableForInstall = true
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Empty(t, sw)
assert.Equal(t, &fleet.PaginationMetadata{}, meta)
// self-service only works too
opts.OnlyAvailableForInstall = false
opts.SelfServiceOnly = true
opts.IncludeAvailableForInstall = true
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Empty(t, sw)
require.Equal(t, &fleet.PaginationMetadata{}, meta)
opts.IncludeAvailableForInstall = false
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Empty(t, sw)
require.Equal(t, &fleet.PaginationMetadata{}, meta)
// add software to the host
software := []fleet.Software{
{Name: "a", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "a", Version: "0.0.2", Source: "deb_packages"}, // different source, so different title than a-chrome
{Name: "b", Version: "0.0.3", Source: "apps"},
{Name: "c", Version: "0.0.4", Source: "deb_packages"},
{Name: "c", Version: "0.0.5", Source: "deb_packages"},
{Name: "d", Version: "0.0.6", Source: "deb_packages"},
{Name: "e", Version: "0.0.2", Source: "deb_packages"}, // not vulnerable version
}
byNSV := map[string]fleet.Software{}
for _, s := range software {
byNSV[s.Name+s.Source+s.Version] = s
}
mutationResults, err := ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.Len(t, mutationResults.Inserted, len(software))
for _, m := range mutationResults.Inserted {
s, ok := byNSV[m.Name+m.Source+m.Version]
require.True(t, ok)
require.Equal(t, m.Name, s.Name, "name")
require.Equal(t, m.Version, s.Version, "version")
require.Equal(t, m.Source, s.Source, "source")
require.Zero(t, s.ID) // not set in the map yet
require.NotZero(t, m.ID)
s.ID = m.ID
byNSV[s.Name+s.Source+s.Version] = s
}
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
require.Equal(t, len(host.Software), len(software))
for _, hs := range host.Software {
s, ok := byNSV[hs.Name+hs.Source+hs.Version]
require.True(t, ok)
require.Equal(t, hs.Name, s.Name, "name")
require.Equal(t, hs.Version, s.Version, "version")
require.Equal(t, hs.Source, s.Source, "source")
require.Equal(t, hs.ID, s.ID)
}
// add other software to the other host, won't be returned
otherSoftware := []fleet.Software{
{Name: "a", Version: "0.0.7", Source: "chrome_extensions"},
{Name: "f", Version: "0.0.8", Source: "chrome_extensions"},
{Name: "e", Version: "0.0.1", Source: "deb_packages"}, // vulnerable version
}
otherSoftwareByNSV := map[string]fleet.Software{}
for _, s := range otherSoftware {
otherSoftwareByNSV[s.Name+s.Source+s.Version] = s
}
otherMutationResults, err := ds.UpdateHostSoftware(ctx, otherHost.ID, otherSoftware)
require.NoError(t, err)
for _, m := range otherMutationResults.Inserted {
s, ok := otherSoftwareByNSV[m.Name+m.Source+m.Version]
require.True(t, ok)
s.ID = m.ID
otherSoftwareByNSV[s.Name+s.Source+s.Version] = s
}
require.NoError(t, ds.LoadHostSoftware(ctx, otherHost, false))
// shorthand keys for expected software
a1 := software[0].Name + software[0].Source + software[0].Version
a2 := software[1].Name + software[1].Source + software[1].Version
b := software[2].Name + software[2].Source + software[2].Version
c1 := software[3].Name + software[3].Source + software[3].Version
c2 := software[4].Name + software[4].Source + software[4].Version
d := software[5].Name + software[5].Source + software[5].Version
e2 := software[6].Name + software[6].Source + software[6].Version
// shorthand keys for other software
e1 := otherSoftware[2].Name + otherSoftware[2].Source + otherSoftware[2].Version
// add some vulnerabilities and installed paths
vulns := []fleet.SoftwareVulnerability{
{SoftwareID: byNSV[a1].ID, CVE: "CVE-a-0001"},
{SoftwareID: byNSV[a1].ID, CVE: "CVE-a-0002"},
{SoftwareID: byNSV[a1].ID, CVE: "CVE-a-0003"},
{SoftwareID: byNSV[b].ID, CVE: "CVE-b-0001"},
{SoftwareID: otherSoftwareByNSV[e1].ID, CVE: "CVE-e-0001"},
}
for _, v := range vulns {
_, err = ds.InsertSoftwareVulnerability(ctx, v, fleet.NVDSource)
require.NoError(t, err)
}
swPaths := map[string]struct{}{}
installPaths := make([]string, 0, len(software))
for _, s := range software {
path := fmt.Sprintf("/some/path/%s", s.Name)
key := fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s", path, fleet.SoftwareFieldSeparator, "", fleet.SoftwareFieldSeparator, "", fleet.SoftwareFieldSeparator, "", fleet.SoftwareFieldSeparator, "", fleet.SoftwareFieldSeparator, s.ToUniqueStr())
swPaths[key] = struct{}{}
installPaths = append(installPaths, path)
}
err = ds.UpdateHostSoftwareInstalledPaths(ctx, host.ID, swPaths, mutationResults)
require.NoError(t, err)
expected := map[string]fleet.HostSoftwareWithInstaller{
byNSV[a1].Name + byNSV[a1].Source: {Name: byNSV[a1].Name, Source: byNSV[a1].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: byNSV[a1].Version, Vulnerabilities: []string{vulns[0].CVE, vulns[1].CVE, vulns[2].CVE}, InstalledPaths: []string{installPaths[0]}},
}},
// a1 and a2 are different software titles because they have different sources
byNSV[a2].Name + byNSV[a2].Source: {Name: byNSV[a2].Name, Source: byNSV[a2].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: byNSV[a2].Version, InstalledPaths: []string{installPaths[1]}},
}},
byNSV[b].Name + byNSV[b].Source: {Name: byNSV[b].Name, Source: byNSV[b].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: byNSV[b].Version, Vulnerabilities: []string{vulns[3].CVE}, InstalledPaths: []string{installPaths[2]}},
}},
// c1 and c2 are the same software title because they have the same name and source
byNSV[c1].Name + byNSV[c1].Source: {Name: byNSV[c1].Name, Source: byNSV[c1].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: byNSV[c1].Version, InstalledPaths: []string{installPaths[3]}},
{Version: byNSV[c2].Version, InstalledPaths: []string{installPaths[4]}},
}},
byNSV[d].Name + byNSV[d].Source: {Name: byNSV[d].Name, Source: byNSV[d].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: byNSV[d].Version, InstalledPaths: []string{installPaths[5]}},
}},
byNSV[e2].Name + byNSV[e2].Source: {Name: byNSV[e2].Name, Source: byNSV[e2].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: byNSV[e2].Version, InstalledPaths: []string{installPaths[6]}},
}},
}
compareResults := func(expected map[string]fleet.HostSoftwareWithInstaller, got []*fleet.HostSoftwareWithInstaller, expectAsc bool, expectOmitted ...string) {
gotToString := func() string {
var builder strings.Builder
builder.WriteString("Got:\n")
for _, g := range got {
builder.WriteString(fmt.Sprintf("%+v\n", g))
}
builder.WriteString("Expected:\n")
for _, e := range expected {
if expectOmitted != nil && slices.Contains(expectOmitted, e.Name+e.Source) {
continue
}
builder.WriteString(fmt.Sprintf("%+v\n", e))
}
return builder.String()
}
require.Len(t, got, len(expected)-len(expectOmitted), gotToString())
prev := ""
for _, g := range got {
for _, omit := range expectOmitted {
if g.Name+g.Source == omit {
require.FailNowf(t, "Found unexpected software in results", fmt.Sprintf("Found %s", omit))
continue
}
}
e, ok := expected[g.Name+g.Source]
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)
if e.SoftwarePackage != nil {
require.Equal(t, e.SoftwarePackage.SelfService, g.SoftwarePackage.SelfService)
require.Equal(t, e.SoftwarePackage.AppStoreID, g.SoftwarePackage.AppStoreID)
require.Equal(t, e.SoftwarePackage.Name, g.SoftwarePackage.Name)
require.Equal(t, e.SoftwarePackage.Version, g.SoftwarePackage.Version)
if e.SoftwarePackage.LastInstall == nil {
require.Nil(t, g.SoftwarePackage.LastInstall)
} else {
require.NotNil(t, g.SoftwarePackage.LastInstall)
require.Equal(t, e.SoftwarePackage.LastInstall.CommandUUID, g.SoftwarePackage.LastInstall.CommandUUID)
require.Equal(t, e.SoftwarePackage.LastInstall.InstallUUID, g.SoftwarePackage.LastInstall.InstallUUID)
require.NotNil(t, g.SoftwarePackage.LastInstall.InstalledAt)
}
if e.SoftwarePackage.LastUninstall == nil {
require.Nil(t, g.SoftwarePackage.LastUninstall)
} else {
require.NotNil(t, g.SoftwarePackage.LastUninstall)
assert.Equal(t, e.SoftwarePackage.LastUninstall.ExecutionID, g.SoftwarePackage.LastUninstall.ExecutionID)
assert.NotNil(t, g.SoftwarePackage.LastUninstall.UninstalledAt)
}
if e.SoftwarePackage.AutomaticInstallPolicies != nil {
require.Len(t, e.SoftwarePackage.AutomaticInstallPolicies, len(g.SoftwarePackage.AutomaticInstallPolicies))
for i, p := range e.SoftwarePackage.AutomaticInstallPolicies {
require.Equal(t, p.ID, g.SoftwarePackage.AutomaticInstallPolicies[i].ID)
require.Equal(t, p.Name, g.SoftwarePackage.AutomaticInstallPolicies[i].Name)
}
}
}
if e.AppStoreApp != nil {
require.Equal(t, e.AppStoreApp.SelfService, g.AppStoreApp.SelfService)
require.Equal(t, e.AppStoreApp.AppStoreID, g.AppStoreApp.AppStoreID)
require.Equal(t, e.AppStoreApp.Name, g.AppStoreApp.Name)
require.Equal(t, e.AppStoreApp.Version, g.AppStoreApp.Version)
require.Equal(t, e.AppStoreApp.Platform, g.AppStoreApp.Platform)
if e.AppStoreApp.LastInstall != nil {
require.Equal(t, e.AppStoreApp.LastInstall.InstallUUID, g.AppStoreApp.LastInstall.InstallUUID)
require.Equal(t, e.AppStoreApp.LastInstall.CommandUUID, g.AppStoreApp.LastInstall.CommandUUID)
require.NotNil(t, g.AppStoreApp.LastInstall.InstalledAt)
}
if e.AppStoreApp.AutomaticInstallPolicies != nil {
require.Len(t, e.AppStoreApp.AutomaticInstallPolicies, len(g.AppStoreApp.AutomaticInstallPolicies))
for i, p := range e.AppStoreApp.AutomaticInstallPolicies {
require.Equal(t, p.ID, g.AppStoreApp.AutomaticInstallPolicies[i].ID)
require.Equal(t, p.Name, g.AppStoreApp.AutomaticInstallPolicies[i].Name)
}
}
}
// require.Equal(t, e.SoftwarePackage, g.SoftwarePackage)
// require.Equal(t, e.AppStoreApp, g.AppStoreApp)
require.Len(t, g.InstalledVersions, len(e.InstalledVersions))
if len(e.InstalledVersions) > 0 {
byVers := make(map[string]fleet.HostSoftwareInstalledVersion, len(e.InstalledVersions))
for _, v := range e.InstalledVersions {
byVers[v.Version] = *v
}
for _, v := range g.InstalledVersions {
ev, ok := byVers[v.Version]
require.True(t, ok)
require.Equal(t, ev.Version, v.Version)
require.ElementsMatch(t, ev.InstalledPaths, v.InstalledPaths)
require.ElementsMatch(t, ev.Vulnerabilities, v.Vulnerabilities)
}
}
if prev != "" {
if expectAsc {
require.Greater(t, g.Name+g.Source, prev)
} else {
require.Less(t, g.Name+g.Source, prev)
}
}
prev = g.Name + g.Source
}
}
// it now returns the software with vulnerabilities and installed paths
opts.SelfServiceOnly = false
opts.IncludeAvailableForInstall = false
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 6}, meta)
compareResults(expected, sw, true)
opts.VulnerableOnly = true
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 2}, meta)
compareResults(expected, sw, true, byNSV[a2].Name+byNSV[a2].Source, byNSV[c1].Name+byNSV[c1].Source, byNSV[d].Name+byNSV[d].Source, byNSV[e2].Name+byNSV[e2].Source)
opts.VulnerableOnly = false
// No software that is available for install
opts.OnlyAvailableForInstall = true
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Empty(t, sw)
assert.Equal(t, &fleet.PaginationMetadata{}, meta)
opts.OnlyAvailableForInstall = false
// create some Fleet installers and map them to a software title,
// including one for a team
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
const numberOfSoftwareInstallers = 8
var swi1Pending, swi2Installed, swi3Failed, swi4Available, swi5Tm, swi6PendingUninstall, swi7FailedUninstall, swi8Uninstalled uint
var hostSwi1InstallUUID, hostSwi2InstallUUID, hostSwi3InstallUUID, hostSwi6InstallUUID, hostSwi6UninstallUUID, hostSwi7UninstallUUID, hostSwi8InstallUUID, hostSwi8UninstallUUID,
otherHostI1UUID, otherHostI2UUID string
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
// keep title id of software B, will use it to associate an installer with it
var swbTitleID uint
err := sqlx.GetContext(ctx, q, &swbTitleID, `SELECT id FROM software_titles WHERE name = 'b' AND source = 'apps'`)
if err != nil {
return err
}
// create the install script content (same for all installers, doesn't matter)
installScript := `echo 'foo'`
res, err := q.ExecContext(ctx, `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(md5(?)), ?)`, installScript, installScript)
if err != nil {
return err
}
scriptContentID, _ := res.LastInsertId()
// create the uninstall script content (same for all installers, doesn't matter)
uninstallScript := `echo 'bar'`
resUninstall, err := q.ExecContext(ctx, `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(md5(?)), ?)`,
uninstallScript, uninstallScript)
if err != nil {
return err
}
uninstallScriptContentID, _ := resUninstall.LastInsertId()
// create software titles for all but swi1Pending (will be linked to
// existing software title b)
var titleIDs []uint
for i := 0; i < numberOfSoftwareInstallers-1; i++ {
res, err := q.ExecContext(ctx, `INSERT INTO software_titles (name, source) VALUES (?, 'apps')`, fmt.Sprintf("i%d", i))
if err != nil {
return err
}
id, _ := res.LastInsertId()
titleIDs = append(titleIDs, uint(id))
}
var swiIDs []uint
for i := 0; i < numberOfSoftwareInstallers; i++ {
var (
titleID uint
teamID *uint
globalOrTeamID uint
)
if i == 0 {
titleID = swbTitleID
} else {
titleID = titleIDs[i-1]
}
if i == 4 {
teamID = &tm.ID
globalOrTeamID = tm.ID
}
res, err := q.ExecContext(ctx, `
INSERT INTO software_installers
(
team_id,
global_or_team_id,
title_id,
filename,
extension,
version,
install_script_content_id,
uninstall_script_content_id,
storage_id,
platform,
self_service,
package_ids,
is_active,
patch_query
)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, TRUE, ?)`,
teamID, globalOrTeamID, titleID, fmt.Sprintf("installer-%d.pkg", i), "pkg", fmt.Sprintf("v%d.0.0", i), scriptContentID,
uninstallScriptContentID,
[]byte("test"), "darwin", i < 2, "[]", "")
if err != nil {
return err
}
id, _ := res.LastInsertId()
swiIDs = append(swiIDs, uint(id))
}
// sw1Pending and swi2Installed are self-service installers
swi1Pending, swi2Installed, swi3Failed, swi4Available, swi5Tm,
swi6PendingUninstall, swi7FailedUninstall, swi8Uninstalled = swiIDs[0], swiIDs[1], swiIDs[2], swiIDs[3], swiIDs[4], swiIDs[5], swiIDs[6], swiIDs[7]
// create the requests/results for the host
ds.testActivateSpecificNextActivities = []string{"-"}
// swi1 is pending (all results are NULL)
hostSwi1InstallUUID, err = ds.InsertSoftwareInstallRequest(ctx, host.ID, swi1Pending, fleet.HostSoftwareInstallOptions{})
if err != nil {
return err
}
// swi2 is installed successfully
hostSwi2InstallUUID, err = ds.InsertSoftwareInstallRequest(ctx, host.ID, swi2Installed, fleet.HostSoftwareInstallOptions{})
if err != nil {
return err
}
ds.testActivateSpecificNextActivities = []string{hostSwi2InstallUUID}
activated, err := ds.activateNextUpcomingActivity(ctx, q, host.ID, "")
if err != nil {
return err
}
require.Equal(t, ds.testActivateSpecificNextActivities, activated)
ds.testActivateSpecificNextActivities = []string{"-"}
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: hostSwi2InstallUUID,
PreInstallConditionOutput: ptr.String("ok"),
InstallScriptExitCode: ptr.Int(0),
PostInstallScriptExitCode: ptr.Int(0),
}, nil)
if err != nil {
return err
}
res, err = q.ExecContext(
ctx,
`INSERT INTO software (name, version, source, title_id, checksum) VALUES (?, ?, ?, ?, ?)`,
"foo2", "0.5", "bar2", &titleIDs[0], []byte("testb"),
)
if err != nil {
return err
}
Swi2InstalledSoftwareID, err := res.LastInsertId()
if err != nil {
return err
}
// insert into host_software
_, err = q.ExecContext(ctx, `INSERT INTO host_software (host_id, software_id) VALUES (?, ?)`,
host.ID, Swi2InstalledSoftwareID)
if err != nil {
return err
}
// swi3 is failed, also add an install request on the other host
hostSwi3InstallUUID, err = ds.InsertSoftwareInstallRequest(ctx, host.ID, swi3Failed, fleet.HostSoftwareInstallOptions{})
if err != nil {
return err
}
ds.testActivateSpecificNextActivities = []string{hostSwi3InstallUUID}
activated, err = ds.activateNextUpcomingActivity(ctx, q, host.ID, "")
if err != nil {
return err
}
require.Equal(t, ds.testActivateSpecificNextActivities, activated)
ds.testActivateSpecificNextActivities = []string{"-"}
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: hostSwi3InstallUUID,
PreInstallConditionOutput: ptr.String("ok"),
InstallScriptExitCode: ptr.Int(1),
}, nil)
if err != nil {
return err
}
otherHostI1UUID, err = ds.InsertSoftwareInstallRequest(ctx, otherHost.ID, swi3Failed, fleet.HostSoftwareInstallOptions{})
if err != nil {
return err
}
// swi4 is available (no install request), but add a pending request on the other host
otherHostI2UUID, err = ds.InsertSoftwareInstallRequest(ctx, otherHost.ID, swi4Available, fleet.HostSoftwareInstallOptions{})
if err != nil {
return err
}
// swi5 is for another team
_ = swi5Tm
// swi6 has been installed, and is pending uninstall
hostSwi6InstallUUID, err = ds.InsertSoftwareInstallRequest(ctx, host.ID, swi6PendingUninstall, fleet.HostSoftwareInstallOptions{})
if err != nil {
return err
}
ds.testActivateSpecificNextActivities = []string{hostSwi6InstallUUID}
activated, err = ds.activateNextUpcomingActivity(ctx, q, host.ID, "")
if err != nil {
return err
}
require.Equal(t, ds.testActivateSpecificNextActivities, activated)
ds.testActivateSpecificNextActivities = []string{"-"}
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: hostSwi6InstallUUID,
PreInstallConditionOutput: ptr.String("ok"),
InstallScriptExitCode: ptr.Int(0),
PostInstallScriptExitCode: ptr.Int(0),
}, nil)
if err != nil {
return err
}
hostSwi6UninstallUUID = uuid.NewString()
err = ds.InsertSoftwareUninstallRequest(ctx, hostSwi6UninstallUUID, host.ID, swi6PendingUninstall, false)
if err != nil {
return err
}
// swi7 is failed uninstall
hostSwi7UninstallUUID = uuid.NewString()
err = ds.InsertSoftwareUninstallRequest(ctx, hostSwi7UninstallUUID, host.ID, swi7FailedUninstall, true)
if err != nil {
return err
}
ds.testActivateSpecificNextActivities = []string{hostSwi7UninstallUUID}
activated, err = ds.activateNextUpcomingActivity(ctx, q, host.ID, "")
if err != nil {
return err
}
require.Equal(t, ds.testActivateSpecificNextActivities, activated)
ds.testActivateSpecificNextActivities = []string{"-"}
_, _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: host.ID,
ExecutionID: hostSwi7UninstallUUID,
ExitCode: 1,
}, nil)
if err != nil {
return err
}
// swi8 is successfully installed
hostSwi8InstallUUID, err = ds.InsertSoftwareInstallRequest(ctx, host.ID, swi8Uninstalled, fleet.HostSoftwareInstallOptions{})
if err != nil {
return err
}
ds.testActivateSpecificNextActivities = []string{hostSwi8InstallUUID}
activated, err = ds.activateNextUpcomingActivity(ctx, q, host.ID, "")
if err != nil {
return err
}
require.Equal(t, ds.testActivateSpecificNextActivities, activated)
ds.testActivateSpecificNextActivities = []string{"-"}
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: hostSwi8InstallUUID,
PreInstallConditionOutput: ptr.String("ok"),
InstallScriptExitCode: ptr.Int(0),
PostInstallScriptExitCode: ptr.Int(0),
}, nil)
if err != nil {
return err
}
// swi8 is successful uninstall
hostSwi8UninstallUUID = uuid.NewString()
err = ds.InsertSoftwareUninstallRequest(ctx, hostSwi8UninstallUUID, host.ID, swi8Uninstalled, true)
if err != nil {
return err
}
ds.testActivateSpecificNextActivities = []string{hostSwi8UninstallUUID}
activated, err = ds.activateNextUpcomingActivity(ctx, q, host.ID, "")
if err != nil {
return err
}
require.Equal(t, ds.testActivateSpecificNextActivities, activated)
ds.testActivateSpecificNextActivities = []string{"-"}
_, _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: host.ID,
ExecutionID: hostSwi8UninstallUUID,
ExitCode: 0,
}, nil)
if err != nil {
return err
}
return nil
})
// swi1Pending uses software title id of "b"
expected[byNSV[b].Name+byNSV[b].Source] = fleet.HostSoftwareWithInstaller{
Name: "b",
Source: "apps",
Status: nil, // pending installs do not get returned, so status is nil
SoftwarePackage: nil,
InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: byNSV[b].Version, Vulnerabilities: []string{vulns[3].CVE}, InstalledPaths: []string{installPaths[2]}},
},
}
i0 := fleet.HostSoftwareWithInstaller{
Name: "i0",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstalled),
SoftwarePackage: nil,
InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: "0.5", Vulnerabilities: []string{}, InstalledPaths: []string{}},
},
}
expected[i0.Name+i0.Source] = i0
// request without available software
opts.IncludeAvailableForInstall = false
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected))}, meta)
compareResults(expected, sw, true)
// request with available software
i4 := fleet.HostSoftwareWithInstaller{
Name: "i4",
Source: "apps",
Status: expectStatus(fleet.SoftwareUninstallPending),
SoftwarePackage: nil,
}
expected[i4.Name+i4.Source] = i4
i5 := fleet.HostSoftwareWithInstaller{
Name: "i5",
Source: "apps",
Status: expectStatus(fleet.SoftwareUninstallFailed),
SoftwarePackage: &fleet.SoftwarePackageOrApp{
Name: "installer-6.pkg", Version: "v6.0.0", Platform: "darwin", SelfService: ptr.Bool(false),
LastUninstall: &fleet.HostSoftwareUninstall{ExecutionID: hostSwi7UninstallUUID},
},
}
expected[i5.Name+i5.Source] = i5
i6 := fleet.HostSoftwareWithInstaller{
Name: "i6",
Source: "apps",
Status: nil,
SoftwarePackage: nil,
}
expected[i6.Name+i6.Source] = i6
i1 := fleet.HostSoftwareWithInstaller{
Name: "i1",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallFailed),
SoftwarePackage: &fleet.SoftwarePackageOrApp{
Name: "installer-2.pkg", Version: "v2.0.0", Platform: "darwin", SelfService: ptr.Bool(false),
LastInstall: &fleet.HostSoftwareInstall{InstallUUID: hostSwi3InstallUUID},
},
}
expected[i1.Name+i1.Source] = i1
i2 := fleet.HostSoftwareWithInstaller{
Name: "i2",
Source: "apps",
Status: nil,
SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-3.pkg", Version: "v3.0.0", Platform: "darwin", SelfService: ptr.Bool(false)},
}
expected[i2.Name+i2.Source] = i2
i3 := fleet.HostSoftwareWithInstaller{
Name: "i3",
Source: "apps",
Status: nil,
SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-4.pkg", Version: "v4.0.0", Platform: "darwin", SelfService: ptr.Bool(false)},
}
expected[i3.Name+i3.Source] = i3
opts.IncludeAvailableForInstall = true
opts.ListOptions.PerPage = 20
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected)) - 1}, meta)
compareResults(expected, sw, true, i3.Name+i3.Source)
// request with available software only (attempted to install and never attempted to install)
expectedAvailableOnly := map[string]fleet.HostSoftwareWithInstaller{}
expectedAvailableOnly[byNSV[b].Name+byNSV[b].Source] = expected[byNSV[b].Name+byNSV[b].Source]
// Although this is already installed, we ignore the status on the host because we can "reinstall" it
// if there is a compatible software package available for the host
expectedAvailableOnly[i0.Name+i0.Source] = i0
expectedAvailableOnly[i1.Name+i1.Source] = i1
expectedAvailableOnly[i2.Name+i2.Source] = i2
expectedAvailableOnly[i4.Name+i4.Source] = i4
expectedAvailableOnly[i5.Name+i5.Source] = i5
expectedAvailableOnly[i6.Name+i6.Source] = i6
opts.OnlyAvailableForInstall = true
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expectedAvailableOnly))}, meta)
compareResults(expectedAvailableOnly, sw, true)
opts.OnlyAvailableForInstall = false
// request in descending order
opts.ListOptions.OrderDirection = fleet.OrderDescending
opts.ListOptions.TestSecondaryOrderDirection = fleet.OrderDescending
opts.IncludeAvailableForInstall = false
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected)) - 6}, meta)
compareResults(expected, sw, false, i1.Name+i1.Source, i2.Name+i2.Source, i3.Name+i3.Source, i4.Name+i4.Source, i5.Name+i5.Source, i6.Name+i6.Source)
opts.ListOptions.OrderDirection = fleet.OrderAscending
opts.ListOptions.TestSecondaryOrderDirection = fleet.OrderAscending
// record a new install request for i1 (swi3), this time as pending, and mark install request for b (swi1) as failed
time.Sleep(time.Second) // ensure the timestamp is later
ds.testActivateSpecificNextActivities = []string{hostSwi1InstallUUID}
activated, err := ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), host.ID, "")
require.NoError(t, err)
require.Equal(t, ds.testActivateSpecificNextActivities, activated)
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: hostSwi1InstallUUID,
InstallScriptExitCode: ptr.Int(2),
}, nil)
require.NoError(t, err)
ds.testActivateSpecificNextActivities = []string{"-"}
// swi3 has a new install request pending
hostSwi3PendingInstallUUID, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, swi3Failed, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
expected[byNSV[b].Name+byNSV[b].Source] = fleet.HostSoftwareWithInstaller{
Name: "b",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallFailed),
SoftwarePackage: nil,
InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: byNSV[b].Version, Vulnerabilities: []string{vulns[3].CVE}, InstalledPaths: []string{installPaths[2]}},
},
}
expected[i1.Name+i1.Source] = fleet.HostSoftwareWithInstaller{
Name: "i1",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallPending),
SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-2.pkg", Version: "v2.0.0", Platform: "darwin", SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: hostSwi3PendingInstallUUID}},
}
expectedAvailableOnly[byNSV[b].Name+byNSV[b].Source] = expected[byNSV[b].Name+byNSV[b].Source]
expectedAvailableOnly[i1.Name+i1.Source] = expected[i1.Name+i1.Source]
// request without available software
opts.IncludeAvailableForInstall = false
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected)) - 6}, meta)
compareResults(expected, sw, true, i1.Name+i1.Source, i2.Name+i2.Source, i3.Name+i3.Source, i4.Name+i4.Source, i5.Name+i5.Source, i6.Name+i6.Source)
// request with available software
opts.IncludeAvailableForInstall = true
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected)) - 1}, meta)
compareResults(expected, sw, true, i3.Name+i3.Source)
// create a new host in the team, with no software
tmHost := test.NewHost(t, ds, "host3", "", "host3key", "host3uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, tmHost, false)
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{tmHost.ID}))
require.NoError(t, err)
tmHost.TeamID = &tm.ID
// no installed software for this host
opts.IncludeAvailableForInstall = false
sw, meta, err = ds.ListHostSoftware(ctx, tmHost, opts)
require.NoError(t, err)
require.Empty(t, sw)
require.Equal(t, &fleet.PaginationMetadata{}, meta)
// sees the available installer in its team
opts.IncludeAvailableForInstall = true
sw, meta, err = ds.ListHostSoftware(ctx, tmHost, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 1}, meta)
compareResults(map[string]fleet.HostSoftwareWithInstaller{
i3.Name + i3.Source: expected[i3.Name+i3.Source],
}, sw, true)
// add policies to software
policy1 := newTestPolicy(t, ds, user, "policy 1", "darwin", &tm.ID)
policy1.SoftwareInstallerID = ptr.Uint(swi5Tm)
err = ds.SavePolicy(context.Background(), policy1, false, false)
require.NoError(t, err)
policy2 := newTestPolicy(t, ds, user, "policy 2", "darwin", &tm.ID)
policy2.SoftwareInstallerID = ptr.Uint(swi5Tm)
err = ds.SavePolicy(context.Background(), policy2, false, false)
require.NoError(t, err)
sw, meta, err = ds.ListHostSoftware(ctx, tmHost, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 1}, meta)
expectedWithPolicies := map[string]fleet.HostSoftwareWithInstaller{
i3.Name + i3.Source: expected[i3.Name+i3.Source],
}
expectedWithPolicies[i3.Name+i3.Source].SoftwarePackage.AutomaticInstallPolicies = []fleet.AutomaticInstallPolicy{
{
ID: policy1.ID,
Name: policy1.Name,
Type: fleet.PolicyTypeDynamic,
},
{
ID: policy2.ID,
Name: policy2.Name,
Type: fleet.PolicyTypeDynamic,
},
}
compareResults(expectedWithPolicies, sw, true)
// test with a search query (searches on name), with and without available software
opts.ListOptions.MatchQuery = "a"
opts.IncludeAvailableForInstall = false
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
compareResults(map[string]fleet.HostSoftwareWithInstaller{
byNSV[a1].Name + byNSV[a1].Source: expected[byNSV[a1].Name+byNSV[a1].Source],
byNSV[a2].Name + byNSV[a2].Source: expected[byNSV[a2].Name+byNSV[a2].Source],
}, sw, true)
opts.IncludeAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
compareResults(map[string]fleet.HostSoftwareWithInstaller{
byNSV[a1].Name + byNSV[a1].Source: expected[byNSV[a1].Name+byNSV[a1].Source],
byNSV[a2].Name + byNSV[a2].Source: expected[byNSV[a2].Name+byNSV[a2].Source],
}, sw, true)
opts.ListOptions.MatchQuery = "zz"
opts.IncludeAvailableForInstall = false
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Empty(t, sw)
opts.IncludeAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Empty(t, sw)
// add VPP apps, one for both no team and team, and two for no-team only.
va1, err := ds.InsertVPPAppWithTeam(ctx,
&fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1",
BundleIdentifier: "com.app.vpp1",
}, nil)
require.NoError(t, err)
_, err = ds.InsertVPPAppWithTeam(ctx,
&fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IOSPlatform}}, Name: "vpp1",
BundleIdentifier: "com.app.vpp1",
}, nil)
require.NoError(t, err)
vatm1, err := ds.InsertVPPAppWithTeam(ctx,
&fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1",
BundleIdentifier: "com.app.vpp1",
}, &tm.ID)
require.NoError(t, err)
vpp1 := va1.AdamID
va2, err := ds.InsertVPPAppWithTeam(ctx,
&fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.MacOSPlatform}}, Name: "vpp2",
BundleIdentifier: "com.app.vpp2",
}, nil)
require.NoError(t, err)
// create vpp3 app that allows self-service
va3, err := ds.InsertVPPAppWithTeam(ctx,
&fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.MacOSPlatform}, SelfService: true}, Name: "vpp3",
BundleIdentifier: "com.app.vpp3",
}, nil)
require.NoError(t, err)
vpp2, vpp3 := va2.AdamID, va3.AdamID
// create an installation request for vpp1 and vpp2, leaving vpp3 as
// available only
vpp1CmdUUID := createVPPAppInstallRequest(t, ds, host, vpp1, user)
vpp2CmdUUID := createVPPAppInstallRequest(t, ds, host, vpp2, user)
// make vpp1 install a success, while vpp2 has its initial request as failed
// and a subsequent request as pending.
ds.testActivateSpecificNextActivities = []string{vpp1CmdUUID, vpp2CmdUUID}
activated, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), host.ID, "")
require.NoError(t, err)
require.Equal(t, ds.testActivateSpecificNextActivities, activated)
ds.testActivateSpecificNextActivities = []string{"-"}
createVPPAppInstallResult(t, ds, host, vpp1CmdUUID, fleet.MDMAppleStatusAcknowledged)
createVPPAppInstallResult(t, ds, host, vpp2CmdUUID, fleet.MDMAppleStatusError)
time.Sleep(time.Second) // ensure a different created_at timestamp
vpp2bCmdUUID := createVPPAppInstallRequest(t, ds, host, vpp2, user)
require.NotEmpty(t, vpp2bCmdUUID)
// add an install request for the team host on vpp1, should not impact
// main host
vpp1TmCmdUUID := createVPPAppInstallRequest(t, ds, tmHost, vpp1, user)
require.NotEmpty(t, vpp1TmCmdUUID)
// add policies to vpp app 1
va1FromDB, err := ds.GetVPPAppMetadataByAdamIDPlatformTeamID(ctx, vatm1.AdamID, vatm1.Platform, &tm.ID)
require.NoError(t, err)
policy3 := newTestPolicy(t, ds, user, "policy 3", "darwin", &tm.ID)
policy3.VPPAppsTeamsID = &va1FromDB.AppTeamID
err = ds.SavePolicy(context.Background(), policy3, false, false)
require.NoError(t, err)
policy4 := newTestPolicy(t, ds, user, "policy 4", "darwin", &tm.ID)
policy4.VPPAppsTeamsID = &va1FromDB.AppTeamID
err = ds.SavePolicy(context.Background(), policy4, false, false)
require.NoError(t, err)
opts.IncludeAvailableForInstall = false
opts.ListOptions.MatchQuery = ""
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected)) - 6}, meta)
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}},
}
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}},
}
expected["vpp3apps"] = fleet.HostSoftwareWithInstaller{
Name: "vpp3",
Source: "apps",
Status: nil,
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp3, Platform: "darwin", SelfService: ptr.Bool(true)},
}
expectedAvailableOnly["vpp1apps"] = expected["vpp1apps"]
expectedAvailableOnly["vpp2apps"] = expected["vpp2apps"]
expectedAvailableOnly["vpp3apps"] = expected["vpp3apps"]
opts.IncludeAvailableForInstall = true
opts.IsMDMEnrolled = true
opts.ListOptions.PerPage = 20
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected)) - 1}, meta)
compareResults(expected, sw, true, i3.Name+i3.Source) // i3 is for team
// Host is not MDM enrolled - we should not get "vpp3papps" because we can't install it.
opts.IsMDMEnrolled = false
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected)) - 2}, meta)
compareResults(expected, sw, true, "vpp3apps", i3.Name+i3.Source) // i3 is for team
// Available for install only
opts.OnlyAvailableForInstall = true
opts.IsMDMEnrolled = true
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expectedAvailableOnly))}, meta)
compareResults(expectedAvailableOnly, sw, true)
// Available for install only with host not MDM enrolled
// We should only exclude "vpp3apps", because it was not installed previously and we can't
// install it without MDM
opts.IsMDMEnrolled = false
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expectedAvailableOnly) - 1)}, meta)
compareResults(expectedAvailableOnly, sw, true, "vpp3apps")
opts.IsMDMEnrolled = false
opts.OnlyAvailableForInstall = false
// team host sees available i3 and pending vpp1
opts.IncludeAvailableForInstall = true
sw, meta, err = ds.ListHostSoftware(ctx, tmHost, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 2}, meta)
compareResults(map[string]fleet.HostSoftwareWithInstaller{
i3.Name + i3.Source: expected[i3.Name+i3.Source],
"vpp1apps": {
Name: "vpp1",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallPending),
AppStoreApp: &fleet.SoftwarePackageOrApp{
AppStoreID: vpp1,
Platform: "darwin",
SelfService: ptr.Bool(false),
LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp1TmCmdUUID},
AutomaticInstallPolicies: []fleet.AutomaticInstallPolicy{
{
ID: policy3.ID,
Name: policy3.Name,
Type: fleet.PolicyTypeDynamic,
},
{
ID: policy4.ID,
Name: policy4.Name,
Type: fleet.PolicyTypeDynamic,
},
},
},
},
}, sw, true)
// other host does not see available VPP apps because it is a linux host
opts.IncludeAvailableForInstall = true
sw, meta, err = ds.ListHostSoftware(ctx, otherHost, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 5}, meta)
expectedOther := map[string]fleet.HostSoftwareWithInstaller{
otherSoftware[0].Name + otherSoftware[0].Source: {Name: otherSoftware[0].Name, Source: otherSoftware[0].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: otherSoftware[0].Version},
}},
otherSoftware[1].Name + otherSoftware[1].Source: {Name: otherSoftware[1].Name, Source: otherSoftware[1].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: otherSoftware[1].Version},
}},
otherSoftware[2].Name + otherSoftware[2].Source: {Name: otherSoftware[2].Name, Source: otherSoftware[2].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: otherSoftware[2].Version, Vulnerabilities: []string{vulns[4].CVE}},
}},
"i1apps": {
Name: "i1",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallPending),
SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-2.pkg", Version: "v2.0.0", Platform: "darwin", SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: otherHostI1UUID}},
},
"i2apps": {
Name: "i2",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallPending),
SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-3.pkg", Version: "v3.0.0", Platform: "darwin", SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: otherHostI2UUID}},
},
}
compareResults(expectedOther, sw, true)
// i4 is "pending uninstall"
// force i4 to be returned by osquery
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
var i4Title struct {
ID uint `db:"id"`
Name string `db:"name"`
Source string `db:"source"`
Version string `db:"version"`
}
err := sqlx.GetContext(ctx, q, &i4Title, `
SELECT software_titles.id, software_titles.name, software_titles.source, software_installers.version
FROM software_installers
JOIN software_titles ON software_installers.title_id = software_titles.id
WHERE software_installers.id = ?`, swi6PendingUninstall)
if err != nil {
return err
}
res, err := q.ExecContext(ctx,
`INSERT INTO software (name, source, bundle_identifier, version, title_id, checksum) VALUES (?, ?, ?, ?, ?, UNHEX(MD5(?)))`,
i4Title.Name,
i4Title.Source,
"i4Title.com.example",
i4Title.Version,
i4Title.ID,
"i4-checksum",
)
if err != nil {
return err
}
softwareID, err := res.LastInsertId()
if err != nil {
return err
}
_, err = q.ExecContext(ctx, `INSERT INTO host_software (host_id, software_id) VALUES (?, ?)`,
host.ID, softwareID)
if err != nil {
return err
}
return nil
})
// test the pagination
cases := []struct {
name string
opts fleet.HostSoftwareTitleListOptions
wantNames []string
wantMeta *fleet.PaginationMetadata
}{
{
name: "No available for install software, page 0",
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 5}, IncludeAvailableForInstall: false},
wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name, byNSV[c1].Name, byNSV[d].Name},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 8},
},
{
name: "No available for install software, page 1",
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 5}, IncludeAvailableForInstall: false},
wantNames: []string{byNSV[e2].Name, i0.Name, i4.Name},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8},
},
{
name: "No available for install software, page 2",
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 2, PerPage: 5}, IncludeAvailableForInstall: false},
wantNames: []string{},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8},
},
{
name: "Include Available for install software with MDM on, page 0",
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 5}, IncludeAvailableForInstall: true, IsMDMEnrolled: true},
wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name, byNSV[c1].Name, byNSV[d].Name},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 15},
},
{
name: "Include Available for install software with MDM on, page 1",
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 5}, IncludeAvailableForInstall: true, IsMDMEnrolled: true},
wantNames: []string{byNSV[e2].Name, i0.Name, i1.Name, i2.Name, i4.Name},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 15},
},
{
name: "Include Available for install software with MDM on, page 2",
opts: fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{Page: 2, PerPage: 5},
IncludeAvailableForInstall: true,
IsMDMEnrolled: true,
},
wantNames: []string{i5.Name, i6.Name, "vpp1", "vpp2", "vpp3"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 15},
},
{
// Excludes vpp3 because it was never installed, and we can't install it with MDM off
name: "Include Available for install software with MDM off, page 2",
opts: fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{Page: 2, PerPage: 5},
IncludeAvailableForInstall: true,
},
wantNames: []string{i5.Name, i6.Name, "vpp1", "vpp2"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 14},
},
{
name: "Include Available for install software with MDM on, page 3",
opts: fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{Page: 3, PerPage: 5},
IncludeAvailableForInstall: true,
IsMDMEnrolled: true,
},
wantNames: []string{},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 15},
},
{
name: "Available for install and self-service only software with MDM on, page 0",
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 3}, IncludeAvailableForInstall: true, SelfServiceOnly: true, IsMDMEnrolled: true},
wantNames: []string{byNSV[b].Name, i0.Name, "vpp3"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 3},
},
{
name: "Available for install and self-service only software with MDM on, page 1",
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 3}, IncludeAvailableForInstall: true, SelfServiceOnly: true, IsMDMEnrolled: true},
wantNames: []string{},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 3},
},
{
name: "Available for install and self-service only software with MDM off, page 0",
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 3}, IncludeAvailableForInstall: true, SelfServiceOnly: true},
wantNames: []string{byNSV[b].Name, i0.Name},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 2},
},
{
name: "Only available for install software with MDM on, page 0",
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 0, PerPage: 4}, OnlyAvailableForInstall: true, IsMDMEnrolled: true},
wantNames: []string{byNSV[b].Name, "i0", "i1", "i2"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 10},
},
{
name: "Only available for install with MDM on, page 2",
opts: fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{Page: 2, PerPage: 4},
OnlyAvailableForInstall: true,
IsMDMEnrolled: true,
},
wantNames: []string{"vpp2", "vpp3"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 10},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
// always include metadata
c.opts.ListOptions.IncludeMetadata = true
c.opts.ListOptions.OrderKey = "name"
c.opts.ListOptions.TestSecondaryOrderKey = "source"
sw, meta, err := ds.ListHostSoftware(ctx, host, c.opts)
require.NoError(t, err)
names := make([]string, 0, len(sw))
for _, s := range sw {
names = append(names, s.Name)
}
assert.Equal(t, c.wantNames, names)
assert.Equal(t, c.wantMeta, meta)
})
}
darwinHost := test.NewHost(t, ds, "hostD", "", "hostDkey", "hostDuuid", time.Now(), test.WithPlatform("darwin"))
softwareAlreadyInstalled := fleet.Software{Name: "DummyApp.app", Version: "1.0.1", Source: "apps", BundleIdentifier: "com.example.dummy"}
// Host has software installed, but not by Fleet, and there is no matching software installer.
// Ensure it is not surfaced as "available for install" when filtering by `VulnerableOnly`
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
res, err := q.ExecContext(ctx, `INSERT INTO software_titles (name, source, bundle_identifier) VALUES (?, ?, ?)`,
softwareAlreadyInstalled.Name, softwareAlreadyInstalled.Source, softwareAlreadyInstalled.BundleIdentifier)
if err != nil {
return err
}
titleID, err := res.LastInsertId()
if err != nil {
return err
}
titleIDUint := uint(titleID)
softwareAlreadyInstalled.TitleID = &titleIDUint
res, err = q.ExecContext(ctx, `INSERT INTO software (name, source, bundle_identifier, version, title_id, checksum) VALUES (?, ?, ?, ?, ?, UNHEX(MD5(?)))`,
softwareAlreadyInstalled.Name, softwareAlreadyInstalled.Source, softwareAlreadyInstalled.BundleIdentifier, softwareAlreadyInstalled.Version, titleID, "dummy-checksum")
if err != nil {
return err
}
softwareID, err := res.LastInsertId()
if err != nil {
return err
}
softwareAlreadyInstalled.ID = uint(softwareID)
_, err = q.ExecContext(ctx, `INSERT INTO host_software (host_id, software_id) VALUES (?, ?)`,
darwinHost.ID, softwareID)
if err != nil {
return err
}
return nil
})
opts = fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 11, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"}}
opts.OnlyAvailableForInstall = true
opts.VulnerableOnly = true
sw, _, err = ds.ListHostSoftware(ctx, darwinHost, opts)
require.NoError(t, err)
assert.Len(t, sw, 0, "Expected to find no software in the list")
// Now add a vulnerability to DummyApp.pkg, it should still not come back because we are filtering for "available for install"
_, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{SoftwareID: softwareAlreadyInstalled.ID, CVE: "CVE-2025-10101"}, fleet.NVDSource)
require.NoError(t, err)
opts = fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 11, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"}}
opts.OnlyAvailableForInstall = true
opts.VulnerableOnly = true
sw, _, err = ds.ListHostSoftware(ctx, darwinHost, opts)
require.NoError(t, err)
assert.Len(t, sw, 0, "Expected to find no software in the list")
// Add a matching software installer
// Ensure it is surfaced as "available for install"
var SoftwareInstallerID uint
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
installScript := `install 'DummyApp.app'`
res, err := q.ExecContext(ctx, `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(md5(?)), ?)`, installScript, installScript)
if err != nil {
return err
}
scriptContentID, _ := res.LastInsertId()
uninstallScript := `uinstall 'DummyApp.app'`
resUninstall, err := q.ExecContext(ctx, `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(md5(?)), ?)`,
uninstallScript, uninstallScript)
if err != nil {
return err
}
uninstallScriptContentID, _ := resUninstall.LastInsertId()
res, err = q.ExecContext(ctx, `
INSERT INTO software_installers
(
team_id,
global_or_team_id,
title_id,
filename,
extension,
version,
install_script_content_id,
uninstall_script_content_id,
storage_id,
platform,
self_service,
package_ids,
is_active,
patch_query
)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, TRUE, ?)`,
darwinHost.TeamID, 0, softwareAlreadyInstalled.TitleID, "DummyApp.pkg", "pkg", "2.0.0",
scriptContentID, uninstallScriptContentID,
[]byte("test"), "darwin", true, "[]", "")
if err != nil {
return err
}
lastInsertID, err := res.LastInsertId()
if err != nil {
return err
}
SoftwareInstallerID = uint(lastInsertID)
return nil
})
require.NoError(t, err)
opts = fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 11, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"}}
opts.OnlyAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, darwinHost, opts)
require.NoError(t, err)
var found bool
index := -1
for i, s := range sw {
if s.Name == softwareAlreadyInstalled.Name && s.Source == softwareAlreadyInstalled.Source {
found = true
index = i
break
}
}
require.True(t, found, "Expected to find software %s in the list", softwareAlreadyInstalled.Name)
assert.Equal(t, sw[index].InstalledVersions[0].Version, softwareAlreadyInstalled.Version)
assert.Equal(t, sw[index].SoftwarePackage.Name, "DummyApp.pkg")
assert.Equal(t, sw[index].SoftwarePackage.Version, "2.0.0")
assert.Equal(t, sw[index].SoftwarePackage.Platform, "darwin")
// Now with matching software installer, if filtering by `VulnerableOnly` we should get the software, as it has a vulnerability
opts = fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 11, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"}}
opts.OnlyAvailableForInstall = true
opts.VulnerableOnly = true
sw, _, err = ds.ListHostSoftware(ctx, darwinHost, opts)
require.NoError(t, err)
found = false
index = -1
for i, s := range sw {
if s.Name == softwareAlreadyInstalled.Name && s.Source == softwareAlreadyInstalled.Source {
found = true
index = i
break
}
}
require.True(t, found, "Expected to find software %s in the list", softwareAlreadyInstalled.Name)
assert.Equal(t, sw[index].InstalledVersions[0].Version, softwareAlreadyInstalled.Version)
// This vulnerable software is not installed on the host, however, has a software installer available for install, should not be returned
opts = fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 11, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"}}
opts.OnlyAvailableForInstall = true
opts.VulnerableOnly = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
found = false
for _, s := range sw {
if s.Name == softwareAlreadyInstalled.Name && s.Source == softwareAlreadyInstalled.Source {
found = true
break
}
}
require.False(t, found, "Expected not find software %s in the list", softwareAlreadyInstalled.Name)
// Attempt to install the vulnerable software on the host (pending), if filtering by `VulnerableOnly`, should not be returned
_, err = ds.InsertSoftwareInstallRequest(ctx, host.ID, SoftwareInstallerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
opts = fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 11, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"}}
opts.OnlyAvailableForInstall = true
opts.VulnerableOnly = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
found = false
for _, s := range sw {
if s.Name == softwareAlreadyInstalled.Name && s.Source == softwareAlreadyInstalled.Source {
found = true
break
}
}
require.False(t, found, "Expected not find software %s in the list", softwareAlreadyInstalled.Name)
}
func testListLinuxHostSoftware(t *testing.T, ds *Datastore) {
ctx := context.Background()
rpmHost := test.NewHost(t, ds, "RHEL", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("rhel"))
debHost := test.NewHost(t, ds, "Ubuntu", "", "host2key", "host2uuid", time.Now(), test.WithPlatform("pop"))
archHost := test.NewHost(t, ds, "Arch", "", "host3key", "host3uuid", time.Now(), test.WithPlatform("arch"))
genericLinuxHost := test.NewHost(t, ds, "Vanilla Linux", "", "host4key", "host4uuid", time.Now(), test.WithPlatform("linux"))
macHost := test.NewHost(t, ds, "Mac", "", "host5key", "host5uuid", time.Now(), test.WithPlatform("darwin"))
// Add titles and installers for deb (visible on deb host), rpm (visible on rpm host), tarball (visible on non-Mac hosts)
type installerInfo struct {
Filename string
Extension string
}
installers := map[string]installerInfo{
"deb_packages": {Filename: "deb.deb", Extension: "deb"},
"rpm_packages": {Filename: "rpm.rpm", Extension: "rpm"},
"tgz_packages": {Filename: "tar.tar.gz", Extension: "tar.gz"},
}
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
script := `hello world`
res, err := q.ExecContext(ctx, `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(md5(?)), ?)`, script, script)
if err != nil {
return err
}
scriptContentID, _ := res.LastInsertId()
for source, installer := range installers {
res, err = q.ExecContext(ctx, `INSERT INTO software_titles (name, source) VALUES (?, ?)`, source, source)
require.NoError(t, err)
titleID, _ := res.LastInsertId()
_, err = q.ExecContext(ctx, `
INSERT INTO software_installers
(
team_id,
global_or_team_id,
title_id,
filename,
extension,
version,
install_script_content_id,
uninstall_script_content_id,
storage_id,
platform,
self_service,
package_ids,
is_active,
patch_query
)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, TRUE, ?)`,
nil, 0, titleID, installer.Filename, installer.Extension, "2.0.0",
scriptContentID, scriptContentID,
[]byte("test"), "linux", true, "[]", "")
require.NoError(t, err)
}
opts := fleet.HostSoftwareTitleListOptions{OnlyAvailableForInstall: true, ListOptions: fleet.ListOptions{OrderKey: "name"}}
for host, expectedInstallers := range map[*fleet.Host][]installerInfo{
rpmHost: {installers["rpm_packages"], installers["tgz_packages"]},
debHost: {installers["deb_packages"], installers["tgz_packages"]},
archHost: {installers["tgz_packages"]},
genericLinuxHost: {installers["deb_packages"], installers["rpm_packages"], installers["tgz_packages"]},
macHost: {},
} {
t.Run(host.Hostname, func(t *testing.T) {
sw, _, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, len(expectedInstallers))
for i, installer := range expectedInstallers {
require.Equal(t, installer.Filename, sw[i].SoftwarePackage.Name)
}
})
}
return nil
})
}
func testListIOSHostSoftware(t *testing.T, ds *Datastore) {
ctx := context.Background()
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("ios"))
nanoEnroll(t, ds, host, false)
opts := fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{
PerPage: 10, IncludeMetadata: true, OrderKey: "name",
TestSecondaryOrderKey: "source",
}}
test.CreateInsertGlobalVPPToken(t, ds)
user, err := ds.NewUser(ctx, &fleet.User{
Password: []byte("p4ssw0rd.123"),
Name: "userIOS",
Email: "userIOS@example.com",
GlobalRole: ptr.String(fleet.RoleAdmin),
})
require.NoError(t, err)
expectStatus := func(s fleet.SoftwareInstallerStatus) *fleet.SoftwareInstallerStatus {
return &s
}
// no software yet
sw, meta, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Empty(t, sw)
assert.Equal(t, &fleet.PaginationMetadata{}, meta)
// add software to the host
software := []fleet.Software{
{Name: "a", Version: "0.0.1", Source: "ios_apps"},
{Name: "b", Version: "0.0.2", Source: "ios_apps"},
{Name: "c", Version: "0.0.3", Source: "ios_apps"},
{Name: "c", Version: "0.0.4", Source: "ios_apps"},
}
byNSV := map[string]fleet.Software{}
for _, s := range software {
byNSV[s.Name+s.Source+s.Version] = s
}
mutationResults, err := ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
assert.Len(t, mutationResults.Inserted, len(software))
for _, m := range mutationResults.Inserted {
s, ok := byNSV[m.Name+m.Source+m.Version]
assert.True(t, ok)
assert.Equal(t, m.Name, s.Name, "name")
assert.Equal(t, m.Version, s.Version, "version")
assert.Equal(t, m.Source, s.Source, "source")
assert.Zero(t, s.ID) // not set in the map yet
assert.NotZero(t, m.ID)
s.ID = m.ID
byNSV[s.Name+s.Source+s.Version] = s
}
assert.NoError(t, ds.LoadHostSoftware(ctx, host, false))
assert.Equal(t, len(host.Software), len(software))
for _, hs := range host.Software {
s, ok := byNSV[hs.Name+hs.Source+hs.Version]
assert.True(t, ok)
assert.Equal(t, hs.Name, s.Name, "name")
assert.Equal(t, hs.Version, s.Version, "version")
assert.Equal(t, hs.Source, s.Source, "source")
assert.Equal(t, hs.ID, s.ID)
}
// shorthand keys for expected software
getKey := func(i int) string {
return software[i].Name + software[i].Source + software[i].Version
}
a1 := getKey(0)
b := getKey(1)
c1 := getKey(2)
c2 := getKey(3)
// add some vulnerabilities
vulns := []fleet.SoftwareVulnerability{
{SoftwareID: byNSV[a1].ID, CVE: "CVE-a-0001"},
{SoftwareID: byNSV[a1].ID, CVE: "CVE-a-0002"},
{SoftwareID: byNSV[a1].ID, CVE: "CVE-a-0003"},
{SoftwareID: byNSV[b].ID, CVE: "CVE-b-0001"},
}
for _, v := range vulns {
_, err = ds.InsertSoftwareVulnerability(ctx, v, fleet.NVDSource)
require.NoError(t, err)
}
expected := map[string]fleet.HostSoftwareWithInstaller{
byNSV[a1].Name + byNSV[a1].Source: {
Name: byNSV[a1].Name, Source: byNSV[a1].Source,
InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: byNSV[a1].Version, Vulnerabilities: []string{vulns[0].CVE, vulns[1].CVE, vulns[2].CVE}},
},
},
byNSV[b].Name + byNSV[b].Source: {
Name: byNSV[b].Name, Source: byNSV[b].Source,
InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: byNSV[b].Version, Vulnerabilities: []string{vulns[3].CVE}},
},
},
// c1 and c2 are the same software title because they have the same name and source
byNSV[c1].Name + byNSV[c1].Source: {
Name: byNSV[c1].Name, Source: byNSV[c1].Source,
InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: byNSV[c1].Version},
{Version: byNSV[c2].Version},
},
},
}
compareResults := func(expected map[string]fleet.HostSoftwareWithInstaller, got []*fleet.HostSoftwareWithInstaller, expectAsc bool,
expectOmitted ...string,
) {
require.Len(t, got, len(expected)-len(expectOmitted))
prev := ""
for _, g := range got {
e, ok := expected[g.Name+g.Source]
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)
if e.SoftwarePackage != nil {
require.Equal(t, e.SoftwarePackage.SelfService, g.SoftwarePackage.SelfService)
require.Equal(t, e.SoftwarePackage.AppStoreID, g.SoftwarePackage.AppStoreID)
require.Equal(t, e.SoftwarePackage.Name, g.SoftwarePackage.Name)
require.Equal(t, e.SoftwarePackage.Version, g.SoftwarePackage.Version)
if e.SoftwarePackage.LastInstall != nil {
require.Equal(t, e.SoftwarePackage.LastInstall.CommandUUID, g.SoftwarePackage.LastInstall.CommandUUID)
require.Equal(t, e.SoftwarePackage.LastInstall.InstallUUID, g.SoftwarePackage.LastInstall.InstallUUID)
require.NotNil(t, g.SoftwarePackage.LastInstall.InstalledAt)
}
}
if e.AppStoreApp != nil {
require.Equal(t, e.AppStoreApp.SelfService, g.AppStoreApp.SelfService)
require.Equal(t, e.AppStoreApp.AppStoreID, g.AppStoreApp.AppStoreID)
require.Equal(t, e.AppStoreApp.Name, g.AppStoreApp.Name)
require.Equal(t, e.AppStoreApp.Version, g.AppStoreApp.Version)
require.Equal(t, e.AppStoreApp.Platform, g.AppStoreApp.Platform)
if e.AppStoreApp.LastInstall != nil {
require.Equal(t, e.AppStoreApp.LastInstall.InstallUUID, g.AppStoreApp.LastInstall.InstallUUID)
require.Equal(t, e.AppStoreApp.LastInstall.CommandUUID, g.AppStoreApp.LastInstall.CommandUUID)
require.NotNil(t, g.AppStoreApp.LastInstall.InstalledAt)
}
}
require.Len(t, g.InstalledVersions, len(e.InstalledVersions))
if len(e.InstalledVersions) > 0 {
byVers := make(map[string]fleet.HostSoftwareInstalledVersion, len(e.InstalledVersions))
for _, v := range e.InstalledVersions {
byVers[v.Version] = *v
}
for _, v := range g.InstalledVersions {
ev, ok := byVers[v.Version]
require.True(t, ok)
require.Equal(t, ev.Version, v.Version)
require.ElementsMatch(t, ev.InstalledPaths, v.InstalledPaths)
require.ElementsMatch(t, ev.Vulnerabilities, v.Vulnerabilities)
}
}
if prev != "" {
if expectAsc {
require.Greater(t, g.Name+g.Source, prev)
} else {
require.Less(t, g.Name+g.Source, prev)
}
}
prev = g.Name + g.Source
}
}
// it now returns the software with vulnerabilities and installed paths
opts.SelfServiceOnly = false
opts.IncludeAvailableForInstall = false
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected))}, meta)
compareResults(expected, sw, true)
opts.VulnerableOnly = true
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected) - 1)}, meta)
compareResults(expected, sw, true, byNSV[c1].Name+byNSV[c1].Source)
opts.VulnerableOnly = false
// No software that is available for install
opts.OnlyAvailableForInstall = true
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Empty(t, sw)
assert.Equal(t, &fleet.PaginationMetadata{}, meta)
opts.OnlyAvailableForInstall = false
// Create a team
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "mobile team"})
require.NoError(t, err)
// add VPP apps, one for both no team and team, and three for no-team only.
va1, err := ds.InsertVPPAppWithTeam(ctx,
&fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IOSPlatform}}, Name: "vpp1",
BundleIdentifier: "com.app.vpp1",
}, nil)
require.NoError(t, err)
_, err = ds.InsertVPPAppWithTeam(ctx,
&fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1",
BundleIdentifier: "com.app.vpp1",
}, nil)
require.NoError(t, err)
_, err = ds.InsertVPPAppWithTeam(ctx,
&fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IPadOSPlatform}}, Name: "vpp1",
BundleIdentifier: "com.app.vpp1",
}, nil)
require.NoError(t, err)
_, err = ds.InsertVPPAppWithTeam(ctx,
&fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IPadOSPlatform}}, Name: "vpp1",
BundleIdentifier: "com.app.vpp1",
}, &tm.ID)
require.NoError(t, err)
vpp1 := va1.AdamID
va2, err := ds.InsertVPPAppWithTeam(ctx,
&fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.IOSPlatform}}, Name: "vpp2",
BundleIdentifier: "com.app.vpp2",
}, nil)
require.NoError(t, err)
va3, err := ds.InsertVPPAppWithTeam(ctx,
&fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.IOSPlatform}}, Name: "vpp3",
BundleIdentifier: "com.app.vpp3",
}, nil)
require.NoError(t, err)
va4, err := ds.InsertVPPAppWithTeam(ctx,
&fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_4", Platform: fleet.IOSPlatform}}, Name: "vpp4",
BundleIdentifier: "com.app.vpp4",
}, nil)
require.NoError(t, err)
vpp2, vpp3, vpp4 := va2.AdamID, va3.AdamID, va4.AdamID
// create an installation request for vpp1 and vpp2, leaving vpp3 and vpp4 as
// available only
vpp1CmdUUID := createVPPAppInstallRequest(t, ds, host, vpp1, user)
vpp2CmdUUID := createVPPAppInstallRequest(t, ds, host, vpp2, user)
// make vpp1 install a success, while vpp2 has its initial request as failed
// and a subsequent request as pending.
createVPPAppInstallResult(t, ds, host, vpp1CmdUUID, fleet.MDMAppleStatusAcknowledged)
createVPPAppInstallResult(t, ds, host, vpp2CmdUUID, fleet.MDMAppleStatusError)
time.Sleep(time.Second) // ensure a different created_at timestamp
vpp2bCmdUUID := createVPPAppInstallRequest(t, ds, host, vpp2, user)
require.NotEmpty(t, vpp2bCmdUUID)
opts.IncludeAvailableForInstall = false
opts.ListOptions.MatchQuery = ""
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected))}, meta)
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}},
}
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}},
}
expected["vpp3ios_apps"] = fleet.HostSoftwareWithInstaller{
Name: "vpp3",
Source: "ios_apps",
Status: nil,
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp3, Platform: "ios", SelfService: ptr.Bool(false)},
}
expected["vpp4ios_apps"] = fleet.HostSoftwareWithInstaller{
Name: "vpp4",
Source: "ios_apps",
Status: nil,
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp4, Platform: "ios", SelfService: ptr.Bool(false)},
}
expectedAvailableOnly := map[string]fleet.HostSoftwareWithInstaller{}
expectedAvailableOnly["vpp1ios_apps"] = expected["vpp1ios_apps"]
expectedAvailableOnly["vpp2ios_apps"] = expected["vpp2ios_apps"]
expectedAvailableOnly["vpp3ios_apps"] = expected["vpp3ios_apps"]
expectedAvailableOnly["vpp4ios_apps"] = expected["vpp4ios_apps"]
opts.IncludeAvailableForInstall = true
opts.IsMDMEnrolled = true
opts.ListOptions.PerPage = 20
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected))}, meta)
compareResults(expected, sw, true)
// Available for install only
opts.OnlyAvailableForInstall = true
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expectedAvailableOnly))}, meta)
compareResults(expectedAvailableOnly, sw, true)
opts.OnlyAvailableForInstall = false
}
func testListHostSoftwareWithVPPApps(t *testing.T, ds *Datastore) {
ctx := context.Background()
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
nanoEnroll(t, ds, host, false)
user := test.NewUser(t, ds, "Alice", "alice@example.com", true)
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{host.ID}))
require.NoError(t, err)
host.TeamID = &tm.ID
numberOfApps := 5
// create a second host and add it to the team
anotherHost := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now())
nanoEnroll(t, ds, anotherHost, false)
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{anotherHost.ID}))
require.NoError(t, err)
anotherHost.TeamID = &tm.ID
software := []fleet.Software{}
for i := 0; i < numberOfApps; i++ {
software = append(software, fleet.Software{
Name: fmt.Sprintf("z%d", i),
Version: fmt.Sprintf("0.0.%d", i),
Source: "apps",
BundleIdentifier: fmt.Sprintf("com.example.%d", i),
})
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Test org"+t.Name(), "Test location"+t.Name())
require.NoError(t, err)
tok1, err := ds.InsertVPPToken(ctx, dataToken)
require.NoError(t, err)
_, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{})
require.NoError(t, err)
time.Sleep(time.Second)
vPPApp := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}},
Name: "vpp1",
BundleIdentifier: "com.app.vpp1",
LatestVersion: "1.0.0",
}
va1, err := ds.InsertVPPAppWithTeam(ctx, vPPApp, &tm.ID)
require.NoError(t, err)
vpp1 := va1.AdamID
// vpp1 is not installed yet, but host is mdm enrolled so it should show up
sw, _, err := ds.ListHostSoftware(
ctx,
anotherHost,
fleet.HostSoftwareTitleListOptions{
OnlyAvailableForInstall: true,
IsMDMEnrolled: true,
ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"},
},
)
require.NoError(t, err)
assert.Len(t, sw, 1)
assert.Equal(t, vPPApp.Name, sw[0].Name)
// vpp1 is not installed yet, but host is not mdm enrolled so it should not show up
sw, _, err = ds.ListHostSoftware(
ctx,
anotherHost,
fleet.HostSoftwareTitleListOptions{
OnlyAvailableForInstall: true,
IsMDMEnrolled: false,
ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"},
},
)
require.NoError(t, err)
assert.Len(t, sw, 0)
vpp1CmdUUID := createVPPAppInstallRequest(t, ds, host, vpp1, user)
_, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), host.ID, "")
require.NoError(t, err)
createVPPAppInstallResult(t, ds, host, vpp1CmdUUID, fleet.MDMAppleStatusAcknowledged)
// Insert software entry for vpp app
res, err := ds.writer(ctx).ExecContext(ctx, `
INSERT INTO software (name, version, source, bundle_identifier, title_id, checksum)
VALUES (?, ?, ?, ?, ?, ?)
`,
vPPApp.Name, "1.2.3", "apps", vPPApp.BundleIdentifier, vPPApp.TitleID, hex.EncodeToString([]byte("vpp1")),
)
require.NoError(t, err)
time.Sleep(time.Second)
softwareID, err := res.LastInsertId()
require.NoError(t, err)
_, err = ds.writer(ctx).ExecContext(ctx, `
INSERT INTO host_software (host_id, software_id)
VALUES (?, ?)
`, host.ID, softwareID)
require.NoError(t, err)
hostLibraryOpts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{PerPage: 20},
SelfServiceOnly: false,
IncludeAvailableForInstall: true,
OnlyAvailableForInstall: true,
IsMDMEnrolled: true,
}
// vpp1 installed and inventoried by osquery
// ensure we don't lose install data from host_vpp_software_installs
sw, _, err = ds.ListHostSoftware(ctx, host, hostLibraryOpts)
require.NoError(t, err)
assert.Len(t, sw, 1)
assert.NotNil(t, sw[0].InstalledVersions)
assert.NotNil(t, sw[0].AppStoreApp.LastInstall)
assert.Equal(t, vpp1CmdUUID, sw[0].AppStoreApp.LastInstall.CommandUUID)
assert.NotNil(t, sw[0].AppStoreApp.LastInstall.InstalledAt)
opts := fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: uint(numberOfApps - 1), IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"}}
sw, meta, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Len(t, sw, numberOfApps-1)
assert.Equal(t, numberOfApps+1, int(meta.TotalResults))
assert.True(t, meta.HasNextResults)
// The vpp app returned should have an installed version as per the sql above.
// However this is a special case because we are not
// passing one of the IncludeAvailableForInstall or OnlyAvailableForInstall flags.
// Passing one of those flags would typically add the installed version (from host_software/software) to the list
// of InstalledVersions. But we need to make a special case for this when neither of those flags are set.
assert.Len(t, sw[0].InstalledVersions, 1)
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)
// 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
opts.IncludeAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Len(t, sw[0].InstalledVersions, 1)
assert.Equal(t, "1.2.3", sw[0].InstalledVersions[0].Version)
// have the second host install a vpp app, but not by fleet
res, err = ds.writer(ctx).ExecContext(ctx, `
INSERT INTO software (name, version, source, bundle_identifier, title_id, checksum)
VALUES (?, ?, ?, ?, ?, ?)
`,
vPPApp.Name, "0.1.0", "apps", vPPApp.BundleIdentifier, vPPApp.TitleID, []byte("vpp1v0.1.0"),
)
require.NoError(t, err)
time.Sleep(time.Second)
softwareID, err = res.LastInsertId()
require.NoError(t, err)
_, err = ds.writer(ctx).ExecContext(ctx, `
INSERT INTO host_software (host_id, software_id)
VALUES (?, ?)
`, anotherHost.ID, softwareID)
require.NoError(t, err)
// when filtering by available for install ensure
// that the pre-installed app store app that has a match vpp app is returned
opts = fleet.HostSoftwareTitleListOptions{OnlyAvailableForInstall: true, ListOptions: fleet.ListOptions{PerPage: uint(numberOfApps - 1), IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"}}
sw, _, err = ds.ListHostSoftware(ctx, anotherHost, opts)
require.NoError(t, err)
assert.Len(t, sw, 1)
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.Nil(t, sw[0].Status)
// insert an icon
icon, err := ds.CreateOrUpdateSoftwareTitleIcon(ctx, &fleet.UploadSoftwareTitleIconPayload{
TeamID: tm.ID,
TitleID: va1.TitleID,
StorageID: "storage-id-1",
Filename: "test-icon.png",
})
require.NoError(t, err)
opts = fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{Page: 0, PerPage: 20},
SelfServiceOnly: false,
IncludeAvailableForInstall: true,
OnlyAvailableForInstall: true,
IsMDMEnrolled: true,
}
sw, _, err = ds.ListHostSoftware(ctx, anotherHost, opts)
require.NoError(t, err)
assert.Len(t, sw, 1)
assert.Equal(t, icon.IconUrl(), *sw[0].IconUrl)
}
func testListHostSoftwareVPPSelfService(t *testing.T, ds *Datastore) {
ctx := context.Background()
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
nanoEnroll(t, ds, host, false)
user := test.NewUser(t, ds, "Alice", "alice@example.com", true)
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{host.ID}))
require.NoError(t, err)
host.TeamID = &tm.ID
opts := fleet.HostSoftwareTitleListOptions{
SelfServiceOnly: true,
IsMDMEnrolled: true,
IncludeAvailableForInstall: true,
OnlyAvailableForInstall: false,
VulnerableOnly: false,
KnownExploit: false,
ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"},
}
// setup vpp
dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Test org"+t.Name(), "Test location"+t.Name())
require.NoError(t, err)
tok1, err := ds.InsertVPPToken(ctx, dataToken)
require.NoError(t, err)
_, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{})
require.NoError(t, err)
time.Sleep(time.Second)
vPPApp := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{SelfService: true, VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}},
Name: "vpp1",
BundleIdentifier: "com.app.vpp1",
LatestVersion: "1.0.0",
}
va1, err := ds.InsertVPPAppWithTeam(ctx, vPPApp, &tm.ID)
require.NoError(t, err)
vpp1 := va1.AdamID
// vpp1 is self service, not installed yet, so it should show as available for install
sw, _, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Len(t, sw, 1)
vpp1CmdUUID := createVPPAppInstallRequest(t, ds, host, vpp1, user)
_, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), host.ID, "")
require.NoError(t, err)
vPPApp2 := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{SelfService: true, VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.MacOSPlatform}},
Name: "vpp2",
BundleIdentifier: "com.app.vpp2",
LatestVersion: "1.0.1",
}
_, err = ds.InsertVPPAppWithTeam(ctx, vPPApp2, &tm.ID)
require.NoError(t, err)
res, err := ds.writer(ctx).ExecContext(ctx, `
INSERT INTO software (name, version, source, bundle_identifier, title_id, checksum)
VALUES (?, ?, ?, ?, ?, ?)
`,
vPPApp2.Name, "0.5.0", "apps", vPPApp2.BundleIdentifier, vPPApp2.TitleID, hex.EncodeToString([]byte("vpp2")),
)
require.NoError(t, err)
time.Sleep(time.Second)
softwareID, err := res.LastInsertId()
require.NoError(t, err)
_, err = ds.writer(ctx).ExecContext(ctx, `
INSERT INTO host_software (host_id, software_id)
VALUES (?, ?)
`, host.ID, softwareID)
require.NoError(t, err)
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Len(t, sw, 2)
// pending install
assert.NotNil(t, sw[0].AppStoreApp)
assert.Equal(t, "1.0.0", sw[0].AppStoreApp.Version)
assert.Equal(t, fleet.SoftwareInstallerStatus("pending_install"), *sw[0].Status)
assert.NotNil(t, sw[0].AppStoreApp.LastInstall)
assert.Nil(t, sw[0].InstalledVersions)
// installed but not by fleet, so status is nil
assert.NotNil(t, sw[1].AppStoreApp)
assert.Equal(t, "1.0.1", sw[1].AppStoreApp.Version)
assert.Nil(t, sw[1].Status)
assert.Nil(t, sw[1].AppStoreApp.LastInstall)
assert.NotNil(t, sw[1].InstalledVersions)
assert.Equal(t, "0.5.0", sw[1].InstalledVersions[0].Version)
createVPPAppInstallResult(t, ds, host, vpp1CmdUUID, fleet.MDMAppleStatusAcknowledged)
// Insert software entry for vpp app
res, err = ds.writer(ctx).ExecContext(ctx, `
INSERT INTO software (name, version, source, bundle_identifier, title_id, checksum)
VALUES (?, ?, ?, ?, ?, ?)
`,
vPPApp.Name, "1.2.3", "apps", vPPApp.BundleIdentifier, vPPApp.TitleID, hex.EncodeToString([]byte("vpp1")),
)
require.NoError(t, err)
time.Sleep(time.Second)
softwareID, err = res.LastInsertId()
require.NoError(t, err)
_, err = ds.writer(ctx).ExecContext(ctx, `
INSERT INTO host_software (host_id, software_id)
VALUES (?, ?)
`, host.ID, softwareID)
require.NoError(t, err)
// vpp apps should not be returned in self service
// if the host is not mdm enrolled
opts.IsMDMEnrolled = false
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Len(t, sw, 0)
}
// testListHostSoftwareVPPSelfServiceTeamFilter tests that when a VPP app exists
// in both global and a team with different self_service values, the correct
// (team-specific) self_service value is returned for hosts in that team.
func testListHostSoftwareVPPSelfServiceTeamFilter(t *testing.T, ds *Datastore) {
ctx := context.Background()
// Create a team
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "team_vpp_filter_" + t.Name()})
require.NoError(t, err)
// Create iOS host and assign to team
host := test.NewHost(t, ds, "host_vpp_filter", "", "host_vpp_filter_key", "host_vpp_filter_uuid", time.Now(), test.WithPlatform("ios"))
nanoEnroll(t, ds, host, false)
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{host.ID}))
require.NoError(t, err)
host.TeamID = &tm.ID
user := test.NewUser(t, ds, "VppFilter", "vppfilter@example.com", true)
// Setup VPP token
dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Test org"+t.Name(), "Test location"+t.Name())
require.NoError(t, err)
tok1, err := ds.InsertVPPToken(ctx, dataToken)
require.NoError(t, err)
_, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{})
require.NoError(t, err)
time.Sleep(time.Second)
// Create VPP app with self_service=true for the team
vppApp := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{SelfService: true, VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_filter_" + t.Name(), Platform: fleet.IOSPlatform}},
Name: "vpp_filter_app",
BundleIdentifier: "com.app.vppfilter",
LatestVersion: "1.0.0",
}
va, err := ds.InsertVPPAppWithTeam(ctx, vppApp, &tm.ID)
require.NoError(t, err)
// Also add the same app globally with self_service=false
// This simulates the bug scenario where multiple vpp_apps_teams entries exist
_, err = ds.writer(ctx).ExecContext(ctx, `
INSERT INTO vpp_apps_teams (adam_id, platform, global_or_team_id, self_service)
VALUES (?, ?, 0, 0)
ON DUPLICATE KEY UPDATE self_service = 0
`, vppApp.AdamID, vppApp.Platform)
require.NoError(t, err)
// Install the VPP app on the host (via host_vpp_software_installs)
cmdUUID := createVPPAppInstallRequest(t, ds, host, va.AdamID, user)
_, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), host.ID, "")
require.NoError(t, err)
createVPPAppInstallResult(t, ds, host, cmdUUID, fleet.MDMAppleStatusAcknowledged)
// Insert software entry for the installed VPP app
res, err := ds.writer(ctx).ExecContext(ctx, `
INSERT INTO software (name, version, source, bundle_identifier, title_id, checksum)
VALUES (?, ?, ?, ?, ?, UNHEX(MD5(?)))
`, vppApp.Name, "1.0.0", "ios_apps", vppApp.BundleIdentifier, va.TitleID, "vppfilter_checksum_input")
require.NoError(t, err)
time.Sleep(time.Second)
softwareID, err := res.LastInsertId()
require.NoError(t, err)
_, err = ds.writer(ctx).ExecContext(ctx, `
INSERT INTO host_software (host_id, software_id)
VALUES (?, ?)
`, host.ID, softwareID)
require.NoError(t, err)
// Query with self-service filter - the team's self_service=true should be used
opts := fleet.HostSoftwareTitleListOptions{
SelfServiceOnly: true,
IsMDMEnrolled: true,
IncludeAvailableForInstall: true,
ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"},
}
sw, _, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
// Should return the VPP app because team's self_service=true
// If the bug existed (no team filter), we might get duplicate rows or wrong self_service value
require.Len(t, sw, 1, "expected 1 self-service VPP app for team host")
assert.Equal(t, vppApp.Name, sw[0].Name)
assert.NotNil(t, sw[0].AppStoreApp)
require.NotNil(t, sw[0].AppStoreApp.SelfService, "expected SelfService to be set")
assert.True(t, *sw[0].AppStoreApp.SelfService, "expected self_service=true from team's vpp_apps_teams entry")
// Verify that if we query without self-service filter, we still get the correct value
opts.SelfServiceOnly = false
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
found := false
for _, s := range sw {
if s.Name == vppApp.Name {
found = true
assert.NotNil(t, s.AppStoreApp)
require.NotNil(t, s.AppStoreApp.SelfService, "expected SelfService to be set")
assert.True(t, *s.AppStoreApp.SelfService, "expected self_service=true from team's vpp_apps_teams entry")
}
}
assert.True(t, found, "expected to find the VPP app in the list")
}
func testCreateIntermediateInstallFailureRecord(t *testing.T, ds *Datastore) {
ctx := t.Context()
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
user := test.NewUser(t, ds, "test", "test@example.com", true)
// Create a software installer using the standard method
tfr, err := fleet.NewTempFileReader(strings.NewReader("test-package"), t.TempDir)
require.NoError(t, err)
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: `echo 'foo'`,
UninstallScript: `echo 'uninstall'`,
InstallerFile: tfr,
StorageID: "test-storage",
Filename: "installer.pkg",
Title: "test-app",
Version: "v1.0.0",
Source: "apps",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
// Create a pending install request with a specific execution ID and created_at for testing
// We need to use raw SQL here to set a specific execution_id and created_at that we can reference in the test
originalCreatedAt := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Microsecond) // Set to 1 hour ago
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
INSERT INTO host_software_installs (execution_id, host_id, software_installer_id, user_id, policy_id, self_service, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
"original-uuid", host.ID, installerID, user.ID, nil, false, originalCreatedAt)
return err
})
// Create an intermediate failure record
failedExecID, err := ds.CreateIntermediateInstallFailureRecord(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: "original-uuid",
InstallScriptExitCode: ptr.Int(1),
InstallScriptOutput: ptr.String("network timeout"),
RetriesRemaining: 2,
})
require.NoError(t, err)
require.NotEmpty(t, failedExecID)
// Verify original record is still pending using GetSoftwareInstallResults
originalResult, err := ds.GetSoftwareInstallResults(ctx, "original-uuid")
require.NoError(t, err)
require.Equal(t, fleet.SoftwareInstallPending, originalResult.Status)
require.Nil(t, originalResult.InstallScriptExitCode)
// Verify new failed record exists with failure details
failedResult, err := ds.GetSoftwareInstallResults(ctx, failedExecID)
require.NoError(t, err)
require.Equal(t, fleet.SoftwareInstallFailed, failedResult.Status)
require.NotNil(t, failedResult.InstallScriptExitCode)
require.Equal(t, 1, *failedResult.InstallScriptExitCode)
require.NotNil(t, failedResult.Output)
require.Equal(t, "network timeout", *failedResult.Output)
// Verify metadata preserved and correct
require.Equal(t, "test-app", failedResult.SoftwareTitle)
require.Equal(t, "installer.pkg", failedResult.SoftwarePackage)
require.NotNil(t, failedResult.UserID)
require.Equal(t, user.ID, *failedResult.UserID)
require.Nil(t, failedResult.PolicyID)
require.False(t, failedResult.SelfService)
// Verify the created_at timestamp was preserved from the original record
var failedRecordCreatedAt time.Time
err = ds.writer(ctx).GetContext(ctx, &failedRecordCreatedAt, `
SELECT created_at FROM host_software_installs WHERE execution_id = ?`, failedExecID)
require.NoError(t, err)
require.Equal(t, originalCreatedAt, failedRecordCreatedAt, "Failed record should preserve original created_at timestamp")
// Verify that we now have 2 distinct records (this still needs raw SQL as there's no method for counting)
var count int
err = ds.writer(ctx).GetContext(ctx, &count, `
SELECT COUNT(*) FROM host_software_installs WHERE host_id = ?`, host.ID)
require.NoError(t, err)
require.Equal(t, 2, count)
// Test idempotency: calling again with same retries_remaining should return same UUID and not create new record
failedExecID2, err := ds.CreateIntermediateInstallFailureRecord(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: "original-uuid",
InstallScriptExitCode: ptr.Int(1),
InstallScriptOutput: ptr.String("network timeout updated"),
RetriesRemaining: 2,
})
require.NoError(t, err)
require.Equal(t, failedExecID, failedExecID2, "Should generate same UUID for same retries_remaining")
// Verify still only 2 records (idempotent)
err = ds.writer(ctx).GetContext(ctx, &count, `
SELECT COUNT(*) FROM host_software_installs WHERE host_id = ?`, host.ID)
require.NoError(t, err)
require.Equal(t, 2, count, "Should not create duplicate record")
// Verify the output was updated
updatedResult, err := ds.GetSoftwareInstallResults(ctx, failedExecID2)
require.NoError(t, err)
require.Equal(t, "network timeout updated", *updatedResult.Output)
// Test with different retries_remaining creates new record
failedExecID3, err := ds.CreateIntermediateInstallFailureRecord(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: "original-uuid",
InstallScriptExitCode: ptr.Int(1),
InstallScriptOutput: ptr.String("network timeout"),
RetriesRemaining: 1,
})
require.NoError(t, err)
require.NotEqual(t, failedExecID, failedExecID3, "Should generate different UUID for different retries_remaining")
// Verify now have 3 records
err = ds.writer(ctx).GetContext(ctx, &count, `
SELECT COUNT(*) FROM host_software_installs WHERE host_id = ?`, host.ID)
require.NoError(t, err)
require.Equal(t, 3, count, "Should create new record for different retries_remaining")
}
func testSetHostSoftwareInstallResult(t *testing.T, ds *Datastore) {
ctx := context.Background()
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
// create a software installer and some host install requests
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
installScript := `echo 'foo'`
res, err := q.ExecContext(ctx, `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(md5(?)), ?)`, installScript, installScript)
if err != nil {
return err
}
scriptContentID, _ := res.LastInsertId()
uninstallScript := `echo 'bar'`
resUninstall, err := q.ExecContext(ctx, `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(md5(?)), ?)`,
uninstallScript, uninstallScript)
if err != nil {
return err
}
uninstallScriptContentID, _ := resUninstall.LastInsertId()
res, err = q.ExecContext(ctx, `INSERT INTO software_titles (name, source) VALUES ('foo', 'apps')`)
if err != nil {
return err
}
titleID, _ := res.LastInsertId()
res, err = q.ExecContext(ctx, `
INSERT INTO software_installers
(title_id, filename, extension, version, install_script_content_id, uninstall_script_content_id, storage_id, platform, package_ids, patch_query)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
titleID, "installer.pkg", "pkg", "v1.0.0", scriptContentID, uninstallScriptContentID, []byte("test"), "darwin", "[]", "")
if err != nil {
return err
}
id, _ := res.LastInsertId()
// create some install requests for the host
for i := 0; i < 3; i++ {
_, err = q.ExecContext(ctx, `
INSERT INTO host_software_installs (execution_id, host_id, software_installer_id) VALUES (?, ?, ?)`,
fmt.Sprintf("uuid%d", i), host.ID, id)
if err != nil {
return err
}
}
return nil
})
checkResults := func(want *fleet.HostSoftwareInstallResultPayload) {
type result struct {
HostID uint `db:"host_id"`
InstallUUID string `db:"execution_id"`
PreInstallConditionOutput *string `db:"pre_install_query_output"`
InstallScriptExitCode *int `db:"install_script_exit_code"`
InstallScriptOutput *string `db:"install_script_output"`
PostInstallScriptExitCode *int `db:"post_install_script_exit_code"`
PostInstallScriptOutput *string `db:"post_install_script_output"`
}
var got result
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &got,
`SELECT
host_id,
execution_id,
pre_install_query_output,
install_script_exit_code,
install_script_output,
post_install_script_exit_code,
post_install_script_output
FROM
host_software_installs
WHERE execution_id = ?`, want.InstallUUID)
})
assert.Equal(t, want.HostID, got.HostID)
assert.Equal(t, want.InstallUUID, got.InstallUUID)
if want.PreInstallConditionOutput == nil {
assert.Nil(t, got.PreInstallConditionOutput)
} else {
assert.NotNil(t, got.PreInstallConditionOutput)
assert.Equal(t, *want.PreInstallConditionOutput, *got.PreInstallConditionOutput)
}
assert.Equal(t, want.InstallScriptExitCode, got.InstallScriptExitCode)
if want.InstallScriptOutput == nil {
assert.Nil(t, got.InstallScriptOutput)
} else {
assert.NotNil(t, got.InstallScriptOutput)
assert.EqualValues(t, want.InstallScriptOutput, got.InstallScriptOutput)
}
assert.Equal(t, want.PostInstallScriptExitCode, got.PostInstallScriptExitCode)
if want.PostInstallScriptOutput == nil {
assert.Nil(t, got.PostInstallScriptOutput)
} else {
assert.NotNil(t, got.PostInstallScriptOutput)
assert.EqualValues(t, want.InstallScriptOutput, got.InstallScriptOutput)
}
}
// set a result with all fields provided
want := &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: "uuid0",
PreInstallConditionOutput: ptr.String("1"),
InstallScriptExitCode: ptr.Int(0),
InstallScriptOutput: ptr.String("ok"),
PostInstallScriptExitCode: ptr.Int(0),
PostInstallScriptOutput: ptr.String("ok"),
}
wasCanceled, err := ds.SetHostSoftwareInstallResult(ctx, want, nil)
require.NoError(t, err)
require.False(t, wasCanceled)
checkResults(want)
// set a result with only the pre-condition that failed
want = &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: "uuid1",
PreInstallConditionOutput: ptr.String(""),
}
_, err = ds.SetHostSoftwareInstallResult(ctx, want, nil)
require.NoError(t, err)
checkResults(want)
// set a result with only the install that failed
want = &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: "uuid2",
InstallScriptExitCode: ptr.Int(1),
InstallScriptOutput: ptr.String("fail"),
}
_, err = ds.SetHostSoftwareInstallResult(ctx, want, nil)
require.NoError(t, err)
checkResults(want)
// set a result for a non-existing uuid
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: "uuid-no-such",
InstallScriptExitCode: ptr.Int(0),
InstallScriptOutput: ptr.String("ok"),
}, nil)
require.Error(t, err)
require.True(t, fleet.IsNotFound(err))
}
func testListHostSoftwareInstallThenTransferTeam(t *testing.T, ds *Datastore) {
ctx := context.Background()
user := test.NewUser(t, ds, "user1", "user1@example.com", false)
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, host, false)
opts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"},
IncludeAvailableForInstall: true,
}
test.CreateInsertGlobalVPPToken(t, ds)
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
require.NoError(t, err)
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"})
require.NoError(t, err)
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team1.ID, []uint{host.ID}))
require.NoError(t, err)
host.TeamID = &team1.ID
// add a single "externally-installed" software for that host
software := []fleet.Software{
{Name: "a", Version: "0.0.1", Source: "chrome_extensions"},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
// create a software installer for team 1
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
installerTm1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
InstallerFile: tfr1,
StorageID: "storage1",
Filename: "file1",
Title: "file1",
Version: "1.0",
Source: "apps",
TeamID: &team1.ID,
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
// install it on the host
hostInstall1, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerTm1, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: hostInstall1,
InstallScriptExitCode: ptr.Int(0),
}, nil)
require.NoError(t, err)
// add a VPP app for team 1
vppTm1, err := ds.InsertVPPAppWithTeam(ctx,
&fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1",
BundleIdentifier: "com.app.vpp1",
}, &team1.ID)
require.NoError(t, err)
// fail to install it on the host
vpp1CmdUUID := createVPPAppInstallRequest(t, ds, host, vppTm1.AdamID, user)
createVPPAppInstallResult(t, ds, host, vpp1CmdUUID, fleet.MDMAppleStatusError)
// add the successful installer to the reported installed software
software = []fleet.Software{
{Name: "a", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "file1", Version: "1.0", Source: "apps"},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
// listing the host's software (including available for install) at this
// point lists "a", "file1" and "vpp1" (because of the install attempt)
sw, meta, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 3)
require.EqualValues(t, 3, meta.TotalResults)
require.Equal(t, sw[0].Name, "a")
require.Nil(t, sw[0].AppStoreApp)
require.Nil(t, sw[0].SoftwarePackage)
require.Equal(t, sw[1].Name, "file1")
require.Nil(t, sw[1].AppStoreApp)
require.NotNil(t, sw[1].SoftwarePackage)
require.Equal(t, sw[2].Name, "vpp1")
require.NotNil(t, sw[2].AppStoreApp)
require.Nil(t, sw[2].SoftwarePackage)
// move host to team 2
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team2.ID, []uint{host.ID}))
require.NoError(t, err)
host.TeamID = &team2.ID
// listing the host's software (including available for install) should now
// only list "a" and "file1" (because they are actually installed) and not
// link them to the installer/VPP app. With and without available software
// should result in the same rows (no available software in that new team).
for _, b := range []bool{true, false} {
opts.IncludeAvailableForInstall = b
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 2)
require.EqualValues(t, 2, meta.TotalResults)
require.Equal(t, sw[0].Name, "a")
require.Nil(t, sw[0].AppStoreApp)
require.Nil(t, sw[0].SoftwarePackage)
require.Equal(t, sw[1].Name, "file1")
require.Nil(t, sw[1].AppStoreApp)
require.Nil(t, sw[1].SoftwarePackage)
}
}
func testListHostSoftwareFailInstallThenTransferTeam(t *testing.T, ds *Datastore) {
ctx := context.Background()
user := test.NewUser(t, ds, "user1", "user1@example.com", false)
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, host, false)
opts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"},
OnlyAvailableForInstall: true,
}
test.CreateInsertGlobalVPPToken(t, ds)
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
require.NoError(t, err)
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"})
require.NoError(t, err)
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team1.ID, []uint{host.ID}))
require.NoError(t, err)
host.TeamID = &team1.ID
// create a software installer for both teams
tfr, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
payloadBoth := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
InstallerFile: tfr,
StorageID: "storage1",
Filename: "foo.pkg",
Title: "foo",
Version: "1.0",
Source: "apps",
Platform: string(fleet.MacOSPlatform),
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
payloadBoth.TeamID = &team1.ID
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, payloadBoth)
require.NoError(t, err)
payloadBoth.TeamID = &team2.ID
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, payloadBoth)
require.NoError(t, err)
// create software installer for only team 1
payloadOnly1 := fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
InstallerFile: tfr,
StorageID: "storage1",
Filename: "bar.pkg",
Title: "bar",
Version: "1.0",
Source: "apps",
Platform: string(fleet.MacOSPlatform),
TeamID: &team1.ID,
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
installerTm1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &payloadOnly1)
require.NoError(t, err)
// Fail to install bar that is only scoped to team 1 on the host
hostInstall1, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerTm1, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: hostInstall1,
InstallScriptExitCode: ptr.Int(1),
}, nil)
require.NoError(t, err)
// add a VPP app for team 1
vppTm1, err := ds.InsertVPPAppWithTeam(ctx,
&fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1",
BundleIdentifier: "com.app.vpp1",
}, &team1.ID)
require.NoError(t, err)
// install it on the host
vpp1CmdUUID := createVPPAppInstallRequest(t, ds, host, vppTm1.AdamID, user)
createVPPAppInstallResult(t, ds, host, vpp1CmdUUID, fleet.MDMAppleStatusAcknowledged)
// listing available for install should include "foo", "vpp1",
// and the failed attempt for "bar"
sw, _, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 3)
require.Equal(t, sw[0].Name, "bar")
require.NotNil(t, sw[0].SoftwarePackage)
// Verify that LastInstall is populated for failed installs
require.NotNil(t, sw[0].SoftwarePackage.LastInstall, "LastInstall should be populated for failed installs")
require.Equal(t, hostInstall1, sw[0].SoftwarePackage.LastInstall.InstallUUID, "LastInstall.InstallUUID should match the install request UUID")
require.Equal(t, sw[1].Name, "foo")
require.NotNil(t, sw[1].SoftwarePackage)
require.Equal(t, sw[2].Name, "vpp1")
require.NotNil(t, sw[2].AppStoreApp)
// move host to team 2
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team2.ID, []uint{host.ID}))
require.NoError(t, err)
host.TeamID = &team2.ID
// listing available for install now should not include the failed install
// of "bar" because the installer is scoped to only team 1.
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 1)
require.Equal(t, sw[0].Name, "foo")
require.NotNil(t, sw[0].SoftwarePackage)
}
// testListHostSoftwareFailInstallThenLabelExclude tests that when a host has a failed
// install attempt and then the installer's label targeting is changed to exclude the host,
// the software no longer appears in the host's "All software" list (IncludeAvailableForInstall=true).
func testListHostSoftwareFailInstallThenLabelExclude(t *testing.T, ds *Datastore) {
ctx := context.Background()
user := test.NewUser(t, ds, "user1", "user1@example.com", false)
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, host, false)
// Use IncludeAvailableForInstall (not OnlyAvailableForInstall) - this is the "All software" view
opts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"},
IncludeAvailableForInstall: true,
OnlyAvailableForInstall: false, // Explicitly false - we want "All software" view
}
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
require.NoError(t, err)
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team1.ID, []uint{host.ID}))
require.NoError(t, err)
host.TeamID = &team1.ID
// Create a label that we'll use to exclude the host
excludeLabel, err := ds.NewLabel(ctx, &fleet.Label{
Name: "exclude-label",
Query: "select 1",
})
require.NoError(t, err)
// Create a software installer with NO label targeting (available to all hosts in team)
tfr, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "exit 1", // Will fail
InstallerFile: tfr,
StorageID: "storage1",
Filename: "bar.pkg",
Title: "bar",
Version: "1.0",
Source: "apps",
Platform: string(fleet.MacOSPlatform),
TeamID: &team1.ID,
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{}, // No labels initially
}
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, payload)
require.NoError(t, err)
// Fail to install the software on the host
hostInstall, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: hostInstall,
InstallScriptExitCode: ptr.Int(1), // Failed
}, nil)
require.NoError(t, err)
// Verify software appears in "All software" view (failed install should show)
sw, _, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 1)
require.Equal(t, "bar", sw[0].Name)
require.NotNil(t, sw[0].SoftwarePackage)
// Verify that LastInstall is populated for failed installs
require.NotNil(t, sw[0].SoftwarePackage.LastInstall, "LastInstall should be populated for failed installs")
require.Equal(t, hostInstall, sw[0].SoftwarePackage.LastInstall.InstallUUID, "LastInstall.InstallUUID should match the install request UUID")
// Now add the host to the exclude label
require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{excludeLabel.ID}))
host.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, host)
require.NoError(t, err)
// Add the exclude label to the installer's targeting
// This simulates changing the installer's targeting to exclude hosts with this label
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
`INSERT INTO software_installer_labels (software_installer_id, label_id, exclude) VALUES (?, ?, 1)`,
installerID, excludeLabel.ID)
return err
})
// Now the software should NOT appear in "All software" view because:
// 1. The software is not installed (failed install)
// 2. The installer is out of scope (host is in the exclude label)
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 0, "Software with failed install should not appear when installer targeting excludes the host")
}
// testListHostSoftwareFailUninstallThenLabelExclude tests that when a host has a failed
// uninstall attempt, the LastUninstall info is populated so users can view the failure details.
// When the installer's label targeting is changed to exclude the host (and software is not in inventory),
// the software no longer appears (consistent with failed install behavior).
func testListHostSoftwareFailUninstallThenLabelExclude(t *testing.T, ds *Datastore) {
ctx := context.Background()
t.Cleanup(func() { ds.testActivateSpecificNextActivities = nil })
user := test.NewUser(t, ds, "user1", "user1@example.com", false)
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, host, false)
opts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"},
IncludeAvailableForInstall: true,
OnlyAvailableForInstall: false,
}
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
require.NoError(t, err)
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team1.ID, []uint{host.ID}))
require.NoError(t, err)
host.TeamID = &team1.ID
// Create a label that we'll use to exclude the host
excludeLabel, err := ds.NewLabel(ctx, &fleet.Label{
Name: "exclude-label",
Query: "select 1",
})
require.NoError(t, err)
// Create a software installer with an uninstall script
tfr, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "exit 0",
UninstallScript: "exit 1", // Will fail
InstallerFile: tfr,
StorageID: "storage1",
Filename: "bar.pkg",
Title: "bar",
Version: "1.0",
Source: "apps",
Platform: string(fleet.MacOSPlatform),
TeamID: &team1.ID,
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, payload)
require.NoError(t, err)
// Successfully install the software on the host
hostInstall, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: hostInstall,
InstallScriptExitCode: ptr.Int(0), // Success
}, nil)
require.NoError(t, err)
// Fail to uninstall the software on the host
uninstallUUID := uuid.NewString()
err = ds.InsertSoftwareUninstallRequest(ctx, uninstallUUID, host.ID, installerID, false)
require.NoError(t, err)
// Activate the uninstall activity and set failure result
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
ds.testActivateSpecificNextActivities = []string{uninstallUUID}
_, err := ds.activateNextUpcomingActivity(ctx, q, host.ID, "")
return err
})
ds.testActivateSpecificNextActivities = []string{"-"}
_, _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: host.ID,
ExecutionID: uninstallUUID,
ExitCode: 1, // Failed
}, nil)
require.NoError(t, err)
// Verify software appears in "All software" view (installer is in scope)
sw, _, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 1)
require.Equal(t, "bar", sw[0].Name)
require.NotNil(t, sw[0].SoftwarePackage)
// Verify that LastUninstall is populated for failed uninstalls
require.NotNil(t, sw[0].SoftwarePackage.LastUninstall, "LastUninstall should be populated for failed uninstalls")
require.Equal(t, uninstallUUID, sw[0].SoftwarePackage.LastUninstall.ExecutionID, "LastUninstall.ExecutionID should match the uninstall request UUID")
// Now add the host to the exclude label
require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{excludeLabel.ID}))
host.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, host)
require.NoError(t, err)
// Add the exclude label to the installer's targeting
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
`INSERT INTO software_installer_labels (software_installer_id, label_id, exclude) VALUES (?, ?, 1)`,
installerID, excludeLabel.ID)
return err
})
// When installer goes out of scope, if there was a successful install, the software
// still appears because the host_software_installs record shows it was installed.
// But SoftwarePackage will be nil because the installer is out of scope.
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 1, "Software should still appear because there was a successful install")
require.Equal(t, "bar", sw[0].Name)
// When installer is out of scope, SoftwarePackage is nil (same as when installer is deleted)
// This is expected behavior - the LastUninstall info was available when installer was in scope
require.Nil(t, sw[0].SoftwarePackage, "SoftwarePackage should be nil when installer is out of scope")
}
func testListHostSoftwareFailVPPInstallThenLabelExclude(t *testing.T, ds *Datastore) {
ctx := context.Background()
user := test.NewUser(t, ds, "user1", "user1@example.com", false)
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, host, false)
opts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"},
IncludeAvailableForInstall: true,
OnlyAvailableForInstall: false,
IsMDMEnrolled: true,
}
dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Test org"+t.Name(), "Test location"+t.Name())
require.NoError(t, err)
tok1, err := ds.InsertVPPToken(ctx, dataToken)
require.NoError(t, err)
_, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{})
require.NoError(t, err)
vppApp := &fleet.VPPApp{Name: "vpp_app_fail_test", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "999", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "com.test.vppfail"}
vppApp, err = ds.InsertVPPAppWithTeam(ctx, vppApp, nil)
require.NoError(t, err)
vppAppTeamID := vppApp.VPPAppTeam.AppTeamID
excludeLabel, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-vpp-label", Query: "select 1"})
require.NoError(t, err)
// Create a FAILED VPP install request
vppCmdUUID := createVPPAppInstallRequest(t, ds, host, vppApp.AdamID, user)
_, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), host.ID, "")
require.NoError(t, err)
createVPPAppInstallResult(t, ds, host, vppCmdUUID, fleet.MDMAppleStatusError)
// Verify VPP app appears in "All software" view (failed install should show initially)
sw, _, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 1)
require.Equal(t, "vpp_app_fail_test", sw[0].Name)
require.NotNil(t, sw[0].AppStoreApp)
// Verify that LastInstall is populated for failed VPP installs
require.NotNil(t, sw[0].AppStoreApp.LastInstall, "LastInstall should be populated for failed VPP installs")
require.Equal(t, vppCmdUUID, sw[0].AppStoreApp.LastInstall.CommandUUID, "LastInstall.CommandUUID should match the install command UUID")
// Add host to exclude label
require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{excludeLabel.ID}))
host.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, host)
require.NoError(t, err)
time.Sleep(time.Second)
// Add exclude label to VPP app targeting
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
`INSERT INTO vpp_app_team_labels (vpp_app_team_id, label_id, exclude) VALUES (?, ?, 1)`,
vppAppTeamID, excludeLabel.ID)
return err
})
// Now, the VPP app should NOT appear because it failed and is out of scope
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 0, "VPP app with failed install should not appear when targeting excludes the host")
}
// testListHostSoftwareFailInHouseInstallThenLabelExclude tests that when a host has a failed
// in-house app install attempt, the LastInstall info is populated so users can view the failure details.
func testListHostSoftwareFailInHouseInstallThenLabelExclude(t *testing.T, ds *Datastore) {
ctx := context.Background()
t.Cleanup(func() { ds.testActivateSpecificNextActivities = nil })
user := test.NewUser(t, ds, "user1", "user1@example.com", false)
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("ios"))
nanoEnroll(t, ds, host, false)
opts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"},
IncludeAvailableForInstall: true,
OnlyAvailableForInstall: false,
IsMDMEnrolled: true, // required for in-house apps
}
// Create an exclude label
excludeLabel, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-inhouse-label", Query: "select 1"})
require.NoError(t, err)
// Create an in-house app installer
inHouseID, inHouseTitleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "inhouse_fail_test",
Source: "ios_apps",
Filename: "inhouse_fail_test.ipa",
Extension: "ipa",
BundleIdentifier: "com.test.inhousefail",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.NotZero(t, inHouseID)
require.NotZero(t, inHouseTitleID)
// Create a FAILED in-house app install request
inHouseCmdUUID := createInHouseAppInstallRequest(t, ds, host.ID, inHouseID, inHouseTitleID, user)
ds.testActivateSpecificNextActivities = []string{inHouseCmdUUID}
_, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), host.ID, "")
require.NoError(t, err)
createInHouseAppInstallResult(t, ds, host, inHouseCmdUUID, "Error") // Failed install
// Verify in-house app appears in "All software" view (failed install should show initially)
sw, _, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 1)
require.Equal(t, "inhouse_fail_test", sw[0].Name)
require.NotNil(t, sw[0].SoftwarePackage, "SoftwarePackage should be populated for in-house apps")
// Verify that LastInstall is populated for failed in-house installs
require.NotNil(t, sw[0].SoftwarePackage.LastInstall, "LastInstall should be populated for failed in-house installs")
require.Equal(t, inHouseCmdUUID, sw[0].SoftwarePackage.LastInstall.CommandUUID, "LastInstall.CommandUUID should match the install command UUID")
// Add host to exclude label
require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{excludeLabel.ID}))
host.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, host)
require.NoError(t, err)
time.Sleep(time.Second)
// Add exclude label to in-house app targeting (in-house apps use in_house_app_labels table)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
`INSERT INTO in_house_app_labels (in_house_app_id, label_id, exclude) VALUES (?, ?, 1)`,
inHouseID, excludeLabel.ID)
return err
})
// Now, the in-house app should NOT appear because it failed and is out of scope
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 0, "In-house app with failed install should not appear when targeting excludes the host")
}
func testListHostSoftwareInstallThenDeleteInstallers(t *testing.T, ds *Datastore) {
ctx := context.Background()
user := test.NewUser(t, ds, "user1", "user1@example.com", false)
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, host, false)
opts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"},
IncludeAvailableForInstall: true,
}
test.CreateInsertGlobalVPPToken(t, ds)
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
require.NoError(t, err)
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team1.ID, []uint{host.ID}))
require.NoError(t, err)
host.TeamID = &team1.ID
// add a single "externally-installed" software for that host
software := []fleet.Software{
{Name: "a", Version: "0.0.1", Source: "chrome_extensions"},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
// create a software installer for team 1
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
installerTm1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
InstallerFile: tfr1,
StorageID: "storage1",
Filename: "file1",
Title: "file1",
Version: "1.0",
Source: "apps",
TeamID: &team1.ID,
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
// fail to install it on the host
hostInstall1, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerTm1, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: hostInstall1,
InstallScriptExitCode: ptr.Int(1),
}, nil)
require.NoError(t, err)
// add a VPP app for team 1
vppTm1, err := ds.InsertVPPAppWithTeam(ctx,
&fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1",
BundleIdentifier: "com.app.vpp1", LatestVersion: "1.0",
}, &team1.ID)
require.NoError(t, err)
// install it on the host
vpp1CmdUUID := createVPPAppInstallRequest(t, ds, host, vppTm1.AdamID, user)
createVPPAppInstallResult(t, ds, host, vpp1CmdUUID, fleet.MDMAppleStatusAcknowledged)
// add the successful VPP app to the reported installed software
software = []fleet.Software{
{Name: "a", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "vpp1", Version: "1.0", Source: "apps", BundleIdentifier: "com.app.vpp1"},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
// listing the host's software (including available for install) at this
// point lists "a", "file1" and "vpp1" (because of the install attempt)
sw, meta, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 3)
require.EqualValues(t, 3, meta.TotalResults)
require.Equal(t, sw[0].Name, "a")
require.Nil(t, sw[0].AppStoreApp)
require.Nil(t, sw[0].SoftwarePackage)
require.Equal(t, sw[1].Name, "file1")
require.Nil(t, sw[1].AppStoreApp)
require.NotNil(t, sw[1].SoftwarePackage)
require.Equal(t, sw[2].Name, "vpp1")
require.NotNil(t, sw[2].AppStoreApp)
require.Nil(t, sw[2].SoftwarePackage)
// delete both installers
err = ds.DeleteSoftwareInstaller(ctx, installerTm1)
require.NoError(t, err)
err = ds.DeleteVPPAppFromTeam(ctx, &team1.ID, vppTm1.VPPAppID)
require.NoError(t, err)
// listing the host's software (including available for install) should now
// only list "a" and "vpp1" (because they are actually installed) and not
// link them to the installer/VPP app. With and without available software
// should result in the same rows (no available software anymore).
for _, b := range []bool{true, false} {
opts.IncludeAvailableForInstall = b
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 2)
require.EqualValues(t, 2, meta.TotalResults)
require.Equal(t, sw[0].Name, "a")
require.Nil(t, sw[0].AppStoreApp)
require.Nil(t, sw[0].SoftwarePackage)
require.Equal(t, sw[1].Name, "vpp1")
require.Nil(t, sw[1].AppStoreApp)
require.Nil(t, sw[1].SoftwarePackage)
}
}
func testListSoftwareVersionsVulnerabilityFilters(t *testing.T, ds *Datastore) {
ctx := context.Background()
host := test.NewHost(t, ds, "host", "", "hostkey", "hostuuid", time.Now())
software := []fleet.Software{
{Name: "chrome", Version: "0.0.1", Source: "apps", BundleIdentifier: "com.example.chrome"},
{Name: "chrome", Version: "0.0.3", Source: "apps", BundleIdentifier: "com.example.chrome"},
{Name: "safari", Version: "0.0.3", Source: "apps", BundleIdentifier: "com.example.safari"},
{Name: "safari", Version: "0.0.1", Source: "apps", BundleIdentifier: "com.example.safari"},
{Name: "firefox", Version: "0.0.3", Source: "apps", BundleIdentifier: "com.example.firefox"},
{Name: "edge", Version: "0.0.3", Source: "apps", BundleIdentifier: "com.example.edge"},
{Name: "brave", Version: "0.0.3", Source: "apps", BundleIdentifier: "com.example.brave"},
{Name: "opera", Version: "0.0.3", Source: "apps", BundleIdentifier: "com.example.opera"},
{Name: "internet explorer", Version: "0.0.3", Source: "apps", BundleIdentifier: "com.example.ie"},
{Name: "netscape", Version: "0.0.3", Source: "apps", BundleIdentifier: "com.example.netscape"},
}
sw, err := ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
var chrome001 uint
var safari001 uint
var firefox003 uint
var edge003 uint
var brave003 uint
var opera003 uint
var ie003 uint
for s := range sw.Inserted {
switch {
case sw.Inserted[s].Name == "chrome" && sw.Inserted[s].Version == "0.0.1":
chrome001 = sw.Inserted[s].ID
case sw.Inserted[s].Name == "safari" && sw.Inserted[s].Version == "0.0.1":
safari001 = sw.Inserted[s].ID
case sw.Inserted[s].Name == "firefox" && sw.Inserted[s].Version == "0.0.3":
firefox003 = sw.Inserted[s].ID
case sw.Inserted[s].Name == "edge" && sw.Inserted[s].Version == "0.0.3":
edge003 = sw.Inserted[s].ID
case sw.Inserted[s].Name == "brave" && sw.Inserted[s].Version == "0.0.3":
brave003 = sw.Inserted[s].ID
case sw.Inserted[s].Name == "opera" && sw.Inserted[s].Version == "0.0.3":
opera003 = sw.Inserted[s].ID
case sw.Inserted[s].Name == "internet explorer" && sw.Inserted[s].Version == "0.0.3":
ie003 = sw.Inserted[s].ID
}
}
_, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{
SoftwareID: chrome001,
CVE: "CVE-2024-1234",
}, fleet.NVDSource)
require.NoError(t, err)
_, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{
SoftwareID: safari001,
CVE: "CVE-2024-1235",
}, fleet.NVDSource)
require.NoError(t, err)
_, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{
SoftwareID: firefox003,
CVE: "CVE-2024-1236",
}, fleet.NVDSource)
require.NoError(t, err)
_, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{
SoftwareID: edge003,
CVE: "CVE-2024-1237",
}, fleet.NVDSource)
require.NoError(t, err)
_, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{
SoftwareID: brave003,
CVE: "CVE-2024-1238",
}, fleet.NVDSource)
require.NoError(t, err)
_, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{
SoftwareID: opera003,
CVE: "CVE-2024-1239",
}, fleet.NVDSource)
require.NoError(t, err)
_, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{
SoftwareID: ie003,
CVE: "CVE-2024-1240",
}, fleet.NVDSource)
require.NoError(t, err)
err = ds.InsertCVEMeta(ctx, []fleet.CVEMeta{
{
// chrome
CVE: "CVE-2024-1234",
CVSSScore: ptr.Float64(7.5),
CISAKnownExploit: ptr.Bool(true),
},
{
// safari
CVE: "CVE-2024-1235",
CVSSScore: ptr.Float64(7.5),
CISAKnownExploit: ptr.Bool(false),
},
{
// firefox
CVE: "CVE-2024-1236",
CVSSScore: ptr.Float64(8.0),
CISAKnownExploit: ptr.Bool(true),
},
{
// edge
CVE: "CVE-2024-1237",
CVSSScore: ptr.Float64(8.0),
CISAKnownExploit: ptr.Bool(false),
},
{
// brave
CVE: "CVE-2024-1238",
CVSSScore: ptr.Float64(9.0),
CISAKnownExploit: ptr.Bool(true),
},
// CVE-2024-1239 for opera has no CVE Meta
{
// internet explorer
CVE: "CVE-2024-1240",
CVSSScore: nil,
CISAKnownExploit: nil,
},
})
require.NoError(t, err)
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now()))
type swVersion struct {
Name string
Version string
}
tc := []struct {
name string
opts fleet.SoftwareListOptions
expected []swVersion
err error
}{
{
name: "vulnerable only",
opts: fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{OrderKey: "name"},
VulnerableOnly: true,
},
expected: []swVersion{
{
Name: "brave",
Version: "0.0.3",
},
{
Name: "chrome",
Version: "0.0.1",
},
{
Name: "edge",
Version: "0.0.3",
},
{
Name: "firefox",
Version: "0.0.3",
},
{
Name: "internet explorer",
Version: "0.0.3",
},
{
Name: "opera",
Version: "0.0.3",
},
{
Name: "safari",
Version: "0.0.1",
},
},
},
{
name: "known exploit true",
opts: fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending},
IncludeCVEScores: true,
VulnerableOnly: true,
KnownExploit: true,
},
expected: []swVersion{
{
Name: "brave",
Version: "0.0.3",
},
{
Name: "chrome",
Version: "0.0.1",
},
{
Name: "firefox",
Version: "0.0.3",
},
},
},
{
name: "minimum cvss 8.0",
opts: fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending},
IncludeCVEScores: true,
VulnerableOnly: true,
MinimumCVSS: 8.0,
},
expected: []swVersion{
{
Name: "brave",
Version: "0.0.3",
},
{
Name: "edge",
Version: "0.0.3",
},
{
Name: "firefox",
Version: "0.0.3",
},
},
},
{
name: "minimum cvss 7.9",
opts: fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending},
IncludeCVEScores: true,
VulnerableOnly: true,
MinimumCVSS: 7.9,
},
expected: []swVersion{
{
Name: "brave",
Version: "0.0.3",
},
{
Name: "edge",
Version: "0.0.3",
},
{
Name: "firefox",
Version: "0.0.3",
},
},
},
{
name: "minimum cvss 8.0 and known exploit",
opts: fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending},
IncludeCVEScores: true,
VulnerableOnly: true,
MinimumCVSS: 8.0,
KnownExploit: true,
},
expected: []swVersion{
{
Name: "brave",
Version: "0.0.3",
},
{
Name: "firefox",
Version: "0.0.3",
},
},
},
{
name: "minimum cvss 7.5 and known exploit",
opts: fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending},
IncludeCVEScores: true,
VulnerableOnly: true,
MinimumCVSS: 7.5,
KnownExploit: true,
},
expected: []swVersion{
{
Name: "brave",
Version: "0.0.3",
},
{
Name: "chrome",
Version: "0.0.1",
},
{
Name: "firefox",
Version: "0.0.3",
},
},
},
{
name: "maximum cvss 7.5",
opts: fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending},
IncludeCVEScores: true,
VulnerableOnly: true,
MaximumCVSS: 7.5,
},
expected: []swVersion{
{
Name: "chrome",
Version: "0.0.1",
},
{
Name: "safari",
Version: "0.0.1",
},
},
},
{
name: "maximum cvss 7.6",
opts: fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending},
IncludeCVEScores: true,
VulnerableOnly: true,
MaximumCVSS: 7.6,
},
expected: []swVersion{
{
Name: "chrome",
Version: "0.0.1",
},
{
Name: "safari",
Version: "0.0.1",
},
},
},
{
name: "maximum cvss 7.5 and known exploit",
opts: fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending},
IncludeCVEScores: true,
VulnerableOnly: true,
MaximumCVSS: 7.5,
KnownExploit: true,
},
expected: []swVersion{
{
Name: "chrome",
Version: "0.0.1",
},
},
},
{
name: "minimum cvss 7.5 and maximum cvss 8.0",
opts: fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending},
IncludeCVEScores: true,
VulnerableOnly: true,
MinimumCVSS: 7.5,
MaximumCVSS: 8.0,
},
expected: []swVersion{
{
Name: "chrome",
Version: "0.0.1",
},
{
Name: "edge",
Version: "0.0.3",
},
{
Name: "firefox",
Version: "0.0.3",
},
{
Name: "safari",
Version: "0.0.1",
},
},
},
{
name: "minimum cvss 7.5 and maximum cvss 8.0 and known exploit",
opts: fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending},
IncludeCVEScores: true,
VulnerableOnly: true,
MinimumCVSS: 7.5,
MaximumCVSS: 8.0,
KnownExploit: true,
},
expected: []swVersion{
{
Name: "chrome",
Version: "0.0.1",
},
{
Name: "firefox",
Version: "0.0.3",
},
},
},
{
name: "err if vulnerableOnly is not set with MinimumCVSS",
opts: fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{},
MinimumCVSS: 7.5,
},
err: fleet.NewInvalidArgumentError("query", "min_cvss_score, max_cvss_score, and exploit can only be provided with vulnerable=true"),
},
{
name: "err if vulnerableOnly is not set with MaximumCVSS",
opts: fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{},
MaximumCVSS: 7.5,
},
err: fleet.NewInvalidArgumentError("query", "min_cvss_score, max_cvss_score, and exploit can only be provided with vulnerable=true"),
},
{
name: "err if vulnerableOnly is not set with KnownExploit",
opts: fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{},
KnownExploit: true,
},
err: fleet.NewInvalidArgumentError("query", "min_cvss_score, max_cvss_score, and exploit can only be provided with vulnerable=true"),
},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
sw, _, err := ds.ListSoftware(ctx, tt.opts)
if tt.err != nil {
require.Error(t, err)
require.Equal(t, tt.err, err)
return
}
require.Len(t, sw, len(tt.expected))
for i, s := range sw {
require.Equal(t, tt.expected[i].Name, s.Name)
require.Equal(t, tt.expected[i].Version, s.Version)
}
count, err := ds.CountSoftware(ctx, tt.opts)
require.NoError(t, err)
require.Equal(t, len(tt.expected), count)
})
}
}
func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) {
ctx := context.Background()
// create a host
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, host, false)
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
anotherHost := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, anotherHost, false)
thirdHost := test.NewHost(t, ds, "host3", "", "host3key", "host3uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, thirdHost, false)
time.Sleep(time.Second) // ensure the labels_updated_at timestamp is before labels creation
// create a software installer
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
installer1 := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
PreInstallQuery: "SELECT 1",
PostInstallScript: "world",
UninstallScript: "goodbye",
InstallerFile: tfr1,
StorageID: "storage1",
Filename: "file1",
Title: "file1",
Version: "1.0",
Source: "apps",
UserID: user1.ID,
BundleIdentifier: "bi1",
Platform: "darwin",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
installerID1, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, installer1)
require.NoError(t, err)
// self-service software installer
tfr2, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
selfServiceinstaller := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello 2",
PreInstallQuery: "SELECT 2",
PostInstallScript: "world 2",
UninstallScript: "goodbye 2",
InstallerFile: tfr2,
StorageID: "storage 2",
Filename: "file2",
Title: "file2",
Version: "1.0",
Source: "apps",
UserID: user1.ID,
BundleIdentifier: "bi2",
Platform: "darwin",
SelfService: true,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
selfServiceInstallerID, selfServiceTitleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, selfServiceinstaller)
require.NoError(t, err)
// install software installer on host via fleet
hostInstall1, err := ds.InsertSoftwareInstallRequest(ctx, thirdHost.ID, selfServiceInstallerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: thirdHost.ID,
InstallUUID: hostInstall1,
InstallScriptExitCode: ptr.Int(0),
}, nil)
require.NoError(t, err)
installedSoftware := []fleet.Software{
{
Name: "file2",
Version: "1.0",
Source: "apps",
TitleID: &selfServiceTitleID,
},
}
mutationResults, err := ds.UpdateHostSoftware(ctx, thirdHost.ID, installedSoftware)
require.NoError(t, err)
assert.Len(t, mutationResults.Inserted, len(installedSoftware))
softwareAlreadyInstalled := fleet.Software{Name: "file1", Version: "1.0.1", Source: "apps", BundleIdentifier: "bi1"}
// Host has software installed, but not by Fleet, that matches the software installer available
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
titleIDUint := titleID
softwareAlreadyInstalled.TitleID = &titleIDUint
res, err := q.ExecContext(ctx, `INSERT INTO software (name, source, bundle_identifier, version, title_id, checksum) VALUES (?, ?, ?, ?, ?, ?)`,
softwareAlreadyInstalled.Name, softwareAlreadyInstalled.Source, softwareAlreadyInstalled.BundleIdentifier, softwareAlreadyInstalled.Version, titleID, []byte("checksum"))
if err != nil {
return err
}
softwareID, err := res.LastInsertId()
if err != nil {
return err
}
softwareAlreadyInstalled.ID = uint(softwareID)
_, err = q.ExecContext(ctx, `INSERT INTO host_software (host_id, software_id) VALUES (?, ?)`,
anotherHost.ID, softwareID)
if err != nil {
return err
}
return nil
})
// we should see installer1, since it has no label associated yet
opts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
PerPage: 11,
IncludeMetadata: true,
OrderKey: "name",
TestSecondaryOrderKey: "source",
},
IncludeAvailableForInstall: true,
}
expectedInstallers := map[string]*fleet.SoftwarePackageOrApp{
installer1.Filename: {
Name: installer1.Filename,
Version: installer1.Version,
Platform: installer1.Platform,
SelfService: ptr.Bool(false),
},
selfServiceinstaller.Filename: {
Name: selfServiceinstaller.Filename,
Version: selfServiceinstaller.Version,
Platform: selfServiceinstaller.Platform,
SelfService: ptr.Bool(true),
},
}
checkSoftware := func(swList []*fleet.HostSoftwareWithInstaller, excludeNames ...string) {
for _, got := range swList {
want, ok := expectedInstallers[got.SoftwarePackage.Name]
if slices.Contains(excludeNames, got.SoftwarePackage.Name) {
require.False(t, ok)
continue
}
names := std_slices.Collect(maps.Keys(expectedInstallers))
require.Truef(t, ok, "didn't find installer for %s in expectedInstallers (%s)", got.SoftwarePackage.Name, strings.Join(names, ", "))
require.Equal(t, want, got.SoftwarePackage)
}
}
software, _, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software)
software, _, err = ds.ListHostSoftware(ctx, anotherHost, opts)
require.NoError(t, err)
require.Len(t, software, 2)
// software[0] was inserted into software/host_software in the ad-hoc SQL execution above, so has an installed version
require.Equal(t, softwareAlreadyInstalled.Name, software[0].Name)
require.Equal(t, softwareAlreadyInstalled.Version, software[0].InstalledVersions[0].Version)
require.Equal(t, installer1.Version, software[0].SoftwarePackage.Version)
require.Len(t, software[0].InstalledVersions, 1)
require.Equal(t, mutationResults.Inserted[0].Name, software[1].Name)
// software[1] is *not* installed but *is* available for install
require.Nil(t, software[1].InstalledVersions)
require.Equal(t, selfServiceinstaller.Version, software[1].SoftwarePackage.Version)
software, _, err = ds.ListHostSoftware(ctx, thirdHost, opts)
require.NoError(t, err)
require.Len(t, software, 2)
require.Equal(t, softwareAlreadyInstalled.Name, software[0].Name)
require.Nil(t, software[0].InstalledVersions)
require.Equal(t, installer1.Version, software[0].SoftwarePackage.Version)
require.Equal(t, mutationResults.Inserted[0].Name, software[1].Name)
require.Equal(t, mutationResults.Inserted[0].Version, software[1].InstalledVersions[0].Version)
require.Equal(t, selfServiceinstaller.Version, software[1].SoftwarePackage.Version)
// installer1 should be in scope since it has no labels
scoped, err := ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
require.NoError(t, err)
require.True(t, scoped)
hostsInScope, err := ds.GetIncludedHostIDMapForSoftwareInstaller(ctx, installerID1)
require.NoError(t, err)
require.Contains(t, hostsInScope, host.ID)
require.Contains(t, hostsInScope, anotherHost.ID)
require.Contains(t, hostsInScope, thirdHost.ID)
label1, err := ds.NewLabel(ctx, &fleet.Label{Name: "label1" + t.Name()})
require.NoError(t, err)
// assign the label to the hosts
require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{label1.ID}))
host.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, host)
require.NoError(t, err)
require.NoError(t, ds.AddLabelsToHost(ctx, anotherHost.ID, []uint{label1.ID}))
anotherHost.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, anotherHost)
require.NoError(t, err)
require.NoError(t, ds.AddLabelsToHost(ctx, thirdHost.ID, []uint{label1.ID}))
thirdHost.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, thirdHost)
require.NoError(t, err)
time.Sleep(time.Second)
// assign the label to the software installers
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID1, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeExcludeAny,
ByName: map[string]fleet.LabelIdent{label1.Name: {LabelName: label1.Name, LabelID: label1.ID}},
}, softwareTypeInstaller)
require.NoError(t, err)
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), selfServiceInstallerID, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeExcludeAny,
ByName: map[string]fleet.LabelIdent{label1.Name: {LabelName: label1.Name, LabelID: label1.ID}},
}, softwareTypeInstaller)
require.NoError(t, err)
// should be empty as the installer label is "exclude any"
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Empty(t, software)
software, _, err = ds.ListHostSoftware(ctx, anotherHost, opts)
require.NoError(t, err)
require.Len(t, software, 1)
require.Equal(t, software[0].InstalledVersions[0].Version, softwareAlreadyInstalled.Version)
require.Nil(t, software[0].SoftwarePackage)
software, _, err = ds.ListHostSoftware(ctx, thirdHost, opts)
require.NoError(t, err)
require.Len(t, software, 1)
require.Equal(t, software[0].InstalledVersions[0].Version, selfServiceinstaller.Version)
require.Nil(t, software[0].SoftwarePackage)
hostsNotInScope, err := ds.GetExcludedHostIDMapForSoftwareInstaller(ctx, installerID1)
require.NoError(t, err)
require.Contains(t, hostsNotInScope, host.ID)
// installer1 should be out of scope since the label is "exclude any"
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
require.NoError(t, err)
require.False(t, scoped)
// self-service flag
selfServiceOpts := fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 10}, IncludeAvailableForInstall: true, SelfServiceOnly: true, IsMDMEnrolled: true}
// softwareAlreadyInstalled should not be returned because it is not self service
software, _, err = ds.ListHostSoftware(ctx, anotherHost, selfServiceOpts)
require.NoError(t, err)
require.Len(t, software, 0)
// selfServiceinstaller is self service however, excluded by label so should not be returned
software, _, err = ds.ListHostSoftware(ctx, thirdHost, selfServiceOpts)
require.NoError(t, err)
require.Len(t, software, 0)
selfServiceOpts.OnlyAvailableForInstall = true
software, _, err = ds.ListHostSoftware(ctx, anotherHost, selfServiceOpts)
require.NoError(t, err)
require.Len(t, software, 0)
software, _, err = ds.ListHostSoftware(ctx, thirdHost, selfServiceOpts)
require.NoError(t, err)
require.Len(t, software, 0)
// Update the label to be "include any"
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID1, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeIncludeAny,
ByName: map[string]fleet.LabelIdent{label1.Name: {LabelName: label1.Name, LabelID: label1.ID}},
}, softwareTypeInstaller)
require.NoError(t, err)
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software)
// Now installer1 is in scope again: label is "include any"
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
require.NoError(t, err)
require.True(t, scoped)
// Add an installer. No label yet.
installer2 := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
PreInstallQuery: "SELECT 1",
PostInstallScript: "world",
UninstallScript: "goodbye",
InstallerFile: tfr1,
StorageID: "storage3",
Filename: "file3",
Title: "file3",
Version: "2.0",
Source: "apps",
UserID: user1.ID,
BundleIdentifier: "bi3",
Platform: "darwin",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
installerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, installer2)
require.NoError(t, err)
expectedInstallers[installer2.Filename] = &fleet.SoftwarePackageOrApp{
Name: installer2.Filename,
Version: installer2.Version,
Platform: installer2.Platform,
SelfService: ptr.Bool(false),
}
// There's 2 installers now: installerID1 and installerID2 (because it has no labels associated)
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software)
// Add "exclude any" labels to installer2
label2, err := ds.NewLabel(ctx, &fleet.Label{Name: "label2" + t.Name(), Query: "select 1"})
require.NoError(t, err)
label3, err := ds.NewLabel(ctx, &fleet.Label{Name: "label3" + t.Name(), LabelMembershipType: fleet.LabelMembershipTypeManual})
require.NoError(t, err)
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID2, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeExcludeAny,
ByName: map[string]fleet.LabelIdent{
label2.Name: {LabelName: label2.Name, LabelID: label2.ID},
label3.Name: {LabelName: label3.Name, LabelID: label3.ID},
},
}, softwareTypeInstaller)
require.NoError(t, err)
// Now host has label1, label2
require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{label2.ID}))
host.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, host)
require.NoError(t, err)
time.Sleep(time.Second)
// List should be back to just installer1
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software, installer2.Filename)
// installer1 is still in scope
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
require.NoError(t, err)
require.True(t, scoped)
// installer2 is out of scope, because host has label2
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID2, host.ID)
require.NoError(t, err)
require.False(t, scoped)
// Add an installer. No label yet.
installer3 := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
PreInstallQuery: "SELECT 1",
PostInstallScript: "world",
UninstallScript: "goodbye",
InstallerFile: tfr1,
StorageID: "storage4",
Filename: "file4",
Title: "file4",
Version: "3.0",
Source: "apps",
UserID: user1.ID,
BundleIdentifier: "bi4",
Platform: "darwin",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
installerID3, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, installer3)
require.NoError(t, err)
time.Sleep(time.Second)
expectedInstallers[installer3.Filename] = &fleet.SoftwarePackageOrApp{
Name: installer3.Filename,
Version: installer3.Version,
Platform: installer3.Platform,
SelfService: ptr.Bool(false),
}
// Add a new label and apply it to the installer. There are no hosts with this label.
label4, err := ds.NewLabel(ctx, &fleet.Label{Name: "label4" + t.Name(), LabelMembershipType: fleet.LabelMembershipTypeDynamic})
require.NoError(t, err)
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID3, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeExcludeAny,
ByName: map[string]fleet.LabelIdent{label4.Name: {LabelName: label4.Name, LabelID: label4.ID}},
}, softwareTypeInstaller)
require.NoError(t, err)
// We should have [installerID1, installerID3], but the exclude any label has
// no results for this host yet, so it's just installerID1 for now.
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, software, 1)
// installer1 is still in scope
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
require.NoError(t, err)
require.True(t, scoped)
// installer3 is not in scope yet, because label is "exclude any" and host doesn't have results
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID3, host.ID)
require.NoError(t, err)
require.False(t, scoped)
// mark as if label had been reported (but host is still not a member)
host.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, host)
require.NoError(t, err)
time.Sleep(time.Second)
// now has 2 software (installer1 and 3)
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software, installer2.Filename)
// installer1 is still in scope
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
require.NoError(t, err)
require.True(t, scoped)
// installer3 is in scope, because label is "exclude any" and host doesn't have the label
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID3, host.ID)
require.NoError(t, err)
require.True(t, scoped)
// Now include hosts with label4. No host has this label, so we shouldn't see installerID3 anymore.
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID3, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeIncludeAny,
ByName: map[string]fleet.LabelIdent{label4.Name: {LabelName: label4.Name, LabelID: label4.ID}},
}, softwareTypeInstaller)
require.NoError(t, err)
// We should have [installerID1]
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software, installer2.Filename, installer3.Filename)
// installer1 is still in scope
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
require.NoError(t, err)
require.True(t, scoped)
// installer3 is not in scope
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID3, host.ID)
require.NoError(t, err)
require.False(t, scoped)
// Add yet another installer. No label yet.
installer4 := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
PreInstallQuery: "SELECT 1",
PostInstallScript: "world",
UninstallScript: "goodbye",
InstallerFile: tfr1,
StorageID: "storage5",
Filename: "file5",
Title: "file5",
Version: "3.0",
Source: "apps",
UserID: user1.ID,
BundleIdentifier: "bi5",
Platform: "darwin",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
installerID4, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, installer4)
require.NoError(t, err)
// No labels yet, so we should see it
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software, installer2.Filename, installer3.Filename, installer4.Filename)
// Create a new manual label and apply it to the new installer
label5, err := ds.NewLabel(ctx, &fleet.Label{Name: "label5" + t.Name(), LabelMembershipType: fleet.LabelMembershipTypeManual})
require.NoError(t, err)
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID4, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeExcludeAny,
ByName: map[string]fleet.LabelIdent{label5.Name: {LabelName: label5.Name, LabelID: label5.ID}},
}, softwareTypeInstaller)
require.NoError(t, err)
// Installer4 is still listed and does not need the host's LabelUpdatedAt to be updated
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software, installer2.Filename, installer3.Filename, installer4.Filename)
t.Run("include_all", func(t *testing.T) {
hostIncludeAll := test.NewHost(t, ds, "host_include_all", "", "host1key_include_all", "host1uuid_include_all", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, hostIncludeAll, false)
label4, err := ds.NewLabel(ctx, &fleet.Label{Name: "label4" + t.Name()})
require.NoError(t, err)
// Scope installer1 to include_all: [label1, label4].
// hostIncludeAll has neither label yet, so installer1 should be out of scope.
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID1, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeIncludeAll,
ByName: map[string]fleet.LabelIdent{label1.Name: {LabelName: label1.Name, LabelID: label1.ID}, label4.Name: {LabelName: label4.Name, LabelID: label4.ID}},
}, softwareTypeInstaller)
require.NoError(t, err)
// host has no labels yet — installer1 is out of scope
scoped, err := ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, hostIncludeAll.ID)
require.NoError(t, err)
require.False(t, scoped)
software, _, err = ds.ListHostSoftware(ctx, hostIncludeAll, opts)
require.NoError(t, err)
// installer1 should be absent (out of scope), installer4 absent (no labels on host)
checkSoftware(software, installer1.Filename, installer4.Filename)
// add only label1: host still missing label4, so still out of scope
require.NoError(t, ds.AddLabelsToHost(ctx, hostIncludeAll.ID, []uint{label1.ID}))
hostIncludeAll.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, hostIncludeAll)
require.NoError(t, err)
time.Sleep(time.Second)
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, hostIncludeAll.ID)
require.NoError(t, err)
require.False(t, scoped)
software, _, err = ds.ListHostSoftware(ctx, hostIncludeAll, opts)
require.NoError(t, err)
checkSoftware(software, installer1.Filename, installer4.Filename)
// add label4 — host now has both required labels, so installer1 is in scope
require.NoError(t, ds.AddLabelsToHost(ctx, hostIncludeAll.ID, []uint{label4.ID}))
hostIncludeAll.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, hostIncludeAll)
require.NoError(t, err)
time.Sleep(time.Second)
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, hostIncludeAll.ID)
require.NoError(t, err)
require.True(t, scoped)
software, _, err = ds.ListHostSoftware(ctx, hostIncludeAll, opts)
require.NoError(t, err)
// installer1 is now in scope; installer4 still absent (no labels on host match it)
checkSoftware(software, installer4.Filename)
})
}
func testListHostSoftwareVulnerableAndVPP(t *testing.T, ds *Datastore) {
ctx := context.Background()
// filter by only vulnerable software
vulnerableOnlyOpts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name",
},
VulnerableOnly: true,
IncludeAvailableForInstall: true,
}
// filter by has known exploit
knownExploitOpts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name",
},
VulnerableOnly: true,
KnownExploit: true,
}
// filter by min CVSSScore
minCVSSScoreOpts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name",
},
VulnerableOnly: true,
MinimumCVSS: 2.0,
}
// filter by max CVSSScore
maxCVSSScoreOpts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name",
},
VulnerableOnly: true,
MaximumCVSS: 1.5,
}
now := time.Now().UTC().Truncate(time.Second)
// create a user
user := test.NewUser(t, ds, "Alice", "alice@example.com", true)
// create a team
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
// create a host on team
tmHost := test.NewHost(t, ds, "host", "", "hostkey", "hostuuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, tmHost, false)
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{tmHost.ID}))
require.NoError(t, err)
tmHost.TeamID = &tm.ID
// add software to the host
software := []fleet.Software{
{Name: "a", Version: "0.0.1", Source: "ios_apps"},
{Name: "b", Version: "0.0.2", Source: "apps"},
{Name: "c", Version: "0.0.3", Source: "apps"},
}
byNSV := map[string]fleet.Software{}
for _, s := range software {
byNSV[s.Name+s.Source+s.Version] = s
}
mutationResults, err := ds.UpdateHostSoftware(ctx, tmHost.ID, software)
for _, m := range mutationResults.Inserted {
s, ok := byNSV[m.Name+m.Source+m.Version]
assert.True(t, ok)
s.ID = m.ID
byNSV[s.Name+s.Source+s.Version] = s
}
require.NoError(t, err)
assert.Len(t, mutationResults.Inserted, len(software))
var cSoftwareID uint
for _, inserted := range mutationResults.Inserted {
if inserted.Name == "c" {
cSoftwareID = inserted.ID
}
}
require.NotZero(t, cSoftwareID)
var cTitleID uint
err = sqlx.GetContext(ctx, ds.primary, &cTitleID, `SELECT title_id FROM software WHERE id = ?`, cSoftwareID)
require.NoError(t, err)
// Insert vulnerable software with the same software title as c,
// but this version is not added to host.
result, err := ds.writer(ctx).ExecContext(
ctx,
`INSERT INTO software (name, version, source, title_id, checksum) VALUES (?, ?, ?, ?, ?)`,
"c", "0.0.1", "apps", &cTitleID, []byte("c.0.0.1apps"),
)
require.NoError(t, err)
insertedID, err := result.LastInsertId()
require.NoError(t, err)
_, err = ds.InsertSoftwareVulnerability(
ctx,
fleet.SoftwareVulnerability{SoftwareID: uint(insertedID), CVE: "CVE-c-00c1"},
fleet.NVDSource,
)
require.NoError(t, err)
getKey := func(i int) string {
return software[i].Name + software[i].Source + software[i].Version
}
a := getKey(0)
b := getKey(1)
// add vulnerabilities to a and b
vulns := []fleet.SoftwareVulnerability{
{SoftwareID: byNSV[a].ID, CVE: "CVE-a-0001"},
{SoftwareID: byNSV[b].ID, CVE: "CVE-b-0002"},
}
for _, v := range vulns {
_, err = ds.InsertSoftwareVulnerability(ctx, v, fleet.NVDSource)
require.NoError(t, err)
}
// add meta around vulnerabilities
cveMeta := []fleet.CVEMeta{
{
CVE: "CVE-a-0001",
CVSSScore: ptr.Float64(2.5),
CISAKnownExploit: ptr.Bool(true),
Published: ptr.Time(now.Add(-2 * time.Hour)),
Description: "description for CVE-a-0001",
},
{
CVE: "CVE-b-0002",
CVSSScore: ptr.Float64(1.0),
CISAKnownExploit: ptr.Bool(false),
Published: ptr.Time(now),
Description: "description for CVE-b-0002",
},
}
err = ds.InsertCVEMeta(context.Background(), cveMeta)
require.NoError(t, err)
swPaths := map[string]struct{}{}
err = ds.UpdateHostSoftwareInstalledPaths(ctx, tmHost.ID, swPaths, mutationResults)
require.NoError(t, err)
var ensureVulnerableState []struct {
HostID *uint `db:"host_id"`
CVE *string `db:"cve"`
}
err = ds.writer(ctx).SelectContext(ctx, &ensureVulnerableState, `
SELECT
host_software.host_id as host_id,
software_cve.cve
FROM software_titles
LEFT JOIN software on software.title_id = software_titles.id
LEFT JOIN software_cve on software_cve.software_id = software.id
LEFT JOIN host_software on host_software.software_id = software.id
WHERE
software_titles.name = 'c'
ORDER BY host_software.host_id IS NOT NULL, host_software.host_id
`)
require.NoError(t, err)
require.Nil(t, ensureVulnerableState[0].HostID)
require.NotNil(t, ensureVulnerableState[0].CVE)
require.Equal(t, ensureVulnerableState[1].HostID, &tmHost.ID)
require.Nil(t, ensureVulnerableState[1].CVE)
// Ensure that software "a" & "b" are returned as they are vulnerable
require.NoError(t, ds.LoadHostSoftware(ctx, tmHost, false))
sw, _, err := ds.ListHostSoftware(ctx, tmHost, vulnerableOnlyOpts)
require.NoError(t, err)
require.Len(t, sw, 2)
require.Equal(t, software[0].Name, sw[0].Name)
require.Equal(t, software[1].Name, sw[1].Name)
// last_software_install
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
installerTm1, installerTitleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
InstallerFile: tfr1,
StorageID: "storage1",
Filename: "file1",
Title: "file1",
Version: "1.0",
Source: "apps",
TeamID: &tm.ID,
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
hostInstall1, err := ds.InsertSoftwareInstallRequest(ctx, tmHost.ID, installerTm1, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: tmHost.ID,
InstallUUID: hostInstall1,
InstallScriptExitCode: ptr.Int(0),
}, nil)
require.NoError(t, err)
software = append(software, fleet.Software{
Name: "file1",
Version: "1.0",
Source: "apps",
TitleID: &installerTitleID,
})
mutationResults, err = ds.UpdateHostSoftware(ctx, tmHost.ID, software)
require.NoError(t, err)
// set up vpp
dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Test org"+t.Name(), "Test location"+t.Name())
require.NoError(t, err)
tok1, err := ds.InsertVPPToken(ctx, dataToken)
require.NoError(t, err)
_, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{})
require.NoError(t, err)
time.Sleep(time.Second) // ensure the labels_updated_at timestamp is before labels creation
// last_vpp_install
vPPApp := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}},
Name: "vpp1",
BundleIdentifier: "com.app.vpp1",
}
va1, err := ds.InsertVPPAppWithTeam(ctx, vPPApp, &tm.ID)
require.NoError(t, err)
vpp1 := va1.AdamID
vpp1CmdUUID := createVPPAppInstallRequest(t, ds, tmHost, vpp1, user)
_, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), tmHost.ID, "")
require.NoError(t, err)
createVPPAppInstallResult(t, ds, tmHost, vpp1CmdUUID, fleet.MDMAppleStatusAcknowledged)
// Insert software entry for vpp app
res, err := ds.writer(ctx).ExecContext(ctx, `
INSERT INTO software (name, version, source, bundle_identifier, title_id, checksum)
VALUES (?, ?, ?, ?, ?, ?)
`,
vPPApp.Name, "0.1.1", "apps", vPPApp.BundleIdentifier, vPPApp.TitleID, hex.EncodeToString([]byte("vpp1")),
)
require.NoError(t, err)
vppSoftwareID, err := res.LastInsertId()
require.NoError(t, err)
time.Sleep(time.Second) // ensure a different created_at timestamp
_, err = ds.InsertSoftwareVulnerability(
ctx,
fleet.SoftwareVulnerability{SoftwareID: uint(vppSoftwareID), CVE: "CVE-vpp1-0001"},
fleet.NVDSource,
)
require.NoError(t, err)
// Ensure that software "a" & "b" are returned as they are the only vulnerable apps at this point
// "vpp1" app is not in inventory yet, so it should not be returned
sw, _, err = ds.ListHostSoftware(ctx, tmHost, vulnerableOnlyOpts)
require.NoError(t, err)
require.Len(t, sw, 2)
require.Equal(t, software[0].Name, sw[0].Name)
require.Equal(t, software[1].Name, sw[1].Name)
// "vpp1" is now in inventory
// "vpp1" although vpp, it is vulnerable software installed on host so make sure it is also returned
_, err = ds.writer(ctx).ExecContext(ctx, `
INSERT INTO host_software (host_id, software_id)
VALUES (?, ?)
`, tmHost.ID, vppSoftwareID)
require.NoError(t, err)
sw, _, err = ds.ListHostSoftware(ctx, tmHost, vulnerableOnlyOpts)
require.NoError(t, err)
require.Len(t, sw, 3)
require.Equal(t, vPPApp.Name, sw[2].Name)
require.Len(t, sw[2].InstalledVersions, 1)
require.Equal(t, "0.1.1", sw[2].InstalledVersions[0].Version)
require.Equal(t, "adam_vpp_1", sw[2].AppStoreApp.AppStoreID)
// remove "vpp1" vulnerability
_, err = ds.writer(ctx).ExecContext(ctx, `
DELETE FROM software_cve
WHERE software_id = ? AND cve = ?
`, vppSoftwareID, "CVE-vpp1-0001")
require.NoError(t, err)
// upcoming_software_install
installerID, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "foo",
Source: "bar",
InstallScript: "echo",
TeamID: &tm.ID,
Filename: "foo.pkg",
UserID: user.ID,
Version: "1.0.0",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
// insert non-vulnerable software without adding to host
_, err = ds.writer(ctx).ExecContext(
ctx,
`INSERT INTO software (name, version, source, title_id, checksum) VALUES (?, ?, ?, ?, ?)`,
"foo", "1.0.0", "bar", &titleID, []byte("foo1.0"),
)
require.NoError(t, err)
// insert vulnerable software with the same software title, but still not added to host
result, err = ds.writer(ctx).ExecContext(
ctx,
`INSERT INTO software (name, version, source, title_id, checksum) VALUES (?, ?, ?, ?, ?)`,
"foo", "0.5", "bar", &titleID, []byte("foo0.5"),
)
require.NoError(t, err)
insertedID, err = result.LastInsertId()
require.NoError(t, err)
_, err = ds.InsertSoftwareVulnerability(
ctx,
fleet.SoftwareVulnerability{SoftwareID: uint(insertedID), CVE: "CVE-bar-0001"},
fleet.NVDSource,
)
require.NoError(t, err)
// pending install request for foo1.0 (non-vulnerable version)
_, err = ds.InsertSoftwareInstallRequest(ctx, tmHost.ID, installerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
// Ensure that software "a" & "b" are returned as they are the only vulnerable apps at this point
sw, _, err = ds.ListHostSoftware(ctx, tmHost, vulnerableOnlyOpts)
require.NoError(t, err)
require.Len(t, sw, 2)
require.Equal(t, software[0].Name, sw[0].Name)
require.Equal(t, software[1].Name, sw[1].Name)
// upcoming_software_uninstall
installerID, titleID, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "foo2",
Source: "bar2",
InstallScript: "cat",
TeamID: &tm.ID,
Filename: "foo2.pkg",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
// insert into software without adding to host
_, err = ds.writer(ctx).ExecContext(
ctx,
`INSERT INTO software (name, version, source, title_id, checksum) VALUES (?, ?, ?, ?, ?)`,
"foo2", "1.0.0", "bar2", &titleID, []byte("foo2"),
)
require.NoError(t, err)
// pending install request
err = ds.InsertSoftwareUninstallRequest(ctx, "abc123", tmHost.ID, installerID, true)
require.NoError(t, err)
// Ensure that software "a" & "b" are returned as they are the only vulnerable apps at this point
sw, _, err = ds.ListHostSoftware(ctx, tmHost, vulnerableOnlyOpts)
require.NoError(t, err)
require.Len(t, sw, 2)
require.Equal(t, software[0].Name, sw[0].Name)
require.Equal(t, software[1].Name, sw[1].Name)
// upcoming_vpp_install
pendingVPPApp := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.MacOSPlatform}},
Name: "vpp2",
BundleIdentifier: "com.app.vpp2",
}
va2, err := ds.InsertVPPAppWithTeam(ctx, pendingVPPApp, &tm.ID)
require.NoError(t, err)
vpp2 := va2.AdamID
createVPPAppInstallRequest(t, ds, tmHost, vpp2, user)
_, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), tmHost.ID, "")
require.NoError(t, err)
// Insert software entry for vpp app
_, err = ds.writer(ctx).ExecContext(ctx, `
INSERT INTO software (name, version, source, bundle_identifier, title_id, checksum)
VALUES (?, ?, ?, ?, ?, ?)
`,
pendingVPPApp.Name, pendingVPPApp.LatestVersion, "apps", pendingVPPApp.BundleIdentifier, pendingVPPApp.TitleID, hex.EncodeToString([]byte("vpp2")),
)
require.NoError(t, err)
time.Sleep(time.Second) // ensure a different created_at timestamp
// Ensure that software "a" & "b" are returned as they are the only vulnerable apps at this point
sw, _, err = ds.ListHostSoftware(ctx, tmHost, vulnerableOnlyOpts)
require.NoError(t, err)
require.Len(t, sw, 2)
require.Equal(t, software[0].Name, sw[0].Name)
require.Equal(t, software[1].Name, sw[1].Name)
// host has vulnerable software installed (not by fleet) that happens to match a vpp app in the fleet catalog
hostInstalledVpps := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.MacOSPlatform}},
Name: "vpp3",
BundleIdentifier: "com.app.vpp3",
}
hvpp, err := ds.InsertVPPAppWithTeam(ctx, hostInstalledVpps, &tm.ID)
require.NoError(t, err)
res, err = ds.writer(ctx).ExecContext(ctx, `
INSERT INTO software (name, version, source, bundle_identifier, title_id, checksum)
VALUES (?, ?, ?, ?, ?, ?)
`,
hostInstalledVpps.Name, "0.1.0", "apps", hostInstalledVpps.BundleIdentifier, hvpp.TitleID, []byte("vpp3v0.1.0"),
)
require.NoError(t, err)
time.Sleep(time.Second)
vppSoftwareID, err = res.LastInsertId()
require.NoError(t, err)
_, err = ds.writer(ctx).ExecContext(ctx, `
INSERT INTO host_software (host_id, software_id)
VALUES (?, ?)
`, tmHost.ID, vppSoftwareID)
require.NoError(t, err)
// add vulnerabilities to last_software_install and last_vpp_install and host installed vpp app
vulns = []fleet.SoftwareVulnerability{
{SoftwareID: mutationResults.Inserted[0].ID, CVE: "CVE-file1-0003"},
{SoftwareID: uint(vppSoftwareID), CVE: "CVE-vpp3-0005"},
}
for _, v := range vulns {
_, err = ds.InsertSoftwareVulnerability(ctx, v, fleet.NVDSource)
require.NoError(t, err)
}
// add meta around vulnerabilities
cveMeta = []fleet.CVEMeta{
{
CVE: "CVE-file1-0003",
CVSSScore: ptr.Float64(1.7),
CISAKnownExploit: ptr.Bool(false),
Published: ptr.Time(now.Add(-2 * time.Hour)),
Description: "description for CVE-file1-0003",
},
{
CVE: "CVE-vpp1-0004",
CVSSScore: ptr.Float64(1.8),
CISAKnownExploit: ptr.Bool(false),
Published: ptr.Time(now),
Description: "description for CVE-vpp1-0004",
},
{
CVE: "CVE-vpp3-0005",
CVSSScore: ptr.Float64(1.7),
CISAKnownExploit: ptr.Bool(false),
Published: ptr.Time(now.Add(-2 * time.Hour)),
Description: "description for CVE-vpp3-0005",
},
}
err = ds.InsertCVEMeta(context.Background(), cveMeta)
require.NoError(t, err)
sw, _, err = ds.ListHostSoftware(ctx, tmHost, vulnerableOnlyOpts)
require.NoError(t, err)
require.Len(t, sw, 4)
require.Equal(t, software[0].Name, sw[0].Name) // "a"
require.Equal(t, software[1].Name, sw[1].Name) // "b"
require.Equal(t, software[3].Name, sw[2].Name) // "file1"
// "vpp3" although vpp, it is vulnerable software installed on host
require.Equal(t, hostInstalledVpps.Name, sw[3].Name)
require.Len(t, sw[3].InstalledVersions, 1)
require.Equal(t, "0.1.0", sw[3].InstalledVersions[0].Version)
require.Equal(t, "adam_vpp_3", sw[3].AppStoreApp.AppStoreID)
sw, _, err = ds.ListHostSoftware(ctx, tmHost, knownExploitOpts)
require.NoError(t, err)
require.Len(t, sw, 1)
require.Equal(t, software[0].Name, sw[0].Name) // should only return "a"
sw, _, err = ds.ListHostSoftware(ctx, tmHost, minCVSSScoreOpts)
require.NoError(t, err)
require.Len(t, sw, 1)
require.Equal(t, software[0].Name, sw[0].Name) // should only return "a"
sw, _, err = ds.ListHostSoftware(ctx, tmHost, maxCVSSScoreOpts)
require.NoError(t, err)
require.Len(t, sw, 1)
require.Equal(t, software[1].Name, sw[0].Name) // should only return "b"
sw, _, err = ds.ListHostSoftware(ctx, tmHost, fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name",
},
VulnerableOnly: true,
MinimumCVSS: 1.6,
IncludeAvailableForInstall: true,
})
require.NoError(t, err)
// should return "a" (2.5), "file1" (1.7), "vpp3" (1.7)
require.Len(t, sw, 3)
require.Equal(t, software[0].Name, sw[0].Name) // should only return "a"
require.Equal(t, software[3].Name, sw[1].Name) // should only return "file1"
require.Equal(t, hostInstalledVpps.Name, sw[2].Name) // should only return "vpp3"
matchingsOpts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name",
},
IncludeAvailableForInstall: true,
VulnerableOnly: true,
MinimumCVSS: 1.5,
MaximumCVSS: 2.0,
}
// should return "file1" & "vpp3" (vpp1 is not compatible with the platform)
sw, _, err = ds.ListHostSoftware(ctx, tmHost, matchingsOpts)
require.NoError(t, err)
require.Len(t, sw, 2)
require.Equal(t, "file1", sw[0].Name)
require.Equal(t, "vpp3", sw[1].Name)
require.Len(t, sw[1].InstalledVersions, 1)
require.Equal(t, "adam_vpp_3", sw[1].AppStoreApp.AppStoreID)
}
func testListHostSoftwareQuerySearching(t *testing.T, ds *Datastore) {
ctx := context.Background()
// create a user
user := test.NewUser(t, ds, "Alice", "alice@example.com", true)
// create a team
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
nanoEnroll(t, ds, host, false)
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{host.ID}))
require.NoError(t, err)
host.TeamID = &tm.ID
software := []fleet.Software{
{Name: "microsoft office 2025", Version: "1.0.0", Source: "apps", BundleIdentifier: "com.example.office"},
{Name: "1password", Version: "1.0.0", Source: "apps", BundleIdentifier: "com.example.1password"},
{Name: "microsoft edge", Version: "1.0.0", Source: "apps", BundleIdentifier: "com.example.edge"},
{Name: "chrome", Version: "1.0.0", Source: "apps", BundleIdentifier: "com.example.chrome"},
{Name: "brave", Version: "1.0.0", Source: "apps", BundleIdentifier: "com.example.brave"},
}
byName := map[string]fleet.Software{}
for _, s := range software {
byName[s.Name] = s
}
mutationResults, err := ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.Len(t, mutationResults.Inserted, len(software))
for _, m := range mutationResults.Inserted {
s := byName[m.Name]
s.ID = m.ID
byName[s.Name] = s
}
vulns := []fleet.SoftwareVulnerability{
{SoftwareID: byName["1password"].ID, CVE: "CVE-2025-0001"},
{SoftwareID: byName["chrome"].ID, CVE: "CVE-2024-0001"},
{SoftwareID: byName["brave"].ID, CVE: "CVE-2024-0002"},
}
for _, v := range vulns {
_, err = ds.InsertSoftwareVulnerability(ctx, v, fleet.NVDSource)
require.NoError(t, err)
}
// no search term, make sure we get all software
sw, meta, err := ds.ListHostSoftware(
ctx,
host,
fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
PerPage: 11,
IncludeMetadata: true,
OrderKey: "name",
TestSecondaryOrderKey: "source",
},
},
)
require.NoError(t, err)
require.Len(t, sw, 5)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 5}, meta)
// search for microsoft
sw, meta, err = ds.ListHostSoftware(
ctx,
host,
fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
PerPage: 11,
IncludeMetadata: true,
OrderKey: "name",
TestSecondaryOrderKey: "source",
MatchQuery: "microsoft",
},
},
)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 2}, meta)
require.Equal(t, software[2].Name, sw[0].Name)
require.Equal(t, software[0].Name, sw[1].Name)
// search for 2025
sw, meta, err = ds.ListHostSoftware(
ctx,
host,
fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
PerPage: 11,
IncludeMetadata: true,
OrderKey: "name",
TestSecondaryOrderKey: "source",
MatchQuery: "2025",
},
},
)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 2}, meta)
require.Equal(t, software[1].Name, sw[0].Name)
require.Equal(t, software[0].Name, sw[1].Name)
// search for cve-2024
sw, meta, err = ds.ListHostSoftware(
ctx,
host,
fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
PerPage: 11,
IncludeMetadata: true,
OrderKey: "name",
TestSecondaryOrderKey: "source",
MatchQuery: "2024",
},
},
)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 2}, meta)
require.Equal(t, software[4].Name, sw[0].Name)
require.Equal(t, software[3].Name, sw[1].Name)
// search for brave and `VulnerableOnly` filter
sw, meta, err = ds.ListHostSoftware(
ctx,
host,
fleet.HostSoftwareTitleListOptions{
VulnerableOnly: true,
ListOptions: fleet.ListOptions{
PerPage: 11,
IncludeMetadata: true,
OrderKey: "name",
TestSecondaryOrderKey: "source",
MatchQuery: "brave",
},
},
)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 1}, meta)
require.Equal(t, software[4].Name, sw[0].Name)
// set up vpp
dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Test org"+t.Name(), "Test location"+t.Name())
require.NoError(t, err)
tok1, err := ds.InsertVPPToken(ctx, dataToken)
require.NoError(t, err)
_, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{})
require.NoError(t, err)
time.Sleep(time.Second) // ensure the labels_updated_at timestamp is before labels creation
// last_vpp_install
vPPApp := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}},
Name: "microsoft teams",
BundleIdentifier: "com.app.teams",
}
va1, err := ds.InsertVPPAppWithTeam(ctx, vPPApp, &tm.ID)
require.NoError(t, err)
vpp1 := va1.AdamID
vpp1CmdUUID := createVPPAppInstallRequest(t, ds, host, vpp1, user)
_, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), host.ID, "")
require.NoError(t, err)
createVPPAppInstallResult(t, ds, host, vpp1CmdUUID, fleet.MDMAppleStatusAcknowledged)
// Insert software entry for vpp app
res, err := ds.writer(ctx).ExecContext(ctx, `
INSERT INTO software (name, version, source, bundle_identifier, title_id, checksum)
VALUES (?, ?, ?, ?, ?, ?)
`,
vPPApp.Name, vPPApp.LatestVersion, "apps", vPPApp.BundleIdentifier, vPPApp.TitleID, hex.EncodeToString([]byte("vpp1")),
)
require.NoError(t, err)
time.Sleep(time.Second) // ensure a different created_at timestamp
vppSoftwareID, err := res.LastInsertId()
require.NoError(t, err)
_, err = ds.writer(ctx).ExecContext(ctx, `
INSERT INTO host_software (host_id, software_id)
VALUES (?, ?)
`, host.ID, vppSoftwareID)
require.NoError(t, err)
// search for microsoft
sw, meta, err = ds.ListHostSoftware(
ctx,
host,
fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
PerPage: 11,
IncludeMetadata: true,
OrderKey: "name",
TestSecondaryOrderKey: "source",
MatchQuery: "microsoft",
},
},
)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 3}, meta)
require.Equal(t, software[2].Name, sw[0].Name)
require.Equal(t, software[0].Name, sw[1].Name)
require.Equal(t, vPPApp.Name, sw[2].Name)
// search with self-service
vPPAppSlack := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{SelfService: true, VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.MacOSPlatform}},
Name: "slack",
BundleIdentifier: "com.app.slack",
}
_, err = ds.InsertVPPAppWithTeam(ctx, vPPAppSlack, &tm.ID)
require.NoError(t, err)
vPPApp1Password := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{SelfService: true, VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.MacOSPlatform}},
Name: "1password",
BundleIdentifier: "com.app.1password",
}
_, err = ds.InsertVPPAppWithTeam(ctx, vPPApp1Password, &tm.ID)
require.NoError(t, err)
opts := fleet.HostSoftwareTitleListOptions{
SelfServiceOnly: true,
IsMDMEnrolled: true,
IncludeAvailableForInstall: true,
ListOptions: fleet.ListOptions{
PerPage: 10,
IncludeMetadata: true,
OrderKey: "name",
TestSecondaryOrderKey: "source",
},
}
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Len(t, sw, 2)
require.Equal(t, vPPApp1Password.Name, sw[0].Name)
require.Equal(t, vPPAppSlack.Name, sw[1].Name)
opts.ListOptions.MatchQuery = "1password"
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Len(t, sw, 1)
require.Equal(t, vPPApp1Password.Name, sw[0].Name)
}
func testListHostSoftwareWithLabelScopingVPP(t *testing.T, ds *Datastore) {
ctx := context.Background()
// create a host
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, host, false)
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Test org"+t.Name(), "Test location"+t.Name())
require.NoError(t, err)
tok1, err := ds.InsertVPPToken(ctx, dataToken)
require.NoError(t, err)
_, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{})
require.NoError(t, err)
time.Sleep(time.Second) // ensure the labels_updated_at timestamp is before labels creation
vppApp := &fleet.VPPApp{Name: "vpp_app_1", VPPAppTeam: fleet.VPPAppTeam{SelfService: true, VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b1"}
vppApp, err = ds.InsertVPPAppWithTeam(ctx, vppApp, nil)
require.NoError(t, err)
vppAppTeamID := vppApp.VPPAppTeam.AppTeamID
// create a software installer
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
installer1 := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
PreInstallQuery: "SELECT 1",
PostInstallScript: "world",
UninstallScript: "goodbye",
InstallerFile: tfr1,
StorageID: "storage1",
Filename: "file1",
Title: "file1",
Version: "1.0",
Source: "apps",
UserID: user1.ID,
BundleIdentifier: "bi1",
Platform: "darwin",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
installerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, installer1)
require.NoError(t, err)
// we should see installer1, since it has no label associated yet
opts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
PerPage: 11,
IncludeMetadata: true,
OrderKey: "name",
TestSecondaryOrderKey: "source",
},
IncludeAvailableForInstall: true,
IsMDMEnrolled: true,
}
expectedInstallers := map[string]*fleet.SoftwarePackageOrApp{
installer1.Filename: {
Name: installer1.Filename,
Version: installer1.Version,
SelfService: ptr.Bool(false),
Platform: "darwin",
},
vppApp.Name: {
AppStoreID: vppApp.AdamID,
SelfService: ptr.Bool(true),
Platform: "darwin",
},
}
checkSoftware := func(swList []*fleet.HostSoftwareWithInstaller, excludeNames ...string) {
expectedLen := len(expectedInstallers) - len(excludeNames)
require.Equal(t, len(swList), expectedLen)
for _, got := range swList {
if got.IsPackage() {
want, ok := expectedInstallers[got.SoftwarePackage.Name]
if slices.Contains(excludeNames, got.SoftwarePackage.Name) {
require.False(t, ok)
continue
}
require.True(t, ok)
require.Equal(t, want, got.SoftwarePackage)
}
if got.IsAppStoreApp() {
want, ok := expectedInstallers[got.Name]
if slices.Contains(excludeNames, got.AppStoreApp.AppStoreID) {
require.False(t, ok)
continue
}
require.True(t, ok)
require.Equal(t, want, got.AppStoreApp)
}
}
}
software, _, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software)
// installer1 should be in scope since it has no labels
scoped, err := ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
require.NoError(t, err)
require.True(t, scoped)
// vppApp should be in scope since it has no labels
scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID)
require.NoError(t, err)
require.True(t, scoped)
// Create a couple of labels
label1, err := ds.NewLabel(ctx, &fleet.Label{Name: "label1" + t.Name()})
require.NoError(t, err)
label2, err := ds.NewLabel(ctx, &fleet.Label{Name: "label2" + t.Name()})
require.NoError(t, err)
// assign the label to the host
require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{label1.ID}))
host.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, host)
require.NoError(t, err)
time.Sleep(time.Second)
// assign the label to the software installer
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID1, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeExcludeAny,
ByName: map[string]fleet.LabelIdent{label1.Name: {LabelName: label1.Name, LabelID: label1.ID}},
}, softwareTypeInstaller)
require.NoError(t, err)
// should contain only the VPP app as the installer label is "exclude any"
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software, installer1.Filename)
hostsNotInScope, err := ds.GetExcludedHostIDMapForVPPApp(ctx, vppAppTeamID)
require.NoError(t, err)
require.Empty(t, hostsNotInScope)
hostsInScope, err := ds.GetIncludedHostIDMapForVPPApp(ctx, vppAppTeamID)
require.NoError(t, err)
require.Equal(t, map[uint]struct{}{host.ID: {}}, hostsInScope)
anotherHost := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, anotherHost, false)
// intall vpp app
vpp1CmdUUID := createVPPAppInstallRequest(t, ds, anotherHost, vppApp.AdamID, user1)
_, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), anotherHost.ID, "")
require.NoError(t, err)
createVPPAppInstallResult(t, ds, anotherHost, vpp1CmdUUID, fleet.MDMAppleStatusAcknowledged)
// Insert software entry for vpp app
_, err = ds.writer(ctx).ExecContext(ctx, `
INSERT INTO software (name, version, source, bundle_identifier, title_id, checksum)
VALUES (?, ?, ?, ?, ?, ?)
`,
vppApp.Name, vppApp.LatestVersion, "apps", vppApp.BundleIdentifier, vppApp.TitleID, hex.EncodeToString([]byte("vpp1")),
)
require.NoError(t, err)
time.Sleep(time.Second)
// update host label timestamp
require.NoError(t, ds.AddLabelsToHost(ctx, anotherHost.ID, []uint{label1.ID}))
anotherHost.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, anotherHost)
require.NoError(t, err)
time.Sleep(time.Second)
thirdHost := test.NewHost(t, ds, "host3", "", "host3key", "host3uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, thirdHost, false)
// have a pre-installed vpp app
res, err := ds.writer(ctx).ExecContext(ctx, `
INSERT INTO software (name, version, source, bundle_identifier, title_id, checksum)
VALUES (?, ?, ?, ?, ?, ?)
`,
vppApp.Name, "0.1.10", "apps", vppApp.BundleIdentifier, vppApp.TitleID, []byte("vpp1v0.1.10"),
)
require.NoError(t, err)
time.Sleep(time.Second)
softwareID, err := res.LastInsertId()
require.NoError(t, err)
_, err = ds.writer(ctx).ExecContext(ctx, `
INSERT INTO host_software (host_id, software_id)
VALUES (?, ?)
`, thirdHost.ID, softwareID)
require.NoError(t, err)
// update host label timestamp
require.NoError(t, ds.AddLabelsToHost(ctx, thirdHost.ID, []uint{label1.ID}))
thirdHost.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, thirdHost)
require.NoError(t, err)
time.Sleep(time.Second)
fourthHost := test.NewHost(t, ds, "host4", "", "host4key", "host4uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, fourthHost, false)
require.NoError(t, ds.AddLabelsToHost(ctx, fourthHost.ID, []uint{label1.ID}))
fourthHost.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, fourthHost)
require.NoError(t, err)
time.Sleep(time.Second)
scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, fourthHost.ID)
require.NoError(t, err)
require.True(t, scoped)
// Assign the label to the VPP app. Now we should have an empty list
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), vppAppTeamID, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeExcludeAny,
ByName: map[string]fleet.LabelIdent{label1.Name: {LabelName: label1.Name, LabelID: label1.ID}},
}, softwareTypeVPP)
require.NoError(t, err)
// install vpp app on fourth host
fourthHostVpp1CmdUUID := createVPPAppInstallRequest(t, ds, fourthHost, vppApp.AdamID, user1)
_, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), fourthHost.ID, "")
require.NoError(t, err)
createVPPAppInstallResult(t, ds, fourthHost, fourthHostVpp1CmdUUID, fleet.MDMAppleStatusAcknowledged)
// but inventory has not been updated yet so no software/host software records exist
software, _, err = ds.ListHostSoftware(
ctx,
fourthHost,
fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{PerPage: 10},
IncludeAvailableForInstall: true,
SelfServiceOnly: true,
IsMDMEnrolled: true,
},
)
require.NoError(t, err)
// installer one is not self service, vpp app is but out of scope due to label
checkSoftware(software, installer1.Filename, vppApp.Name)
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software, installer1.Filename, vppApp.Name)
hostsNotInScope, err = ds.GetExcludedHostIDMapForVPPApp(ctx, vppAppTeamID)
require.NoError(t, err)
require.Equal(t, map[uint]struct{}{host.ID: {}, anotherHost.ID: {}, thirdHost.ID: {}, fourthHost.ID: {}}, hostsNotInScope)
hostsInScope, err = ds.GetIncludedHostIDMapForVPPApp(ctx, vppAppTeamID)
require.NoError(t, err)
require.Empty(t, hostsInScope)
// vpp app is installed but not verified, so should still be pending
software, _, err = ds.ListHostSoftware(ctx, anotherHost, opts)
require.NoError(t, err)
require.Len(t, software, 1)
require.Equal(t, software[0].Name, vppApp.Name)
expectedStatus := fleet.SoftwareInstallPending
require.Equal(t, &expectedStatus, software[0].Status)
// but should not be available for install because of the ExcludeAny label
require.Nil(t, software[0].AppStoreApp)
// verify the install
require.NoError(t, ds.SetVPPInstallAsVerified(ctx, anotherHost.ID, vpp1CmdUUID, uuid.NewString()))
// Now the app should come back as installed
software, _, err = ds.ListHostSoftware(ctx, anotherHost, opts)
require.NoError(t, err)
require.Len(t, software, 1)
require.Equal(t, software[0].Name, vppApp.Name)
expectedStatus = fleet.SoftwareInstalled
require.Equal(t, &expectedStatus, software[0].Status)
// but should not be available for install because of the ExcludeAny label
require.Nil(t, software[0].AppStoreApp)
// vpp app is installed, so should come back in list of software
software, _, err = ds.ListHostSoftware(ctx, thirdHost, opts)
require.NoError(t, err)
require.Len(t, software, 1)
require.Equal(t, software[0].Name, vppApp.Name)
// nil because it was pre-installed on host
require.Nil(t, software[0].Status)
// but should not be available for install because of the ExcludeAny label
require.Nil(t, software[0].AppStoreApp)
// filtering by only available for install should exclude the vpp app
opts.OnlyAvailableForInstall = true
software, _, err = ds.ListHostSoftware(ctx, anotherHost, opts)
require.NoError(t, err)
checkSoftware(software, installer1.Filename, vppApp.Name)
software, _, err = ds.ListHostSoftware(ctx, thirdHost, opts)
require.NoError(t, err)
checkSoftware(software, installer1.Filename, vppApp.Name)
opts.OnlyAvailableForInstall = false
// Make the label include any. We should have both of them back.
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID1, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeIncludeAny,
ByName: map[string]fleet.LabelIdent{label1.Name: {LabelName: label1.Name, LabelID: label1.ID}},
}, softwareTypeInstaller)
require.NoError(t, err)
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), vppAppTeamID, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeIncludeAny,
ByName: map[string]fleet.LabelIdent{label1.Name: {LabelName: label1.Name, LabelID: label1.ID}},
}, softwareTypeVPP)
require.NoError(t, err)
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software)
// Give the VPP app a different label. Only the installer should show up now, since the host
// only has label1.
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), vppAppTeamID, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeIncludeAny,
ByName: map[string]fleet.LabelIdent{label2.Name: {LabelName: label2.Name, LabelID: label2.ID}},
}, softwareTypeVPP)
require.NoError(t, err)
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software, vppApp.Name)
scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID)
require.NoError(t, err)
require.False(t, scoped)
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), vppAppTeamID, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeIncludeAny,
ByName: map[string]fleet.LabelIdent{label2.Name: {LabelName: label2.Name, LabelID: label2.ID}, label1.Name: {LabelName: label1.Name, LabelID: label1.ID}},
}, softwareTypeVPP)
require.NoError(t, err)
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software)
scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID)
require.NoError(t, err)
require.True(t, scoped)
// Create another label.
time.Sleep(time.Second)
label3, err := ds.NewLabel(ctx, &fleet.Label{Name: "label3" + t.Name()})
require.NoError(t, err)
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), vppAppTeamID, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeExcludeAny,
ByName: map[string]fleet.LabelIdent{label3.Name: {LabelName: label3.Name, LabelID: label3.ID}},
}, softwareTypeVPP)
require.NoError(t, err)
// the VPP app is still out of scope, because label3 was added as exclude any and the host's
// LabelUpdatedAt isn't fresh enough.
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software, vppApp.Name)
scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID)
require.NoError(t, err)
require.False(t, scoped)
// mark as if label had been reported (but host is still not a member). This should bring the
// VPP app back in scope, since it's exclude any and the host doesn't have label 3.
host.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, host)
require.NoError(t, err)
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software)
scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID)
require.NoError(t, err)
require.True(t, scoped)
// Create a manual label (prior was dynamic) and set it instead as exclude-any for the VPP app
label4, err := ds.NewLabel(ctx, &fleet.Label{Name: "label4" + t.Name(), LabelMembershipType: fleet.LabelMembershipTypeManual})
require.NoError(t, err)
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), vppAppTeamID, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeExcludeAny,
ByName: map[string]fleet.LabelIdent{label4.Name: {LabelName: label4.Name, LabelID: label4.ID}},
}, softwareTypeVPP)
require.NoError(t, err)
// The host need not update its LabelUpdatedAt because the label is manually scoped and not applied,
// so it is immediately resolvable (and the host doesn't have the excluded label applied to it)
scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID)
require.NoError(t, err)
require.True(t, scoped)
// --- include_all tests for VPP ---
// Create two fresh labels for the include_all scope tests.
label5, err := ds.NewLabel(ctx, &fleet.Label{Name: "label5" + t.Name()})
require.NoError(t, err)
label6, err := ds.NewLabel(ctx, &fleet.Label{Name: "label6" + t.Name()})
require.NoError(t, err)
// Scope the VPP app to include_all: [label5, label6].
// host currently has label1 but not label5 or label6.
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), vppAppTeamID, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeIncludeAll,
ByName: map[string]fleet.LabelIdent{
label5.Name: {LabelName: label5.Name, LabelID: label5.ID},
label6.Name: {LabelName: label6.Name, LabelID: label6.ID},
},
}, softwareTypeVPP)
require.NoError(t, err)
// host has neither required label — out of scope
scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID)
require.NoError(t, err)
require.False(t, scoped)
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software, vppApp.Name)
// add label5 only — still missing label6, so still out of scope
require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{label5.ID}))
host.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, host)
require.NoError(t, err)
time.Sleep(time.Second)
scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID)
require.NoError(t, err)
require.False(t, scoped)
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software, vppApp.Name)
// add label6 — host now has both required labels, so VPP app is in scope
require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{label6.ID}))
host.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, host)
require.NoError(t, err)
time.Sleep(time.Second)
scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID)
require.NoError(t, err)
require.True(t, scoped)
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software)
}
func testListHostSoftwareLastOpenedAt(t *testing.T, ds *Datastore) {
ctx := context.Background()
// create a host
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, host, false)
hostInventoryOpts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{Page: 0, PerPage: 20},
SelfServiceOnly: false,
IncludeAvailableForInstall: false,
IncludeAvailableForInstallExplicitlySet: true,
OnlyAvailableForInstall: false,
IsMDMEnrolled: true,
}
var err error
var parsedTimeOne time.Time
var parsedTimeTwo time.Time
updateSoftwareForHost := func(host *fleet.Host, software []fleet.Software) {
mutationResults, err := ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.Len(t, mutationResults.Inserted, len(software))
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
query := `INSERT INTO host_software_installed_paths (host_id, software_id, installed_path) VALUES (?, ?, ?)`
for i := range host.Software {
args := []interface{}{host.ID, host.Software[i].ID, fmt.Sprintf("/Applications/%s-%s.app", host.Software[i].Name, host.Software[i].Version)}
_, err = ds.writer(ctx).ExecContext(ctx, query, args...)
require.NoError(t, err)
}
require.NoError(t, err)
}
testCases := []struct {
name string
before func(ds *Datastore)
testFunc func(*testing.T, *Datastore)
}{
{
name: "older version has been opened, newer version not opened",
before: func(ds *Datastore) {
parsedTimeOne, err = time.Parse("2006-01-02 15:04", "2025-01-01 12:00")
require.NoError(t, err)
software := []fleet.Software{
{ID: 1, Name: "Spotify", Version: "1.2.69.449", LastOpenedAt: &parsedTimeOne, TitleID: ptr.Uint(1), Source: "apps", BundleIdentifier: "com.spotify"},
{ID: 2, Name: "Spotify", Version: "1.2.70.409", LastOpenedAt: nil, TitleID: ptr.Uint(1), Source: "apps", BundleIdentifier: "com.spotify"},
}
updateSoftwareForHost(host, software)
},
testFunc: func(t *testing.T, ds *Datastore) {
expectedTimeStamps := map[string]*time.Time{
"1.2.69.449": &parsedTimeOne,
"1.2.70.409": nil,
}
sw, _, err := ds.ListHostSoftware(ctx, host, hostInventoryOpts)
require.NoError(t, err)
require.Len(t, sw, 1)
require.Len(t, sw[0].InstalledVersions, 2)
for _, installedVersion := range sw[0].InstalledVersions {
version := installedVersion.Version
if expectedTime, ok := expectedTimeStamps[version]; ok {
require.Equal(t, expectedTime, installedVersion.LastOpenedAt, "LastOpenedAt for version %s does not match expected value", version)
} else {
require.Nil(t, installedVersion.LastOpenedAt, "LastOpenedAt for version %s is not nil", version)
}
}
},
},
{
name: "Both versions have been opened",
before: func(ds *Datastore) {
parsedTimeOne, err = time.Parse("2006-01-02 15:04", "2025-01-01 12:00")
require.NoError(t, err)
parsedTimeTwo, err = time.Parse("2006-01-02 15:04", "2025-02-01 12:00")
require.NoError(t, err)
software := []fleet.Software{
{ID: 1, Name: "Spotify", Version: "1.2.69.449", LastOpenedAt: &parsedTimeOne, TitleID: ptr.Uint(1), Source: "apps", BundleIdentifier: "com.spotify"},
{ID: 2, Name: "Spotify", Version: "1.2.70.409", LastOpenedAt: &parsedTimeTwo, TitleID: ptr.Uint(1), Source: "apps", BundleIdentifier: "com.spotify"},
}
updateSoftwareForHost(host, software)
},
testFunc: func(t *testing.T, ds *Datastore) {
sw, _, err := ds.ListHostSoftware(ctx, host, hostInventoryOpts)
require.NoError(t, err)
require.Len(t, sw, 1)
require.Len(t, sw[0].InstalledVersions, 2)
expectedTimeStamps := map[string]*time.Time{
"1.2.69.449": &parsedTimeOne,
"1.2.70.409": &parsedTimeTwo,
}
for _, installedVersion := range sw[0].InstalledVersions {
version := installedVersion.Version
if expectedTime, ok := expectedTimeStamps[version]; ok {
require.Equal(t, expectedTime, installedVersion.LastOpenedAt, "LastOpenedAt for version %s does not match expected value", version)
} else {
require.Nil(t, installedVersion.LastOpenedAt, "LastOpenedAt for version %s is not nil", version)
}
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
defer TruncateTables(t, ds)
tc.before(ds)
tc.testFunc(t, ds)
})
}
}
func testListHostSoftwareSelfServiceWithLabelScopingHostInstalled(t *testing.T, ds *Datastore) {
ctx := context.Background()
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, host, false)
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{host.ID}))
require.NoError(t, err)
host.TeamID = &tm.ID
opts := fleet.HostSoftwareTitleListOptions{
SelfServiceOnly: true,
IsMDMEnrolled: true,
IncludeAvailableForInstall: true,
OnlyAvailableForInstall: false,
VulnerableOnly: false,
KnownExploit: false,
ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"},
}
dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Test org"+t.Name(), "Test location"+t.Name())
require.NoError(t, err)
tok1, err := ds.InsertVPPToken(ctx, dataToken)
require.NoError(t, err)
_, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{})
require.NoError(t, err)
time.Sleep(time.Second)
// self-service software installer
tfr2, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
selfServiceinstaller := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello 2",
PreInstallQuery: "SELECT 2",
PostInstallScript: "world 2",
UninstallScript: "goodbye 2",
InstallerFile: tfr2,
StorageID: "storage 2",
Filename: "file2",
Title: "file2",
Version: "1.0",
Source: "apps",
UserID: user1.ID,
BundleIdentifier: "bi2",
Platform: "darwin",
SelfService: true,
TeamID: &tm.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
selfServiceInstallerID, selfServiceTitleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, selfServiceinstaller)
require.NoError(t, err)
vPPApp := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
SelfService: true,
VPPAppID: fleet.VPPAppID{
AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform,
},
},
Name: "vpp1",
BundleIdentifier: "com.app.vpp1",
LatestVersion: "1.0.0",
}
_, err = ds.InsertVPPAppWithTeam(ctx, vPPApp, &tm.ID)
require.NoError(t, err)
// Install software on host
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
res, err := q.ExecContext(ctx, `INSERT INTO software (name, source, bundle_identifier, version, title_id, checksum) VALUES (?, ?, ?, ?, ?, ?)`,
selfServiceinstaller.Title, selfServiceinstaller.Source, selfServiceinstaller.BundleIdentifier, selfServiceinstaller.Version, selfServiceTitleID, []byte("checksum"))
if err != nil {
return err
}
softwareID, err := res.LastInsertId()
if err != nil {
return err
}
_, err = q.ExecContext(ctx, `INSERT INTO host_software (host_id, software_id) VALUES (?, ?)`,
host.ID, softwareID)
if err != nil {
return err
}
res, err = q.ExecContext(ctx, `
INSERT INTO software (name, version, source, bundle_identifier, title_id, checksum)
VALUES (?, ?, ?, ?, ?, ?)
`,
vPPApp.Name, "1.2.3", "apps", vPPApp.BundleIdentifier, vPPApp.TitleID, hex.EncodeToString([]byte("vpp1")),
)
require.NoError(t, err)
time.Sleep(time.Second)
softwareID, err = res.LastInsertId()
require.NoError(t, err)
_, err = q.ExecContext(ctx, `
INSERT INTO host_software (host_id, software_id)
VALUES (?, ?)
`,
host.ID, softwareID)
require.NoError(t, err)
return nil
})
sw, _, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Len(t, sw, 2)
// selfServiceIstaller should be in scope since it has no labels
scoped, err := ds.IsSoftwareInstallerLabelScoped(ctx, selfServiceInstallerID, host.ID)
require.NoError(t, err)
require.True(t, scoped)
hostsInScope, err := ds.GetIncludedHostIDMapForSoftwareInstaller(ctx, selfServiceInstallerID)
require.NoError(t, err)
require.Contains(t, hostsInScope, host.ID)
// vppApp should be in scope since it has no labels
scoped, err = ds.IsVPPAppLabelScoped(ctx, vPPApp.VPPAppTeam.AppTeamID, host.ID)
require.NoError(t, err)
require.True(t, scoped)
hostsInScope, err = ds.GetIncludedHostIDMapForVPPApp(ctx, vPPApp.VPPAppTeam.AppTeamID)
require.NoError(t, err)
require.Contains(t, hostsInScope, host.ID)
// exclude label
excludeLabel, err := ds.NewLabel(ctx, &fleet.Label{Name: "Exclude Label" + t.Name()})
require.NoError(t, err)
// label host
require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{excludeLabel.ID}))
host.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, host)
require.NoError(t, err)
// label software
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), selfServiceInstallerID, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeExcludeAny,
ByName: map[string]fleet.LabelIdent{excludeLabel.Name: {LabelName: excludeLabel.Name, LabelID: excludeLabel.ID}},
}, softwareTypeInstaller)
require.NoError(t, err)
// label vpp app
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), vPPApp.VPPAppTeam.AppTeamID, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeExcludeAny,
ByName: map[string]fleet.LabelIdent{excludeLabel.Name: {LabelName: excludeLabel.Name, LabelID: excludeLabel.ID}},
}, softwareTypeVPP)
require.NoError(t, err)
// selfServiceIstaller should not be in scope since it has exclude any label
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, selfServiceInstallerID, host.ID)
require.NoError(t, err)
require.False(t, scoped)
hostsInScope, err = ds.GetIncludedHostIDMapForSoftwareInstaller(ctx, selfServiceInstallerID)
require.NoError(t, err)
require.Empty(t, hostsInScope)
// vppApp should not be in scope since it has exclude any label
scoped, err = ds.IsVPPAppLabelScoped(ctx, vPPApp.VPPAppTeam.AppTeamID, host.ID)
require.NoError(t, err)
require.False(t, scoped)
hostsInScope, err = ds.GetIncludedHostIDMapForVPPApp(ctx, vPPApp.VPPAppTeam.AppTeamID)
require.NoError(t, err)
require.Empty(t, hostsInScope)
// both apps are out of scope so we should get an empty list
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
assert.Len(t, sw, 0)
}
func testDeletedInstalledSoftware(t *testing.T, ds *Datastore) {
ctx := context.Background()
host1 := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
require.NoError(t, err)
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "GoLand",
Source: "app",
InstallScript: "echo",
TeamID: &team.ID,
Filename: "foo.pkg",
UserID: user1.ID,
BundleIdentifier: "com.jetbrains.goland",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
_, err = ds.InsertSoftwareInstallRequest(ctx, host1.ID, installerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err = q.ExecContext(ctx, `UPDATE host_software_installs SET post_install_script_exit_code = 0`)
require.NoError(t, err)
return nil
})
software1 := []fleet.Software{
{Name: "GoLand", Version: "1.0.2", Source: "app", BundleIdentifier: "com.jetbrains.goland"},
{Name: "GoLand", Version: "1.0.2", Source: "app", BundleIdentifier: "com.jetbrains.goland2"},
}
_, err = ds.UpdateHostSoftware(context.Background(), host1.ID, software1)
require.NoError(t, err)
// remove software with different bundle id same name as installed software
software1 = []fleet.Software{
{Name: "GoLand", Version: "1.0.2", Source: "app", BundleIdentifier: "com.jetbrains.goland"},
}
_, err = ds.UpdateHostSoftware(context.Background(), host1.ID, software1)
require.NoError(t, err)
var hostSoftwareInstalls []struct {
HostID uint `db:"host_id"`
SoftwareInstallerID uint `db:"software_installer_id"`
Removed bool `db:"removed"`
Status string `db:"status"`
}
err = sqlx.SelectContext(
ctx,
ds.writer(ctx),
&hostSoftwareInstalls,
`select host_id, software_installer_id, removed, status from host_software_installs where host_id = ?`,
host1.ID,
)
if err != nil {
fmt.Printf("error getting software titles: %v\n", err)
}
// Ensure installed software is not marked as removed
for _, value := range hostSoftwareInstalls {
assert.False(t, value.Removed)
}
}
func testSoftwareCategories(t *testing.T, ds *Datastore) {
ctx := context.Background()
user := test.NewUser(t, ds, "user1"+t.Name(), fmt.Sprintf("user1%s@example.com", t.Name()), false)
// create some categories
cat1, err := ds.NewSoftwareCategory(ctx, "category1")
require.NoError(t, err)
require.Equal(t, "category1", cat1.Name)
cat2, err := ds.NewSoftwareCategory(ctx, "category2")
require.NoError(t, err)
require.Equal(t, "category2", cat2.Name)
// get the IDs
ids, err := ds.GetSoftwareCategoryIDs(ctx, []string{cat1.Name, cat2.Name})
require.NoError(t, err)
require.Len(t, ids, 2)
require.Contains(t, ids, cat1.ID)
require.Contains(t, ids, cat2.ID)
// create a software installer for no team
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
installerNoTeam, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
InstallerFile: tfr1,
StorageID: "storage1",
Filename: "file1",
Title: "file1",
Version: "1.0",
Source: "apps",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
// Add the categories to the installer
err = ds.SaveInstallerUpdates(ctx, &fleet.UpdateSoftwareInstallerPayload{
TitleID: titleID,
InstallerID: installerNoTeam,
CategoryIDs: ids,
InstallScript: ptr.String("hello"),
SelfService: ptr.Bool(false),
UninstallScript: ptr.String(""),
PreInstallQuery: ptr.String(""),
UserID: user.ID,
})
require.NoError(t, err)
categories, err := ds.GetCategoriesForSoftwareTitles(ctx, []uint{titleID}, nil)
require.NoError(t, err)
require.Len(t, categories, 1)
require.Contains(t, categories[installerNoTeam], cat1.Name)
require.Contains(t, categories[installerNoTeam], cat2.Name)
dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Test org"+t.Name(), "Test location"+t.Name())
require.NoError(t, err)
tok1, err := ds.InsertVPPToken(ctx, dataToken)
require.NoError(t, err)
_, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{})
require.NoError(t, err)
vppApp := &fleet.VPPApp{Name: "vpp_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}, CategoryIDs: ids}, BundleIdentifier: "b1"}
vppApp, err = ds.InsertVPPAppWithTeam(ctx, vppApp, nil)
require.NoError(t, err)
vppAppTitleID := vppApp.TitleID
categories, err = ds.GetCategoriesForSoftwareTitles(ctx, []uint{titleID, vppAppTitleID}, nil)
require.NoError(t, err)
require.Len(t, categories, 2)
require.Contains(t, categories[installerNoTeam], cat1.Name)
require.Contains(t, categories[installerNoTeam], cat2.Name)
require.Contains(t, categories[installerNoTeam], cat1.Name)
require.Contains(t, categories[installerNoTeam], cat2.Name)
}
func testLabelScopingTimestampLogic(t *testing.T, ds *Datastore) {
ctx := context.Background()
// create a host
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, host, false)
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
tfr2, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
selfServiceinstaller := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello 2",
PreInstallQuery: "SELECT 2",
PostInstallScript: "world 2",
UninstallScript: "goodbye 2",
InstallerFile: tfr2,
StorageID: "storage 2",
Filename: "file2",
Title: "file2",
Version: "1.0",
Source: "apps",
UserID: user1.ID,
BundleIdentifier: "bi2",
Platform: "darwin",
SelfService: true,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
selfServiceInstallerID, selfServiceTitleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, selfServiceinstaller)
require.NoError(t, err)
softwareAlreadyInstalled := fleet.Software{Name: "file1", Version: "1.0.1", Source: "apps", BundleIdentifier: "bi1"}
// Host has software installed, but not by Fleet, that matches the software installer available
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
titleIDUint := selfServiceTitleID
softwareAlreadyInstalled.TitleID = &titleIDUint
res, err := q.ExecContext(ctx, `INSERT INTO software (name, source, bundle_identifier, version, title_id, checksum) VALUES (?, ?, ?, ?, ?, ?)`,
softwareAlreadyInstalled.Name, softwareAlreadyInstalled.Source, softwareAlreadyInstalled.BundleIdentifier, softwareAlreadyInstalled.Version, selfServiceTitleID, []byte("checksum"))
if err != nil {
return err
}
softwareID, err := res.LastInsertId()
if err != nil {
return err
}
softwareAlreadyInstalled.ID = uint(softwareID)
_, err = q.ExecContext(ctx, `INSERT INTO host_software (host_id, software_id) VALUES (?, ?)`,
host.ID, softwareID)
if err != nil {
return err
}
return nil
})
// Dynamic label
label1Orig, err := ds.NewLabel(ctx, &fleet.Label{Name: "label1" + t.Name(), LabelMembershipType: fleet.LabelMembershipTypeDynamic})
require.NoError(t, err)
// Manual label
label2Orig, err := ds.NewLabel(ctx, &fleet.Label{Name: "label2" + t.Name(), LabelMembershipType: fleet.LabelMembershipTypeManual})
require.NoError(t, err)
// make sure the label is created after the host's labels_updated_at timestamp
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err = q.ExecContext(ctx, `UPDATE labels SET created_at = ? WHERE id in (?, ?)`, host.LabelUpdatedAt.Add(time.Hour), label1Orig.ID, label2Orig.ID)
if err != nil {
return err
}
return nil
})
// refetch labels to ensure their state is correct
label1, _, err := ds.Label(ctx, label1Orig.ID, fleet.TeamFilter{})
require.NoError(t, err)
label2, _, err := ds.Label(ctx, label2Orig.ID, fleet.TeamFilter{})
require.NoError(t, err)
require.Greater(t, label1.CreatedAt, host.LabelUpdatedAt)
require.Greater(t, label2.CreatedAt, host.LabelUpdatedAt)
selfServiceOpts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{PerPage: 10},
IncludeAvailableForInstall: true,
SelfServiceOnly: true,
IsMDMEnrolled: true,
}
hostLibraryOpts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{PerPage: 10},
SelfServiceOnly: false,
IncludeAvailableForInstall: true,
OnlyAvailableForInstall: true,
IsMDMEnrolled: true,
}
// Dynamic label exclude any
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), selfServiceInstallerID, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeExcludeAny,
ByName: map[string]fleet.LabelIdent{
label1.Name: {LabelName: label1.Name, LabelID: label1.ID},
},
}, softwareTypeInstaller)
require.NoError(t, err)
// self service
software, _, err := ds.ListHostSoftware(ctx, host, selfServiceOpts)
require.NoError(t, err)
require.Len(t, software, 0)
// host library
software, _, err = ds.ListHostSoftware(ctx, host, hostLibraryOpts)
require.NoError(t, err)
require.Len(t, software, 0)
// manual label exclude any
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), selfServiceInstallerID, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeExcludeAny,
ByName: map[string]fleet.LabelIdent{
label2.Name: {LabelName: label2.Name, LabelID: label2.ID},
},
}, softwareTypeInstaller)
require.NoError(t, err)
// self service
software, _, err = ds.ListHostSoftware(ctx, host, selfServiceOpts)
require.NoError(t, err)
require.Len(t, software, 1)
// host library
software, _, err = ds.ListHostSoftware(ctx, host, hostLibraryOpts)
require.NoError(t, err)
require.Len(t, software, 1)
// Add manual label to host
require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{label2.ID}))
host, err = ds.Host(ctx, host.ID)
require.NoError(t, err)
label1, _, err = ds.Label(ctx, label1.ID, fleet.TeamFilter{})
require.NoError(t, err)
label2, _, err = ds.Label(ctx, label2.ID, fleet.TeamFilter{})
require.NoError(t, err)
// ensure our timestamps are still correct
require.Greater(t, label1.CreatedAt, host.LabelUpdatedAt)
require.Greater(t, label2.CreatedAt, host.LabelUpdatedAt)
// Manual label added to host, so we should not see the software instantly
// self service
software, _, err = ds.ListHostSoftware(ctx, host, selfServiceOpts)
require.NoError(t, err)
require.Len(t, software, 0)
// host library
software, _, err = ds.ListHostSoftware(ctx, host, hostLibraryOpts)
require.NoError(t, err)
require.Len(t, software, 0)
// manual label include any
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), selfServiceInstallerID, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeIncludeAny,
ByName: map[string]fleet.LabelIdent{
label2.Name: {LabelName: label2.Name, LabelID: label2.ID},
},
}, softwareTypeInstaller)
require.NoError(t, err)
// self service
software, _, err = ds.ListHostSoftware(ctx, host, selfServiceOpts)
require.NoError(t, err)
require.Len(t, software, 1)
// host library
software, _, err = ds.ListHostSoftware(ctx, host, hostLibraryOpts)
require.NoError(t, err)
require.Len(t, software, 1)
// add dynamic label to host
require.NoError(t, ds.RemoveLabelsFromHost(ctx, host.ID, []uint{label2.ID}))
require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{label1.ID}))
host, err = ds.Host(ctx, host.ID)
require.NoError(t, err)
label1, _, err = ds.Label(ctx, label1.ID, fleet.TeamFilter{})
require.NoError(t, err)
label2, _, err = ds.Label(ctx, label2.ID, fleet.TeamFilter{})
require.NoError(t, err)
// ensure our timestamps are still correct
require.Greater(t, label1.CreatedAt, host.LabelUpdatedAt)
require.Greater(t, label2.CreatedAt, host.LabelUpdatedAt)
// Dynamic label include any
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), selfServiceInstallerID, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeIncludeAny,
ByName: map[string]fleet.LabelIdent{
label1.Name: {LabelName: label1.Name, LabelID: label1.ID},
},
}, softwareTypeInstaller)
require.NoError(t, err)
// self service
software, _, err = ds.ListHostSoftware(ctx, host, selfServiceOpts)
require.NoError(t, err)
require.Len(t, software, 1)
// host library
software, _, err = ds.ListHostSoftware(ctx, host, hostLibraryOpts)
require.NoError(t, err)
require.Len(t, software, 1)
}
func testInventoryPendingSoftware(t *testing.T, ds *Datastore) {
ctx := context.Background()
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, host, false)
user := test.NewUser(t, ds, "user"+t.Name(), fmt.Sprintf("user%s@example.com", t.Name()), false)
// set up vpp
dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Test org"+t.Name(), "Test location"+t.Name())
require.NoError(t, err)
tok1, err := ds.InsertVPPToken(ctx, dataToken)
require.NoError(t, err)
_, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{})
require.NoError(t, err)
time.Sleep(time.Second) // ensure the labels_updated_at timestamp is before labels creation
selfServiceOpts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{Page: 0, PerPage: 20},
SelfServiceOnly: true,
IncludeAvailableForInstall: true,
OnlyAvailableForInstall: false,
IsMDMEnrolled: true,
}
hostInventoryOpts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{Page: 0, PerPage: 20},
SelfServiceOnly: false,
IncludeAvailableForInstall: false,
OnlyAvailableForInstall: false,
IsMDMEnrolled: true,
}
tfr, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
softwareInstaller := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
PreInstallQuery: "SELECT",
PostInstallScript: "world",
UninstallScript: "goodbye",
InstallerFile: tfr,
StorageID: "storage",
Filename: "file1",
Title: "file1",
Version: "1.0",
Source: "apps",
UserID: user.ID,
BundleIdentifier: "bi1",
Platform: "darwin",
SelfService: true,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
installerID, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, softwareInstaller)
require.NoError(t, err)
// software installer is pending (all results are NULL)
hostSoftwareInstallUUID, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
// Pending installer with no results
software, _, err := ds.ListHostSoftware(ctx, host, selfServiceOpts)
require.NoError(t, err)
require.Len(t, software, 1)
software, _, err = ds.ListHostSoftware(ctx, host, hostInventoryOpts)
require.NoError(t, err)
require.Len(t, software, 0)
// Set the result as installed
ds.testActivateSpecificNextActivities = []string{"-"}
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: hostSoftwareInstallUUID,
PreInstallConditionOutput: ptr.String("ok"),
InstallScriptExitCode: ptr.Int(0),
PostInstallScriptExitCode: ptr.Int(0),
}, nil)
require.NoError(t, err)
// Successfully installed, however not inventoried by osquery
software, _, err = ds.ListHostSoftware(ctx, host, selfServiceOpts)
require.NoError(t, err)
require.Len(t, software, 1)
software, _, err = ds.ListHostSoftware(ctx, host, hostInventoryOpts)
require.NoError(t, err)
require.Len(t, software, 0)
// Now we inventory the software, osquery returns it
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
var title struct {
ID uint `db:"id"`
Name string `db:"name"`
Source string `db:"source"`
Version string `db:"version"`
}
err := sqlx.GetContext(ctx, q, &title, `
SELECT software_titles.id, software_titles.name, software_titles.source, software_installers.version
FROM software_installers
JOIN software_titles ON software_installers.title_id = software_titles.id
WHERE software_installers.id = ?`, installerID)
require.NoError(t, err)
res, err := q.ExecContext(ctx,
`INSERT INTO software (name, source, bundle_identifier, version, title_id, checksum) VALUES (?, ?, ?, ?, ?, ?)`,
title.Name,
title.Source,
"title.com.example",
title.Version,
title.ID,
[]byte("checksum"),
)
require.NoError(t, err)
softwareID, err := res.LastInsertId()
require.NoError(t, err)
_, err = q.ExecContext(ctx, `INSERT INTO host_software (host_id, software_id) VALUES (?, ?)`,
host.ID, softwareID)
require.NoError(t, err)
return nil
})
// Successfully installed and inventoried, so we should see it now
software, _, err = ds.ListHostSoftware(ctx, host, selfServiceOpts)
require.NoError(t, err)
require.Len(t, software, 1)
software, _, err = ds.ListHostSoftware(ctx, host, hostInventoryOpts)
require.NoError(t, err)
require.Len(t, software, 1)
// setup host 2
host2 := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, host2, false)
host2SoftwareInstallUUID, err := ds.InsertSoftwareInstallRequest(ctx, host2.ID, installerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
// Pending installer with no results
software, _, err = ds.ListHostSoftware(ctx, host2, selfServiceOpts)
require.NoError(t, err)
require.Len(t, software, 1)
software, _, err = ds.ListHostSoftware(ctx, host2, hostInventoryOpts)
require.NoError(t, err)
require.Len(t, software, 0)
// fail the install
ds.testActivateSpecificNextActivities = []string{host2SoftwareInstallUUID}
activated, err := ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), host2.ID, "")
require.NoError(t, err)
require.Equal(t, ds.testActivateSpecificNextActivities, activated)
ds.testActivateSpecificNextActivities = []string{"-"}
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host2.ID,
InstallUUID: host2SoftwareInstallUUID,
PreInstallConditionOutput: ptr.String("ok"),
InstallScriptExitCode: ptr.Int(1),
}, nil)
require.NoError(t, err)
// Failed install
software, _, err = ds.ListHostSoftware(ctx, host2, selfServiceOpts)
require.NoError(t, err)
require.Len(t, software, 1)
software, _, err = ds.ListHostSoftware(ctx, host2, hostInventoryOpts)
require.NoError(t, err)
require.Len(t, software, 0)
// setup host 3
host3 := test.NewHost(t, ds, "host3", "", "host3key", "host3uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, host3, false)
// has software installed, but not by Fleet, that matches the software installer available
res, err := ds.writer(ctx).ExecContext(
ctx,
`INSERT INTO software (name, version, source, title_id, checksum) VALUES (?, ?, ?, ?, ?)`,
"foo2", "0.5", "bar2", &titleID, []byte("foo2"),
)
require.NoError(t, err)
installedSoftwareID, err := res.LastInsertId()
require.NoError(t, err)
_, err = ds.writer(ctx).ExecContext(ctx, `INSERT INTO host_software (host_id, software_id) VALUES (?, ?)`, host3.ID, installedSoftwareID)
require.NoError(t, err)
// Installed software should show up
software, _, err = ds.ListHostSoftware(ctx, host3, selfServiceOpts)
require.NoError(t, err)
require.Len(t, software, 1)
software, _, err = ds.ListHostSoftware(ctx, host3, hostInventoryOpts)
require.NoError(t, err)
require.Len(t, software, 1)
// Create a software install request
_, err = ds.InsertSoftwareInstallRequest(ctx, host3.ID, installerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
// Pending request, but software is installed and inventoried by osquery, so we should see it
software, _, err = ds.ListHostSoftware(ctx, host3, selfServiceOpts)
require.NoError(t, err)
require.Len(t, software, 1)
software, _, err = ds.ListHostSoftware(ctx, host3, hostInventoryOpts)
require.NoError(t, err)
require.Len(t, software, 1)
// setup host 4
host4 := test.NewHost(t, ds, "host4", "", "host4key", "host4uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, host4, false)
// create a team
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
// Add host to team
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{host.ID}))
require.NoError(t, err)
host4.TeamID = &tm.ID
vPPApp := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
SelfService: true,
VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform},
},
Name: "vpp1",
BundleIdentifier: "com.app.vpp1",
}
va1, err := ds.InsertVPPAppWithTeam(ctx, vPPApp, &tm.ID)
require.NoError(t, err)
vpp1 := va1.AdamID
vpp1CmdUUID := createVPPAppInstallRequest(t, ds, host4, vpp1, user)
// vpp app is pending, no results
software, _, err = ds.ListHostSoftware(ctx, host4, selfServiceOpts)
require.NoError(t, err)
require.Len(t, software, 1)
software, _, err = ds.ListHostSoftware(ctx, host4, hostInventoryOpts)
require.NoError(t, err)
require.Len(t, software, 0)
// vpp app comes back as installed
ds.testActivateSpecificNextActivities = []string{vpp1CmdUUID}
_, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), host4.ID, "")
require.NoError(t, err)
createVPPAppInstallResult(t, ds, host4, vpp1CmdUUID, fleet.MDMAppleStatusAcknowledged)
// vpp app is installed, but not inventoried by osquery
software, _, err = ds.ListHostSoftware(ctx, host4, selfServiceOpts)
require.NoError(t, err)
require.Len(t, software, 1)
software, _, err = ds.ListHostSoftware(ctx, host4, hostInventoryOpts)
require.NoError(t, err)
require.Len(t, software, 0)
// inventory by osquery
res, err = ds.writer(ctx).ExecContext(ctx, `
INSERT INTO software (name, version, source, bundle_identifier, title_id, checksum)
VALUES (?, ?, ?, ?, ?, ?)
`,
vPPApp.Name, "0.1.1", "apps", vPPApp.BundleIdentifier, vPPApp.TitleID, hex.EncodeToString([]byte("vpp1")),
)
require.NoError(t, err)
vppSoftwareID, err := res.LastInsertId()
require.NoError(t, err)
_, err = ds.writer(ctx).ExecContext(ctx, `INSERT INTO host_software (host_id, software_id) VALUES (?, ?)`, host4.ID, vppSoftwareID)
require.NoError(t, err)
// should see it now because it is inventoried by osquery
software, _, err = ds.ListHostSoftware(ctx, host4, selfServiceOpts)
require.NoError(t, err)
require.Len(t, software, 1)
software, _, err = ds.ListHostSoftware(ctx, host4, hostInventoryOpts)
require.NoError(t, err)
require.Len(t, software, 1)
}
func testCheckForDeletedInstalledSoftware(t *testing.T, ds *Datastore) {
ctx := context.Background()
host1 := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
user := test.NewUser(t, ds, "Alice", "alice@example.com", true)
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team1.ID, []uint{host1.ID})))
existingSw, err := fleet.SoftwareFromOsqueryRow("htop", "3.4.0-2", "deb_packages", "", "", "", "", "", "", "", "", "")
require.NoError(t, err)
updateSw, err := fleet.SoftwareFromOsqueryRow("htop", "3.4.1-5", "deb_packages", "", "", "", "", "", "", "", "", "")
require.NoError(t, err)
_, err = ds.UpdateHostSoftware(ctx, host1.ID, []fleet.Software{*existingSw})
require.NoError(t, err)
tfr, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
softwareInstaller := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
PreInstallQuery: "SELECT",
PostInstallScript: "world",
UninstallScript: "goodbye",
InstallerFile: tfr,
StorageID: "storage",
Filename: "file1",
Title: "htop",
Version: "3.4.1-5",
Source: "deb_packages",
UserID: user.ID,
TeamID: &team1.ID,
BundleIdentifier: "",
Platform: "linux",
SelfService: true,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, softwareInstaller)
require.NoError(t, err)
t.Run("host_software_installs row isnt removed", func(t *testing.T) {
hostInstall1, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, installerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host1.ID,
InstallUUID: hostInstall1,
InstallScriptExitCode: ptr.Int(0),
}, nil)
require.NoError(t, err)
_, err = ds.applyChangesForNewSoftwareDB(ctx, host1.ID, []fleet.Software{*updateSw})
require.NoError(t, err)
var removed int
err = ds.writer(ctx).Get(&removed, `SELECT removed FROM host_software_installs
WHERE software_title_name = ? ORDER BY updated_at DESC LIMIT 1`, "htop")
require.NoError(t, err)
require.Equal(t, 0, removed)
})
numHosts := 5
var installHosts []*fleet.Host
for i := range 5 {
host := test.NewHost(t, ds, "host-"+strconv.Itoa(i), "", "key"+strconv.Itoa(i), "uuid"+strconv.Itoa(i), time.Now())
err := ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team1.ID, []uint{host.ID}))
require.NoError(t, err)
installHosts = append(installHosts, host)
}
t.Run("installer host count is correct", func(t *testing.T) {
for _, host := range installHosts {
_, err = ds.UpdateHostSoftware(ctx, host.ID, []fleet.Software{*existingSw})
require.NoError(t, err)
}
for _, host := range installHosts {
hostInstall1, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: hostInstall1,
InstallScriptExitCode: ptr.Int(0),
}, nil)
require.NoError(t, err)
_, err = ds.applyChangesForNewSoftwareDB(ctx, host.ID, []fleet.Software{*updateSw})
require.NoError(t, err)
}
counts, err := ds.GetSummaryHostSoftwareInstalls(ctx, installerID)
require.NoError(t, err)
require.Equal(t, numHosts+1, int(counts.Installed)) // include first host's install
})
}
func testPreInsertSoftwareInventory(t *testing.T, ds *Datastore) {
ctx := t.Context()
// Generate test software
softwareChecksums := make(map[string]fleet.Software)
for i := 0; i < 10; i++ {
checksum := make([]byte, 32)
_, _ = crand.Read(checksum)
checksumStr := hex.EncodeToString(checksum)
softwareChecksums[checksumStr] = fleet.Software{
Name: fmt.Sprintf("idempotent-test-%d", i),
Version: fmt.Sprintf("1.0.%d", i),
Source: "test",
Checksum: checksumStr,
}
}
// Pre-insert once
err := ds.preInsertSoftwareInventory(ctx, nil, softwareChecksums, nil)
require.NoError(t, err)
// Count inserted software
var count int
err = ds.writer(ctx).GetContext(ctx, &count,
"SELECT COUNT(*) FROM software WHERE name LIKE 'idempotent-test-%'")
require.NoError(t, err)
require.Equal(t, 10, count)
// Pre-insert again (should be idempotent)
err = ds.preInsertSoftwareInventory(ctx, nil, softwareChecksums, nil)
require.NoError(t, err)
// Count should remain the same
err = ds.writer(ctx).GetContext(ctx, &count,
"SELECT COUNT(*) FROM software WHERE name LIKE 'idempotent-test-%'")
require.NoError(t, err)
require.Equal(t, 10, count)
}
// testUpdateHostBundleIDRenameOnlyNoNewSoftware tests if a host reports ONLY renamed software
// (same bundle ID, different name) with NO new software
func testUpdateHostBundleIDRenameOnlyNoNewSoftware(t *testing.T, ds *Datastore) {
ctx := t.Context()
host := test.NewHost(t, ds, "rename-test-host", "", "renamekey", "renameuuid", time.Now())
// Initial software with bundle ID
initialSoftware := []fleet.Software{
{Name: "Original.app", Version: "1.0", Source: "apps", BundleIdentifier: "com.example.app"},
{Name: "Another.app", Version: "2.0", Source: "apps", BundleIdentifier: "com.example.another"},
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, initialSoftware)
require.NoError(t, err)
// Verify initial state
err = ds.LoadHostSoftware(ctx, host, false)
require.NoError(t, err)
require.Len(t, host.Software, 2)
originalIDs := map[string]uint{}
for _, s := range host.Software {
originalIDs[s.BundleIdentifier] = s.ID
}
// Report ONLY renamed software (same bundle IDs, different names)
renamedSoftware := []fleet.Software{
{Name: "Renamed.app", Version: "1.0", Source: "apps", BundleIdentifier: "com.example.app"},
{Name: "AlsoRenamed.app", Version: "2.0", Source: "apps", BundleIdentifier: "com.example.another"},
}
// Complete the update and verify the software was linked correctly
_, err = ds.UpdateHostSoftware(ctx, host.ID, renamedSoftware)
require.NoError(t, err)
// Verify the host only has 2 pieces of sofware
err = ds.LoadHostSoftware(ctx, host, false)
require.NoError(t, err)
require.Len(t, host.Software, 2, "Should still have exactly 2 software entries")
// Verify the IDs are are not the same
for _, s := range host.Software {
originalID, ok := originalIDs[s.BundleIdentifier]
require.True(t, ok, "Bundle ID %s should exist", s.BundleIdentifier)
require.NotEqual(t, originalID, s.ID,
"Software ID should not be reused for bundle ID %s", s.BundleIdentifier)
}
// Verify new software entries were created
var softwareCount int
err = ds.writer(ctx).GetContext(ctx, &softwareCount,
`SELECT COUNT(DISTINCT id) FROM software
WHERE bundle_identifier IN ('com.example.app', 'com.example.another')`)
require.NoError(t, err)
require.Equal(t, 4, softwareCount, "Should have exactly 4 software entries")
}
// testUpdateHostBundleIDRenameWithNewSoftware tests the edge case where a host reports BOTH:
// 1. New software that needs to be inserted
// 2. Existing software with renamed bundle IDs
// This tests that both operations work correctly in the same update.
func testUpdateHostBundleIDRenameWithNewSoftware(t *testing.T, ds *Datastore) {
ctx := t.Context()
host := test.NewHost(t, ds, "mixed-test-host", "", "mixedkey", "mixeduuid", time.Now())
// Step 1: Initial software setup - just one app with bundle ID
initialSoftware := []fleet.Software{
{Name: "Slack.app", Version: "1.0.0", Source: "apps", BundleIdentifier: "com.tinyspeck.slackmacgap"},
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, initialSoftware)
require.NoError(t, err)
// Verify initial state
err = ds.LoadHostSoftware(ctx, host, false)
require.NoError(t, err)
require.Len(t, host.Software, 1)
slackOriginalID := host.Software[0].ID
mixedUpdate := []fleet.Software{
// same bundle ID, different name
{Name: "Slack 2.app", Version: "1.0.0", Source: "apps", BundleIdentifier: "com.tinyspeck.slackmacgap"},
// Brand new software
{Name: "Chrome.app", Version: "110.0", Source: "apps", BundleIdentifier: "com.google.Chrome"},
// Another new software without bundle ID
{Name: "CustomTool", Version: "3.2.1", Source: "programs"},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, mixedUpdate)
require.NoError(t, err)
// Verify the results
err = ds.LoadHostSoftware(ctx, host, false)
require.NoError(t, err)
require.Len(t, host.Software, 3, "Should have 3 software entries total")
// Check each software entry
foundSlack := false
foundChrome := false
foundCustomTool := false
for _, s := range host.Software {
switch s.BundleIdentifier {
case "com.tinyspeck.slackmacgap":
foundSlack = true
require.Equal(t, "Slack 2.app", s.Name, "Slack should be renamed")
require.NotEqual(t, slackOriginalID, s.ID)
case "com.google.Chrome":
foundChrome = true
require.Equal(t, "Chrome.app", s.Name)
require.NotEqual(t, slackOriginalID, s.ID, "Chrome should have a different ID")
case "":
if s.Name == "CustomTool" {
foundCustomTool = true
require.Equal(t, "CustomTool", s.Name)
require.NotEqual(t, slackOriginalID, s.ID, "CustomTool should have a different ID")
}
}
}
require.True(t, foundSlack, "Should find new Slack")
require.True(t, foundChrome, "Should find new Chrome")
require.True(t, foundCustomTool, "Should find new CustomTool")
// Verify two slack entries exist in the software table
var softwareCount int
err = ds.writer(ctx).GetContext(ctx, &softwareCount,
`SELECT COUNT(DISTINCT id) FROM software WHERE bundle_identifier = 'com.tinyspeck.slackmacgap'`)
require.NoError(t, err)
require.Equal(t, 2, softwareCount, "Should have exactly 2 Slack software entries")
// Verify titles were created correctly
// A new one should not have been created for Slack 2.app
var titleCount int
err = ds.writer(ctx).GetContext(ctx, &titleCount,
`SELECT COUNT(DISTINCT id) FROM software_titles`)
require.NoError(t, err)
require.Equal(t, 3, titleCount, "Should have 3 software titles")
}
// testUpdateHostBrowserExtensions explicitly tests browser extension handling.
// Browser extensions have extension IDs and browser fields, but no bundle identifiers.
func testUpdateHostBrowserExtensions(t *testing.T, ds *Datastore) {
ctx := t.Context()
host := test.NewHost(t, ds, "ext-test-host", "", "extkey", "extuuid", time.Now())
// Test Case 1: Add multiple browser extensions
extensions := []fleet.Software{
{Name: "1Password", Version: "1.45.0", Source: "browser_plugins", ExtensionID: "cjpalhdlnbpafiamejdnhcphjbkeiagm", Browser: "chrome"},
{Name: "React Developer Tools", Version: "3.14.2", Source: "browser_plugins", ExtensionID: "cfhdojbkjhnklbpkdaibdccddilifddb", Browser: "chrome"},
{Name: "Grammarly", Version: "14.1.0", Source: "browser_plugins", ExtensionID: "kbfnbcaeplbcioakkpcpgfkobkghlhen", Browser: "chrome"},
{Name: "uBlock Origin", Version: "1.52.2", Source: "browser_plugins", ExtensionID: "cjpalhdlnbpafiamejdnhcphjbkeiagm", Browser: "firefox"}, // Same ext ID, different browser
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, extensions)
require.NoError(t, err)
// Verify extensions were added correctly
err = ds.LoadHostSoftware(ctx, host, false)
require.NoError(t, err)
require.Len(t, host.Software, 4, "Should have 4 browser extensions")
// Verify each extension
extensionsByName := make(map[string]fleet.HostSoftwareEntry)
for _, s := range host.Software {
extensionsByName[s.Name] = s
// All browser extensions should have empty bundle identifier
require.Empty(t, s.BundleIdentifier, "Browser extensions should not have bundle identifier")
require.Equal(t, "browser_plugins", s.Source)
}
// Verify specific extensions
require.Contains(t, extensionsByName, "1Password")
require.Contains(t, extensionsByName, "React Developer Tools")
require.Contains(t, extensionsByName, "Grammarly")
require.Contains(t, extensionsByName, "uBlock Origin")
// Test Case 2: Update extension version (same extension ID, new version)
updatedExtensions := []fleet.Software{
{Name: "1Password", Version: "1.46.0", Source: "browser_plugins", ExtensionID: "cjpalhdlnbpafiamejdnhcphjbkeiagm", Browser: "chrome"}, // Version update
{Name: "React Developer Tools", Version: "3.14.2", Source: "browser_plugins", ExtensionID: "cfhdojbkjhnklbpkdaibdccddilifddb", Browser: "chrome"}, // No change
// Grammarly removed
{Name: "uBlock Origin", Version: "1.52.2", Source: "browser_plugins", ExtensionID: "cjpalhdlnbpafiamejdnhcphjbkeiagm", Browser: "firefox"},
{Name: "Bitwarden", Version: "2023.10.1", Source: "browser_plugins", ExtensionID: "nngceckbapebfimnlniiiahkandclblb", Browser: "chrome"}, // New extension
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, updatedExtensions)
require.NoError(t, err)
// Verify update results
err = ds.LoadHostSoftware(ctx, host, false)
require.NoError(t, err)
require.Len(t, host.Software, 4, "Should have 4 browser extensions after update")
// Check version update
extensionsByName = make(map[string]fleet.HostSoftwareEntry)
for _, s := range host.Software {
extensionsByName[s.Name] = s
}
require.Equal(t, "1.46.0", extensionsByName["1Password"].Version, "1Password version should be updated")
require.Contains(t, extensionsByName, "Bitwarden", "New extension should be added")
require.NotContains(t, extensionsByName, "Grammarly", "Removed extension should not be present")
// Test Case 3: Extensions with same name but different browsers/extension IDs
// This simulates cross-browser extensions
crossBrowserExtensions := []fleet.Software{
{Name: "Adblock", Version: "5.0", Source: "browser_plugins", ExtensionID: "gighmmpiobklfepjocnamgkkbiglidom", Browser: "chrome"},
{Name: "Adblock", Version: "5.0", Source: "browser_plugins", ExtensionID: "jid1-NIfFY2CA8fy1tg", Browser: "firefox"},
{Name: "Adblock", Version: "4.9", Source: "browser_plugins", ExtensionID: "pdffkfellgipmhklpdmokmckkkfcopbh", Browser: "edge"},
}
host2 := test.NewHost(t, ds, "ext-test-host2", "", "extkey2", "extuuid2", time.Now())
_, err = ds.UpdateHostSoftware(ctx, host2.ID, crossBrowserExtensions)
require.NoError(t, err)
err = ds.LoadHostSoftware(ctx, host2, false)
require.NoError(t, err)
require.Len(t, host2.Software, 3, "Should have 3 Adblock variants")
// Verify they're tracked as separate software entries
var adblockCount int
for _, s := range host2.Software {
if s.Name == "Adblock" {
adblockCount++
require.Equal(t, "browser_plugins", s.Source)
require.Empty(t, s.BundleIdentifier)
}
}
require.Equal(t, 3, adblockCount, "Should have 3 distinct Adblock entries for different browsers")
// Verify database has correct number of browser extension entries
var totalExtensions int
err = ds.writer(ctx).GetContext(ctx, &totalExtensions,
`SELECT COUNT(DISTINCT id) FROM software WHERE source = 'browser_plugins'`)
require.NoError(t, err)
require.GreaterOrEqual(t, totalExtensions, 7, "Should have at least 7 distinct browser extensions in database")
// Verify extensions don't interfere with bundle ID logic
// Add a macOS app with a bundle ID alongside extensions
mixedSoftware := crossBrowserExtensions
mixedSoftware = append(mixedSoftware,
fleet.Software{Name: "Safari.app", Version: "17.0", Source: "apps", BundleIdentifier: "com.apple.Safari"},
)
host3 := test.NewHost(t, ds, "ext-test-host3", "", "extkey3", "extuuid3", time.Now())
_, err = ds.UpdateHostSoftware(ctx, host3.ID, mixedSoftware)
require.NoError(t, err)
err = ds.LoadHostSoftware(ctx, host3, false)
require.NoError(t, err)
require.Len(t, host3.Software, 4, "Should have 3 extensions + 1 app")
// Verify Safari has bundle ID while extensions don't
for _, s := range host3.Software {
if s.Name == "Safari.app" {
require.Equal(t, "com.apple.Safari", s.BundleIdentifier, "Safari should have bundle ID")
require.Equal(t, "apps", s.Source)
} else if s.Name == "Adblock" {
require.Empty(t, s.BundleIdentifier, "Extensions should not have bundle ID")
require.Equal(t, "browser_plugins", s.Source)
}
}
}
func testListHostSoftwareWithExtensionFor(t *testing.T, ds *Datastore) {
ctx := context.Background()
// Create a test host
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin"))
// Add software with extension_for set (browser extensions)
// Same extension name but different extension_for values creates different titles
software := []fleet.Software{
{Name: "Adblock Plus", Version: "3.14", Source: "chrome_extensions", ExtensionFor: "chrome"},
{Name: "Adblock Plus", Version: "3.14", Source: "chrome_extensions", ExtensionFor: "edge"}, // Same name, different extension_for
{Name: "Adblock Plus", Version: "3.14", Source: "chrome_extensions", ExtensionFor: "firefox"}, // Same name, another extension_for
{Name: "uBlock Origin", Version: "1.42.0", Source: "chrome_extensions", ExtensionFor: "chrome"},
{Name: "Regular App", Version: "1.0", Source: "apps"}, // No extension_for
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
// List host software
opts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
PerPage: 10,
IncludeMetadata: true,
OrderKey: "name",
TestSecondaryOrderKey: "extension_for", // Sort by extension_for to get deterministic order
},
}
sw, meta, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.NotNil(t, meta)
require.Len(t, sw, 5, "Should return 5 software titles (3 Adblock Plus for different browsers, 1 uBlock Origin, 1 Regular App)")
// Find each Adblock Plus variant
adblockChrome := findSoftware(sw, "Adblock Plus", "chrome")
adblockEdge := findSoftware(sw, "Adblock Plus", "edge")
adblockFirefox := findSoftware(sw, "Adblock Plus", "firefox")
ublock := findSoftware(sw, "uBlock Origin", "chrome")
regularApp := findSoftware(sw, "Regular App", "")
// Verify Adblock Plus for Chrome
require.NotNil(t, adblockChrome, "Should find Adblock Plus for chrome")
require.Equal(t, "chrome_extensions", adblockChrome.Source)
require.Equal(t, "chrome", adblockChrome.ExtensionFor)
// Verify Adblock Plus for Edge
require.NotNil(t, adblockEdge, "Should find Adblock Plus for edge")
require.Equal(t, "chrome_extensions", adblockEdge.Source)
require.Equal(t, "edge", adblockEdge.ExtensionFor)
// Verify Adblock Plus for Firefox
require.NotNil(t, adblockFirefox, "Should find Adblock Plus for firefox")
require.Equal(t, "chrome_extensions", adblockFirefox.Source)
require.Equal(t, "firefox", adblockFirefox.ExtensionFor)
// Verify uBlock Origin
require.NotNil(t, ublock, "Should find uBlock Origin")
require.Equal(t, "chrome_extensions", ublock.Source)
require.Equal(t, "chrome", ublock.ExtensionFor)
// Verify Regular App has empty extension_for
require.NotNil(t, regularApp, "Should find Regular App")
require.Equal(t, "apps", regularApp.Source)
require.Equal(t, "", regularApp.ExtensionFor)
}
func testLongestCommonPrefix(t *testing.T, ds *Datastore) {
tests := []struct {
input []string
expected string
}{
{input: []string{}, expected: ""},
{input: []string{"no_common1", "another_one3"}, expected: ""},
{input: []string{"single"}, expected: "single"},
{input: []string{"prefix_common", "prefix_common_suffix1", "prefix_common_suffix2"}, expected: "prefix_common"},
{input: []string{"common_prefix_suffix1", "common_prefix_suffix2", "common_prefix"}, expected: "common_prefix"},
{input: []string{"same", "same", "same"}, expected: "same"},
{input: []string{"partial_common1", "partial_common2", "none"}, expected: ""},
{input: []string{"", "softwarename"}, expected: ""},
{input: []string{"softwarename", "prefix_common", "prefix_common"}, expected: ""},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%v", tt.input), func(t *testing.T) {
result := longestCommonPrefix(tt.input)
require.Equal(t, tt.expected, result)
})
}
}
// Helper function to find software by name and extension_for
func findSoftware(sw []*fleet.HostSoftwareWithInstaller, name, extensionFor string) *fleet.HostSoftwareWithInstaller {
for _, s := range sw {
if s.Name == name && s.ExtensionFor == extensionFor {
return s
}
}
return nil
}
func testListHostSoftwareInHouseApps(t *testing.T, ds *Datastore) {
ctx := context.Background()
t.Cleanup(func() { ds.testActivateSpecificNextActivities = nil })
// use time -1s to ensure host label-updated-at is before the labels creation timestamp
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now().Add(-1*time.Second), test.WithPlatform("ios"))
nanoEnroll(t, ds, host, false)
otherHost := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now(), test.WithPlatform("ubuntu"))
require.NotNil(t, otherHost)
opts := fleet.HostSoftwareTitleListOptions{
IsMDMEnrolled: true, // required for vpp/in-house apps, and the host is MDM-enrolled
ListOptions: fleet.ListOptions{PerPage: 11, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"},
}
// create a distinct team
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
user, err := ds.NewUser(ctx, &fleet.User{
Password: []byte("p4ssw0rd.123"),
Name: "user1",
Email: "user1@example.com",
GlobalRole: ptr.String(fleet.RoleAdmin),
})
require.NoError(t, err)
// create some in-house apps for no-team (this creates both iOS and iPadOS,
// but returns the iOS ids)
inHouseID1, inHouseTitleID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "inhouse1",
Source: "ios_apps",
Filename: "inhouse1.ipa",
Extension: "ipa",
BundleIdentifier: "inhouse1",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.NotZero(t, inHouseID1)
require.NotZero(t, inHouseTitleID1)
inHouseID2, inHouseTitleID2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "inhouse2",
Source: "ios_apps",
Filename: "inhouse2.ipa",
Extension: "ipa",
BundleIdentifier: "inhouse2",
UserID: user.ID,
SelfService: true,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.NotZero(t, inHouseID2)
require.NotZero(t, inHouseTitleID2)
inHouseID3, inHouseTitleID3, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "inhouse3",
Source: "ios_apps",
Filename: "inhouse3.ipa",
Extension: "ipa",
BundleIdentifier: "inhouse3",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.NotZero(t, inHouseID3)
require.NotZero(t, inHouseTitleID3)
// add an in-house app on the team, should not affect the host's results
inHouseIDTm, inHouseTitleIDTm, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "inhouse-tm",
Source: "ios_apps",
Filename: "inhouse-tm.ipa",
Extension: "ipa",
BundleIdentifier: "inhouse-tm",
TeamID: &team.ID,
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.NotZero(t, inHouseIDTm)
require.NotZero(t, inHouseTitleIDTm)
// add software to the host
software := []fleet.Software{
{Name: "a", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "b", Version: "0.0.3", Source: "apps"},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now()))
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
// make software "b" vulnerable
var swBID uint
if host.Software[0].Name == "b" {
swBID = host.Software[0].ID
} else {
swBID = host.Software[1].ID
}
cpes := []fleet.SoftwareCPE{{SoftwareID: swBID, CPE: "somecpe"}}
_, err = ds.UpsertSoftwareCPEs(ctx, cpes)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host, false))
vulns := []fleet.SoftwareVulnerability{
{SoftwareID: swBID, CVE: "CVE-2022-0001"},
}
for _, v := range vulns {
_, err = ds.InsertSoftwareVulnerability(ctx, v, fleet.NVDSource)
require.NoError(t, err)
}
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
pluckSoftwareNames := func(sw []*fleet.HostSoftwareWithInstaller) []string {
names := make([]string, 0, len(sw))
for _, s := range sw {
names = append(names, s.Name)
}
return names
}
// there should be 2 titles installed
sw, _, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 2)
require.Equal(t, []string{"a", "b"}, pluckSoftwareNames(sw))
// 5 titles including the in-house apps available for install
opts.IncludeAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 5)
require.Equal(t, []string{"a", "b", "inhouse1", "inhouse2", "inhouse3"}, pluckSoftwareNames(sw))
// only 1 with self-service
opts.IncludeAvailableForInstall = true
opts.SelfServiceOnly = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 1)
require.Equal(t, []string{"inhouse2"}, pluckSoftwareNames(sw))
// vulnerable only returns "b"
opts.SelfServiceOnly = false
opts.IncludeAvailableForInstall = false
opts.VulnerableOnly = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 1)
require.Equal(t, []string{"b"}, pluckSoftwareNames(sw))
// only available for install returns the in-house apps
opts.VulnerableOnly = false
opts.OnlyAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 3)
require.Equal(t, []string{"inhouse1", "inhouse2", "inhouse3"}, pluckSoftwareNames(sw))
// make inhouse-1 pending install
inhouse1InstallCmd := createInHouseAppInstallRequest(t, ds, host.ID, inHouseID1, inHouseTitleID1, user)
ds.testActivateSpecificNextActivities = []string{inhouse1InstallCmd}
_, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), host.ID, "")
require.NoError(t, err)
// software inventory, no available for install, does not include the pending
// as it's not installed yet
opts.OnlyAvailableForInstall = false
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 2)
require.Equal(t, []string{"a", "b"}, pluckSoftwareNames(sw))
// TODO(mna): thinking of leaving this on here for a bit as I've seen it fail
// with some flakiness before but couldn't repro locally nor on CI. Error was
// in createInHouseAppInstallResultVerified, the nano command for the result
// was not found.
ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error {
fmt.Println(">>> command uuid: ", inhouse1InstallCmd)
DumpTable(t, tx, "hosts", "id", "uuid", "platform", "hostname", "team_id")
DumpTable(t, tx, "nano_devices")
DumpTable(t, tx, "nano_commands")
DumpTable(t, tx, "nano_command_results")
return nil
})
// make inhouse-1 installed, inhouse-2 pending
createInHouseAppInstallResultVerified(t, ds, host, inhouse1InstallCmd, "Acknowledged")
inhouse2InstallCmd := createInHouseAppInstallRequest(t, ds, host.ID, inHouseID2, inHouseTitleID2, user)
ds.testActivateSpecificNextActivities = []string{inhouse2InstallCmd}
_, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), host.ID, "")
require.NoError(t, err)
// mark it as reported as installed on the host
software = []fleet.Software{
{Name: "a", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "b", Version: "0.0.3", Source: "apps"},
{Name: "inhouse1", Version: "0.0.3", Source: "ios_apps", ApplicationID: ptr.String("inhouse1")},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now()))
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
// software inventory, no available for install, includes the installed one
opts.OnlyAvailableForInstall = false
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 3)
require.Equal(t, []string{"a", "b", "inhouse1"}, pluckSoftwareNames(sw))
require.Equal(t, sw[2].Status, ptr.T(fleet.SoftwareInstalled))
require.NotNil(t, sw[2].SoftwarePackage)
require.Equal(t, sw[2].SoftwarePackage.Name, "inhouse1.ipa")
require.Equal(t, sw[2].SoftwarePackage.Platform, "ios")
require.Equal(t, sw[2].SoftwarePackage.SelfService, ptr.Bool(false))
require.NotNil(t, sw[2].SoftwarePackage.LastInstall)
require.Equal(t, sw[2].SoftwarePackage.LastInstall.CommandUUID, inhouse1InstallCmd)
// software with available for install, also includes the pending one
opts.IncludeAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 5)
require.Equal(t, []string{"a", "b", "inhouse1", "inhouse2", "inhouse3"}, pluckSoftwareNames(sw))
require.Equal(t, sw[2].Status, ptr.T(fleet.SoftwareInstalled))
require.Equal(t, sw[3].Status, ptr.T(fleet.SoftwareInstallPending))
require.NotNil(t, sw[3].SoftwarePackage)
require.Equal(t, sw[3].SoftwarePackage.Name, "inhouse2.ipa")
require.Equal(t, sw[3].SoftwarePackage.Platform, "ios")
require.Equal(t, sw[3].SoftwarePackage.SelfService, ptr.Bool(true))
require.NotNil(t, sw[3].SoftwarePackage.LastInstall)
require.Equal(t, sw[3].SoftwarePackage.LastInstall.CommandUUID, inhouse2InstallCmd)
require.Nil(t, sw[4].Status)
require.NotNil(t, sw[4].SoftwarePackage)
require.Equal(t, sw[4].SoftwarePackage.Name, "inhouse3.ipa")
require.Equal(t, sw[4].SoftwarePackage.Platform, "ios")
require.Equal(t, sw[4].SoftwarePackage.SelfService, ptr.Bool(false))
require.Nil(t, sw[4].SoftwarePackage.LastInstall)
// add inhouse3 as installed outside of Fleet
software = []fleet.Software{
{Name: "a", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "b", Version: "0.0.3", Source: "apps"},
{Name: "inhouse1", Version: "0.0.3", Source: "ios_apps", ApplicationID: ptr.String("inhouse1")},
{Name: "inhouse3", Version: "0.0.4", Source: "ios_apps", ApplicationID: ptr.String("inhouse3")},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now()))
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
// software inventory includes it
opts.IncludeAvailableForInstall = false
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 4)
require.Equal(t, []string{"a", "b", "inhouse1", "inhouse3"}, pluckSoftwareNames(sw))
require.Nil(t, sw[3].Status)
// record a failed install for inhouse2
createInHouseAppInstallResultVerified(t, ds, host, inhouse2InstallCmd, "Error")
// software inventory still does not list it
opts.IncludeAvailableForInstall = false
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 4)
require.Equal(t, []string{"a", "b", "inhouse1", "inhouse3"}, pluckSoftwareNames(sw))
// software library shows it as failed
opts.OnlyAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 3)
require.Equal(t, []string{"inhouse1", "inhouse2", "inhouse3"}, pluckSoftwareNames(sw))
require.Equal(t, sw[1].Status, ptr.T(fleet.SoftwareInstallFailed))
require.NotNil(t, sw[1].SoftwarePackage)
require.Equal(t, sw[1].SoftwarePackage.Name, "inhouse2.ipa")
require.Equal(t, sw[1].SoftwarePackage.Platform, "ios")
require.Equal(t, sw[1].SoftwarePackage.SelfService, ptr.Bool(true))
require.NotNil(t, sw[1].SoftwarePackage.LastInstall)
require.Equal(t, sw[1].SoftwarePackage.LastInstall.CommandUUID, inhouse2InstallCmd)
// test with label conditions
lbl1, err := ds.NewLabel(ctx, &fleet.Label{Name: "label1", LabelMembershipType: fleet.LabelMembershipTypeManual})
require.NoError(t, err)
lbl2, err := ds.NewLabel(ctx, &fleet.Label{Name: "label2", Query: "select 1", LabelMembershipType: fleet.LabelMembershipTypeDynamic})
require.NoError(t, err)
lbl3, err := ds.NewLabel(ctx, &fleet.Label{Name: "label3", LabelMembershipType: fleet.LabelMembershipTypeManual})
require.NoError(t, err)
// create an in-house app with include any labels
inHouseIDIncl, inHouseTitleIDIncl, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "inhouseincl",
Source: "ios_apps",
Filename: "inhouseincl.ipa",
Extension: "ipa",
BundleIdentifier: "inhouseincl",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeIncludeAny,
ByName: map[string]fleet.LabelIdent{
lbl1.Name: {LabelID: lbl1.ID, LabelName: lbl1.Name},
lbl2.Name: {LabelID: lbl2.ID, LabelName: lbl2.Name},
},
},
})
require.NoError(t, err)
require.NotZero(t, inHouseIDIncl)
require.NotZero(t, inHouseTitleIDIncl)
// create an in-house app with exclude any labels
inHouseIDExcl, inHouseTitleIDExcl, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "inhouseexcl",
Source: "ios_apps",
Filename: "inhouseexcl.ipa",
Extension: "ipa",
BundleIdentifier: "inhouseexcl",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeExcludeAny,
ByName: map[string]fleet.LabelIdent{
lbl2.Name: {LabelID: lbl2.ID, LabelName: lbl2.Name},
lbl3.Name: {LabelID: lbl3.ID, LabelName: lbl3.Name},
},
},
})
require.NoError(t, err)
require.NotZero(t, inHouseIDExcl)
require.NotZero(t, inHouseTitleIDExcl)
// software inventory does not list those
opts.OnlyAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 3)
require.Equal(t, []string{"inhouse1", "inhouse2", "inhouse3"}, pluckSoftwareNames(sw))
// make host a member of lbl1
err = ds.AddLabelsToHost(ctx, host.ID, []uint{lbl1.ID})
require.NoError(t, err)
// software inventory now shows the include in-house app
opts.OnlyAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 4)
require.Equal(t, []string{"inhouse1", "inhouse2", "inhouse3", "inhouseincl"}, pluckSoftwareNames(sw))
// update the host's labels updated at timestamp so the exclude any condition kicks in
host.LabelUpdatedAt = time.Now()
host.PolicyUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, host)
require.NoError(t, err)
// software inventory now shows the exclude in-house app
opts.OnlyAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 5)
require.Equal(t, []string{"inhouse1", "inhouse2", "inhouse3", "inhouseexcl", "inhouseincl"}, pluckSoftwareNames(sw))
// make host a member of lbl3
err = ds.AddLabelsToHost(ctx, host.ID, []uint{lbl3.ID})
require.NoError(t, err)
// exclude in-house app is now removed
opts.OnlyAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 4)
require.Equal(t, []string{"inhouse1", "inhouse2", "inhouse3", "inhouseincl"}, pluckSoftwareNames(sw))
// Useful for debugging:
// ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error {
// DumpTable(t, tx, "hosts", "id", "uuid", "platform", "hostname", "team_id")
// DumpTable(t, tx, "host_software")
// DumpTable(t, tx, "software", "id", "title_id")
// DumpTable(t, tx, "in_house_apps", "id", "title_id", "global_or_team_id", "filename", "version", "platform")
// DumpTable(t, tx, "in_house_app_labels")
// DumpTable(t, tx, "software_titles", "id", "name", "source", "bundle_identifier", "additional_identifier", "application_id", "unique_identifier")
// return nil
// })
// the other host is unaffected, does not see inhouse-tm since it is not
// mdm-enrolled and wrong platform
opts.IsMDMEnrolled = false
opts.IncludeAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, otherHost, opts)
require.NoError(t, err)
require.Len(t, sw, 0)
}
// testListHostSoftwareAndroidVPPAppMatching verifies that Android inventory software
// matches VPP app titles by application_id even when names differ (bug #36809).
func testListHostSoftwareAndroidVPPAppMatching(t *testing.T, ds *Datastore) {
ctx := context.Background()
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "android-vpp-team"})
require.NoError(t, err)
host := test.NewHost(t, ds, "android-host", "", "android-host-key", "android-host-uuid", time.Now(), test.WithPlatform("android"))
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{host.ID}))
require.NoError(t, err)
host.TeamID = &tm.ID
dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Test org android", "Test location android")
require.NoError(t, err)
tok, err := ds.InsertVPPToken(ctx, dataToken)
require.NoError(t, err)
_, err = ds.UpdateVPPTokenTeams(ctx, tok.ID, []uint{tm.ID})
require.NoError(t, err)
// VPP app with long Play Store name
androidApp := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "com.amazon.shopping",
Platform: fleet.AndroidPlatform,
},
SelfService: true,
},
Name: "Amazon Shopping - Search, Find, Ship, and Save",
BundleIdentifier: "com.amazon.shopping",
LatestVersion: "26.0.0",
IconURL: "https://example.com/amazon-icon.png",
}
vppApp, err := ds.InsertVPPAppWithTeam(ctx, androidApp, &tm.ID)
require.NoError(t, err)
require.NotZero(t, vppApp.TitleID)
var vppTitleSource string
err = ds.writer(ctx).GetContext(ctx, &vppTitleSource,
"SELECT source FROM software_titles WHERE id = ?", vppApp.TitleID)
require.NoError(t, err)
assert.Equal(t, "android_apps", vppTitleSource)
// MDM inventory reports shorter name but same application_id
inventorySoftware := []fleet.Software{
{
Name: "Amazon Shopping",
Version: "26.0.0",
Source: "android_apps",
ApplicationID: ptr.String("com.amazon.shopping"),
},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, inventorySoftware)
require.NoError(t, err)
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now()))
// Verify inventory matched VPP app's title_id
var inventoryTitleID *uint
err = ds.writer(ctx).GetContext(ctx, &inventoryTitleID, `
SELECT s.title_id FROM software s
INNER JOIN host_software hs ON hs.software_id = s.id
WHERE hs.host_id = ? AND s.source = 'android_apps'
LIMIT 1`, host.ID)
require.NoError(t, err)
if inventoryTitleID == nil {
t.Logf("BUG: VPP title_id: %d, Inventory title_id: NULL", vppApp.TitleID)
} else {
t.Logf("VPP app title_id: %d, Inventory title_id: %d (should be same after fix)", vppApp.TitleID, *inventoryTitleID)
}
require.NotNil(t, inventoryTitleID)
assert.Equal(t, vppApp.TitleID, *inventoryTitleID)
opts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true},
IncludeAvailableForInstall: true,
IsMDMEnrolled: true,
}
sw, _, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.NotEmpty(t, sw)
var amazonApp *fleet.HostSoftwareWithInstaller
for i := range sw {
if sw[i].Source == "android_apps" && strings.Contains(sw[i].Name, "Amazon Shopping") {
amazonApp = sw[i]
break
}
}
require.NotNil(t, amazonApp)
assert.NotNil(t, amazonApp.AppStoreApp)
if amazonApp.AppStoreApp != nil {
assert.Equal(t, "com.amazon.shopping", amazonApp.AppStoreApp.AppStoreID)
require.NotNil(t, amazonApp.AppStoreApp.SelfService)
assert.True(t, *amazonApp.AppStoreApp.SelfService)
assert.NotNil(t, amazonApp.IconUrl)
}
}
func testCountHostSoftwareInstallAttempts(t *testing.T, ds *Datastore) {
ctx := context.Background()
// Create test data
host := test.NewHost(t, ds, "host1", "10.0.0.1", "host1Key", "host1UUID", time.Now())
user := test.NewUser(t, ds, "User", "test@example.com", true)
policy, err := ds.NewGlobalPolicy(ctx, &user.ID, fleet.PolicyPayload{
Name: "policy",
Query: "SELECT 1;",
})
require.NoError(t, err)
// software installer
tfr, err := fleet.NewTempFileReader(strings.NewReader("content"), t.TempDir)
require.NoError(t, err)
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "echo 'installing'",
InstallerFile: tfr,
StorageID: "storage1",
Filename: "installer.pkg",
Title: "Software",
Version: "1.0",
Source: "apps",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
// no attempts exist
count, err := ds.CountHostSoftwareInstallAttempts(ctx, host.ID, installerID, policy.ID)
require.NoError(t, err)
require.Equal(t, 0, count)
// 1 attempt
install1UUID, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerID, fleet.HostSoftwareInstallOptions{
PolicyID: &policy.ID,
})
require.NoError(t, err)
require.NotEmpty(t, install1UUID)
// Set result
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: install1UUID,
InstallScriptExitCode: ptr.Int(1), // failed
InstallScriptOutput: ptr.String("install failed"),
}, nil)
require.NoError(t, err)
// 1 attempt, count 1
count, err = ds.CountHostSoftwareInstallAttempts(ctx, host.ID, installerID, policy.ID)
require.NoError(t, err)
require.Equal(t, 1, count)
// retry
install2UUID, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerID, fleet.HostSoftwareInstallOptions{
PolicyID: &policy.ID,
})
require.NoError(t, err)
require.NotEmpty(t, install2UUID)
// Set result for second attempt
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: install2UUID,
InstallScriptExitCode: ptr.Int(1), // failed
InstallScriptOutput: ptr.String("install failed"),
}, ptr.Int(2))
require.NoError(t, err)
// 2 attempts, count is 2
count, err = ds.CountHostSoftwareInstallAttempts(ctx, host.ID, installerID, policy.ID)
require.NoError(t, err)
require.Equal(t, 2, count)
// Test 4: Create third attempt (success this time)
install3UUID, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerID, fleet.HostSoftwareInstallOptions{
PolicyID: &policy.ID,
})
require.NoError(t, err)
require.NotEmpty(t, install3UUID)
// retry
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: install3UUID,
InstallScriptExitCode: ptr.Int(0), // success
InstallScriptOutput: ptr.String("install succeeded"),
}, ptr.Int(3))
require.NoError(t, err)
// 3 attempts, count is 3
count, err = ds.CountHostSoftwareInstallAttempts(ctx, host.ID, installerID, policy.ID)
require.NoError(t, err)
require.Equal(t, 3, count)
// same software but without policy_id
install4UUID, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerID, fleet.HostSoftwareInstallOptions{
PolicyID: nil,
})
require.NoError(t, err)
require.NotEmpty(t, install4UUID)
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: install4UUID,
InstallScriptExitCode: ptr.Int(0),
InstallScriptOutput: ptr.String("user install succeeded"),
}, nil)
require.NoError(t, err)
// Count should not change
count, err = ds.CountHostSoftwareInstallAttempts(ctx, host.ID, installerID, policy.ID)
require.NoError(t, err)
require.Equal(t, 3, count)
// Different host, same installer and policy
host2 := test.NewHost(t, ds, "host2", "10.0.0.2", "host2Key", "host2UUID", time.Now())
count, err = ds.CountHostSoftwareInstallAttempts(ctx, host2.ID, installerID, policy.ID)
require.NoError(t, err)
require.Equal(t, 0, count)
// Same host, different policy
policy2, err := ds.NewGlobalPolicy(ctx, &user.ID, fleet.PolicyPayload{
Name: "test policy 2",
Query: "SELECT 2;",
})
require.NoError(t, err)
count, err = ds.CountHostSoftwareInstallAttempts(ctx, host.ID, installerID, policy2.ID)
require.NoError(t, err)
require.Equal(t, 0, count)
// Same host and policy, different installer
tfr2, err := fleet.NewTempFileReader(strings.NewReader("installer2 content"), t.TempDir)
require.NoError(t, err)
installer2ID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "echo install2",
InstallerFile: tfr2,
StorageID: "storage2",
Filename: "installer2.pkg",
Title: "Test Software 2",
Version: "2.0",
Source: "apps",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
count, err = ds.CountHostSoftwareInstallAttempts(ctx, host.ID, installer2ID, policy.ID)
require.NoError(t, err)
require.Equal(t, 0, count)
}
func testResetNonPolicyInstallAttempts(t *testing.T, ds *Datastore) {
ctx := context.Background()
host := test.NewHost(t, ds, "host-reset-1", "10.0.0.20", "hostReset1Key", "hostReset1UUID", time.Now())
user := test.NewUser(t, ds, "ResetUser", "reset@example.com", true)
// Create a software installer
tfr, err := fleet.NewTempFileReader(strings.NewReader("reset content"), t.TempDir)
require.NoError(t, err)
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "echo installing",
InstallerFile: tfr,
StorageID: "storage-reset-1",
Filename: "reset-installer.pkg",
Title: "ResetSoftware",
Version: "1.0",
Source: "apps",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
// Insert non-policy installs with attempt numbers
install1UUID, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: install1UUID,
InstallScriptExitCode: ptr.Int(1),
InstallScriptOutput: ptr.String("failed"),
}, ptr.Int(1))
require.NoError(t, err)
install2UUID, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: install2UUID,
InstallScriptExitCode: ptr.Int(1),
InstallScriptOutput: ptr.String("failed again"),
}, ptr.Int(2))
require.NoError(t, err)
// Also insert a policy install to ensure it's NOT reset
policy, err := ds.NewGlobalPolicy(ctx, &user.ID, fleet.PolicyPayload{
Name: "reset-test-policy",
Query: "SELECT 1;",
})
require.NoError(t, err)
install3UUID, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerID, fleet.HostSoftwareInstallOptions{
PolicyID: &policy.ID,
})
require.NoError(t, err)
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: install3UUID,
InstallScriptExitCode: ptr.Int(1),
InstallScriptOutput: ptr.String("policy install failed"),
}, ptr.Int(1))
require.NoError(t, err)
// Helper to count non-policy install attempts (where attempt_number > 0 or IS NULL)
countNonPolicyAttempts := func() int {
var count int
err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `
SELECT COUNT(*) FROM host_software_installs
WHERE host_id = ? AND software_installer_id = ? AND policy_id IS NULL
AND removed = 0 AND canceled = 0 AND host_deleted_at IS NULL
AND (attempt_number > 0 OR attempt_number IS NULL)
`, host.ID, installerID)
require.NoError(t, err)
return count
}
// Verify non-policy count before reset
require.Equal(t, 2, countNonPolicyAttempts())
// Verify policy count before reset
policyCount, err := ds.CountHostSoftwareInstallAttempts(ctx, host.ID, installerID, policy.ID)
require.NoError(t, err)
require.Equal(t, 1, policyCount)
// Reset non-policy attempts
err = ds.ResetNonPolicyInstallAttempts(ctx, host.ID, installerID)
require.NoError(t, err)
// Non-policy count should be 0 (all reset to attempt_number=0)
require.Equal(t, 0, countNonPolicyAttempts())
// Policy count should be unchanged
policyCount, err = ds.CountHostSoftwareInstallAttempts(ctx, host.ID, installerID, policy.ID)
require.NoError(t, err)
require.Equal(t, 1, policyCount)
}
// testListSoftwareVersionsSearchByTitleName tests that searching software versions
// by a software title name finds all software entries under that title, even when
// individual software entry names differ from the title name.
// This is a regression test for https://github.com/fleetdm/fleet/issues/35028
func testListSoftwareVersionsSearchByTitleName(t *testing.T, ds *Datastore) {
ctx := t.Context()
// Create a host
host := test.NewHost(t, ds, "search-test-host", "", "searchtestkey", "searchtestuuid", time.Now())
// First, create software with a specific name to establish the title
initialSoftware := []fleet.Software{
{Name: "SharePoint", Version: "1.0.0", Source: "apps", BundleIdentifier: "com.microsoft.sharepoint"},
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, initialSoftware)
require.NoError(t, err)
// Now add more software entries with different names but the same bundle_identifier.
// These will be grouped under the same title "SharePoint" but their individual names
// do NOT contain "SharePoint".
allSoftware := []fleet.Software{
{Name: "SharePoint", Version: "1.0.0", Source: "apps", BundleIdentifier: "com.microsoft.sharepoint"},
{Name: "Office Shared MUI Components", Version: "16.0.5765", Source: "apps", BundleIdentifier: "com.microsoft.sharepoint"},
{Name: "Office Runtime Libraries", Version: "16.0.1234", Source: "apps", BundleIdentifier: "com.microsoft.sharepoint"},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, allSoftware)
require.NoError(t, err)
// Sync host software to populate software_host_counts
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
// Test: Search by the title name "sharepoint" on the versions method.
// This should find all 3 software entries because they all belong to a title named "SharePoint",
// even though only 1 of them has "SharePoint" in its software.name.
opts := fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{
MatchQuery: "sharepoint",
},
WithHostCounts: true,
}
software, _, err := ds.ListSoftware(ctx, opts)
require.NoError(t, err)
// All 3 software entries should be found because they belong to a title named "SharePoint".
require.Len(t, software, 3, "Search by title name should find all software entries under that title")
// Verify we got the expected software entries
foundNames := make(map[string]bool)
for _, sw := range software {
foundNames[sw.Name] = true
}
assert.True(t, foundNames["SharePoint"], "Should find 'SharePoint'")
assert.True(t, foundNames["Office Shared MUI Components"], "Should find 'Office Shared MUI Components'")
assert.True(t, foundNames["Office Runtime Libraries"], "Should find 'Office Runtime Libraries'")
// Test: Search by a name that only matches one software entry (not the title)
// This should still work as before - find the entry by its software.name
opts.ListOptions.MatchQuery = "Runtime"
software, _, err = ds.ListSoftware(ctx, opts)
require.NoError(t, err)
require.Len(t, software, 1, "Search by software name should still work")
assert.Equal(t, "Office Runtime Libraries", software[0].Name)
}
// This test verifies the fix for https://github.com/fleetdm/fleet/issues/33815
func testListSoftwareInventoryDeletedHost(t *testing.T, ds *Datastore) {
ctx := t.Context()
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
user := test.NewUser(t, ds, "User", "test@example.com", true)
tfr, err := fleet.NewTempFileReader(strings.NewReader("content"), t.TempDir)
require.NoError(t, err)
installerID, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "echo 'installing'",
InstallerFile: tfr,
StorageID: "storage1",
Filename: "installer.pkg",
Title: "Software",
Version: "1.0",
Source: "apps",
UserID: user.ID,
Platform: host.Platform,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
// install the software on the host
installUUID, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: installUUID,
PreInstallConditionOutput: ptr.String("ok"),
InstallScriptExitCode: ptr.Int(0),
PostInstallScriptExitCode: ptr.Int(0),
}, nil)
require.NoError(t, err)
opts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name",
},
OnlyAvailableForInstall: true,
}
software, _, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, software, 1)
require.Equal(t, "Software", software[0].Name)
require.Equal(t, titleID, software[0].ID)
err = ds.DeleteHost(ctx, host.ID)
require.NoError(t, err)
// it should still show up as available for install (still part of the inventory
// and the datastore layer does not check if host exists)
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, software, 1)
require.Equal(t, "Software", software[0].Name)
require.Equal(t, titleID, software[0].ID)
}
// testListHostSoftwareShPackageForDarwin tests that .sh packages
// (stored as platform='linux') are visible to darwin hosts
func testListHostSoftwareShPackageForDarwin(t *testing.T, ds *Datastore) {
ctx := t.Context()
// Create a darwin host
darwinHost := test.NewHost(t, ds, "darwin-host", "", "darwinkey", "darwinuuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, darwinHost, false)
// Create a linux host for comparison
linuxHost := test.NewHost(t, ds, "linux-host", "", "linuxkey", "linuxuuid", time.Now(), test.WithPlatform("ubuntu"))
nanoEnroll(t, ds, linuxHost, false)
user := test.NewUser(t, ds, "testuser", "testuser@example.com", true)
// Create a .sh installer (platform='linux', extension='sh')
tfr, err := fleet.NewTempFileReader(strings.NewReader("#!/bin/bash\necho hello"), t.TempDir)
require.NoError(t, err)
_, shTitleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "#!/bin/bash\necho install",
InstallerFile: tfr,
StorageID: "sh-storage-darwin-test",
Filename: "test-script.sh",
Title: "Test Script",
Version: "1.0.0",
Source: "sh_packages",
Platform: "linux", // .sh files are stored as linux platform
Extension: "sh",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
// Create a regular .deb installer (platform='linux'), shouldn't be visible to darwin
tfr2, err := fleet.NewTempFileReader(strings.NewReader("deb content"), t.TempDir)
require.NoError(t, err)
_, debTitleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "dpkg -i installer.deb",
InstallerFile: tfr2,
StorageID: "deb-storage-test",
Filename: "installer.deb",
Title: "Linux Deb Package",
Version: "1.0.0",
Source: "deb_packages",
Platform: "linux",
Extension: "deb",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
opts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{PerPage: 100, OrderKey: "name"},
IncludeAvailableForInstall: true,
}
// Query available software for darwin host
sw, _, err := ds.ListHostSoftware(ctx, darwinHost, opts)
require.NoError(t, err)
// Darwin host should see the .sh package but not the .deb package
var foundSh, foundDeb bool
for _, s := range sw {
if s.ID == shTitleID {
foundSh = true
require.Equal(t, "Test Script", s.Name)
require.Equal(t, "sh_packages", s.Source)
}
if s.ID == debTitleID {
foundDeb = true
}
}
require.True(t, foundSh, ".sh package should be visible to darwin host")
require.False(t, foundDeb, ".deb package should NOT be visible to darwin host")
// Query available software for linux host, should see both
sw, _, err = ds.ListHostSoftware(ctx, linuxHost, opts)
require.NoError(t, err)
foundSh = false
foundDeb = false
for _, s := range sw {
if s.ID == shTitleID {
foundSh = true
}
if s.ID == debTitleID {
foundDeb = true
}
}
require.True(t, foundSh, ".sh package should be visible to linux host")
require.True(t, foundDeb, ".deb package should be visible to linux host")
// Create a Windows host
windowsHost := test.NewHost(t, ds, "windows-host", "", "windowskey", "windowsuuid", time.Now(), test.WithPlatform("windows"))
nanoEnroll(t, ds, windowsHost, false)
// Query available software for Windows host
sw, _, err = ds.ListHostSoftware(ctx, windowsHost, opts)
require.NoError(t, err)
// Windows host should NOT see .sh or .deb packages
foundSh = false
foundDeb = false
for _, s := range sw {
if s.ID == shTitleID {
foundSh = true
}
if s.ID == debTitleID {
foundDeb = true
}
}
require.False(t, foundSh, ".sh package should NOT be visible to windows host")
require.False(t, foundDeb, ".deb package should NOT be visible to windows host")
}
// testListHostSoftwarePaginationWithMultipleInstallers verifies that pagination metadata
// (HasNextResults, TotalResults) is correct when a software title has multiple installers
// (different versions). The UNION data query LEFT JOINs software_installers and groups by
// installer ID, so multiple installers for the same title produce multiple rows. Without the
// fix, the row-count heuristic for HasNextResults would report false prematurely because
// post-query deduplication shrinks the result set below perPage.
// See https://github.com/fleetdm/fleet/issues/41233
func testListHostSoftwarePaginationWithMultipleInstallers(t *testing.T, ds *Datastore) {
ctx := t.Context()
host := test.NewHost(t, ds, "pag-host", "", "pagkey", "paguuid", time.Now(), test.WithPlatform("darwin"))
// Install 10 software titles on the host with perPage=5, giving 2 pages.
const totalTitles = 10
software := make([]fleet.Software, totalTitles)
for i := range software {
software[i] = fleet.Software{
Name: fmt.Sprintf("pagsw-%02d", i),
Version: "1.0.0",
Source: "apps",
}
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
// Give the last title on page 0 (pagsw-04, the 5th item when ordered by name)
// two installers (different versions).
//
// The data query produces these raw rows for page 0 (LIMIT 6, OFFSET 0):
// pagsw-00 (1 row), pagsw-01 (1 row), pagsw-02 (1 row), pagsw-03 (1 row),
// pagsw-04 installer-v1 (1 row), pagsw-04 installer-v2 (1 row)
// = 6 raw rows → 5 unique titles after dedup.
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
insertScript := func(script string) (int64, error) {
res, err := q.ExecContext(ctx,
`INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(MD5(?)), ?)`, script, script,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
installScriptID, err := insertScript("echo install")
if err != nil {
return err
}
uninstallScriptID, err := insertScript("echo uninstall")
if err != nil {
return err
}
var titleID uint
if err := sqlx.GetContext(ctx, q, &titleID,
`SELECT id FROM software_titles WHERE name = 'pagsw-04' AND source = 'apps'`,
); err != nil {
return err
}
for _, version := range []string{"1.0.0", "2.0.0"} {
if _, err := q.ExecContext(ctx, `
INSERT INTO software_installers
(team_id, global_or_team_id, title_id, filename, extension, version,
install_script_content_id, uninstall_script_content_id, storage_id, platform, self_service, package_ids, patch_query)
VALUES (NULL, 0, ?, ?, 'pkg', ?, ?, ?, ?, 'darwin', 0, '[]', '')`,
titleID, fmt.Sprintf("installer-%s.pkg", version), version,
installScriptID, uninstallScriptID, fmt.Appendf(nil, "storage-%s", version),
); err != nil {
return err
}
}
return nil
})
const perPage = 5
opts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
PerPage: perPage,
IncludeMetadata: true,
OrderKey: "name",
},
}
// Page 0: 5 unique titles, HasNextResults must be true (there are 10 total).
opts.ListOptions.Page = 0
sw, meta, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, perPage, "page 0 should have %d items", perPage)
require.NotNil(t, meta)
assert.Equal(t, uint(totalTitles), meta.TotalResults, "total results should reflect unique titles, not duplicated rows")
assert.True(t, meta.HasNextResults, "page 0 of %d items with perPage=%d should have next results", totalTitles, perPage)
assert.False(t, meta.HasPreviousResults)
}
// TestUniqueSoftwareTitleStrNormalization tests that UniqueSoftwareTitleStr
// produces consistent keys regardless of Unicode format characters (like RTL mark)
// which MySQL's utf8mb4_unicode_ci collation ignores.
func TestUniqueSoftwareTitleStrNormalization(t *testing.T) {
// With RTL mark (U+200F) - the actual bug case from production
keyWithRTL := UniqueSoftwareTitleStr("\u200fSmart Connect", "programs", "")
keyWithoutRTL := UniqueSoftwareTitleStr("Smart Connect", "programs", "")
assert.Equal(t, keyWithoutRTL, keyWithRTL, "RTL mark should be stripped")
// Verify regular unicode is preserved
keyJapanese := UniqueSoftwareTitleStr("日本語ソフト", "apps", "")
assert.Contains(t, keyJapanese, "日本語ソフト")
}
func testHostSWPaginationWithMultipleFMAVersions(t *testing.T, ds *Datastore) {
ctx := context.Background()
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
tmHost := test.NewHost(t, ds, "host", "", "hostkey", "hostuuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, tmHost, false)
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{tmHost.ID}))
require.NoError(t, err)
tmHost.TeamID = &tm.ID
user := test.NewUser(t, ds, "Alice", "alice@example.com", true)
// Add some software that's pre-installed on the host
const totalTitles = 10
software := make([]fleet.Software, totalTitles)
for i := range software {
software[i] = fleet.Software{
Name: fmt.Sprintf("pagsw-%02d", i),
Version: "1.0.0",
Source: "apps",
}
}
_, err = ds.UpdateHostSoftware(ctx, tmHost.ID, software)
require.NoError(t, err)
// Add an FMA and install it on the host
fma, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{ID: 1})
require.NoError(t, err)
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
installerTm1, installerTitleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
InstallerFile: tfr1,
StorageID: "storage1",
FleetMaintainedAppID: ptr.Uint(fma.ID),
Filename: "file1",
Title: "file1",
Version: "1.0",
Source: "apps",
TeamID: &tm.ID,
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
hostInstall1, err := ds.InsertSoftwareInstallRequest(ctx, tmHost.ID, installerTm1, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: tmHost.ID,
InstallUUID: hostInstall1,
InstallScriptExitCode: ptr.Int(0),
}, nil)
require.NoError(t, err)
software = append(software, fleet.Software{
Name: "file1",
Version: "1.0",
Source: "apps",
TitleID: &installerTitleID,
})
_, err = ds.UpdateHostSoftware(ctx, tmHost.ID, software)
require.NoError(t, err)
opts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
PerPage: 2,
IncludeMetadata: true,
OrderKey: "name",
},
}
opts.ListOptions.Page = 0
sw, _, err := ds.ListHostSoftware(ctx, tmHost, opts)
require.NoError(t, err)
require.Len(t, sw, 2)
// Store a couple of new versions and update the currently active installer for the FMA
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, "UPDATE software_installers SET is_active = FALSE WHERE id = ?", installerTm1)
return err
})
installerTm1, installerTitleID, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
InstallerFile: tfr1,
StorageID: "storage1",
FleetMaintainedAppID: ptr.Uint(fma.ID),
Filename: "file1",
Title: "file1",
Version: "2.0",
Source: "apps",
TeamID: &tm.ID,
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, "UPDATE software_installers SET is_active = FALSE WHERE id = ?", installerTm1)
return err
})
installerTm1, installerTitleID, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
InstallerFile: tfr1,
StorageID: "storage1",
FleetMaintainedAppID: ptr.Uint(fma.ID),
Filename: "file1",
Title: "file1",
Version: "3.0",
Source: "apps",
TeamID: &tm.ID,
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, "UPDATE software_installers SET is_active = FALSE WHERE id = ?", installerTm1)
return err
})
opts.ListOptions.Page = 0
sw, _, err = ds.ListHostSoftware(ctx, tmHost, opts)
require.NoError(t, err)
require.Len(t, sw, 2) // Even though there are multiple installer versions for this FMA, the title only appears once.
require.Equal(t, sw[0].Name, "file1") // FMA
require.Equal(t, sw[1].Name, "pagsw-00") // "other" software
}
func testSoftwareLiteByID(t *testing.T, ds *Datastore) {
ctx := context.Background()
host := test.NewHost(t, ds, "svnfhf-host", "", "svnfhf-key", "svnfhf-uuid", time.Now())
sw := []fleet.Software{
{Name: "GoLand.app", Version: "2024.3", Source: "apps", BundleIdentifier: "com.jetbrains.goland"},
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, sw)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
require.Len(t, host.Software, 1)
swID := host.Software[0].ID
swLite, err := ds.SoftwareLiteByID(ctx, swID)
require.NoError(t, err)
assert.Equal(t, "GoLand.app", swLite.Name)
assert.Equal(t, "2024.3", swLite.Version)
// non-existent sw
_, err = ds.SoftwareLiteByID(ctx, 999999)
require.Error(t, err)
require.True(t, fleet.IsNotFound(err))
}