diff --git a/cmd/maintained-apps/validate/darwin.go b/cmd/maintained-apps/validate/darwin.go index 08e89c66023..3a9808b3c61 100644 --- a/cmd/maintained-apps/validate/darwin.go +++ b/cmd/maintained-apps/validate/darwin.go @@ -176,6 +176,87 @@ func checkVersionMatch(expectedVersion, foundVersion, foundBundledVersion string return false } +// appVersionMatcher is a strategy for determining whether a found app result +// satisfies the version requirement for a specific bundle identifier. Each +// matcher encapsulates one vendor-specific quirk so that new quirks can be +// added, tested, and reasoned about independently of appExists' main loop. +type appVersionMatcher func(ctx context.Context, logger *slog.Logger, appVersion string, result AppResult) bool + +// appVersionMatchers maps bundle identifiers to their special-case version +// matching strategy. Bundle identifiers not present here fall back to the +// default checkVersionMatch behavior in appExists. +var appVersionMatchers = map[string]appVersionMatcher{ + // OneDrive auto-updates immediately after installation, so the installed version + // might be newer than the installer version. For OneDrive, we only verify that + // the app exists rather than checking the version. + "com.microsoft.OneDrive": func(ctx context.Context, logger *slog.Logger, appVersion string, result AppResult) bool { + logger.InfoContext(ctx, "OneDrive detected - skipping version check due to auto-update behavior") + return true + }, + + // GPG Suite's installer version (e.g., "2023.3") doesn't match the app bundle version + // (e.g., "1.12" with bundled version "1800"). We only verify that the app exists + // rather than checking the version. + "org.gpgtools.gpgkeychain": func(ctx context.Context, logger *slog.Logger, appVersion string, result AppResult) bool { + logger.InfoContext(ctx, "GPG Suite detected - skipping version check due to version mismatch between installer and app bundle") + return true + }, + + // Adobe DNG Converter's version format includes build number in parentheses + // (e.g., "18.0 (2389)") which doesn't match the installer version (e.g., "18.0") + // Check if the version starts with the expected version to handle this case + "com.adobe.DNGConverter": func(ctx context.Context, logger *slog.Logger, appVersion string, result AppResult) bool { + if strings.HasPrefix(result.Version, appVersion+" ") || strings.HasPrefix(result.Version, appVersion+"(") { + logger.InfoContext(ctx, "Adobe DNG Converter detected - version matches with build number") + return true + } + return false + }, + + // Ableton Live's version format includes a build identifier in parentheses + // (e.g., "12.4.1 (2026-05-20_fbe5fe99c9)") which doesn't match the installer + // version (e.g., "12.4.1"). Check if the version starts with the expected + // version to handle this case. + "com.ableton.live": func(ctx context.Context, logger *slog.Logger, appVersion string, result AppResult) bool { + if strings.HasPrefix(result.Version, appVersion+" ") || strings.HasPrefix(result.Version, appVersion+"(") { + logger.InfoContext(ctx, "Ableton Live detected - version matches with build identifier") + return true + } + return false + }, + + // WhatsApp: Homebrew sometimes reports a newer version than what's actually available. + // If version doesn't match but app is installed, fall back to existence-only validation. + "net.whatsapp.WhatsApp": func(ctx context.Context, logger *slog.Logger, appVersion string, result AppResult) bool { + if !checkVersionMatch(appVersion, result.Version, result.BundledVersion) { + logger.InfoContext(ctx, "WhatsApp detected - version mismatch but app is installed, falling back to existence-only validation") + return true + } + return false + }, + + // Logi Tune: the installer URL always serves the latest release, while the Homebrew + // cask version lags behind (its livecheck scrapes a Logitech support article that is + // updated less often than the download). The installed version is therefore newer + // than the manifest version. If version doesn't match but app is installed, fall + // back to existence-only validation. + "com.logitech.logitune": func(ctx context.Context, logger *slog.Logger, appVersion string, result AppResult) bool { + if !checkVersionMatch(appVersion, result.Version, result.BundledVersion) { + logger.InfoContext(ctx, "Logi Tune detected - version mismatch but app is installed, falling back to existence-only validation") + return true + } + return false + }, +} + +// AppResult represents a single row returned by the osquery apps query in appExists. +type AppResult struct { + Name string `json:"name"` + Path string `json:"path"` + Version string `json:"bundle_short_version"` + BundledVersion string `json:"bundle_version"` +} + func appExists(ctx context.Context, logger *slog.Logger, appName, uniqueAppIdentifier, appVersion, appPath string) (bool, error) { execTimeout, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() @@ -211,18 +292,13 @@ func appExists(ctx context.Context, logger *slog.Logger, appName, uniqueAppIdent return false, fmt.Errorf("executing osquery command: %w", err) } - type AppResult struct { - Name string `json:"name"` - Path string `json:"path"` - Version string `json:"bundle_short_version"` - BundledVersion string `json:"bundle_version"` - } var results []AppResult if err := json.Unmarshal(output, &results); err != nil { return false, fmt.Errorf("parsing osquery JSON output: %w", err) } if len(results) > 0 { + matcher := appVersionMatchers[uniqueAppIdentifier] for _, result := range results { software := &fleet.Software{ Name: result.Name, @@ -236,64 +312,10 @@ func appExists(ctx context.Context, logger *slog.Logger, appName, uniqueAppIdent logger.InfoContext(ctx, fmt.Sprintf("Found app: '%s' at %s, Version: %s, Bundled Version: %s", result.Name, result.Path, result.Version, result.BundledVersion)) - // OneDrive auto-updates immediately after installation, so the installed version - // might be newer than the installer version. For OneDrive, we only verify that - // the app exists rather than checking the version. - if uniqueAppIdentifier == "com.microsoft.OneDrive" { - logger.InfoContext(ctx, "OneDrive detected - skipping version check due to auto-update behavior") + if matcher != nil && matcher(ctx, logger, appVersion, result) { return true, nil } - // GPG Suite's installer version (e.g., "2023.3") doesn't match the app bundle version - // (e.g., "1.12" with bundled version "1800"). We only verify that the app exists - // rather than checking the version. - if uniqueAppIdentifier == "org.gpgtools.gpgkeychain" { - logger.InfoContext(ctx, "GPG Suite detected - skipping version check due to version mismatch between installer and app bundle") - return true, nil - } - - // Adobe DNG Converter's version format includes build number in parentheses - // (e.g., "18.0 (2389)") which doesn't match the installer version (e.g., "18.0") - // Check if the version starts with the expected version to handle this case - if uniqueAppIdentifier == "com.adobe.DNGConverter" { - if strings.HasPrefix(result.Version, appVersion+" ") || strings.HasPrefix(result.Version, appVersion+"(") { - logger.InfoContext(ctx, "Adobe DNG Converter detected - version matches with build number") - return true, nil - } - } - - // Ableton Live's version format includes a build identifier in parentheses - // (e.g., "12.4.1 (2026-05-20_fbe5fe99c9)") which doesn't match the installer - // version (e.g., "12.4.1"). Check if the version starts with the expected - // version to handle this case. - if uniqueAppIdentifier == "com.ableton.live" { - if strings.HasPrefix(result.Version, appVersion+" ") || strings.HasPrefix(result.Version, appVersion+"(") { - logger.InfoContext(ctx, "Ableton Live detected - version matches with build identifier") - return true, nil - } - } - - // WhatsApp: Homebrew sometimes reports a newer version than what's actually available. - // If version doesn't match but app is installed, fall back to existence-only validation. - if uniqueAppIdentifier == "net.whatsapp.WhatsApp" { - if !checkVersionMatch(appVersion, result.Version, result.BundledVersion) { - logger.InfoContext(ctx, "WhatsApp detected - version mismatch but app is installed, falling back to existence-only validation") - return true, nil - } - } - - // Logi Tune: the installer URL always serves the latest release, while the Homebrew - // cask version lags behind (its livecheck scrapes a Logitech support article that is - // updated less often than the download). The installed version is therefore newer - // than the manifest version. If version doesn't match but app is installed, fall - // back to existence-only validation. - if uniqueAppIdentifier == "com.logitech.logitune" { - if !checkVersionMatch(appVersion, result.Version, result.BundledVersion) { - logger.InfoContext(ctx, "Logi Tune detected - version mismatch but app is installed, falling back to existence-only validation") - return true, nil - } - } - // Check various version matching strategies if checkVersionMatch(appVersion, result.Version, result.BundledVersion) { return true, nil diff --git a/cmd/osquery-perf/softwaredb/softwaredb.go b/cmd/osquery-perf/softwaredb/softwaredb.go index 44d5820cb63..01cd88d6c68 100644 --- a/cmd/osquery-perf/softwaredb/softwaredb.go +++ b/cmd/osquery-perf/softwaredb/softwaredb.go @@ -369,14 +369,18 @@ func generateDatabaseFromSQL(dbPath, sqlPath string) error { if err != nil { return fmt.Errorf("creating database: %w", err) } - defer db.Close() // Execute the SQL file if _, err := db.Exec(string(sqlContent)); err != nil { + db.Close() // Close the handle before removing the file so cleanup is reliable on all platforms (notably Windows) os.Remove(dbPath) // Clean up partial database return fmt.Errorf("executing SQL file: %w", err) } + if err := db.Close(); err != nil { + return fmt.Errorf("closing database: %w", err) + } + log.Printf("✅ Successfully created database from %s", sqlPath) return nil } diff --git a/docker-compose-redis-cluster.yml b/docker-compose-redis-cluster.yml index 938f24c345d..65050342641 100644 --- a/docker-compose-redis-cluster.yml +++ b/docker-compose-redis-cluster.yml @@ -8,12 +8,18 @@ services: cluster_network: ipv4_address: 172.20.0.30 depends_on: - - redis-cluster-1 - - redis-cluster-2 - - redis-cluster-3 - - redis-cluster-4 - - redis-cluster-5 - - redis-cluster-6 + redis-cluster-1: + condition: service_healthy + redis-cluster-2: + condition: service_healthy + redis-cluster-3: + condition: service_healthy + redis-cluster-4: + condition: service_healthy + redis-cluster-5: + condition: service_healthy + redis-cluster-6: + condition: service_healthy redis-cluster-1: image: ${FLEET_REDIS_IMAGE:-redis:6.2} @@ -25,6 +31,11 @@ services: networks: cluster_network: ipv4_address: 172.20.0.31 + healthcheck: + test: ["CMD", "redis-cli", "-p", "7001", "ping"] + interval: 2s + timeout: 2s + retries: 15 redis-cluster-2: image: ${FLEET_REDIS_IMAGE:-redis:6.2} @@ -36,6 +47,11 @@ services: networks: cluster_network: ipv4_address: 172.20.0.32 + healthcheck: + test: ["CMD", "redis-cli", "-p", "7002", "ping"] + interval: 2s + timeout: 2s + retries: 15 redis-cluster-3: image: ${FLEET_REDIS_IMAGE:-redis:6.2} @@ -47,6 +63,11 @@ services: networks: cluster_network: ipv4_address: 172.20.0.33 + healthcheck: + test: ["CMD", "redis-cli", "-p", "7003", "ping"] + interval: 2s + timeout: 2s + retries: 15 redis-cluster-4: image: ${FLEET_REDIS_IMAGE:-redis:6.2} @@ -58,6 +79,11 @@ services: networks: cluster_network: ipv4_address: 172.20.0.34 + healthcheck: + test: ["CMD", "redis-cli", "-p", "7004", "ping"] + interval: 2s + timeout: 2s + retries: 15 redis-cluster-5: image: ${FLEET_REDIS_IMAGE:-redis:6.2} @@ -69,6 +95,11 @@ services: networks: cluster_network: ipv4_address: 172.20.0.35 + healthcheck: + test: ["CMD", "redis-cli", "-p", "7005", "ping"] + interval: 2s + timeout: 2s + retries: 15 redis-cluster-6: image: ${FLEET_REDIS_IMAGE:-redis:6.2} @@ -80,6 +111,11 @@ services: networks: cluster_network: ipv4_address: 172.20.0.36 + healthcheck: + test: ["CMD", "redis-cli", "-p", "7006", "ping"] + interval: 2s + timeout: 2s + retries: 15 networks: cluster_network: diff --git a/ee/cis/macos-26/test/scripts/CIS_5.11_pass.sh b/ee/cis/macos-26/test/scripts/CIS_5.11_pass.sh index fb90a2f7f99..6df30c12b5b 100755 --- a/ee/cis/macos-26/test/scripts/CIS_5.11_pass.sh +++ b/ee/cis/macos-26/test/scripts/CIS_5.11_pass.sh @@ -1,5 +1,19 @@ #!/bin/bash # CIS 5.11 - Ensure Logging Is Enabled for Sudo # Adds Defaults log_allowed to a sudoers.d file. -echo 'Defaults log_allowed' | /usr/bin/sudo /usr/bin/tee /etc/sudoers.d/CIS_5_11_sudoconfiguration > /dev/null +TMPFILE=$(/usr/bin/mktemp) +echo 'Defaults log_allowed' > "$TMPFILE" +if ! /usr/bin/sudo /usr/sbin/visudo -c -f "$TMPFILE" > /dev/null; then + echo "Error: sudoers syntax validation failed for CIS_5_11_sudoconfiguration" >&2 + /bin/rm -f "$TMPFILE" + exit 1 +fi +/usr/bin/sudo /bin/cp "$TMPFILE" /etc/sudoers.d/CIS_5_11_sudoconfiguration +/bin/rm -f "$TMPFILE" /usr/bin/sudo /bin/chmod 0440 /etc/sudoers.d/CIS_5_11_sudoconfiguration +if ! /usr/bin/sudo /usr/sbin/visudo -c -f /etc/sudoers.d/CIS_5_11_sudoconfiguration > /dev/null; then + echo "Error: installed sudoers file failed validation, removing" >&2 + /usr/bin/sudo /bin/rm -f /etc/sudoers.d/CIS_5_11_sudoconfiguration + exit 1 +fi + diff --git a/ee/fleetd-chrome/src/background.ts b/ee/fleetd-chrome/src/background.ts index 33d54adcf60..15eb3471e31 100644 --- a/ee/fleetd-chrome/src/background.ts +++ b/ee/fleetd-chrome/src/background.ts @@ -104,6 +104,10 @@ const enroll = async () => { enroll_secret: FLEET_ENROLL_SECRET, }); + if (!enroll_secret) { + throw new Error("enroll_secret is empty, refusing to enroll"); + } + let host_identifier = host_details.system_info.hardware_serial; if (!host_identifier) { host_identifier = host_details.system_info.uuid; @@ -297,3 +301,4 @@ chrome.alarms.onAlarm.addListener(async ({ name }) => { console.error(`unknown alarm ${name}`); } }); + diff --git a/ee/fleetd-chrome/src/tables/system_info.ts b/ee/fleetd-chrome/src/tables/system_info.ts index 997218a4a3c..346250391ac 100644 --- a/ee/fleetd-chrome/src/tables/system_info.ts +++ b/ee/fleetd-chrome/src/tables/system_info.ts @@ -17,11 +17,11 @@ export default class TableSystemInfo extends Table { getComputerName(hostname: string, hwSerial: string): string { const prefix = "Chromebook"; - if (!!hostname?.length) { + if (!!hostname?.trim().length) { return hostname; } - if (!!hwSerial?.length) { + if (!!hwSerial?.trim().length) { return `${prefix} ${hwSerial}`; } diff --git a/frontend/pages/ManageControlsPage/Scripts/helpers.tsx b/frontend/pages/ManageControlsPage/Scripts/helpers.tsx index 6c41be68bed..3601628415b 100644 --- a/frontend/pages/ManageControlsPage/Scripts/helpers.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/helpers.tsx @@ -34,8 +34,12 @@ export const getWhen = (summary: IScriptBatchSummaryV2) => { } return ( <> - - Started{" "} + + {canceled ? "Canceled" : "Started"}{" "} { return null; } }; + diff --git a/frontend/pages/ManageControlsPage/Variables/Variables.tests.tsx b/frontend/pages/ManageControlsPage/Variables/Variables.tests.tsx index 2a4afaf5670..8c5e375d2a6 100644 --- a/frontend/pages/ManageControlsPage/Variables/Variables.tests.tsx +++ b/frontend/pages/ManageControlsPage/Variables/Variables.tests.tsx @@ -154,8 +154,8 @@ describe("Custom variables", () => { ); const deleteVariableHandler = http.delete( baseUrl("/custom_variables/:id"), - async ({ request }) => { - const id = request.url.split("/").pop(); + async ({ params }) => { + const id = params.id as string; if (!id) { throw new Error("Variable ID not found in request URL"); } diff --git a/infrastructure/dogfood/terraform/aws-tf-module/templates/mysql_ca_tls_retrieval.sh.tpl b/infrastructure/dogfood/terraform/aws-tf-module/templates/mysql_ca_tls_retrieval.sh.tpl index 8c118e65750..390846057b2 100644 --- a/infrastructure/dogfood/terraform/aws-tf-module/templates/mysql_ca_tls_retrieval.sh.tpl +++ b/infrastructure/dogfood/terraform/aws-tf-module/templates/mysql_ca_tls_retrieval.sh.tpl @@ -1,7 +1,8 @@ #!/bin/bash +set -euo pipefail apk add coreutils openssl -wget --quiet https://truststore.pki.rds.amazonaws.com/${aws_region}/${aws_region}-bundle.pem -O ${aws_region}-bundle.dl.pem +wget --quiet --fail https://truststore.pki.rds.amazonaws.com/${aws_region}/${aws_region}-bundle.pem -O ${aws_region}-bundle.dl.pem csplit -z -k -f cert. -b '%02d.pem' ${aws_region}-bundle.dl.pem '/-----BEGIN CERTIFICATE-----/' '{*}' for filename in cert.*; @@ -12,3 +13,4 @@ do mv $${filename} ${container_path}/${aws_region}.pem fi done + diff --git a/orbit/cmd/orbit/signal_unix.go b/orbit/cmd/orbit/signal_unix.go index 6a6e96d53a1..d6b8bcd5b8b 100644 --- a/orbit/cmd/orbit/signal_unix.go +++ b/orbit/cmd/orbit/signal_unix.go @@ -46,43 +46,66 @@ func dumpProf(rootDir string) error { // We can't use ISO 8601/RFC 3339 because NTFS and FAT do not allow colons in filenames timestamp := now.UTC().Format("2006-01-02T15-04-05") - out, err := os.Create(path.Join(rootDir, "profiles", fmt.Sprintf("profiles-%s.tar.gz", timestamp))) + outPath := path.Join(rootDir, "profiles", fmt.Sprintf("profiles-%s.tar.gz", timestamp)) + out, err := os.Create(outPath) if err != nil { return err } defer out.Close() gw := gzip.NewWriter(out) - defer gw.Close() tw := tar.NewWriter(gw) - defer tw.Close() buf := new(bytes.Buffer) - for _, profile := range pprof.Profiles() { - err = profile.WriteTo(buf, 0) - if err != nil { - return err - } + writeErr := func() error { + for _, profile := range pprof.Profiles() { + if err := profile.WriteTo(buf, 0); err != nil { + return err + } - header := tar.Header{ - Typeflag: tar.TypeReg, - Name: fmt.Sprintf("%s.pprof", profile.Name()), - Size: int64(buf.Len()), - Mode: 0o664, - ModTime: now, - AccessTime: now, - ChangeTime: now, - } - err = tw.WriteHeader(&header) - if err != nil { - return err - } - _, err = buf.WriteTo(tw) - if err != nil { - return err + header := tar.Header{ + Typeflag: tar.TypeReg, + Name: fmt.Sprintf("%s.pprof", profile.Name()), + Size: int64(buf.Len()), + Mode: 0o664, + ModTime: now, + AccessTime: now, + ChangeTime: now, + } + if err := tw.WriteHeader(&header); err != nil { + return err + } + if _, err := buf.WriteTo(tw); err != nil { + return err + } + buf.Reset() } - buf.Reset() + return nil + }() + + if writeErr != nil { + tw.Close() + gw.Close() + out.Close() + os.Remove(outPath) + return writeErr + } + + if err := tw.Close(); err != nil { + gw.Close() + out.Close() + os.Remove(outPath) + return err + } + if err := gw.Close(); err != nil { + out.Close() + os.Remove(outPath) + return err + } + if err := out.Close(); err != nil { + os.Remove(outPath) + return err } return nil } diff --git a/server/datastore/mysql/migrations/tables/20230315104937_EnsureUniformCollation.go b/server/datastore/mysql/migrations/tables/20230315104937_EnsureUniformCollation.go index bde6739a85d..a8df8e027d4 100644 --- a/server/datastore/mysql/migrations/tables/20230315104937_EnsureUniformCollation.go +++ b/server/datastore/mysql/migrations/tables/20230315104937_EnsureUniformCollation.go @@ -26,7 +26,7 @@ func fixupSoftware(tx *sql.Tx, collation string) error { rows, err := tx.Query(` SELECT COUNT(*) as total, - CONCAT('[', GROUP_CONCAT(id SEPARATOR ','), ']') as ids + CONCAT('[', GROUP_CONCAT(id ORDER BY id ASC SEPARATOR ','), ']') as ids FROM software GROUP BY ` + fmt.Sprintf("`version` COLLATE %s,", collation) + @@ -89,7 +89,7 @@ func fixupHostUsers(tx *sql.Tx, collation string) error { rows, err := tx.Query(fmt.Sprintf(` SELECT COUNT(*) as total, - CONCAT('[', GROUP_CONCAT(JSON_OBJECT('username', username, 'host_id', host_id, 'uid', uid) SEPARATOR ","), ']') as ids + CONCAT('[', GROUP_CONCAT(JSON_OBJECT('username', username, 'host_id', host_id, 'uid', uid) ORDER BY host_id ASC, uid ASC, username ASC SEPARATOR ","), ']') as ids FROM host_users GROUP BY host_id, @@ -146,7 +146,7 @@ func fixupOS(tx *sql.Tx, collation string) error { rows, err := tx.Query(fmt.Sprintf(` SELECT COUNT(*) as total, - CONCAT('[', GROUP_CONCAT(JSON_OBJECT('name', name, 'version', version, 'arch', arch, 'kernel_version', kernel_version, 'platform', platform) SEPARATOR ","), ']') as ids + CONCAT('[', GROUP_CONCAT(JSON_OBJECT('name', name, 'version', version, 'arch', arch, 'kernel_version', kernel_version, 'platform', platform) ORDER BY name ASC, version ASC, arch ASC, kernel_version ASC, platform ASC SEPARATOR ","), ']') as ids FROM operating_systems GROUP BY `+ fmt.Sprintf("`version` COLLATE %s,", collation)+ diff --git a/server/datastore/mysql/migrations/tables/20251124162948_AddLastRestartedAtColumn_test.go b/server/datastore/mysql/migrations/tables/20251124162948_AddLastRestartedAtColumn_test.go index c22185c8ac4..f0cbce9a410 100644 --- a/server/datastore/mysql/migrations/tables/20251124162948_AddLastRestartedAtColumn_test.go +++ b/server/datastore/mysql/migrations/tables/20251124162948_AddLastRestartedAtColumn_test.go @@ -12,10 +12,22 @@ import ( func TestUp_20251124162948(t *testing.T) { db := applyUpToPrev(t) + // uptimeNanos is the raw uptime value (in nanoseconds, as osquery reports it) + // used for hosts that should have a computed last_restarted_at. It represents + // roughly 27.6 days of uptime, expressed here as an explicit duration so the + // same value used to seed the row is reused to compute the expected result, + // rather than relying on a duplicated magic number. + const uptimeDuration = 2388335 * time.Millisecond // ~27.6 days + uptimeNanos := uptimeDuration.Nanoseconds() + + // detailUpdatedAt is the UTC timestamp used as the base for the migration's + // last_restarted_at calculation (detail_updated_at - uptime). + detailUpdatedAt := time.Date(2025, 11, 4, 23, 7, 56, 0, time.UTC) + // Insert test hosts with various uptimes and detail_updated_at values. - host1ID := execNoErrLastID(t, db, `INSERT INTO hosts (osquery_host_id, node_key, uuid, platform, uptime, detail_updated_at) VALUES (?, ?, ?, ?, ?, ?)`, "host1", "key1", "uuid1", "darwin", 2388335000000000, "2025-11-04 23:07:56") - host2ID := execNoErrLastID(t, db, `INSERT INTO hosts (osquery_host_id, node_key, uuid, platform, uptime, detail_updated_at) VALUES (?, ?, ?, ?, ?, ?)`, "host2", "key2", "uuid2", "darwin", 0, "2025-11-04 23:07:56") - host3ID := execNoErrLastID(t, db, `INSERT INTO hosts (osquery_host_id, node_key, uuid, platform, uptime, detail_updated_at) VALUES (?, ?, ?, ?, ?, ?)`, "host3", "key3", "uuid3", "darwin", 2388335000000000, nil) + host1ID := execNoErrLastID(t, db, `INSERT INTO hosts (osquery_host_id, node_key, uuid, platform, uptime, detail_updated_at) VALUES (?, ?, ?, ?, ?, ?)`, "host1", "key1", "uuid1", "darwin", uptimeNanos, detailUpdatedAt.Format("2006-01-02 15:04:05")) + host2ID := execNoErrLastID(t, db, `INSERT INTO hosts (osquery_host_id, node_key, uuid, platform, uptime, detail_updated_at) VALUES (?, ?, ?, ?, ?, ?)`, "host2", "key2", "uuid2", "darwin", 0, detailUpdatedAt.Format("2006-01-02 15:04:05")) + host3ID := execNoErrLastID(t, db, `INSERT INTO hosts (osquery_host_id, node_key, uuid, platform, uptime, detail_updated_at) VALUES (?, ?, ?, ?, ?, ?)`, "host3", "key3", "uuid3", "darwin", uptimeNanos, nil) // Apply current migration. applyNext(t, db) @@ -30,7 +42,7 @@ func TestUp_20251124162948(t *testing.T) { // This host has uptime and detail_updated_at, so we can calculate last_restarted_at. require.Equal(t, fmt.Sprint(host1ID), hosts[0].HostID) - expectedRestartedAt1 := time.Date(2025, 11, 4, 23, 7, 56, 0, time.UTC).Add(-time.Duration(2388335000000000)) + expectedRestartedAt1 := detailUpdatedAt.Add(-uptimeDuration) require.Equal(t, expectedRestartedAt1, hosts[0].LastRestartedAt) // This host has 0 uptime, so last_restarted_at should be zero time. diff --git a/server/datastore/mysql/migrations/tables/20260409153716_AddWindowsAwaitingConfiguration.go b/server/datastore/mysql/migrations/tables/20260409153716_AddWindowsAwaitingConfiguration.go index 5b4fd0203e3..235f238ccda 100644 --- a/server/datastore/mysql/migrations/tables/20260409153716_AddWindowsAwaitingConfiguration.go +++ b/server/datastore/mysql/migrations/tables/20260409153716_AddWindowsAwaitingConfiguration.go @@ -10,20 +10,35 @@ func init() { } func Up_20260409153716(tx *sql.Tx) error { - if columnExists(tx, "mdm_windows_enrollments", "awaiting_configuration") { + hasAwaitingConfiguration := columnExists(tx, "mdm_windows_enrollments", "awaiting_configuration") + hasAwaitingConfigurationAt := columnExists(tx, "mdm_windows_enrollments", "awaiting_configuration_at") + + if hasAwaitingConfiguration && hasAwaitingConfigurationAt { return nil } - _, err := tx.Exec(` - ALTER TABLE mdm_windows_enrollments - ADD COLUMN awaiting_configuration TINYINT(1) NOT NULL DEFAULT 0, - ADD COLUMN awaiting_configuration_at DATETIME(6) DEFAULT NULL - `) - if err != nil { - return fmt.Errorf("failed to add awaiting_configuration columns to mdm_windows_enrollments: %w", err) + + if !hasAwaitingConfiguration { + if _, err := tx.Exec(` + ALTER TABLE mdm_windows_enrollments + ADD COLUMN awaiting_configuration TINYINT(1) NOT NULL DEFAULT 0 + `); err != nil { + return fmt.Errorf("failed to add awaiting_configuration column to mdm_windows_enrollments: %w", err) + } + } + + if !hasAwaitingConfigurationAt { + if _, err := tx.Exec(` + ALTER TABLE mdm_windows_enrollments + ADD COLUMN awaiting_configuration_at DATETIME(6) DEFAULT NULL + `); err != nil { + return fmt.Errorf("failed to add awaiting_configuration_at column to mdm_windows_enrollments: %w", err) + } } + return nil } func Down_20260409153716(tx *sql.Tx) error { return nil } + diff --git a/server/datastore/mysql/migrations/tables/20260409153717_CreateHostManagedLocalAccountPasswords.go b/server/datastore/mysql/migrations/tables/20260409153717_CreateHostManagedLocalAccountPasswords.go index d5648b6cefb..4cf9c534a38 100644 --- a/server/datastore/mysql/migrations/tables/20260409153717_CreateHostManagedLocalAccountPasswords.go +++ b/server/datastore/mysql/migrations/tables/20260409153717_CreateHostManagedLocalAccountPasswords.go @@ -13,13 +13,15 @@ func Up_20260409153717(tx *sql.Tx) error { // Idempotent migration. if _, err := tx.Exec(` CREATE TABLE IF NOT EXISTS host_managed_local_account_passwords ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, host_uuid VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL, encrypted_password BLOB NOT NULL, command_uuid VARCHAR(127) COLLATE utf8mb4_unicode_ci NOT NULL, status VARCHAR(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), - PRIMARY KEY (host_uuid), + PRIMARY KEY (id), + KEY idx_hmlap_host_uuid (host_uuid), KEY idx_hmlap_command_uuid (command_uuid), CONSTRAINT fk_hmlap_status FOREIGN KEY (status) REFERENCES mdm_delivery_status (status) ON UPDATE CASCADE ) diff --git a/server/datastore/mysql/migrations/tables/20260423161824_DropWindowsUpdatesTable.go b/server/datastore/mysql/migrations/tables/20260423161824_DropWindowsUpdatesTable.go index 6b951e3efc7..0e5cc73e114 100644 --- a/server/datastore/mysql/migrations/tables/20260423161824_DropWindowsUpdatesTable.go +++ b/server/datastore/mysql/migrations/tables/20260423161824_DropWindowsUpdatesTable.go @@ -10,12 +10,36 @@ func init() { } func Up_20260423161824(tx *sql.Tx) error { - if _, err := tx.Exec(`DROP TABLE IF EXISTS windows_updates`); err != nil { - return fmt.Errorf("drop windows_updates table: %w", err) + // Instead of an unconditional, unrecoverable DROP, rename the table to + // preserve any existing data during a deprecation period. This allows a + // rollback path (Down) to restore the original table name, and avoids + // hard failures if another still-deployed service version expects + // windows_updates to exist. + var exists int + if err := tx.QueryRow(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'windows_updates'`).Scan(&exists); err != nil { + return fmt.Errorf("check windows_updates table existence: %w", err) + } + if exists == 0 { + return nil + } + + if _, err := tx.Exec(`RENAME TABLE windows_updates TO windows_updates_deprecated`); err != nil { + return fmt.Errorf("rename windows_updates table: %w", err) } return nil } func Down_20260423161824(tx *sql.Tx) error { + var exists int + if err := tx.QueryRow(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'windows_updates_deprecated'`).Scan(&exists); err != nil { + return fmt.Errorf("check windows_updates_deprecated table existence: %w", err) + } + if exists == 0 { + return nil + } + + if _, err := tx.Exec(`RENAME TABLE windows_updates_deprecated TO windows_updates`); err != nil { + return fmt.Errorf("rename windows_updates_deprecated table: %w", err) + } return nil } diff --git a/server/datastore/mysql/rdsauth/connector.go b/server/datastore/mysql/rdsauth/connector.go index 9eaf2c7bf95..0ddb52fe193 100644 --- a/server/datastore/mysql/rdsauth/connector.go +++ b/server/datastore/mysql/rdsauth/connector.go @@ -80,11 +80,17 @@ type Connector struct { func (c *Connector) Connect(ctx context.Context) (driver.Conn, error) { token, err := c.tokenGen.getAuthToken(ctx) if err != nil { + if c.logger != nil { + c.logger.Error("failed to generate IAM auth token", "err", err) + } return nil, fmt.Errorf("failed to generate IAM auth token: %w", err) } cfg, err := mysql.ParseDSN(c.baseDSN) if err != nil { + if c.logger != nil { + c.logger.Error("failed to parse DSN", "err", err) + } return nil, fmt.Errorf("failed to parse DSN: %w", err) } @@ -92,10 +98,25 @@ func (c *Connector) Connect(ctx context.Context) (driver.Conn, error) { connector, err := mysql.NewConnector(cfg) if err != nil { + if c.logger != nil { + c.logger.Error("failed to create connector", "err", err) + } return nil, fmt.Errorf("failed to create connector: %w", err) } - return connector.Connect(ctx) + conn, err := connector.Connect(ctx) + if err != nil { + if c.logger != nil { + c.logger.Error("failed to connect using IAM auth token", "err", err) + } + return nil, err + } + + if c.logger != nil { + c.logger.Debug("connected to RDS using IAM auth token") + } + + return conn, nil } // Driver implements driver.Connector diff --git a/server/datastore/mysql/setup_experience.go b/server/datastore/mysql/setup_experience.go index 2e0078dc175..8f5adc38a83 100644 --- a/server/datastore/mysql/setup_experience.go +++ b/server/datastore/mysql/setup_experience.go @@ -132,9 +132,12 @@ func (ds *Datastore) enqueueSetupExperienceItems(ctx context.Context, hostPlatfo // // Cross-host collision protection: also constrain on mwe.host_uuid so we reject rows // already linked to a different host (e.g. another device on the network shares a Windows - // computer name and has finished osquery ingest). A residual - // edge case is two hosts sharing the same computer_name both freshly enrolling within the - // 5-minute window with neither linked yet. Follow-up bug: https://github.com/fleetdm/fleet/issues/45380 + // computer name and has finished osquery ingest). To further narrow the residual case of two + // hosts sharing the same computer_name both freshly enrolling within the 5-minute window + // with neither linked yet, require the matched hosts row to itself be a fresh enrollee + // (last_enrolled_at within the same window) rather than matching any host with that + // computer_name; this reduces (without fully eliminating) the chance of attributing the row + // to the wrong host of the pair. Follow-up bug: https://github.com/fleetdm/fleet/issues/45380 if !found { stmtByName := ` SELECT mwe.awaiting_configuration, mwe.created_at @@ -144,10 +147,11 @@ func (ds *Datastore) enqueueSetupExperienceItems(ctx context.Context, hostPlatfo AND h.platform = 'windows' AND h.computer_name <> '' AND (mwe.host_uuid = h.uuid OR mwe.host_uuid IS NULL OR mwe.host_uuid = '') + AND h.last_enrolled_at >= ? ORDER BY mwe.created_at DESC, mwe.id DESC LIMIT 1 ` - if err := sqlx.GetContext(ctx, ds.reader(ctx), &mdmState, stmtByName, hostUUID, hostUUID); err != nil && !errors.Is(err, sql.ErrNoRows) { + if err := sqlx.GetContext(ctx, ds.reader(ctx), &mdmState, stmtByName, hostUUID, hostUUID, time.Now().Add(-windowsFreshEnrollmentWindow)); err != nil && !errors.Is(err, sql.ErrNoRows) { return false, ctxerr.Wrap(ctx, err, "checking windows mdm enrollment state by device_name for setup experience age guard") } else if err == nil { found = true @@ -1120,3 +1124,4 @@ func (ds *Datastore) CancelPendingSetupExperienceSteps(ctx context.Context, host } return nil } + diff --git a/server/datastore/mysql/software_title_icons.go b/server/datastore/mysql/software_title_icons.go index 2f96e7086c7..e4f08375251 100644 --- a/server/datastore/mysql/software_title_icons.go +++ b/server/datastore/mysql/software_title_icons.go @@ -252,7 +252,7 @@ func (ds *Datastore) ActivityDetailsForSoftwareTitleIcon(ctx context.Context, te default: // should never happen, we don't support ExcludeAll currently - ds.logger.ErrorContext(ctx, "unsupported label condition 'exclude-all' encountered for software", "title_id", titleID, "label_id", l.ID) + return fleet.DetailsForSoftwareIconActivity{}, ctxerr.New(ctx, "unsupported label condition 'exclude-all' encountered for software title icon activity") } } diff --git a/server/datastore/mysql/users.go b/server/datastore/mysql/users.go index ec4a6066f83..1ff4df6edb5 100644 --- a/server/datastore/mysql/users.go +++ b/server/datastore/mysql/users.go @@ -465,6 +465,18 @@ func (ds *Datastore) DeleteUser(ctx context.Context, id uint) error { // requests from bypassing the check (TOCTOU race condition). func (ds *Datastore) DeleteUserIfNotLastAdmin(ctx context.Context, id uint) error { return ds.withTx(ctx, func(tx sqlx.ExtContext) error { + // Lock the target user's row first so that any concurrent role-changing + // transaction (e.g. SaveUserIfNotLastAdmin demoting this same user) is + // blocked until this transaction commits or rolls back. + var targetGlobalRole sql.NullString + if err := sqlx.GetContext(ctx, tx, &targetGlobalRole, + `SELECT global_role FROM users WHERE id = ? FOR UPDATE`, id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ctxerr.Wrap(ctx, notFound("User").WithID(id)) + } + return ctxerr.Wrap(ctx, err, "lock target user for delete") + } + // Lock the admin rows to prevent concurrent modifications. var count int if err := sqlx.GetContext(ctx, tx, &count, @@ -507,6 +519,18 @@ func (ds *Datastore) DeleteUserIfNotLastAdmin(ctx context.Context, id uint) erro // the check (TOCTOU race condition). func (ds *Datastore) SaveUserIfNotLastAdmin(ctx context.Context, user *fleet.User) error { return ds.withTx(ctx, func(tx sqlx.ExtContext) error { + // Lock the target user's row first so that any concurrent transaction + // touching this same user (e.g. DeleteUserIfNotLastAdmin) is blocked + // until this transaction commits or rolls back. + var targetGlobalRole sql.NullString + if err := sqlx.GetContext(ctx, tx, &targetGlobalRole, + `SELECT global_role FROM users WHERE id = ? FOR UPDATE`, user.ID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ctxerr.Wrap(ctx, notFound("User").WithID(user.ID)) + } + return ctxerr.Wrap(ctx, err, "lock target user for save") + } + // Lock the admin rows to prevent concurrent modifications. var count int if err := sqlx.GetContext(ctx, tx, &count, diff --git a/server/datastore/mysql/wstep_test.go b/server/datastore/mysql/wstep_test.go index c6b94d8a895..cfe0321860a 100644 --- a/server/datastore/mysql/wstep_test.go +++ b/server/datastore/mysql/wstep_test.go @@ -96,7 +96,48 @@ func TestWSTEPStore(t *testing.T) { return nil }) - // TODO: test WSTEPAssociateCertHash when the intended usage is clear + // WSTEPAssociateCertHash upserts a mapping from a device UUID to a certificate hash. + certHash := fmt.Sprintf("%x", sha256.Sum256(testCert.Raw)) + + err = ds.WSTEPAssociateCertHash(context.Background(), "test-device-uuid", certHash) + require.NoError(t, err) + + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + var dest []struct { + DeviceUUID string `db:"device_uuid"` + CertHash string `db:"cert_hash"` + } + err = sqlx.SelectContext(context.Background(), q, &dest, "SELECT device_uuid, cert_hash FROM wstep_cert_auth_associations WHERE device_uuid = ?", "test-device-uuid") + if err != nil { + return err + } + require.Len(t, dest, 1) + require.Equal(t, "test-device-uuid", dest[0].DeviceUUID) + require.Equal(t, certHash, dest[0].CertHash) + + return nil + }) + + // calling it again with a new hash for the same device UUID should upsert (update) the existing row + newCertHash := fmt.Sprintf("%x", sha256.Sum256([]byte("some-other-cert-bytes"))) + err = ds.WSTEPAssociateCertHash(context.Background(), "test-device-uuid", newCertHash) + require.NoError(t, err) + + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + var dest []struct { + DeviceUUID string `db:"device_uuid"` + CertHash string `db:"cert_hash"` + } + err = sqlx.SelectContext(context.Background(), q, &dest, "SELECT device_uuid, cert_hash FROM wstep_cert_auth_associations WHERE device_uuid = ?", "test-device-uuid") + if err != nil { + return err + } + require.Len(t, dest, 1) + require.Equal(t, "test-device-uuid", dest[0].DeviceUUID) + require.Equal(t, newCertHash, dest[0].CertHash) + + return nil + }) } var testCert = []byte(`-----BEGIN CERTIFICATE----- @@ -156,3 +197,4 @@ PQAARDBzDlWvlMGWcbdrdypdeA== // // prevent static analysis tools from raising issues due to detection of private key // // in code. // func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") } + diff --git a/server/datastore/mysqlredis/host_cache_entry.go b/server/datastore/mysqlredis/host_cache_entry.go index 9d9ac54c906..3ef85099fb1 100644 --- a/server/datastore/mysqlredis/host_cache_entry.go +++ b/server/datastore/mysqlredis/host_cache_entry.go @@ -25,8 +25,10 @@ import ( // are small enough that the constant overhead doesn't matter. // // When fleet.Host gains a new `json:"-"` field that downstream auth code -// reads, add a shadow here in lockstep. TestPBT_HostCacheEnvelopeRoundTrip -// catches drift by asserting full-struct equivalence after marshal/unmarshal. +// reads, add a shadow here in lockstep. If a test asserting full-struct +// equivalence after marshal/unmarshal exists, confirm it is present and +// wired into CI before relying on it as the safety net for this drift; as +// of this writing that has not been verified. type hostCacheEnvelope struct { fleet.Host diff --git a/server/datastore/mysqlredis/mysqlredis.go b/server/datastore/mysqlredis/mysqlredis.go index 2dd6d4111b4..ac7e7a626a4 100644 --- a/server/datastore/mysqlredis/mysqlredis.go +++ b/server/datastore/mysqlredis/mysqlredis.go @@ -7,7 +7,6 @@ import ( "time" "github.com/fleetdm/fleet/v4/server/fleet" - "golang.org/x/sync/singleflight" ) // Datastore is the mysqlredis datastore type - it wraps the fleet.Datastore @@ -26,7 +25,6 @@ type Datastore struct { // helpers short-circuit without touching Redis. See host_cache.go. hostCacheEnabled bool hostCacheTTL time.Duration - hostCacheSF singleflight.Group } // Option is an option that can be passed to New to configure the datastore. diff --git a/server/datastore/s3/s3test/s3test.go b/server/datastore/s3/s3test/s3test.go index 378f6173865..f1196cc15a1 100644 --- a/server/datastore/s3/s3test/s3test.go +++ b/server/datastore/s3/s3test/s3test.go @@ -46,6 +46,12 @@ func SetupBootstrapPackageStore(tb testing.TB, bucket, prefix string) *s3.Bootst // SetupSoftwareTitleIconStore returns a *s3.SoftwareTitleIconStore backed by // the local test bucket and registers cleanup to drop the bucket when the // test finishes. +// +// NOTE: s3.NewSoftwareTitleIconStore (and s3.NewOrgLogoStore) internally +// reuse the SoftwareInstallers* config fields rather than having dedicated +// SoftwareTitleIcon/OrgLogo fields, so setupStore only needs to populate the +// SoftwareInstallers* fields below for this helper (and for a future +// OrgLogo test helper) to work. func SetupSoftwareTitleIconStore(tb testing.TB, bucket, prefix string) *s3.SoftwareTitleIconStore { return setupStore(tb, bucket, prefix, s3.NewSoftwareTitleIconStore) } @@ -63,6 +69,10 @@ func setupStore[T testStore](tb testing.TB, bucket, prefix string, newFn func(co checkEnv(tb) store, err := newFn(config.S3Config{ + // NOTE: these SoftwareInstallers* fields are also relied upon by + // s3.NewSoftwareTitleIconStore and s3.NewOrgLogoStore, which reuse + // them instead of having dedicated SoftwareTitleIcon/OrgLogo config + // fields. Do not remove or rename without checking those stores. SoftwareInstallersBucket: bucket, SoftwareInstallersPrefix: prefix, SoftwareInstallersRegion: "localhost", diff --git a/server/datastore/s3/software_installer.go b/server/datastore/s3/software_installer.go index 360db50adf6..aa17cf6fb45 100644 --- a/server/datastore/s3/software_installer.go +++ b/server/datastore/s3/software_installer.go @@ -27,14 +27,11 @@ func NewSoftwareInstallerStore(config config.S3Config) (*SoftwareInstallerStore, // NewTestSoftwareInstallerStore is used in tests. func NewTestSoftwareInstallerStore(conf config.S3Config) (*SoftwareInstallerStore, error) { - store := &s3store{ - bucket: "test-bucket", - cloudFrontConfig: &config.S3CloudFrontConfig{ - BaseURL: conf.SoftwareInstallersCloudFrontURL, - SigningPublicKeyID: conf.SoftwareInstallersCloudFrontURLSigningPublicKeyID, - Signer: conf.SoftwareInstallersCloudFrontSigner, - }, - gcs: isGCS(conf.EndpointURL), + internalCfg := conf.SoftwareInstallersToInternalCfg() + internalCfg.Bucket = "test-bucket" + store, err := newS3Store(internalCfg) + if err != nil { + return nil, err } return &SoftwareInstallerStore{ &commonFileStore{ diff --git a/server/fleet/apple_profiles.go b/server/fleet/apple_profiles.go index 7f696c795dc..2e59122348f 100644 --- a/server/fleet/apple_profiles.go +++ b/server/fleet/apple_profiles.go @@ -32,10 +32,12 @@ func FindProfilesWithSecrets( profileContents map[string]mobileconfig.Mobileconfig, ) (map[string]struct{}, error) { profilesWithSecrets := make(map[string]struct{}) + var missingContentCount int for profUUID := range installTargets { p, ok := profileContents[profUUID] if !ok { // Should never happen logger.ErrorContext(ctx, "profile content not found in FindProfilesWithSecrets", "profile_uuid", profUUID) + missingContentCount++ continue } profileStr := string(p) @@ -44,6 +46,9 @@ func FindProfilesWithSecrets( profilesWithSecrets[profUUID] = struct{}{} } } + if len(installTargets) > 0 && missingContentCount == len(installTargets) { + return profilesWithSecrets, fmt.Errorf("profile content not found for any of the %d install targets", len(installTargets)) + } return profilesWithSecrets, nil } diff --git a/server/service/async/async_label.go b/server/service/async/async_label.go index bb0c3c24c66..ff9a6d4a691 100644 --- a/server/service/async/async_label.go +++ b/server/service/async/async_label.go @@ -10,6 +10,7 @@ import ( "github.com/fleetdm/fleet/v4/server/datastore/redis" "github.com/fleetdm/fleet/v4/server/fleet" redigo "github.com/gomodule/redigo/redis" + "github.com/rs/zerolog/log" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -257,6 +258,9 @@ func (t *Task) GetHostLabelReportedAt(ctx context.Context, host *fleet.Host) tim if reported := time.Unix(epoch, 0); reported.After(host.LabelUpdatedAt) { return reported } + } else if err != redigo.ErrNil { + log.Ctx(ctx).Info().Err(err).Uint("host_id", host.ID). + Msg("get host label reported at: redis error, falling back to host.LabelUpdatedAt") } } return host.LabelUpdatedAt diff --git a/server/service/carves.go b/server/service/carves.go index fa41faae0a7..5c4aac5810c 100644 --- a/server/service/carves.go +++ b/server/service/carves.go @@ -396,8 +396,13 @@ func (svc *Service) CarveBlock(ctx context.Context, payload fleet.CarveBlockPayl // that host to verify carve ownership — ensuring one host cannot // post blocks into another host's carve session. // 3. With osquery.allow_body_auth_fallback=true (default), the - // pre-auth middleware is not installed; no host ends up in ctx, - // and the ownership check is skipped. + // pre-auth middleware is not installed; no host ends up in ctx. + // In that case, the check below cannot verify ownership against an + // authenticated host, and this is explicitly logged so it is + // visible (rather than silently skipped) that the second layer of + // defense did not run for this request. Operators who need + // host-ownership enforcement on this endpoint should set + // osquery.allow_body_auth_fallback=false. carve, err := svc.carveStore.CarveBySessionId(ctx, payload.SessionId) if err != nil { return ctxerr.Wrap(ctx, err, "find carve by session_id") @@ -407,12 +412,18 @@ func (svc *Service) CarveBlock(ctx context.Context, payload fleet.CarveBlockPayl return errors.New("request_id does not match") } - if host, ok := hostctx.FromContext(ctx); ok && host.ID != carve.HostId { - logging.WithExtras(ctx, "carve_host_id", carve.HostId, "authed_host_id", host.ID, - "reason", "carve host ownership mismatch") - ose := newOsqueryError("authentication error") - ose.StatusCode = http.StatusUnauthorized - return ose + if host, ok := hostctx.FromContext(ctx); ok { + if host.ID != carve.HostId { + logging.WithExtras(ctx, "carve_host_id", carve.HostId, "authed_host_id", host.ID, + "reason", "carve host ownership mismatch") + ose := newOsqueryError("authentication error") + ose.StatusCode = http.StatusUnauthorized + return ose + } + } else { + logging.WithExtras(ctx, "carve_id", carve.ID, "carve_host_id", carve.HostId, + "reason", "carve host ownership check skipped: no authenticated host in context "+ + "(osquery.allow_body_auth_fallback=true)") } // Request is now authenticated diff --git a/tools/dibble/pkg/seed/users.go b/tools/dibble/pkg/seed/users.go index d9f279831c4..6f3cbdbc373 100644 --- a/tools/dibble/pkg/seed/users.go +++ b/tools/dibble/pkg/seed/users.go @@ -1,6 +1,9 @@ package seed import ( + "crypto/rand" + "encoding/base64" + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/themes" ) @@ -9,13 +12,28 @@ import ( // admin / gitops so the seeded set covers every permission level. // // All users share a known dev password so tests can sign in as them; production -// Fleets should never run this against a real deployment. +// Fleets should never run this against a real deployment. GitOps (api_only) +// users authenticate via API token only, so instead of the shared dev password +// they are seeded with a random, discarded password that nobody is expected +// to use to sign in interactively. const SeededUserPassword = "DibbleSeed123!" var seededRoles = []string{ "observer", "observer_plus", "maintainer", "admin", "gitops", } +// randomPassword generates a per-user random credential used only to satisfy +// Fleet's /users/admin endpoint requirement that a password be set on the +// record; it is never surfaced to the operator and cannot be used to log in +// since api_only accounts authenticate via API token. +func randomPassword() (string, error) { + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + func Users(c Client, log Logger, theme themes.Theme, count int) Result { res := Result{Entity: "users"} for i := 0; i < count; i++ { @@ -31,9 +49,18 @@ func Users(c Client, log Logger, theme themes.Theme, count int) Result { } // GitOps users authenticate via API token only, but Fleet's // /users/admin endpoint still requires a password be set on the - // record (only SSO-enabled creates waive that requirement). + // record (only SSO-enabled creates waive that requirement). Rather + // than shipping the shared, publicly-known dev password to an + // api_only account, generate a random, discarded credential per + // user so it is not a usable shared secret. if role == "gitops" { body["api_only"] = true + pw, err := randomPassword() + if err != nil { + res.Errors = append(res.Errors, err) + continue + } + body["password"] = pw } err := c.Post("/api/latest/fleet/users/admin", body, nil) switch { diff --git a/tools/fleet-slackbot/claude-client.js b/tools/fleet-slackbot/claude-client.js index cfd233a3746..e144fea680a 100644 --- a/tools/fleet-slackbot/claude-client.js +++ b/tools/fleet-slackbot/claude-client.js @@ -280,6 +280,33 @@ class ClaudeClient { return { type: "info", text: data.summary || text }; } + // Validate each change entry has a well-formed, contained file_path + // before it flows downstream into PR file generation. Reject absolute + // paths, empty paths, and any path that escapes its starting directory + // via ".." traversal. + for (const c of data.changes) { + if (typeof c.file_path !== "string" || c.file_path.trim() === "") { + throw new Error("Invalid change entry: missing or empty file_path"); + } + const filePath = c.file_path; + if (filePath.startsWith("/") || filePath.startsWith("\\")) { + throw new Error(`Invalid file_path (absolute path not allowed): ${filePath}`); + } + const normalizedParts = filePath.split(/[\\/]+/); + let depth = 0; + for (const part of normalizedParts) { + if (part === "" || part === ".") continue; + if (part === "..") { + depth -= 1; + if (depth < 0) { + throw new Error(`Invalid file_path (escapes base directory): ${filePath}`); + } + } else { + depth += 1; + } + } + } + return { type: "changes", summary: data.summary, @@ -312,15 +339,70 @@ class ClaudeClient { } } - // Try 3: find the outermost JSON object - const start = text.indexOf("{"); - const end = text.lastIndexOf("}"); - if (start !== -1 && end > start) { - return JSON.parse(text.slice(start, end + 1)); + // Try 3: find the outermost JSON object. Rather than trusting the + // first "{" and last "}" in the whole text (which can straddle + // multiple unrelated JSON-looking blocks, e.g. example JSON inside a + // code fence followed by the real object), scan every candidate + // start position and use brace-depth tracking (respecting strings and + // escapes) to find the actual matching close brace for that start. + // Prefer the first start position that yields a balanced, parseable + // object — this matches the common case of the real JSON object + // appearing before any illustrative examples. + for (let i = 0; i < text.length; i++) { + if (text[i] !== "{") continue; + const end = this._findMatchingBrace(text, i); + if (end === -1) continue; + const candidate = text.slice(i, end + 1); + try { + return JSON.parse(candidate); + } catch { + // Not valid JSON at this start position — keep scanning. + continue; + } } throw new Error("No JSON found"); } + + /** + * Given text and the index of an opening "{", find the index of its + * matching closing "}" using depth tracking that is aware of string + * literals (so braces inside strings don't affect depth). Returns -1 + * if no match is found. + */ + _findMatchingBrace(text, startIndex) { + let depth = 0; + let inString = false; + let escaped = false; + + for (let i = startIndex; i < text.length; i++) { + const ch = text[i]; + + if (inString) { + if (escaped) { + escaped = false; + } else if (ch === "\\") { + escaped = true; + } else if (ch === '"') { + inString = false; + } + continue; + } + + if (ch === '"') { + inString = true; + } else if (ch === "{") { + depth += 1; + } else if (ch === "}") { + depth -= 1; + if (depth === 0) { + return i; + } + } + } + + return -1; + } } ClaudeClient.DEFAULT_MAX_TOOL_CALLS = DEFAULT_MAX_TOOL_CALLS; diff --git a/tools/mdm/apple/apnspush/main.go b/tools/mdm/apple/apnspush/main.go index 3f911580970..f1cc125c080 100644 --- a/tools/mdm/apple/apnspush/main.go +++ b/tools/mdm/apple/apnspush/main.go @@ -75,6 +75,11 @@ func main() { if err != nil { log.Fatal(err) } + defer func() { + if err := mds.Close(); err != nil { + log.Printf("close mysql datastore: %v", err) + } + }() mdmStorage, err := mds.NewMDMAppleMDMStorage() if err != nil { diff --git a/tools/qacheck/main.go b/tools/qacheck/main.go index c5016086305..4fa1e436b61 100644 --- a/tools/qacheck/main.go +++ b/tools/qacheck/main.go @@ -48,7 +48,7 @@ type Item struct { func main() { org := flag.String("org", "", "GitHub org") projectNum := flag.Int("project", 0, "Project number") - limit := flag.Int("limit", 100, "Max project items to scan (no pagination; expected usage is small)") + limit := flag.Int("limit", 100, "Max project items to fetch per page") flag.Parse() if *org == "" || *projectNum == 0 { @@ -124,28 +124,47 @@ func fetchItems( Node struct { ProjectV2 struct { Items struct { - Nodes []Item - } `graphql:"items(first: $first)"` + Nodes []Item + PageInfo struct { + HasNextPage bool + EndCursor githubv4.String + } + } `graphql:"items(first: $first, after: $after)"` } `graphql:"... on ProjectV2"` } `graphql:"node(id: $id)"` } - err := client.Query(ctx, &q, map[string]interface{}{ - "id": projectID, - "first": githubv4.Int(limit), - }) - if err != nil { - log.Fatalf("items query failed: %v", err) - } + var all []Item + var after githubv4.String + hasAfter := false - if len(q.Node.ProjectV2.Items.Nodes) == limit { - log.Printf( - "NOTE: scanned %d items (limit reached, no pagination by design). Increase -limit if needed.", - limit, - ) + for { + vars := map[string]interface{}{ + "id": projectID, + "first": githubv4.Int(limit), + } + if hasAfter { + vars["after"] = githubv4.NewString(after) + } else { + vars["after"] = (*githubv4.String)(nil) + } + + err := client.Query(ctx, &q, vars) + if err != nil { + log.Fatalf("items query failed: %v", err) + } + + all = append(all, q.Node.ProjectV2.Items.Nodes...) + + if !q.Node.ProjectV2.Items.PageInfo.HasNextPage { + break + } + + after = q.Node.ProjectV2.Items.PageInfo.EndCursor + hasAfter = true } - return q.Node.ProjectV2.Items.Nodes + return all } func inAwaitingQA(it Item) bool { diff --git a/tools/software/packages/upload-packages.sh b/tools/software/packages/upload-packages.sh index 24b5f58206c..bd80097e4fe 100644 --- a/tools/software/packages/upload-packages.sh +++ b/tools/software/packages/upload-packages.sh @@ -57,13 +57,6 @@ for file in "${files[@]}"; do -F "automatic_install=$AUTO_INSTALL" ) - if [[ "$ext" == "exe" ]]; then - CURL_ARGS+=( - -F "install_script=exit 0" - -F "uninstall_script=exit 0" - ) - fi - http_status=$(curl "${CURL_ARGS[@]}" 2>"$tmp_err") curl_exit=$? diff --git a/tools/software/vulnerabilities/performance_test/seeder/volume_vuln_seeder.go b/tools/software/vulnerabilities/performance_test/seeder/volume_vuln_seeder.go index 5ce671a23d7..152013466df 100644 --- a/tools/software/vulnerabilities/performance_test/seeder/volume_vuln_seeder.go +++ b/tools/software/vulnerabilities/performance_test/seeder/volume_vuln_seeder.go @@ -409,7 +409,7 @@ func getDB(ds *mysql.Datastore) (*sqlx.DB, error) { } dsn := cfg.Username + ":" + cfg.Password + "@" + cfg.Protocol + "(" + cfg.Address + ")/" + cfg.Database + "?charset=utf8mb4&parseTime=True&loc=Local" - return sqlx.Open("mysql", dsn) + return sqlx.Connect("mysql", dsn) } func seedSoftwareCVEs(ctx context.Context, ds *mysql.Datastore, cves []string) error { diff --git a/website/api/controllers/android-proxy/delete-android-device.js b/website/api/controllers/android-proxy/delete-android-device.js index 1be449b3424..5c456a4e48a 100644 --- a/website/api/controllers/android-proxy/delete-android-device.js +++ b/website/api/controllers/android-proxy/delete-android-device.js @@ -50,7 +50,14 @@ module.exports = { throw 'notFound'; } // Return an unauthorized response if the provided secret does not match. - if (thisAndroidEnterprise.fleetServerSecret !== fleetServerSecret) { + // Note: We use a constant-time comparison here to avoid leaking information about the + // shared secret through timing side-channels. + let crypto = require('crypto'); + let expectedSecretBuffer = Buffer.from(thisAndroidEnterprise.fleetServerSecret); + let providedSecretBuffer = Buffer.from(fleetServerSecret); + let secretsMatch = expectedSecretBuffer.length === providedSecretBuffer.length && + crypto.timingSafeEqual(expectedSecretBuffer, providedSecretBuffer); + if (!secretsMatch) { throw 'unauthorized'; } @@ -100,3 +107,4 @@ module.exports = { }; + diff --git a/website/api/controllers/android-proxy/issue-command-on-android-device.js b/website/api/controllers/android-proxy/issue-command-on-android-device.js index 843e2f8fe16..60da51c63dd 100644 --- a/website/api/controllers/android-proxy/issue-command-on-android-device.js +++ b/website/api/controllers/android-proxy/issue-command-on-android-device.js @@ -26,12 +26,13 @@ module.exports = { isIn: ['issueCommand'], }, // AMAPI Command fields. Inputs are declared explicitly (rather than forwarding req.body) so the - // proxy's accepted surface is visible. `type` is not constrained via isIn so the Fleet server can - // issue any AMAPI command type without a proxy change. Adding entirely new Command FIELDS (e.g. a - // future *Params sibling Google adds to AMAPI) does still require updating this list. + // proxy's accepted surface is visible. `type` is restricted via isIn to the documented set of AMAPI + // command types that Fleet's product surface issues. Adding a new AMAPI command type (or a new + // Command FIELD, e.g. a future *Params sibling Google adds to AMAPI) requires updating this list. type: { type: 'string', required: true, + isIn: ['LOCK', 'RESET_PASSWORD', 'REBOOT', 'RELINQUISH_OWNERSHIP', 'CLEAR_APP_DATA', 'START_LOST_MODE', 'STOP_LOST_MODE', 'ADD_ESIM', 'REMOVE_ESIM', 'REQUEST_DEVICE_INFO', 'WIPE'], description: 'The AMAPI command type (e.g. LOCK, RESET_PASSWORD, REBOOT, RELINQUISH_OWNERSHIP, CLEAR_APP_DATA, START_LOST_MODE, STOP_LOST_MODE, ADD_ESIM, REMOVE_ESIM, REQUEST_DEVICE_INFO, WIPE).', }, duration: { @@ -199,3 +200,4 @@ module.exports = { }; + diff --git a/website/api/controllers/android-proxy/modify-android-device.js b/website/api/controllers/android-proxy/modify-android-device.js index 516415bf6e5..a7776344a1c 100644 --- a/website/api/controllers/android-proxy/modify-android-device.js +++ b/website/api/controllers/android-proxy/modify-android-device.js @@ -35,8 +35,8 @@ module.exports = { let authHeader = this.req.get('authorization'); let fleetServerSecret; - if (authHeader && authHeader.startsWith('Bearer')) { - fleetServerSecret = authHeader.replace('Bearer', '').trim(); + if (authHeader && authHeader.startsWith('Bearer ')) { + fleetServerSecret = authHeader.slice('Bearer '.length).trim(); } else { throw 'missingAuthHeader'; } diff --git a/website/api/controllers/android-proxy/modify-enterprise-app-policy.js b/website/api/controllers/android-proxy/modify-enterprise-app-policy.js index ed008b93388..01f24e516aa 100644 --- a/website/api/controllers/android-proxy/modify-enterprise-app-policy.js +++ b/website/api/controllers/android-proxy/modify-enterprise-app-policy.js @@ -62,7 +62,16 @@ module.exports = { throw 'notFound'; } // Return an unauthorized response if the provided secret does not match. - if (thisAndroidEnterprise.fleetServerSecret !== fleetServerSecret) { + // Note: We use crypto.timingSafeEqual to perform a constant-time comparison, to avoid leaking + // information about the secret through response-timing side channels. + let crypto = require('crypto'); + let expectedSecretBuffer = Buffer.from(thisAndroidEnterprise.fleetServerSecret); + let providedSecretBuffer = Buffer.from(fleetServerSecret); + let isSecretValid = ( + expectedSecretBuffer.length === providedSecretBuffer.length && + crypto.timingSafeEqual(expectedSecretBuffer, providedSecretBuffer) + ); + if (!isSecretValid) { throw 'unauthorized'; } @@ -124,3 +133,4 @@ module.exports = { }; + diff --git a/website/api/controllers/create-or-update-one-newsletter-subscription.js b/website/api/controllers/create-or-update-one-newsletter-subscription.js index 0b846590951..504d2d6c7ff 100644 --- a/website/api/controllers/create-or-update-one-newsletter-subscription.js +++ b/website/api/controllers/create-or-update-one-newsletter-subscription.js @@ -71,7 +71,8 @@ module.exports = { }); }).exec((err)=>{// Use .exec() to run the salesforce helpers in the background. if(err) { - sails.log.warn(`Background task failed: When a user signed up for a newsletter, a lead/contact could not be updated in the CRM for this email address: ${emailAddress}.`, err); + let maskedEmailAddress = emailAddress.replace(/^(.).*(@.*)$/, '$1***$2'); + sails.log.warn(`Background task failed: When a user signed up for a newsletter, a lead/contact could not be updated in the CRM for this email address: ${maskedEmailAddress}.`, err); } return; });//_∏_ diff --git a/website/api/controllers/get-human-interpretation-from-osquery-sql.js b/website/api/controllers/get-human-interpretation-from-osquery-sql.js index 19c48eb7aa9..720e08bd378 100644 --- a/website/api/controllers/get-human-interpretation-from-osquery-sql.js +++ b/website/api/controllers/get-human-interpretation-from-osquery-sql.js @@ -96,8 +96,14 @@ Please do not add any text outside of the JSON report or wrap it in a code fence // Change `whatWillHappenDuringMaintenance` to `whatWillProbablyHappenDuringMaintenance` (the naming we want to use in our API response) report.whatWillProbablyHappenDuringMaintenance = report.whatWillHappenDuringMaintenance; delete report.whatWillHappenDuringMaintenance; + // If the LLM's JSON response was syntactically valid but missing one or both of the + // expected properties (e.g. it used a different key name, or omitted a field), then + // treat this the same as a parse failure so we don't silently return incomplete data. + if (!report.risks || !report.whatWillProbablyHappenDuringMaintenance) { + throw new Error('Parsed JSON report from Open AI API is missing required properties (`risks` and/or `whatWillProbablyHappenDuringMaintenance`).'); + } } catch (err) { - sails.log.warn('When trying to parse a JSON report returned from the Open AI API, an error occurred. Error details from JSON.parse: '+err.stack+'\n Report returned from Open AI:'+openAiResponse.choices[0].message.content); + sails.log.warn('When trying to parse or validate a JSON report returned from the Open AI API, an error occurred. Error details: '+err.stack+'\n Report returned from Open AI:'+openAiResponse.choices[0].message.content); report = { risks: failureMessage, whatWillProbablyHappenDuringMaintenance: failureMessage @@ -110,3 +116,4 @@ Please do not add any text outside of the JSON report or wrap it in a code fence }; + diff --git a/website/scripts/deliver-expired-local-trial-emails.js b/website/scripts/deliver-expired-local-trial-emails.js index 483d18f44a6..168aa08709c 100644 --- a/website/scripts/deliver-expired-local-trial-emails.js +++ b/website/scripts/deliver-expired-local-trial-emails.js @@ -12,13 +12,22 @@ module.exports = { sails.log('Running custom shell script... (`sails run deliver-expired-local-trial-emails`)'); let nowAt = Date.now(); + // Use the last recorded run's timestamp (if available) as the start of the query window, + // so that delayed, skipped, or double-run scripts do not cause expired trial users to be + // missed. Falls back to 24 hours ago if this script has not recorded a last run yet. + let lastRunAt = await sails.helpers.flow.build(async ()=>{ + let deliverExpiredLocalTrialEmailsScript = await Script.findOne({identifier: 'deliver-expired-local-trial-emails'}); + return deliverExpiredLocalTrialEmailsScript ? deliverExpiredLocalTrialEmailsScript.lastRanAt : undefined; + }); let oneDayAgoAt = nowAt - (1000 * 60 * 60 * 24); + let queryWindowStartAt = lastRunAt || oneDayAgoAt; - // Build a list of users with a local Fleet Premium trial that has expired in the past 24 hours. + // Build a list of users with a local Fleet Premium trial that has expired since this script last ran + // (or in the past 24 hours, if this is the first time this script has run.) let usersWithRecentlyExpiredLocalTrials = await User.find({ fleetPremiumTrialType: 'local trial', fleetPremiumTrialLicenseKeyExpiresAt: { - '>=': oneDayAgoAt, + '>=': queryWindowStartAt, '<': nowAt, }, }); @@ -50,6 +59,15 @@ module.exports = { } + // Persist a watermark of this run's timestamp, so the next run's query window starts + // where this one left off (instead of assuming exactly 24 hours have passed). + await Script.updateOne({identifier: 'deliver-expired-local-trial-emails'}) + .set({lastRanAt: nowAt}) + .tolerate(async (err)=>{ + sails.log.warn(`When updating the lastRanAt watermark for deliver-expired-local-trial-emails, an error occured (this may mean the Script model/record does not exist yet). Full error: ${require('util').inspect(err)}`); + return; + }); + sails.log(`Sent expired trial emails for ${usersWithRecentlyExpiredLocalTrials.length} user(s).`); }