fleet/server/datastore/mysql/software.go

3331 lines
105 KiB
Go
Raw Normal View History

package mysql
import (
"context"
"crypto/md5" //nolint:gosec // This hash is used as a DB optimization for software row lookup, not security
"encoding/hex"
"fmt"
"sort"
"strings"
"time"
"github.com/doug-martin/goqu/v9"
_ "github.com/doug-martin/goqu/v9/dialect/mysql"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
2021-06-26 04:46:51 +00:00
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/go-kit/log/level"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type softwareIDChecksum struct {
ID uint `db:"id"`
Checksum string `db:"checksum"`
}
// Since DB may have millions of software items, we need to batch the aggregation counts to avoid long SQL query times.
// This is a variable so it can be adjusted during unit testing.
var countHostSoftwareBatchSize = uint64(100000)
// Since a host may have a lot of software items, we need to batch the inserts.
// The maximum number of software items we can insert at one time is governed by max_allowed_packet, which already be set to a high value for MDM bootstrap packages,
// and by the maximum number of placeholders in a prepared statement, which is 65,536. These are already fairly large limits.
// This is a variable, so it can be adjusted during unit testing.
var softwareInsertBatchSize = 1000
func softwareSliceToMap(softwareItems []fleet.Software) map[string]fleet.Software {
result := make(map[string]fleet.Software, len(softwareItems))
for _, s := range softwareItems {
result[s.ToUniqueStr()] = s
}
return result
}
func (ds *Datastore) UpdateHostSoftware(ctx context.Context, hostID uint, software []fleet.Software) (*fleet.UpdateHostSoftwareDBResult, error) {
return ds.applyChangesForNewSoftwareDB(ctx, hostID, software)
}
func (ds *Datastore) UpdateHostSoftwareInstalledPaths(
ctx context.Context,
hostID uint,
reported map[string]struct{},
mutationResults *fleet.UpdateHostSoftwareDBResult,
) error {
currS := mutationResults.CurrInstalled()
hsip, err := ds.getHostSoftwareInstalledPaths(ctx, hostID)
if err != nil {
return err
}
toI, toD, err := hostSoftwareInstalledPathsDelta(hostID, reported, hsip, currS)
if err != nil {
return err
}
if len(toI) == 0 && len(toD) == 0 {
// Nothing to do ...
return nil
}
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
if err := deleteHostSoftwareInstalledPaths(ctx, tx, toD); err != nil {
return err
}
if err := insertHostSoftwareInstalledPaths(ctx, tx, toI); err != nil {
return err
}
return nil
})
}
// getHostSoftwareInstalledPaths returns all HostSoftwareInstalledPath for the given hostID.
func (ds *Datastore) getHostSoftwareInstalledPaths(
ctx context.Context,
hostID uint,
) (
[]fleet.HostSoftwareInstalledPath,
error,
) {
stmt := `
Add `team_identifier` to macOS software (#23766) Changes to add `team_identifier` signing information to macOS applications on the `/api/latest/fleet/hosts/:id/software` API endpoint. Docs: https://github.com/fleetdm/fleet/pull/23743 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [X] Added/updated tests - [X] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [X] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ X Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [X] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [X] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [X] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [X] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)). --------- Co-authored-by: Tim Lee <timlee@fleetdm.com> Co-authored-by: Ian Littman <iansltx@gmail.com>
2024-11-15 17:17:04 +00:00
SELECT t.id, t.host_id, t.software_id, t.installed_path, t.team_identifier
FROM host_software_installed_paths t
WHERE t.host_id = ?
`
var result []fleet.HostSoftwareInstalledPath
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &result, stmt, hostID); err != nil {
return nil, err
}
return result, nil
}
// hostSoftwareInstalledPathsDelta returns what should be inserted and deleted to keep the
// 'host_software_installed_paths' table in-sync with the osquery reported query results.
// 'reported' is a set of 'installed_path-software.UniqueStr' strings, built from the osquery
// results.
// 'stored' contains all 'host_software_installed_paths' rows for the given host.
// 'hostSoftware' contains the current software installed on the host.
func hostSoftwareInstalledPathsDelta(
hostID uint,
reported map[string]struct{},
stored []fleet.HostSoftwareInstalledPath,
hostSoftware []fleet.Software,
) (
toInsert []fleet.HostSoftwareInstalledPath,
toDelete []uint,
err error,
) {
if len(reported) != 0 && len(hostSoftware) == 0 {
// Error condition, something reported implies that the host has some software
err = fmt.Errorf("software installed paths for host %d were reported but host contains no software", hostID)
return
}
sIDLookup := map[uint]fleet.Software{}
for _, s := range hostSoftware {
sIDLookup[s.ID] = s
}
sUnqStrLook := map[string]fleet.Software{}
for _, s := range hostSoftware {
sUnqStrLook[s.ToUniqueStr()] = s
}
iSPathLookup := make(map[string]fleet.HostSoftwareInstalledPath)
for _, r := range stored {
s, ok := sIDLookup[r.SoftwareID]
// Software currently not found on the host, should be deleted ...
if !ok {
toDelete = append(toDelete, r.ID)
continue
}
Add `team_identifier` to macOS software (#23766) Changes to add `team_identifier` signing information to macOS applications on the `/api/latest/fleet/hosts/:id/software` API endpoint. Docs: https://github.com/fleetdm/fleet/pull/23743 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [X] Added/updated tests - [X] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [X] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ X Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [X] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [X] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [X] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [X] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)). --------- Co-authored-by: Tim Lee <timlee@fleetdm.com> Co-authored-by: Ian Littman <iansltx@gmail.com>
2024-11-15 17:17:04 +00:00
key := fmt.Sprintf(
"%s%s%s%s%s",
r.InstalledPath, fleet.SoftwareFieldSeparator, r.TeamIdentifier, fleet.SoftwareFieldSeparator, s.ToUniqueStr(),
)
iSPathLookup[key] = r
// Anything stored but not reported should be deleted
if _, ok := reported[key]; !ok {
toDelete = append(toDelete, r.ID)
}
}
for key := range reported {
Add `team_identifier` to macOS software (#23766) Changes to add `team_identifier` signing information to macOS applications on the `/api/latest/fleet/hosts/:id/software` API endpoint. Docs: https://github.com/fleetdm/fleet/pull/23743 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [X] Added/updated tests - [X] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [X] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ X Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [X] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [X] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [X] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [X] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)). --------- Co-authored-by: Tim Lee <timlee@fleetdm.com> Co-authored-by: Ian Littman <iansltx@gmail.com>
2024-11-15 17:17:04 +00:00
parts := strings.SplitN(key, fleet.SoftwareFieldSeparator, 3)
installedPath, teamIdentifier, unqStr := parts[0], parts[1], parts[2]
// Shouldn't be possible ... everything 'reported' should be in the the software table
// because this executes after 'ds.UpdateHostSoftware'
s, ok := sUnqStrLook[unqStr]
if !ok {
err = fmt.Errorf("reported installed path for %s does not belong to any stored software entry", unqStr)
return
}
if _, ok := iSPathLookup[key]; ok {
// Nothing to do
continue
}
toInsert = append(toInsert, fleet.HostSoftwareInstalledPath{
Add `team_identifier` to macOS software (#23766) Changes to add `team_identifier` signing information to macOS applications on the `/api/latest/fleet/hosts/:id/software` API endpoint. Docs: https://github.com/fleetdm/fleet/pull/23743 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [X] Added/updated tests - [X] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [X] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ X Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [X] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [X] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [X] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [X] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)). --------- Co-authored-by: Tim Lee <timlee@fleetdm.com> Co-authored-by: Ian Littman <iansltx@gmail.com>
2024-11-15 17:17:04 +00:00
HostID: hostID,
SoftwareID: s.ID,
InstalledPath: installedPath,
TeamIdentifier: teamIdentifier,
})
}
return
}
func deleteHostSoftwareInstalledPaths(
ctx context.Context,
tx sqlx.ExtContext,
toDelete []uint,
) error {
if len(toDelete) == 0 {
return nil
}
stmt := `DELETE FROM host_software_installed_paths WHERE id IN (?)`
stmt, args, err := sqlx.In(stmt, toDelete)
if err != nil {
return ctxerr.Wrap(ctx, err, "building delete statement for delete host_software_installed_paths")
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "executing delete statement for delete host_software_installed_paths")
}
return nil
}
func insertHostSoftwareInstalledPaths(
ctx context.Context,
tx sqlx.ExtContext,
toInsert []fleet.HostSoftwareInstalledPath,
) error {
if len(toInsert) == 0 {
return nil
}
Add `team_identifier` to macOS software (#23766) Changes to add `team_identifier` signing information to macOS applications on the `/api/latest/fleet/hosts/:id/software` API endpoint. Docs: https://github.com/fleetdm/fleet/pull/23743 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [X] Added/updated tests - [X] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [X] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ X Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [X] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [X] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [X] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [X] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)). --------- Co-authored-by: Tim Lee <timlee@fleetdm.com> Co-authored-by: Ian Littman <iansltx@gmail.com>
2024-11-15 17:17:04 +00:00
stmt := "INSERT INTO host_software_installed_paths (host_id, software_id, installed_path, team_identifier) VALUES %s"
batchSize := 500
for i := 0; i < len(toInsert); i += batchSize {
end := i + batchSize
if end > len(toInsert) {
end = len(toInsert)
}
batch := toInsert[i:end]
var args []interface{}
for _, v := range batch {
Add `team_identifier` to macOS software (#23766) Changes to add `team_identifier` signing information to macOS applications on the `/api/latest/fleet/hosts/:id/software` API endpoint. Docs: https://github.com/fleetdm/fleet/pull/23743 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [X] Added/updated tests - [X] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [X] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ X Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [X] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [X] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [X] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [X] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)). --------- Co-authored-by: Tim Lee <timlee@fleetdm.com> Co-authored-by: Ian Littman <iansltx@gmail.com>
2024-11-15 17:17:04 +00:00
args = append(args, v.HostID, v.SoftwareID, v.InstalledPath, v.TeamIdentifier)
}
Add `team_identifier` to macOS software (#23766) Changes to add `team_identifier` signing information to macOS applications on the `/api/latest/fleet/hosts/:id/software` API endpoint. Docs: https://github.com/fleetdm/fleet/pull/23743 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [X] Added/updated tests - [X] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [X] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ X Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [X] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [X] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [X] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [X] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)). --------- Co-authored-by: Tim Lee <timlee@fleetdm.com> Co-authored-by: Ian Littman <iansltx@gmail.com>
2024-11-15 17:17:04 +00:00
placeHolders := strings.TrimSuffix(strings.Repeat("(?, ?, ?, ?), ", len(batch)), ", ")
stmt := fmt.Sprintf(stmt, placeHolders)
_, err := tx.ExecContext(ctx, stmt, args...)
if err != nil {
return ctxerr.Wrap(ctx, err, "inserting rows into host_software_installed_paths")
}
}
return nil
}
func nothingChanged(current, incoming []fleet.Software, minLastOpenedAtDiff time.Duration) (
map[string]fleet.Software, map[string]fleet.Software, bool,
) {
// Process incoming software to ensure there are no duplicates, since the same software can be installed at multiple paths.
incomingMap := make(map[string]fleet.Software, len(current)) // setting len(current) as the length since that should be the common case
for _, s := range incoming {
uniqueStr := s.ToUniqueStr()
if duplicate, ok := incomingMap[uniqueStr]; ok {
// Check the last opened at timestamp and keep the latest.
if s.LastOpenedAt == nil ||
(duplicate.LastOpenedAt != nil && !s.LastOpenedAt.After(*duplicate.LastOpenedAt)) {
continue // keep the duplicate
}
}
incomingMap[uniqueStr] = s
}
currentMap := softwareSliceToMap(current)
if len(currentMap) != len(incomingMap) {
return currentMap, incomingMap, false
}
for _, s := range incomingMap {
cur, ok := currentMap[s.ToUniqueStr()]
if !ok {
return currentMap, incomingMap, false
}
// if the incoming software has a last opened at timestamp and it differs
// significantly from the current timestamp (or there is no current
// timestamp), then consider that something changed.
if s.LastOpenedAt != nil {
if cur.LastOpenedAt == nil {
return currentMap, incomingMap, false
}
oldLast := *cur.LastOpenedAt
newLast := *s.LastOpenedAt
if newLast.Sub(oldLast) >= minLastOpenedAtDiff {
return currentMap, incomingMap, false
}
}
}
return currentMap, incomingMap, true
}
func (ds *Datastore) ListSoftwareByHostIDShort(ctx context.Context, hostID uint) ([]fleet.Software, error) {
return listSoftwareByHostIDShort(ctx, ds.reader(ctx), hostID)
}
func listSoftwareByHostIDShort(
ctx context.Context,
db sqlx.QueryerContext,
hostID uint,
) ([]fleet.Software, error) {
q := `
SELECT
s.id,
s.name,
s.version,
s.source,
s.browser,
s.bundle_identifier,
s.release,
s.vendor,
s.arch,
s.extension_id,
hs.last_opened_at
FROM
software s
JOIN host_software hs ON hs.software_id = s.id
WHERE
hs.host_id = ?
`
var softwares []fleet.Software
err := sqlx.SelectContext(ctx, db, &softwares, q, hostID)
if err != nil {
return nil, err
}
return softwares, nil
}
// applyChangesForNewSoftwareDB returns the current host software and the applied mutations: what
// was inserted and what was deleted
func (ds *Datastore) applyChangesForNewSoftwareDB(
ctx context.Context,
hostID uint,
software []fleet.Software,
) (*fleet.UpdateHostSoftwareDBResult, error) {
r := &fleet.UpdateHostSoftwareDBResult{}
// This code executes once an hour for each host, so we should optimize for MySQL master (writer) DB performance.
// We use a slave (reader) DB to avoid accessing the master. If nothing has changed, we avoid all access to the master.
// It is possible that the software list is out of sync between the slave and the master. This is unlikely because
// it is updated once an hour under normal circumstances. If this does occur, the software list will be updated
// once again in an hour.
currentSoftware, err := listSoftwareByHostIDShort(ctx, ds.reader(ctx), hostID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "loading current software for host")
}
r.WasCurrInstalled = currentSoftware
current, incoming, notChanged := nothingChanged(currentSoftware, software, ds.minLastOpenedAtDiff)
if notChanged {
return r, nil
}
existingSoftware, incomingByChecksum, existingTitlesForNewSoftware, err := ds.getExistingSoftware(ctx, current, incoming)
if err != nil {
return r, err
}
err = ds.withRetryTxx(
ctx, func(tx sqlx.ExtContext) error {
deleted, err := deleteUninstalledHostSoftwareDB(ctx, tx, hostID, current, incoming)
if err != nil {
return err
}
r.Deleted = deleted
// Copy incomingByChecksum because ds.insertNewInstalledHostSoftwareDB is modifying it and we
// are runnning inside ds.withRetryTxx.
incomingByChecksumCopy := make(map[string]fleet.Software, len(incomingByChecksum))
for key, value := range incomingByChecksum {
incomingByChecksumCopy[key] = value
}
inserted, err := ds.insertNewInstalledHostSoftwareDB(
ctx, tx, hostID, existingSoftware, incomingByChecksumCopy, existingTitlesForNewSoftware,
)
if err != nil {
return err
}
r.Inserted = inserted
if err = checkForDeletedInstalledSoftware(ctx, tx, deleted, inserted, hostID); err != nil {
return err
}
if err = updateModifiedHostSoftwareDB(ctx, tx, hostID, current, incoming, ds.minLastOpenedAtDiff); err != nil {
return err
}
if err = updateSoftwareUpdatedAt(ctx, tx, hostID); err != nil {
return err
}
return nil
},
)
if err != nil {
return nil, err
}
return r, err
}
func checkForDeletedInstalledSoftware(ctx context.Context, tx sqlx.ExtContext, deleted []fleet.Software, inserted []fleet.Software,
hostID uint,
) error {
// Between deleted and inserted software, check which software titles were deleted.
// If software titles were deleted, get the software titles of the installed software.
// See if deleted titles match installed software titles.
// If so, mark the installed software as removed.
var deletedTitles map[string]struct{}
if len(deleted) > 0 {
deletedTitles = make(map[string]struct{}, len(deleted))
for _, d := range deleted {
// We don't support installing browser plugins as of 2024/08/22
if d.Browser == "" {
deletedTitles[UniqueSoftwareTitleStr(BundleIdentifierOrName(d.BundleIdentifier, d.Name), d.Source)] = struct{}{}
}
}
for _, i := range inserted {
// We don't support installing browser plugins as of 2024/08/22
if i.Browser == "" {
key := UniqueSoftwareTitleStr(i.Name, i.Source, i.BundleIdentifier)
delete(deletedTitles, key)
}
}
}
if len(deletedTitles) > 0 {
installedTitles, err := getInstalledByFleetSoftwareTitles(ctx, tx, hostID)
if err != nil {
return err
}
type deletedValue struct {
vpp bool
}
deletedTitleIDs := make(map[uint]deletedValue, 0)
for _, title := range installedTitles {
bundleIdentifier := ""
if title.BundleIdentifier != nil {
bundleIdentifier = *title.BundleIdentifier
}
key := UniqueSoftwareTitleStr(BundleIdentifierOrName(bundleIdentifier, title.Name), title.Source)
if _, ok := deletedTitles[key]; ok {
deletedTitleIDs[title.ID] = deletedValue{vpp: title.VPPAppsCount > 0}
}
}
if len(deletedTitleIDs) > 0 {
IDs := make([]uint, 0, len(deletedTitleIDs))
vppIDs := make([]uint, 0, len(deletedTitleIDs))
for id, value := range deletedTitleIDs {
if value.vpp {
vppIDs = append(vppIDs, id)
} else {
IDs = append(IDs, id)
}
}
if len(IDs) > 0 {
if err = markHostSoftwareInstallsRemoved(ctx, tx, hostID, IDs); err != nil {
return err
}
}
if len(vppIDs) > 0 {
if err = markHostVPPSoftwareInstallsRemoved(ctx, tx, hostID, vppIDs); err != nil {
return err
}
}
}
}
return nil
}
func (ds *Datastore) getExistingSoftware(
ctx context.Context, current map[string]fleet.Software, incoming map[string]fleet.Software,
) (
currentSoftware []softwareIDChecksum, incomingChecksumToSoftware map[string]fleet.Software,
incomingChecksumToTitle map[string]fleet.SoftwareTitle, err error,
) {
// Compute checksums for all incoming software, which we will use for faster retrieval, since checksum is a unique index
incomingChecksumToSoftware = make(map[string]fleet.Software, len(current))
newSoftware := make(map[string]struct{})
for uniqueName, s := range incoming {
if _, ok := current[uniqueName]; !ok {
checksum, err := computeRawChecksum(s)
if err != nil {
return nil, nil, nil, err
}
incomingChecksumToSoftware[string(checksum)] = s
newSoftware[string(checksum)] = struct{}{}
}
}
if len(incomingChecksumToSoftware) > 0 {
keys := make([]string, 0, len(incomingChecksumToSoftware))
for checksum := range incomingChecksumToSoftware {
keys = append(keys, checksum)
}
// We use the replica DB for retrieval to minimize the traffic to the master DB.
// It is OK if the software is not found in the replica DB, because we will then attempt to insert it in the master DB.
currentSoftware, err = getSoftwareIDsByChecksums(ctx, ds.reader(ctx), keys)
if err != nil {
return nil, nil, nil, err
}
for _, s := range currentSoftware {
_, ok := incomingChecksumToSoftware[s.Checksum]
if !ok {
// This should never happen. If it does, we have a bug.
return nil, nil, nil, ctxerr.New(
ctx, fmt.Sprintf("current software: software not found for checksum %s", hex.EncodeToString([]byte(s.Checksum))),
)
}
delete(newSoftware, s.Checksum)
}
}
if len(newSoftware) == 0 {
return currentSoftware, incomingChecksumToSoftware, incomingChecksumToTitle, nil
}
// There's new software, so we try to get the titles already stored in `software_titles` for them.
incomingChecksumToTitle, err = ds.getIncomingSoftwareChecksumsToExistingTitles(ctx, newSoftware, incomingChecksumToSoftware)
if err != nil {
return nil, nil, nil, ctxerr.Wrap(ctx, err, "get incoming software checksums to existing titles")
}
return currentSoftware, incomingChecksumToSoftware, incomingChecksumToTitle, nil
}
// getIncomingSoftwareChecksumsToExistingTitles loads the existing titles for the new incoming software.
// It returns a map of software checksums to existing software titles.
//
// To make best use of separate indexes, it runs two queries to get the existing titles from the DB:
// - One query for software with bundle_identifier.
// - One query for software without bundle_identifier.
func (ds *Datastore) getIncomingSoftwareChecksumsToExistingTitles(
ctx context.Context,
newSoftwareChecksums map[string]struct{},
incomingChecksumToSoftware map[string]fleet.Software,
) (map[string]fleet.SoftwareTitle, error) {
var (
incomingChecksumToTitle = make(map[string]fleet.SoftwareTitle, len(newSoftwareChecksums))
argsWithoutBundleIdentifier []interface{}
argsWithBundleIdentifier []interface{}
uniqueTitleStrToChecksum = make(map[string]string)
)
for checksum := range newSoftwareChecksums {
sw := incomingChecksumToSoftware[checksum]
if sw.BundleIdentifier != "" {
argsWithBundleIdentifier = append(argsWithBundleIdentifier, sw.BundleIdentifier)
} else {
argsWithoutBundleIdentifier = append(argsWithoutBundleIdentifier, sw.Name, sw.Source, sw.Browser)
}
// Map software title identifier to software checksums so that we can map checksums to actual titles later.
uniqueTitleStrToChecksum[UniqueSoftwareTitleStr(
BundleIdentifierOrName(sw.BundleIdentifier, sw.Name), sw.Source, sw.Browser,
)] = checksum
}
// Get titles for software without bundle_identifier.
if len(argsWithoutBundleIdentifier) > 0 {
whereClause := strings.TrimSuffix(
strings.Repeat(`
(
(name = ? AND source = ? AND browser = ?)
) OR`, len(argsWithoutBundleIdentifier)/3), " OR",
)
stmt := fmt.Sprintf(
"SELECT id, name, source, browser FROM software_titles WHERE %s",
whereClause,
)
var existingSoftwareTitlesForNewSoftwareWithoutBundleIdentifier []fleet.SoftwareTitle
if err := sqlx.SelectContext(ctx,
ds.reader(ctx),
&existingSoftwareTitlesForNewSoftwareWithoutBundleIdentifier,
stmt,
argsWithoutBundleIdentifier...,
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get existing titles without bundle identifier")
}
for _, title := range existingSoftwareTitlesForNewSoftwareWithoutBundleIdentifier {
checksum, ok := uniqueTitleStrToChecksum[UniqueSoftwareTitleStr(title.Name, title.Source, title.Browser)]
if ok {
incomingChecksumToTitle[checksum] = title
}
}
}
// Get titles for software with bundle_identifier
if len(argsWithBundleIdentifier) > 0 {
incomingChecksumToTitle = make(map[string]fleet.SoftwareTitle, len(newSoftwareChecksums))
stmtBundleIdentifier := `SELECT id, name, source, browser, bundle_identifier FROM software_titles WHERE bundle_identifier IN (?)`
stmtBundleIdentifier, argsWithBundleIdentifier, err := sqlx.In(stmtBundleIdentifier, argsWithBundleIdentifier)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "build query to existing titles with bundle_identifier")
}
var existingSoftwareTitlesForNewSoftwareWithBundleIdentifier []fleet.SoftwareTitle
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &existingSoftwareTitlesForNewSoftwareWithBundleIdentifier, stmtBundleIdentifier, argsWithBundleIdentifier...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get existing titles with bundle_identifier")
}
// Map software titles to software checksums.
for _, title := range existingSoftwareTitlesForNewSoftwareWithBundleIdentifier {
checksum, ok := uniqueTitleStrToChecksum[UniqueSoftwareTitleStr(*title.BundleIdentifier, title.Source, title.Browser)]
if ok {
incomingChecksumToTitle[checksum] = title
}
}
}
return incomingChecksumToTitle, nil
}
// BundleIdentifierOrName returns the bundle identifier if it is not empty, otherwise name
func BundleIdentifierOrName(bundleIdentifier, name string) string {
if bundleIdentifier != "" {
return bundleIdentifier
}
return name
}
// UniqueSoftwareTitleStr creates a unique string representation of the software title
func UniqueSoftwareTitleStr(values ...string) string {
return strings.Join(values, fleet.SoftwareFieldSeparator)
}
// delete host_software that is in current map, but not in incoming map.
// returns the deleted software on the host
func deleteUninstalledHostSoftwareDB(
ctx context.Context,
tx sqlx.ExecerContext,
hostID uint,
currentMap map[string]fleet.Software,
incomingMap map[string]fleet.Software,
) ([]fleet.Software, error) {
var deletesHostSoftwareIDs []uint
var deletedSoftware []fleet.Software
for currentKey, curSw := range currentMap {
if _, ok := incomingMap[currentKey]; !ok {
deletedSoftware = append(deletedSoftware, curSw)
deletesHostSoftwareIDs = append(deletesHostSoftwareIDs, curSw.ID)
}
}
if len(deletesHostSoftwareIDs) == 0 {
return nil, nil
}
stmt := `DELETE FROM host_software WHERE host_id = ? AND software_id IN (?);`
stmt, args, err := sqlx.In(stmt, hostID, deletesHostSoftwareIDs)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "build delete host software query")
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "delete host software")
}
return deletedSoftware, nil
}
// computeRawChecksum computes the checksum for a software entry
// The calculation must match the one in softwareChecksumComputedColumn
func computeRawChecksum(sw fleet.Software) ([]byte, error) {
h := md5.New() //nolint:gosec // This hash is used as a DB optimization for software row lookup, not security
cols := []string{sw.Name, sw.Version, sw.Source, sw.BundleIdentifier, sw.Release, sw.Arch, sw.Vendor, sw.Browser, sw.ExtensionID}
_, err := fmt.Fprint(h, strings.Join(cols, "\x00"))
if err != nil {
return nil, err
}
return h.Sum(nil), nil
}
// insertNewInstalledHostSoftwareDB inserts host_software that is in softwareChecksums map,
// but not in existingSoftware. It also inserts any new software titles that are needed.
//
// It returns the inserted software on the host.
func (ds *Datastore) insertNewInstalledHostSoftwareDB(
ctx context.Context,
tx sqlx.ExtContext,
hostID uint,
existingSoftware []softwareIDChecksum,
softwareChecksums map[string]fleet.Software,
existingTitlesForNewSoftware map[string]fleet.SoftwareTitle,
) ([]fleet.Software, error) {
var insertsHostSoftware []interface{}
var insertedSoftware []fleet.Software
// First, we remove incoming software that already exists in the software table.
if len(softwareChecksums) > 0 {
for _, s := range existingSoftware {
software, ok := softwareChecksums[s.Checksum]
if !ok {
return nil, ctxerr.New(ctx, fmt.Sprintf("existing software: software not found for checksum %q", hex.EncodeToString([]byte(s.Checksum))))
}
software.ID = s.ID
insertsHostSoftware = append(insertsHostSoftware, hostID, software.ID, software.LastOpenedAt)
insertedSoftware = append(insertedSoftware, software)
delete(softwareChecksums, s.Checksum)
}
}
// For software items that don't already exist in the software table, we insert them.
if len(softwareChecksums) > 0 {
keys := make([]string, 0, len(softwareChecksums))
for checksum := range softwareChecksums {
keys = append(keys, checksum)
}
for i := 0; i < len(keys); i += softwareInsertBatchSize {
start := i
end := i + softwareInsertBatchSize
if end > len(keys) {
end = len(keys)
}
totalToProcess := end - start
// Insert into software
const numberOfArgsPerSoftware = 11 // number of ? in each VALUES clause
values := strings.TrimSuffix(
strings.Repeat("(?,?,?,?,?,?,?,?,?,?,?),", totalToProcess), ",",
)
// INSERT IGNORE is used to avoid duplicate key errors, which may occur since our previous read came from the replica.
stmt := fmt.Sprintf(
Add `team_identifier` to macOS software (#23766) Changes to add `team_identifier` signing information to macOS applications on the `/api/latest/fleet/hosts/:id/software` API endpoint. Docs: https://github.com/fleetdm/fleet/pull/23743 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [X] Added/updated tests - [X] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [X] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ X Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [X] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [X] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [X] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [X] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)). --------- Co-authored-by: Tim Lee <timlee@fleetdm.com> Co-authored-by: Ian Littman <iansltx@gmail.com>
2024-11-15 17:17:04 +00:00
`INSERT IGNORE INTO software (
name,
version,
source,
`+"`release`"+`,
vendor,
arch,
bundle_identifier,
extension_id,
browser,
title_id,
checksum
) VALUES %s`,
values,
)
args := make([]interface{}, 0, totalToProcess*numberOfArgsPerSoftware)
newTitlesNeeded := make(map[string]fleet.SoftwareTitle)
for j := start; j < end; j++ {
checksum := keys[j]
sw := softwareChecksums[checksum]
var titleID *uint
title, ok := existingTitlesForNewSoftware[checksum]
if ok {
titleID = &title.ID
} else if _, ok := newTitlesNeeded[checksum]; !ok {
st := fleet.SoftwareTitle{
Name: sw.Name,
Source: sw.Source,
Browser: sw.Browser,
}
if sw.BundleIdentifier != "" {
st.BundleIdentifier = ptr.String(sw.BundleIdentifier)
}
newTitlesNeeded[checksum] = st
}
args = append(
args, sw.Name, sw.Version, sw.Source, sw.Release, sw.Vendor, sw.Arch, sw.BundleIdentifier, sw.ExtensionID, sw.Browser,
titleID, checksum,
)
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "insert software")
}
// Insert into software_titles
totalTitlesToProcess := len(newTitlesNeeded)
if totalTitlesToProcess > 0 {
const numberOfArgsPerSoftwareTitles = 4 // number of ? in each VALUES clause
titlesValues := strings.TrimSuffix(strings.Repeat("(?,?,?,?),", totalTitlesToProcess), ",")
// INSERT IGNORE is used to avoid duplicate key errors, which may occur since our previous read came from the replica.
titlesStmt := fmt.Sprintf("INSERT IGNORE INTO software_titles (name, source, browser, bundle_identifier) VALUES %s", titlesValues)
titlesArgs := make([]interface{}, 0, totalTitlesToProcess*numberOfArgsPerSoftwareTitles)
titleChecksums := make([]string, 0, totalTitlesToProcess)
for checksum, title := range newTitlesNeeded {
titlesArgs = append(titlesArgs, title.Name, title.Source, title.Browser, title.BundleIdentifier)
titleChecksums = append(titleChecksums, checksum)
}
if _, err := tx.ExecContext(ctx, titlesStmt, titlesArgs...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "insert software_titles")
}
updateSoftwareWithoutIdentifierStmt := `
UPDATE software s
JOIN software_titles st
ON COALESCE(s.bundle_identifier, '') = '' AND s.name = st.name AND s.source = st.source AND s.browser = st.browser
SET s.title_id = st.id
WHERE (s.title_id IS NULL OR s.title_id != st.id)
AND COALESCE(s.bundle_identifier, '') = ''
AND s.checksum IN (?)
`
updateSoftwareWithoutIdentifierStmt, updateArgs, err := sqlx.In(updateSoftwareWithoutIdentifierStmt, titleChecksums)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "build update software title_id without identifier")
}
if _, err = tx.ExecContext(ctx, updateSoftwareWithoutIdentifierStmt, updateArgs...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update software title_id without identifier")
}
// update new title ids for new software table entries
updateSoftwareStmt := `
UPDATE software s
JOIN software_titles st
ON s.bundle_identifier = st.bundle_identifier AND
IF(s.source IN ('apps', 'ios_apps', 'ipados_apps'), s.source = st.source, 1)
SET s.title_id = st.id
WHERE s.title_id IS NULL
OR s.title_id != st.id
AND s.checksum IN (?)`
updateSoftwareStmt, updateArgs, err = sqlx.In(updateSoftwareStmt, titleChecksums)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "build update software title_id with identifier")
}
if _, err = tx.ExecContext(ctx, updateSoftwareStmt, updateArgs...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update software title_id with identifier")
}
}
}
// Here, we use the transaction (tx) for retrieval because we must retrieve the software IDs that we just inserted.
updatedExistingSoftware, err := getSoftwareIDsByChecksums(ctx, tx, keys)
if err != nil {
return nil, err
}
for _, s := range updatedExistingSoftware {
software, ok := softwareChecksums[s.Checksum]
if !ok {
return nil, ctxerr.New(ctx, fmt.Sprintf("updated existing software: software not found for checksum %s", hex.EncodeToString([]byte(s.Checksum))))
}
software.ID = s.ID
insertsHostSoftware = append(insertsHostSoftware, hostID, software.ID, software.LastOpenedAt)
insertedSoftware = append(insertedSoftware, software)
delete(softwareChecksums, s.Checksum)
}
}
if len(softwareChecksums) > 0 {
// We log and continue. We should almost never see this error. If we see it regularly, we need to investigate.
level.Error(ds.logger).Log(
"msg", "could not find or create software items. This error may be caused by master and replica DBs out of sync.", "host_id",
hostID, "number", len(softwareChecksums),
)
for checksum, software := range softwareChecksums {
uuidString := ""
checksumAsUUID, err := uuid.FromBytes([]byte(checksum))
if err == nil {
// We ignore error
uuidString = checksumAsUUID.String()
}
level.Debug(ds.logger).Log(
"msg", "software item not found or created", "name", software.Name, "version", software.Version, "source", software.Source,
"bundle_identifier", software.BundleIdentifier, "checksum", uuidString,
)
}
}
if len(insertsHostSoftware) > 0 {
values := strings.TrimSuffix(strings.Repeat("(?,?,?),", len(insertsHostSoftware)/3), ",")
sql := fmt.Sprintf(`INSERT IGNORE INTO host_software (host_id, software_id, last_opened_at) VALUES %s`, values)
if _, err := tx.ExecContext(ctx, sql, insertsHostSoftware...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "insert host software")
}
}
return insertedSoftware, nil
}
func getSoftwareIDsByChecksums(ctx context.Context, tx sqlx.QueryerContext, checksums []string) ([]softwareIDChecksum, error) {
// get existing software ids for checksums
stmt, args, err := sqlx.In("SELECT id, checksum FROM software WHERE checksum IN (?)", checksums)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "build select software query")
}
var existingSoftware []softwareIDChecksum
if err = sqlx.SelectContext(ctx, tx, &existingSoftware, stmt, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get existing software")
}
return existingSoftware, nil
}
// update host_software when incoming software has a significantly more recent
// last opened timestamp (or didn't have on in currentMap). Note that it only
// processes software that is in both current and incoming maps, as the case
// where it is only in incoming is already handled by
// insertNewInstalledHostSoftwareDB.
func updateModifiedHostSoftwareDB(
ctx context.Context,
tx sqlx.ExtContext,
hostID uint,
currentMap map[string]fleet.Software,
incomingMap map[string]fleet.Software,
minLastOpenedAtDiff time.Duration,
) error {
var keysToUpdate []string
for key, newSw := range incomingMap {
curSw, ok := currentMap[key]
if !ok || newSw.LastOpenedAt == nil {
// software must also exist in current map, and new software must have a
// last opened at timestamp (otherwise we don't overwrite the old one)
continue
}
if curSw.LastOpenedAt == nil || newSw.LastOpenedAt.Sub(*curSw.LastOpenedAt) >= minLastOpenedAtDiff {
keysToUpdate = append(keysToUpdate, key)
}
}
sort.Strings(keysToUpdate)
for i := 0; i < len(keysToUpdate); i += softwareInsertBatchSize {
start := i
end := i + softwareInsertBatchSize
if end > len(keysToUpdate) {
end = len(keysToUpdate)
}
totalToProcess := end - start
const numberOfArgsPerSoftware = 3 // number of ? in each UPDATE
// Using UNION ALL (instead of UNION) because it is faster since it does not check for duplicates.
values := strings.TrimSuffix(
strings.Repeat(" SELECT ? as host_id, ? as software_id, ? as last_opened_at UNION ALL", totalToProcess), "UNION ALL",
)
stmt := fmt.Sprintf(
`UPDATE host_software hs JOIN (%s) a ON hs.host_id = a.host_id AND hs.software_id = a.software_id SET hs.last_opened_at = a.last_opened_at`,
values,
)
args := make([]interface{}, 0, totalToProcess*numberOfArgsPerSoftware)
for j := start; j < end; j++ {
key := keysToUpdate[j]
curSw, newSw := currentMap[key], incomingMap[key]
args = append(args, hostID, curSw.ID, newSw.LastOpenedAt)
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "update host software")
}
}
return nil
}
func updateSoftwareUpdatedAt(
ctx context.Context,
tx sqlx.ExtContext,
hostID uint,
) error {
const stmt = `INSERT INTO host_updates(host_id, software_updated_at) VALUES (?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE software_updated_at=VALUES(software_updated_at)`
if _, err := tx.ExecContext(ctx, stmt, hostID); err != nil {
return ctxerr.Wrap(ctx, err, "update host updates")
}
return nil
}
var dialect = goqu.Dialect("mysql")
// listSoftwareDB returns software installed on hosts. Use opts for pagination, filtering, and controlling
// fields populated in the returned software.
// Used on software/versions not software/titles
func listSoftwareDB(
ctx context.Context,
q sqlx.QueryerContext,
opts fleet.SoftwareListOptions,
) ([]fleet.Software, error) {
sql, args, err := selectSoftwareSQL(opts)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "sql build")
}
var results []softwareCVE
if err := sqlx.SelectContext(ctx, q, &results, sql, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "select host software")
}
var softwares []fleet.Software
ids := make(map[uint]int) // map of ids to index into softwares
for _, result := range results {
result := result // create a copy because we need to take the address to fields below
idx, ok := ids[result.ID]
if !ok {
idx = len(softwares)
softwares = append(softwares, result.Software)
ids[result.ID] = idx
}
// handle null cve from left join
if result.CVE != nil {
cveID := *result.CVE
cve := fleet.CVE{
CVE: cveID,
DetailsLink: fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", cveID),
CreatedAt: *result.CreatedAt,
}
if opts.IncludeCVEScores && !opts.WithoutVulnerabilityDetails {
cve.CVSSScore = &result.CVSSScore
cve.EPSSProbability = &result.EPSSProbability
cve.CISAKnownExploit = &result.CISAKnownExploit
cve.CVEPublished = &result.CVEPublished
cve.Description = &result.Description
cve.ResolvedInVersion = &result.ResolvedInVersion
}
softwares[idx].Vulnerabilities = append(softwares[idx].Vulnerabilities, cve)
}
}
return softwares, nil
}
// softwareCVE is used for left joins with cve
//
//
type softwareCVE struct {
fleet.Software
// CVE is the CVE identifier pulled from the NVD json (e.g. CVE-2019-1234)
CVE *string `db:"cve"`
// CVSSScore is the CVSS score pulled from the NVD json (premium only)
CVSSScore *float64 `db:"cvss_score"`
// EPSSProbability is the EPSS probability pulled from FIRST (premium only)
EPSSProbability *float64 `db:"epss_probability"`
// CISAKnownExploit is the CISAKnownExploit pulled from CISA (premium only)
CISAKnownExploit *bool `db:"cisa_known_exploit"`
// CVEPublished is the CVE published date pulled from the NVD json (premium only)
CVEPublished *time.Time `db:"cve_published"`
// Description is the CVE description field pulled from the NVD json
Description *string `db:"description"`
// ResolvedInVersion is the version of software where the CVE is no longer applicable.
// This is pulled from the versionEndExcluding field in the NVD json
ResolvedInVersion *string `db:"resolved_in_version"`
// CreatedAt is the time the software vulnerability was created
CreatedAt *time.Time `db:"created_at"`
}
func selectSoftwareSQL(opts fleet.SoftwareListOptions) (string, []interface{}, error) {
ds := dialect.
From(goqu.I("software").As("s")).
Select(
"s.id",
"s.name",
"s.version",
"s.source",
"s.bundle_identifier",
"s.extension_id",
"s.browser",
"s.release",
"s.vendor",
"s.arch",
goqu.I("scp.cpe").As("generated_cpe"),
).
// Include this in the sub-query in case we want to sort by 'generated_cpe'
LeftJoin(
goqu.I("software_cpe").As("scp"),
goqu.On(
goqu.I("s.id").Eq(goqu.I("scp.software_id")),
),
)
if opts.HostID != nil {
ds = ds.
Join(
goqu.I("host_software").As("hs"),
goqu.On(
goqu.I("hs.software_id").Eq(goqu.I("s.id")),
goqu.I("hs.host_id").Eq(opts.HostID),
),
).
SelectAppend("hs.last_opened_at")
if opts.TeamID != nil {
ds = ds.
Join(
goqu.I("hosts").As("h"),
goqu.On(
goqu.I("hs.host_id").Eq(goqu.I("h.id")),
goqu.I("h.team_id").Eq(opts.TeamID),
),
)
}
} else {
// When loading software from all hosts, filter out software that is not associated with any
// hosts.
ds = ds.
Join(
goqu.I("software_host_counts").As("shc"),
goqu.On(
goqu.I("s.id").Eq(goqu.I("shc.software_id")),
goqu.I("shc.hosts_count").Gt(0),
),
).
GroupByAppend(
"shc.hosts_count",
"shc.updated_at",
"shc.global_stats",
"shc.team_id",
)
if opts.TeamID == nil { //nolint:gocritic // ignore ifElseChain
ds = ds.Where(
goqu.And(
goqu.I("shc.team_id").Eq(0),
goqu.I("shc.global_stats").Eq(1),
),
)
} else if *opts.TeamID == 0 {
ds = ds.Where(
goqu.And(
goqu.I("shc.team_id").Eq(0),
goqu.I("shc.global_stats").Eq(0),
),
)
} else {
ds = ds.Where(
goqu.And(
goqu.I("shc.team_id").Eq(*opts.TeamID),
goqu.I("shc.global_stats").Eq(0),
),
)
}
}
if opts.VulnerableOnly {
ds = ds.
Join(
goqu.I("software_cve").As("scv"),
goqu.On(goqu.I("s.id").Eq(goqu.I("scv.software_id"))),
)
} else {
ds = ds.
LeftJoin(
goqu.I("software_cve").As("scv"),
goqu.On(goqu.I("s.id").Eq(goqu.I("scv.software_id"))),
)
}
if opts.IncludeCVEScores {
baseJoinConditions := goqu.Ex{
"c.cve": goqu.I("scv.cve"),
}
if opts.KnownExploit || opts.MinimumCVSS > 0 || opts.MaximumCVSS > 0 {
if opts.KnownExploit {
baseJoinConditions["c.cisa_known_exploit"] = true
}
if opts.MinimumCVSS > 0 {
baseJoinConditions["c.cvss_score"] = goqu.Op{"gte": opts.MinimumCVSS}
}
if opts.MaximumCVSS > 0 {
baseJoinConditions["c.cvss_score"] = goqu.Op{"lte": opts.MaximumCVSS}
}
ds = ds.InnerJoin(
2022-06-01 16:06:57 +00:00
goqu.I("cve_meta").As("c"),
goqu.On(baseJoinConditions),
)
} else {
ds = ds.
LeftJoin(
goqu.I("cve_meta").As("c"),
goqu.On(baseJoinConditions),
)
}
ds = ds.SelectAppend(
goqu.MAX("c.cvss_score").As("cvss_score"), // for ordering
goqu.MAX("c.epss_probability").As("epss_probability"), // for ordering
goqu.MAX("c.cisa_known_exploit").As("cisa_known_exploit"), // for ordering
goqu.MAX("c.published").As("cve_published"), // for ordering
goqu.MAX("c.description").As("description"), // for ordering
goqu.MAX("scv.resolved_in_version").As("resolved_in_version"), // for ordering
)
}
if match := opts.ListOptions.MatchQuery; match != "" {
match = likePattern(match)
ds = ds.Where(
goqu.Or(
goqu.I("s.name").ILike(match),
goqu.I("s.version").ILike(match),
goqu.I("scv.cve").ILike(match),
),
)
}
if opts.WithHostCounts {
ds = ds.
SelectAppend(
goqu.I("shc.hosts_count"),
goqu.I("shc.updated_at").As("counts_updated_at"),
)
}
ds = ds.GroupBy(
"s.id",
"s.name",
"s.version",
"s.source",
"s.bundle_identifier",
"s.extension_id",
"s.browser",
"s.release",
"s.vendor",
"s.arch",
"generated_cpe",
)
// Pagination is a bit more complex here due to the join with software_cve table and aggregated columns from cve_meta table.
// Apply order by again after joining on sub query
ds = appendListOptionsToSelect(ds, opts.ListOptions)
2022-06-01 16:06:57 +00:00
// join on software_cve and cve_meta after apply pagination using the sub-query above
ds = dialect.From(ds.As("s")).
Select(
"s.id",
"s.name",
"s.version",
"s.source",
"s.bundle_identifier",
"s.extension_id",
"s.browser",
"s.release",
"s.vendor",
"s.arch",
goqu.COALESCE(goqu.I("s.generated_cpe"), "").As("generated_cpe"),
"scv.cve",
"scv.created_at",
).
LeftJoin(
goqu.I("software_cve").As("scv"),
goqu.On(goqu.I("scv.software_id").Eq(goqu.I("s.id"))),
).
LeftJoin(
2022-06-01 16:06:57 +00:00
goqu.I("cve_meta").As("c"),
goqu.On(goqu.I("c.cve").Eq(goqu.I("scv.cve"))),
)
// select optional columns
if opts.IncludeCVEScores {
ds = ds.SelectAppend(
"c.cvss_score",
"c.epss_probability",
"c.cisa_known_exploit",
"c.description",
goqu.I("c.published").As("cve_published"),
"scv.resolved_in_version",
)
}
if opts.HostID != nil {
ds = ds.SelectAppend(
goqu.I("s.last_opened_at"),
)
}
if opts.WithHostCounts {
ds = ds.SelectAppend(
goqu.I("s.hosts_count"),
goqu.I("s.counts_updated_at"),
)
}
ds = appendOrderByToSelect(ds, opts.ListOptions)
return ds.ToSQL()
}
func countSoftwareDB(
ctx context.Context,
q sqlx.QueryerContext,
opts fleet.SoftwareListOptions,
) (int, error) {
opts.ListOptions = fleet.ListOptions{
MatchQuery: opts.ListOptions.MatchQuery,
}
sql, args, err := selectSoftwareSQL(opts)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "sql build")
}
sql = `SELECT COUNT(DISTINCT s.id) FROM (` + sql + `) AS s`
var count int
if err := sqlx.GetContext(ctx, q, &count, sql, args...); err != nil {
return 0, ctxerr.Wrap(ctx, err, "count host software")
}
return count, nil
}
2022-06-01 16:06:57 +00:00
func (ds *Datastore) LoadHostSoftware(ctx context.Context, host *fleet.Host, includeCVEScores bool) error {
opts := fleet.SoftwareListOptions{
HostID: &host.ID,
IncludeCVEScores: includeCVEScores,
}
software, err := listSoftwareDB(ctx, ds.reader(ctx), opts)
if err != nil {
return err
}
installedPaths, err := ds.getHostSoftwareInstalledPaths(
ctx,
host.ID,
)
if err != nil {
return err
}
Add `team_identifier` to macOS software (#23766) Changes to add `team_identifier` signing information to macOS applications on the `/api/latest/fleet/hosts/:id/software` API endpoint. Docs: https://github.com/fleetdm/fleet/pull/23743 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [X] Added/updated tests - [X] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [X] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ X Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [X] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [X] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [X] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [X] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)). --------- Co-authored-by: Tim Lee <timlee@fleetdm.com> Co-authored-by: Ian Littman <iansltx@gmail.com>
2024-11-15 17:17:04 +00:00
installedPathsList := make(map[uint][]string)
pathSignatureInformation := make(map[uint][]fleet.PathSignatureInformation)
for _, ip := range installedPaths {
Add `team_identifier` to macOS software (#23766) Changes to add `team_identifier` signing information to macOS applications on the `/api/latest/fleet/hosts/:id/software` API endpoint. Docs: https://github.com/fleetdm/fleet/pull/23743 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [X] Added/updated tests - [X] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [X] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ X Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [X] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [X] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [X] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [X] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)). --------- Co-authored-by: Tim Lee <timlee@fleetdm.com> Co-authored-by: Ian Littman <iansltx@gmail.com>
2024-11-15 17:17:04 +00:00
installedPathsList[ip.SoftwareID] = append(installedPathsList[ip.SoftwareID], ip.InstalledPath)
pathSignatureInformation[ip.SoftwareID] = append(pathSignatureInformation[ip.SoftwareID], fleet.PathSignatureInformation{
InstalledPath: ip.InstalledPath,
TeamIdentifier: ip.TeamIdentifier,
})
}
host.Software = make([]fleet.HostSoftwareEntry, 0, len(software))
for _, s := range software {
host.Software = append(host.Software, fleet.HostSoftwareEntry{
Add `team_identifier` to macOS software (#23766) Changes to add `team_identifier` signing information to macOS applications on the `/api/latest/fleet/hosts/:id/software` API endpoint. Docs: https://github.com/fleetdm/fleet/pull/23743 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [X] Added/updated tests - [X] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [X] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ X Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [X] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [X] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [X] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [X] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)). --------- Co-authored-by: Tim Lee <timlee@fleetdm.com> Co-authored-by: Ian Littman <iansltx@gmail.com>
2024-11-15 17:17:04 +00:00
Software: s,
InstalledPaths: installedPathsList[s.ID],
PathSignatureInformation: pathSignatureInformation[s.ID],
})
}
return nil
}
type softwareIterator struct {
rows *sqlx.Rows
}
func (si *softwareIterator) Value() (*fleet.Software, error) {
dest := fleet.Software{}
err := si.rows.StructScan(&dest)
if err != nil {
return nil, err
}
return &dest, nil
}
func (si *softwareIterator) Err() error {
return si.rows.Err()
}
func (si *softwareIterator) Close() error {
return si.rows.Close()
}
func (si *softwareIterator) Next() bool {
return si.rows.Next()
}
// AllSoftwareIterator Returns an iterator for the 'software' table, filtering out
// software entries based on the 'query' param. The rows.Close call is done by the caller once
// iteration using the returned fleet.SoftwareIterator is done.
func (ds *Datastore) AllSoftwareIterator(
ctx context.Context,
query fleet.SoftwareIterQueryOptions,
) (fleet.SoftwareIterator, error) {
if !query.IsValid() {
return nil, fmt.Errorf("invalid query params %+v", query)
}
var err error
var args []interface{}
stmt := `SELECT
Add `team_identifier` to macOS software (#23766) Changes to add `team_identifier` signing information to macOS applications on the `/api/latest/fleet/hosts/:id/software` API endpoint. Docs: https://github.com/fleetdm/fleet/pull/23743 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [X] Added/updated tests - [X] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [X] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ X Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [X] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [X] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [X] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [X] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)). --------- Co-authored-by: Tim Lee <timlee@fleetdm.com> Co-authored-by: Ian Littman <iansltx@gmail.com>
2024-11-15 17:17:04 +00:00
s.id, s.name, s.version, s.source, s.bundle_identifier, s.release, s.arch, s.vendor, s.browser, s.extension_id, s.title_id,
COALESCE(sc.cpe, '') AS generated_cpe
FROM software s
LEFT JOIN software_cpe sc ON (s.id=sc.software_id)`
var conditionals []string
if len(query.ExcludedSources) != 0 {
conditionals = append(conditionals, "s.source NOT IN (?)")
args = append(args, query.ExcludedSources)
}
if len(query.IncludedSources) != 0 {
conditionals = append(conditionals, "s.source IN (?)")
args = append(args, query.IncludedSources)
}
if query.NameMatch != "" {
conditionals = append(conditionals, "s.name REGEXP ?")
args = append(args, query.NameMatch)
}
if query.NameExclude != "" {
conditionals = append(conditionals, "s.name NOT REGEXP ?")
args = append(args, query.NameExclude)
}
if len(conditionals) != 0 {
stmt += " WHERE " + strings.Join(conditionals, " AND ")
}
stmt, args, err = sqlx.In(stmt, args...)
if err != nil {
return nil, fmt.Errorf("error building 'In' query part on software iterator: %w", err)
}
rows, err := ds.reader(ctx).QueryxContext(ctx, stmt, args...) //nolint:sqlclosecheck
if err != nil {
return nil, fmt.Errorf("executing all software iterator %w", err)
}
return &softwareIterator{rows: rows}, nil
}
func (ds *Datastore) UpsertSoftwareCPEs(ctx context.Context, cpes []fleet.SoftwareCPE) (int64, error) {
var args []interface{}
if len(cpes) == 0 {
return 0, nil
}
values := strings.TrimSuffix(strings.Repeat("(?,?),", len(cpes)), ",")
sql := fmt.Sprintf(
`INSERT INTO software_cpe (software_id, cpe) VALUES %s ON DUPLICATE KEY UPDATE cpe = VALUES(cpe)`,
values,
)
for _, cpe := range cpes {
args = append(args, cpe.SoftwareID, cpe.CPE)
}
res, err := ds.writer(ctx).ExecContext(ctx, sql, args...)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "insert software cpes")
}
count, _ := res.RowsAffected()
return count, nil
}
func (ds *Datastore) DeleteSoftwareCPEs(ctx context.Context, cpes []fleet.SoftwareCPE) (int64, error) {
if len(cpes) == 0 {
return 0, nil
}
stmt := `DELETE FROM software_cpe WHERE (software_id) IN (?)`
softwareIDs := make([]uint, 0, len(cpes))
for _, cpe := range cpes {
softwareIDs = append(softwareIDs, cpe.SoftwareID)
}
query, args, err := sqlx.In(stmt, softwareIDs)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "error building 'In' query part when deleting software CPEs")
}
res, err := ds.writer(ctx).ExecContext(ctx, query, args...)
if err != nil {
return 0, ctxerr.Wrapf(ctx, err, "deleting cpes software")
}
count, _ := res.RowsAffected()
return count, nil
}
func (ds *Datastore) ListSoftwareCPEs(ctx context.Context) ([]fleet.SoftwareCPE, error) {
var result []fleet.SoftwareCPE
var err error
var args []interface{}
stmt := `SELECT id, software_id, cpe FROM software_cpe`
err = sqlx.SelectContext(ctx, ds.reader(ctx), &result, stmt, args...)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "loads cpes")
}
return result, nil
}
func (ds *Datastore) ListSoftware(ctx context.Context, opt fleet.SoftwareListOptions) ([]fleet.Software, *fleet.PaginationMetadata, error) {
if !opt.VulnerableOnly && (opt.MinimumCVSS > 0 || opt.MaximumCVSS > 0 || opt.KnownExploit) {
return nil, nil, fleet.NewInvalidArgumentError(
"query", "min_cvss_score, max_cvss_score, and exploit can only be provided with vulnerable=true",
)
}
software, err := listSoftwareDB(ctx, ds.reader(ctx), opt)
if err != nil {
return nil, nil, err
}
perPage := opt.ListOptions.PerPage
var metaData *fleet.PaginationMetadata
if opt.ListOptions.IncludeMetadata {
if perPage <= 0 {
perPage = defaultSelectLimit
}
metaData = &fleet.PaginationMetadata{HasPreviousResults: opt.ListOptions.Page > 0}
if len(software) > int(perPage) { //nolint:gosec // dismiss G115
metaData.HasNextResults = true
software = software[:len(software)-1]
}
}
return software, metaData, nil
}
func (ds *Datastore) CountSoftware(ctx context.Context, opt fleet.SoftwareListOptions) (int, error) {
return countSoftwareDB(ctx, ds.reader(ctx), opt)
}
// DeleteSoftwareVulnerabilities deletes the given list of software vulnerabilities
func (ds *Datastore) DeleteSoftwareVulnerabilities(ctx context.Context, vulnerabilities []fleet.SoftwareVulnerability) error {
if len(vulnerabilities) == 0 {
return nil
}
sql := fmt.Sprintf(
`DELETE FROM software_cve WHERE (software_id, cve) IN (%s)`,
strings.TrimSuffix(strings.Repeat("(?,?),", len(vulnerabilities)), ","),
)
var args []interface{}
for _, vulnerability := range vulnerabilities {
args = append(args, vulnerability.SoftwareID, vulnerability.CVE)
}
if _, err := ds.writer(ctx).ExecContext(ctx, sql, args...); err != nil {
return ctxerr.Wrapf(ctx, err, "deleting vulnerable software")
}
return nil
}
func (ds *Datastore) DeleteOutOfDateVulnerabilities(ctx context.Context, source fleet.VulnerabilitySource, duration time.Duration) error {
sql := `DELETE FROM software_cve WHERE source = ? AND updated_at < ?`
var args []interface{}
cutPoint := time.Now().UTC().Add(-1 * duration)
args = append(args, source, cutPoint)
if _, err := ds.writer(ctx).ExecContext(ctx, sql, args...); err != nil {
return ctxerr.Wrap(ctx, err, "deleting out of date vulnerabilities")
}
return nil
}
func (ds *Datastore) SoftwareByID(ctx context.Context, id uint, teamID *uint, includeCVEScores bool, tmFilter *fleet.TeamFilter) (*fleet.Software, error) {
q := dialect.From(goqu.I("software").As("s")).
Select(
"s.id",
"s.name",
"s.version",
"s.source",
"s.browser",
"s.bundle_identifier",
"s.release",
"s.vendor",
"s.arch",
"s.extension_id",
"scv.cve",
"scv.created_at",
goqu.COALESCE(goqu.I("scp.cpe"), "").As("generated_cpe"),
).
LeftJoin(
goqu.I("software_cpe").As("scp"),
goqu.On(
goqu.I("s.id").Eq(goqu.I("scp.software_id")),
),
).
LeftJoin(
goqu.I("software_cve").As("scv"),
goqu.On(goqu.I("s.id").Eq(goqu.I("scv.software_id"))),
)
// join only on software_id as we'll need counts for all teams
// to filter down to the team's the user has access to
if tmFilter != nil {
q = q.LeftJoin(
goqu.I("software_host_counts").As("shc"),
goqu.On(goqu.I("s.id").Eq(goqu.I("shc.software_id"))),
)
}
if includeCVEScores {
q = q.
LeftJoin(
2022-06-01 16:06:57 +00:00
goqu.I("cve_meta").As("c"),
goqu.On(goqu.I("c.cve").Eq(goqu.I("scv.cve"))),
).
SelectAppend(
"c.cvss_score",
"c.epss_probability",
"c.cisa_known_exploit",
"c.description",
goqu.I("c.published").As("cve_published"),
"scv.resolved_in_version",
)
}
q = q.Where(goqu.I("s.id").Eq(id))
// If teamID is not specified, we still return the software even if it is not associated with any hosts.
// Software is cleaned up by a cron job, so it is possible to have software in software_hosts_counts that has been deleted from a host.
if teamID != nil {
// If teamID filter is used, host counts need to be up-to-date.
// This should generally be the case, since unused software is cleared when host counts are updated.
// However, it is possible that the software was deleted from all hosts after the last host count update.
q = q.Where(
goqu.L(
"EXISTS (SELECT 1 FROM software_host_counts WHERE software_id = ? AND team_id = ? AND hosts_count > 0 AND global_stats = 0)", id, *teamID,
),
)
}
// filter by teams
if tmFilter != nil {
q = q.Where(goqu.L(ds.whereFilterGlobalOrTeamIDByTeams(*tmFilter, "shc")))
}
sql, args, err := q.ToSQL()
if err != nil {
return nil, err
}
var results []softwareCVE
err = sqlx.SelectContext(ctx, ds.reader(ctx), &results, sql, args...)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get software")
}
if len(results) == 0 {
return nil, ctxerr.Wrap(ctx, notFound("Software").WithID(id))
}
var software fleet.Software
for i, result := range results {
result := result // create a copy because we need to take the address to fields below
if i == 0 {
software = result.Software
}
if result.CVE != nil {
cveID := *result.CVE
cve := fleet.CVE{
CVE: cveID,
DetailsLink: fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", cveID),
CreatedAt: *result.CreatedAt,
}
if includeCVEScores {
cve.CVSSScore = &result.CVSSScore
cve.EPSSProbability = &result.EPSSProbability
cve.CISAKnownExploit = &result.CISAKnownExploit
cve.CVEPublished = &result.CVEPublished
cve.ResolvedInVersion = &result.ResolvedInVersion
}
software.Vulnerabilities = append(software.Vulnerabilities, cve)
}
}
return &software, nil
}
// SyncHostsSoftware calculates the number of hosts having each
// software installed and stores that information in the software_host_counts
// table.
//
// After aggregation, it cleans up unused software (e.g. software installed
// on removed hosts, software uninstalled on hosts, etc.)
func (ds *Datastore) SyncHostsSoftware(ctx context.Context, updatedAt time.Time) error {
const (
resetStmt = `
UPDATE software_host_counts
SET hosts_count = 0, updated_at = ?`
// team_id is added to the select list to have the same structure as
// the teamCountsStmt, making it easier to use a common implementation
globalCountsStmt = `
SELECT count(*), 0 as team_id, software_id, 1 as global_stats
FROM host_software
WHERE software_id > ? AND software_id <= ?
GROUP BY software_id`
teamCountsStmt = `
SELECT count(*), h.team_id, hs.software_id, 0 as global_stats
FROM host_software hs
INNER JOIN hosts h
ON hs.host_id = h.id
WHERE h.team_id IS NOT NULL AND hs.software_id > ? AND hs.software_id <= ?
GROUP BY hs.software_id, h.team_id`
noTeamCountsStmt = `
SELECT count(*), 0 as team_id, software_id, 0 as global_stats
FROM host_software hs
INNER JOIN hosts h
ON hs.host_id = h.id
WHERE h.team_id IS NULL AND hs.software_id > ? AND hs.software_id <= ?
GROUP BY hs.software_id`
insertStmt = `
INSERT INTO software_host_counts
(software_id, hosts_count, team_id, global_stats, updated_at)
VALUES
%s
ON DUPLICATE KEY UPDATE
hosts_count = VALUES(hosts_count),
updated_at = VALUES(updated_at)`
valuesPart = `(?, ?, ?, ?, ?),`
// We must ensure that software is not in host_software table before deleting it.
// This prevents a race condition where a host just added the software, but it is not part of software_host_counts yet.
// When a host adds software, software table and host_software table are updated in the same transaction.
cleanupSoftwareStmt = `
DELETE s
FROM software s
LEFT JOIN software_host_counts shc
ON s.id = shc.software_id
WHERE
(shc.software_id IS NULL OR
(shc.team_id = 0 AND shc.hosts_count = 0)) AND
NOT EXISTS (SELECT 1 FROM host_software hsw WHERE hsw.software_id = s.id)
`
cleanupOrphanedStmt = `
DELETE shc
FROM
software_host_counts shc
LEFT JOIN software s ON s.id = shc.software_id
WHERE
s.id IS NULL
`
cleanupTeamStmt = `
DELETE shc
FROM software_host_counts shc
LEFT JOIN teams t
ON t.id = shc.team_id
WHERE
shc.team_id > 0 AND
t.id IS NULL`
)
// first, reset all counts to 0
if _, err := ds.writer(ctx).ExecContext(ctx, resetStmt, updatedAt); err != nil {
return ctxerr.Wrap(ctx, err, "reset all software_host_counts to 0")
}
db := ds.reader(ctx)
// Figure out how many software items we need to count.
type minMaxIDs struct {
Min uint64 `db:"min"`
Max uint64 `db:"max"`
}
minMax := minMaxIDs{}
err := sqlx.GetContext(
ctx, db, &minMax, "SELECT COALESCE(MIN(software_id),1) as min, COALESCE(MAX(software_id),0) as max FROM host_software",
)
if err != nil {
return ctxerr.Wrap(ctx, err, "get min/max software_id")
}
for minSoftwareID, maxSoftwareID := minMax.Min-1, minMax.Min-1+countHostSoftwareBatchSize; minSoftwareID < minMax.Max; minSoftwareID, maxSoftwareID = maxSoftwareID, maxSoftwareID+countHostSoftwareBatchSize {
// next get a cursor for the global and team counts for each software
stmtLabel := []string{"global", "team", "noteam"}
for i, countStmt := range []string{globalCountsStmt, teamCountsStmt, noTeamCountsStmt} {
rows, err := db.QueryContext(ctx, countStmt, minSoftwareID, maxSoftwareID)
if err != nil {
return ctxerr.Wrapf(ctx, err, "read %s counts from host_software", stmtLabel[i])
}
defer rows.Close()
// use a loop to iterate to prevent loading all in one go in memory, as it
// could get pretty big at >100K hosts with 1000+ software each. Use a write
// batch to prevent making too many single-row inserts.
const batchSize = 100
var batchCount int
args := make([]interface{}, 0, batchSize*4)
for rows.Next() {
var (
count int
teamID uint
sid uint
global_stats bool
)
if err := rows.Scan(&count, &teamID, &sid, &global_stats); err != nil {
return ctxerr.Wrapf(ctx, err, "scan %s row into variables", stmtLabel[i])
}
args = append(args, sid, count, teamID, global_stats, updatedAt)
batchCount++
if batchCount == batchSize {
values := strings.TrimSuffix(strings.Repeat(valuesPart, batchCount), ",")
if _, err := ds.writer(ctx).ExecContext(ctx, fmt.Sprintf(insertStmt, values), args...); err != nil {
return ctxerr.Wrapf(ctx, err, "insert %s batch into software_host_counts", stmtLabel[i])
}
args = args[:0]
batchCount = 0
}
}
if batchCount > 0 {
values := strings.TrimSuffix(strings.Repeat(valuesPart, batchCount), ",")
if _, err := ds.writer(ctx).ExecContext(ctx, fmt.Sprintf(insertStmt, values), args...); err != nil {
return ctxerr.Wrapf(ctx, err, "insert last %s batch into software_host_counts", stmtLabel[i])
}
}
if err := rows.Err(); err != nil {
return ctxerr.Wrapf(ctx, err, "iterate over %s host_software counts", stmtLabel[i])
}
rows.Close()
}
}
// remove any unused software (global counts = 0)
if _, err := ds.writer(ctx).ExecContext(ctx, cleanupSoftwareStmt); err != nil {
return ctxerr.Wrap(ctx, err, "delete unused software")
}
// remove any software count row for software that don't exist anymore
if _, err := ds.writer(ctx).ExecContext(ctx, cleanupOrphanedStmt); err != nil {
return ctxerr.Wrap(ctx, err, "delete software_host_counts for non-existing software")
}
// remove any software count row for teams that don't exist anymore
if _, err := ds.writer(ctx).ExecContext(ctx, cleanupTeamStmt); err != nil {
return ctxerr.Wrap(ctx, err, "delete software_host_counts for non-existing teams")
}
return nil
}
func (ds *Datastore) ReconcileSoftwareTitles(ctx context.Context) error {
// TODO: consider if we should batch writes to software or software_titles table
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
// ensure all software titles are in the software_titles table
upsertTitlesStmt := `
INSERT INTO software_titles (name, source, browser, bundle_identifier)
SELECT
name,
source,
browser,
bundle_identifier
FROM (
SELECT DISTINCT
name,
source,
browser,
bundle_identifier
FROM
software s
WHERE
NOT EXISTS (
SELECT 1 FROM software_titles st
WHERE s.bundle_identifier = st.bundle_identifier AND
IF(s.source IN ('apps', 'ios_apps', 'ipados_apps'), s.source = st.source, 1)
)
AND COALESCE(bundle_identifier, '') != ''
UNION ALL
SELECT DISTINCT
name,
source,
browser,
NULL as bundle_identifier
FROM
software s
WHERE
NOT EXISTS (
SELECT 1 FROM software_titles st
WHERE (s.name, s.source, s.browser) = (st.name, st.source, st.browser)
)
AND COALESCE(s.bundle_identifier, '') = ''
) as combined_results
ON DUPLICATE KEY UPDATE
software_titles.name = software_titles.name,
software_titles.source = software_titles.source,
software_titles.browser = software_titles.browser,
software_titles.bundle_identifier = software_titles.bundle_identifier
`
res, err := tx.ExecContext(ctx, upsertTitlesStmt)
if err != nil {
return ctxerr.Wrap(ctx, err, "upsert software titles")
}
n, _ := res.RowsAffected()
level.Debug(ds.logger).Log("msg", "upsert software titles", "rows_affected", n)
// update title ids for software table entries
separate queries for software matching to use indexes (#20354) during load testing we found this query to be a bottleneck, locally splitting in to two different statements makes a difference since we can effectively use the indexes. ``` mysql> explain UPDATE software s -> JOIN software_titles st -> ON COALESCE(s.bundle_identifier, '') = '' AND s.name = st.name AND s.source = st.source AND s.browser = st.browser -> SET s.title_id = st.id -> WHERE (s.title_id IS NULL OR s.title_id != st.id) -> AND COALESCE(s.bundle_identifier, '') = ''; +----+-------------+-------+------------+-------+-------------------------------------------------------------------------------------+----------------------------+---------+------------------------------------------------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+-------------------------------------------------------------------------------------+----------------------------+---------+------------------------------------------------+------+----------+-------------+ | 1 | SIMPLE | st | NULL | index | idx_sw_titles | idx_sw_titles | 2302 | NULL | 765 | 100.00 | Using index | | 1 | UPDATE | s | NULL | ref | software_listing_idx,software_source_vendor_idx,title_id,idx_sw_name_source_browser | idx_sw_name_source_browser | 2302 | fleet.st.name,fleet.st.source,fleet.st.browser | 1 | 91.00 | Using where | +----+-------------+-------+------------+-------+-------------------------------------------------------------------------------------+----------------------------+---------+------------------------------------------------+------+----------+-------------+ 2 rows in set (0.00 sec) mysql> explain UPDATE software s -> JOIN software_titles st -> ON s.bundle_identifier = st.bundle_identifier -> SET s.title_id = st.id -> WHERE s.title_id IS NULL -> OR s.title_id != st.id; +----+-------------+-------+------------+------+-----------------------------------------------------+---------------------------------------+---------+---------------------------+------+----------+--------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+-----------------------------------------------------+---------------------------------------+---------+---------------------------+------+----------+--------------------------+ | 1 | UPDATE | s | NULL | ALL | title_id,idx_software_bundle_id | NULL | NULL | NULL | 788 | 100.00 | Using where | | 1 | SIMPLE | st | NULL | ref | idx_software_titles_bundle_identifier,idx_composite | idx_software_titles_bundle_identifier | 1023 | fleet.s.bundle_identifier | 1 | 100.00 | Using where; Using index | +----+-------------+-------+------------+------+-----------------------------------------------------+---------------------------------------+---------+---------------------------+------+----------+--------------------------+ 2 rows in set (0.00 sec) ``` # Checklist for submitter If some of the following don't apply, delete the relevant line. <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Manual QA for all new/changed functionality
2024-07-10 21:16:38 +00:00
updateSoftwareWithoutIdentifierStmt := `
UPDATE software s
JOIN software_titles st
separate queries for software matching to use indexes (#20354) during load testing we found this query to be a bottleneck, locally splitting in to two different statements makes a difference since we can effectively use the indexes. ``` mysql> explain UPDATE software s -> JOIN software_titles st -> ON COALESCE(s.bundle_identifier, '') = '' AND s.name = st.name AND s.source = st.source AND s.browser = st.browser -> SET s.title_id = st.id -> WHERE (s.title_id IS NULL OR s.title_id != st.id) -> AND COALESCE(s.bundle_identifier, '') = ''; +----+-------------+-------+------------+-------+-------------------------------------------------------------------------------------+----------------------------+---------+------------------------------------------------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+-------------------------------------------------------------------------------------+----------------------------+---------+------------------------------------------------+------+----------+-------------+ | 1 | SIMPLE | st | NULL | index | idx_sw_titles | idx_sw_titles | 2302 | NULL | 765 | 100.00 | Using index | | 1 | UPDATE | s | NULL | ref | software_listing_idx,software_source_vendor_idx,title_id,idx_sw_name_source_browser | idx_sw_name_source_browser | 2302 | fleet.st.name,fleet.st.source,fleet.st.browser | 1 | 91.00 | Using where | +----+-------------+-------+------------+-------+-------------------------------------------------------------------------------------+----------------------------+---------+------------------------------------------------+------+----------+-------------+ 2 rows in set (0.00 sec) mysql> explain UPDATE software s -> JOIN software_titles st -> ON s.bundle_identifier = st.bundle_identifier -> SET s.title_id = st.id -> WHERE s.title_id IS NULL -> OR s.title_id != st.id; +----+-------------+-------+------------+------+-----------------------------------------------------+---------------------------------------+---------+---------------------------+------+----------+--------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+-----------------------------------------------------+---------------------------------------+---------+---------------------------+------+----------+--------------------------+ | 1 | UPDATE | s | NULL | ALL | title_id,idx_software_bundle_id | NULL | NULL | NULL | 788 | 100.00 | Using where | | 1 | SIMPLE | st | NULL | ref | idx_software_titles_bundle_identifier,idx_composite | idx_software_titles_bundle_identifier | 1023 | fleet.s.bundle_identifier | 1 | 100.00 | Using where; Using index | +----+-------------+-------+------------+------+-----------------------------------------------------+---------------------------------------+---------+---------------------------+------+----------+--------------------------+ 2 rows in set (0.00 sec) ``` # Checklist for submitter If some of the following don't apply, delete the relevant line. <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Manual QA for all new/changed functionality
2024-07-10 21:16:38 +00:00
ON COALESCE(s.bundle_identifier, '') = '' AND s.name = st.name AND s.source = st.source AND s.browser = st.browser
SET s.title_id = st.id
WHERE (s.title_id IS NULL OR s.title_id != st.id)
AND COALESCE(s.bundle_identifier, '') = '';
`
res, err = tx.ExecContext(ctx, updateSoftwareWithoutIdentifierStmt)
if err != nil {
return ctxerr.Wrap(ctx, err, "update software title_id without bundle identifier")
}
n, _ = res.RowsAffected()
level.Debug(ds.logger).Log("msg", "update software title_id without bundle identifier", "rows_affected", n)
updateSoftwareWithIdentifierStmt := `
UPDATE software s
JOIN software_titles st
ON s.bundle_identifier = st.bundle_identifier AND
IF(s.source IN ('apps', 'ios_apps', 'ipados_apps'), s.source = st.source, 1)
SET s.title_id = st.id
WHERE s.title_id IS NULL
OR s.title_id != st.id;
`
separate queries for software matching to use indexes (#20354) during load testing we found this query to be a bottleneck, locally splitting in to two different statements makes a difference since we can effectively use the indexes. ``` mysql> explain UPDATE software s -> JOIN software_titles st -> ON COALESCE(s.bundle_identifier, '') = '' AND s.name = st.name AND s.source = st.source AND s.browser = st.browser -> SET s.title_id = st.id -> WHERE (s.title_id IS NULL OR s.title_id != st.id) -> AND COALESCE(s.bundle_identifier, '') = ''; +----+-------------+-------+------------+-------+-------------------------------------------------------------------------------------+----------------------------+---------+------------------------------------------------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+-------------------------------------------------------------------------------------+----------------------------+---------+------------------------------------------------+------+----------+-------------+ | 1 | SIMPLE | st | NULL | index | idx_sw_titles | idx_sw_titles | 2302 | NULL | 765 | 100.00 | Using index | | 1 | UPDATE | s | NULL | ref | software_listing_idx,software_source_vendor_idx,title_id,idx_sw_name_source_browser | idx_sw_name_source_browser | 2302 | fleet.st.name,fleet.st.source,fleet.st.browser | 1 | 91.00 | Using where | +----+-------------+-------+------------+-------+-------------------------------------------------------------------------------------+----------------------------+---------+------------------------------------------------+------+----------+-------------+ 2 rows in set (0.00 sec) mysql> explain UPDATE software s -> JOIN software_titles st -> ON s.bundle_identifier = st.bundle_identifier -> SET s.title_id = st.id -> WHERE s.title_id IS NULL -> OR s.title_id != st.id; +----+-------------+-------+------------+------+-----------------------------------------------------+---------------------------------------+---------+---------------------------+------+----------+--------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+-----------------------------------------------------+---------------------------------------+---------+---------------------------+------+----------+--------------------------+ | 1 | UPDATE | s | NULL | ALL | title_id,idx_software_bundle_id | NULL | NULL | NULL | 788 | 100.00 | Using where | | 1 | SIMPLE | st | NULL | ref | idx_software_titles_bundle_identifier,idx_composite | idx_software_titles_bundle_identifier | 1023 | fleet.s.bundle_identifier | 1 | 100.00 | Using where; Using index | +----+-------------+-------+------------+------+-----------------------------------------------------+---------------------------------------+---------+---------------------------+------+----------+--------------------------+ 2 rows in set (0.00 sec) ``` # Checklist for submitter If some of the following don't apply, delete the relevant line. <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Manual QA for all new/changed functionality
2024-07-10 21:16:38 +00:00
res, err = tx.ExecContext(ctx, updateSoftwareWithIdentifierStmt)
if err != nil {
separate queries for software matching to use indexes (#20354) during load testing we found this query to be a bottleneck, locally splitting in to two different statements makes a difference since we can effectively use the indexes. ``` mysql> explain UPDATE software s -> JOIN software_titles st -> ON COALESCE(s.bundle_identifier, '') = '' AND s.name = st.name AND s.source = st.source AND s.browser = st.browser -> SET s.title_id = st.id -> WHERE (s.title_id IS NULL OR s.title_id != st.id) -> AND COALESCE(s.bundle_identifier, '') = ''; +----+-------------+-------+------------+-------+-------------------------------------------------------------------------------------+----------------------------+---------+------------------------------------------------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+-------------------------------------------------------------------------------------+----------------------------+---------+------------------------------------------------+------+----------+-------------+ | 1 | SIMPLE | st | NULL | index | idx_sw_titles | idx_sw_titles | 2302 | NULL | 765 | 100.00 | Using index | | 1 | UPDATE | s | NULL | ref | software_listing_idx,software_source_vendor_idx,title_id,idx_sw_name_source_browser | idx_sw_name_source_browser | 2302 | fleet.st.name,fleet.st.source,fleet.st.browser | 1 | 91.00 | Using where | +----+-------------+-------+------------+-------+-------------------------------------------------------------------------------------+----------------------------+---------+------------------------------------------------+------+----------+-------------+ 2 rows in set (0.00 sec) mysql> explain UPDATE software s -> JOIN software_titles st -> ON s.bundle_identifier = st.bundle_identifier -> SET s.title_id = st.id -> WHERE s.title_id IS NULL -> OR s.title_id != st.id; +----+-------------+-------+------------+------+-----------------------------------------------------+---------------------------------------+---------+---------------------------+------+----------+--------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+-----------------------------------------------------+---------------------------------------+---------+---------------------------+------+----------+--------------------------+ | 1 | UPDATE | s | NULL | ALL | title_id,idx_software_bundle_id | NULL | NULL | NULL | 788 | 100.00 | Using where | | 1 | SIMPLE | st | NULL | ref | idx_software_titles_bundle_identifier,idx_composite | idx_software_titles_bundle_identifier | 1023 | fleet.s.bundle_identifier | 1 | 100.00 | Using where; Using index | +----+-------------+-------+------------+------+-----------------------------------------------------+---------------------------------------+---------+---------------------------+------+----------+--------------------------+ 2 rows in set (0.00 sec) ``` # Checklist for submitter If some of the following don't apply, delete the relevant line. <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Manual QA for all new/changed functionality
2024-07-10 21:16:38 +00:00
return ctxerr.Wrap(ctx, err, "update software title_id with bundle identifier")
}
n, _ = res.RowsAffected()
separate queries for software matching to use indexes (#20354) during load testing we found this query to be a bottleneck, locally splitting in to two different statements makes a difference since we can effectively use the indexes. ``` mysql> explain UPDATE software s -> JOIN software_titles st -> ON COALESCE(s.bundle_identifier, '') = '' AND s.name = st.name AND s.source = st.source AND s.browser = st.browser -> SET s.title_id = st.id -> WHERE (s.title_id IS NULL OR s.title_id != st.id) -> AND COALESCE(s.bundle_identifier, '') = ''; +----+-------------+-------+------------+-------+-------------------------------------------------------------------------------------+----------------------------+---------+------------------------------------------------+------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+-------------------------------------------------------------------------------------+----------------------------+---------+------------------------------------------------+------+----------+-------------+ | 1 | SIMPLE | st | NULL | index | idx_sw_titles | idx_sw_titles | 2302 | NULL | 765 | 100.00 | Using index | | 1 | UPDATE | s | NULL | ref | software_listing_idx,software_source_vendor_idx,title_id,idx_sw_name_source_browser | idx_sw_name_source_browser | 2302 | fleet.st.name,fleet.st.source,fleet.st.browser | 1 | 91.00 | Using where | +----+-------------+-------+------------+-------+-------------------------------------------------------------------------------------+----------------------------+---------+------------------------------------------------+------+----------+-------------+ 2 rows in set (0.00 sec) mysql> explain UPDATE software s -> JOIN software_titles st -> ON s.bundle_identifier = st.bundle_identifier -> SET s.title_id = st.id -> WHERE s.title_id IS NULL -> OR s.title_id != st.id; +----+-------------+-------+------------+------+-----------------------------------------------------+---------------------------------------+---------+---------------------------+------+----------+--------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+-----------------------------------------------------+---------------------------------------+---------+---------------------------+------+----------+--------------------------+ | 1 | UPDATE | s | NULL | ALL | title_id,idx_software_bundle_id | NULL | NULL | NULL | 788 | 100.00 | Using where | | 1 | SIMPLE | st | NULL | ref | idx_software_titles_bundle_identifier,idx_composite | idx_software_titles_bundle_identifier | 1023 | fleet.s.bundle_identifier | 1 | 100.00 | Using where; Using index | +----+-------------+-------+------------+------+-----------------------------------------------------+---------------------------------------+---------+---------------------------+------+----------+--------------------------+ 2 rows in set (0.00 sec) ``` # Checklist for submitter If some of the following don't apply, delete the relevant line. <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Manual QA for all new/changed functionality
2024-07-10 21:16:38 +00:00
level.Debug(ds.logger).Log("msg", "update software title_id with bundle identifier", "rows_affected", n)
// clean up orphaned software titles
cleanupStmt := `
DELETE st FROM software_titles st
LEFT JOIN software s ON s.title_id = st.id
WHERE s.title_id IS NULL AND
NOT EXISTS (SELECT 1 FROM software_installers si WHERE si.title_id = st.id) AND
NOT EXISTS (SELECT 1 FROM vpp_apps vap WHERE vap.title_id = st.id)`
res, err = tx.ExecContext(ctx, cleanupStmt)
if err != nil {
return ctxerr.Wrap(ctx, err, "cleanup orphaned software titles")
}
n, _ = res.RowsAffected()
level.Debug(ds.logger).Log("msg", "cleanup orphaned software titles", "rows_affected", n)
return nil
})
}
func (ds *Datastore) HostVulnSummariesBySoftwareIDs(ctx context.Context, softwareIDs []uint) ([]fleet.HostVulnerabilitySummary, error) {
stmt := `
SELECT DISTINCT
h.id,
h.hostname,
if(h.computer_name = '', h.hostname, h.computer_name) display_name,
COALESCE(hsip.installed_path, '') AS software_installed_path
FROM hosts h
INNER JOIN host_software hs ON h.id = hs.host_id AND hs.software_id IN (?)
LEFT JOIN host_software_installed_paths hsip ON hs.host_id = hsip.host_id AND hs.software_id = hsip.software_id
ORDER BY h.id`
stmt, args, err := sqlx.In(stmt, softwareIDs)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "building query args")
}
var qR []struct {
HostID uint `db:"id"`
HostName string `db:"hostname"`
DisplayName string `db:"display_name"`
SPath string `db:"software_installed_path"`
}
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &qR, stmt, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "selecting hosts by softwareIDs")
}
var result []fleet.HostVulnerabilitySummary
lookup := make(map[uint]int)
for _, r := range qR {
i, ok := lookup[r.HostID]
if ok {
result[i].AddSoftwareInstalledPath(r.SPath)
continue
}
mapped := fleet.HostVulnerabilitySummary{
ID: r.HostID,
Hostname: r.HostName,
DisplayName: r.DisplayName,
}
mapped.AddSoftwareInstalledPath(r.SPath)
result = append(result, mapped)
lookup[r.HostID] = len(result) - 1
}
return result, nil
}
// ** DEPRECATED **
func (ds *Datastore) HostsByCVE(ctx context.Context, cve string) ([]fleet.HostVulnerabilitySummary, error) {
stmt := `
SELECT DISTINCT
(h.id),
h.hostname,
if(h.computer_name = '', h.hostname, h.computer_name) display_name,
COALESCE(hsip.installed_path, '') AS software_installed_path
FROM hosts h
INNER JOIN host_software hs ON h.id = hs.host_id
INNER JOIN software_cve scv ON scv.software_id = hs.software_id
LEFT JOIN host_software_installed_paths hsip ON hs.host_id = hsip.host_id AND hs.software_id = hsip.software_id
WHERE scv.cve = ?
ORDER BY h.id`
var qR []struct {
HostID uint `db:"id"`
HostName string `db:"hostname"`
DisplayName string `db:"display_name"`
SPath string `db:"software_installed_path"`
}
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &qR, stmt, cve); err != nil {
return nil, ctxerr.Wrap(ctx, err, "selecting hosts by softwareIDs")
}
var result []fleet.HostVulnerabilitySummary
lookup := make(map[uint]int)
for _, r := range qR {
i, ok := lookup[r.HostID]
if ok {
result[i].AddSoftwareInstalledPath(r.SPath)
continue
}
mapped := fleet.HostVulnerabilitySummary{
ID: r.HostID,
Hostname: r.HostName,
DisplayName: r.DisplayName,
}
mapped.AddSoftwareInstalledPath(r.SPath)
result = append(result, mapped)
lookup[r.HostID] = len(result) - 1
}
return result, nil
}
2022-06-01 16:06:57 +00:00
func (ds *Datastore) InsertCVEMeta(ctx context.Context, cveMeta []fleet.CVEMeta) error {
query := `
INSERT INTO cve_meta (cve, cvss_score, epss_probability, cisa_known_exploit, published, description)
VALUES %s
ON DUPLICATE KEY UPDATE
cvss_score = VALUES(cvss_score),
epss_probability = VALUES(epss_probability),
2022-06-01 16:06:57 +00:00
cisa_known_exploit = VALUES(cisa_known_exploit),
published = VALUES(published),
description = VALUES(description)
`
batchSize := 500
2022-06-01 16:06:57 +00:00
for i := 0; i < len(cveMeta); i += batchSize {
end := i + batchSize
2022-06-01 16:06:57 +00:00
if end > len(cveMeta) {
end = len(cveMeta)
}
2022-06-01 16:06:57 +00:00
batch := cveMeta[i:end]
valuesFrag := strings.TrimSuffix(strings.Repeat("(?, ?, ?, ?, ?, ?), ", len(batch)), ", ")
var args []interface{}
2022-06-01 16:06:57 +00:00
for _, meta := range batch {
args = append(args, meta.CVE, meta.CVSSScore, meta.EPSSProbability, meta.CISAKnownExploit, meta.Published, meta.Description)
}
query := fmt.Sprintf(query, valuesFrag)
_, err := ds.writer(ctx).ExecContext(ctx, query, args...)
if err != nil {
return ctxerr.Wrap(ctx, err, "insert cve scores")
}
}
return nil
}
func (ds *Datastore) InsertSoftwareVulnerability(
ctx context.Context,
vuln fleet.SoftwareVulnerability,
source fleet.VulnerabilitySource,
) (bool, error) {
if vuln.CVE == "" {
return false, nil
}
var args []interface{}
stmt := `
INSERT INTO software_cve (cve, source, software_id, resolved_in_version)
VALUES (?,?,?,?)
ON DUPLICATE KEY UPDATE
source = VALUES(source),
resolved_in_version = VALUES(resolved_in_version),
updated_at=?
`
args = append(args, vuln.CVE, source, vuln.SoftwareID, vuln.ResolvedInVersion, time.Now().UTC())
res, err := ds.writer(ctx).ExecContext(ctx, stmt, args...)
if err != nil {
return false, ctxerr.Wrap(ctx, err, "insert software vulnerability")
}
return insertOnDuplicateDidInsertOrUpdate(res), nil
}
func (ds *Datastore) ListSoftwareVulnerabilitiesByHostIDsSource(
ctx context.Context,
hostIDs []uint,
source fleet.VulnerabilitySource,
) (map[uint][]fleet.SoftwareVulnerability, error) {
result := make(map[uint][]fleet.SoftwareVulnerability)
type softwareVulnerabilityWithHostId struct {
fleet.SoftwareVulnerability
HostID uint `db:"host_id"`
}
var queryR []softwareVulnerabilityWithHostId
stmt := dialect.
From(goqu.T("software_cve").As("sc")).
Join(
goqu.T("host_software").As("hs"),
goqu.On(goqu.Ex{
"sc.software_id": goqu.I("hs.software_id"),
}),
).
Select(
goqu.I("hs.host_id"),
goqu.I("sc.software_id"),
goqu.I("sc.cve"),
goqu.I("sc.resolved_in_version"),
).
Where(
goqu.I("hs.host_id").In(hostIDs),
goqu.I("sc.source").Eq(source),
)
sql, args, err := stmt.ToSQL()
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "error generating SQL statement")
}
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &queryR, sql, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "error executing SQL statement")
}
for _, r := range queryR {
result[r.HostID] = append(result[r.HostID], r.SoftwareVulnerability)
}
return result, nil
}
func (ds *Datastore) ListSoftwareForVulnDetection(
ctx context.Context,
2024-07-09 17:50:22 +00:00
filters fleet.VulnSoftwareFilter,
) ([]fleet.Software, error) {
var result []fleet.Software
2024-07-09 17:50:22 +00:00
var sqlstmt string
var args []interface{}
2024-07-09 17:50:22 +00:00
baseSQL := `
SELECT
2024-07-09 17:50:22 +00:00
s.id,
s.name,
s.version,
s.release,
s.arch,
COALESCE(cpe.cpe, '') AS generated_cpe
FROM
2024-07-09 17:50:22 +00:00
software s
LEFT JOIN
2024-07-09 17:50:22 +00:00
software_cpe cpe ON s.id = cpe.software_id
`
2024-07-09 17:50:22 +00:00
if filters.HostID != nil {
baseSQL += "JOIN host_software hs ON s.id = hs.software_id "
}
conditions := []string{}
if filters.HostID != nil {
conditions = append(conditions, "hs.host_id = ?")
args = append(args, *filters.HostID)
}
if filters.Name != "" {
conditions = append(conditions, "s.name LIKE ?")
args = append(args, "%"+filters.Name+"%")
}
if filters.Source != "" {
conditions = append(conditions, "s.source = ?")
args = append(args, filters.Source)
}
if len(conditions) > 0 {
sqlstmt = baseSQL + "WHERE " + strings.Join(conditions, " AND ")
} else {
sqlstmt = baseSQL
}
2024-07-09 17:50:22 +00:00
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &result, sqlstmt, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "error executing SQL statement")
}
return result, nil
}
// ListCVEs returns all cve_meta rows published after 'maxAge'
func (ds *Datastore) ListCVEs(ctx context.Context, maxAge time.Duration) ([]fleet.CVEMeta, error) {
var result []fleet.CVEMeta
maxAgeDate := time.Now().Add(-1 * maxAge)
stmt := dialect.From(goqu.T("cve_meta")).
Select(
goqu.C("cve"),
goqu.C("cvss_score"),
goqu.C("epss_probability"),
goqu.C("cisa_known_exploit"),
goqu.C("published"),
goqu.C("description"),
).
Where(goqu.C("published").Gte(maxAgeDate))
sql, args, err := stmt.ToSQL()
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "error generating SQL statement")
}
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &result, sql, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "error executing SQL statement")
}
return result, nil
}
func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) {
if !opts.VulnerableOnly && (opts.MinimumCVSS > 0 || opts.MaximumCVSS > 0 || opts.KnownExploit) {
return nil, nil, fleet.NewInvalidArgumentError(
"query", "min_cvss_score, max_cvss_score, and exploit can only be provided with vulnerable=true",
)
}
var onlySelfServiceClause string
if opts.SelfServiceOnly {
onlySelfServiceClause = ` AND ( si.self_service = 1 OR ( vat.self_service = 1 AND :is_mdm_enrolled ) ) `
}
var excludeVPPAppsClause string
if !opts.IsMDMEnrolled {
excludeVPPAppsClause = ` AND vat.id IS NULL `
}
var vulnerableLastSoftwareInstallJoins,
vulnerableLastSoftwareUninstallJoins,
vulnerableLastVppInstall,
onlyVulnerableJoin,
vulnerabilityFiltersClause,
cveMetaJoin string
var hasCVEFilters bool
if opts.VulnerableOnly {
// we don't currently do any vulnerability scanning on upcoming software/vpp installs/uninstalls
// so we don't need to join to software_cve for
// upcoming_software_install, upcoming_software_uninstall, upcoming_vpp_install
vulnerableLastSoftwareInstallJoins = `
INNER JOIN software_installers ON software_installers.id = hsi.software_installer_id
INNER JOIN software_titles ON software_titles.id = software_installers.title_id
INNER JOIN software ON software.title_id = software_titles.id
INNER JOIN software_cve ON software_cve.software_id = software.id
`
vulnerableLastSoftwareUninstallJoins = `
INNER JOIN software_installers ON software_installers.id = hsi.software_installer_id
INNER JOIN software_titles ON software_titles.id = software_installers.title_id
INNER JOIN software ON software.title_id = software_titles.id
INNER JOIN software_cve ON software_cve.software_id = software.id
`
vulnerableLastVppInstall = `
INNER JOIN vpp_apps va ON va.adam_id = hvsi.adam_id
AND va.platform = hvsi.platform
INNER JOIN software_titles ON software_titles.id = va.title_id
INNER JOIN software ON software.title_id = software_titles.id
INNER JOIN software_cve ON software_cve.software_id = software.id
`
onlyVulnerableJoin = `
INNER JOIN software_cve ON software_cve.software_id = s.id
`
cveMetaJoin = "INNER JOIN cve_meta ON software_cve.cve = cve_meta.cve"
if opts.KnownExploit {
vulnerabilityFiltersClause += " AND cve_meta.cisa_known_exploit = 1"
hasCVEFilters = true
}
if opts.MinimumCVSS > 0 {
vulnerabilityFiltersClause += " AND cve_meta.cvss_score >= :min_cvss"
hasCVEFilters = true
}
if opts.MaximumCVSS > 0 {
vulnerabilityFiltersClause += " AND cve_meta.cvss_score <= :max_cvss"
hasCVEFilters = true
}
// Only join CVE table if there are filters
if hasCVEFilters {
onlyVulnerableJoin += cveMetaJoin
vulnerableLastSoftwareInstallJoins += cveMetaJoin
vulnerableLastSoftwareUninstallJoins += cveMetaJoin
vulnerableLastVppInstall += cveMetaJoin
}
}
softwareIsInstalledOnHostClause := fmt.Sprintf(`
EXISTS (
SELECT 1
FROM
host_software hs
INNER JOIN
software s ON hs.software_id = s.id
%s -- onlyVulnerableJoin (includes software_cve and potentially cve_meta)
WHERE
hs.host_id = :host_id AND
s.title_id = st.id
%s
) OR `, onlyVulnerableJoin, vulnerabilityFiltersClause)
status := fmt.Sprintf(`COALESCE(%s, %s)`, `
CASE
WHEN lsia.created_at IS NULL AND lsua.created_at IS NULL THEN NULL
WHEN lsia.created_at IS NULL THEN lsua.status
WHEN lsua.created_at IS NULL THEN lsia.status
WHEN lsia.created_at > lsua.created_at THEN lsia.status
ELSE lsua.status
END
`, "lvia.status")
if opts.OnlyAvailableForInstall {
// Get software that has a package/VPP installer but was not installed with Fleet
2024-09-04 21:46:48 +00:00
softwareIsInstalledOnHostClause = fmt.Sprintf(` %s IS NULL AND (si.id IS NOT NULL OR vat.adam_id IS NOT NULL) AND %s`, status,
softwareIsInstalledOnHostClause)
}
// this statement lists only the software that is reported as installed on
// the host or has been attempted to be installed on the host.
// Latest row is found using a groupwise maximum with left join,
// more efficient than MAX/GROUP BY: https://stackoverflow.com/a/23285814
stmtInstalled := fmt.Sprintf(`
-- select most recent upcoming software install
WITH upcoming_software_install AS (
SELECT
ua.execution_id,
ua.host_id,
ua.created_at,
siua.software_installer_id,
'pending_install' as status
FROM
upcoming_activities ua
INNER JOIN software_install_upcoming_activities siua ON ua.id = siua.upcoming_activity_id
LEFT JOIN (
upcoming_activities ua2
INNER JOIN software_install_upcoming_activities siua2
ON ua2.id = siua2.upcoming_activity_id
) ON ua.host_id = ua2.host_id AND
siua.software_installer_id = siua2.software_installer_id AND
ua.activity_type = ua2.activity_type AND
(ua2.priority < ua.priority OR ua2.created_at > ua.created_at)
WHERE
ua.host_id = :host_id AND
ua.activity_type = 'software_install' AND
ua2.id IS NULL
),
upcoming_software_uninstall AS (
SELECT
ua.execution_id,
ua.host_id,
ua.created_at,
siua.software_installer_id,
'pending_uninstall' as status
FROM
upcoming_activities ua
INNER JOIN software_install_upcoming_activities siua ON ua.id = siua.upcoming_activity_id
LEFT JOIN (
upcoming_activities ua2
INNER JOIN software_install_upcoming_activities siua2
ON ua2.id = siua2.upcoming_activity_id
) ON ua.host_id = ua2.host_id AND
siua.software_installer_id = siua2.software_installer_id AND
ua.activity_type = ua2.activity_type AND
(ua2.priority < ua.priority OR ua2.created_at > ua.created_at)
WHERE
ua.host_id = :host_id AND
ua.activity_type = 'software_uninstall' AND
ua2.id IS NULL
),
last_software_install AS (
SELECT
hsi.execution_id,
hsi.host_id,
hsi.created_at,
hsi.software_installer_id,
hsi.status
FROM
host_software_installs hsi
%s
LEFT JOIN host_software_installs hsi2
ON hsi.host_id = hsi2.host_id AND
hsi.software_installer_id = hsi2.software_installer_id AND
hsi.uninstall = hsi2.uninstall AND
hsi2.removed = 0 AND
hsi2.host_deleted_at IS NULL AND
(hsi.created_at < hsi2.created_at OR (hsi.created_at = hsi2.created_at AND hsi.id < hsi2.id))
WHERE
hsi.host_id = :host_id AND
hsi.removed = 0 AND
hsi.uninstall = 0 AND
hsi.host_deleted_at IS NULL AND
hsi2.id IS NULL AND
NOT EXISTS (
SELECT 1
FROM
upcoming_activities ua
INNER JOIN software_install_upcoming_activities siua ON ua.id = siua.upcoming_activity_id
WHERE
ua.host_id = hsi.host_id AND
siua.software_installer_id = hsi.software_installer_id AND
ua.activity_type = 'software_install'
)
%s
),
last_software_uninstall AS (
SELECT
hsi.execution_id,
hsi.host_id,
hsi.created_at,
hsi.software_installer_id,
hsi.status
FROM
host_software_installs hsi
%s
LEFT JOIN host_software_installs hsi2
ON hsi.host_id = hsi2.host_id AND
hsi.software_installer_id = hsi2.software_installer_id AND
hsi.uninstall = hsi2.uninstall AND
hsi2.removed = 0 AND
hsi2.host_deleted_at IS NULL AND
(hsi.created_at < hsi2.created_at OR (hsi.created_at = hsi2.created_at AND hsi.id < hsi2.id))
WHERE
hsi.host_id = :host_id AND
hsi.removed = 0 AND
hsi.uninstall = 1 AND
hsi.host_deleted_at IS NULL AND
hsi2.id IS NULL AND
NOT EXISTS (
SELECT 1
FROM
upcoming_activities ua
INNER JOIN software_install_upcoming_activities siua ON ua.id = siua.upcoming_activity_id
WHERE
ua.host_id = hsi.host_id AND
siua.software_installer_id = hsi.software_installer_id AND
ua.activity_type = 'software_uninstall'
)
%s
),
upcoming_vpp_install AS (
SELECT
ua.execution_id,
ua.host_id,
ua.created_at,
vaua.adam_id,
vaua.platform,
'pending_install' as status
FROM
upcoming_activities ua
INNER JOIN vpp_app_upcoming_activities vaua ON ua.id = vaua.upcoming_activity_id
LEFT JOIN (
upcoming_activities ua2
INNER JOIN vpp_app_upcoming_activities vaua2
ON ua2.id = vaua2.upcoming_activity_id
) ON ua.host_id = ua2.host_id AND
vaua.adam_id = vaua2.adam_id AND
vaua.platform = vaua2.platform AND
ua.activity_type = ua2.activity_type AND
(ua2.priority < ua.priority OR ua2.created_at > ua.created_at)
WHERE
ua.host_id = :host_id AND
ua.activity_type = 'vpp_app_install' AND
ua2.id IS NULL
),
last_vpp_install AS (
SELECT
hvsi.command_uuid as execution_id,
hvsi.host_id,
hvsi.created_at,
hvsi.adam_id,
hvsi.platform,
%s
FROM
host_vpp_software_installs hvsi
%s
LEFT JOIN nano_command_results ncr
ON ncr.command_uuid = hvsi.command_uuid
LEFT JOIN host_vpp_software_installs hvsi2
ON hvsi.host_id = hvsi2.host_id AND
hvsi.adam_id = hvsi2.adam_id AND
hvsi.platform = hvsi2.platform AND
hvsi2.removed = 0 AND
(hvsi.created_at < hvsi2.created_at OR (hvsi.created_at = hvsi2.created_at AND hvsi.id < hvsi2.id))
WHERE
hvsi.host_id = :host_id AND
hvsi.removed = 0 AND
hvsi2.id IS NULL AND
NOT EXISTS (
SELECT 1
FROM
upcoming_activities ua
INNER JOIN vpp_app_upcoming_activities vaua ON ua.id = vaua.upcoming_activity_id
WHERE
ua.host_id = hvsi.host_id AND
vaua.adam_id = hvsi.adam_id AND
vaua.platform = hvsi.platform AND
ua.activity_type = 'vpp_app_install'
)
%s
)
SELECT
st.id,
st.name,
st.source,
si.self_service as package_self_service,
si.filename as package_name,
si.version as package_version,
si.platform as package_platform,
vat.self_service as vpp_app_self_service,
vat.adam_id as vpp_app_adam_id,
vap.latest_version as vpp_app_version,
vap.platform as vpp_app_platform,
NULLIF(vap.icon_url, '') as vpp_app_icon_url,
COALESCE(lsia.created_at, lvia.created_at) as last_install_installed_at,
COALESCE(lsia.execution_id, lvia.execution_id) as last_install_install_uuid,
lsua.created_at as last_uninstall_uninstalled_at,
lsua.execution_id as last_uninstall_script_execution_id,
2024-09-05 19:20:36 +00:00
-- get either the software installer status or the vpp app status
2024-09-04 21:46:48 +00:00
%s as status
FROM
software_titles st
LEFT OUTER JOIN
software_installers si ON st.id = si.title_id AND si.global_or_team_id = :global_or_team_id
LEFT OUTER JOIN -- get the latest install
( SELECT * FROM upcoming_software_install UNION SELECT * FROM last_software_install ) AS lsia -- latest_software_install_attempt
ON si.id = lsia.software_installer_id
LEFT OUTER JOIN -- get the latest uninstall
( SELECT * FROM upcoming_software_uninstall UNION SELECT * FROM last_software_uninstall ) AS lsua -- latest_software_uninstall_attempt
ON si.id = lsua.software_installer_id
LEFT OUTER JOIN
vpp_apps vap ON st.id = vap.title_id AND vap.platform = :host_platform
LEFT OUTER JOIN
vpp_apps_teams vat ON vap.adam_id = vat.adam_id AND vap.platform = vat.platform AND vat.global_or_team_id = :global_or_team_id
LEFT OUTER JOIN -- get the latest vpp install
( SELECT * FROM upcoming_vpp_install UNION SELECT * FROM last_vpp_install ) AS lvia -- latest_vpp_install_attempt
ON vat.adam_id = lvia.adam_id
LEFT OUTER JOIN
host_script_results hsr ON hsr.host_id = :host_id AND hsr.execution_id = lsua.execution_id
WHERE
-- software is installed on host or software un/install has been attempted
-- on host (via installer or VPP app). If only available for install is
-- requested, then the software installed on host clause is empty.
( %s lsia.host_id IS NOT NULL OR lsua.host_id IS NOT NULL OR lvia.host_id IS NOT NULL )
AND
-- label membership check
(
CASE WHEN ((si.ID IS NOT NULL AND lsua.created_at > lsia.created_at AND hsr.exit_code = 0) OR (:avail OR :self_service)) THEN (
-- do the label membership check for software installers and VPP apps
EXISTS (
SELECT 1 FROM (
-- no labels
SELECT 0 AS count_installer_labels, 0 AS count_host_labels, 0 as count_host_updated_after_labels
WHERE NOT EXISTS (
SELECT 1 FROM software_installer_labels sil WHERE sil.software_installer_id = si.id
) AND NOT EXISTS (SELECT 1 FROM vpp_app_team_labels vatl WHERE vatl.vpp_app_team_id = vat.id)
UNION
-- include any
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
0 as count_host_updated_after_labels
FROM
software_installer_labels sil
LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id
AND lm.host_id = :host_id
WHERE
sil.software_installer_id = si.id
AND sil.exclude = 0
HAVING
count_installer_labels > 0 AND count_host_labels > 0
UNION
-- exclude any, ignore software that depends on labels created
-- _after_ the label_updated_at timestamp of the host (because
-- we don't have results for that label yet, the host may or may
-- not be a member).
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
SUM(CASE WHEN lbl.created_at IS NOT NULL AND :host_label_updated_at >= lbl.created_at THEN 1 ELSE 0 END) as count_host_updated_after_labels
FROM
software_installer_labels sil
LEFT OUTER JOIN labels lbl
ON lbl.id = sil.label_id
LEFT OUTER JOIN label_membership lm
ON lm.label_id = sil.label_id AND lm.host_id = :host_id
WHERE
sil.software_installer_id = si.id
AND sil.exclude = 1
HAVING
count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0
UNION
-- vpp include any
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
0 as count_host_updated_after_labels
FROM
vpp_app_team_labels vatl
LEFT OUTER JOIN label_membership lm ON lm.label_id = vatl.label_id
AND lm.host_id = :host_id
WHERE
vatl.vpp_app_team_id = vat.id
AND vatl.exclude = 0
HAVING
count_installer_labels > 0 AND count_host_labels > 0
UNION
-- vpp exclude any
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
SUM(CASE
WHEN lbl.created_at IS NOT NULL AND lbl.label_membership_type = 0 AND :host_label_updated_at >= lbl.created_at THEN 1
WHEN lbl.created_at IS NOT NULL AND lbl.label_membership_type = 1 THEN 1
ELSE 0 END) as count_host_updated_after_labels
FROM
vpp_app_team_labels vatl
LEFT OUTER JOIN labels lbl
ON lbl.id = vatl.label_id
LEFT OUTER JOIN label_membership lm
ON lm.label_id = vatl.label_id AND lm.host_id = :host_id
WHERE
vatl.vpp_app_team_id = vat.id
AND vatl.exclude = 1
HAVING
count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0
) t
)) ELSE true END
)
%s
`,
vulnerableLastSoftwareInstallJoins,
vulnerabilityFiltersClause,
vulnerableLastSoftwareUninstallJoins,
vulnerabilityFiltersClause,
vppAppHostStatusNamedQuery("hvsi", "ncr", "status"),
vulnerableLastVppInstall,
vulnerabilityFiltersClause,
status,
softwareIsInstalledOnHostClause,
onlySelfServiceClause,
)
// this statement lists only the software that has never been installed nor
// attempted to be installed on the host, but that is available to be
// installed on the host's platform.
// Cannot scan available software for vulnerabilities
stmtAvailable := fmt.Sprintf(`
SELECT
st.id,
st.name,
st.source,
si.self_service as package_self_service,
si.filename as package_name,
si.version as package_version,
si.platform as package_platform,
vat.self_service as vpp_app_self_service,
vat.adam_id as vpp_app_adam_id,
vap.latest_version as vpp_app_version,
vap.platform as vpp_app_platform,
NULLIF(vap.icon_url, '') as vpp_app_icon_url,
NULL as last_install_installed_at,
NULL as last_install_install_uuid,
2024-09-05 19:20:36 +00:00
NULL as last_uninstall_uninstalled_at,
NULL as last_uninstall_script_execution_id,
NULL as status
FROM
software_titles st
LEFT OUTER JOIN
-- filter out software that is not available for install on the host's platform
software_installers si ON st.id = si.title_id AND si.platform IN (:host_compatible_platforms) AND si.global_or_team_id = :global_or_team_id
LEFT OUTER JOIN
-- include VPP apps only if the host is on a supported platform
vpp_apps vap ON st.id = vap.title_id AND :host_platform IN (:vpp_apps_platforms)
LEFT OUTER JOIN
vpp_apps_teams vat ON vap.adam_id = vat.adam_id AND vap.platform = vat.platform AND vat.global_or_team_id = :global_or_team_id
WHERE
-- software is not installed on host (but is available in host's team)
NOT EXISTS (
SELECT 1
FROM
host_software hs
INNER JOIN
software s ON hs.software_id = s.id
WHERE
hs.host_id = :host_id AND
s.title_id = st.id
) AND
-- sofware install has not been attempted on host
NOT EXISTS (
SELECT 1
FROM
host_software_installs hsi
WHERE
hsi.host_id = :host_id AND
hsi.software_installer_id = si.id AND
hsi.removed = 0
) AND
-- sofware install/uninstall is not upcoming on host
NOT EXISTS (
SELECT 1
FROM
upcoming_activities ua
INNER JOIN
software_install_upcoming_activities siua ON siua.upcoming_activity_id = ua.id
WHERE
ua.host_id = :host_id AND
ua.activity_type IN ('software_install', 'software_uninstall') AND
siua.software_installer_id = si.id
) AND
-- VPP install has not been attempted on host
NOT EXISTS (
SELECT 1
FROM
host_vpp_software_installs hvsi
WHERE
hvsi.host_id = :host_id AND
hvsi.adam_id = vat.adam_id AND
hvsi.removed = 0
) AND
-- VPP install is not upcoming on host
NOT EXISTS (
SELECT 1
FROM
upcoming_activities ua
INNER JOIN
vpp_app_upcoming_activities vaua ON vaua.upcoming_activity_id = ua.id
WHERE
ua.host_id = :host_id AND
ua.activity_type = 'vpp_app_install' AND
vaua.adam_id = vat.adam_id
) AND
-- either the software installer or the vpp app exists for the host's team
( si.id IS NOT NULL OR vat.platform = :host_platform ) AND
-- label membership check
(
-- do the label membership check for software installers and VPP apps
EXISTS (
SELECT 1 FROM (
-- no labels
SELECT 0 AS count_installer_labels, 0 AS count_host_labels, 0 as count_host_updated_after_labels
WHERE NOT EXISTS (
SELECT 1 FROM software_installer_labels sil WHERE sil.software_installer_id = si.id
) AND NOT EXISTS (SELECT 1 FROM vpp_app_team_labels vatl WHERE vatl.vpp_app_team_id = vat.id)
UNION
-- include any
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
0 as count_host_updated_after_labels
FROM
software_installer_labels sil
LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id
AND lm.host_id = :host_id
WHERE
sil.software_installer_id = si.id
AND sil.exclude = 0
HAVING
count_installer_labels > 0 AND count_host_labels > 0
UNION
-- exclude any, ignore software that depends on labels created
-- _after_ the label_updated_at timestamp of the host (because
-- we don't have results for that label yet, the host may or may
-- not be a member).
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
SUM(CASE WHEN lbl.created_at IS NOT NULL AND :host_label_updated_at >= lbl.created_at THEN 1 ELSE 0 END) as count_host_updated_after_labels
FROM
software_installer_labels sil
LEFT OUTER JOIN labels lbl
ON lbl.id = sil.label_id
LEFT OUTER JOIN label_membership lm
ON lm.label_id = sil.label_id AND lm.host_id = :host_id
WHERE
sil.software_installer_id = si.id
AND sil.exclude = 1
HAVING
count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0
UNION
-- vpp include any
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
0 as count_host_updated_after_labels
FROM
vpp_app_team_labels vatl
LEFT OUTER JOIN label_membership lm ON lm.label_id = vatl.label_id
AND lm.host_id = :host_id
WHERE
vatl.vpp_app_team_id = vat.id
AND vatl.exclude = 0
HAVING
count_installer_labels > 0 AND count_host_labels > 0
UNION
-- vpp exclude any
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
SUM(CASE
WHEN lbl.created_at IS NOT NULL AND lbl.label_membership_type = 0 AND :host_label_updated_at >= lbl.created_at THEN 1
WHEN lbl.created_at IS NOT NULL AND lbl.label_membership_type = 1 THEN 1
ELSE 0 END) as count_host_updated_after_labels
FROM
vpp_app_team_labels vatl
LEFT OUTER JOIN labels lbl
ON lbl.id = vatl.label_id
LEFT OUTER JOIN label_membership lm
ON lm.label_id = vatl.label_id AND lm.host_id = :host_id
WHERE
vatl.vpp_app_team_id = vat.id
AND vatl.exclude = 1
HAVING
count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0
) t
)
)
%s %s
`, onlySelfServiceClause, excludeVPPAppsClause)
// this is the top-level SELECT of fields from the UNION of the sub-selects
// (stmtAvailable and stmtInstalled).
const selectColNames = `
SELECT
id,
name,
source,
package_self_service,
package_name,
package_version,
package_platform,
vpp_app_self_service,
vpp_app_adam_id,
vpp_app_version,
vpp_app_platform,
vpp_app_icon_url,
last_install_installed_at,
last_install_install_uuid,
2024-09-05 19:20:36 +00:00
last_uninstall_uninstalled_at,
last_uninstall_script_execution_id,
status
`
var globalOrTeamID uint
if host.TeamID != nil {
globalOrTeamID = *host.TeamID
}
namedArgs := map[string]any{
"host_id": host.ID,
"host_platform": host.FleetPlatform(),
2024-09-04 21:46:48 +00:00
"software_status_failed": fleet.SoftwareInstallFailed,
"software_status_pending": fleet.SoftwareInstallPending,
"software_status_installed": fleet.SoftwareInstalled,
"mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged,
"mdm_status_error": fleet.MDMAppleStatusError,
"mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError,
"global_or_team_id": globalOrTeamID,
"is_mdm_enrolled": opts.IsMDMEnrolled,
"host_label_updated_at": host.LabelUpdatedAt,
"avail": opts.OnlyAvailableForInstall,
"self_service": opts.SelfServiceOnly,
"min_cvss": opts.MinimumCVSS,
"max_cvss": opts.MaximumCVSS,
}
stmt := stmtInstalled
// We currently don't scan only available software for vulnerabilities
if (opts.OnlyAvailableForInstall && !opts.VulnerableOnly) || (opts.IncludeAvailableForInstall && !opts.VulnerableOnly) {
namedArgs["vpp_apps_platforms"] = fleet.VPPAppsPlatforms
if fleet.IsLinux(host.Platform) {
namedArgs["host_compatible_platforms"] = fleet.HostLinuxOSs
} else {
namedArgs["host_compatible_platforms"] = []string{host.FleetPlatform()}
}
stmt += ` UNION ` + stmtAvailable
}
// must resolve the named bindings here, before adding the searchLike which
// uses standard placeholders.
stmt, args, err := sqlx.Named(stmt, namedArgs)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "build named query for list host software")
}
stmt, args, err = sqlx.In(stmt, args...)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "expand IN query for list host software")
}
stmt = selectColNames + ` FROM ( ` + stmt + ` ) AS tbl `
if opts.ListOptions.MatchQuery != "" {
stmt += " WHERE TRUE " // searchLike adds a "AND <condition>"
stmt, args = searchLike(stmt, args, opts.ListOptions.MatchQuery, "name")
}
// build the count statement before adding pagination constraints
countStmt := fmt.Sprintf(`SELECT COUNT(DISTINCT s.id) FROM (%s) AS s`, stmt)
stmt, _ = appendListOptionsToSQL(stmt, &opts.ListOptions)
// perform a second query to grab the titleCount
var titleCount uint
if err := sqlx.GetContext(ctx, ds.reader(ctx), &titleCount, countStmt, args...); err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "get host software count")
}
type hostSoftware struct {
fleet.HostSoftwareWithInstaller
2024-09-05 19:20:36 +00:00
LastInstallInstalledAt *time.Time `db:"last_install_installed_at"`
LastInstallInstallUUID *string `db:"last_install_install_uuid"`
LastUninstallUninstalledAt *time.Time `db:"last_uninstall_uninstalled_at"`
LastUninstallScriptExecutionID *string `db:"last_uninstall_script_execution_id"`
PackageSelfService *bool `db:"package_self_service"`
PackageName *string `db:"package_name"`
PackageVersion *string `db:"package_version"`
PackagePlatform *string `db:"package_platform"`
2024-09-05 19:20:36 +00:00
VPPAppSelfService *bool `db:"vpp_app_self_service"`
VPPAppAdamID *string `db:"vpp_app_adam_id"`
VPPAppVersion *string `db:"vpp_app_version"`
VPPAppPlatform *string `db:"vpp_app_platform"`
2024-09-05 19:20:36 +00:00
VPPAppIconURL *string `db:"vpp_app_icon_url"`
}
var hostSoftwareList []*hostSoftware
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostSoftwareList, stmt, args...); err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "list host software")
}
// collect the title ids to get the versions, vulnerabilities and installed
// paths for each software in the list.
titleIDs := make([]uint, 0, len(hostSoftwareList))
byTitleID := make(map[uint]*hostSoftware, len(hostSoftwareList))
for _, hs := range hostSoftwareList {
// promote the package name and version to the proper destination fields
if hs.PackageName != nil {
var version string
if hs.PackageVersion != nil {
version = *hs.PackageVersion
}
var platform string
if hs.PackagePlatform != nil {
platform = *hs.PackagePlatform
}
hs.SoftwarePackage = &fleet.SoftwarePackageOrApp{
Name: *hs.PackageName,
Version: version,
Platform: platform,
SelfService: hs.PackageSelfService,
}
// promote the last install info to the proper destination fields
if hs.LastInstallInstallUUID != nil && *hs.LastInstallInstallUUID != "" {
hs.SoftwarePackage.LastInstall = &fleet.HostSoftwareInstall{
InstallUUID: *hs.LastInstallInstallUUID,
}
if hs.LastInstallInstalledAt != nil {
hs.SoftwarePackage.LastInstall.InstalledAt = *hs.LastInstallInstalledAt
}
}
2024-09-05 19:20:36 +00:00
// promote the last uninstall info to the proper destination fields
if hs.LastUninstallScriptExecutionID != nil && *hs.LastUninstallScriptExecutionID != "" {
hs.SoftwarePackage.LastUninstall = &fleet.HostSoftwareUninstall{
ExecutionID: *hs.LastUninstallScriptExecutionID,
}
if hs.LastUninstallUninstalledAt != nil {
hs.SoftwarePackage.LastUninstall.UninstalledAt = *hs.LastUninstallUninstalledAt
}
}
}
// promote the VPP app id and version to the proper destination fields
if hs.VPPAppAdamID != nil {
var version string
if hs.VPPAppVersion != nil {
version = *hs.VPPAppVersion
}
var platform string
if hs.VPPAppPlatform != nil {
platform = *hs.VPPAppPlatform
}
hs.AppStoreApp = &fleet.SoftwarePackageOrApp{
AppStoreID: *hs.VPPAppAdamID,
Version: version,
Platform: platform,
SelfService: hs.VPPAppSelfService,
IconURL: hs.VPPAppIconURL,
}
// promote the last install info to the proper destination fields
if hs.LastInstallInstallUUID != nil && *hs.LastInstallInstallUUID != "" {
hs.AppStoreApp.LastInstall = &fleet.HostSoftwareInstall{
CommandUUID: *hs.LastInstallInstallUUID,
}
if hs.LastInstallInstalledAt != nil {
hs.AppStoreApp.LastInstall.InstalledAt = *hs.LastInstallInstalledAt
}
}
}
titleIDs = append(titleIDs, hs.ID)
byTitleID[hs.ID] = hs
}
if len(titleIDs) > 0 {
// get the software versions installed on that host
const versionStmt = `
SELECT
st.id as software_title_id,
s.id as software_id,
s.version,
Add `team_identifier` to macOS software (#23766) Changes to add `team_identifier` signing information to macOS applications on the `/api/latest/fleet/hosts/:id/software` API endpoint. Docs: https://github.com/fleetdm/fleet/pull/23743 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [X] Added/updated tests - [X] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [X] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ X Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [X] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [X] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [X] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [X] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)). --------- Co-authored-by: Tim Lee <timlee@fleetdm.com> Co-authored-by: Ian Littman <iansltx@gmail.com>
2024-11-15 17:17:04 +00:00
s.bundle_identifier,
s.source,
hs.last_opened_at
FROM
software s
INNER JOIN
software_titles st ON s.title_id = st.id
INNER JOIN
host_software hs ON s.id = hs.software_id AND hs.host_id = ?
WHERE
st.id IN (?)
`
var installedVersions []*fleet.HostSoftwareInstalledVersion
stmt, args, err := sqlx.In(versionStmt, host.ID, titleIDs)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "building query args to list versions")
}
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &installedVersions, stmt, args...); err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "list software versions")
}
// store the installed versions with the proper software entry and collect
// the software ids.
softwareIDs := make([]uint, 0, len(installedVersions))
bySoftwareID := make(map[uint]*fleet.HostSoftwareInstalledVersion, len(hostSoftwareList))
for _, ver := range installedVersions {
hs := byTitleID[ver.SoftwareTitleID]
hs.InstalledVersions = append(hs.InstalledVersions, ver)
softwareIDs = append(softwareIDs, ver.SoftwareID)
bySoftwareID[ver.SoftwareID] = ver
}
if len(softwareIDs) > 0 {
const cveStmt = `
SELECT
sc.software_id,
sc.cve
FROM
software_cve sc
WHERE
sc.software_id IN (?)
ORDER BY
software_id, cve
`
type softwareCVE struct {
SoftwareID uint `db:"software_id"`
CVE string `db:"cve"`
}
var softwareCVEs []softwareCVE
stmt, args, err = sqlx.In(cveStmt, softwareIDs)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "building query args to list cves")
}
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &softwareCVEs, stmt, args...); err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "list software cves")
}
// store the CVEs with the proper software entry
for _, cve := range softwareCVEs {
ver := bySoftwareID[cve.SoftwareID]
ver.Vulnerabilities = append(ver.Vulnerabilities, cve.CVE)
}
const pathsStmt = `
SELECT
hsip.software_id,
Add `team_identifier` to macOS software (#23766) Changes to add `team_identifier` signing information to macOS applications on the `/api/latest/fleet/hosts/:id/software` API endpoint. Docs: https://github.com/fleetdm/fleet/pull/23743 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [X] Added/updated tests - [X] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [X] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ X Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [X] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [X] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [X] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [X] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)). --------- Co-authored-by: Tim Lee <timlee@fleetdm.com> Co-authored-by: Ian Littman <iansltx@gmail.com>
2024-11-15 17:17:04 +00:00
hsip.installed_path,
hsip.team_identifier
FROM
host_software_installed_paths hsip
WHERE
hsip.host_id = ? AND
hsip.software_id IN (?)
ORDER BY
software_id, installed_path
`
type installedPath struct {
Add `team_identifier` to macOS software (#23766) Changes to add `team_identifier` signing information to macOS applications on the `/api/latest/fleet/hosts/:id/software` API endpoint. Docs: https://github.com/fleetdm/fleet/pull/23743 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [X] Added/updated tests - [X] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [X] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ X Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [X] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [X] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [X] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [X] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)). --------- Co-authored-by: Tim Lee <timlee@fleetdm.com> Co-authored-by: Ian Littman <iansltx@gmail.com>
2024-11-15 17:17:04 +00:00
SoftwareID uint `db:"software_id"`
InstalledPath string `db:"installed_path"`
TeamIdentifier string `db:"team_identifier"`
}
var installedPaths []installedPath
stmt, args, err = sqlx.In(pathsStmt, host.ID, softwareIDs)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "building query args to list installed paths")
}
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &installedPaths, stmt, args...); err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "list software installed paths")
}
// store the installed paths with the proper software entry
for _, path := range installedPaths {
ver := bySoftwareID[path.SoftwareID]
ver.InstalledPaths = append(ver.InstalledPaths, path.InstalledPath)
Add `team_identifier` to macOS software (#23766) Changes to add `team_identifier` signing information to macOS applications on the `/api/latest/fleet/hosts/:id/software` API endpoint. Docs: https://github.com/fleetdm/fleet/pull/23743 - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [X] Added/updated tests - [X] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [X] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ X Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [X] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [X] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [X] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [X] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)). --------- Co-authored-by: Tim Lee <timlee@fleetdm.com> Co-authored-by: Ian Littman <iansltx@gmail.com>
2024-11-15 17:17:04 +00:00
if ver.Source == "apps" {
ver.SignatureInformation = append(ver.SignatureInformation, fleet.PathSignatureInformation{
InstalledPath: path.InstalledPath,
TeamIdentifier: path.TeamIdentifier,
})
}
}
}
}
perPage := opts.ListOptions.PerPage
var metaData *fleet.PaginationMetadata
if opts.ListOptions.IncludeMetadata {
if perPage <= 0 {
perPage = defaultSelectLimit
}
metaData = &fleet.PaginationMetadata{
HasPreviousResults: opts.ListOptions.Page > 0,
TotalResults: titleCount,
}
if len(hostSoftwareList) > int(perPage) { //nolint:gosec // dismiss G115
metaData.HasNextResults = true
hostSoftwareList = hostSoftwareList[:len(hostSoftwareList)-1]
}
}
software := make([]*fleet.HostSoftwareWithInstaller, 0, len(hostSoftwareList))
for _, hs := range hostSoftwareList {
hs := hs
software = append(software, &hs.HostSoftwareWithInstaller)
}
return software, metaData, nil
}
func (ds *Datastore) SetHostSoftwareInstallResult(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) error {
const stmt = `
UPDATE
host_software_installs
SET
pre_install_query_output = ?,
install_script_exit_code = ?,
install_script_output = ?,
post_install_script_exit_code = ?,
post_install_script_output = ?
WHERE
execution_id = ? AND
host_id = ?
`
truncateOutput := func(output *string) *string {
if output != nil {
output = ptr.String(truncateScriptResult(*output))
}
return output
}
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
res, err := tx.ExecContext(ctx, stmt,
truncateOutput(result.PreInstallConditionOutput),
result.InstallScriptExitCode,
truncateOutput(result.InstallScriptOutput),
result.PostInstallScriptExitCode,
truncateOutput(result.PostInstallScriptOutput),
result.InstallUUID,
result.HostID,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "update host software installation result")
}
if n, _ := res.RowsAffected(); n == 0 {
return ctxerr.Wrap(ctx, notFound("HostSoftwareInstall").WithName(result.InstallUUID), "host software installation not found")
}
if result.Status() != fleet.SoftwareInstallPending {
if _, err := ds.activateNextUpcomingActivity(ctx, tx, result.HostID, result.InstallUUID); err != nil {
return ctxerr.Wrap(ctx, err, "activate next activity")
}
}
return nil
})
return err
}
func getInstalledByFleetSoftwareTitles(ctx context.Context, qc sqlx.QueryerContext, hostID uint) ([]fleet.SoftwareTitle, error) {
// We are overloading vpp_apps_count to indicate whether installed title is a VPP app or not.
const stmt = `
SELECT
st.id,
st.name,
st.source,
st.browser,
st.bundle_identifier,
0 as vpp_apps_count
FROM software_titles st
INNER JOIN software_installers si ON si.title_id = st.id
2024-09-06 14:49:07 +00:00
INNER JOIN host_software_installs hsi ON hsi.host_id = :host_id AND hsi.software_installer_id = si.id
WHERE hsi.removed = 0 AND hsi.status = :software_status_installed
UNION
SELECT
st.id,
st.name,
st.source,
st.browser,
st.bundle_identifier,
1 as vpp_apps_count
FROM software_titles st
INNER JOIN vpp_apps vap ON vap.title_id = st.id
2024-09-06 14:49:07 +00:00
INNER JOIN host_vpp_software_installs hvsi ON hvsi.host_id = :host_id AND hvsi.adam_id = vap.adam_id AND hvsi.platform = vap.platform
INNER JOIN nano_command_results ncr ON ncr.command_uuid = hvsi.command_uuid
2024-09-06 14:49:07 +00:00
WHERE hvsi.removed = 0 AND ncr.status = :mdm_status_acknowledged
`
2024-09-06 14:49:07 +00:00
selectStmt, args, err := sqlx.Named(stmt, map[string]interface{}{
"host_id": hostID,
"software_status_installed": fleet.SoftwareInstalled,
"mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "build query to get installed software titles")
}
var titles []fleet.SoftwareTitle
2024-09-06 14:49:07 +00:00
if err := sqlx.SelectContext(ctx, qc, &titles, selectStmt, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get installed software titles")
}
return titles, nil
}
func markHostSoftwareInstallsRemoved(ctx context.Context, ex sqlx.ExtContext, hostID uint, titleIDs []uint) error {
const stmt = `
UPDATE host_software_installs hsi
INNER JOIN software_installers si ON hsi.software_installer_id = si.id
INNER JOIN software_titles st ON si.title_id = st.id
SET hsi.removed = 1
WHERE hsi.host_id = ? AND st.id IN (?)
`
stmtExpanded, args, err := sqlx.In(stmt, hostID, titleIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build query args to mark host software install removed")
}
if _, err := ex.ExecContext(ctx, stmtExpanded, args...); err != nil {
return ctxerr.Wrap(ctx, err, "mark host software install removed")
}
return nil
}
func markHostVPPSoftwareInstallsRemoved(ctx context.Context, ex sqlx.ExtContext, hostID uint, titleIDs []uint) error {
const stmt = `
UPDATE host_vpp_software_installs hvsi
INNER JOIN vpp_apps vap ON hvsi.adam_id = vap.adam_id AND hvsi.platform = vap.platform
INNER JOIN software_titles st ON vap.title_id = st.id
SET hvsi.removed = 1
WHERE hvsi.host_id = ? AND st.id IN (?)
`
stmtExpanded, args, err := sqlx.In(stmt, hostID, titleIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build query args to mark host vpp software install removed")
}
if _, err := ex.ExecContext(ctx, stmtExpanded, args...); err != nil {
return ctxerr.Wrap(ctx, err, "mark host vpp software install removed")
}
return nil
}