Repository: fleetdm/fleet (Go)

Issue:
Vuln processing command
### Goal

Today Fleet runs vulnerability downloading and processing via internal scheduled cron job. This internal mechanism is very useful for frictionless deployments and is well suited for most use cases. However I think it would be helpful to have the ability to externally schedule this command, in similar fashion to `fleet prepare db`, where the command runs to completion and exists gracefully.

It is possible to use https://fleetdm.com/docs/deploying/configuration#current-instance-checks to limit specific instances to process vuln data, but this still requires the instance deployed with this setting to run 24/7. It could be possible to schedule vuln processing tasks to run via https://docs.aws.amazon.com/AmazonECS/latest/developerguide/scheduling_tasks.html in ECS or https://kubernetes.io/docs/tasks/job/automated-tasks-with-cron-jobs/ in Kubernetes and https://cloud.google.com/run/docs/triggering/using-scheduler#create_job with Google Cloud Run.

**What does this accomplish? What is the point?**

Vuln processing requires significantly more memory that the fleet web server does. Its recommended at 4GB of Memory. Without [limiting ](https://fleetdm.com/docs/deploying/configuration#current-instance-checks) which instance of Fleet is elected the leader for a particular round of vuln processing, you are required to run 4GB of memory across your entire deployment, since leader election would be random and any instance of fleet could attempt vuln processing. If you do use the limiting feature, you are still required to run at least one provisioned instanced with 4GB of memory 24/7, even though vuln processing[ only occurs by default 1h](https://fleetdm.com/docs/deploying/configuration#periodicity).

Having a dedicated command to processing vulnerabilities enables large deployments to significantly reduce the required memory for the majority of their instances saving on memory and thus costs.

### How?

- [ ] Introduce a dedicated vulnerability processing command similar to `fleet prepare db`

### Concerns

- Vuln db would need to be downloaded from each time since the command would be starting from scratch in a containerized environment. It would be possible to mitigate this with stateful-sets(?) and https://kubernetes.io/docs/concepts/storage/persistent-volumes/ allowing the vuln processing task to mount a persistent volume, effectively caching the vuln db. This is also achievable in ECS https://docs.aws.amazon.com/AmazonECS/latest/developerguide/using_data_volumes.html
- If you are already running dozens of fleet containers, and leader election is seemingly random, its likely that today the vuln db is downloaded a number of times before it starts getting cached anyways. 



Discussion: @zwass here is the issue related to vuln processing we discussed.
@edwardsb thank you for filing this.

@noahtalerman @chiiph and I were just discussing all of the different "intervals" in Fleet and I brought up your suggestion about this. We think it probably makes sense to extend this idea to all of the background jobs as part of an effort to create a generic framework for background jobs in Fleet.
> it probably makes sense to extend this idea to all of the background jobs as part of an effort to create a generic framework for background jobs in Fleet.

:+1:

Would be good for it to be opt-in though.  So that everything still just works™ unless you need more customization.

cc @CptOfEvilMinions



Fix (patch):
diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go
index 8e72d0c9a446..3d952e1f1af2 100644
--- a/cmd/fleet/cron.go
+++ b/cmd/fleet/cron.go
@@ -3,6 +3,7 @@ package main
 import (
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"net/url"
 	"os"
@@ -78,6 +79,9 @@ func cronVulnerabilities(
 	logger kitlog.Logger,
 	config *config.VulnerabilitiesConfig,
 ) error {
+	if config == nil {
+		return errors.New("nil configuration")
+	}
 	if config.CurrentInstanceChecks == "no" || config.CurrentInstanceChecks == "0" {
 		level.Info(logger).Log("msg", "host not configured to check for vulnerabilities")
 		return nil
@@ -95,21 +99,7 @@ func cronVulnerabilities(
 		return nil
 	}
 
-	var vulnPath string
-	switch {
-	case config.DatabasesPath != "" && appConfig.VulnerabilitySettings.DatabasesPath != "":
-		vulnPath = config.DatabasesPath
-		level.Info(logger).Log(
-			"msg", "fleet config takes precedence over app config when both are configured",
-			"databases_path", vulnPath,
-		)
-	case config.DatabasesPath != "":
-		vulnPath = config.DatabasesPath
-	case appConfig.VulnerabilitySettings.DatabasesPath != "":
-		vulnPath = appConfig.VulnerabilitySettings.DatabasesPath
-	default:
-		level.Info(logger).Log("msg", "vulnerability scanning not configured, vulnerabilities databases path is empty")
-	}
+	vulnPath := configureVulnPath(*config, appConfig, logger)
 	if vulnPath != "" {
 		level.Info(logger).Log("msg", "scanning vulnerabilities")
 		if err := scanVulnerabilities(ctx, ds, logger, config, appConfig, vulnPath); err != nil {
@@ -301,10 +291,6 @@ func checkOvalVulnerabilities(
 	config *config.VulnerabilitiesConfig,
 	collectVulns bool,
 ) []fleet.SoftwareVulnerability {
-	if config.DisableDataSync {
-		return nil
-	}
-
 	var results []fleet.SoftwareVulnerability
 
 	// Get Platforms
@@ -314,13 +300,15 @@ func checkOvalVulnerabilities(
 		return nil
 	}
 
-	// Sync on disk OVAL definitions with current OS Versions.
-	downloaded, err := oval.Refresh(ctx, versions, vulnPath)
-	if err != nil {
-		errHandler(ctx, logger, "updating oval definitions", err)
-	}
-	for _, d := range downloaded {
-		level.Debug(logger).Log("oval-sync-downloaded", d)
+	if !config.DisableDataSync {
+		// Sync on disk OVAL definitions with current OS Versions.
+		downloaded, err := oval.Refresh(ctx, versions, vulnPath)
+		if err != nil {
+			errHandler(ctx, logger, "updating oval definitions", err)
+		}
+		for _, d := range downloaded {
+			level.Debug(logger).Log("oval-sync-downloaded", d)
+		}
 	}
 
 	// Analyze all supported os versions using the synched OVAL definitions.
@@ -364,7 +352,7 @@ func checkNVDVulnerabilities(
 		}
 	}
 
-	if err := nvd.LoadCVEMeta(logger, vulnPath, ds); err != nil {
+	if err := nvd.LoadCVEMeta(ctx, logger, vulnPath, ds); err != nil {
 		errHandler(ctx, logger, "load cve meta", err)
 		// don't return, continue on ...
 	}
diff --git a/cmd/fleet/main.go b/cmd/fleet/main.go
index ee66e037a16a..8a0148f50ff1 100644
--- a/cmd/fleet/main.go
+++ b/cmd/fleet/main.go
@@ -7,6 +7,8 @@ import (
 	"time"
 
 	"github.com/fleetdm/fleet/v4/server/config"
+	kitlog "github.com/go-kit/kit/log"
+	"github.com/go-kit/kit/log/level"
 	_ "github.com/go-sql-driver/mysql"
 	"github.com/spf13/cobra"
 )
@@ -21,6 +23,7 @@ func main() {
 
 	configManager := config.NewManager(rootCmd)
 
+	rootCmd.AddCommand(createVulnProcessingCmd(configManager))
 	rootCmd.AddCommand(createPrepareCmd(configManager))
 	rootCmd.AddCommand(createServeCmd(configManager))
 	rootCmd.AddCommand(createConfigDumpCmd(configManager))
@@ -91,3 +94,22 @@ func applyDevFlags(cfg *config.FleetConfig) {
 		ForceS3PathStyle: true,
 	}
 }
+
+func initLogger(cfg config.FleetConfig) kitlog.Logger {
+	var logger kitlog.Logger
+	{
+		output := os.Stderr
+		if cfg.Logging.JSON {
+			logger = kitlog.NewJSONLogger(output)
+		} else {
+			logger = kitlog.NewLogfmtLogger(output)
+		}
+		if cfg.Logging.Debug {
+			logger = level.NewFilter(logger, level.AllowDebug())
+		} else {
+			logger = level.NewFilter(logger, level.AllowInfo())
+		}
+		logger = kitlog.With(logger, "ts", kitlog.DefaultTimestampUTC)
+	}
+	return logger
+}
diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go
index 9481a6a61c30..c318ff791aeb 100644
--- a/cmd/fleet/serve.go
+++ b/cmd/fleet/serve.go
@@ -105,15 +105,7 @@ the way that the Fleet server works.
 				applyDevFlags(&config)
 			}
 
-			if devLicense {
-				// This license key is valid for development only
-				config.License.Key = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGbGVldCBEZXZpY2UgTWFuYWdlbWVudCBJbmMuIiwiZXhwIjoxNzUxMjQxNjAwLCJzdWIiOiJkZXZlbG9wbWVudC1vbmx5IiwiZGV2aWNlcyI6MTAwLCJub3RlIjoiZm9yIGRldmVsb3BtZW50IG9ubHkiLCJ0aWVyIjoicHJlbWl1bSIsImlhdCI6MTY1NjY5NDA4N30.dvfterOvfTGdrsyeWYH9_lPnyovxggM5B7tkSl1q1qgFYk_GgOIxbaqIZ6gJlL0cQuBF9nt5NgV0AUT9RmZUaA"
-			} else if devExpiredLicense {
-				// An expired license key
-				config.License.Key = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGbGVldCBEZXZpY2UgTWFuYWdlbWVudCBJbmMuIiwiZXhwIjoxNjI5NzYzMjAwLCJzdWIiOiJEZXYgbGljZW5zZSAoZXhwaXJlZCkiLCJkZXZpY2VzIjo1MDAwMDAsIm5vdGUiOiJUaGlzIGxpY2Vuc2UgaXMgdXNlZCB0byBmb3IgZGV2ZWxvcG1lbnQgcHVycG9zZXMuIiwidGllciI6ImJhc2ljIiwiaWF0IjoxNjI5OTA0NzMyfQ.AOppRkl1Mlc_dYKH9zwRqaTcL0_bQzs7RM3WSmxd3PeCH9CxJREfXma8gm0Iand6uIWw8gHq5Dn0Ivtv80xKvQ"
-			}
-
-			license, err := licensing.LoadLicense(config.License.Key)
+			license, err := initLicense(config, devLicense, devExpiredLicense)
 			if err != nil {
 				initFatal(
 					err,
@@ -125,21 +117,7 @@ the way that the Fleet server works.
 				fleet.WriteExpiredLicenseBanner(os.Stderr)
 			}
 
-			var logger kitlog.Logger
-			{
-				output := os.Stderr
-				if config.Logging.JSON {
-					logger = kitlog.NewJSONLogger(output)
-				} else {
-					logger = kitlog.NewLogfmtLogger(output)
-				}
-				if config.Logging.Debug {
-					logger = level.NewFilter(logger, level.AllowDebug())
-				} else {
-					logger = level.NewFilter(logger, level.AllowInfo())
-				}
-				logger = kitlog.With(logger, "ts", kitlog.DefaultTimestampUTC)
-			}
+			logger := initLogger(config)
 
 			// Init tracing
 			if config.Logging.TracingEnabled {
@@ -636,10 +614,15 @@ the way that the Fleet server works.
 				initFatal(err, "failed to register stats schedule")
 			}
 
-			if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
-				return newVulnerabilitiesSchedule(ctx, instanceID, ds, logger, &config.Vulnerabilities)
-			}); err != nil {
-				initFatal(err, "failed to register vulnerabilities schedule")
+			if !config.Vulnerabilities.DisableSchedule {
+				// vuln processing by default is run by internal cron mechanism
+				if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
+					return newVulnerabilitiesSchedule(ctx, instanceID, ds, logger, &config.Vulnerabilities)
+				}); err != nil {
+					initFatal(err, "failed to register vulnerabilities schedule")
+				}
+			} else {
+				level.Info(logger).Log("msg", "vulnerabilities schedule disabled")
 			}
 
 			if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
@@ -902,6 +885,17 @@ the way that the Fleet server works.
 	return serveCmd
 }
 
+func initLicense(config configpkg.FleetConfig, devLicense, devExpiredLicense bool) (*fleet.LicenseInfo, error) {
+	if devLicense {
+		// This license key is valid for development only
+		config.License.Key = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGbGVldCBEZXZpY2UgTWFuYWdlbWVudCBJbmMuIiwiZXhwIjoxNzUxMjQxNjAwLCJzdWIiOiJkZXZlbG9wbWVudC1vbmx5IiwiZGV2aWNlcyI6MTAwLCJub3RlIjoiZm9yIGRldmVsb3BtZW50IG9ubHkiLCJ0aWVyIjoicHJlbWl1bSIsImlhdCI6MTY1NjY5NDA4N30.dvfterOvfTGdrsyeWYH9_lPnyovxggM5B7tkSl1q1qgFYk_GgOIxbaqIZ6gJlL0cQuBF9nt5NgV0AUT9RmZUaA"
+	} else if devExpiredLicense {
+		// An expired license key
+		config.License.Key = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGbGVldCBEZXZpY2UgTWFuYWdlbWVudCBJbmMuIiwiZXhwIjoxNjI5NzYzMjAwLCJzdWIiOiJEZXYgbGljZW5zZSAoZXhwaXJlZCkiLCJkZXZpY2VzIjo1MDAwMDAsIm5vdGUiOiJUaGlzIGxpY2Vuc2UgaXMgdXNlZCB0byBmb3IgZGV2ZWxvcG1lbnQgcHVycG9zZXMuIiwidGllciI6ImJhc2ljIiwiaWF0IjoxNjI5OTA0NzMyfQ.AOppRkl1Mlc_dYKH9zwRqaTcL0_bQzs7RM3WSmxd3PeCH9CxJREfXma8gm0Iand6uIWw8gHq5Dn0Ivtv80xKvQ"
+	}
+	return licensing.LoadLicense(config.License.Key)
+}
+
 // basicAuthHandler wraps the given handler behind HTTP Basic Auth.
 func basicAuthHandler(username, password string, next http.Handler) http.HandlerFunc {
 	hashFn := func(s string) []byte {
diff --git a/cmd/fleet/vuln_process.go b/cmd/fleet/vuln_process.go
new file mode 100644
index 000000000000..f42d099a9f5b
--- /dev/null
+++ b/cmd/fleet/vuln_process.go
@@ -0,0 +1,163 @@
+package main
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"github.com/fleetdm/fleet/v4/server"
+	"github.com/fleetdm/fleet/v4/server/contexts/license"
+	"os"
+	"time"
+
+	"github.com/WatchBeam/clock"
+	"github.com/fleetdm/fleet/v4/server/config"
+	"github.com/fleetdm/fleet/v4/server/datastore/mysql"
+	"github.com/fleetdm/fleet/v4/server/fleet"
+	kitlog "github.com/go-kit/kit/log"
+	"github.com/go-kit/kit/log/level"
+	"github.com/spf13/cobra"
+)
+
+var (
+	dev               bool
+	devLicense        bool
+	devExpiredLicense bool
+	lockDuration      time.Duration
+)
+
+func createVulnProcessingCmd(configManager config.Manager) *cobra.Command {
+	vulnProcessingCmd := &cobra.Command{
+		Use:   "vuln_processing",
+		Short: "Run the vulnerability processing features of Fleet",
+		Long: `The vuln_processing command is intended for advanced configurations that want to externally manage 
+vulnerability processing. By default the Fleet server command internally manages vulnerability processing via scheduled
+'cron' style jobs, but setting 'vulnerabilities.disable_schedule=true' or 'FLEET_VULNERABILITIES_DISABLE_SCHEDULE=true' 
+will disable it on the server allowing the user configure their own 'cron' mechanism. Successful processing will be indicated
+by an exit code of zero.`,
+		Run: func(cmd *cobra.Command, args []string) {
+			cfg := configManager.LoadConfig()
+			if dev {
+				applyDevFlags(&cfg)
+			}
+
+			licenseInfo, err := initLicense(cfg, devLicense, devExpiredLicense)
+			if err != nil {
+				initFatal(
+					err,
+					"failed to load license - for help use https://fleetdm.com/contact",
+				)
+			}
+
+			if licenseInfo != nil && licenseInfo.IsPremium() && licenseInfo.IsExpired() {
+				fleet.WriteExpiredLicenseBanner(os.Stderr)
+			}
+
+			ds, err := mysql.New(cfg.Mysql, clock.C)
+			if err != nil {
+				initFatal(err, "creating db connection")
+			}
+
+			// we need to ensure this command isn't running with an out-of-date database
+			status, err := ds.MigrationStatus(cmd.Context())
+			if err != nil {
+				initFatal(err, "retrieving migration status")
+			}
+
+			var migrationError error
+			switch status.StatusCode {
+			case fleet.AllMigrationsCompleted:
+				// only continue if db is considered up-to-date
+			case fleet.NoMigrationsCompleted:
+				migrationError = errors.New("no migrations completed")
+			case fleet.SomeMigrationsCompleted:
+				migrationError = errors.New("partial migrations completed")
+			case fleet.UnknownMigrations:
+				migrationError = errors.New("database migrations incompatible with current version")
+			}
+			if migrationError != nil {
+				initFatal(migrationError, "refusing to continue processing vulnerabilities, database out of sync")
+			}
+
+			ctx, cancel := context.WithTimeout(context.Background(), lockDuration)
+			ctx = license.NewContext(ctx, licenseInfo)
+			instanceID, err := server.GenerateRandomText(64)
+			if err != nil {
+				initFatal(errors.New("error generating random instance identifier"), "")
+			}
+			// using the same lock name as the cron scheduled version of vuln processing, that way if we fail to obtain the lock
+			// it's most likely due to vulnerabilities.disable_schedule=false but still trying to run external vuln processing command
+			lock, err := ds.Lock(ctx, string(fleet.CronVulnerabilities), instanceID, lockDuration)
+			if err != nil {
+				initFatal(err, "failed to obtain vuln processing lock")
+			}
+			if !lock {
+				initFatal(errors.New("vulnerabilities processing locked"),
+					"failed to obtain vuln processing lock, something else still has lock ownership")
+			}
+			defer func() {
+				err = ds.Unlock(ctx, "cron_vulnerabilities", "vuln_processing_command")
+				if err != nil {
+					initFatal(err, "failed to release vuln processing lock")
+				}
+				cancel()
+			}()
+
+			logger := initLogger(cfg)
+			logger = kitlog.With(logger, fleet.CronVulnerabilities)
+
+			appConfig, err := ds.AppConfig(ctx)
+			if err != nil {
+				initFatal(err, "error fetching app config during vulnerability processing")
+			}
+			vulnConfig := cfg.Vulnerabilities
+			vulnPath := configureVulnPath(vulnConfig, appConfig, logger)
+			// this really shouldn't ever be empty string since it's defaulted, but could be due to some misconfiguration
+			// we'll throw an error here since the entire point of this command is to process vulnerabilities
+			if vulnPath == "" {
+				initFatal(errors.New("vuln path empty, check environment variables or app config yml"), "error during vulnerability processing")
+			}
+			level.Info(logger).Log("msg", "scanning vulnerabilities")
+			err = scanVulnerabilities(ctx, ds, logger, &vulnConfig, appConfig, vulnPath)
+			if err != nil {
+				// errors during vuln processing should bubble up, so you know the job is failing without having to scour logs, e.g. non-zero exit code
+				initFatal(fmt.Errorf("scanning vulnerabilities: %w", err), "error during vulnerability processing")
+			}
+
+			err = ds.SyncHostsSoftware(ctx, time.Now())
+			if err != nil {
+				// though vulnerability processing succeeded, we'll still fatally error here to indicate there was a problem
+				initFatal(err, "failed to sync host software")
+			}
+
+			return
+		},
+	}
+	vulnProcessingCmd.PersistentFlags().BoolVar(&dev, "dev", false, "Enable developer options")
+	vulnProcessingCmd.PersistentFlags().BoolVar(&devLicense, "dev_license", false, "Enable development license")
+	vulnProcessingCmd.PersistentFlags().BoolVar(&devExpiredLicense, "dev_expired_license", false, "Enable expired development license")
+	vulnProcessingCmd.PersistentFlags().DurationVar(
+		&lockDuration,
+		"lock_duration",
+		time.Second*60*60,
+		"the duration (https://pkg.go.dev/time#ParseDuration) the lock should be obtained, ideally this duration is less than the interval in which the job runs (defaults to 60m). If vuln processing isn't finished before this duration the command will exit with a non-zero status code.")
+
+	return vulnProcessingCmd
+}
+
+func configureVulnPath(vulnConfig config.VulnerabilitiesConfig, appConfig *fleet.AppConfig, logger kitlog.Logger) (vulnPath string) {
+	switch {
+	case vulnConfig.DatabasesPath != "" && appConfig != nil && appConfig.VulnerabilitySettings.DatabasesPath != "":
+		vulnPath = vulnConfig.DatabasesPath
+		level.Info(logger).Log(
+			"msg", "fleet config takes precedence over app config when both are configured",
+			"databases_path", vulnPath,
+		)
+	case vulnConfig.DatabasesPath != "":
+		vulnPath = vulnConfig.DatabasesPath
+	case appConfig != nil && appConfig.VulnerabilitySettings.DatabasesPath != "":
+		vulnPath = appConfig.VulnerabilitySettings.DatabasesPath
+	default:
+		level.Info(logger).Log("msg", "vulnerability scanning not configured, vulnerabilities databases path is empty")
+	}
+	return vulnPath
+}
diff --git a/docs/Deploying/Configuration.md b/docs/Deploying/Configuration.md
index d63b4bf2cb5b..1726576fd3c6 100644
--- a/docs/Deploying/Configuration.md
+++ b/docs/Deploying/Configuration.md
@@ -2159,7 +2159,7 @@ The path specified needs to exist and Fleet needs to be able to read and write t
 
 When `current_instance_checks` is set to `auto` (the default), Fleet instances will try to create the `databases_path` if it doesn't exist.
 
-- Default value: none
+- Default value: `/tmp/vulndbs`
 - Environment variable: `FLEET_VULNERABILITIES_DATABASES_PATH`
 - Config file format:
   ```
@@ -2237,6 +2237,19 @@ When running multiple instances of the Fleet server, by default, one of them dyn
   	current_instance_checks: yes
   ```
 
+##### disable_schedule
+
+To externally manage running vulnerability processing set the value to `true` and then run `fleet vuln_processing` using external
+tools like crontab.
+
+- Default value: `false`
+- Environment variable: `FLEET_VULNERABILITIES_DISABLE_SCHEDULE`
+- Config file format:
+  ```
+  vulnerabilities:
+  	disable_schedule: false
+  ```
+
 ##### disable_data_sync
 
 Fleet by default automatically downloads and keeps the different data streams needed to properly do vulnerability processing. In some setups, this behavior is not wanted, as access to outside resources might be blocked, or the data stream files might need review/audit before use.
diff --git a/docs/Using-Fleet/Vulnerability-Processing.md b/docs/Using-Fleet/Vulnerability-Processing.md
index aefd48e6bc17..6ae9b648ddc7 100644
--- a/docs/Using-Fleet/Vulnerability-Processing.md
+++ b/docs/Using-Fleet/Vulnerability-Processing.md
@@ -127,6 +127,38 @@ found in the [configuration documentation](https://fleetdm.com/docs/deploying/co
 
 You'll need to restart the Fleet instances after changing these settings.
 
+### Advanced Configuration
+
+Fleet runs vulnerability downloading and processing via internal scheduled cron job. This internal mechanism is very useful
+for frictionless deployments and is well suited for most use cases. However, in larger deployments, 
+where there can be dozens of Fleet server replicas sitting behind a load balancer, it is desirable to manage vulnerability processing externally.
+
+The reasons for this are as follows:
+
+- lower resource requirements across the entire Fleet server deployment (as vulnerability processing requires considerably more resources than just running Fleet server alone)
+- more control over scheduling constraints (only process during windows of low utilization, etc.)
+
+It is possible to limit vulnerability processing to a single [dedicated host](https://fleetdm.com/docs/deploying/configuration#current-instance-checks), by setting
+`current_instance_checks` to `no` but still run one Fleet server as `yes`, but the drawback here is still having to dedicate resources
+for this single host 24/7. The Fleet binary has a command which handles the same vulnerability processing, but will exit (successfully with 0) on completion. Using this sub-command we can delegate vulnerability processing
+to external systems such as:
+
+- [ECS](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/scheduling_tasks.html)
+- [K8S](https://kubernetes.io/docs/tasks/job/automated-tasks-with-cron-jobs/)
+- [GCP](https://cloud.google.com/run/docs/triggering/using-scheduler#create_job)
+- [Plain old cron](https://en.wikipedia.org/wiki/Cron)
+
+To opt into this functionality, be sure to configure your Fleet server deployment with
+```
+FLEET_VULNERABILITIES_DISABLE_SCHEDULE=true
+```
+which will **disable** the internal scheduling mechanism for vulnerability processing.
+
+And then externally run with the same environment variables/configuration files passed to the server command.
+```
+fleet vuln_processing
+```
+
 ## Performance
 
 ### Windows/Mac OS
diff --git a/server/config/config.go b/server/config/config.go
index ff46390cdd9f..d37cfd12072c 100644
--- a/server/config/config.go
+++ b/server/config/config.go
@@ -324,6 +324,7 @@ type VulnerabilitiesConfig struct {
 	CPETranslationsURL          string        `json:"cpe_translations_url" yaml:"cpe_translations_url"`
 	CVEFeedPrefixURL            string        `json:"cve_feed_prefix_url" yaml:"cve_feed_prefix_url"`
 	CurrentInstanceChecks       string        `json:"current_instance_checks" yaml:"current_instance_checks"`
+	DisableSchedule             bool          `json:"disable_schedule" yaml:"disable_schedule"`
 	DisableDataSync             bool          `json:"disable_data_sync" yaml:"disable_data_sync"`
 	RecentVulnerabilityMaxAge   time.Duration `json:"recent_vulnerability_max_age" yaml:"recent_vulnerability_max_age"`
 	DisableWinOSVulnerabilities bool          `json:"disable_win_os_vulnerabilities" yaml:"disable_win_os_vulnerabilities"`
@@ -969,6 +970,8 @@ func (man Manager) addConfigs() {
 		"Prefix URL for the CVE data feed. If empty, default to https://nvd.nist.gov/")
 	man.addConfigString("vulnerabilities.current_instance_checks", "auto",
 		"Allows to manually select an instance to do the vulnerability processing.")
+	man.addConfigBool("vulnerabilities.disable_schedule", false,
+		"Set this to true when the vulnerability processing job is scheduled by an external mechanism")
 	man.addConfigBool("vulnerabilities.disable_data_sync", false,
 		"Skips synchronizing data streams and expects them to be available in the databases_path.")
 	man.addConfigDuration("vulnerabilities.recent_vulnerability_max_age", 30*24*time.Hour,
@@ -1240,6 +1243,7 @@ func (man Manager) LoadConfig() FleetConfig {
 			CPETranslationsURL:          man.getConfigString("vulnerabilities.cpe_translations_url"),
 			CVEFeedPrefixURL:            man.getConfigString("vulnerabilities.cve_feed_prefix_url"),
 			CurrentInstanceChecks:       man.getConfigString("vulnerabilities.current_instance_checks"),
+			DisableSchedule:             man.getConfigBool("vulnerabilities.disable_schedule"),
 			DisableDataSync:             man.getConfigBool("vulnerabilities.disable_data_sync"),
 			RecentVulnerabilityMaxAge:   man.getConfigDuration("vulnerabilities.recent_vulnerability_max_age"),
 			DisableWinOSVulnerabilities: man.getConfigBool("vulnerabilities.disable_win_os_vulnerabilities"),
diff --git a/server/vulnerabilities/nvd/sync.go b/server/vulnerabilities/nvd/sync.go
index f44419c5a667..444f2252236b 100644
--- a/server/vulnerabilities/nvd/sync.go
+++ b/server/vulnerabilities/nvd/sync.go
@@ -5,6 +5,7 @@ import (
 	"encoding/csv"
 	"encoding/json"
 	"fmt"
+	"github.com/fleetdm/fleet/v4/server/contexts/license"
 	"io"
 	"net/url"
 	"os"
@@ -177,7 +178,11 @@ func DownloadCISAKnownExploitsFeed(vulnPath string) error {
 
 // LoadCVEMeta loads the cvss scores, epss scores, and known exploits from the previously downloaded feeds and saves
 // them to the database.
-func LoadCVEMeta(logger log.Logger, vulnPath string, ds fleet.Datastore) error {
+func LoadCVEMeta(ctx context.Context, logger log.Logger, vulnPath string, ds fleet.Datastore) error {
+	if !license.IsPremium(ctx) {
+		level.Info(logger).Log("msg", "skipping cve_meta parsing due to license check")
+		return nil
+	}
 	// load cvss scores
 	files, err := getNVDCVEFeedFiles(vulnPath)
 	if err != nil {
@@ -277,10 +282,9 @@ func LoadCVEMeta(logger log.Logger, vulnPath string, ds fleet.Datastore) error {
 		meta = append(meta, score)
 	}
 
-	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
+	insertCtx, cancel := context.WithTimeout(ctx, 1*time.Minute)
 	defer cancel()
-
-	if err := ds.InsertCVEMeta(ctx, meta); err != nil {
+	if err := ds.InsertCVEMeta(insertCtx, meta); err != nil {
 		return fmt.Errorf("insert cve meta: %w", err)
 	}