fleet/server/datastore/mysql/software_test.go
Victor Lyuboslavsky abf4837eff
Broke apart the hourly host_software count query to reduce the individual query runtime (#18773)
#18221
Broke apart the hourly host_software count query to reduce the
individual query runtime. This fixes timeouts seen when host_software
table has over 25 million records.

I recommend hiding whitespace during review:
<img width="240" alt="image"
src="https://github.com/fleetdm/fleet/assets/2685025/6da9b643-8582-4d2f-bf32-8a1cc38f1032">


# Checklist for submitter

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://fleetdm.com/docs/contributing/committing-changes#changes-files)
for more information.
- [x] Added/updated tests
- [x] Manual QA for all new/changed functionality
2024-05-08 09:27:17 -05:00

2967 lines
102 KiB
Go

package mysql
import (
"context"
"crypto/md5" // nolint:gosec (only used for tests)
"database/sql"
"encoding/hex"
"fmt"
"math/rand"
"sort"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/fleetdm/fleet/v4/server/vulnerabilities/oval"
"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},
{"HostDuplicates", testSoftwareHostDuplicates},
{"LoadVulnerabilities", testSoftwareLoadVulnerabilities},
{"ListSoftwareCPEs", testListSoftwareCPEs},
{"NothingChanged", testSoftwareNothingChanged},
{"LoadSupportsTonsOfCVEs", testSoftwareLoadSupportsTonsOfCVEs},
{"List", testSoftwareList},
{"SyncHostsSoftware", testSoftwareSyncHostsSoftware},
{"DeleteSoftwareVulnerabilities", testDeleteSoftwareVulnerabilities},
{"HostsByCVE", testHostsByCVE},
{"HostVulnSummariesBySoftwareIDs", testHostVulnSummariesBySoftwareIDs},
{"UpdateHostSoftware", testUpdateHostSoftware},
{"UpdateHostSoftwareDeadlock", testUpdateHostSoftwareDeadlock},
{"UpdateHostSoftwareUpdatesSoftware", testUpdateHostSoftwareUpdatesSoftware},
{"ListSoftwareByHostIDShort", testListSoftwareByHostIDShort},
{"ListSoftwareVulnerabilitiesByHostIDsSource", testListSoftwareVulnerabilitiesByHostIDsSource},
{"InsertSoftwareVulnerability", testInsertSoftwareVulnerability},
{"ListCVEs", testListCVEs},
{"ListSoftwareForVulnDetection", testListSoftwareForVulnDetection},
{"AllSoftwareIterator", testAllSoftwareIterator},
{"UpsertSoftwareCPEs", testUpsertSoftwareCPEs},
{"DeleteOutOfDateVulnerabilities", testDeleteOutOfDateVulnerabilities},
{"DeleteSoftwareCPEs", testDeleteSoftwareCPEs},
{"SoftwareByIDNoDuplicatedVulns", testSoftwareByIDNoDuplicatedVulns},
{"SoftwareByIDIncludesCVEPublishedDate", testSoftwareByIDIncludesCVEPublishedDate},
{"getHostSoftwareInstalledPaths", testGetHostSoftwareInstalledPaths},
{"hostSoftwareInstalledPathsDelta", testHostSoftwareInstalledPathsDelta},
{"deleteHostSoftwareInstalledPaths", testDeleteHostSoftwareInstalledPaths},
{"insertHostSoftwareInstalledPaths", testInsertHostSoftwareInstalledPaths},
{"VerifySoftwareChecksum", testVerifySoftwareChecksum},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
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 testSoftwareHostDuplicates(t *testing.T, ds *Datastore) {
host1 := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
longName := strings.Repeat("a", fleet.SoftwareNameMaxLength+5)
incoming := make(map[string]fleet.Software)
sw, err := fleet.SoftwareFromOsqueryRow(longName+"b", "0.0.1", "chrome_extension", "", "", "", "", "", "", "", "")
require.NoError(t, err)
soft2Key := sw.ToUniqueStr()
incoming[soft2Key] = *sw
tx, err := ds.writer(context.Background()).Beginx()
require.NoError(t, err)
_, err = insertNewInstalledHostSoftwareDB(context.Background(), tx, host1.ID, make(map[string]fleet.Software), incoming)
require.NoError(t, err)
require.NoError(t, tx.Commit())
// Check that the software entry was stored for the host.
var software []fleet.Software
err = sqlx.SelectContext(context.Background(), ds.reader(context.Background()),
&software, `SELECT s.id, s.name FROM software s JOIN host_software hs WHERE hs.host_id = ?`,
host1.ID,
)
require.NoError(t, err)
require.Len(t, software, 1)
require.NotZero(t, software[0].ID)
require.Equal(t, strings.Repeat("a", fleet.SoftwareNameMaxLength), software[0].Name)
incoming = make(map[string]fleet.Software)
sw, err = fleet.SoftwareFromOsqueryRow(longName+"c", "0.0.1", "chrome_extension", "", "", "", "", "", "", "", "")
require.NoError(t, err)
soft3Key := sw.ToUniqueStr()
incoming[soft3Key] = *sw
tx, err = ds.writer(context.Background()).Beginx()
require.NoError(t, err)
_, err = insertNewInstalledHostSoftwareDB(context.Background(), tx, host1.ID, make(map[string]fleet.Software), incoming)
require.NoError(t, err)
require.NoError(t, tx.Commit())
// Check that the software entry was not modified with the new insert because of the name trimming.
var software2 []fleet.Software
err = sqlx.SelectContext(context.Background(), ds.reader(context.Background()),
&software2, `SELECT s.id, s.name FROM software s JOIN host_software hs WHERE hs.host_id = ?`,
host1.ID,
)
require.NoError(t, err)
require.Len(t, software2, 1)
require.Equal(t, strings.Repeat("a", fleet.SoftwareNameMaxLength), software2[0].Name)
require.Equal(t, software[0].ID, software2[0].ID)
}
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.Equal(t, "somecpe", host.Software[0].GenerateCPE)
require.Len(t, host.Software[0].Vulnerabilities, 2)
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,
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
got := nothingChanged(c.current, c.incoming, defaultMinLastOpenedAtDiff)
if c.want {
require.True(t, got)
} else {
require.False(t, got)
}
})
}
}
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.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"},
}
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(), &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)
})
t.Run("filters by team and paginates", func(t *testing.T) {
team1, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team1-" + t.Name()})
require.NoError(t, err)
require.NoError(t, ds.AddHostsToTeam(context.Background(), &team1.ID, []uint{host1.ID}))
require.NoError(t, ds.SyncHostsSoftware(context.Background(), time.Now()))
opts := fleet.SoftwareListOptions{
ListOptions: fleet.ListOptions{
PerPage: 1,
Page: 1,
OrderKey: "id",
IncludeMetadata: true,
},
TeamID: &team1.ID,
}
software := listSoftwareCheckCount(t, ds, 1, 2, opts, true)
expected := []fleet.Software{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 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) {
software := 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, software[0].Name)
assert.Equal(t, 2, software[0].HostsCount)
})
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)
}
}
})
}
func listSoftwareCheckCount(t *testing.T, ds *Datastore, expectedListCount int, expectedFullCount int, opts fleet.SoftwareListOptions, returnSorted bool) []fleet.Software {
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 _, s := range software {
sort.Slice(s.Vulnerabilities, func(i, j int) bool {
return s.Vulnerabilities[i].CVE < s.Vulnerabilities[j].CVE
})
}
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
t.Cleanup(
func() {
countHostSoftwareBatchSize = countHostSoftwareBatchSizeOrig
},
)
countHostSoftwareBatchSize = 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"},
{Name: "def", Version: "0.0.1", Source: "apps"},
}
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(16)
// 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(4)
// 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(3)
// 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("")))
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, &team1.ID, []uint{host3.ID}))
host4 := test.NewHost(t, ds, "host4", "", "host4key", "host4uuid", time.Now())
require.NoError(t, ds.AddHostsToTeam(ctx, &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(), &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(3)
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(3)
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), so we expect more rows
checkTableTotalCount(8)
soft1ByID, err := ds.SoftwareByID(context.Background(), host1.HostSoftware.Software[0].ID, &team1.ID, false, nil)
require.NoError(t, err)
software1[0].ID = host1.HostSoftware.Software[0].ID
assert.Equal(t, software1[0], *soft1ByID)
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(6)
// 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))
// this call will remove team2 from the software host counts table
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: 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(5)
}
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", fmt.Sprintf("/some/path/%s", s.Name), 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", fmt.Sprintf("/some/path/%s", s.Name), 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)
// "new" is not returned until ds.SyncHostsSoftware is executed.
// "baz2" is gone from the software list.
// "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, 2, 2, opts, false)
expectedSoftware = []fleet.Software{
{Name: "foo", Version: "0.0.1", HostsCount: 2},
{Name: "baz", Version: "0.0.3", HostsCount: 2},
}
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()
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)
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"},
{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})
}
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)
inserted, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{
SoftwareID: host.Software[0].ID, CVE: "cve-1",
}, fleet.UbuntuOVALSource)
require.NoError(t, err)
require.False(t, inserted)
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] = occurrence[v.CVE] + 1
}
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)
inserted, err = ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.UbuntuOVALSource)
require.NoError(t, err)
require.False(t, inserted)
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] = occurrence[v.CVE] + 1
}
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 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))
result, err := ds.ListSoftwareForVulnDetection(ctx, host.ID)
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)
}
})
}
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(), &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",
})
}
_, 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: "foo", Version: "v0.0.2", Source: "apps"},
{Name: "foo", Version: "0.0.3", Source: "apps"},
{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))
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 {
q fleet.SoftwareIterQueryOptions
expected []fleet.Software
}{
{
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"}},
},
{
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: "deb_packages", GenerateCPE: "cpe:bar_v3"},
},
q: fleet.SoftwareIterQueryOptions{ExcludedSources: []string{"apps"}},
},
{
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"}},
},
{
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: "deb_packages", GenerateCPE: "cpe:bar_v3"},
},
q: fleet.SoftwareIterQueryOptions{},
},
}
for _, tC := range testCases {
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 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.
inserted, err = ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.NVDSource)
require.NoError(t, err)
require.False(t, inserted)
err = ds.DeleteOutOfDateVulnerabilities(ctx, fleet.NVDSource, 2*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 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(host.ID, nil, nil, nil)
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 {
stored = append(stored, fleet.HostSoftwareInstalledPath{
ID: uint(i),
HostID: host.ID,
SoftwareID: s.ID,
InstalledPath: fmt.Sprintf("/some/path/%d", s.ID),
})
}
toI, toD, err := hostSoftwareInstalledPathsDelta(host.ID, nil, stored, software)
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", software[0].ID, fleet.SoftwareFieldSeparator, software[0].ToUniqueStr())] = struct{}{}
reported[fmt.Sprintf("/some/path/%d%s%s", software[1].ID+1, fleet.SoftwareFieldSeparator, software[1].ToUniqueStr())] = struct{}{}
reported[fmt.Sprintf("/some/path/%d%s%s", software[2].ID, fleet.SoftwareFieldSeparator, software[2].ToUniqueStr())] = struct{}{}
var stored []fleet.HostSoftwareInstalledPath
_, _, err := hostSoftwareInstalledPathsDelta(host.ID, reported, stored, nil)
require.Error(t, err)
})
t.Run("we have some deltas", func(t *testing.T) {
getKey := func(s fleet.Software, change uint) string {
return fmt.Sprintf("/some/path/%d%s%s", s.ID+change, fleet.SoftwareFieldSeparator, s.ToUniqueStr())
}
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,
InstalledPath: fmt.Sprintf("/some/path/%d", software[0].ID),
})
stored = append(stored, fleet.HostSoftwareInstalledPath{
ID: 2,
HostID: host.ID,
SoftwareID: software[1].ID,
InstalledPath: fmt.Sprintf("/some/path/%d", software[1].ID),
})
stored = append(stored, fleet.HostSoftwareInstalledPath{
ID: 3,
HostID: host.ID,
SoftwareID: software[2].ID,
InstalledPath: fmt.Sprintf("/some/path/%d", software[2].ID+1),
})
stored = append(stored, fleet.HostSoftwareInstalledPath{
ID: 4,
HostID: host.ID,
SoftwareID: software[3].ID,
InstalledPath: fmt.Sprintf("/some/path/%d", software[3].ID),
})
toI, toD, err := hostSoftwareInstalledPathsDelta(host.ID, reported, stored, software)
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)},
)
})
}
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",
},
{
HostID: 1,
SoftwareID: 2,
InstalledPath: "2",
},
{
HostID: 1,
SoftwareID: 3,
InstalledPath: "3",
},
}
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 FROM host_software_installed_paths`))
require.ElementsMatch(t, actual, toInsert)
}
func TestReconcileSoftwareTitles(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", Browser: "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"},
}
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, browser, `+"`release`"+`, vendor, arch, title_id
FROM software ORDER BY name, source, browser, 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, browser FROM software_titles ORDER BY name, source, browser`)
if err != nil {
return nil, err
}
return swt, nil
}
expectedTitlesByNSB := map[string]fleet.SoftwareTitle{}
assertSoftware := func(t *testing.T, wantSoftware []fleet.Software, wantNilTitleID []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.Browser+s.Version] = s
}
for _, r := range gotSoftware {
_, ok := byNSBV[r.Name+r.Source+r.Browser+r.Version]
require.True(t, ok)
if r.TitleID == nil {
var found bool
for _, s := range wantNilTitleID {
if s.Name == r.Name && s.Source == r.Source && s.Browser == r.Browser && s.Version == r.Version {
found = true
break
}
}
require.True(t, found)
} else {
require.NotNil(t, r.TitleID)
swt, ok := expectedTitlesByNSB[r.Name+r.Source+r.Browser]
require.True(t, ok)
require.NotNil(t, r.TitleID)
require.Equal(t, swt.ID, *r.TitleID)
require.Equal(t, swt.Name, r.Name)
require.Equal(t, swt.Source, r.Source)
require.Equal(t, swt.Browser, r.Browser)
}
}
}
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.Browser]
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.Browser, r.Browser)
}
}
// title_id is initially nil for all software entries
assertSoftware(t, expectedSoftware, expectedSoftware)
// reconcile software titles
require.NoError(t, ds.ReconcileSoftwareTitles(ctx))
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].Browser, "")
expectedTitlesByNSB[swt[0].Name+swt[0].Source+swt[0].Browser] = swt[0]
require.Equal(t, swt[1].Name, "baz")
require.Equal(t, swt[1].Source, "deb_packages")
require.Equal(t, swt[1].Browser, "")
expectedTitlesByNSB[swt[1].Name+swt[1].Source+swt[1].Browser] = swt[1]
require.Equal(t, swt[2].Name, "foo")
require.Equal(t, swt[2].Source, "chrome_extensions")
require.Equal(t, swt[2].Browser, "")
expectedTitlesByNSB[swt[2].Name+swt[2].Source+swt[2].Browser] = swt[2]
require.Equal(t, swt[3].Name, "foo")
require.Equal(t, swt[3].Source, "chrome_extensions")
require.Equal(t, swt[3].Browser, "chrome")
expectedTitlesByNSB[swt[3].Name+swt[3].Source+swt[3].Browser] = swt[3]
// title_id is now populated for all software entries
assertSoftware(t, expectedSoftware, nil)
// remove the bar software title from host 2
_, err = ds.UpdateHostSoftware(context.Background(), host2.ID, software2[:2])
require.NoError(t, err)
assertSoftware(t, []fleet.Software{expectedSoftware[0], expectedSoftware[1], expectedSoftware[2], expectedSoftware[4]}, nil)
// bar is no longer associated with any host so the title should be deleted
require.NoError(t, ds.ReconcileSoftwareTitles(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()))
// title_id is initially nil for new software entries
assertSoftware(t, expectedSoftware, []fleet.Software{expectedSoftware[3]})
// bar isn't added back to software titles until we reconcile software titles
gotTitles, err = getTitles()
require.NoError(t, err)
require.Len(t, gotTitles, 3)
assertTitles(t, gotTitles, []string{"bar"})
// reconcile software titles
require.NoError(t, ds.ReconcileSoftwareTitles(ctx))
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)
// title_id is now populated for bar
assertSoftware(t, expectedSoftware, nil)
// 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)
// title_id is initially nil for new software entries
assertSoftware(t, expectedSoftware, []fleet.Software{expectedSoftware[5]})
// new version of foo doesn't result in a new software title entry
require.NoError(t, ds.ReconcileSoftwareTitles(ctx))
gotTitles, err = getTitles()
require.NoError(t, err)
require.Len(t, gotTitles, 4)
assertTitles(t, gotTitles, nil)
// title_id is now populated for new version of foo
assertSoftware(t, expectedSoftware, nil)
// 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)
// title_id is initially nil for new software entries
assertSoftware(t, expectedSoftware, []fleet.Software{expectedSoftware[6]})
// new source of foo results in a new software title entry
require.NoError(t, ds.ReconcileSoftwareTitles(ctx))
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].Browser)
expectedTitlesByNSB[gotTitles[4].Name+gotTitles[4].Source+gotTitles[4].Browser] = gotTitles[4]
assertTitles(t, gotTitles, nil)
// title_id is now populated for new source of foo
assertSoftware(t, expectedSoftware, nil)
}
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())
computeChecksum := func(sw fleet.Software) string {
h := md5.New()
// compute the same way as the DB, see the softwareChecksumComputedColumn function
cols := []string{sw.Name, sw.Version, sw.Source, sw.BundleIdentifier, sw.Release, sw.Arch, sw.Vendor, sw.Browser, sw.ExtensionID}
fmt.Fprint(h, strings.Join(cols, "\x00"))
checksum := h.Sum(nil)
return hex.EncodeToString(checksum)
}
software := []fleet.Software{
{Name: "foo", Version: "0.0.1", Source: "test"},
{Name: "foo", Version: "0.0.1", Source: "test", Browser: "firefox"},
{Name: "foo", Version: "0.0.1", Source: "test", ExtensionID: "ext"},
{Name: "foo", Version: "0.0.2", Source: "test"},
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
checksums := make([]string, len(software))
for i, sw := range software {
checksums[i] = computeChecksum(sw)
}
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, browser, extension_id FROM software WHERE checksum = UNHEX(?)`, cs)
})
require.Equal(t, software[i], got)
}
}