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).`);
}