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" "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 := ` SELECT t.id, t.host_id, t.software_id, t.installed_path 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 } key := fmt.Sprintf("%s%s%s", r.InstalledPath, 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 { parts := strings.SplitN(key, fleet.SoftwareFieldSeparator, 2) iSPath, unqStr := parts[0], parts[1] // 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{ HostID: hostID, SoftwareID: s.ID, InstalledPath: iSPath, }) } 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 } stmt := "INSERT INTO host_software_installed_paths (host_id, software_id, installed_path) 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 { args = append(args, v.HostID, v.SoftwareID, v.InstalledPath) } 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 inserted, err := ds.insertNewInstalledHostSoftwareDB( ctx, tx, hostID, existingSoftware, incomingByChecksum, existingTitlesForNewSoftware, ) if err != nil { return err } r.Inserted = inserted 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 (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("software not found for checksum %s", hex.EncodeToString([]byte(s.Checksum))), ) } delete(newSoftware, s.Checksum) } } // Get software titles for new software, if any incomingChecksumToTitle = make(map[string]fleet.SoftwareTitle, len(newSoftware)) if len(newSoftware) > 0 { totalToProcess := len(newSoftware) const numberOfArgsPerSoftwareTitle = 4 // number of ? in each WHERE clause whereClause := strings.TrimSuffix( strings.Repeat(` ( (bundle_identifier = ?) OR (name = ? AND source = ? AND browser = ? AND bundle_identifier IS NULL) ) OR`, totalToProcess), " OR", ) stmt := fmt.Sprintf( "SELECT id, name, source, browser, COALESCE(bundle_identifier, '') as bundle_identifier FROM software_titles WHERE %s", whereClause, ) args := make([]interface{}, 0, totalToProcess*numberOfArgsPerSoftwareTitle) uniqueTitleStrToChecksum := make(map[string]string, totalToProcess) for checksum := range newSoftware { sw := incomingChecksumToSoftware[checksum] args = append(args, sw.BundleIdentifier, 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(sw.Name, sw.Source, sw.Browser)] = checksum } var existingSoftwareTitlesForNewSoftware []fleet.SoftwareTitle if err := sqlx.SelectContext(ctx, ds.reader(ctx), &existingSoftwareTitlesForNewSoftware, stmt, args...); err != nil { return nil, nil, nil, ctxerr.Wrap(ctx, err, "get existing titles") } // Map software titles to software checksums. for _, title := range existingSoftwareTitlesForNewSoftware { checksum, ok := uniqueTitleStrToChecksum[UniqueSoftwareTitleStr(title.Name, title.Source, title.Browser)] if ok { incomingChecksumToTitle[checksum] = title } } } return currentSoftware, incomingChecksumToSoftware, incomingChecksumToTitle, nil } // 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 } // Insert host_software that is in softwareChecksums map, but not in existingSoftware. // Also insert any new software titles that are needed. // 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("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) } } // 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( "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, 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 ('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("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. 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 { 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 { 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 { ds = ds. LeftJoin( goqu.I("cve_meta").As("c"), goqu.On(goqu.I("c.cve").Eq(goqu.I("scv.cve"))), ). 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) // 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( 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 } 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 } lookup := make(map[uint][]string) for _, ip := range installedPaths { lookup[ip.SoftwareID] = append(lookup[ip.SoftwareID], ip.InstalledPath) } host.Software = make([]fleet.HostSoftwareEntry, 0, len(software)) for _, s := range software { host.Software = append(host.Software, fleet.HostSoftwareEntry{ Software: s, InstalledPaths: lookup[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 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) { 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) { 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( 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 } fmt.Println(sql, args) 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 ('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.id = software_titles.id ` 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 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, '') = ''; ` 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 ('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; ` res, err = tx.ExecContext(ctx, updateSoftwareWithIdentifierStmt) if err != nil { return ctxerr.Wrap(ctx, err, "update software title_id with bundle identifier") } n, _ = res.RowsAffected() 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 } 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), cisa_known_exploit = VALUES(cisa_known_exploit), published = VALUES(published), description = VALUES(description) ` batchSize := 500 for i := 0; i < len(cveMeta); i += batchSize { end := i + batchSize if end > len(cveMeta) { end = len(cveMeta) } batch := cveMeta[i:end] valuesFrag := strings.TrimSuffix(strings.Repeat("(?, ?, ?, ?, ?, ?), ", len(batch)), ", ") var args []interface{} 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 insertOnDuplicateDidInsert(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, filters fleet.VulnSoftwareFilter, ) ([]fleet.Software, error) { var result []fleet.Software var sqlstmt string var args []interface{} baseSQL := ` SELECT s.id, s.name, s.version, s.release, s.arch, COALESCE(cpe.cpe, '') AS generated_cpe FROM software s LEFT JOIN software_cpe cpe ON s.id = cpe.software_id ` 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 } 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 } // tblAlias is the table alias to use as prefix for the host_software_installs // column names, no prefix used if empty. // colAlias is the name to be assigned to the computed status column, pass // empty to have the value only, no column alias set. func softwareInstallerHostStatusNamedQuery(tblAlias, colAlias string) string { if tblAlias != "" { tblAlias += "." } if colAlias != "" { colAlias = " AS " + colAlias } // the computed column assumes that all results (pre, install and post) are // stored at once, so that if there is an exit code for the install script // and none for the post-install, it is because there is no post-install. return fmt.Sprintf(` CASE WHEN %[1]spost_install_script_exit_code IS NOT NULL AND %[1]spost_install_script_exit_code = 0 THEN :software_status_installed WHEN %[1]spost_install_script_exit_code IS NOT NULL AND %[1]spost_install_script_exit_code != 0 THEN :software_status_failed WHEN %[1]sinstall_script_exit_code IS NOT NULL AND %[1]sinstall_script_exit_code = 0 THEN :software_status_installed WHEN %[1]sinstall_script_exit_code IS NOT NULL AND %[1]sinstall_script_exit_code != 0 THEN :software_status_failed WHEN %[1]spre_install_query_output IS NOT NULL AND %[1]spre_install_query_output = '' THEN :software_status_failed WHEN %[1]shost_id IS NOT NULL THEN :software_status_pending ELSE NULL -- not installed from Fleet installer END %[2]s `, tblAlias, colAlias) } func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { var onlySelfServiceClause string if opts.SelfServiceOnly { onlySelfServiceClause = ` AND si.self_service = 1 ` } var onlyVulnerableClause string if opts.VulnerableOnly { onlyVulnerableClause = ` AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id = s.id WHERE s.title_id = st.id) ` } var softwareIsInstalledOnHostClause string if !opts.OnlyAvailableForInstall { softwareIsInstalledOnHostClause = ` 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 ) OR ` } // this statement lists only the software that is reported as installed on // the host or has been attempted to be installed on the host. stmtInstalled := 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, -- in a future iteration, will be supported for VPP apps NULL as vpp_app_self_service, vat.adam_id as vpp_app_adam_id, vap.latest_version as vpp_app_version, NULLIF(vap.icon_url, '') as vpp_app_icon_url, COALESCE(hsi.created_at, hvsi.created_at) as last_install_installed_at, COALESCE(hsi.execution_id, hvsi.command_uuid) as last_install_install_uuid, -- get either the softare installer status or the vpp app status COALESCE(%s, %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 host_software_installs hsi ON si.id = hsi.software_installer_id AND hsi.host_id = :host_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 host_vpp_software_installs hvsi ON vat.adam_id = hvsi.adam_id AND hvsi.host_id = :host_id LEFT OUTER JOIN nano_command_results ncr ON ncr.command_uuid = hvsi.command_uuid WHERE -- use the latest install attempt only ( hsi.id IS NULL OR hsi.id = ( SELECT hsi2.id FROM host_software_installs hsi2 WHERE hsi2.host_id = hsi.host_id AND hsi2.software_installer_id = hsi.software_installer_id ORDER BY hsi2.created_at DESC LIMIT 1 ) ) AND ( hvsi.id IS NULL OR hvsi.id = ( SELECT hvsi2.id FROM host_vpp_software_installs hvsi2 WHERE hvsi2.host_id = hvsi.host_id AND hvsi2.adam_id = hvsi.adam_id AND hvsi2.platform = hvsi.platform ORDER BY hvsi2.created_at DESC LIMIT 1 ) ) AND -- software is installed on host or software 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 hsi.host_id IS NOT NULL OR hvsi.host_id IS NOT NULL ) %s %s `, softwareInstallerHostStatusNamedQuery("hsi", ""), vppAppHostStatusNamedQuery("hvsi", "ncr", ""), softwareIsInstalledOnHostClause, onlySelfServiceClause, onlyVulnerableClause) // 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. 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, -- in a future iteration, will be supported for VPP apps NULL as vpp_app_self_service, vap.adam_id as vpp_app_adam_id, vap.latest_version as vpp_app_version, NULLIF(vap.icon_url, '') as vpp_app_icon_url, NULL as last_install_installed_at, NULL as last_install_install_uuid, 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 NOT EXISTS ( SELECT 1 FROM host_vpp_software_installs hvsi WHERE hvsi.host_id = :host_id AND hvsi.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 ) %s `, onlySelfServiceClause) // 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, vpp_app_self_service, vpp_app_adam_id, vpp_app_version, vpp_app_icon_url, last_install_installed_at, last_install_install_uuid, status ` var globalOrTeamID uint if host.TeamID != nil { globalOrTeamID = *host.TeamID } namedArgs := map[string]any{ "host_id": host.ID, "host_platform": host.FleetPlatform(), "software_status_failed": fleet.SoftwareInstallerFailed, "software_status_pending": fleet.SoftwareInstallerPending, "software_status_installed": fleet.SoftwareInstallerInstalled, "mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged, "mdm_status_error": fleet.MDMAppleStatusError, "mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError, "global_or_team_id": globalOrTeamID, } stmt := stmtInstalled if opts.OnlyAvailableForInstall || (opts.IncludeAvailableForInstall && !opts.VulnerableOnly) { namedArgs["vpp_apps_platforms"] = []fleet.AppleDevicePlatform{fleet.IOSPlatform, fleet.IPadOSPlatform, fleet.MacOSPlatform} 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 " 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 LastInstallInstalledAt *time.Time `db:"last_install_installed_at"` LastInstallInstallUUID *string `db:"last_install_install_uuid"` PackageSelfService *bool `db:"package_self_service"` PackageName *string `db:"package_name"` PackageVersion *string `db:"package_version"` VPPAppSelfService *bool `db:"vpp_app_self_service"` VPPAppAdamID *string `db:"vpp_app_adam_id"` VPPAppVersion *string `db:"vpp_app_version"` 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 } hs.SoftwarePackage = &fleet.SoftwarePackageOrApp{ Name: *hs.PackageName, Version: version, 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 } } } // 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 } hs.AppStoreApp = &fleet.SoftwarePackageOrApp{ AppStoreID: *hs.VPPAppAdamID, Version: version, 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, 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, hsip.installed_path FROM host_software_installed_paths hsip WHERE hsip.host_id = ? AND hsip.software_id IN (?) ORDER BY software_id, installed_path ` type installedPath struct { SoftwareID uint `db:"software_id"` InstalledPath string `db:"installed_path"` } 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) } } } 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) { 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 } res, err := ds.writer(ctx).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") } return nil }